Add the Secrets and Alerts screens

Two backends that had no way in: secrets could only be set through the API,
and alerting could only be configured by hand-editing its file. Both are
engine-wide operator settings rather than personal ones, so they get sidebar
entries of their own instead of tabs under the per-user Settings — and the
missing-secret error now names the place the page actually is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
2026-08-16 16:36:41 +02:00
co-authored by Claude Fable 5
parent f239c884b6
commit 45392d31d9
5 changed files with 798 additions and 100 deletions
+451
View File
@@ -0,0 +1,451 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { Plus, Send, Trash2 } from "lucide-react"
import { useState } from "react"
import { type AlertsConfig, AlertsService, type Channel } from "@/client"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
export const Route = createFileRoute("/_layout/alerts")({
component: Alerts,
head: () => ({
meta: [
{
title: "Alerts - Fluksio",
},
],
}),
})
const alertsKey = ["alerts", "config"]
const SECTION =
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
/** What the engine can alert on. Mirrors `ALERTING_EVENTS` in the backend; a
* rule with none of them ticked still covers everything, including any event
* added there later. */
const EVENTS: [string, string][] = [
["node_error", "A node failed"],
["node_health", "A connection dropped"],
["flow_quarantined", "A flow was quarantined"],
["task_crashed", "A background task crashed"],
["engine_degraded", "The engine is struggling"],
["cascade_dropped", "Work was given up on"],
["queue_unavailable", "The queue is unreachable"],
]
/** The settings each kind of channel needs, in the order they read best. */
const FIELDS: Record<Channel["kind"], [string, string, string][]> = {
ntfy: [
["server", "Server", "https://ntfy.sh"],
["topic", "Topic", "your-topic"],
["token", "Token", "only for a protected topic"],
],
smtp: [["to", "Send to", "someone@example.com"]],
webhook: [["url", "URL", "https://example.com/hook"]],
}
const KINDS: Channel["kind"][] = ["ntfy", "smtp", "webhook"]
/** A setting may hold a `{"$secret": "name"}` reference rather than a literal,
* so text that parses as JSON is stored as JSON and survives a round trip. */
function parseSetting(text: string): unknown {
if (text.startsWith("{")) {
try {
return JSON.parse(text)
} catch {
return text
}
}
return text
}
function showSetting(value: unknown): string {
if (value === undefined || value === null) return ""
return typeof value === "string" ? value : JSON.stringify(value)
}
function Alerts() {
const { data } = useQuery({
queryKey: alertsKey,
queryFn: () => AlertsService.readAlertsConfig(),
})
return (
<div className="grid gap-6">
<div className="grid gap-1">
<h1 className="text-2xl">Alerts</h1>
<p className="text-sm text-muted-foreground">
Where the engine goes when something breaks. A channel is somewhere to
send to; a rule says which failures go to which channels. The same
fault repeating is held back until its cooldown passes.
</p>
</div>
{data ? <AlertsForm initial={data} /> : null}
</div>
)
}
function AlertsForm({ initial }: { initial: AlertsConfig }) {
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const [draft, setDraft] = useState<AlertsConfig>(initial)
const [saved, setSaved] = useState<AlertsConfig>(initial)
const save = useMutation({
mutationFn: (config: AlertsConfig) =>
AlertsService.saveAlertsConfig({ requestBody: config }),
onSuccess: (config) => {
showSuccessToast("Alerting updated")
setDraft(config)
setSaved(config)
queryClient.invalidateQueries({ queryKey: alertsKey })
},
onError: handleError.bind(showErrorToast),
})
const test = useMutation({
mutationFn: (channelName: string) =>
AlertsService.testChannel({ channelName }),
onSuccess: (message) => showSuccessToast(message.message),
onError: handleError.bind(showErrorToast),
})
const channels = draft.channels ?? []
const rules = draft.rules ?? []
const dirty = JSON.stringify(draft) !== JSON.stringify(saved)
const patchChannel = (index: number, patch: Partial<Channel>) =>
setDraft({
...draft,
channels: channels.map((channel, i) =>
i === index ? { ...channel, ...patch } : channel,
),
})
return (
<>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card p-4 shadow-e1">
<div className="grid gap-0.5">
<span className="text-sm font-medium">Alerting</span>
<span className="text-sm text-muted-foreground">
{draft.enabled
? "Failures reach the channels below."
: "Switched off — failures only reach the log."}
</span>
</div>
<Switch
checked={draft.enabled ?? true}
aria-label="Alerting enabled"
data-testid="alerts-enabled"
onCheckedChange={(enabled) => setDraft({ ...draft, enabled })}
/>
</div>
<section className="grid gap-3">
<div className="flex items-center justify-between">
<h2 className={SECTION}>Channels</h2>
<Button
variant="ghost"
size="sm"
data-testid="add-channel"
onClick={() =>
setDraft({
...draft,
channels: [
...channels,
{
name: `channel-${channels.length + 1}`,
kind: "ntfy",
enabled: true,
config: {},
},
],
})
}
>
<Plus />
Add channel
</Button>
</div>
{channels.length === 0 ? (
<p className="text-sm text-muted-foreground">
No channels yet. Add one to have somewhere to send to.
</p>
) : (
channels.map((channel, index) => (
<div
// Names are what rules point at, but they are edited here, so the
// position is the only thing that stays put while typing.
key={index}
className="grid gap-3 rounded-lg border border-border bg-card p-4 shadow-e1"
data-testid="channel-card"
>
<div className="flex flex-wrap items-center gap-2">
<Input
value={channel.name}
aria-label="Channel name"
className="max-w-48"
onChange={(event) =>
patchChannel(index, { name: event.target.value })
}
/>
<Select
value={channel.kind}
onValueChange={(kind) =>
patchChannel(index, { kind: kind as Channel["kind"] })
}
>
<SelectTrigger className="w-32" aria-label="Channel kind">
<SelectValue />
</SelectTrigger>
<SelectContent>
{KINDS.map((kind) => (
<SelectItem key={kind} value={kind}>
{kind}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2">
<Switch
checked={channel.enabled ?? true}
aria-label={`${channel.name} enabled`}
onCheckedChange={(enabled) =>
patchChannel(index, { enabled })
}
/>
<Button
variant="ghost"
size="sm"
disabled={dirty || test.isPending}
title={
dirty
? "Save first — the engine sends through the stored channel"
: "Send one alert through this channel"
}
data-testid="test-channel"
onClick={() => test.mutate(channel.name)}
>
<Send />
Test
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove ${channel.name}`}
className="text-destructive hover:text-destructive"
onClick={() =>
setDraft({
...draft,
channels: channels.filter((_, i) => i !== index),
})
}
>
<Trash2 />
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
{FIELDS[channel.kind].map(([key, label, placeholder]) => (
<div key={key} className="grid gap-1.5">
<Label className="text-xs text-muted-foreground">
{label}
</Label>
<Input
value={showSetting(channel.config?.[key])}
placeholder={placeholder}
onChange={(event) =>
patchChannel(index, {
config: {
...channel.config,
[key]: parseSetting(event.target.value),
},
})
}
/>
</div>
))}
</div>
</div>
))
)}
{channels.length > 0 ? (
<p className="text-xs text-muted-foreground">
A setting can hold{" "}
<code className="font-mono">{`{"$secret": "name"}`}</code> instead
of the value itself, and the stored secret is used.
</p>
) : null}
</section>
<section className="grid gap-3">
<div className="flex items-center justify-between">
<h2 className={SECTION}>Rules</h2>
<Button
variant="ghost"
size="sm"
data-testid="add-rule"
onClick={() =>
setDraft({
...draft,
rules: [
...rules,
{ events: [], channels: [], cooldown_s: 900 },
],
})
}
>
<Plus />
Add rule
</Button>
</div>
{rules.length === 0 ? (
<p className="text-sm text-muted-foreground">
No rules yet, so nothing is sent. A rule picks the failures worth
hearing about and the channels that carry them.
</p>
) : (
rules.map((rule, index) => {
const events = rule.events ?? []
const targets = rule.channels ?? []
const patchRule = (patch: Partial<typeof rule>) =>
setDraft({
...draft,
rules: rules.map((r, i) =>
i === index ? { ...r, ...patch } : r,
),
})
const toggle = (list: string[], value: string) =>
list.includes(value)
? list.filter((item) => item !== value)
: [...list, value]
return (
<div
key={index}
className="grid gap-4 rounded-lg border border-border bg-card p-4 shadow-e1"
data-testid="rule-card"
>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<span className={SECTION}>
{events.length === 0
? "On any failure"
: `On ${events.length} of these`}
</span>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove rule ${index + 1}`}
className="text-destructive hover:text-destructive"
onClick={() =>
setDraft({
...draft,
rules: rules.filter((_, i) => i !== index),
})
}
>
<Trash2 />
</Button>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{EVENTS.map(([event, label]) => (
<Label
key={event}
className="flex items-center gap-2 text-sm font-normal"
>
<Checkbox
checked={events.includes(event)}
onCheckedChange={() =>
patchRule({ events: toggle(events, event) })
}
/>
{label}
</Label>
))}
</div>
</div>
<div className="grid gap-2">
<span className={SECTION}>Send to</span>
{channels.length === 0 ? (
<p className="text-sm text-muted-foreground">
Add a channel first.
</p>
) : (
<div className="flex flex-wrap gap-4">
{channels.map((channel) => (
<Label
key={channel.name}
className="flex items-center gap-2 text-sm font-normal"
>
<Checkbox
checked={targets.includes(channel.name)}
onCheckedChange={() =>
patchRule({
channels: toggle(targets, channel.name),
})
}
/>
{channel.name}
</Label>
))}
</div>
)}
</div>
<div className="grid max-w-56 gap-1.5">
<Label className="text-xs text-muted-foreground">
Cooldown, in seconds
</Label>
<Input
type="number"
min={0}
value={rule.cooldown_s ?? 900}
aria-label="Cooldown in seconds"
onChange={(event) =>
patchRule({ cooldown_s: Number(event.target.value) })
}
/>
</div>
</div>
)
})
)}
</section>
<div className="flex items-center gap-3">
<Button
disabled={!dirty || save.isPending}
data-testid="save-alerts"
onClick={() => save.mutate(draft)}
>
Save
</Button>
{dirty ? (
<span className="text-sm text-muted-foreground">
Unsaved changes.
</span>
) : null}
</div>
</>
)
}
+199
View File
@@ -0,0 +1,199 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { KeyRound, Plus, Trash2 } from "lucide-react"
import { useState } from "react"
import { SecretsService } from "@/client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
export const Route = createFileRoute("/_layout/secrets")({
component: Secrets,
head: () => ({
meta: [
{
title: "Secrets - Fluksio",
},
],
}),
})
/** The flow editor's credential picker reads this too, so saving refreshes it. */
const secretsKey = ["secrets"]
/** A name that survives being a URL path segment, and reads well in a flow. */
const NAME_PATTERN = /^[\w.-]+$/
function Secrets() {
const { data } = useQuery({
queryKey: secretsKey,
queryFn: () => SecretsService.readSecrets(),
})
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const [name, setName] = useState("")
const [value, setValue] = useState("")
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
const refresh = () => {
queryClient.invalidateQueries({ queryKey: secretsKey })
}
const save = useMutation({
mutationFn: (secret: { name: string; value: string }) =>
SecretsService.saveSecret({
name: secret.name,
requestBody: { value: secret.value },
}),
onSuccess: (_result, secret) => {
showSuccessToast(`Saved '${secret.name}'`)
setName("")
setValue("")
},
onError: handleError.bind(showErrorToast),
onSettled: refresh,
})
const remove = useMutation({
mutationFn: (secret: string) =>
SecretsService.deleteSecret({ name: secret }),
onSuccess: (_result, secret) => {
showSuccessToast(`Deleted '${secret}'`)
setPendingDelete(null)
},
onError: handleError.bind(showErrorToast),
onSettled: refresh,
})
const names = data?.data ?? []
const trimmed = name.trim()
const valid = NAME_PATTERN.test(trimmed) && value.length > 0
const replacing = names.includes(trimmed)
return (
<div className="grid gap-6">
<div className="grid gap-1">
<h1 className="text-2xl">Secrets</h1>
<p className="text-sm text-muted-foreground">
Credentials your nodes reach for by name, stored encrypted outside the
flow store. A value goes in and never comes back out: replace one you
have rotated, delete one you no longer use, but nothing here can read
it back.
</p>
</div>
<form
className="grid gap-2 sm:grid-cols-[1fr_1fr_auto]"
onSubmit={(event) => {
event.preventDefault()
if (valid) save.mutate({ name: trimmed, value })
}}
>
<Input
value={name}
placeholder="Name"
aria-label="Secret name"
data-testid="secret-name"
onChange={(event) => setName(event.target.value)}
/>
<Input
type="password"
value={value}
placeholder="Value"
aria-label="Secret value"
data-testid="secret-value"
onChange={(event) => setValue(event.target.value)}
/>
<Button
type="submit"
variant="secondary"
disabled={!valid || save.isPending}
data-testid="save-secret"
>
<Plus />
{replacing ? "Replace" : "Add"}
</Button>
</form>
{names.length === 0 ? (
<p className="text-sm text-muted-foreground">
No secrets yet. A node parameter written as{" "}
<code className="font-mono">{`{"$secret": "name"}`}</code> picks its
value up from here.
</p>
) : (
<div className="grid gap-2">
{names.map((secret) => (
<div
key={secret}
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card p-3 shadow-e1"
data-testid="secret-row"
>
<span className="flex items-center gap-2 font-mono text-sm">
<KeyRound className="size-4 text-muted-foreground" />
{secret}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setName(secret)}
title="Put this name in the form to store a new value"
>
Replace
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${secret}`}
className="text-destructive hover:text-destructive"
onClick={() => setPendingDelete(secret)}
>
<Trash2 />
</Button>
</div>
</div>
))}
</div>
)}
<Dialog
open={pendingDelete !== null}
onOpenChange={(open) => !open && setPendingDelete(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete '{pendingDelete}'?</DialogTitle>
<DialogDescription>
Any node whose parameter points at this name stops working until a
value is stored under it again. The value cannot be recovered.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setPendingDelete(null)}>
Keep it
</Button>
<Button
variant="destructive"
disabled={remove.isPending}
onClick={() => pendingDelete && remove.mutate(pendingDelete)}
data-testid="confirm-delete-secret"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}