first commit

This commit is contained in:
mega 2026-03-19 11:32:17 +00:00
commit 4b98219bf7
144 changed files with 31561 additions and 0 deletions

View file

@ -0,0 +1,468 @@
"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>
);
}