mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 09:03:43 +02:00
Add AMS humidity/temperature history tracking with charts
- Add database model for storing AMS sensor history (humidity, temperature) - Record sensor data every 5 minutes via background task - Add API endpoint to retrieve history with min/max/avg statistics - Create AMSHistoryModal component with Recharts line charts - Make humidity/temperature indicators clickable to open history - Support time range selection (6h, 24h, 48h, 7d) - Show threshold reference lines on charts (Good/Fair levels) - Add configurable data retention (default 30 days) in Settings - Auto-cleanup old data daily - Fix dark theme styling in modal using inline styles - Add Escape key to close modal
This commit is contained in:
@@ -432,6 +432,7 @@ export interface AppSettings {
|
||||
ams_humidity_fair: number; // <= this is orange, > is red
|
||||
ams_temp_good: number; // <= this is green/blue
|
||||
ams_temp_fair: number; // <= this is orange, > is red
|
||||
ams_history_retention_days: number; // days to keep AMS sensor history
|
||||
// Date/time format settings
|
||||
date_format: 'system' | 'us' | 'eu' | 'iso';
|
||||
time_format: 'system' | '12h' | '24h';
|
||||
@@ -1893,4 +1894,28 @@ export const api = {
|
||||
}),
|
||||
deleteAPIKey: (id: number) =>
|
||||
request<{ message: string }>(`/api-keys/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// AMS History
|
||||
getAMSHistory: (printerId: number, amsId: number, hours = 24) =>
|
||||
request<AMSHistoryResponse>(`/ams-history/${printerId}/${amsId}?hours=${hours}`),
|
||||
};
|
||||
|
||||
// AMS History types
|
||||
export interface AMSHistoryPoint {
|
||||
recorded_at: string;
|
||||
humidity: number | null;
|
||||
humidity_raw: number | null;
|
||||
temperature: number | null;
|
||||
}
|
||||
|
||||
export interface AMSHistoryResponse {
|
||||
printer_id: number;
|
||||
ams_id: number;
|
||||
data: AMSHistoryPoint[];
|
||||
min_humidity: number | null;
|
||||
max_humidity: number | null;
|
||||
avg_humidity: number | null;
|
||||
min_temperature: number | null;
|
||||
max_temperature: number | null;
|
||||
avg_temperature: number | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { X, Droplets, Thermometer, TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { api, type AMSHistoryResponse } from '../api/client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
interface AMSHistoryModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
printerId: number;
|
||||
printerName: string;
|
||||
amsId: number;
|
||||
amsLabel: string;
|
||||
initialMode?: 'humidity' | 'temperature';
|
||||
thresholds?: {
|
||||
humidityGood: number;
|
||||
humidityFair: number;
|
||||
tempGood: number;
|
||||
tempFair: number;
|
||||
};
|
||||
}
|
||||
|
||||
type TimeRange = '6h' | '24h' | '48h' | '7d';
|
||||
|
||||
const TIME_RANGES: { value: TimeRange; label: string; hours: number }[] = [
|
||||
{ value: '6h', label: '6h', hours: 6 },
|
||||
{ value: '24h', label: '24h', hours: 24 },
|
||||
{ value: '48h', label: '48h', hours: 48 },
|
||||
{ value: '7d', label: '7d', hours: 168 },
|
||||
];
|
||||
|
||||
export function AMSHistoryModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
printerId,
|
||||
printerName,
|
||||
amsId,
|
||||
amsLabel,
|
||||
initialMode = 'humidity',
|
||||
thresholds,
|
||||
}: AMSHistoryModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useTheme();
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('24h');
|
||||
const [mode, setMode] = useState<'humidity' | 'temperature'>(initialMode);
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const hours = TIME_RANGES.find(r => r.value === timeRange)?.hours || 24;
|
||||
|
||||
const { data, isLoading, error } = useQuery<AMSHistoryResponse>({
|
||||
queryKey: ['ams-history', printerId, amsId, hours],
|
||||
queryFn: () => api.getAMSHistory(printerId, amsId, hours),
|
||||
enabled: isOpen,
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Format data for chart
|
||||
const chartData = data?.data.map(point => ({
|
||||
time: new Date(point.recorded_at).getTime(),
|
||||
humidity: point.humidity,
|
||||
temperature: point.temperature,
|
||||
timeLabel: new Date(point.recorded_at).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
...(hours > 24 ? { day: 'numeric', month: 'short' } : {}),
|
||||
}),
|
||||
})) || [];
|
||||
|
||||
// Get thresholds
|
||||
const humidityGood = thresholds?.humidityGood || 40;
|
||||
const humidityFair = thresholds?.humidityFair || 60;
|
||||
const tempGood = thresholds?.tempGood || 30;
|
||||
const tempFair = thresholds?.tempFair || 35;
|
||||
|
||||
// Current values (last data point)
|
||||
const lastPoint = chartData[chartData.length - 1];
|
||||
const currentHumidity = lastPoint?.humidity;
|
||||
const currentTemp = lastPoint?.temperature;
|
||||
|
||||
// Trend calculation (compare first and last 20% of data)
|
||||
const getTrend = (values: (number | null)[]) => {
|
||||
const filtered = values.filter((v): v is number => v != null);
|
||||
if (filtered.length < 4) return 'stable';
|
||||
const firstQuarter = filtered.slice(0, Math.floor(filtered.length / 4));
|
||||
const lastQuarter = filtered.slice(-Math.floor(filtered.length / 4));
|
||||
const firstAvg = firstQuarter.reduce((a, b) => a + b, 0) / firstQuarter.length;
|
||||
const lastAvg = lastQuarter.reduce((a, b) => a + b, 0) / lastQuarter.length;
|
||||
const diff = lastAvg - firstAvg;
|
||||
if (Math.abs(diff) < 2) return 'stable';
|
||||
return diff > 0 ? 'up' : 'down';
|
||||
};
|
||||
|
||||
const humidityTrend = getTrend(chartData.map(d => d.humidity));
|
||||
const tempTrend = getTrend(chartData.map(d => d.temperature));
|
||||
|
||||
const TrendIcon = ({ trend }: { trend: string }) => {
|
||||
if (trend === 'up') return <TrendingUp className="w-4 h-4 text-red-400" />;
|
||||
if (trend === 'down') return <TrendingDown className="w-4 h-4 text-green-400" />;
|
||||
return <Minus className="w-4 h-4 text-gray-400 dark:text-bambu-gray" />;
|
||||
};
|
||||
|
||||
// Get status color for current value
|
||||
const getHumidityColor = (value: number | undefined | null) => {
|
||||
if (value == null) return '#9ca3af';
|
||||
if (value <= humidityGood) return '#22a352';
|
||||
if (value <= humidityFair) return '#d4a017';
|
||||
return '#c62828';
|
||||
};
|
||||
|
||||
const getTempColor = (value: number | undefined | null) => {
|
||||
if (value == null) return '#9ca3af';
|
||||
if (value <= tempGood) return '#22a352';
|
||||
if (value <= tempFair) return '#d4a017';
|
||||
return '#c62828';
|
||||
};
|
||||
|
||||
// Theme-aware styles (using isDark since dark: prefix doesn't work in portals)
|
||||
const modalBg = isDark ? '#2d2d2d' : '#ffffff';
|
||||
const cardBg = isDark ? '#1d1d1d' : '#f3f4f6';
|
||||
const borderColor = isDark ? '#3d3d3d' : '#e5e7eb';
|
||||
const textPrimary = isDark ? '#ffffff' : '#111827';
|
||||
const textSecondary = isDark ? '#9ca3af' : '#4b5563';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div
|
||||
className="rounded-xl w-full max-w-4xl max-h-[90vh] overflow-hidden shadow-xl"
|
||||
style={{ backgroundColor: modalBg }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-4 border-b"
|
||||
style={{ borderColor }}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold" style={{ color: textPrimary }}>
|
||||
{amsLabel} {t('common.history', 'History')}
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: textSecondary }}>{printerName}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg transition-colors"
|
||||
style={{ color: textSecondary }}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-80px)]">
|
||||
{/* Time Range & Mode Selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1 rounded-lg p-1" style={{ backgroundColor: cardBg }}>
|
||||
<button
|
||||
onClick={() => setMode('humidity')}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
mode === 'humidity' ? 'bg-blue-600 text-white' : ''
|
||||
}`}
|
||||
style={mode !== 'humidity' ? { color: textSecondary } : undefined}
|
||||
>
|
||||
<Droplets className="w-4 h-4" />
|
||||
{t('common.humidity', 'Humidity')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('temperature')}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
mode === 'temperature' ? 'bg-orange-600 text-white' : ''
|
||||
}`}
|
||||
style={mode !== 'temperature' ? { color: textSecondary } : undefined}
|
||||
>
|
||||
<Thermometer className="w-4 h-4" />
|
||||
{t('common.temperature', 'Temperature')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 rounded-lg p-1" style={{ backgroundColor: cardBg }}>
|
||||
{TIME_RANGES.map(range => (
|
||||
<button
|
||||
key={range.value}
|
||||
onClick={() => setTimeRange(range.value)}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
timeRange === range.value ? 'bg-bambu-green text-white' : ''
|
||||
}`}
|
||||
style={timeRange !== range.value ? { color: textSecondary } : undefined}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{mode === 'humidity' ? (
|
||||
<>
|
||||
<div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
|
||||
<p className="text-xs" style={{ color: textSecondary }}>{t('common.current', 'Current')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-2xl font-bold" style={{ color: getHumidityColor(currentHumidity) }}>
|
||||
{currentHumidity != null ? `${currentHumidity}%` : '—'}
|
||||
</p>
|
||||
<TrendIcon trend={humidityTrend} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
|
||||
<p className="text-xs" style={{ color: textSecondary }}>{t('common.average', 'Average')}</p>
|
||||
<p className="text-2xl font-bold" style={{ color: textPrimary }}>
|
||||
{data?.avg_humidity != null ? `${data.avg_humidity}%` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
|
||||
<p className="text-xs" style={{ color: textSecondary }}>{t('common.min', 'Min')}</p>
|
||||
<p className="text-2xl font-bold text-green-500">
|
||||
{data?.min_humidity != null ? `${data.min_humidity}%` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
|
||||
<p className="text-xs" style={{ color: textSecondary }}>{t('common.max', 'Max')}</p>
|
||||
<p className="text-2xl font-bold text-red-500">
|
||||
{data?.max_humidity != null ? `${data.max_humidity}%` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
|
||||
<p className="text-xs" style={{ color: textSecondary }}>{t('common.current', 'Current')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-2xl font-bold" style={{ color: getTempColor(currentTemp) }}>
|
||||
{currentTemp != null ? `${currentTemp}°C` : '—'}
|
||||
</p>
|
||||
<TrendIcon trend={tempTrend} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
|
||||
<p className="text-xs" style={{ color: textSecondary }}>{t('common.average', 'Average')}</p>
|
||||
<p className="text-2xl font-bold" style={{ color: textPrimary }}>
|
||||
{data?.avg_temperature != null ? `${data.avg_temperature}°C` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
|
||||
<p className="text-xs" style={{ color: textSecondary }}>{t('common.min', 'Min')}</p>
|
||||
<p className="text-2xl font-bold text-blue-500">
|
||||
{data?.min_temperature != null ? `${data.min_temperature}°C` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
|
||||
<p className="text-xs" style={{ color: textSecondary }}>{t('common.max', 'Max')}</p>
|
||||
<p className="text-2xl font-bold text-red-500">
|
||||
{data?.max_temperature != null ? `${data.max_temperature}°C` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
|
||||
{isLoading ? (
|
||||
<div className="h-[300px] flex items-center justify-center" style={{ color: textSecondary }}>
|
||||
{t('common.loading', 'Loading...')}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="h-[300px] flex items-center justify-center text-red-500">
|
||||
{t('common.error', 'Error loading data')}
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-[300px] flex items-center justify-center" style={{ color: textSecondary }}>
|
||||
{t('common.noData', 'No data available for this time range')}
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={isDark ? '#3d3d3d' : '#e5e7eb'} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
type="number"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tickFormatter={(ts) => {
|
||||
const date = new Date(ts);
|
||||
if (hours > 24) {
|
||||
return date.toLocaleDateString([], { day: 'numeric', month: 'short' });
|
||||
}
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}}
|
||||
stroke={isDark ? '#9ca3af' : '#6b7280'}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={isDark ? '#9ca3af' : '#6b7280'}
|
||||
tick={{ fontSize: 12 }}
|
||||
domain={mode === 'humidity' ? [0, 100] : ['auto', 'auto']}
|
||||
tickFormatter={(value) => mode === 'humidity' ? `${value}%` : `${value}°C`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: isDark ? '#2d2d2d' : '#ffffff',
|
||||
border: `1px solid ${isDark ? '#3d3d3d' : '#e5e7eb'}`,
|
||||
borderRadius: '8px',
|
||||
color: isDark ? '#fff' : '#000',
|
||||
}}
|
||||
labelFormatter={(ts) => new Date(ts).toLocaleString()}
|
||||
formatter={(value: number) => [
|
||||
mode === 'humidity' ? `${value}%` : `${value}°C`,
|
||||
mode === 'humidity' ? 'Humidity' : 'Temperature'
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
|
||||
{/* Threshold lines */}
|
||||
{mode === 'humidity' ? (
|
||||
<>
|
||||
<ReferenceLine y={humidityGood} stroke="#22a352" strokeDasharray="5 5" label={{ value: 'Good', fill: '#22a352', fontSize: 10 }} />
|
||||
<ReferenceLine y={humidityFair} stroke="#d4a017" strokeDasharray="5 5" label={{ value: 'Fair', fill: '#d4a017', fontSize: 10 }} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ReferenceLine y={tempGood} stroke="#22a352" strokeDasharray="5 5" label={{ value: 'Good', fill: '#22a352', fontSize: 10 }} />
|
||||
<ReferenceLine y={tempFair} stroke="#d4a017" strokeDasharray="5 5" label={{ value: 'Fair', fill: '#d4a017', fontSize: 10 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={mode}
|
||||
name={mode === 'humidity' ? 'Humidity' : 'Temperature'}
|
||||
stroke={mode === 'humidity' ? '#3b82f6' : '#f97316'}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="text-xs text-center" style={{ color: textSecondary }}>
|
||||
{t('amsHistory.recordingInfo', 'Data is recorded every 5 minutes while the printer is connected')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { FileManagerModal } from '../components/FileManagerModal';
|
||||
import { MQTTDebugModal } from '../components/MQTTDebugModal';
|
||||
import { HMSErrorModal } from '../components/HMSErrorModal';
|
||||
import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
|
||||
import { AMSHistoryModal } from '../components/AMSHistoryModal';
|
||||
|
||||
// Nozzle side indicators (Bambu Lab style - square badge with L/R)
|
||||
function NozzleBadge({ side }: { side: 'L' | 'R' }) {
|
||||
@@ -203,9 +204,10 @@ interface HumidityIndicatorProps {
|
||||
humidity: number | string;
|
||||
goodThreshold?: number; // <= this is green
|
||||
fairThreshold?: number; // <= this is orange, > is red
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60 }: HumidityIndicatorProps) {
|
||||
function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60, onClick }: HumidityIndicatorProps) {
|
||||
const humidityValue = typeof humidity === 'string' ? parseInt(humidity, 10) : humidity;
|
||||
const good = typeof goodThreshold === 'number' ? goodThreshold : 40;
|
||||
const fair = typeof fairThreshold === 'number' ? fairThreshold : 60;
|
||||
@@ -242,10 +244,15 @@ function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60 }:
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1" title={`Humidity: ${humidityValue}% - ${statusText}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex items-center justify-end gap-1 ${onClick ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
|
||||
title={`Humidity: ${humidityValue}% - ${statusText}${onClick ? ' (click for history)' : ''}`}
|
||||
>
|
||||
<DropComponent className="w-3 h-4" />
|
||||
<span className="text-xs font-medium tabular-nums w-8 text-right" style={{ color: textColor }}>{humidityValue}%</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -254,32 +261,42 @@ interface TemperatureIndicatorProps {
|
||||
temp: number;
|
||||
goodThreshold?: number; // <= this is blue
|
||||
fairThreshold?: number; // <= this is orange, > is red
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35 }: TemperatureIndicatorProps) {
|
||||
function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35, onClick }: TemperatureIndicatorProps) {
|
||||
// Ensure thresholds are numbers
|
||||
const good = typeof goodThreshold === 'number' ? goodThreshold : 28;
|
||||
const fair = typeof fairThreshold === 'number' ? fairThreshold : 35;
|
||||
|
||||
let textColor: string;
|
||||
let statusText: string;
|
||||
let ThermoComponent: React.FC<{ className?: string }>;
|
||||
|
||||
if (temp <= good) {
|
||||
textColor = '#22a352'; // Green - good (same as humidity)
|
||||
statusText = 'Good';
|
||||
ThermoComponent = ThermometerEmpty;
|
||||
} else if (temp <= fair) {
|
||||
textColor = '#d4a017'; // Gold - fair (same as humidity)
|
||||
statusText = 'Fair';
|
||||
ThermoComponent = ThermometerHalf;
|
||||
} else {
|
||||
textColor = '#c62828'; // Red - bad (same as humidity)
|
||||
statusText = 'Bad';
|
||||
ThermoComponent = ThermometerFull;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1" title="Temperature">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1 ${onClick ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
|
||||
title={`Temperature: ${temp}°C - ${statusText}${onClick ? ' (click for history)' : ''}`}
|
||||
>
|
||||
<ThermoComponent className="w-3 h-4" />
|
||||
<span className="tabular-nums w-12 text-right" style={{ color: textColor }}>{temp}°C</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -507,6 +524,11 @@ function PrinterCard({
|
||||
const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
|
||||
const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
|
||||
const [showHMSModal, setShowHMSModal] = useState(false);
|
||||
const [amsHistoryModal, setAmsHistoryModal] = useState<{
|
||||
amsId: number;
|
||||
amsLabel: string;
|
||||
mode: 'humidity' | 'temperature';
|
||||
} | null>(null);
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['printerStatus', printer.id],
|
||||
@@ -1048,6 +1070,11 @@ function PrinterCard({
|
||||
humidity={ams.humidity}
|
||||
goodThreshold={amsThresholds?.humidityGood}
|
||||
fairThreshold={amsThresholds?.humidityFair}
|
||||
onClick={() => setAmsHistoryModal({
|
||||
amsId: ams.id,
|
||||
amsLabel: getAmsLabel(ams.id, ams.tray.length),
|
||||
mode: 'humidity',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{ams.temp != null && (
|
||||
@@ -1055,6 +1082,11 @@ function PrinterCard({
|
||||
temp={ams.temp}
|
||||
goodThreshold={amsThresholds?.tempGood}
|
||||
fairThreshold={amsThresholds?.tempFair}
|
||||
onClick={() => setAmsHistoryModal({
|
||||
amsId: ams.id,
|
||||
amsLabel: getAmsLabel(ams.id, ams.tray.length),
|
||||
mode: 'temperature',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1271,6 +1303,20 @@ function PrinterCard({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AMS History Modal */}
|
||||
{amsHistoryModal && (
|
||||
<AMSHistoryModal
|
||||
isOpen={!!amsHistoryModal}
|
||||
onClose={() => setAmsHistoryModal(null)}
|
||||
printerId={printer.id}
|
||||
printerName={printer.name}
|
||||
amsId={amsHistoryModal.amsId}
|
||||
amsLabel={amsHistoryModal.amsLabel}
|
||||
initialMode={amsHistoryModal.mode}
|
||||
thresholds={amsThresholds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Printer Modal */}
|
||||
{showEditModal && (
|
||||
<EditPrinterModal
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy } from 'lucide-react';
|
||||
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
import type { AppSettings, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
|
||||
@@ -314,6 +314,7 @@ export function SettingsPage() {
|
||||
settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
|
||||
settings.ams_temp_good !== localSettings.ams_temp_good ||
|
||||
settings.ams_temp_fair !== localSettings.ams_temp_fair ||
|
||||
settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
|
||||
settings.date_format !== localSettings.date_format ||
|
||||
settings.time_format !== localSettings.time_format ||
|
||||
settings.default_printer_id !== localSettings.default_printer_id;
|
||||
@@ -788,6 +789,33 @@ export function SettingsPage() {
|
||||
Above fair threshold shows as red (hot)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* History Retention */}
|
||||
<div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<Database className="w-4 h-4 text-purple-400" />
|
||||
<span className="font-medium">History Retention</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
Keep sensor history for
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={localSettings.ams_history_retention_days ?? 30}
|
||||
onChange={(e) => updateSetting('ams_history_retention_days', parseInt(e.target.value) || 30)}
|
||||
className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
<span className="text-bambu-gray">days</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray">
|
||||
Older humidity and temperature data will be automatically deleted
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user