feat(web): typed confirmations on destructive actions; toasts for API outcomes
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 48s

Destructive actions were confirmed by a second danger button rendered where
the first one had been, so a double click on Remove deleted the thing without
the operator ever reading which thing it was. Servers, monitors, secret keys
and sign-in providers now go through ConfirmDialog with requireTyped, matching
the secret-group delete that already worked this way. Notification channels and
vulnerability alert rules get an untyped dialog: both are a name and a URL and
are rebuilt in a minute, but neither had any confirmation at all, and both
silently stop alerts that nobody misses until an incident goes unannounced.

Mutations otherwise succeeded in silence, or reported into whatever inline
banner the page happened to own. Two failure modes came of that: a modal that
closed on error left the message nowhere to land, and a save that was rejected
left the old values on screen looking exactly like a save that worked
(/settings had no error path at all). Every mutation now reports through the
existing toast context.

Errors stay inline where the surface that raised them is still on screen and
the message is a correction to make in it: form validation, the cron field,
the tag rows, a rejected licence blob, and the workflow designer's autosave,
which is a standing condition rather than an event. Everything else toasts.

Ad-hoc feedback removed in favour of it: the "Sent!" button labels on the
server maintenance tab, the settings "Saved!" flag, the channel test line, and
the steps page's notice/error pair.
This commit is contained in:
2026-08-10 14:26:02 +01:00
parent ef86ef04a1
commit 21238fe707
22 changed files with 393 additions and 125 deletions
+6 -1
View File
@@ -3,7 +3,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { Button } from "@/components/ui";
import { Button, useToast } from "@/components/ui";
/*
* A tag is key:value, so the chip shows both halves with the key dimmed — the
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui";
export function TagChips({ serverId, tags, editable = false }: { serverId: string; tags?: Record<string, string>; editable?: boolean }) {
const queryClient = useQueryClient();
const toast = useToast();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<[string, string][]>(Object.entries(tags ?? {}));
const [error, setError] = useState<string | null>(null);
@@ -35,7 +36,11 @@ export function TagChips({ serverId, tags, editable = false }: { serverId: strin
queryClient.invalidateQueries({ queryKey: ["server-tags"] });
setEditing(false);
setError(null);
toast.success("Tags saved.");
},
// Kept inline as well as being an editor that stays open: a rejected tag
// is a correction to make in the rows still on screen (bad character,
// too long, reserved sys: prefix), not a notice to read afterwards.
onError: (e: Error) => setError(e.message),
});
+38 -22
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { api, ServerWithKeys } from "@/lib/api";
import { Badge, Button, Card, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
import { Badge, Button, Card, ConfirmDialog, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
/*
* Everything that changes what is installed on the machine: its OS packages,
@@ -18,23 +18,21 @@ export function MaintenanceTab({
latestVersion,
onApplyUpdates,
isApplying,
applySuccess,
onUpdateAgent,
isUpdatingAgent,
updateAgentSuccess,
onDelete,
isDeleting,
deleteError,
}: {
server: ServerWithKeys;
latestVersion?: string;
onApplyUpdates: () => void;
isApplying: boolean;
applySuccess: boolean;
onUpdateAgent: () => void;
isUpdatingAgent: boolean;
updateAgentSuccess: boolean;
onDelete: () => void;
isDeleting: boolean;
deleteError?: string | null;
}) {
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
@@ -87,7 +85,7 @@ export function MaintenanceTab({
<div className="flex flex-wrap items-center gap-3 border-t border-border px-6 py-4">
<Button variant="primary" loading={isApplying} onClick={onApplyUpdates} disabled={server.status !== "active"} title={server.status !== "active" ? "Agent must be online to apply updates" : undefined}>
{applySuccess ? "Sent!" : "Apply updates"}
Apply updates
</Button>
<p className="text-xs text-text-tertiary">Upgrade runs in the background and may take several minutes.</p>
</div>
@@ -135,7 +133,7 @@ export function MaintenanceTab({
disabled={server.status !== "active"}
title={server.status !== "active" ? "Agent must be online to update" : undefined}
>
{updateAgentSuccess ? "Update sent!" : "Update agent"}
Update agent
</Button>
</div>
</Card>
@@ -148,23 +146,41 @@ export function MaintenanceTab({
<p className="text-sm text-text-secondary">
Deletes this server and its history from Vantage. The agent stays installed on the machine and keeps trying to connect until you uninstall it there.
</p>
{!confirmDelete ? (
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
Remove server
</Button>
) : (
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-danger">Remove {server.hostname}?</span>
<Button variant="danger" loading={isDeleting} onClick={onDelete}>
Confirm
</Button>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
</div>
)}
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
Remove server
</Button>
</div>
</Card>
{/*
* Typing the hostname, rather than a second danger button in the
* same place the first one was. The inline two-step armed and
* confirmed under the same pointer, so a double click on
* "Remove server" deleted the machine and its whole history
* without the operator reading which machine it was.
*/}
<ConfirmDialog
open={confirmDelete}
title="Remove server"
confirmLabel="Remove server"
requireTyped={server.hostname}
loading={isDeleting}
error={deleteError}
onClose={() => setConfirmDelete(false)}
onConfirm={onDelete}
body={
<>
<p>
<span className="font-mono text-text-primary">{server.hostname}</span> and its history keys,
inventory, workflow runs and findings are removed from Vantage.
</p>
<p>
The agent stays installed on the machine and keeps trying to connect until you uninstall it
there.
</p>
</>
}
/>
</div>
</div>
);