From 45392d31d90a89e800fa68132daf67a7a5cc0cdd Mon Sep 17 00:00:00 2001 From: stroblme Date: Sun, 16 Aug 2026 16:36:41 +0200 Subject: [PATCH] Add the Secrets and Alerts screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A --- backend/app/flow/secrets.py | 2 +- .../src/components/Sidebar/AppSidebar.tsx | 6 + frontend/src/routeTree.gen.ts | 240 ++++++---- frontend/src/routes/_layout/alerts.tsx | 451 ++++++++++++++++++ frontend/src/routes/_layout/secrets.tsx | 199 ++++++++ 5 files changed, 798 insertions(+), 100 deletions(-) create mode 100644 frontend/src/routes/_layout/alerts.tsx create mode 100644 frontend/src/routes/_layout/secrets.tsx diff --git a/backend/app/flow/secrets.py b/backend/app/flow/secrets.py index 943e2aa..adfa5fb 100644 --- a/backend/app/flow/secrets.py +++ b/backend/app/flow/secrets.py @@ -31,7 +31,7 @@ class SecretNotFound(KeyError): self.name = name def __str__(self) -> str: - return f"No secret named '{self.name}' — add it under Settings › Secrets." + return f"No secret named '{self.name}' — add it under Secrets." class SecretsStore: diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index fc27f42..5791dd3 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -1,5 +1,7 @@ import { + Bell, Home, + KeyRound, LayoutDashboard, LogOut, Settings, @@ -23,6 +25,10 @@ const baseItems: Item[] = [ { icon: Home, title: "Home", path: "/" }, { icon: Workflow, title: "Flows", path: "/flows" }, { icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" }, + // Both are engine-wide operator settings rather than personal ones, so they + // sit here and not among the per-user tabs under Settings. + { icon: KeyRound, title: "Secrets", path: "/secrets" }, + { icon: Bell, title: "Alerts", path: "/alerts" }, ] export function AppSidebar() { diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index ec530bc..36d44a6 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -9,34 +9,29 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as SignupRouteImport } from './routes/signup' -import { Route as ResetPasswordRouteImport } from './routes/reset-password' -import { Route as RecoverPasswordRouteImport } from './routes/recover-password' -import { Route as LoginRouteImport } from './routes/login' -import { Route as LayoutRouteImport } from './routes/_layout' import { Route as CanvasRouteImport } from './routes/_canvas' +import { Route as LayoutRouteImport } from './routes/_layout' +import { Route as LoginRouteImport } from './routes/login' +import { Route as RecoverPasswordRouteImport } from './routes/recover-password' +import { Route as ResetPasswordRouteImport } from './routes/reset-password' +import { Route as SignupRouteImport } from './routes/signup' import { Route as LayoutIndexRouteImport } from './routes/_layout/index' -import { Route as OauthAuthorizeRouteImport } from './routes/oauth.authorize' -import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' import { Route as LayoutAdminRouteImport } from './routes/_layout/admin' -import { Route as LayoutDashboardsIndexRouteImport } from './routes/_layout/dashboards/index' +import { Route as LayoutAlertsRouteImport } from './routes/_layout/alerts' +import { Route as LayoutSecretsRouteImport } from './routes/_layout/secrets' +import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' +import { Route as OauthAuthorizeRouteImport } from './routes/oauth.authorize' import { Route as CanvasFlowsIndexRouteImport } from './routes/_canvas/flows/index' -import { Route as LayoutDashboardsNameRouteImport } from './routes/_layout/dashboards/$name' import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName' +import { Route as LayoutDashboardsIndexRouteImport } from './routes/_layout/dashboards/index' +import { Route as LayoutDashboardsNameRouteImport } from './routes/_layout/dashboards/$name' -const SignupRoute = SignupRouteImport.update({ - id: '/signup', - path: '/signup', +const CanvasRoute = CanvasRouteImport.update({ + id: '/_canvas', getParentRoute: () => rootRouteImport, } as any) -const ResetPasswordRoute = ResetPasswordRouteImport.update({ - id: '/reset-password', - path: '/reset-password', - getParentRoute: () => rootRouteImport, -} as any) -const RecoverPasswordRoute = RecoverPasswordRouteImport.update({ - id: '/recover-password', - path: '/recover-password', +const LayoutRoute = LayoutRouteImport.update({ + id: '/_layout', getParentRoute: () => rootRouteImport, } as any) const LoginRoute = LoginRouteImport.update({ @@ -44,12 +39,19 @@ const LoginRoute = LoginRouteImport.update({ path: '/login', getParentRoute: () => rootRouteImport, } as any) -const LayoutRoute = LayoutRouteImport.update({ - id: '/_layout', +const RecoverPasswordRoute = RecoverPasswordRouteImport.update({ + id: '/recover-password', + path: '/recover-password', getParentRoute: () => rootRouteImport, } as any) -const CanvasRoute = CanvasRouteImport.update({ - id: '/_canvas', +const ResetPasswordRoute = ResetPasswordRouteImport.update({ + id: '/reset-password', + path: '/reset-password', + getParentRoute: () => rootRouteImport, +} as any) +const SignupRoute = SignupRouteImport.update({ + id: '/signup', + path: '/signup', getParentRoute: () => rootRouteImport, } as any) const LayoutIndexRoute = LayoutIndexRouteImport.update({ @@ -57,41 +59,51 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({ path: '/', getParentRoute: () => LayoutRoute, } as any) -const OauthAuthorizeRoute = OauthAuthorizeRouteImport.update({ - id: '/oauth/authorize', - path: '/oauth/authorize', - getParentRoute: () => rootRouteImport, +const LayoutAdminRoute = LayoutAdminRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => LayoutRoute, +} as any) +const LayoutAlertsRoute = LayoutAlertsRouteImport.update({ + id: '/alerts', + path: '/alerts', + getParentRoute: () => LayoutRoute, +} as any) +const LayoutSecretsRoute = LayoutSecretsRouteImport.update({ + id: '/secrets', + path: '/secrets', + getParentRoute: () => LayoutRoute, } as any) const LayoutSettingsRoute = LayoutSettingsRouteImport.update({ id: '/settings', path: '/settings', getParentRoute: () => LayoutRoute, } as any) -const LayoutAdminRoute = LayoutAdminRouteImport.update({ - id: '/admin', - path: '/admin', - getParentRoute: () => LayoutRoute, -} as any) -const LayoutDashboardsIndexRoute = LayoutDashboardsIndexRouteImport.update({ - id: '/dashboards/', - path: '/dashboards/', - getParentRoute: () => LayoutRoute, +const OauthAuthorizeRoute = OauthAuthorizeRouteImport.update({ + id: '/oauth/authorize', + path: '/oauth/authorize', + getParentRoute: () => rootRouteImport, } as any) const CanvasFlowsIndexRoute = CanvasFlowsIndexRouteImport.update({ id: '/flows/', path: '/flows/', getParentRoute: () => CanvasRoute, } as any) -const LayoutDashboardsNameRoute = LayoutDashboardsNameRouteImport.update({ - id: '/dashboards/$name', - path: '/dashboards/$name', - getParentRoute: () => LayoutRoute, -} as any) const CanvasFlowsFlowNameRoute = CanvasFlowsFlowNameRouteImport.update({ id: '/flows/$flowName', path: '/flows/$flowName', getParentRoute: () => CanvasRoute, } as any) +const LayoutDashboardsIndexRoute = LayoutDashboardsIndexRouteImport.update({ + id: '/dashboards/', + path: '/dashboards/', + getParentRoute: () => LayoutRoute, +} as any) +const LayoutDashboardsNameRoute = LayoutDashboardsNameRouteImport.update({ + id: '/dashboards/$name', + path: '/dashboards/$name', + getParentRoute: () => LayoutRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof LayoutIndexRoute @@ -100,6 +112,8 @@ export interface FileRoutesByFullPath { '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/admin': typeof LayoutAdminRoute + '/alerts': typeof LayoutAlertsRoute + '/secrets': typeof LayoutSecretsRoute '/settings': typeof LayoutSettingsRoute '/oauth/authorize': typeof OauthAuthorizeRoute '/flows/$flowName': typeof CanvasFlowsFlowNameRoute @@ -114,6 +128,8 @@ export interface FileRoutesByTo { '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/admin': typeof LayoutAdminRoute + '/alerts': typeof LayoutAlertsRoute + '/secrets': typeof LayoutSecretsRoute '/settings': typeof LayoutSettingsRoute '/oauth/authorize': typeof OauthAuthorizeRoute '/flows/$flowName': typeof CanvasFlowsFlowNameRoute @@ -130,6 +146,8 @@ export interface FileRoutesById { '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/_layout/admin': typeof LayoutAdminRoute + '/_layout/alerts': typeof LayoutAlertsRoute + '/_layout/secrets': typeof LayoutSecretsRoute '/_layout/settings': typeof LayoutSettingsRoute '/oauth/authorize': typeof OauthAuthorizeRoute '/_layout/': typeof LayoutIndexRoute @@ -147,6 +165,8 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/admin' + | '/alerts' + | '/secrets' | '/settings' | '/oauth/authorize' | '/flows/$flowName' @@ -161,6 +181,8 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/admin' + | '/alerts' + | '/secrets' | '/settings' | '/oauth/authorize' | '/flows/$flowName' @@ -176,6 +198,8 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/_layout/admin' + | '/_layout/alerts' + | '/_layout/secrets' | '/_layout/settings' | '/oauth/authorize' | '/_layout/' @@ -197,32 +221,11 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/signup': { - id: '/signup' - path: '/signup' - fullPath: '/signup' - preLoaderRoute: typeof SignupRouteImport - parentRoute: typeof rootRouteImport - } - '/reset-password': { - id: '/reset-password' - path: '/reset-password' - fullPath: '/reset-password' - preLoaderRoute: typeof ResetPasswordRouteImport - parentRoute: typeof rootRouteImport - } - '/recover-password': { - id: '/recover-password' - path: '/recover-password' - fullPath: '/recover-password' - preLoaderRoute: typeof RecoverPasswordRouteImport - parentRoute: typeof rootRouteImport - } - '/login': { - id: '/login' - path: '/login' - fullPath: '/login' - preLoaderRoute: typeof LoginRouteImport + '/_canvas': { + id: '/_canvas' + path: '' + fullPath: '/' + preLoaderRoute: typeof CanvasRouteImport parentRoute: typeof rootRouteImport } '/_layout': { @@ -232,11 +235,32 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } - '/_canvas': { - id: '/_canvas' - path: '' - fullPath: '/' - preLoaderRoute: typeof CanvasRouteImport + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } + '/recover-password': { + id: '/recover-password' + path: '/recover-password' + fullPath: '/recover-password' + preLoaderRoute: typeof RecoverPasswordRouteImport + parentRoute: typeof rootRouteImport + } + '/reset-password': { + id: '/reset-password' + path: '/reset-password' + fullPath: '/reset-password' + preLoaderRoute: typeof ResetPasswordRouteImport + parentRoute: typeof rootRouteImport + } + '/signup': { + id: '/signup' + path: '/signup' + fullPath: '/signup' + preLoaderRoute: typeof SignupRouteImport parentRoute: typeof rootRouteImport } '/_layout/': { @@ -246,12 +270,26 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutIndexRouteImport parentRoute: typeof LayoutRoute } - '/oauth/authorize': { - id: '/oauth/authorize' - path: '/oauth/authorize' - fullPath: '/oauth/authorize' - preLoaderRoute: typeof OauthAuthorizeRouteImport - parentRoute: typeof rootRouteImport + '/_layout/admin': { + id: '/_layout/admin' + path: '/admin' + fullPath: '/admin' + preLoaderRoute: typeof LayoutAdminRouteImport + parentRoute: typeof LayoutRoute + } + '/_layout/alerts': { + id: '/_layout/alerts' + path: '/alerts' + fullPath: '/alerts' + preLoaderRoute: typeof LayoutAlertsRouteImport + parentRoute: typeof LayoutRoute + } + '/_layout/secrets': { + id: '/_layout/secrets' + path: '/secrets' + fullPath: '/secrets' + preLoaderRoute: typeof LayoutSecretsRouteImport + parentRoute: typeof LayoutRoute } '/_layout/settings': { id: '/_layout/settings' @@ -260,19 +298,12 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutSettingsRouteImport parentRoute: typeof LayoutRoute } - '/_layout/admin': { - id: '/_layout/admin' - path: '/admin' - fullPath: '/admin' - preLoaderRoute: typeof LayoutAdminRouteImport - parentRoute: typeof LayoutRoute - } - '/_layout/dashboards/': { - id: '/_layout/dashboards/' - path: '/dashboards' - fullPath: '/dashboards/' - preLoaderRoute: typeof LayoutDashboardsIndexRouteImport - parentRoute: typeof LayoutRoute + '/oauth/authorize': { + id: '/oauth/authorize' + path: '/oauth/authorize' + fullPath: '/oauth/authorize' + preLoaderRoute: typeof OauthAuthorizeRouteImport + parentRoute: typeof rootRouteImport } '/_canvas/flows/': { id: '/_canvas/flows/' @@ -281,13 +312,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CanvasFlowsIndexRouteImport parentRoute: typeof CanvasRoute } - '/_layout/dashboards/$name': { - id: '/_layout/dashboards/$name' - path: '/dashboards/$name' - fullPath: '/dashboards/$name' - preLoaderRoute: typeof LayoutDashboardsNameRouteImport - parentRoute: typeof LayoutRoute - } '/_canvas/flows/$flowName': { id: '/_canvas/flows/$flowName' path: '/flows/$flowName' @@ -295,6 +319,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CanvasFlowsFlowNameRouteImport parentRoute: typeof CanvasRoute } + '/_layout/dashboards/': { + id: '/_layout/dashboards/' + path: '/dashboards' + fullPath: '/dashboards/' + preLoaderRoute: typeof LayoutDashboardsIndexRouteImport + parentRoute: typeof LayoutRoute + } + '/_layout/dashboards/$name': { + id: '/_layout/dashboards/$name' + path: '/dashboards/$name' + fullPath: '/dashboards/$name' + preLoaderRoute: typeof LayoutDashboardsNameRouteImport + parentRoute: typeof LayoutRoute + } } } @@ -313,6 +351,8 @@ const CanvasRouteWithChildren = interface LayoutRouteChildren { LayoutAdminRoute: typeof LayoutAdminRoute + LayoutAlertsRoute: typeof LayoutAlertsRoute + LayoutSecretsRoute: typeof LayoutSecretsRoute LayoutSettingsRoute: typeof LayoutSettingsRoute LayoutIndexRoute: typeof LayoutIndexRoute LayoutDashboardsNameRoute: typeof LayoutDashboardsNameRoute @@ -321,6 +361,8 @@ interface LayoutRouteChildren { const LayoutRouteChildren: LayoutRouteChildren = { LayoutAdminRoute: LayoutAdminRoute, + LayoutAlertsRoute: LayoutAlertsRoute, + LayoutSecretsRoute: LayoutSecretsRoute, LayoutSettingsRoute: LayoutSettingsRoute, LayoutIndexRoute: LayoutIndexRoute, LayoutDashboardsNameRoute: LayoutDashboardsNameRoute, diff --git a/frontend/src/routes/_layout/alerts.tsx b/frontend/src/routes/_layout/alerts.tsx new file mode 100644 index 0000000..21c5b36 --- /dev/null +++ b/frontend/src/routes/_layout/alerts.tsx @@ -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 = { + 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 ( +
+
+

Alerts

+

+ 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. +

+
+ {data ? : null} +
+ ) +} + +function AlertsForm({ initial }: { initial: AlertsConfig }) { + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + const [draft, setDraft] = useState(initial) + const [saved, setSaved] = useState(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) => + setDraft({ + ...draft, + channels: channels.map((channel, i) => + i === index ? { ...channel, ...patch } : channel, + ), + }) + + return ( + <> +
+
+ Alerting + + {draft.enabled + ? "Failures reach the channels below." + : "Switched off — failures only reach the log."} + +
+ setDraft({ ...draft, enabled })} + /> +
+ +
+
+

Channels

+ +
+ + {channels.length === 0 ? ( +

+ No channels yet. Add one to have somewhere to send to. +

+ ) : ( + channels.map((channel, index) => ( +
+
+ + patchChannel(index, { name: event.target.value }) + } + /> + +
+ + patchChannel(index, { enabled }) + } + /> + + +
+
+ +
+ {FIELDS[channel.kind].map(([key, label, placeholder]) => ( +
+ + + patchChannel(index, { + config: { + ...channel.config, + [key]: parseSetting(event.target.value), + }, + }) + } + /> +
+ ))} +
+
+ )) + )} + {channels.length > 0 ? ( +

+ A setting can hold{" "} + {`{"$secret": "name"}`} instead + of the value itself, and the stored secret is used. +

+ ) : null} +
+ +
+
+

Rules

+ +
+ + {rules.length === 0 ? ( +

+ No rules yet, so nothing is sent. A rule picks the failures worth + hearing about and the channels that carry them. +

+ ) : ( + rules.map((rule, index) => { + const events = rule.events ?? [] + const targets = rule.channels ?? [] + const patchRule = (patch: Partial) => + 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 ( +
+
+
+ + {events.length === 0 + ? "On any failure" + : `On ${events.length} of these`} + + +
+
+ {EVENTS.map(([event, label]) => ( + + ))} +
+
+ +
+ Send to + {channels.length === 0 ? ( +

+ Add a channel first. +

+ ) : ( +
+ {channels.map((channel) => ( + + ))} +
+ )} +
+ +
+ + + patchRule({ cooldown_s: Number(event.target.value) }) + } + /> +
+
+ ) + }) + )} +
+ +
+ + {dirty ? ( + + Unsaved changes. + + ) : null} +
+ + ) +} diff --git a/frontend/src/routes/_layout/secrets.tsx b/frontend/src/routes/_layout/secrets.tsx new file mode 100644 index 0000000..b462dbd --- /dev/null +++ b/frontend/src/routes/_layout/secrets.tsx @@ -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(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 ( +
+
+

Secrets

+

+ 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. +

+
+ +
{ + event.preventDefault() + if (valid) save.mutate({ name: trimmed, value }) + }} + > + setName(event.target.value)} + /> + setValue(event.target.value)} + /> + +
+ + {names.length === 0 ? ( +

+ No secrets yet. A node parameter written as{" "} + {`{"$secret": "name"}`} picks its + value up from here. +

+ ) : ( +
+ {names.map((secret) => ( +
+ + + {secret} + +
+ + +
+
+ ))} +
+ )} + + !open && setPendingDelete(null)} + > + + + Delete '{pendingDelete}'? + + Any node whose parameter points at this name stops working until a + value is stored under it again. The value cannot be recovered. + + + + + + + + +
+ ) +}