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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user