From 3366714b90d40cc7b76805dc658d5003e7a77bb5 Mon Sep 17 00:00:00 2001 From: AlliotTech <24980252+AlliotTech@users.noreply.github.com> Date: Fri, 6 Feb 2026 14:58:25 +0800 Subject: [PATCH 1/6] feat: collector.discard_sct_temp_history --- app/settings/page.tsx | 32 +++++++++++++++++++++++++ lib/i18n/en.json | 3 +++ lib/i18n/zh-CN.json | 3 +++ lib/types.ts | 3 +++ mocks/data/settings.json | 3 +++ tests/settings-page.test.tsx | 45 ++++++++++++++++++++++++++++++++++++ 6 files changed, 89 insertions(+) diff --git a/app/settings/page.tsx b/app/settings/page.tsx index d79fc0b..c6a1c78 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -42,6 +42,12 @@ export default function SettingsPage() { metrics: { ...(prev?.metrics ?? {}), ...next }, })); }; + const updateCollector = (next: Partial) => { + setDraft((prev) => ({ + ...(prev ?? {}), + collector: { ...(prev?.collector ?? {}), ...next }, + })); + }; const handleSave = async () => { if (!draft) return; @@ -286,6 +292,32 @@ export default function SettingsPage() { + + + {t("settings.collector.title")} + + +
+ +
+ updateCollector({ discard_sct_temp_history: value })} + /> + + {draft.collector?.discard_sct_temp_history ? t("common.on") : t("common.off")} + +
+

+ {t("settings.collector.discard_sct_temp_history_help")} +

+
+
+
+ diff --git a/lib/i18n/en.json b/lib/i18n/en.json index 5bdba1e..d019bba 100644 --- a/lib/i18n/en.json +++ b/lib/i18n/en.json @@ -99,6 +99,9 @@ "settings.display.smooth": "Smooth", "settings.display.straight": "Straight", "settings.display.step": "Step", + "settings.collector.title": "Collector", + "settings.collector.discard_sct_temp_history": "Discard SCT Temperature History", + "settings.collector.discard_sct_temp_history_help": "Discard historical temperature data retrieved from the SMART Command Transport (SCT) Data Table, which may be inaccurate for some drives. The current temperature is always stored.", "settings.actions.save": "Save Settings", "settings.actions.saving": "Saving...", "settings.actions.saved": "Settings updated", diff --git a/lib/i18n/zh-CN.json b/lib/i18n/zh-CN.json index cbe847b..47a013d 100644 --- a/lib/i18n/zh-CN.json +++ b/lib/i18n/zh-CN.json @@ -99,6 +99,9 @@ "settings.display.smooth": "平滑", "settings.display.straight": "直线", "settings.display.step": "阶梯", + "settings.collector.title": "采集", + "settings.collector.discard_sct_temp_history": "丢弃 SCT 温度历史", + "settings.collector.discard_sct_temp_history_help": "丢弃从 SMART Command Transport(SCT)数据表获取的历史温度数据,该数据在某些硬盘上可能不准确。当前温度仍会保存。", "settings.actions.save": "保存设置", "settings.actions.saving": "保存中...", "settings.actions.saved": "设置已更新", diff --git a/lib/types.ts b/lib/types.ts index 91a2525..8bb7a39 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -132,6 +132,9 @@ export interface AppConfig { file_size_si_units?: boolean; powered_on_hours_unit?: DevicePoweredOnUnit; line_stroke?: LineStroke; + collector?: { + discard_sct_temp_history?: boolean; + }; metrics?: { notify_level?: MetricsNotifyLevel; status_filter_attributes?: MetricsStatusFilterAttributes; diff --git a/mocks/data/settings.json b/mocks/data/settings.json index f3aa707..cd54d2a 100644 --- a/mocks/data/settings.json +++ b/mocks/data/settings.json @@ -9,6 +9,9 @@ "file_size_si_units": true, "powered_on_hours_unit": "humanize", "line_stroke": "smooth", + "collector": { + "discard_sct_temp_history": false + }, "metrics": { "notify_level": 2, "status_filter_attributes": 0, diff --git a/tests/settings-page.test.tsx b/tests/settings-page.test.tsx index a7feea0..8c1aa6d 100644 --- a/tests/settings-page.test.tsx +++ b/tests/settings-page.test.tsx @@ -1,7 +1,18 @@ import { describe, expect, it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { vi } from "vitest"; import SettingsPage from "@/app/settings/page"; import { renderWithProviders } from "@/tests/render"; +import { saveSettings } from "@/lib/api"; + +vi.mock("@/lib/api", async () => { + const actual = await vi.importActual("@/lib/api"); + return { + ...actual, + saveSettings: vi.fn(), + }; +}); describe("SettingsPage", () => { it("shows health status and settings sections", async () => { @@ -11,5 +22,39 @@ describe("SettingsPage", () => { expect(await findByText("Healthy")).toBeInTheDocument(); expect(await findByText("Metrics")).toBeInTheDocument(); expect(await findByText("Display")).toBeInTheDocument(); + expect(await findByText("Collector")).toBeInTheDocument(); + }); + + it("saves collector discard flag", async () => { + const user = userEvent.setup(); + vi.mocked(saveSettings).mockResolvedValue({ + theme: "light", + layout: "default", + dashboard_display: "name", + dashboard_sort: "status", + temperature_unit: "celsius", + file_size_si_units: true, + powered_on_hours_unit: "humanize", + line_stroke: "smooth", + collector: { + discard_sct_temp_history: true, + }, + metrics: { + notify_level: 2, + status_filter_attributes: 0, + status_threshold: 3, + repeat_notifications: false, + }, + }); + + const { findByRole, getByText } = renderWithProviders(); + + const toggle = await findByRole("switch", { name: "Discard SCT Temperature History" }); + await user.click(toggle); + await user.click(getByText("Save Settings")); + + expect(saveSettings).toHaveBeenCalled(); + const payload = vi.mocked(saveSettings).mock.calls[0]?.[0]; + expect(payload?.collector?.discard_sct_temp_history).toBe(true); }); }); From 6dcbc33207c006e1bbe31df7140da63db765bb2e Mon Sep 17 00:00:00 2001 From: AlliotTech <24980252+AlliotTech@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:02:20 +0800 Subject: [PATCH 2/6] feat: collector.discard_sct_temp_history --- app/settings/page.tsx | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/app/settings/page.tsx b/app/settings/page.tsx index c6a1c78..968423d 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { Info } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -8,6 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useI18n } from "@/lib/i18n"; import { useHealth, useSettings } from "@/lib/hooks"; import { saveSettings, sendTestNotification } from "@/lib/api"; @@ -298,11 +300,33 @@ export default function SettingsPage() {
- +
+ + + + + + + + {t("settings.collector.discard_sct_temp_history_help")} + + + +
updateCollector({ discard_sct_temp_history: value })} @@ -311,9 +335,6 @@ export default function SettingsPage() { {draft.collector?.discard_sct_temp_history ? t("common.on") : t("common.off")}
-

- {t("settings.collector.discard_sct_temp_history_help")} -

From ba28c98aad8897dcc3f691f824cdb426f14d9c92 Mon Sep 17 00:00:00 2001 From: AlliotTech <24980252+AlliotTech@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:04:54 +0800 Subject: [PATCH 3/6] feat: add unsaved changes notification to settings page --- app/settings/page.tsx | 42 ++++++++++++++++++++++++++++++++---- lib/i18n/en.json | 1 + lib/i18n/zh-CN.json | 1 + tests/settings-page.test.tsx | 14 ++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 968423d..70fcbbd 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Info } from "lucide-react"; import { toast } from "sonner"; @@ -28,6 +28,25 @@ export default function SettingsPage() { const [saving, setSaving] = useState(false); const [testingNotify, setTestingNotify] = useState(false); + const isDirty = useMemo(() => { + if (!draft || !settings.data) return false; + const normalizeValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map((item) => normalizeValue(item)); + } + if (value && typeof value === "object") { + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => a.localeCompare(b)); + return Object.fromEntries(entries.map(([k, v]) => [k, normalizeValue(v)])); + } + return value; + }; + const left = JSON.stringify(normalizeValue(draft)); + const right = JSON.stringify(normalizeValue(settings.data)); + return left !== right; + }, [draft, settings.data]); + useEffect(() => { if (settings.data) { setDraft(settings.data); @@ -77,6 +96,16 @@ export default function SettingsPage() { } }; + useEffect(() => { + if (!isDirty) return; + const handler = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [isDirty]); + if (settings.error) { return ( @@ -339,9 +368,14 @@ export default function SettingsPage() { - +
+ {isDirty ? ( +

{t("settings.actions.unsaved")}

+ ) : null} + +
); } diff --git a/lib/i18n/en.json b/lib/i18n/en.json index d019bba..a34bacd 100644 --- a/lib/i18n/en.json +++ b/lib/i18n/en.json @@ -102,6 +102,7 @@ "settings.collector.title": "Collector", "settings.collector.discard_sct_temp_history": "Discard SCT Temperature History", "settings.collector.discard_sct_temp_history_help": "Discard historical temperature data retrieved from the SMART Command Transport (SCT) Data Table, which may be inaccurate for some drives. The current temperature is always stored.", + "settings.actions.unsaved": "You have unsaved changes.", "settings.actions.save": "Save Settings", "settings.actions.saving": "Saving...", "settings.actions.saved": "Settings updated", diff --git a/lib/i18n/zh-CN.json b/lib/i18n/zh-CN.json index 47a013d..d135114 100644 --- a/lib/i18n/zh-CN.json +++ b/lib/i18n/zh-CN.json @@ -102,6 +102,7 @@ "settings.collector.title": "采集", "settings.collector.discard_sct_temp_history": "丢弃 SCT 温度历史", "settings.collector.discard_sct_temp_history_help": "丢弃从 SMART Command Transport(SCT)数据表获取的历史温度数据,该数据在某些硬盘上可能不准确。当前温度仍会保存。", + "settings.actions.unsaved": "你有未保存的更改。", "settings.actions.save": "保存设置", "settings.actions.saving": "保存中...", "settings.actions.saved": "设置已更新", diff --git a/tests/settings-page.test.tsx b/tests/settings-page.test.tsx index 8c1aa6d..f8cba33 100644 --- a/tests/settings-page.test.tsx +++ b/tests/settings-page.test.tsx @@ -57,4 +57,18 @@ describe("SettingsPage", () => { const payload = vi.mocked(saveSettings).mock.calls[0]?.[0]; expect(payload?.collector?.discard_sct_temp_history).toBe(true); }); + + it("enables save when settings change", async () => { + const user = userEvent.setup(); + const { findByRole, findByText } = renderWithProviders(); + + const saveButton = await findByRole("button", { name: "Save Settings" }); + expect(saveButton).toBeDisabled(); + + const toggle = await findByRole("switch", { name: "Discard SCT Temperature History" }); + await user.click(toggle); + + expect(saveButton).toBeEnabled(); + expect(await findByText("You have unsaved changes.")).toBeInTheDocument(); + }); }); From 322fd7581578d4e1aad43e60e73b31a8fdd25ac6 Mon Sep 17 00:00:00 2001 From: AlliotTech <24980252+AlliotTech@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:10:39 +0800 Subject: [PATCH 4/6] feat: implement buildSettingsPatch function and InfoTip component for settings page --- app/settings/page.tsx | 85 +++++++++++++++++++++++++++++--------- components/ui/info-tip.tsx | 29 +++++++++++++ lib/settings.ts | 56 +++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 19 deletions(-) create mode 100644 components/ui/info-tip.tsx create mode 100644 lib/settings.ts diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 70fcbbd..feda38d 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Info } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -9,7 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Skeleton } from "@/components/ui/skeleton"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { InfoTip } from "@/components/ui/info-tip"; import { useI18n } from "@/lib/i18n"; import { useHealth, useSettings } from "@/lib/hooks"; import { saveSettings, sendTestNotification } from "@/lib/api"; @@ -70,11 +69,71 @@ export default function SettingsPage() { })); }; + const buildSettingsPatch = (original: AppConfig, next: AppConfig): Partial => { + const patch: Partial = {}; + const keys: Array = [ + "theme", + "layout", + "dashboard_display", + "dashboard_sort", + "temperature_unit", + "file_size_si_units", + "powered_on_hours_unit", + "line_stroke", + ]; + keys.forEach((key) => { + const nextValue = next[key]; + if (nextValue !== undefined && nextValue !== original[key]) { + patch[key] = nextValue; + } + }); + + const metricsKeys: Array> = [ + "notify_level", + "status_filter_attributes", + "status_threshold", + "repeat_notifications", + ]; + const metricsPatch: Partial> = {}; + metricsKeys.forEach((key) => { + const nextValue = next.metrics?.[key]; + const originalValue = original.metrics?.[key]; + if (nextValue !== undefined && nextValue !== originalValue) { + metricsPatch[key] = nextValue; + } + }); + if (Object.keys(metricsPatch).length > 0) { + patch.metrics = metricsPatch; + } + + const collectorKeys: Array> = [ + "discard_sct_temp_history", + ]; + const collectorPatch: Partial> = {}; + collectorKeys.forEach((key) => { + const nextValue = next.collector?.[key]; + const originalValue = original.collector?.[key]; + if (nextValue !== undefined && nextValue !== originalValue) { + collectorPatch[key] = nextValue; + } + }); + if (Object.keys(collectorPatch).length > 0) { + patch.collector = collectorPatch; + } + + return patch; + }; + const handleSave = async () => { if (!draft) return; + const payload = buildSettingsPatch(settings.data ?? {}, draft); + if (Object.keys(payload).length === 0) { + toast.success(t("settings.actions.saved")); + return; + } try { setSaving(true); - await saveSettings(draft); + await saveSettings(payload); await settings.mutate(); toast.success(t("settings.actions.saved")); } catch { @@ -336,22 +395,10 @@ export default function SettingsPage() { > {t("settings.collector.discard_sct_temp_history")} - - - - - - - {t("settings.collector.discard_sct_temp_history_help")} - - - +
+ + + + + {text} + + + ); +} diff --git a/lib/settings.ts b/lib/settings.ts new file mode 100644 index 0000000..a95a3a8 --- /dev/null +++ b/lib/settings.ts @@ -0,0 +1,56 @@ +import { AppConfig } from "@/lib/types"; + +export function buildSettingsPatch(original: AppConfig, next: AppConfig): Partial { + const patch: Partial = {}; + const keys: Array = [ + "theme", + "layout", + "dashboard_display", + "dashboard_sort", + "temperature_unit", + "file_size_si_units", + "powered_on_hours_unit", + "line_stroke", + ]; + keys.forEach((key) => { + const nextValue = next[key]; + if (nextValue !== undefined && nextValue !== original[key]) { + patch[key] = nextValue; + } + }); + + const metricsKeys: Array> = [ + "notify_level", + "status_filter_attributes", + "status_threshold", + "repeat_notifications", + ]; + const metricsPatch: Partial> = {}; + metricsKeys.forEach((key) => { + const nextValue = next.metrics?.[key]; + const originalValue = original.metrics?.[key]; + if (nextValue !== undefined && nextValue !== originalValue) { + metricsPatch[key] = nextValue; + } + }); + if (Object.keys(metricsPatch).length > 0) { + patch.metrics = metricsPatch; + } + + const collectorKeys: Array> = [ + "discard_sct_temp_history", + ]; + const collectorPatch: Partial> = {}; + collectorKeys.forEach((key) => { + const nextValue = next.collector?.[key]; + const originalValue = original.collector?.[key]; + if (nextValue !== undefined && nextValue !== originalValue) { + collectorPatch[key] = nextValue; + } + }); + if (Object.keys(collectorPatch).length > 0) { + patch.collector = collectorPatch; + } + + return patch; +} From 12b809b5cd319529a35278d123159835306f8708 Mon Sep 17 00:00:00 2001 From: AlliotTech <24980252+AlliotTech@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:16:24 +0800 Subject: [PATCH 5/6] feat: refactor device list and card components; implement buildSettingsPatch function and add tests --- app/settings/page.tsx | 55 +---- components/dashboard/device-card.tsx | 248 ++++++++++++++++++++ components/dashboard/device-list-mobile.tsx | 136 ++--------- components/dashboard/device-list.tsx | 165 +------------ lib/api.ts | 9 +- tests/settings-patch.test.ts | 51 ++++ 6 files changed, 332 insertions(+), 332 deletions(-) create mode 100644 components/dashboard/device-card.tsx create mode 100644 tests/settings-patch.test.ts diff --git a/app/settings/page.tsx b/app/settings/page.tsx index feda38d..1d7e1a4 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -12,6 +12,7 @@ import { InfoTip } from "@/components/ui/info-tip"; import { useI18n } from "@/lib/i18n"; import { useHealth, useSettings } from "@/lib/hooks"; import { saveSettings, sendTestNotification } from "@/lib/api"; +import { buildSettingsPatch } from "@/lib/settings"; import { AppConfig, MetricsNotifyLevel, @@ -69,60 +70,6 @@ export default function SettingsPage() { })); }; - const buildSettingsPatch = (original: AppConfig, next: AppConfig): Partial => { - const patch: Partial = {}; - const keys: Array = [ - "theme", - "layout", - "dashboard_display", - "dashboard_sort", - "temperature_unit", - "file_size_si_units", - "powered_on_hours_unit", - "line_stroke", - ]; - keys.forEach((key) => { - const nextValue = next[key]; - if (nextValue !== undefined && nextValue !== original[key]) { - patch[key] = nextValue; - } - }); - - const metricsKeys: Array> = [ - "notify_level", - "status_filter_attributes", - "status_threshold", - "repeat_notifications", - ]; - const metricsPatch: Partial> = {}; - metricsKeys.forEach((key) => { - const nextValue = next.metrics?.[key]; - const originalValue = original.metrics?.[key]; - if (nextValue !== undefined && nextValue !== originalValue) { - metricsPatch[key] = nextValue; - } - }); - if (Object.keys(metricsPatch).length > 0) { - patch.metrics = metricsPatch; - } - - const collectorKeys: Array> = [ - "discard_sct_temp_history", - ]; - const collectorPatch: Partial> = {}; - collectorKeys.forEach((key) => { - const nextValue = next.collector?.[key]; - const originalValue = original.collector?.[key]; - if (nextValue !== undefined && nextValue !== originalValue) { - collectorPatch[key] = nextValue; - } - }); - if (Object.keys(collectorPatch).length > 0) { - patch.collector = collectorPatch; - } - - return patch; - }; const handleSave = async () => { if (!draft) return; diff --git a/components/dashboard/device-card.tsx b/components/dashboard/device-card.tsx new file mode 100644 index 0000000..59e2355 --- /dev/null +++ b/components/dashboard/device-card.tsx @@ -0,0 +1,248 @@ +"use client"; + +import Link from "next/link"; +import { MoreHorizontal } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { StatusPill } from "@/components/ui/status-pill"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Card, CardContent } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { + formatBytes, + formatDateTime, + formatPowerOnHours, + formatTemperature, + summaryAgeClass, +} from "@/lib/format"; +import { AppConfig, DeviceSummaryModel, MetricsStatusThreshold } from "@/lib/types"; +import { deviceHref, getDeviceCardData } from "@/components/dashboard/device-list-shared"; + +export type DeviceCardVariant = "desktop" | "mobile"; + +interface DeviceCardProps { + deviceSummary: DeviceSummaryModel; + settings?: AppConfig; + threshold: MetricsStatusThreshold; + t: (key: string) => string; + variant: DeviceCardVariant; + onAction: (payload: { action: "archive" | "unarchive" | "delete"; wwn: string; label: string }) => void; + onNavigate?: (href: string) => void; +} + +export function DeviceCard({ deviceSummary, settings, threshold, t, variant, onAction, onNavigate }: DeviceCardProps) { + const { + failedEmphasis, + pillStatus, + ProtocolIcon, + protocol, + statusLabel, + title, + } = getDeviceCardData(deviceSummary, settings, threshold, t); + const temperatureUnit = settings?.temperature_unit ?? "celsius"; + + if (variant === "mobile") { + const handleNavigate = () => onNavigate?.(deviceHref(deviceSummary.device.wwn)); + return ( +
+
+
+
+ + + +

{title}

+ {deviceSummary.device.archived && ( + {t("dashboard.devices.archived")} + )} +
+

{deviceSummary.device.wwn}

+
+ +
+ +
+
+

{t("dashboard.devices.last_updated")}

+

+ {formatDateTime(deviceSummary.smart?.collector_date)} +

+
+
+

{t("dashboard.devices.temp")}

+

+ {formatTemperature(deviceSummary.smart?.temp, temperatureUnit)} +

+
+
+ +
+ + + + + + event.preventDefault()} + onClick={handleNavigate} + className="cursor-pointer" + > + {t("nav.device")} + + event.preventDefault()} + onClick={() => + onAction({ + action: deviceSummary.device.archived ? "unarchive" : "archive", + wwn: deviceSummary.device.wwn, + label: title, + }) + } + className="cursor-pointer" + > + {deviceSummary.device.archived ? t("device.actions.unarchive") : t("device.actions.archive")} + + event.preventDefault()} + onClick={() => + onAction({ + action: "delete", + wwn: deviceSummary.device.wwn, + label: title, + }) + } + className="cursor-pointer" + > + {t("device.actions.delete")} + + + +
+
+ ); + } + + return ( + + + +
+
+
+ + + +

{title}

+ {deviceSummary.device.archived && ( + {t("dashboard.devices.archived")} + )} +
+

{deviceSummary.device.wwn}

+
+ +
+
+
+ + + + + + event.preventDefault()} asChild className="cursor-pointer"> + event.stopPropagation()}> + {t("nav.device")} + + + event.preventDefault()} + onClick={(event) => { + event.stopPropagation(); + onAction({ + action: deviceSummary.device.archived ? "unarchive" : "archive", + wwn: deviceSummary.device.wwn, + label: title, + }); + }} + className="cursor-pointer" + > + {deviceSummary.device.archived ? t("device.actions.unarchive") : t("device.actions.archive")} + + event.preventDefault()} + onClick={(event) => { + event.stopPropagation(); + onAction({ + action: "delete", + wwn: deviceSummary.device.wwn, + label: title, + }); + }} + className="cursor-pointer" + > + {t("device.actions.delete")} + + + +
+
+ + +
+
+

{t("dashboard.devices.last_updated")}

+

+ {formatDateTime(deviceSummary.smart?.collector_date)} +

+
+
+

{t("dashboard.devices.temp")}

+

+ {formatTemperature(deviceSummary.smart?.temp, temperatureUnit)} +

+
+
+

{t("dashboard.devices.capacity")}

+

+ {formatBytes(deviceSummary.device.capacity, settings?.file_size_si_units)} +

+
+
+

{t("dashboard.devices.power_on")}

+

+ {formatPowerOnHours(deviceSummary.smart?.power_on_hours, settings?.powered_on_hours_unit)} +

+
+
+
+
+ ); +} diff --git a/components/dashboard/device-list-mobile.tsx b/components/dashboard/device-list-mobile.tsx index 958045e..eba9a46 100644 --- a/components/dashboard/device-list-mobile.tsx +++ b/components/dashboard/device-list-mobile.tsx @@ -1,19 +1,9 @@ "use client"; -import { MoreHorizontal } from "lucide-react"; import { toast } from "sonner"; import React from "react"; import { useRouter } from "next/navigation"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { StatusPill } from "@/components/ui/status-pill"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, @@ -22,16 +12,12 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; import { useI18n } from "@/lib/i18n"; import { performDeviceAction } from "@/lib/device-actions"; import { AppConfig, DeviceSummaryModel, MetricsStatusThreshold } from "@/lib/types"; -import { formatDateTime, formatTemperature, summaryAgeClass } from "@/lib/format"; -import { cn } from "@/lib/utils"; -import { - deviceHref, - getDeviceCardData, - groupDevicesByHost, -} from "@/components/dashboard/device-list-shared"; +import { groupDevicesByHost } from "@/components/dashboard/device-list-shared"; +import { DeviceCard } from "@/components/dashboard/device-card"; interface DeviceListMobileProps { summary: Record; @@ -50,8 +36,6 @@ export function DeviceListMobile({ summary, settings, showArchived, onAction }: } | null>(null); const threshold = settings?.metrics?.status_threshold ?? MetricsStatusThreshold.Both; - const temperatureUnit = settings?.temperature_unit ?? "celsius"; - const devices = Object.values(summary).filter((device) => showArchived ? true : !device.device.archived ); @@ -89,108 +73,18 @@ export function DeviceListMobile({ summary, settings, showArchived, onAction }: {host}
- {group.map((deviceSummary) => { - const { - failedEmphasis, - pillStatus, - ProtocolIcon, - protocol, - statusLabel, - title, - } = getDeviceCardData(deviceSummary, settings, threshold, t); - return ( -
router.push(deviceHref(deviceSummary.device.wwn))} - > -
-
-
- - - -

{title}

- {deviceSummary.device.archived && ( - {t("dashboard.devices.archived")} - )} -
-

{deviceSummary.device.wwn}

-
- -
- -
-
-

{t("dashboard.devices.last_updated")}

-

- {formatDateTime(deviceSummary.smart?.collector_date)} -

-
-
-

{t("dashboard.devices.temp")}

-

- {formatTemperature(deviceSummary.smart?.temp, temperatureUnit)} -

-
-
- -
- - - - - - event.preventDefault()} - onClick={() => router.push(deviceHref(deviceSummary.device.wwn))} - className="cursor-pointer" - > - {t("nav.device")} - - event.preventDefault()} - onClick={() => - setConfirmState({ - action: deviceSummary.device.archived ? "unarchive" : "archive", - wwn: deviceSummary.device.wwn, - label: title, - }) - } - className="cursor-pointer" - > - {deviceSummary.device.archived - ? t("device.actions.unarchive") - : t("device.actions.archive")} - - event.preventDefault()} - onClick={() => - setConfirmState({ - action: "delete", - wwn: deviceSummary.device.wwn, - label: title, - }) - } - className="cursor-pointer" - > - {t("device.actions.delete")} - - - -
-
- ); - })} + {group.map((deviceSummary) => ( + setConfirmState(payload)} + onNavigate={(href) => router.push(href)} + /> + ))}
))} diff --git a/components/dashboard/device-list.tsx b/components/dashboard/device-list.tsx index 5e70ea1..766c336 100644 --- a/components/dashboard/device-list.tsx +++ b/components/dashboard/device-list.tsx @@ -1,19 +1,8 @@ "use client"; -import Link from "next/link"; -import { MoreHorizontal } from "lucide-react"; import { toast } from "sonner"; import React from "react"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { StatusPill } from "@/components/ui/status-pill"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, @@ -22,24 +11,12 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; import { useI18n } from "@/lib/i18n"; import { performDeviceAction } from "@/lib/device-actions"; import { AppConfig, DeviceSummaryModel, MetricsStatusThreshold } from "@/lib/types"; -import { - formatBytes, - formatDateTime, - formatPowerOnHours, - formatTemperature, - summaryAgeClass, -} from "@/lib/format"; -import { cn } from "@/lib/utils"; -import { - deviceHref, - getDeviceCardData, - groupDevicesByHost, - sortDevicesForDashboard, -} from "@/components/dashboard/device-list-shared"; +import { groupDevicesByHost, sortDevicesForDashboard } from "@/components/dashboard/device-list-shared"; +import { DeviceCard } from "@/components/dashboard/device-card"; interface DeviceListProps { summary: Record; @@ -57,8 +34,6 @@ export function DeviceList({ summary, settings, showArchived, onAction }: Device } | null>(null); const threshold = settings?.metrics?.status_threshold ?? MetricsStatusThreshold.Both; - const temperatureUnit = settings?.temperature_unit ?? "celsius"; - const devices = Object.values(summary).filter((device) => showArchived ? true : !device.device.archived ); @@ -99,132 +74,16 @@ export function DeviceList({ summary, settings, showArchived, onAction }: Device
{group.map((deviceSummary) => { - const { - failedEmphasis, - pillStatus, - ProtocolIcon, - protocol, - statusLabel, - title, - } = getDeviceCardData(deviceSummary, settings, threshold, t); return ( - - - -
-
-
- - - -

{title}

- {deviceSummary.device.archived && ( - {t("dashboard.devices.archived")} - )} -
-

{deviceSummary.device.wwn}

-
- -
-
-
- - - - - - event.preventDefault()} - asChild - className="cursor-pointer" - > - event.stopPropagation()} - > - {t("nav.device")} - - - event.preventDefault()} - onClick={(event) => { - event.stopPropagation(); - setConfirmState({ - action: deviceSummary.device.archived ? "unarchive" : "archive", - wwn: deviceSummary.device.wwn, - label: title, - }); - }} - className="cursor-pointer" - > - {deviceSummary.device.archived - ? t("device.actions.unarchive") - : t("device.actions.archive")} - - event.preventDefault()} - onClick={(event) => { - event.stopPropagation(); - setConfirmState({ - action: "delete", - wwn: deviceSummary.device.wwn, - label: title, - }); - }} - className="cursor-pointer" - > - {t("device.actions.delete")} - - - -
-
- - -
-
-

{t("dashboard.devices.last_updated")}

-

- {formatDateTime(deviceSummary.smart?.collector_date)} -

-
-
-

{t("dashboard.devices.temp")}

-

- {formatTemperature(deviceSummary.smart?.temp, temperatureUnit)} -

-
-
-

{t("dashboard.devices.capacity")}

-

- {formatBytes(deviceSummary.device.capacity, settings?.file_size_si_units)} -

-
-
-

{t("dashboard.devices.power_on")}

-

- {formatPowerOnHours(deviceSummary.smart?.power_on_hours, settings?.powered_on_hours_unit)} -

-
-
-
-
+ setConfirmState(payload)} + /> ); })}
diff --git a/lib/api.ts b/lib/api.ts index 0474555..1fbff8b 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -29,12 +29,13 @@ function withBase(path: string) { async function apiFetch(path: string, init?: RequestInit): Promise { await ensureMswReady(); + const headers = new Headers(init?.headers ?? {}); + if (init?.body !== undefined && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } const response = await fetch(withBase(path), { ...init, - headers: { - "Content-Type": "application/json", - ...(init?.headers ?? {}), - }, + headers, }); if (!response.ok) { diff --git a/tests/settings-patch.test.ts b/tests/settings-patch.test.ts new file mode 100644 index 0000000..09c7f60 --- /dev/null +++ b/tests/settings-patch.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { buildSettingsPatch } from "@/lib/settings"; +import { AppConfig, MetricsNotifyLevel, MetricsStatusFilterAttributes, MetricsStatusThreshold } from "@/lib/types"; + +const base: AppConfig = { + theme: "light", + layout: "default", + dashboard_display: "name", + dashboard_sort: "status", + temperature_unit: "celsius", + file_size_si_units: true, + powered_on_hours_unit: "humanize", + line_stroke: "smooth", + collector: { + discard_sct_temp_history: false, + }, + metrics: { + notify_level: MetricsNotifyLevel.Fail, + status_filter_attributes: MetricsStatusFilterAttributes.All, + status_threshold: MetricsStatusThreshold.Both, + repeat_notifications: false, + }, +}; + +describe("buildSettingsPatch", () => { + it("returns empty patch when no changes", () => { + const patch = buildSettingsPatch(base, { ...base }); + expect(patch).toEqual({}); + }); + + it("includes only changed top-level fields", () => { + const patch = buildSettingsPatch(base, { + ...base, + temperature_unit: "fahrenheit", + }); + expect(patch).toEqual({ temperature_unit: "fahrenheit" }); + }); + + it("includes only changed nested fields", () => { + const patch = buildSettingsPatch(base, { + ...base, + collector: { discard_sct_temp_history: true }, + metrics: { ...base.metrics, status_threshold: MetricsStatusThreshold.Smart }, + }); + expect(patch).toEqual({ + collector: { discard_sct_temp_history: true }, + metrics: { status_threshold: MetricsStatusThreshold.Smart }, + }); + }); +}); From f670b0cf6f1f84b8a9bc8a45efd9bb27e86ad1b8 Mon Sep 17 00:00:00 2001 From: AlliotTech <24980252+AlliotTech@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:16:30 +0800 Subject: [PATCH 6/6] feat: refactor device list and card components; implement buildSettingsPatch function and add tests --- components/dashboard/device-card.tsx | 4 ++-- components/dashboard/device-list.tsx | 1 + lib/settings.ts | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/components/dashboard/device-card.tsx b/components/dashboard/device-card.tsx index 59e2355..76414d9 100644 --- a/components/dashboard/device-card.tsx +++ b/components/dashboard/device-card.tsx @@ -32,7 +32,7 @@ interface DeviceCardProps { t: (key: string) => string; variant: DeviceCardVariant; onAction: (payload: { action: "archive" | "unarchive" | "delete"; wwn: string; label: string }) => void; - onNavigate?: (href: string) => void; + onNavigate: (href: string) => void; } export function DeviceCard({ deviceSummary, settings, threshold, t, variant, onAction, onNavigate }: DeviceCardProps) { @@ -47,7 +47,7 @@ export function DeviceCard({ deviceSummary, settings, threshold, t, variant, onA const temperatureUnit = settings?.temperature_unit ?? "celsius"; if (variant === "mobile") { - const handleNavigate = () => onNavigate?.(deviceHref(deviceSummary.device.wwn)); + const handleNavigate = () => onNavigate(deviceHref(deviceSummary.device.wwn)); return (
setConfirmState(payload)} + onNavigate={() => {}} /> ); })} diff --git a/lib/settings.ts b/lib/settings.ts index a95a3a8..befbf45 100644 --- a/lib/settings.ts +++ b/lib/settings.ts @@ -1,5 +1,6 @@ import { AppConfig } from "@/lib/types"; +// Keep keys in sync with AppConfig when adding new settings fields. export function buildSettingsPatch(original: AppConfig, next: AppConfig): Partial { const patch: Partial = {}; const keys: Array = [