HeaterHistoryModal.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. import { useState, useEffect } from 'react';
  2. import { useQuery } from '@tanstack/react-query';
  3. import { X, Flame, Square, Box, TrendingUp, TrendingDown, Minus } from 'lucide-react';
  4. import {
  5. LineChart,
  6. Line,
  7. XAxis,
  8. YAxis,
  9. CartesianGrid,
  10. Tooltip,
  11. ResponsiveContainer,
  12. Legend,
  13. } from 'recharts';
  14. import { api, type HeaterSensorKind, type PrinterSensorHistoryResponse } from '../api/client';
  15. import { parseUTCDate, applyTimeFormat, type TimeFormat } from '../utils/date';
  16. import { useTranslation } from 'react-i18next';
  17. interface HeaterHistoryModalProps {
  18. isOpen: boolean;
  19. onClose: () => void;
  20. printerId: number;
  21. printerName: string;
  22. initialKind?: HeaterSensorKind;
  23. availableKinds?: HeaterSensorKind[];
  24. }
  25. type TimeRange = '6h' | '24h' | '48h' | '7d';
  26. const TIME_RANGES: { value: TimeRange; label: string; hours: number }[] = [
  27. { value: '6h', label: '6h', hours: 6 },
  28. { value: '24h', label: '24h', hours: 24 },
  29. { value: '48h', label: '48h', hours: 48 },
  30. { value: '7d', label: '7d', hours: 168 },
  31. ];
  32. const KIND_COLORS: Record<HeaterSensorKind, string> = {
  33. nozzle: '#fb923c',
  34. nozzle_2: '#f97316',
  35. bed: '#60a5fa',
  36. chamber: '#34d399',
  37. };
  38. const KIND_TARGET_COLORS: Record<HeaterSensorKind, string> = {
  39. nozzle: '#fed7aa',
  40. nozzle_2: '#fdba74',
  41. bed: '#bfdbfe',
  42. chamber: '#a7f3d0',
  43. };
  44. export function HeaterHistoryModal({
  45. isOpen,
  46. onClose,
  47. printerId,
  48. printerName,
  49. initialKind = 'nozzle',
  50. availableKinds = ['nozzle', 'bed', 'chamber'],
  51. }: HeaterHistoryModalProps) {
  52. const { t } = useTranslation();
  53. const [timeRange, setTimeRange] = useState<TimeRange>('24h');
  54. const [kind, setKind] = useState<HeaterSensorKind>(initialKind);
  55. useEffect(() => {
  56. setKind(initialKind);
  57. }, [initialKind]);
  58. const { data: settings } = useQuery({
  59. queryKey: ['settings'],
  60. queryFn: api.getSettings,
  61. });
  62. const timeFormat: TimeFormat = settings?.time_format || 'system';
  63. useEffect(() => {
  64. if (!isOpen) return;
  65. const handleKeyDown = (e: KeyboardEvent) => {
  66. if (e.key === 'Escape') onClose();
  67. };
  68. window.addEventListener('keydown', handleKeyDown);
  69. return () => window.removeEventListener('keydown', handleKeyDown);
  70. }, [isOpen, onClose]);
  71. const hours = TIME_RANGES.find(r => r.value === timeRange)?.hours || 24;
  72. const { data, isLoading, error } = useQuery<PrinterSensorHistoryResponse>({
  73. queryKey: ['printer-sensor-history', printerId, hours, availableKinds.join(',')],
  74. queryFn: () => api.getPrinterSensorHistory(printerId, hours, availableKinds),
  75. enabled: isOpen,
  76. refetchInterval: 60000,
  77. });
  78. if (!isOpen) return null;
  79. const series = data?.series.find(s => s.sensor_kind === kind);
  80. const rawPoints = (series?.data || []).map(p => {
  81. const date = parseUTCDate(p.recorded_at) || new Date();
  82. return {
  83. time: date.getTime(),
  84. value: p.value,
  85. target: p.target,
  86. };
  87. });
  88. const domainStart = Date.now() - hours * 60 * 60 * 1000;
  89. const domainEnd = Date.now();
  90. const chartData = [...rawPoints];
  91. if (chartData.length > 0) {
  92. const first = chartData[0];
  93. if (first.time > domainStart) {
  94. chartData.unshift({ ...first, time: domainStart });
  95. }
  96. const last = chartData[chartData.length - 1];
  97. if (last.time < domainEnd) {
  98. chartData.push({ ...last, time: domainEnd });
  99. }
  100. }
  101. const lastPoint = chartData[chartData.length - 1];
  102. const currentValue = lastPoint?.value;
  103. const currentTarget = lastPoint?.target;
  104. const getTrend = (values: (number | null)[]) => {
  105. const filtered = values.filter((v): v is number => v != null);
  106. if (filtered.length < 4) return 'stable';
  107. const firstQuarter = filtered.slice(0, Math.floor(filtered.length / 4));
  108. const lastQuarter = filtered.slice(-Math.floor(filtered.length / 4));
  109. const firstAvg = firstQuarter.reduce((a, b) => a + b, 0) / firstQuarter.length;
  110. const lastAvg = lastQuarter.reduce((a, b) => a + b, 0) / lastQuarter.length;
  111. const diff = lastAvg - firstAvg;
  112. if (Math.abs(diff) < 2) return 'stable';
  113. return diff > 0 ? 'up' : 'down';
  114. };
  115. const trend = getTrend(chartData.map(d => d.value));
  116. const TrendIcon = ({ trend }: { trend: string }) => {
  117. if (trend === 'up') return <TrendingUp className="w-4 h-4 text-red-600 dark:text-red-400" />;
  118. if (trend === 'down') return <TrendingDown className="w-4 h-4 text-green-600 dark:text-green-400" />;
  119. return <Minus className="w-4 h-4 text-gray-400 dark:text-bambu-gray" />;
  120. };
  121. const modalBg = 'var(--bg-secondary)';
  122. const cardBg = 'var(--bg-primary)';
  123. const borderColor = 'var(--border-color)';
  124. const textPrimary = 'var(--text-primary)';
  125. const textSecondary = 'var(--text-secondary)';
  126. const axisColor = 'var(--text-muted)';
  127. const kindLabel = (k: HeaterSensorKind) => {
  128. switch (k) {
  129. case 'nozzle':
  130. return t('printers.heaterHistory.nozzle', 'Nozzle');
  131. case 'nozzle_2':
  132. return t('printers.heaterHistory.nozzle2', 'Nozzle 2');
  133. case 'bed':
  134. return t('printers.heaterHistory.bed', 'Bed');
  135. case 'chamber':
  136. return t('printers.heaterHistory.chamber', 'Chamber');
  137. }
  138. };
  139. const KindIcon = ({ k }: { k: HeaterSensorKind }) => {
  140. if (k === 'nozzle' || k === 'nozzle_2') return <Flame className="w-4 h-4" />;
  141. if (k === 'bed') return <Square className="w-4 h-4" />;
  142. return <Box className="w-4 h-4" />;
  143. };
  144. return (
  145. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
  146. <div
  147. className="rounded-xl w-full max-w-4xl max-h-[90vh] overflow-hidden shadow-xl"
  148. style={{ backgroundColor: modalBg }}
  149. onClick={e => e.stopPropagation()}
  150. >
  151. <div className="flex items-center justify-between px-6 py-4 border-b" style={{ borderColor }}>
  152. <div>
  153. <h2 className="text-lg font-semibold" style={{ color: textPrimary }}>
  154. {t('printers.heaterHistory.title', 'Heater History')}
  155. </h2>
  156. <p className="text-sm" style={{ color: textSecondary }}>{printerName}</p>
  157. </div>
  158. <button
  159. onClick={onClose}
  160. className="p-2 rounded-lg transition-colors"
  161. style={{ color: textSecondary }}
  162. aria-label={t('common.close', 'Close')}
  163. >
  164. <X className="w-5 h-5" />
  165. </button>
  166. </div>
  167. <div className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-80px)]">
  168. <div className="flex items-center justify-between max-[550px]:flex-col max-[550px]:items-start max-[550px]:gap-3">
  169. <div className="inline-flex gap-1 rounded-lg p-1 max-w-full flex-wrap w-fit" style={{ backgroundColor: cardBg }}>
  170. {availableKinds.map(k => (
  171. <button
  172. key={k}
  173. onClick={() => setKind(k)}
  174. className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
  175. kind === k ? 'text-white' : ''
  176. }`}
  177. style={kind === k ? { backgroundColor: KIND_COLORS[k] } : { color: textSecondary }}
  178. >
  179. <KindIcon k={k} />
  180. {kindLabel(k)}
  181. </button>
  182. ))}
  183. </div>
  184. <div className="inline-flex gap-1 rounded-lg p-1 max-w-full flex-wrap w-fit" style={{ backgroundColor: cardBg }}>
  185. {TIME_RANGES.map(range => (
  186. <button
  187. key={range.value}
  188. onClick={() => setTimeRange(range.value)}
  189. className={`px-3 py-1 text-sm rounded-md transition-colors ${
  190. timeRange === range.value ? 'bg-bambu-green text-white' : ''
  191. }`}
  192. style={timeRange !== range.value ? { color: textSecondary } : undefined}
  193. >
  194. {range.label}
  195. </button>
  196. ))}
  197. </div>
  198. </div>
  199. <div className="grid grid-cols-4 gap-4 max-[550px]:grid-cols-2">
  200. <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
  201. <p className="text-xs" style={{ color: textSecondary }}>{t('common.current', 'Current')}</p>
  202. <div className="flex items-center gap-2">
  203. <p className="text-2xl font-bold" style={{ color: KIND_COLORS[kind] }}>
  204. {currentValue != null ? `${Math.round(currentValue)}°C` : '—'}
  205. </p>
  206. <TrendIcon trend={trend} />
  207. </div>
  208. {currentTarget != null && currentTarget > 0 && (
  209. <p className="text-xs mt-1" style={{ color: textSecondary }}>
  210. {t('common.target', 'Target')}: {Math.round(currentTarget)}°C
  211. </p>
  212. )}
  213. </div>
  214. <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
  215. <p className="text-xs" style={{ color: textSecondary }}>{t('common.average', 'Average')}</p>
  216. <p className="text-2xl font-bold" style={{ color: textPrimary }}>
  217. {series?.avg_value != null ? `${series.avg_value}°C` : '—'}
  218. </p>
  219. </div>
  220. <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
  221. <p className="text-xs" style={{ color: textSecondary }}>{t('common.min', 'Min')}</p>
  222. <p className="text-2xl font-bold" style={{ color: textPrimary }}>
  223. {series?.min_value != null ? `${Math.round(series.min_value)}°C` : '—'}
  224. </p>
  225. </div>
  226. <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
  227. <p className="text-xs" style={{ color: textSecondary }}>{t('common.max', 'Max')}</p>
  228. <p className="text-2xl font-bold" style={{ color: textPrimary }}>
  229. {series?.max_value != null ? `${Math.round(series.max_value)}°C` : '—'}
  230. </p>
  231. </div>
  232. </div>
  233. <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
  234. {isLoading ? (
  235. <div className="h-64 flex items-center justify-center" style={{ color: textSecondary }}>
  236. {t('common.loading', 'Loading...')}
  237. </div>
  238. ) : error ? (
  239. <div className="h-64 flex items-center justify-center text-red-700 dark:text-red-400">
  240. {t('printers.heaterHistory.error', 'Failed to load history')}
  241. </div>
  242. ) : chartData.length === 0 ? (
  243. <div className="h-64 flex items-center justify-center" style={{ color: textSecondary }}>
  244. {t('printers.heaterHistory.empty', 'No data recorded yet')}
  245. </div>
  246. ) : (
  247. <ResponsiveContainer width="100%" height={300}>
  248. <LineChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
  249. <CartesianGrid strokeDasharray="3 3" stroke={borderColor} />
  250. <XAxis
  251. dataKey="time"
  252. type="number"
  253. domain={[domainStart, domainEnd]}
  254. tickFormatter={ts =>
  255. new Date(ts).toLocaleTimeString(
  256. [],
  257. applyTimeFormat({ hour: '2-digit', minute: '2-digit' }, timeFormat),
  258. )
  259. }
  260. stroke={axisColor}
  261. fontSize={11}
  262. />
  263. <YAxis
  264. stroke={axisColor}
  265. fontSize={11}
  266. domain={[0, 'auto']}
  267. tickFormatter={v => `${Math.round(v)}°`}
  268. />
  269. <Tooltip
  270. contentStyle={{
  271. backgroundColor: modalBg,
  272. border: `1px solid ${borderColor}`,
  273. borderRadius: 6,
  274. color: textPrimary,
  275. }}
  276. labelFormatter={(ts) =>
  277. new Date(ts as number).toLocaleString(
  278. undefined,
  279. applyTimeFormat(
  280. { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' },
  281. timeFormat,
  282. ),
  283. )
  284. }
  285. formatter={(value) => (value != null ? `${Math.round(Number(value))}°C` : '—')}
  286. />
  287. <Legend />
  288. <Line
  289. type="monotone"
  290. dataKey="value"
  291. name={t('common.current', 'Current')}
  292. stroke={KIND_COLORS[kind]}
  293. strokeWidth={2}
  294. dot={false}
  295. isAnimationActive={false}
  296. />
  297. <Line
  298. type="stepAfter"
  299. dataKey="target"
  300. name={t('common.target', 'Target')}
  301. stroke={KIND_TARGET_COLORS[kind]}
  302. strokeDasharray="4 4"
  303. strokeWidth={1.5}
  304. dot={false}
  305. isAnimationActive={false}
  306. />
  307. </LineChart>
  308. </ResponsiveContainer>
  309. )}
  310. </div>
  311. </div>
  312. </div>
  313. </div>
  314. );
  315. }