This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, AuditEvent } from "@/lib/api";
|
||||
import { Card } from "@/components/ui";
|
||||
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
"server.created": "Server Created",
|
||||
"server.deleted": "Server Deleted",
|
||||
"server.offline": "Server Offline",
|
||||
"key.uploaded": "Key Uploaded",
|
||||
"key.deleted": "Key Deleted",
|
||||
"key.assigned": "Key Assigned",
|
||||
"key.revoked": "Key Revoked",
|
||||
"key.generation_dispatched": "Key Generation",
|
||||
"agent.update_dispatched": "Agent Updated",
|
||||
"updates.applied": "Updates Applied",
|
||||
"settings.updated": "Settings Updated",
|
||||
};
|
||||
|
||||
const EVENT_COLOURS: Record<string, string> = {
|
||||
"server.offline": "text-danger",
|
||||
"server.deleted": "text-danger",
|
||||
"key.deleted": "text-danger",
|
||||
"key.revoked": "text-warning",
|
||||
"server.created": "text-success",
|
||||
"key.uploaded": "text-success",
|
||||
"key.assigned": "text-success",
|
||||
};
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
function EventTypeBadge({ type }: { type: string }) {
|
||||
const label = EVENT_LABELS[type] ?? type;
|
||||
const colour = EVENT_COLOURS[type] ?? "text-text-secondary";
|
||||
return (
|
||||
<span className={`font-mono text-xs font-medium ${colour}`}>{label}</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuditPage() {
|
||||
const { data: events, isLoading, error } = useQuery({
|
||||
queryKey: ["audit"],
|
||||
queryFn: () => api.listAuditEvents(200),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Audit Log</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
All administrative actions and server status changes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card padding={false}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-20 text-center text-danger">Failed to load audit log.</div>
|
||||
) : events && events.length > 0 ? (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Time</Th>
|
||||
<Th>Event</Th>
|
||||
<Th>Actor</Th>
|
||||
<Th>Details</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{events.map((e: AuditEvent) => (
|
||||
<Tr key={e.id}>
|
||||
<Td>
|
||||
<span className="whitespace-nowrap font-mono text-xs text-text-secondary">
|
||||
{formatDate(e.created_at)}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<EventTypeBadge type={e.event_type} />
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-sm text-text-primary">{e.actor}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-sm text-text-secondary">{e.details}</span>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="py-20 text-center">
|
||||
<p className="text-text-secondary text-sm">No audit events recorded yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, AlertSettings, EmailSettings } from "@/lib/api";
|
||||
import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
|
||||
|
||||
function Toggle({ enabled, onChange }: { enabled: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!enabled)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none ${
|
||||
enabled ? "bg-accent" : "bg-surface-2 border border-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
||||
enabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
description,
|
||||
enabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border border-border bg-surface-2 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">{label}</p>
|
||||
<p className="text-xs text-text-secondary">{description}</p>
|
||||
</div>
|
||||
<Toggle enabled={enabled} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
|
||||
{children}
|
||||
{hint && <p className="mt-1 text-xs text-text-tertiary">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: api.getSettings,
|
||||
});
|
||||
|
||||
// Webhook / offline alerting state
|
||||
const [alertsEnabled, setAlertsEnabled] = useState(false);
|
||||
const [webhookURL, setWebhookURL] = useState("");
|
||||
const [thresholdMinutes, setThresholdMinutes] = useState(5);
|
||||
|
||||
// Email state
|
||||
const [emailEnabled, setEmailEnabled] = useState(false);
|
||||
const [smtpHost, setSmtpHost] = useState("");
|
||||
const [smtpPort, setSmtpPort] = useState(587);
|
||||
const [smtpUser, setSmtpUser] = useState("");
|
||||
const [smtpPass, setSmtpPass] = useState("");
|
||||
const [fromAddr, setFromAddr] = useState("");
|
||||
const [toAddrs, setToAddrs] = useState(""); // comma-separated in UI
|
||||
const [useTLS, setUseTLS] = useState(false);
|
||||
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setAlertsEnabled(settings.alerts.enabled);
|
||||
setWebhookURL(settings.alerts.webhook_url ?? "");
|
||||
setThresholdMinutes(settings.alerts.offline_threshold_minutes || 5);
|
||||
setEmailEnabled(settings.email?.enabled ?? false);
|
||||
setSmtpHost(settings.email?.smtp_host ?? "");
|
||||
setSmtpPort(settings.email?.smtp_port || 587);
|
||||
setSmtpUser(settings.email?.username ?? "");
|
||||
setSmtpPass(settings.email?.password ?? "");
|
||||
setFromAddr(settings.email?.from_addr ?? "");
|
||||
setToAddrs((settings.email?.to_addrs ?? []).join(", "));
|
||||
setUseTLS(settings.email?.use_tls ?? false);
|
||||
}, [settings]);
|
||||
|
||||
const { mutate: save, isPending } = useMutation({
|
||||
mutationFn: (payload: { alerts: AlertSettings; email: EmailSettings }) =>
|
||||
api.saveSettings(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const toList = toAddrs
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
save({
|
||||
alerts: {
|
||||
enabled: alertsEnabled,
|
||||
webhook_url: webhookURL,
|
||||
offline_threshold_minutes: thresholdMinutes,
|
||||
},
|
||||
email: {
|
||||
enabled: emailEnabled,
|
||||
smtp_host: smtpHost,
|
||||
smtp_port: smtpPort,
|
||||
username: smtpUser,
|
||||
password: smtpPass,
|
||||
from_addr: fromAddr,
|
||||
to_addrs: toList,
|
||||
use_tls: useTLS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Settings</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Configure alerting and monitoring behaviour</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="max-w-xl space-y-6">
|
||||
{/* Webhook alerting */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Webhook Alerting</CardTitle>
|
||||
</CardHeader>
|
||||
<p className="mb-5 text-sm text-text-secondary">
|
||||
POST a JSON payload to a URL when a server goes offline. Compatible with Slack,
|
||||
Discord, n8n, and any service that accepts JSON.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<ToggleRow
|
||||
label="Enable webhook alerts"
|
||||
description="Webhook fires only when this is on"
|
||||
enabled={alertsEnabled}
|
||||
onChange={setAlertsEnabled}
|
||||
/>
|
||||
<Field
|
||||
label="Webhook URL"
|
||||
hint={`POST body: { event, hostname, server_id, ip_address, timestamp, message }`}
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
value={webhookURL}
|
||||
onChange={(e) => setWebhookURL(e.target.value)}
|
||||
placeholder="https://hooks.slack.com/... or https://discord.com/api/webhooks/..."
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Offline threshold (minutes)"
|
||||
hint="How long a server must be silent before being marked offline. Agents poll every 30s, so 5 minutes is a safe minimum."
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={thresholdMinutes}
|
||||
onChange={(e) => setThresholdMinutes(Number(e.target.value))}
|
||||
className="w-32 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Email alerting */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Notifications</CardTitle>
|
||||
</CardHeader>
|
||||
<p className="mb-5 text-sm text-text-secondary">
|
||||
Send an email when a server goes offline. Uses the same offline threshold as the
|
||||
webhook setting above.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<ToggleRow
|
||||
label="Enable email alerts"
|
||||
description="Emails are only sent when this is on"
|
||||
enabled={emailEnabled}
|
||||
onChange={setEmailEnabled}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field label="SMTP Host" hint="">
|
||||
<input
|
||||
type="text"
|
||||
value={smtpHost}
|
||||
onChange={(e) => setSmtpHost(e.target.value)}
|
||||
placeholder="smtp.gmail.com"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Port" hint="">
|
||||
<input
|
||||
type="number"
|
||||
value={smtpPort}
|
||||
onChange={(e) => setSmtpPort(Number(e.target.value))}
|
||||
placeholder="587"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex flex-col justify-center pt-5">
|
||||
<ToggleRow
|
||||
label="TLS (port 465)"
|
||||
description="Use implicit TLS instead of STARTTLS"
|
||||
enabled={useTLS}
|
||||
onChange={(v) => {
|
||||
setUseTLS(v);
|
||||
setSmtpPort(v ? 465 : 587);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Username">
|
||||
<input
|
||||
type="text"
|
||||
value={smtpUser}
|
||||
onChange={(e) => setSmtpUser(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
className={inputClass}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Password">
|
||||
<input
|
||||
type="password"
|
||||
value={smtpPass}
|
||||
onChange={(e) => setSmtpPass(e.target.value)}
|
||||
placeholder="App password or SMTP password"
|
||||
className={inputClass}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="From address">
|
||||
<input
|
||||
type="email"
|
||||
value={fromAddr}
|
||||
onChange={(e) => setFromAddr(e.target.value)}
|
||||
placeholder="vantage@example.com"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="To addresses"
|
||||
hint="Separate multiple addresses with commas"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={toAddrs}
|
||||
onChange={(e) => setToAddrs(e.target.value)}
|
||||
placeholder="admin@example.com, ops@example.com"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" variant="primary" loading={isPending}>
|
||||
{saved ? "Saved!" : "Save Settings"}
|
||||
</Button>
|
||||
{saved && <span className="text-sm text-success">Settings saved successfully.</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user