468 lines
19 KiB
TypeScript
468 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
Settings, Database, Bell, Globe, Sliders, Plug,
|
|
Save, CheckCircle2, AlertTriangle,
|
|
} from "lucide-react";
|
|
import {
|
|
fetchSiteSettings, updateSiteSettings,
|
|
fetchNotifications, updateNotifications,
|
|
fetchIntegrations, updateIntegrations,
|
|
fetchPagePrefs, updatePagePrefs,
|
|
type SiteSettings, type NotificationSettings,
|
|
type IntegrationSettings, type PagePrefs,
|
|
type SensorDevice,
|
|
} from "@/lib/api";
|
|
import { SensorTable } from "@/components/settings/SensorTable";
|
|
import { SensorSheet } from "@/components/settings/SensorSheet";
|
|
import { SensorDetailSheet } from "@/components/settings/SensorDetailSheet";
|
|
import { ThresholdEditor } from "@/components/settings/ThresholdEditor";
|
|
|
|
const SITE_ID = "sg-01";
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
|
|
function FieldGroup({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">{label}</label>
|
|
{children}
|
|
{hint && <p className="text-[10px] text-muted-foreground">{hint}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TextInput({ value, onChange, disabled, placeholder, type = "text" }: {
|
|
value: string; onChange?: (v: string) => void; disabled?: boolean; placeholder?: string; type?: string;
|
|
}) {
|
|
return (
|
|
<input
|
|
type={type}
|
|
value={value}
|
|
disabled={disabled}
|
|
placeholder={placeholder}
|
|
onChange={e => onChange?.(e.target.value)}
|
|
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function NumberInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
|
return (
|
|
<input
|
|
type="number"
|
|
value={value}
|
|
min={min}
|
|
max={max}
|
|
onChange={e => onChange(Number(e.target.value))}
|
|
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
role="switch"
|
|
aria-checked={checked}
|
|
onClick={() => onChange(!checked)}
|
|
className={cn(
|
|
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
|
|
checked ? "bg-primary" : "bg-muted",
|
|
)}
|
|
>
|
|
<span className={cn(
|
|
"inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform",
|
|
checked ? "translate-x-4" : "translate-x-0",
|
|
)} />
|
|
</button>
|
|
{label && <span className="text-sm">{label}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SaveButton({ saving, saved, onClick }: { saving: boolean; saved: boolean; onClick: () => void }) {
|
|
return (
|
|
<Button size="sm" onClick={onClick} disabled={saving} className="gap-2">
|
|
{saving ? (
|
|
<span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
) : saved ? (
|
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
|
) : (
|
|
<Save className="w-3.5 h-3.5" />
|
|
)}
|
|
{saving ? "Saving..." : saved ? "Saved" : "Save Changes"}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function SectionCard({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3 border-b border-border/30">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
|
{action}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-4 space-y-4">
|
|
{children}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ── Site Tab ───────────────────────────────────────────────────────────────────
|
|
|
|
function SiteTab() {
|
|
const [form, setForm] = useState<SiteSettings>({ name: "", timezone: "", description: "" });
|
|
const [saving, setSaving] = useState(false);
|
|
const [saved, setSaved] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchSiteSettings(SITE_ID).then(setForm).catch(() => {});
|
|
}, []);
|
|
|
|
const save = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const updated = await updateSiteSettings(SITE_ID, form);
|
|
setForm(updated);
|
|
setSaved(true);
|
|
setTimeout(() => setSaved(false), 3000);
|
|
} catch { /* ignore */ }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const TIMEZONES = [
|
|
"Asia/Singapore", "Asia/Tokyo", "Asia/Hong_Kong", "Asia/Kuala_Lumpur",
|
|
"Europe/London", "Europe/Paris", "America/New_York", "America/Los_Angeles",
|
|
"UTC",
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-4 max-w-xl">
|
|
<SectionCard title="Site Information">
|
|
<FieldGroup label="Site Name">
|
|
<TextInput value={form.name} onChange={v => setForm(f => ({ ...f, name: v }))} />
|
|
</FieldGroup>
|
|
<FieldGroup label="Description">
|
|
<TextInput value={form.description} onChange={v => setForm(f => ({ ...f, description: v }))} placeholder="Optional description" />
|
|
</FieldGroup>
|
|
<FieldGroup label="Timezone">
|
|
<select
|
|
value={form.timezone}
|
|
onChange={e => setForm(f => ({ ...f, timezone: e.target.value }))}
|
|
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
|
>
|
|
{TIMEZONES.map(tz => <option key={tz} value={tz}>{tz}</option>)}
|
|
</select>
|
|
</FieldGroup>
|
|
<div className="pt-2">
|
|
<SaveButton saving={saving} saved={saved} onClick={save} />
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard title="Site Overview">
|
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
|
{[
|
|
{ label: "Site ID", value: SITE_ID },
|
|
{ label: "Halls", value: "2 (Hall A, Hall B)" },
|
|
{ label: "Total Racks", value: "80 (40 per hall)" },
|
|
{ label: "UPS Units", value: "2" },
|
|
{ label: "Generators", value: "1" },
|
|
{ label: "CRAC Units", value: "2" },
|
|
].map(({ label, value }) => (
|
|
<div key={label}>
|
|
<p className="text-muted-foreground">{label}</p>
|
|
<p className="font-medium mt-0.5">{value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SectionCard>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Sensors Tab ────────────────────────────────────────────────────────────────
|
|
|
|
function SensorsTab() {
|
|
const [editingSensor, setEditingSensor] = useState<SensorDevice | null>(null);
|
|
const [addOpen, setAddOpen] = useState(false);
|
|
const [detailId, setDetailId] = useState<number | null>(null);
|
|
const [tableKey, setTableKey] = useState(0); // force re-render after save
|
|
|
|
const handleSaved = () => {
|
|
setAddOpen(false);
|
|
setEditingSensor(null);
|
|
setTableKey(k => k + 1);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="text-sm text-muted-foreground">
|
|
Device-level sensor registry. Each device can be enabled/disabled, and protocol configuration is stored for all supported connection types. Currently only MQTT is active.
|
|
</div>
|
|
|
|
<SensorTable
|
|
key={tableKey}
|
|
onAdd={() => setAddOpen(true)}
|
|
onEdit={s => setEditingSensor(s)}
|
|
onDetail={id => setDetailId(id)}
|
|
/>
|
|
|
|
<SensorSheet
|
|
siteId={SITE_ID}
|
|
sensor={editingSensor}
|
|
open={addOpen || !!editingSensor}
|
|
onClose={() => { setAddOpen(false); setEditingSensor(null); }}
|
|
onSaved={handleSaved}
|
|
/>
|
|
|
|
<SensorDetailSheet
|
|
sensorId={detailId}
|
|
onClose={() => setDetailId(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Notifications Tab ──────────────────────────────────────────────────────────
|
|
|
|
function NotificationsTab() {
|
|
const [form, setForm] = useState<NotificationSettings>({
|
|
critical_alarms: true, warning_alarms: true,
|
|
generator_events: true, maintenance_reminders: true,
|
|
webhook_url: "", email_recipients: "",
|
|
});
|
|
const [saving, setSaving] = useState(false);
|
|
const [saved, setSaved] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchNotifications(SITE_ID).then(setForm).catch(() => {});
|
|
}, []);
|
|
|
|
const save = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const updated = await updateNotifications(SITE_ID, form);
|
|
setForm(updated);
|
|
setSaved(true);
|
|
setTimeout(() => setSaved(false), 3000);
|
|
} catch { /* ignore */ }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const toggles: { key: keyof NotificationSettings; label: string; desc: string }[] = [
|
|
{ key: "critical_alarms", label: "Critical alarms", desc: "Notify on all critical severity alarms" },
|
|
{ key: "warning_alarms", label: "Warning alarms", desc: "Notify on warning severity alarms" },
|
|
{ key: "generator_events", label: "Generator events", desc: "Generator start, stop, and fault events" },
|
|
{ key: "maintenance_reminders", label: "Maintenance reminders", desc: "Upcoming scheduled maintenance windows" },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-4 max-w-xl">
|
|
<SectionCard title="Alarm Notifications">
|
|
<div className="space-y-4">
|
|
{toggles.map(({ key, label, desc }) => (
|
|
<div key={key} className="flex items-center justify-between gap-4">
|
|
<div>
|
|
<p className="text-sm font-medium">{label}</p>
|
|
<p className="text-xs text-muted-foreground">{desc}</p>
|
|
</div>
|
|
<Toggle
|
|
checked={form[key] as boolean}
|
|
onChange={v => setForm(f => ({ ...f, [key]: v }))}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard title="Delivery Channels">
|
|
<FieldGroup label="Webhook URL" hint="POST request sent on each new alarm — leave blank to disable">
|
|
<TextInput
|
|
value={form.webhook_url}
|
|
onChange={v => setForm(f => ({ ...f, webhook_url: v }))}
|
|
placeholder="https://hooks.example.com/alarm"
|
|
type="url"
|
|
/>
|
|
</FieldGroup>
|
|
<FieldGroup label="Email Recipients" hint="Comma-separated email addresses">
|
|
<TextInput
|
|
value={form.email_recipients}
|
|
onChange={v => setForm(f => ({ ...f, email_recipients: v }))}
|
|
placeholder="ops@example.com, oncall@example.com"
|
|
/>
|
|
</FieldGroup>
|
|
</SectionCard>
|
|
|
|
<div>
|
|
<SaveButton saving={saving} saved={saved} onClick={save} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Page Preferences Tab ───────────────────────────────────────────────────────
|
|
|
|
function PagePrefsTab() {
|
|
const [form, setForm] = useState<PagePrefs>({ default_time_range_hours: 6, refresh_interval_seconds: 30 });
|
|
const [saving, setSaving] = useState(false);
|
|
const [saved, setSaved] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchPagePrefs(SITE_ID).then(setForm).catch(() => {});
|
|
}, []);
|
|
|
|
const save = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const updated = await updatePagePrefs(SITE_ID, form);
|
|
setForm(updated);
|
|
setSaved(true);
|
|
setTimeout(() => setSaved(false), 3000);
|
|
} catch { /* ignore */ }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4 max-w-xl">
|
|
<SectionCard title="Dashboard Defaults">
|
|
<FieldGroup label="Default Time Range" hint="Used by charts on all pages as the initial view">
|
|
<select
|
|
value={form.default_time_range_hours}
|
|
onChange={e => setForm(f => ({ ...f, default_time_range_hours: Number(e.target.value) }))}
|
|
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
|
>
|
|
{[1, 3, 6, 12, 24].map(h => <option key={h} value={h}>{h} hour{h !== 1 ? "s" : ""}</option>)}
|
|
</select>
|
|
</FieldGroup>
|
|
<FieldGroup label="Auto-refresh Interval" hint="How often pages poll for new data">
|
|
<select
|
|
value={form.refresh_interval_seconds}
|
|
onChange={e => setForm(f => ({ ...f, refresh_interval_seconds: Number(e.target.value) }))}
|
|
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
|
>
|
|
{[10, 15, 30, 60, 120].map(s => <option key={s} value={s}>Every {s} seconds</option>)}
|
|
</select>
|
|
</FieldGroup>
|
|
<div className="pt-2">
|
|
<SaveButton saving={saving} saved={saved} onClick={save} />
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<div className="rounded-lg border border-border/30 bg-muted/10 px-4 py-3 text-xs text-muted-foreground">
|
|
Per-page configuration (visible panels, default overlays, etc.) is coming in a future update.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Integrations Tab ───────────────────────────────────────────────────────────
|
|
|
|
function IntegrationsTab() {
|
|
const [form, setForm] = useState<IntegrationSettings>({ mqtt_host: "mqtt", mqtt_port: 1883 });
|
|
const [saving, setSaving] = useState(false);
|
|
const [saved, setSaved] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchIntegrations(SITE_ID).then(setForm).catch(() => {});
|
|
}, []);
|
|
|
|
const save = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const updated = await updateIntegrations(SITE_ID, form);
|
|
setForm(updated);
|
|
setSaved(true);
|
|
setTimeout(() => setSaved(false), 3000);
|
|
} catch { /* ignore */ }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const future = [
|
|
{ name: "Slack", desc: "Post alarm notifications to a Slack channel" },
|
|
{ name: "PagerDuty", desc: "Create incidents for critical alarms" },
|
|
{ name: "Email SMTP", desc: "Send alarm emails via custom SMTP server" },
|
|
{ name: "Syslog", desc: "Forward alarms to syslog / SIEM" },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-4 max-w-xl">
|
|
<SectionCard title="MQTT Broker">
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<FieldGroup label="Host" hint="Broker hostname or IP">
|
|
<TextInput value={form.mqtt_host} onChange={v => setForm(f => ({ ...f, mqtt_host: v }))} />
|
|
</FieldGroup>
|
|
<FieldGroup label="Port">
|
|
<NumberInput value={form.mqtt_port} onChange={v => setForm(f => ({ ...f, mqtt_port: v }))} min={1} max={65535} />
|
|
</FieldGroup>
|
|
</div>
|
|
<div className="rounded-md bg-amber-500/5 border border-amber-500/20 px-3 py-2 text-xs text-amber-400 flex items-start gap-2">
|
|
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
|
Changing the MQTT broker requires a backend restart to take effect.
|
|
</div>
|
|
<div>
|
|
<SaveButton saving={saving} saved={saved} onClick={save} />
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard title="Future Integrations">
|
|
<div className="grid gap-2">
|
|
{future.map(({ name, desc }) => (
|
|
<div key={name} className="flex items-center justify-between rounded-lg border border-border/30 bg-muted/10 px-3 py-2.5">
|
|
<div>
|
|
<p className="text-sm font-medium">{name}</p>
|
|
<p className="text-xs text-muted-foreground">{desc}</p>
|
|
</div>
|
|
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
|
|
Coming soon
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SectionCard>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Page ───────────────────────────────────────────────────────────────────────
|
|
|
|
export default function SettingsPage() {
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
<div>
|
|
<h1 className="text-xl font-semibold flex items-center gap-2">
|
|
<Settings className="w-5 h-5 text-primary" />
|
|
Settings
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground">Site-wide configuration for Singapore DC01</p>
|
|
</div>
|
|
|
|
<Tabs defaultValue="site">
|
|
<TabsList className="h-9 mb-6">
|
|
<TabsTrigger value="site" className="gap-1.5 text-xs px-3"><Globe className="w-3.5 h-3.5" />Site</TabsTrigger>
|
|
<TabsTrigger value="sensors" className="gap-1.5 text-xs px-3"><Database className="w-3.5 h-3.5" />Sensors</TabsTrigger>
|
|
<TabsTrigger value="thresholds" className="gap-1.5 text-xs px-3"><Sliders className="w-3.5 h-3.5" />Thresholds</TabsTrigger>
|
|
<TabsTrigger value="notifications" className="gap-1.5 text-xs px-3"><Bell className="w-3.5 h-3.5" />Notifications</TabsTrigger>
|
|
<TabsTrigger value="page-prefs" className="gap-1.5 text-xs px-3"><Settings className="w-3.5 h-3.5" />Page Prefs</TabsTrigger>
|
|
<TabsTrigger value="integrations" className="gap-1.5 text-xs px-3"><Plug className="w-3.5 h-3.5" />Integrations</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="site"> <SiteTab /> </TabsContent>
|
|
<TabsContent value="sensors"> <SensorsTab /> </TabsContent>
|
|
<TabsContent value="thresholds"> <ThresholdEditor siteId={SITE_ID} /> </TabsContent>
|
|
<TabsContent value="notifications"> <NotificationsTab /> </TabsContent>
|
|
<TabsContent value="page-prefs"> <PagePrefsTab /> </TabsContent>
|
|
<TabsContent value="integrations"> <IntegrationsTab /> </TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|