feat(web): redesign settings page, drop legacy webhook/email alerting UI
Server Deploy / deploy (push) Successful in 1m24s

This commit is contained in:
2026-07-21 14:40:36 +01:00
parent df6f8b6f62
commit f28ab1a741
+135 -286
View File
@@ -2,58 +2,40 @@
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";
import Link from "next/link";
import { api } from "@/lib/api";
import { Button, Card } 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,
function SectionCard({
title,
description,
enabled,
onChange,
icon,
children,
className,
}: {
label: string;
description: string;
enabled: boolean;
onChange: (v: boolean) => void;
title: string;
description?: string;
icon: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
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>
<Card className={className}>
<div className="mb-4 flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg border border-border bg-surface-2 text-accent">
{icon}
</div>
<div>
<h2 className="text-base font-semibold text-text-primary">{title}</h2>
{description && <p className="mt-0.5 text-sm text-text-secondary">{description}</p>}
</div>
</div>
<Toggle enabled={enabled} onChange={onChange} />
</div>
{children}
</Card>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
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>
@@ -63,23 +45,44 @@ function Field({
);
}
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";
function BellIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
);
}
function SecretsTokenCard({
tokenSet,
rotatedAt,
}: {
tokenSet: boolean;
rotatedAt?: string;
}) {
function ServerIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 17.25v-.228a4.5 4.5 0 00-.12-1.03l-2.268-9.64a3.375 3.375 0 00-3.285-2.602H7.923a3.375 3.375 0 00-3.285 2.602l-2.268 9.64a4.5 4.5 0 00-.12 1.03v.228m19.5 0a3 3 0 01-3 3H5.25a3 3 0 01-3-3m19.5 0a3 3 0 00-3-3H5.25a3 3 0 00-3 3m16.5 0h.008v.008h-.008v-.008zm-3 0h.008v.008h-.008v-.008z" />
</svg>
);
}
function DocumentIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
);
}
function KeyIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
);
}
function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedAt?: string }) {
const queryClient = useQueryClient();
const [token, setToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const readUrl =
typeof window !== "undefined"
? `${window.location.origin}/api/secrets/<group>/values`
: "/api/secrets/<group>/values";
typeof window !== "undefined" ? `${window.location.origin}/api/secrets/<group>/values` : "/api/secrets/<group>/values";
const { mutate: rotate, isPending } = useMutation({
mutationFn: api.rotateSecretsToken,
@@ -97,20 +100,18 @@ function SecretsTokenCard({
}
return (
<Card>
<CardHeader>
<CardTitle>Secrets Read Token (ESO)</CardTitle>
</CardHeader>
<p className="mb-5 text-sm text-text-secondary">
Kubernetes External Secrets Operator authenticates to the read endpoint with this bearer
token. Point your <span className="font-mono">ClusterSecretStore</span> at{" "}
<SectionCard
title="Secrets Read Token (ESO)"
description="Kubernetes External Secrets Operator authenticates to the read endpoint with this bearer token."
icon={<KeyIcon />}
>
<p className="mb-4 text-sm text-text-secondary">
Point your <span className="font-mono">ClusterSecretStore</span> at{" "}
<span className="font-mono text-text-primary">{readUrl}</span>.
</p>
<div className="mb-4 flex items-center gap-2 text-sm">
<span
className={`inline-block h-2 w-2 rounded-full ${tokenSet ? "bg-success" : "bg-text-tertiary"}`}
/>
<span className={`inline-block h-2 w-2 rounded-full ${tokenSet ? "bg-success" : "bg-text-tertiary"}`} />
<span className="text-text-secondary">
{tokenSet ? "A read token is configured" : "No read token configured yet"}
{tokenSet && rotatedAt && ` · rotated ${new Date(rotatedAt).toLocaleString()}`}
@@ -119,9 +120,7 @@ function SecretsTokenCard({
{token && (
<div className="mb-4 rounded-lg border border-warning/30 bg-warning/10 p-3">
<p className="mb-2 text-xs font-medium text-warning">
Copy this token now it will not be shown again.
</p>
<p className="mb-2 text-xs font-medium text-warning">Copy this token now it will not be shown again.</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-surface-2 px-2 py-1.5 font-mono text-xs text-text-primary">
{token}
@@ -141,60 +140,27 @@ function SecretsTokenCard({
Rotating invalidates the previous token. Update the Kubernetes secret afterwards.
</p>
)}
</Card>
</SectionCard>
);
}
export default function SettingsPage() {
const queryClient = useQueryClient();
const { data: settings, isLoading } = useQuery({
queryKey: ["settings"],
queryFn: api.getSettings,
});
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);
// Workflow log retention (days). 0 = keep forever.
const [logRetentionDays, setLogRetentionDays] = useState(30);
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);
setLogRetentionDays(settings.workflow_log_retention_days ?? 30);
}, [settings]);
const { mutate: save, isPending } = useMutation({
mutationFn: (payload: {
alerts: AlertSettings;
email: EmailSettings;
workflow_log_retention_days?: number | null;
}) => api.saveSettings(payload),
mutationFn: (payload: Parameters<typeof api.saveSettings>[0]) => api.saveSettings(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
setSaved(true);
@@ -204,27 +170,12 @@ export default function SettingsPage() {
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const toList = toAddrs
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (!settings) return;
// Preserve legacy alert/email values (managed via Notification Channels now);
// only the offline threshold and log retention are edited here.
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,
},
alerts: { ...settings.alerts, offline_threshold_minutes: thresholdMinutes },
email: settings.email,
workflow_log_retention_days: logRetentionDays,
});
}
@@ -239,181 +190,79 @@ export default function SettingsPage() {
return (
<div className="p-8">
<div className="mb-6">
<div className="mb-8">
<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>
<p className="mt-1 text-sm text-text-secondary">Configure monitoring, alerting, and integrations.</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 className="max-w-5xl space-y-6">
{/* Alerting — replaces the legacy webhook/email settings */}
<SectionCard
title="Alerting"
description="Alerts are now delivered through notification channels, triggered by service monitors."
icon={<BellIcon />}
>
<div className="flex flex-wrap gap-3">
<Link href="/settings/notifications">
<Button variant="secondary">Manage Notification Channels</Button>
</Link>
<Link href="/monitors">
<Button variant="ghost">View Monitors</Button>
</Link>
</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 className="mt-4 text-xs text-text-tertiary">
Webhook, email (SMTP), Discord, Slack, and Telegram destinations are configured under
Notification Channels and attached per monitor.
</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="">
</SectionCard>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SectionCard
title="Server Health"
description="When to consider an agent-backed server offline."
icon={<ServerIcon />}
>
<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"
value={smtpPort}
onChange={(e) => setSmtpPort(Number(e.target.value))}
placeholder="587"
className={inputClass}
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 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"
</SectionCard>
<SectionCard
title="Workflow Logs"
description="How long run logs are kept before automatic deletion."
icon={<DocumentIcon />}
>
<input
type="text"
value={toAddrs}
onChange={(e) => setToAddrs(e.target.value)}
placeholder="admin@example.com, ops@example.com"
className={inputClass}
/>
</Field>
<Field label="Log retention (days)" hint="0 = keep forever. Applies to per-run step output logs.">
<input
type="number"
min={0}
value={logRetentionDays}
onChange={(e) => setLogRetentionDays(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>
</SectionCard>
</div>
</Card>
{/* Workflow logs */}
<Card>
<CardHeader>
<CardTitle>Workflow Logs</CardTitle>
</CardHeader>
<p className="mb-5 text-sm text-text-secondary">
How long to keep workflow run logs on the server before they are
automatically deleted.
</p>
<Field
label="Log retention (days)"
hint="0 = keep forever. Applies to per-run step output logs."
>
<input
type="number"
min={0}
value={logRetentionDays}
onChange={(e) => setLogRetentionDays(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>
</Card>
<div className="mt-6 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 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 className="mt-6 max-w-xl">
<SecretsTokenCard
tokenSet={settings?.secrets?.read_token_set ?? false}
rotatedAt={settings?.secrets?.rotated_at}