Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 86 additions & 5 deletions app/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
"use client";

import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
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,
Expand All @@ -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<string, unknown>)
.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);
Expand All @@ -42,12 +63,24 @@ export default function SettingsPage() {
metrics: { ...(prev?.metrics ?? {}), ...next },
}));
};
const updateCollector = (next: Partial<AppConfig["collector"]>) => {
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 {
Expand All @@ -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 (
<Card className="glass-panel">
Expand Down Expand Up @@ -286,9 +329,47 @@ export default function SettingsPage() {
</CardContent>
</Card>

<Button onClick={handleSave} disabled={saving} className="w-full md:w-auto">
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
</Button>
<Card className="glass-panel">
<CardHeader>
<CardTitle>{t("settings.collector.title")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div>
<div className="flex items-center gap-2">
<label
htmlFor="collector-discard-sct-temp-history"
className="text-xs uppercase text-muted-foreground"
>
{t("settings.collector.discard_sct_temp_history")}
</label>
<InfoTip
label={t("settings.collector.discard_sct_temp_history")}
text={t("settings.collector.discard_sct_temp_history_help")}
/>
</div>
<div className="mt-2 flex items-center gap-2">
<Switch
id="collector-discard-sct-temp-history"
checked={draft.collector?.discard_sct_temp_history ?? false}
aria-label={t("settings.collector.discard_sct_temp_history")}
onCheckedChange={(value) => updateCollector({ discard_sct_temp_history: value })}
/>
<span className="text-sm">
{draft.collector?.discard_sct_temp_history ? t("common.on") : t("common.off")}
</span>
</div>
</div>
</CardContent>
</Card>

<div className="space-y-2">
{isDirty ? (
<p className="text-xs text-muted-foreground">{t("settings.actions.unsaved")}</p>
) : null}
<Button onClick={handleSave} disabled={saving || !isDirty} className="w-full md:w-auto">
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
</Button>
</div>
</div>
);
}
248 changes: 248 additions & 0 deletions components/dashboard/device-card.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={`rounded-lg border bg-card p-4 cursor-pointer transition-colors hover:bg-muted/30 ${failedEmphasis}`}
onClick={handleNavigate}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<span
className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-muted text-muted-foreground"
title={protocol || t("common.unknown")}
>
<ProtocolIcon className="h-3.5 w-3.5" />
</span>
<h3 className="text-base font-semibold">{title}</h3>
{deviceSummary.device.archived && (
<Badge variant="outline">{t("dashboard.devices.archived")}</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{deviceSummary.device.wwn}</p>
</div>
<StatusPill status={pillStatus} label={statusLabel} />
</div>

<div className="mt-3 grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-[11px] uppercase text-muted-foreground">{t("dashboard.devices.last_updated")}</p>
<p className={cn("text-base font-semibold", summaryAgeClass(deviceSummary.smart))}>
{formatDateTime(deviceSummary.smart?.collector_date)}
</p>
</div>
<div>
<p className="text-[11px] uppercase text-muted-foreground">{t("dashboard.devices.temp")}</p>
<p className="text-base font-semibold">
{formatTemperature(deviceSummary.smart?.temp, temperatureUnit)}
</p>
</div>
</div>

<div className="mt-3 flex items-center justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" onClick={(event) => event.stopPropagation()}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={(event) => event.preventDefault()}
onClick={handleNavigate}
className="cursor-pointer"
>
{t("nav.device")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => 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")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => event.preventDefault()}
onClick={() =>
onAction({
action: "delete",
wwn: deviceSummary.device.wwn,
label: title,
})
}
className="cursor-pointer"
>
{t("device.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}

return (
<Card className={`glass-panel ${failedEmphasis}`}>
<CardContent className="flex h-full flex-col gap-4 pt-6">
<Link
href={deviceHref(deviceSummary.device.wwn)}
className="relative flex flex-col gap-3 rounded-md transition-colors hover:bg-muted/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-muted text-muted-foreground"
title={protocol || t("common.unknown")}
>
<ProtocolIcon className="h-4 w-4" />
</span>
<h3 className="truncate text-lg font-semibold">{title}</h3>
{deviceSummary.device.archived && (
<Badge variant="outline">{t("dashboard.devices.archived")}</Badge>
)}
</div>
<p className="truncate text-xs text-muted-foreground">{deviceSummary.device.wwn}</p>
<div className="mt-2">
<StatusPill status={pillStatus} label={statusLabel} />
</div>
</div>
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t("header.actions")}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={(event) => event.preventDefault()} asChild className="cursor-pointer">
<Link href={deviceHref(deviceSummary.device.wwn)} onClick={(event) => event.stopPropagation()}>
{t("nav.device")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => 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")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation();
onAction({
action: "delete",
wwn: deviceSummary.device.wwn,
label: title,
});
}}
className="cursor-pointer"
>
{t("device.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Link>

<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-[11px] uppercase text-muted-foreground">{t("dashboard.devices.last_updated")}</p>
<p className={cn("text-base font-semibold", summaryAgeClass(deviceSummary.smart))}>
{formatDateTime(deviceSummary.smart?.collector_date)}
</p>
</div>
<div>
<p className="text-[11px] uppercase text-muted-foreground">{t("dashboard.devices.temp")}</p>
<p className="text-base font-semibold">
{formatTemperature(deviceSummary.smart?.temp, temperatureUnit)}
</p>
</div>
<div>
<p className="text-[11px] uppercase text-muted-foreground">{t("dashboard.devices.capacity")}</p>
<p className="text-sm text-muted-foreground">
{formatBytes(deviceSummary.device.capacity, settings?.file_size_si_units)}
</p>
</div>
<div>
<p className="text-[11px] uppercase text-muted-foreground">{t("dashboard.devices.power_on")}</p>
<p className="text-sm text-muted-foreground">
{formatPowerOnHours(deviceSummary.smart?.power_on_hours, settings?.powered_on_hours_unit)}
</p>
</div>
</div>
</CardContent>
</Card>
);
}
Loading