feat: edit monitors + notification channels; HTTP monitor insecure-TLS option
This commit is contained in:
@@ -19,14 +19,15 @@ const CONFIG_FIELDS: Record<ChannelType, string[]> = {
|
||||
smtp: ["host", "port", "username", "password", "from", "to"],
|
||||
};
|
||||
|
||||
function ChannelForm({ onDone }: { onDone: () => void }) {
|
||||
function ChannelForm({ initial, onDone }: { initial?: NotificationChannel; onDone: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState<ChannelType>("webhook");
|
||||
const [config, setConfig] = useState<Record<string, string>>({});
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [type, setType] = useState<ChannelType>(initial?.type ?? "webhook");
|
||||
const [config, setConfig] = useState<Record<string, string>>(initial?.config ?? {});
|
||||
|
||||
const { mutate: create, isPending, error } = useMutation({
|
||||
mutationFn: (input: ChannelInput) => api.createChannel(input),
|
||||
const { mutate: submit, isPending, error } = useMutation({
|
||||
mutationFn: (input: ChannelInput) =>
|
||||
initial ? api.updateChannel(initial.channel_id, input) : api.createChannel(input).then(() => undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["channels"] });
|
||||
onDone();
|
||||
@@ -37,7 +38,7 @@ function ChannelForm({ onDone }: { onDone: () => void }) {
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
create({ name, type, config, enabled: true });
|
||||
submit({ name, type, config, enabled: initial?.enabled ?? true });
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
@@ -76,7 +77,7 @@ function ChannelForm({ onDone }: { onDone: () => void }) {
|
||||
{error && <p className="text-sm text-danger">{(error as Error).message}</p>}
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" variant="primary" loading={isPending}>
|
||||
Add Channel
|
||||
{initial ? "Save Changes" : "Add Channel"}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onDone}>
|
||||
Cancel
|
||||
@@ -89,6 +90,7 @@ function ChannelForm({ onDone }: { onDone: () => void }) {
|
||||
function ChannelRow({ ch }: { ch: NotificationChannel }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [testMsg, setTestMsg] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const { mutate: remove } = useMutation({
|
||||
mutationFn: () => api.deleteChannel(ch.channel_id),
|
||||
@@ -106,6 +108,14 @@ function ChannelRow({ ch }: { ch: NotificationChannel }) {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["channels"] }),
|
||||
});
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="border-b border-border p-4 last:border-0">
|
||||
<ChannelForm initial={ch} onDone={() => setEditing(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3 last:border-0">
|
||||
<div>
|
||||
@@ -120,6 +130,9 @@ function ChannelRow({ ch }: { ch: NotificationChannel }) {
|
||||
<Button variant="ghost" size="sm" loading={testing} onClick={() => test()}>
|
||||
Test
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => toggle(!ch.enabled)}>
|
||||
{ch.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
|
||||
+200
-229
@@ -6,268 +6,239 @@ import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button, Card } from "@/components/ui";
|
||||
|
||||
function SectionCard({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
function SectionCard({ title, description, icon, children, className }: { title: string; description?: string; icon: React.ReactNode; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<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>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
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 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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
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";
|
||||
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";
|
||||
|
||||
const { mutate: rotate, isPending } = useMutation({
|
||||
mutationFn: api.rotateSecretsToken,
|
||||
onSuccess: (res) => {
|
||||
setToken(res.token);
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
});
|
||||
const { mutate: rotate, isPending } = useMutation({
|
||||
mutationFn: api.rotateSecretsToken,
|
||||
onSuccess: (res) => {
|
||||
setToken(res.token);
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
});
|
||||
|
||||
async function copy() {
|
||||
if (!token) return;
|
||||
await navigator.clipboard.writeText(token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
async function copy() {
|
||||
if (!token) return;
|
||||
await navigator.clipboard.writeText(token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
return (
|
||||
<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="text-text-secondary">
|
||||
{tokenSet ? "A read token is configured" : "No read token configured yet"}
|
||||
{tokenSet && rotatedAt && ` · rotated ${new Date(rotatedAt).toLocaleString()}`}
|
||||
</span>
|
||||
</div>
|
||||
<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="text-text-secondary">
|
||||
{tokenSet ? "A read token is configured" : "No read token configured yet"}
|
||||
{tokenSet && rotatedAt && ` · rotated ${new Date(rotatedAt).toLocaleString()}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
<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}
|
||||
</code>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={copy}>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
{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>
|
||||
<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}</code>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={copy}>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="button" variant="primary" loading={isPending} onClick={() => rotate()}>
|
||||
{tokenSet ? "Rotate Token" : "Generate Token"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="button" variant="primary" loading={isPending} onClick={() => rotate()}>
|
||||
{tokenSet ? "Rotate Token" : "Generate Token"}
|
||||
</Button>
|
||||
{tokenSet && (
|
||||
<p className="mt-2 text-xs text-text-tertiary">
|
||||
Rotating invalidates the previous token. Update the Kubernetes secret afterwards.
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
{tokenSet && <p className="mt-2 text-xs text-text-tertiary">Rotating invalidates the previous token. Update the Kubernetes secret afterwards.</p>}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings, isLoading } = useQuery({ queryKey: ["settings"], queryFn: api.getSettings });
|
||||
const { data: settings, isLoading } = useQuery({ queryKey: ["settings"], queryFn: api.getSettings });
|
||||
|
||||
const [thresholdMinutes, setThresholdMinutes] = useState(5);
|
||||
const [logRetentionDays, setLogRetentionDays] = useState(30);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [thresholdMinutes, setThresholdMinutes] = useState(5);
|
||||
const [logRetentionDays, setLogRetentionDays] = useState(30);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setThresholdMinutes(settings.alerts.offline_threshold_minutes || 5);
|
||||
setLogRetentionDays(settings.workflow_log_retention_days ?? 30);
|
||||
}, [settings]);
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setThresholdMinutes(settings.alerts.offline_threshold_minutes || 5);
|
||||
setLogRetentionDays(settings.workflow_log_retention_days ?? 30);
|
||||
}, [settings]);
|
||||
|
||||
const { mutate: save, isPending } = useMutation({
|
||||
mutationFn: (payload: Parameters<typeof api.saveSettings>[0]) => api.saveSettings(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
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: { ...settings.alerts, offline_threshold_minutes: thresholdMinutes },
|
||||
email: settings.email,
|
||||
workflow_log_retention_days: logRetentionDays,
|
||||
const { mutate: save, isPending } = useMutation({
|
||||
mutationFn: (payload: Parameters<typeof api.saveSettings>[0]) => api.saveSettings(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
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: { ...settings.alerts, offline_threshold_minutes: thresholdMinutes },
|
||||
email: settings.email,
|
||||
workflow_log_retention_days: logRetentionDays,
|
||||
});
|
||||
}
|
||||
|
||||
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="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>
|
||||
<div className="p-8">
|
||||
<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 monitoring, alerting, and integrations.</p>
|
||||
</div>
|
||||
|
||||
<div className="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>
|
||||
<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>
|
||||
</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"
|
||||
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>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Workflow Logs" description="How long run logs are kept before automatic deletion." icon={<DocumentIcon />}>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<SecretsTokenCard tokenSet={settings?.secrets?.read_token_set ?? false} rotatedAt={settings?.secrets?.rotated_at} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<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 monitoring, alerting, and integrations.</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</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"
|
||||
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>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Workflow Logs"
|
||||
description="How long run logs are kept before automatic deletion."
|
||||
icon={<DocumentIcon />}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<SecretsTokenCard
|
||||
tokenSet={settings?.secrets?.read_token_set ?? false}
|
||||
rotatedAt={settings?.secrets?.rotated_at}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user