refactor(web): list pages share the async primitives; dialogs hide the page behind

Keys, secrets, audit, steps and workflows each carried the same
loading/error/empty ternary with its own copy of the spinner and its own
wording for a failed fetch — five of them had drifted to five different
sentences for "the request did not come back". They go through AsyncBoundary
now, which means a skeleton in place of a spinner, a retry button on failure,
and backend messages passed through friendlyMessage rather than printed raw.

Steps gets the filtered-empty state the fleet just got: "no steps match that
filter" is not "no steps yet", and only one of them should offer to create the
first one.

Two more from review:

Modal's effect ran before `mounted` flipped, so a dialog rendered already open
found null refs and took no focus at all. It depends on `mounted` now.

aria-modal was a claim with no mechanism behind it — portalled to the body,
the app tree is a sibling of the dialog and a screen reader's virtual cursor
still browsed the page underneath. The body's other children are marked
aria-hidden while any dialog is open, refcounted alongside the scroll lock.
This does mean a toast raised while a dialog is open is not announced, which
is the correct trade for a modal: ConfirmDialog shows its own errors inline.
This commit is contained in:
2026-08-10 09:54:32 +01:00
parent 0cfaf6670c
commit fe1dfe472a
6 changed files with 145 additions and 115 deletions
+17 -15
View File
@@ -2,7 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import { api, AuditEvent } from "@/lib/api";
import { Card } from "@/components/ui";
import { AsyncBoundary, Card, EmptyState, TableSkeleton } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const EVENT_LABELS: Record<string, string> = {
@@ -42,7 +42,7 @@ function EventTypeBadge({ type }: { type: string }) {
}
export default function AuditPage() {
const { data: events, isLoading, error } = useQuery({
const { data: events, isLoading, error, refetch } = useQuery({
queryKey: ["audit"],
queryFn: () => api.listAuditEvents(200),
refetchInterval: 30_000,
@@ -58,13 +58,19 @@ export default function AuditPage() {
</div>
<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 audit log.</div>
) : events && events.length > 0 ? (
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={5} />}
isEmpty={!events || events.length === 0}
empty={
<EmptyState
title="No audit events recorded yet."
description="Every mutating action — a key assigned, a workflow run, a member added — is written here as it happens."
/>
}
>
<Table>
<Thead>
<Tr>
@@ -75,7 +81,7 @@ export default function AuditPage() {
</Tr>
</Thead>
<Tbody>
{events.map((e: AuditEvent) => (
{events?.map((e: AuditEvent) => (
<Tr key={e.id}>
<Td label="Time">
<span className="whitespace-nowrap font-mono text-xs text-text-secondary">
@@ -95,11 +101,7 @@ export default function AuditPage() {
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<p className="text-text-secondary text-sm">No audit events recorded yet.</p>
</div>
)}
</AsyncBoundary>
</Card>
</div>
);
+27 -26
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, Key } from "@/lib/api";
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { AsyncBoundary, Badge, Button, Card, CardHeader, CardTitle, EmptyState, TableSkeleton } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
function UploadKeyModal({ onClose }: { onClose: () => void }) {
@@ -100,6 +100,7 @@ export default function KeysPage() {
data: keys,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ["keys"],
queryFn: api.listKeys,
@@ -125,13 +126,29 @@ export default function KeysPage() {
</div>
<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 keys. Is the backend running?</div>
) : keys && keys.length > 0 ? (
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={5} />}
isEmpty={!keys || keys.length === 0}
empty={
<EmptyState
title="No SSH keys yet."
description="Upload a public key, or have an agent generate one on a server, then assign it to the servers that should accept it."
icon={
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
}
action={{ label: "Upload your first key", onClick: () => setShowUpload(true) }}
/>
}
>
<Table>
<Thead>
<Tr>
@@ -144,7 +161,7 @@ export default function KeysPage() {
</Tr>
</Thead>
<Tbody>
{keys.map((key: Key) => (
{keys?.map((key: Key) => (
<Tr key={key.key_id}>
<Td label="Label">
<span className="font-medium text-text-primary">{key.label}</span>
@@ -173,23 +190,7 @@ export default function KeysPage() {
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div>
<p className="text-text-secondary">No SSH keys yet.</p>
<Button variant="primary" size="sm" className="mt-4" onClick={() => setShowUpload(true)}>
Upload your first key
</Button>
</div>
)}
</AsyncBoundary>
</Card>
</div>
);
+23 -25
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, SecretGroupSummary } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { AsyncBoundary, Button, Card, EmptyState, TableSkeleton } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
@@ -93,7 +93,7 @@ function NewGroupModal({ onClose }: { onClose: () => void }) {
export default function SecretsPage() {
const [showNew, setShowNew] = useState(false);
const { data: groups, isLoading, error } = useQuery({
const { data: groups, isLoading, error, refetch } = useQuery({
queryKey: ["secret-groups"],
queryFn: api.listSecretGroups,
});
@@ -118,15 +118,25 @@ export default function SecretsPage() {
</div>
<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 secrets. Is the backend running?
</div>
) : groups && groups.length > 0 ? (
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={4} />}
isEmpty={!groups || groups.length === 0}
empty={
<EmptyState
title="No secret groups yet."
description="A group holds related key/value pairs, encrypted at rest, and is read by workflow steps and External Secrets."
icon={
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
}
action={{ label: "Create your first group", onClick: () => setShowNew(true) }}
/>
}
>
<Table>
<Thead>
<Tr>
@@ -137,7 +147,7 @@ export default function SecretsPage() {
</Tr>
</Thead>
<Tbody>
{groups.map((g: SecretGroupSummary) => (
{groups?.map((g: SecretGroupSummary) => (
<Tr key={g.group}>
<Td label="Group">
<span className="font-mono font-medium text-text-primary">{g.group}</span>
@@ -162,19 +172,7 @@ export default function SecretsPage() {
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
<p className="text-text-secondary">No secret groups yet.</p>
<Button variant="primary" size="sm" className="mt-4" onClick={() => setShowNew(true)}>
Create your first group
</Button>
</div>
)}
</AsyncBoundary>
</Card>
</div>
);
+31 -24
View File
@@ -3,7 +3,7 @@
import { useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, WorkflowStep } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { AsyncBoundary, Button, Card, EmptyState, TableSkeleton } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { EditStepModal } from "@/components/workflows/EditStepModal";
@@ -18,7 +18,7 @@ function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) {
export default function StepsPage() {
const qc = useQueryClient();
const { data: steps, isLoading, error: loadError } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
const { data: steps, isLoading, error: loadError, refetch } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
const { data: usage } = useQuery({ queryKey: ["step-usage"], queryFn: api.stepUsage });
const [search, setSearch] = useState("");
@@ -134,13 +134,34 @@ export default function StepsPage() {
</div>
<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>
) : loadError ? (
<div className="py-20 text-center text-danger">Failed to load steps. Is the backend running?</div>
) : rows.length > 0 ? (
<AsyncBoundary
isLoading={isLoading}
error={loadError}
onRetry={refetch}
skeleton={<TableSkeleton columns={5} />}
isEmpty={rows.length === 0}
empty={
// Filtered to nothing and owning nothing are different
// situations and want different ways out.
steps && steps.length > 0 ? (
<EmptyState
title="No steps match that filter."
description="Clear the search or pick a different source to see the rest of the library."
/>
) : (
<EmptyState
title="No steps yet."
description="A step is one script with declared inputs and outputs. Workflows are built by composing them."
icon={
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z" />
</svg>
}
action={{ label: "Create your first step", onClick: openNew }}
/>
)
}
>
<Table>
<Thead>
<Tr>
@@ -204,21 +225,7 @@ export default function StepsPage() {
})}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
<p className="text-text-secondary">{steps && steps.length > 0 ? "No steps match that filter." : "No steps yet."}</p>
{(!steps || steps.length === 0) && (
<Button variant="primary" size="sm" className="mt-4" onClick={openNew}>
Create your first step
</Button>
)}
</div>
)}
</AsyncBoundary>
</Card>
<EditStepModal
+23 -22
View File
@@ -5,7 +5,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, Workflow } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { AsyncBoundary, Button, Card, EmptyState, TableSkeleton } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { resolveTargets } from "@/lib/targets";
@@ -18,6 +18,7 @@ export default function WorkflowsPage() {
data: workflows,
isLoading,
error: loadError,
refetch,
} = useQuery({
queryKey: ["workflows"],
queryFn: api.listWorkflows,
@@ -57,13 +58,25 @@ export default function WorkflowsPage() {
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<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>
) : loadError ? (
<div className="py-20 text-center text-danger">Failed to load workflows. Is the backend running?</div>
) : workflows && workflows.length > 0 ? (
<AsyncBoundary
isLoading={isLoading}
error={loadError}
onRetry={refetch}
skeleton={<TableSkeleton columns={5} />}
isEmpty={!workflows || workflows.length === 0}
empty={
<EmptyState
title="No workflows yet."
description="A workflow composes library steps and runs them across the servers you target, by name or by tag."
icon={
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
}
action={{ label: "Create your first workflow", onClick: () => create() }}
/>
}
>
<Table>
<Thead>
<Tr>
@@ -75,7 +88,7 @@ export default function WorkflowsPage() {
</Tr>
</Thead>
<Tbody>
{workflows.map((w: Workflow) => (
{workflows?.map((w: Workflow) => (
<Tr key={w.workflow_id}>
<Td label="Name">
<span className="font-medium text-text-primary">{w.name}</span>
@@ -126,19 +139,7 @@ export default function WorkflowsPage() {
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<p className="text-text-secondary">No workflows yet.</p>
<Button variant="primary" size="sm" className="mt-4" loading={isPending} onClick={() => create()}>
Create your first workflow
</Button>
</div>
)}
</AsyncBoundary>
</Card>
</div>
);