fix(web,adminsite): accessible dialogs, real confirmations, shared async UI

Four correctness/accessibility defects and the destructive-action flow.

- Button: the loading spinner carried xmlns="http://www.w3.instance/2000/svg",
  a find/replace of "org" that landed inside a URL. Button also grows an href
  form, because <Link><Button> nested a button inside an anchor at nineteen
  call sites: invalid markup, two tab stops, and Enter firing only the anchor.

- Fleet status was four meanings carried by hue with the distinction living in
  a title attribute, which touch never shows and screen readers need not
  announce. It now carries a text label and an accessible name, which is the
  one rule the design system states outright.

- Modal had no focus management at all: no trap, no initial focus, no restore,
  no scroll lock, no aria-labelledby. Dialogs nest (a confirm over an edit), so
  a stack decides which panel owns Escape and Tab.

- Seven destructive actions went through window.confirm(). ConfirmDialog
  replaces them and can say what is about to happen; deleting a secret group,
  a shared base step or a workflow now requires typing the name, since those
  have no undo and a wide blast radius. adminsite keeps its own inline idiom
  rather than importing a dialog system it does not have.

Adds Toast, AsyncBoundary/EmptyState/ErrorState/TableSkeleton and
friendlyMessage, replacing per-page loading ternaries and raw
(error as Error).message text. Wired here only where a call site was already
being edited; the remaining pages follow.
This commit is contained in:
2026-08-10 09:25:53 +01:00
parent d559cccd44
commit 1fa9160c59
20 changed files with 1029 additions and 221 deletions
+4 -5
View File
@@ -164,11 +164,10 @@ export default function KeysPage() {
<span className="text-text-secondary text-xs">{new Date(key.created_at).toLocaleDateString()}</span>
</Td>
<Td>
<Link href={`/keys/${key.key_id}`}>
<Button variant="ghost" size="sm">
View
</Button>
</Link>
<Button href={`/keys/${key.key_id}`} variant="ghost" size="sm">
View <span aria-hidden="true"></span>
<span className="sr-only">{key.label}</span>
</Button>
</Td>
</Tr>
))}
+6 -8
View File
@@ -283,9 +283,9 @@ export default function MonitorDetailPage() {
{monitor.state.message && <p className="mt-1.5 text-sm text-text-secondary">{monitor.state.message}</p>}
</div>
<div className="flex flex-wrap gap-2">
<Link href={`/monitors/${monitorId}/edit`}>
<Button variant="secondary">Edit</Button>
</Link>
<Button href={`/monitors/${monitorId}/edit`} variant="secondary">
Edit
</Button>
<Button variant="secondary" loading={isToggling} onClick={() => toggleEnabled(!monitor.enabled)}>
{monitor.enabled ? "Pause checks" : "Resume checks"}
</Button>
@@ -384,11 +384,9 @@ export default function MonitorDetailPage() {
))}
</dl>
)}
<Link href="/settings/notifications">
<Button variant="secondary" size="sm" className="mt-4">
Manage channels
</Button>
</Link>
<Button href="/settings/notifications" variant="secondary" size="sm" className="mt-4">
Manage channels
</Button>
</Panel>
<div className="rounded-lg border border-border bg-surface px-5 py-4">
+9 -11
View File
@@ -138,12 +138,12 @@ export default function MonitorsPage() {
<h1 className="mt-1 text-2xl font-bold tracking-tight text-text-primary">Monitors</h1>
</div>
<div className="flex gap-2">
<Link href="/settings/notifications">
<Button variant="secondary">Notification channels</Button>
</Link>
<Link href="/monitors/new">
<Button variant="primary">New monitor</Button>
</Link>
<Button href="/settings/notifications" variant="secondary">
Notification channels
</Button>
<Button href="/monitors/new" variant="primary">
New monitor
</Button>
</div>
</div>
@@ -158,11 +158,9 @@ export default function MonitorsPage() {
Add a check and Vantage records uptime and response time on your interval, opens an incident when it fails, and tells the
channels you pick.
</p>
<Link href="/monitors/new">
<Button variant="primary" className="mt-4">
Add your first check
</Button>
</Link>
<Button href="/monitors/new" variant="primary" className="mt-4">
Add your first check
</Button>
</div>
) : (
<>
+91 -29
View File
@@ -5,7 +5,18 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, Secret } from "@/lib/api";
import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
import {
AsyncBoundary,
Button,
Card,
CardHeader,
CardTitle,
ConfirmDialog,
EmptyState,
TableSkeleton,
friendlyMessage,
useToast,
} from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
@@ -116,17 +127,28 @@ spec:
function SecretRow({ group, secret }: { group: string; secret: Secret }) {
const queryClient = useQueryClient();
const toast = useToast();
const [revealed, setRevealed] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [confirming, setConfirming] = useState(false);
const { mutate: reveal, isPending: revealing } = useMutation({
mutationFn: () => api.revealSecret(group, secret.key),
onSuccess: (res) => setRevealed(res.value),
onError: toast.error,
});
const { mutate: remove, isPending: removing } = useMutation({
const {
mutate: remove,
isPending: removing,
error: removeError,
} = useMutation({
mutationFn: () => api.deleteSecret(group, secret.key),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["secret-group", group] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-group", group] });
setConfirming(false);
toast.success(`Deleted ${secret.key}.`);
},
});
async function copy() {
@@ -164,15 +186,31 @@ function SecretRow({ group, secret }: { group: string; secret: Secret }) {
<Button
variant="ghost"
size="sm"
loading={removing}
className="text-danger hover:text-danger"
onClick={() => {
if (confirm(`Delete key "${secret.key}"?`)) remove();
}}
onClick={() => setConfirming(true)}
>
Delete
Delete<span className="sr-only"> {secret.key}</span>
</Button>
</div>
<ConfirmDialog
open={confirming}
title="Delete key"
confirmLabel="Delete key"
loading={removing}
error={removeError ? friendlyMessage(removeError) : null}
onClose={() => setConfirming(false)}
onConfirm={() => remove()}
body={
<>
<p>
<span className="font-mono text-text-primary">{secret.key}</span> will be removed from the{" "}
<span className="font-mono text-text-primary">{group}</span> group.
</p>
<p>Anything reading this key a workflow step, an External Secrets sync starts failing at its next run.</p>
</>
}
/>
</Td>
</Tr>
);
@@ -225,17 +263,24 @@ export default function SecretGroupPage() {
const router = useRouter();
const queryClient = useQueryClient();
const group = decodeURIComponent(String(params.group));
const toast = useToast();
const [showYaml, setShowYaml] = useState(false);
const [confirmingGroup, setConfirmingGroup] = useState(false);
const { data, isLoading, error } = useQuery({
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["secret-group", group],
queryFn: () => api.getSecretGroup(group),
});
const { mutate: deleteGroup, isPending: deleting } = useMutation({
const {
mutate: deleteGroup,
isPending: deleting,
error: deleteError,
} = useMutation({
mutationFn: () => api.deleteSecretGroup(group),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
toast.success(`Deleted the ${group} group.`);
router.push("/secrets");
},
});
@@ -262,30 +307,49 @@ export default function SecretGroupPage() {
</svg>
ExternalSecret YAML
</Button>
<Button
variant="ghost"
className="text-danger hover:text-danger"
loading={deleting}
onClick={() => {
if (confirm(`Delete the entire "${group}" group and all its keys?`)) deleteGroup();
}}
>
<Button variant="ghost" className="text-danger hover:text-danger" onClick={() => setConfirmingGroup(true)}>
Delete Group
</Button>
</div>
</div>
<ConfirmDialog
open={confirmingGroup}
title="Delete secret group"
confirmLabel="Delete group"
// No undo, and the blast radius is every consumer of the group
// rather than one key — so the name has to be typed.
requireTyped={group}
loading={deleting}
error={deleteError ? friendlyMessage(deleteError) : null}
onClose={() => setConfirmingGroup(false)}
onConfirm={() => deleteGroup()}
body={
<>
<p>
This deletes <span className="font-mono text-text-primary">{group}</span> and all{" "}
{data ? `${data.secrets.length} of its keys` : "of its keys"}. The values cannot be recovered.
</p>
<p>
Every workflow step referencing this group, and any External Secrets sync reading{" "}
<span className="font-mono">/api/secrets/{group}/values</span>, fails at its next run.
</p>
</>
}
/>
<div className="space-y-6">
<AddKeyCard group={group} />
<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 group. It may have been deleted.</div>
) : data && data.secrets.length > 0 ? (
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={4} />}
isEmpty={!data || data.secrets.length === 0}
empty={<EmptyState title="This group has no keys yet." description="Add one above and it becomes available to workflow steps and External Secrets straight away." />}
>
<Table>
<Thead>
<Tr>
@@ -296,14 +360,12 @@ export default function SecretGroupPage() {
</Tr>
</Thead>
<Tbody>
{data.secrets.map((s: Secret) => (
{data?.secrets.map((s: Secret) => (
<SecretRow key={s.key} group={group} secret={s} />
))}
</Tbody>
</Table>
) : (
<div className="py-16 text-center text-text-secondary">This group has no keys. Add one above.</div>
)}
</AsyncBoundary>
</Card>
</div>
</div>
+4 -3
View File
@@ -153,9 +153,10 @@ export default function SecretsPage() {
</span>
</Td>
<Td>
<Link href={`/secrets/${encodeURIComponent(g.group)}`}>
<Button variant="ghost" size="sm">View </Button>
</Link>
<Button href={`/secrets/${encodeURIComponent(g.group)}`} variant="ghost" size="sm">
View <span aria-hidden="true"></span>
<span className="sr-only">{g.group}</span>
</Button>
</Td>
</Tr>
))}
+56 -26
View File
@@ -2,7 +2,6 @@
import { Suspense } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { api, Server } from "@/lib/api";
import { Button, Card } from "@/components/ui";
@@ -37,10 +36,37 @@ const DOT_LABELS: Record<DotStatus, string> = {
ok: "OK",
};
const DOT_TEXT: Record<DotStatus, string> = {
offline: "text-danger",
"needs-update": "text-warning",
"has-package-updates": "text-accent",
ok: "text-success",
};
// Short forms for the desktop column, which is narrow. The full sentence is
// still the accessible name, so nothing is lost to a screen reader.
const DOT_SHORT: Record<DotStatus, string> = {
offline: "Offline",
"needs-update": "Agent stale",
"has-package-updates": "Updates",
ok: "OK",
};
/*
* The dot alone was the whole control: four meanings carried by hue, with the
* distinction living in a `title` a touch user never sees and a screen reader
* is not obliged to announce. This is the one rule the design system states
* outright — state never reads by colour alone — so the label is now part of
* the component rather than something each page remembers to add.
*/
function StatusDot({ status }: { status: DotStatus }) {
return (
<span title={DOT_LABELS[status]} className="flex items-center">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${DOT_CLASSES[status]}`} />
<span className={`inline-flex items-center gap-2 whitespace-nowrap ${DOT_TEXT[status]}`}>
<span className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${DOT_CLASSES[status]}`} aria-hidden="true" />
<span className="font-mono text-[0.65rem] uppercase tracking-[0.08em]" aria-hidden="true">
{DOT_SHORT[status]}
</span>
<span className="sr-only">{DOT_LABELS[status]}</span>
</span>
);
}
@@ -104,14 +130,12 @@ function ServersPageBody() {
{servers?.length ?? 0} registered server{servers?.length !== 1 ? "s" : ""}
</p>
</div>
<Link href="/servers/new">
<Button variant="primary">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add Server
</Button>
</Link>
<Button href="/servers/new" variant="primary">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add Server
</Button>
</div>
<TagFilterBar value={selected} onChange={setSelected} />
@@ -161,18 +185,26 @@ function ServersPageBody() {
<StatusDot status={resolveStatus(server, latestVersion)} />
</Td>
<Td label="Last Seen">
<span className="text-text-secondary">
{server.last_seen
? formatLastSeen(server.last_seen)
: "Never"}
</span>
{server.last_seen ? (
// "3d ago" is the useful reading; the exact instant is
// what someone correlating an incident needs, so it is on
// the element rather than gone.
<time
dateTime={server.last_seen}
title={new Date(server.last_seen).toLocaleString()}
className="text-text-secondary"
>
{formatLastSeen(server.last_seen)}
</time>
) : (
<span className="text-text-secondary">Never</span>
)}
</Td>
<Td>
<Link href={`/servers/${server.server_id}`}>
<Button variant="ghost" size="sm">
View
</Button>
</Link>
<Button href={`/servers/${server.server_id}`} variant="ghost" size="sm">
View <span aria-hidden="true"></span>
<span className="sr-only">{server.hostname}</span>
</Button>
</Td>
</Tr>
))}
@@ -186,11 +218,9 @@ function ServersPageBody() {
</svg>
</div>
<p className="text-text-secondary">No servers registered yet.</p>
<Link href="/servers/new">
<Button variant="primary" size="sm" className="mt-4">
Add your first server
</Button>
</Link>
<Button href="/servers/new" variant="primary" size="sm" className="mt-4">
Add your first server
</Button>
</div>
)}
</Card>
+6 -6
View File
@@ -229,12 +229,12 @@ export default function SettingsPage() {
<Group label="Monitoring">
<SectionCard title="Alerting" description="Alerts are delivered through notification channels, triggered by service monitors and by servers going offline." 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>
<Button href="/settings/notifications" variant="secondary">
Manage notification channels
</Button>
<Button href="/monitors" variant="ghost">
View monitors
</Button>
</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.
+7 -10
View File
@@ -113,16 +113,13 @@ export default function WorkflowsPage() {
</Td>
<Td>
<div className="flex items-center justify-end gap-2">
<Link href={`/workflows/${w.workflow_id}/runs`}>
<Button variant="ghost" size="sm">
Runs
</Button>
</Link>
<Link href={`/workflows/${w.workflow_id}`}>
<Button variant="ghost" size="sm">
Open
</Button>
</Link>
<Button href={`/workflows/${w.workflow_id}/runs`} variant="ghost" size="sm">
Runs<span className="sr-only"> for {w.name}</span>
</Button>
<Button href={`/workflows/${w.workflow_id}`} variant="ghost" size="sm">
Open <span aria-hidden="true"></span>
<span className="sr-only">{w.name}</span>
</Button>
</div>
</Td>
</Tr>
+4 -1
View File
@@ -2,9 +2,12 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/query-client";
import { ToastProvider } from "@/components/ui/Toast";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>
<ToastProvider>{children}</ToastProvider>
</QueryClientProvider>
);
}
+45 -9
View File
@@ -4,12 +4,16 @@ import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type InstanceUser, type Role } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Badge, Button, Modal, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
import { Badge, Button, ConfirmDialog, Modal, Table, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui";
import { Field, inputClass } from "./Field";
import { SectionCard } from "./SectionCard";
const ROLES: Role[] = ["owner", "admin", "member"];
/** The member a pending removal refers to, carried so the dialog and the
* confirmation message name a person rather than a user_id. */
type Member = { id: string; email: string };
function UsersIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -31,7 +35,9 @@ function roleVariant(role: Role) {
export function MembersCard() {
const queryClient = useQueryClient();
const { user } = useAuth();
const toast = useToast();
const [addOpen, setAddOpen] = useState(false);
const [removing, setRemoving] = useState<Member | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("member");
@@ -48,6 +54,7 @@ export function MembersCard() {
mutationFn: () => api.createInstanceUser({ email, password, role }),
onSuccess: () => {
invalidate();
toast.success(`Added ${email} as ${role}.`);
setAddOpen(false);
setEmail("");
setPassword("");
@@ -63,12 +70,23 @@ export function MembersCard() {
onError: invalidate,
});
const { mutate: removeUser, error: removeError } = useMutation({
mutationFn: (userId: string) => api.deleteInstanceUser(userId),
onSuccess: invalidate,
const {
mutate: removeUser,
isPending: isRemoving,
error: removeError,
} = useMutation({
mutationFn: (member: Member) => api.deleteInstanceUser(member.id),
onSuccess: (_data, member) => {
invalidate();
toast.success(`Removed ${member.email}.`);
setRemoving(null);
},
});
const actionError = (roleError ?? removeError) as Error | null;
// Removal failures are shown inside the confirm dialog that raised them, so
// only the inline role change lands here — otherwise the same sentence
// appears twice on screen.
const actionError = roleError as Error | null;
const isOwner = user?.role === "owner";
const assignableRoles = isOwner ? ROLES : ROLES.filter((r) => r !== "owner");
@@ -154,11 +172,10 @@ export function MembersCard() {
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm(`Remove ${u.email} from this instance?`)) removeUser(u.user_id);
}}
className="text-danger hover:text-danger"
onClick={() => setRemoving({ id: u.user_id, email: u.email })}
>
Remove
Remove<span className="sr-only"> {u.email}</span>
</Button>
)
)}
@@ -170,6 +187,25 @@ export function MembersCard() {
</Table>
)}
<ConfirmDialog
open={removing !== null}
title="Remove member"
confirmLabel="Remove member"
loading={isRemoving}
error={removeError ? friendlyMessage(removeError) : null}
onClose={() => setRemoving(null)}
onConfirm={() => removing && removeUser(removing)}
body={
<>
<p>
<span className="text-text-primary">{removing?.email}</span> loses access to this instance immediately, including any
open session.
</p>
<p>Their audit history is kept. Adding them again later creates a new member.</p>
</>
}
/>
<Modal open={addOpen} title="Add member" onClose={() => setAddOpen(false)}>
<form
onSubmit={(e) => {
+172
View File
@@ -0,0 +1,172 @@
"use client";
import { clsx } from "clsx";
import { Button } from "./Button";
/*
* Twenty-three copies of the same spinner div existed across app/ and
* components/, each with the loading / error / empty branch rewritten by hand
* beside it. They had already drifted: some said "Failed to load servers. Is
* the backend running?", some rendered the raw exception message, some showed
* nothing at all while a list was empty.
*/
export function Spinner({ className, label = "Loading" }: { className?: string; label?: string }) {
return (
<span role="status" className="inline-flex items-center">
<span
className={clsx("inline-block animate-spin rounded-full border-2 border-border border-t-accent", className ?? "h-8 w-8")}
aria-hidden="true"
/>
<span className="sr-only">{label}</span>
</span>
);
}
export function CenteredSpinner({ label }: { label?: string }) {
return (
<div className="flex items-center justify-center py-20">
<Spinner label={label} />
</div>
);
}
/*
* A skeleton rather than a spinner wherever the shape of what is coming is
* already known: the table does not collapse and re-expand, so the page stops
* jumping under the pointer as data lands.
*/
export function TableSkeleton({ rows = 5, columns = 4 }: { rows?: number; columns?: number }) {
return (
<div className="animate-pulse p-4" aria-hidden="true">
{Array.from({ length: rows }).map((_, r) => (
<div key={r} className="flex gap-4 border-b border-border/40 py-3 last:border-0">
{Array.from({ length: columns }).map((_, c) => (
<div
key={c}
className="h-3 rounded bg-surface-2"
style={{ width: `${[28, 20, 16, 12, 10, 8][c % 6]}%` }}
/>
))}
</div>
))}
</div>
);
}
export function EmptyState({
title,
description,
icon,
action,
}: {
title: string;
description?: string;
icon?: React.ReactNode;
action?: { label: string; href?: string; onClick?: () => void };
}) {
return (
<div className="px-6 py-16 text-center">
{icon && (
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2 text-text-secondary">
{icon}
</div>
)}
<p className="text-[15px] font-semibold text-text-primary">{title}</p>
{description && <p className="mx-auto mt-2 max-w-[46ch] text-sm text-text-secondary">{description}</p>}
{action &&
(action.href ? (
<Button href={action.href} variant="primary" size="sm" className="mt-4">
{action.label}
</Button>
) : (
<Button variant="primary" size="sm" className="mt-4" onClick={action.onClick}>
{action.label}
</Button>
))}
</div>
);
}
export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
return (
<div className="px-6 py-16 text-center" role="alert">
<p className="text-[15px] font-semibold text-text-primary">{friendlyMessage(error)}</p>
{detailOf(error) && <p className="mx-auto mt-2 max-w-[52ch] font-mono text-xs text-text-secondary">{detailOf(error)}</p>}
{onRetry && (
<Button variant="secondary" size="sm" className="mt-4" onClick={onRetry}>
Try again
</Button>
)}
</div>
);
}
/**
* One loading/error/empty decision instead of the same ternary chain rewritten
* in every list page. `isEmpty` is passed rather than inferred, because only
* the caller knows whether an empty array is empty or simply filtered to
* nothing.
*/
export function AsyncBoundary({
isLoading,
error,
isEmpty,
onRetry,
skeleton,
empty,
children,
}: {
isLoading: boolean;
error?: unknown;
isEmpty?: boolean;
onRetry?: () => void;
skeleton?: React.ReactNode;
empty?: React.ReactNode;
children: React.ReactNode;
}) {
if (isLoading) return <>{skeleton ?? <CenteredSpinner />}</>;
if (error) return <ErrorState error={error} onRetry={onRetry} />;
if (isEmpty && empty) return <>{empty}</>;
return <>{children}</>;
}
/*
* Backend messages went straight to the screen. Some are written for an
* operator and are the most useful thing available; some are a Go error string
* or a bare "Failed to fetch" from a dropped connection, which tells the
* customer nothing and reads as a crash. Classify first, then show the detail
* underneath rather than instead of an explanation.
*/
export function friendlyMessage(error: unknown): string {
const status = (error as { status?: number } | null)?.status;
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
// The backend writes its 4xx messages for an operator and they are usually
// the most specific thing available ("default steps cannot be edited",
// "vulnerability scanning is not licensed"). Keep them; only replace the
// ones that are a status code wearing a coat.
const useful = raw && !/^HTTP \d{3}$/.test(raw) && !/^[A-Z][a-z]+( [A-Z]?[a-z]+)*$/.test(raw) ? raw : "";
if (status === 401) return "Your session has expired. Sign in again to continue.";
if (status === 403) return useful || "You do not have permission to do this.";
if (status === 404) return useful || "That is no longer here.";
if (status === 409) return useful || "That conflicts with the current state.";
if (status === 429) return "Too many requests. Wait a moment and try again.";
if (typeof status === "number" && status >= 500) return "The server could not complete that. Try again shortly.";
if (typeof status === "number" && status >= 400) return useful || "That request was rejected.";
// fetch() rejects with a TypeError and no status when the request never
// reached the server at all.
if (!status && /failed to fetch|networkerror|load failed/i.test(raw)) {
return "Cannot reach the server. Check your connection.";
}
return raw || "Something went wrong.";
}
function detailOf(error: unknown): string | null {
const raw = error instanceof Error ? error.message : null;
if (!raw) return null;
return raw === friendlyMessage(error) ? null : raw;
}
+78 -37
View File
@@ -1,13 +1,32 @@
import { ButtonHTMLAttributes, forwardRef } from "react";
import { AnchorHTMLAttributes, ButtonHTMLAttributes, forwardRef } from "react";
import Link from "next/link";
import { clsx } from "clsx";
type Variant = "primary" | "secondary" | "danger" | "ghost";
type Size = "sm" | "md" | "lg";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
interface CommonProps {
variant?: Variant;
size?: Size;
className?: string;
children?: React.ReactNode;
}
interface ButtonProps extends CommonProps, Omit<ButtonHTMLAttributes<HTMLButtonElement>, keyof CommonProps> {
loading?: boolean;
href?: undefined;
}
interface LinkButtonProps extends CommonProps, Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof CommonProps> {
/**
* Renders a next/link styled as this button instead of a <button>.
*
* <Link><Button/></Link> nests an interactive element inside an anchor: the
* markup is invalid, the pair takes two tab stops, and a keyboard Enter fires
* only the outer anchor. Nineteen call sites did that. Pass href here instead.
*/
href: string;
loading?: undefined;
}
/*
@@ -32,45 +51,67 @@ const sizeClasses: Record<Size, string> = {
lg: "px-5 py-2.5 text-base",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{ variant = "primary", size = "md", loading, className, children, disabled, ...props },
ref
) => {
const baseClasses =
"inline-flex items-center gap-2 rounded border font-semibold no-underline transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-background disabled:opacity-50 disabled:cursor-not-allowed active:translate-y-px";
function classesFor(variant: Variant, size: Size, className?: string) {
return clsx(baseClasses, variantClasses[variant], sizeClasses[size], className);
}
function Spinner() {
return (
<svg
className="h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
focusable="false"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
);
}
export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps | LinkButtonProps>(
({ variant = "primary", size = "md", className, children, ...rest }, ref) => {
if (typeof rest.href === "string") {
const { href, ...anchorProps } = rest as LinkButtonProps;
return (
<Link
ref={ref as React.Ref<HTMLAnchorElement>}
href={href}
className={classesFor(variant, size, className)}
{...anchorProps}
>
{children}
</Link>
);
}
const { loading, disabled, ...buttonProps } = rest as ButtonProps;
return (
<button
ref={ref}
ref={ref as React.Ref<HTMLButtonElement>}
disabled={disabled || loading}
className={clsx(
"inline-flex items-center gap-2 rounded border font-semibold transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-background disabled:opacity-50 disabled:cursor-not-allowed active:translate-y-px",
variantClasses[variant],
sizeClasses[size],
className
)}
{...props}
// A control that is busy is still a control; announce it rather than
// leaving a screen reader on the pre-click label with nothing happening.
aria-busy={loading || undefined}
className={classesFor(variant, size, className)}
{...buttonProps}
>
{loading && (
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.instance/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{loading && <Spinner />}
{children}
</button>
);
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useEffect, useId, useState } from "react";
import { Button } from "./Button";
import { Modal } from "./Modal";
/*
* Destructive actions used to go through window.confirm(). That dialog is
* browser chrome: it cannot say what is about to be deleted beyond one line of
* plain text, it looks nothing like the product, it cannot show the error when
* the delete then fails, and it offers the same two buttons whether the action
* removes one key or an entire secret group.
*
* `requireTyped` is for the cases with no undo — deleting a secret group, a
* step used by every workflow. Typing the name is not friction for its own
* sake: it is what stops a muscle-memory Enter from destroying something whose
* name the operator never actually read.
*/
export function ConfirmDialog({
open,
title,
body,
confirmLabel = "Delete",
requireTyped,
destructive = true,
loading,
error,
onConfirm,
onClose,
}: {
open: boolean;
title: string;
body: React.ReactNode;
confirmLabel?: string;
/** When set, the confirm button stays disabled until this exact string is typed. */
requireTyped?: string;
destructive?: boolean;
loading?: boolean;
error?: string | null;
onConfirm: () => void;
onClose: () => void;
}) {
const [typed, setTyped] = useState("");
const inputId = useId();
// A reopened dialog must not carry the previous attempt's typing.
useEffect(() => {
if (open) setTyped("");
}, [open]);
const armed = !requireTyped || typed === requireTyped;
return (
<Modal open={open} title={title} onClose={onClose}>
<div className="space-y-4 text-sm text-text-secondary">
<div className="space-y-2">{body}</div>
{requireTyped && (
<div className="space-y-1.5">
<label htmlFor={inputId} className="block text-xs text-text-secondary">
Type <span className="font-mono text-text-primary">{requireTyped}</span> to confirm
</label>
<input
id={inputId}
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
spellCheck={false}
className="w-full rounded border border-border bg-surface-2 px-3 py-2 font-mono text-sm text-text-primary outline-none focus:border-accent"
/>
</div>
)}
{error && (
<p role="alert" className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-danger">
{error}
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<Button variant="secondary" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
variant={destructive ? "danger" : "primary"}
onClick={onConfirm}
loading={loading}
disabled={!armed}
>
{confirmLabel}
</Button>
</div>
</div>
</Modal>
);
}
+148 -35
View File
@@ -1,45 +1,158 @@
"use client";
import { useEffect } from "react";
import { useCallback, useEffect, useId, useRef } from "react";
/*
* Dialogs nest: a confirm sits on top of the edit modal that raised it. Both
* listen on document, so without a stack Escape would close the pair at once
* and the trap of the covered dialog would fight the top one for focus. Only
* the last opened panel acts.
*/
const stack: symbol[] = [];
const FOCUSABLE = [
"a[href]",
"button:not([disabled])",
"input:not([disabled]):not([type='hidden'])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
export function Modal({
open,
title,
onClose,
children,
wide,
open,
title,
onClose,
children,
wide,
}: {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
wide?: boolean;
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
wide?: boolean;
}) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
const panelRef = useRef<HTMLDivElement>(null);
const restoreRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const idRef = useRef<symbol>(Symbol("modal"));
if (!open) return null;
/*
* Everything below is what an accessible dialog owes the person using it,
* and none of it was here: focus stayed on the page behind, Tab walked out
* of the dialog into content the overlay had covered, the background
* scrolled under the panel, and closing left focus on <body> so the next
* Tab restarted from the top of the document.
*/
return (
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div
className={`relative z-10 w-full ${wide ? "sm:max-w-2xl" : "sm:max-w-md"} max-h-[85dvh] overflow-auto rounded rounded-b-none border border-b-0 border-border bg-surface shadow-panel sm:rounded sm:border-b`}
role="dialog"
aria-modal="true"
>
<div className="flex items-center justify-between border-b border-border px-5 py-3">
<h2 className="text-sm font-bold text-text-primary">{title}</h2>
<button onClick={onClose} className="text-text-secondary hover:text-text-primary" aria-label="Close">
</button>
// Escape closes. Tab is confined to the panel.
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
// Only the topmost dialog reacts.
if (stack[stack.length - 1] !== idRef.current) return;
if (e.key === "Escape") {
e.stopPropagation();
onClose();
return;
}
if (e.key !== "Tab" || !panelRef.current) return;
const items = Array.from(panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.offsetParent !== null || el === document.activeElement,
);
if (items.length === 0) {
// Nothing focusable inside; keep focus on the panel rather than
// letting Tab escape to the page underneath.
e.preventDefault();
panelRef.current.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey && (active === first || active === panelRef.current)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
},
[onClose],
);
useEffect(() => {
if (!open) return;
const id = idRef.current;
stack.push(id);
restoreRef.current = document.activeElement as HTMLElement | null;
// The page behind must not scroll while a dialog is over it. Padding
// replaces the scrollbar's width so the layout does not jump sideways
// as it disappears.
const { body } = document;
const prevOverflow = body.style.overflow;
const prevPadding = body.style.paddingRight;
const gap = window.innerWidth - document.documentElement.clientWidth;
body.style.overflow = "hidden";
if (gap > 0) body.style.paddingRight = `${gap}px`;
document.addEventListener("keydown", onKeyDown, true);
// Focus the first real control, falling back to the panel itself. The
// close button is deliberately not preferred: opening a dialog on its
// dismiss control reads as "are you sure you want to be here".
const target =
panelRef.current?.querySelector<HTMLElement>(FOCUSABLE) ?? panelRef.current;
target?.focus();
return () => {
const at = stack.lastIndexOf(id);
if (at !== -1) stack.splice(at, 1);
document.removeEventListener("keydown", onKeyDown, true);
// Each dialog restores what it found, so an inner one closing over
// an outer one puts back "hidden" rather than releasing the page.
body.style.overflow = prevOverflow;
body.style.paddingRight = prevPadding;
// Return focus to whatever opened the dialog, if it is still there.
if (restoreRef.current?.isConnected) restoreRef.current.focus();
};
}, [open, onKeyDown]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
<div className="absolute inset-0 bg-black/60" onClick={onClose} aria-hidden="true" />
<div
ref={panelRef}
tabIndex={-1}
className={`relative z-10 w-full ${wide ? "sm:max-w-2xl" : "sm:max-w-md"} max-h-[85dvh] overflow-auto rounded rounded-b-none border border-b-0 border-border bg-surface shadow-panel focus:outline-none sm:rounded sm:border-b`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="flex items-center justify-between border-b border-border px-5 py-3">
<h2 id={titleId} className="text-sm font-bold text-text-primary">
{title}
</h2>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-text-secondary transition-colors hover:bg-surface-2 hover:text-text-primary"
aria-label="Close dialog"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div className="p-5">{children}</div>
</div>
</div>
<div className="p-5">{children}</div>
</div>
</div>
);
);
}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { clsx } from "clsx";
import { friendlyMessage } from "./Async";
/*
* Mutations succeeded silently. Copying an install one-liner, generating a key,
* restarting a container, rotating the ESO token — all of them changed
* something and said nothing, so the only way to know it worked was to watch
* for the list to redraw. Failures were worse: each page wired its own
* `onError: setError` into its own inline div, so an error raised by a modal
* that then closed had nowhere to land at all.
*
* No dependency for this. It is a context, a list and a fixed div; sonner would
* be 12KB to render three lines of text in a palette we would then have to
* override anyway.
*/
type ToastKind = "success" | "error" | "info";
interface Toast {
id: number;
kind: ToastKind;
message: string;
}
interface ToastApi {
success: (message: string) => void;
info: (message: string) => void;
/** Accepts a thrown value directly, so call sites do not each re-derive a message. */
error: (error: unknown) => void;
}
const ToastContext = createContext<ToastApi | null>(null);
const DURATION: Record<ToastKind, number> = {
// An error stays four times as long as a confirmation: it is the one the
// reader has to act on, and it may be the only record of what failed.
success: 4000,
info: 5000,
error: 12000,
};
const KIND_CLASSES: Record<ToastKind, string> = {
success: "border-success/40 text-success",
error: "border-danger/40 text-danger",
info: "border-accent/40 text-accent",
};
const KIND_LABEL: Record<ToastKind, string> = {
success: "Success",
error: "Error",
info: "Note",
};
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(1);
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
const dismiss = useCallback((id: number) => {
const timer = timers.current.get(id);
if (timer) {
clearTimeout(timer);
timers.current.delete(id);
}
setToasts((list) => list.filter((t) => t.id !== id));
}, []);
const push = useCallback(
(kind: ToastKind, message: string) => {
const id = nextId.current++;
// Cap the stack. A mutation looping on a failing endpoint would
// otherwise paper over the screen with the same sentence.
setToasts((list) => [...list.slice(-2), { id, kind, message }]);
timers.current.set(
id,
setTimeout(() => dismiss(id), DURATION[kind]),
);
},
[dismiss],
);
const api = useMemo<ToastApi>(
() => ({
success: (message) => push("success", message),
info: (message) => push("info", message),
error: (error) => push("error", friendlyMessage(error)),
}),
[push],
);
return (
<ToastContext.Provider value={api}>
{children}
<div
className="pointer-events-none fixed inset-x-0 bottom-0 z-[60] flex flex-col items-center gap-2 p-4 sm:items-end"
// Announce without stealing focus. Errors are assertive because
// they usually mean the thing the operator asked for did not
// happen; a success can wait for a pause in speech.
aria-live="polite"
aria-atomic="false"
>
{toasts.map((t) => (
<div
key={t.id}
role={t.kind === "error" ? "alert" : "status"}
className={clsx(
"pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded border bg-surface px-4 py-3 text-sm shadow-panel",
KIND_CLASSES[t.kind],
)}
>
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-current" aria-hidden="true" />
<span className="min-w-0 flex-1 break-words text-text-primary">
<span className="sr-only">{KIND_LABEL[t.kind]}: </span>
{t.message}
</span>
<button
type="button"
onClick={() => dismiss(t.id)}
aria-label="Dismiss notification"
className="shrink-0 rounded p-0.5 text-text-secondary transition-colors hover:text-text-primary"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastApi {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used inside <ToastProvider>");
return ctx;
}
+11
View File
@@ -3,4 +3,15 @@ export { Badge } from "./Badge";
export { Card, CardHeader, CardTitle } from "./Card";
export { Table, Thead, Tbody, Tr, Th, Td } from "./Table";
export { Modal } from "./Modal";
export { ConfirmDialog } from "./ConfirmDialog";
export { Pagination, usePagination, PAGE_SIZES } from "./Pagination";
export {
AsyncBoundary,
CenteredSpinner,
EmptyState,
ErrorState,
Spinner,
TableSkeleton,
friendlyMessage,
} from "./Async";
export { ToastProvider, useToast } from "./Toast";
+31 -5
View File
@@ -3,7 +3,7 @@
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { api, WorkflowStep, InputParam } from "@/lib/api";
import { Button, Modal } from "@/components/ui";
import { Button, ConfirmDialog, Modal, friendlyMessage, useToast } from "@/components/ui";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
@@ -16,6 +16,8 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
const [inputs, setInputs] = useState<InputParam[]>(step?.declared_inputs ?? []);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const toast = useToast();
// Default steps are re-seeded from the image on every boot, so the server
// refuses to update or delete them. The form mirrors that rather than
@@ -33,19 +35,22 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
if (step) await api.updateStep(step.step_id, payload);
else await api.createStep(payload);
qc.invalidateQueries({ queryKey: ["steps"] });
toast.success(step ? `Saved ${payload.name}.` : `Created ${payload.name}.`);
onClose();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
} catch (e) { setError(friendlyMessage(e)); } finally { setBusy(false); }
};
const del = async () => {
if (!step || !window.confirm("Delete this step? It will be removed from every workflow that uses it.")) return;
if (!step) return;
setBusy(true); setError(null);
try {
await api.deleteStep(step.step_id);
qc.invalidateQueries({ queryKey: ["steps"] });
qc.invalidateQueries({ queryKey: ["workflow"] });
toast.success(`Deleted ${step.name}.`);
setConfirmingDelete(false);
onClose();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
} catch (e) { setError(friendlyMessage(e)); setConfirmingDelete(false); } finally { setBusy(false); }
};
return (
@@ -106,13 +111,34 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
)}
</div>
<div className="flex items-center justify-between pt-2">
{step && !locked ? <Button variant="danger" onClick={del} loading={busy}>Delete step</Button> : <span />}
{step && !locked ? <Button variant="danger" onClick={() => setConfirmingDelete(true)}>Delete step</Button> : <span />}
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>{locked ? "Close" : "Cancel"}</Button>
{!locked && <Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>}
</div>
</div>
</div>
<ConfirmDialog
open={confirmingDelete}
title="Delete base step"
confirmLabel="Delete step"
// A base step is shared: deleting it edits every workflow that uses it,
// which is not what "delete this one thing" usually implies.
requireTyped={step?.name}
loading={busy}
onClose={() => setConfirmingDelete(false)}
onConfirm={del}
body={
<>
<p>
<span className="font-mono text-text-primary">{step?.name}</span> is a shared library step. Deleting it removes it from every
workflow that references it.
</p>
<p>Runs already recorded keep their snapshot of the script and are not affected.</p>
</>
}
/>
</Modal>
);
}
+28 -5
View File
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { api, Workflow } from "@/lib/api";
import { Button, Modal } from "@/components/ui";
import { Button, ConfirmDialog, Modal, friendlyMessage, useToast } from "@/components/ui";
import { ScheduleCard } from "./ScheduleCard";
import { DualListBox } from "./DualListBox";
@@ -20,6 +20,8 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
const [tagRows, setTagRows] = useState<[string, string][]>(Object.entries(workflow.target_tags ?? {}));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const toast = useToast();
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const { data: knownTags } = useQuery({ queryKey: ["server-tags"], queryFn: () => api.listKnownTags(), staleTime: 60_000 });
@@ -42,23 +44,25 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
target_tags: Object.fromEntries(tagRows.filter(([k, v]) => k && v)),
});
onSaved(updated);
toast.success(`Saved ${updated.name}.`);
onClose();
} catch (e) {
setError((e as Error).message);
setError(friendlyMessage(e));
} finally {
setBusy(false);
}
};
const del = async () => {
if (!window.confirm("Delete this workflow? This cannot be undone.")) return;
setBusy(true);
setError(null);
try {
await api.deleteWorkflow(workflow.workflow_id);
toast.success(`Deleted ${workflow.name}.`);
router.push("/workflows");
} catch (e) {
setError((e as Error).message);
setError(friendlyMessage(e));
setConfirmingDelete(false);
setBusy(false);
}
};
@@ -154,7 +158,7 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
<ScheduleCard workflow={workflow} />
<div className="flex items-center justify-between border-t border-border-soft pt-4">
<Button variant="danger" onClick={del} loading={busy}>
<Button variant="danger" onClick={() => setConfirmingDelete(true)}>
Delete workflow
</Button>
<div className="flex gap-2">
@@ -167,6 +171,25 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
</div>
</div>
</div>
<ConfirmDialog
open={confirmingDelete}
title="Delete workflow"
confirmLabel="Delete workflow"
requireTyped={workflow.name}
loading={busy}
onClose={() => setConfirmingDelete(false)}
onConfirm={del}
body={
<>
<p>
<span className="font-mono text-text-primary">{workflow.name}</span> and its schedule are removed. Its base steps stay
in the library.
</p>
<p>Past runs and their logs are kept, but nothing new can be run from this workflow.</p>
</>
}
/>
</Modal>
);
}