feat(web): typed confirmations on destructive actions; toasts for API outcomes
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:
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user