diff --git a/app/settings/page.tsx b/app/settings/page.tsx index d79fc0b..1d7e1a4 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 { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -8,9 +8,11 @@ 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 { 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, @@ -26,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); @@ -42,12 +63,24 @@ 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; + 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 { @@ -69,6 +102,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 ( @@ -286,9 +329,47 @@ 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")} + +
+
+
+
+ +
+ {isDirty ? ( +

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

+ ) : null} + +
); } diff --git a/components/dashboard/device-card.tsx b/components/dashboard/device-card.tsx new file mode 100644 index 0000000..76414d9 --- /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..35fc0ec 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,17 @@ 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)} + onNavigate={() => {}} + /> ); })}
diff --git a/components/ui/info-tip.tsx b/components/ui/info-tip.tsx new file mode 100644 index 0000000..df9886d --- /dev/null +++ b/components/ui/info-tip.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Info } from "lucide-react"; + +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +interface InfoTipProps { + label: string; + text: string; +} + +export function InfoTip({ label, text }: InfoTipProps) { + return ( + + + + + + {text} + + + ); +} 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/lib/i18n/en.json b/lib/i18n/en.json index 5bdba1e..a34bacd 100644 --- a/lib/i18n/en.json +++ b/lib/i18n/en.json @@ -99,6 +99,10 @@ "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.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 cbe847b..d135114 100644 --- a/lib/i18n/zh-CN.json +++ b/lib/i18n/zh-CN.json @@ -99,6 +99,10 @@ "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.unsaved": "你有未保存的更改。", "settings.actions.save": "保存设置", "settings.actions.saving": "保存中...", "settings.actions.saved": "设置已更新", diff --git a/lib/settings.ts b/lib/settings.ts new file mode 100644 index 0000000..befbf45 --- /dev/null +++ b/lib/settings.ts @@ -0,0 +1,57 @@ +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 = [ + "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; +} 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..f8cba33 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,53 @@ 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); + }); + + 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(); }); }); 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 }, + }); + }); +});