first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
468
frontend/app/(dashboard)/settings/page.tsx
Normal file
468
frontend/app/(dashboard)/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue