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
+12 -1
View File
@@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, Server } from "@/lib/api";
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Badge, Button, Card, CardHeader, CardTitle, useToast } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
function AssignModal({
@@ -18,6 +18,7 @@ function AssignModal({
onClose: () => void;
}) {
const queryClient = useQueryClient();
const toast = useToast();
const [selectedServer, setSelectedServer] = useState("");
const { data: servers } = useQuery({
@@ -30,6 +31,11 @@ function AssignModal({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keys", keyId] });
queryClient.invalidateQueries({ queryKey: ["servers"] });
// Toasted before the modal closes: the confirmation would otherwise be
// the row appearing in a table the operator is no longer looking at.
toast.success(
`Key assigned to ${servers?.find((s: Server) => s.server_id === selectedServer)?.hostname ?? "the server"}. The agent applies it within 30 seconds.`,
);
onClose();
},
});
@@ -188,6 +194,7 @@ export default function KeyDetailPage() {
const [showAssign, setShowAssign] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [copiedKey, setCopiedKey] = useState(false);
const toast = useToast();
const { data: key, isLoading, error } = useQuery({
queryKey: ["keys", keyId],
@@ -199,15 +206,19 @@ export default function KeyDetailPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keys", keyId] });
queryClient.invalidateQueries({ queryKey: ["servers"] });
toast.success("Access revoked. The agent rewrites authorized_keys within 30 seconds.");
},
onError: toast.error,
});
const { mutate: deleteKey, isPending: isDeleting } = useMutation({
mutationFn: () => api.deleteKey(keyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keys"] });
toast.success(`Deleted ${key?.label ?? "the key"}.`);
router.push("/keys");
},
onError: toast.error,
});
const handleCopyKey = async () => {
+6 -1
View File
@@ -4,11 +4,12 @@ import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, Key } from "@/lib/api";
import { AsyncBoundary, Badge, Button, Card, CardHeader, CardTitle, EmptyState, TableSkeleton } from "@/components/ui";
import { AsyncBoundary, Badge, Button, Card, CardHeader, CardTitle, EmptyState, TableSkeleton, useToast } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
function UploadKeyModal({ onClose }: { onClose: () => void }) {
const queryClient = useQueryClient();
const toast = useToast();
const [label, setLabel] = useState("");
const [publicKey, setPublicKey] = useState("");
const [privateKey, setPrivateKey] = useState("");
@@ -22,8 +23,12 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) {
mutationFn: () => api.uploadKey(label.trim(), publicKey.trim(), privateKey.trim() || undefined, passphrase || undefined),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keys"] });
toast.success(`Uploaded ${label.trim()}. Assign it to a server to grant access.`);
onClose();
},
// The error stays inline in the modal, next to the fields that caused
// it. A rejected public key is a correction to make here, not a
// notification to read after the form has gone.
});
return (
@@ -5,11 +5,13 @@ import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, MonitorInput } from "@/lib/api";
import { MonitorForm } from "@/components/monitors/MonitorForm";
import { useToast } from "@/components/ui";
export default function EditMonitorPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const toast = useToast();
const monitorId = params.id as string;
const { data: monitor, isLoading } = useQuery({
@@ -26,6 +28,9 @@ export default function EditMonitorPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["monitors"] });
queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] });
// Same reasoning as the create page: the form navigates away, so
// only a toast outlives the transition. The error stays inline.
toast.success(`Saved ${monitor?.name ?? "the monitor"}.`);
router.push(`/monitors/${monitorId}`);
},
});
+43 -18
View File
@@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, Incident, Rollup } from "@/lib/api";
import { Button } from "@/components/ui";
import { Button, ConfirmDialog, friendlyMessage, useToast } from "@/components/ui";
import {
Slot,
StatusChip,
@@ -182,6 +182,7 @@ export default function MonitorDetailPage() {
const queryClient = useQueryClient();
const monitorId = params.id as string;
const [confirmDelete, setConfirmDelete] = useState(false);
const toast = useToast();
const { data: monitor, isLoading } = useQuery({
queryKey: ["monitors", monitorId],
@@ -213,17 +214,28 @@ export default function MonitorDetailPage() {
enabled: !!monitor && monitor.runner !== "server",
});
const { mutate: deleteMonitor, isPending: isDeleting } = useMutation({
const {
mutate: deleteMonitor,
isPending: isDeleting,
error: deleteError,
} = useMutation({
mutationFn: () => api.deleteMonitor(monitorId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["monitors"] });
toast.success(`Deleted ${monitor?.name ?? "the monitor"}.`);
router.push("/monitors");
},
// The dialog stays open and shows this; a toast would report it behind
// the thing the operator is looking at.
});
const { mutate: toggleEnabled, isPending: isToggling } = useMutation({
mutationFn: (enabled: boolean) => api.updateMonitor(monitorId, { enabled }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] }),
onSuccess: (_data, enabled) => {
queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] });
toast.success(enabled ? "Checks resumed." : "Checks paused. No alerts will be raised while paused.");
},
onError: toast.error,
});
if (isLoading) {
@@ -289,24 +301,37 @@ export default function MonitorDetailPage() {
<Button variant="secondary" loading={isToggling} onClick={() => toggleEnabled(!monitor.enabled)}>
{monitor.enabled ? "Pause checks" : "Resume checks"}
</Button>
{!confirmDelete ? (
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
Delete
</Button>
) : (
<div className="flex items-center gap-2">
<span className="text-sm text-danger">Delete this monitor and its history?</span>
<Button variant="danger" loading={isDeleting} onClick={() => deleteMonitor()}>
Delete
</Button>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Keep
</Button>
</div>
)}
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
Delete
</Button>
</div>
</div>
{/*
* Typed, like the server and secret-group deletes. Uptime history
* and incidents go with the monitor and there is nothing to restore
* them from, and this button sits inches from "Pause checks" — the
* reversible action someone reaching for it usually wanted.
*/}
<ConfirmDialog
open={confirmDelete}
title="Delete monitor"
requireTyped={monitor.name}
loading={isDeleting}
error={deleteError ? friendlyMessage(deleteError) : null}
onClose={() => setConfirmDelete(false)}
onConfirm={() => deleteMonitor()}
body={
<>
<p>
<span className="font-mono text-text-primary">{monitor.name}</span> is deleted along with its
uptime history and incidents.
</p>
<p>To stop it checking without losing the history, pause it instead.</p>
</>
}
/>
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-[minmax(0,1fr)_300px]">
<div className="flex flex-col gap-5">
<div className="rounded-lg border border-border bg-surface">
+6
View File
@@ -5,10 +5,12 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { api, MonitorInput } from "@/lib/api";
import { MonitorForm } from "@/components/monitors/MonitorForm";
import { useToast } from "@/components/ui";
export default function NewMonitorPage() {
const router = useRouter();
const queryClient = useQueryClient();
const toast = useToast();
const {
mutate: create,
@@ -18,6 +20,10 @@ export default function NewMonitorPage() {
mutationFn: (input: MonitorInput) => api.createMonitor(input),
onSuccess: (m) => {
queryClient.invalidateQueries({ queryKey: ["monitors"] });
// Toasted because the page navigates: the confirmation has to
// survive the route change, which is the one thing an inline
// banner on the form cannot do. The error stays on the form.
toast.success(`Created ${m.name}. First check runs within its interval.`);
router.push(`/monitors/${m.monitor_id}`);
},
});
+9
View File
@@ -198,6 +198,11 @@ function SecretRow({ group, secret }: { group: string; secret: Secret }) {
open={confirming}
title="Delete key"
confirmLabel="Delete key"
// Typed, like the group delete above it. A secret cannot be
// read back before it is deleted — only revealed — so there
// is no way to put it back afterwards from anything Vantage
// holds.
requireTyped={secret.key}
loading={removing}
error={removeError ? friendlyMessage(removeError) : null}
onClose={() => {
@@ -222,6 +227,7 @@ function SecretRow({ group, secret }: { group: string; secret: Secret }) {
function AddKeyCard({ group }: { group: string }) {
const queryClient = useQueryClient();
const toast = useToast();
const [key, setKey] = useState("");
const [value, setValue] = useState("");
@@ -233,6 +239,9 @@ function AddKeyCard({ group }: { group: string }) {
mutationFn: () => api.putSecrets(group, { [key.trim()]: value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-group", group] });
// Named, because this form overwrites silently when the key exists
// and the row it changed may be off screen in a long group.
toast.success(`Saved ${key.trim()}.`);
setKey("");
setValue("");
},
+4 -1
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 { AsyncBoundary, Button, Card, EmptyState, TableSkeleton } from "@/components/ui";
import { AsyncBoundary, Button, Card, EmptyState, TableSkeleton, useToast } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
@@ -12,6 +12,7 @@ const inputClass =
function NewGroupModal({ onClose }: { onClose: () => void }) {
const queryClient = useQueryClient();
const toast = useToast();
const [group, setGroup] = useState("");
const [key, setKey] = useState("");
const [value, setValue] = useState("");
@@ -21,8 +22,10 @@ function NewGroupModal({ onClose }: { onClose: () => void }) {
api.createSecretGroup(group.trim(), { [key.trim()]: value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
toast.success(`Created the ${group.trim()} group.`);
onClose();
},
// Error stays inline: a rejected group name is corrected in this form.
});
return (
+25 -14
View File
@@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { api, GenerateKeyOptions, ServerStatus, vulnerabilities, workloads as workloadsApi } from "@/lib/api";
import { Badge } from "@/components/ui";
import { Badge, friendlyMessage, useToast } from "@/components/ui";
import { useLicense } from "@/lib/useLicense";
import { TagChips } from "@/components/servers/TagChips";
import { ServerVulnerabilities } from "@/components/vulnerabilities/ServerVulnerabilities";
@@ -52,9 +52,8 @@ export default function ServerDetailPage() {
const serverId = params.id as string;
const [showGenerateModal, setShowGenerateModal] = useState(false);
const [updateSuccess, setUpdateSuccess] = useState(false);
const [applySuccess, setApplySuccess] = useState(false);
const panelsRef = useRef<HTMLDivElement>(null);
const toast = useToast();
const { hasFeature } = useLicense();
// Control actions and log reads are owner|admin server-side; the UI matches
@@ -111,36 +110,49 @@ export default function ServerDetailPage() {
setShowGenerateModal(false);
queryClient.invalidateQueries({ queryKey: ["servers", serverId] });
queryClient.invalidateQueries({ queryKey: ["keys"] });
toast.success("Key generation requested. It appears in the key library once the agent reports back.");
},
onError: toast.error,
});
/*
* These three dispatch a command to the agent; none of them waits for it to
* finish. The toast says "sent", not "done", because "Updated" would be a
* claim about a machine this response knows nothing about yet. They used to
* flip the button's own label to "Sent!" for four seconds, which said the
* same thing in the one place the operator stops looking at once clicked.
*/
const { mutate: triggerUpdate, isPending: isUpdating } = useMutation({
mutationFn: () => api.updateAgent(serverId),
onSuccess: () => {
setUpdateSuccess(true);
setTimeout(() => setUpdateSuccess(false), 4000);
},
onSuccess: () => toast.success("Agent update sent. The agent replaces itself and reconnects on the new build."),
onError: toast.error,
});
const { mutate: applyUpdates, isPending: isApplying } = useMutation({
mutationFn: () => api.applyUpdates(serverId),
onSuccess: () => {
setApplySuccess(true);
setTimeout(() => setApplySuccess(false), 4000);
},
onSuccess: () => toast.success("Update command sent. Patching runs in the background and may take several minutes."),
onError: toast.error,
});
const { mutate: refreshWorkloads } = useMutation({
mutationFn: () => workloadsApi.refresh(serverId),
onSuccess: () => setTimeout(() => queryClient.invalidateQueries({ queryKey: ["workloads", serverId] }), 1500),
onError: toast.error,
});
const { mutate: deleteServer, isPending: isDeleting } = useMutation({
const {
mutate: deleteServer,
isPending: isDeleting,
error: deleteError,
} = useMutation({
mutationFn: () => api.deleteServer(serverId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["servers"] });
toast.success(`Removed ${server?.hostname ?? "the server"}.`);
router.push("/servers");
},
// Not toasted: this one has a dialog open in front of it, and the error
// belongs where the operator is still looking and can retry.
});
const openFindings = useMemo(() => (findings ?? []).filter((f) => f.state === "open"), [findings]);
@@ -298,12 +310,11 @@ export default function ServerDetailPage() {
latestVersion={latestVersion?.version}
onApplyUpdates={() => applyUpdates()}
isApplying={isApplying}
applySuccess={applySuccess}
onUpdateAgent={() => triggerUpdate()}
isUpdatingAgent={isUpdating}
updateAgentSuccess={updateSuccess}
onDelete={() => deleteServer()}
isDeleting={isDeleting}
deleteError={deleteError ? friendlyMessage(deleteError) : null}
/>
)}
</div>
+8 -1
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { licence, type LicenseInfo, type LicenseState } from "@/lib/api";
import { useLicense } from "@/lib/useLicense";
import { Button, Card } from "@/components/ui";
import { Button, Card, useToast } from "@/components/ui";
import { Group } from "@/components/settings/Group";
import { inputClass } from "@/components/settings/Field";
@@ -186,6 +186,7 @@ function RecordPanel({ license }: { license: LicenseInfo }) {
export default function LicensePage() {
const { license, isLoading } = useLicense();
const queryClient = useQueryClient();
const toast = useToast();
const [blob, setBlob] = useState("");
const [fileName, setFileName] = useState("");
const [error, setError] = useState("");
@@ -197,7 +198,13 @@ export default function LicensePage() {
setFileName("");
setError("");
queryClient.invalidateQueries({ queryKey: ["license"] });
// The panel above rerenders with the new tier and expiry, but the
// textarea being cleared is the only thing that changes down here,
// and that looks identical to a paste that was thrown away.
toast.success("Licence applied.");
},
// Inline as well: a rejected blob is a bad paste to fix in the field
// still holding it, and this is the way out of degraded mode.
onError: (e: Error) => setError(e.message),
});
+51 -10
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, ChannelInput, ChannelType, NotificationChannel } from "@/lib/api";
import { Badge, Button, Card } from "@/components/ui";
import { Badge, Button, Card, ConfirmDialog, friendlyMessage, useToast } from "@/components/ui";
import { VulnAlertRulesCard } from "@/components/vulnerabilities/VulnAlertRulesCard";
const inputClass =
@@ -22,6 +22,7 @@ const CONFIG_FIELDS: Record<ChannelType, string[]> = {
function ChannelForm({ initial, onDone }: { initial?: NotificationChannel; onDone: () => void }) {
const queryClient = useQueryClient();
const toast = useToast();
const [name, setName] = useState(initial?.name ?? "");
const [type, setType] = useState<ChannelType>(initial?.type ?? "webhook");
const [config, setConfig] = useState<Record<string, string>>(initial?.config ?? {});
@@ -31,8 +32,10 @@ function ChannelForm({ initial, onDone }: { initial?: NotificationChannel; onDon
initial ? api.updateChannel(initial.channel_id, input) : api.createChannel(input).then(() => undefined),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["channels"] });
toast.success(initial ? `Saved ${name.trim() || initial.name}.` : `Created ${name.trim()}.`);
onDone();
},
// Error stays inline on the form, beside the config fields it is about.
});
return (
@@ -90,23 +93,40 @@ function ChannelForm({ initial, onDone }: { initial?: NotificationChannel; onDon
function ChannelRow({ ch }: { ch: NotificationChannel }) {
const queryClient = useQueryClient();
const [testMsg, setTestMsg] = useState<string | null>(null);
const toast = useToast();
const [editing, setEditing] = useState(false);
const [confirming, setConfirming] = useState(false);
const { mutate: remove } = useMutation({
const {
mutate: remove,
isPending: removing,
error: removeError,
} = useMutation({
mutationFn: () => api.deleteChannel(ch.channel_id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["channels"] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["channels"] });
queryClient.invalidateQueries({ queryKey: ["monitors"] });
toast.success(`Deleted ${ch.name}.`);
setConfirming(false);
},
});
const { mutate: test, isPending: testing } = useMutation({
mutationFn: () => api.testChannel(ch.channel_id),
onSuccess: () => setTestMsg("Sent!"),
onError: (e) => setTestMsg((e as Error).message),
// A test result is the answer to a question just asked, so it goes to the
// same place every other answer does rather than to a line of grey text
// under the row that stayed there until the page was reloaded.
onSuccess: () => toast.success(`Test message sent to ${ch.name}.`),
onError: toast.error,
});
const { mutate: toggle } = useMutation({
mutationFn: (enabled: boolean) => api.updateChannel(ch.channel_id, { enabled }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["channels"] }),
onSuccess: (_data, enabled) => {
queryClient.invalidateQueries({ queryKey: ["channels"] });
toast.success(enabled ? `${ch.name} enabled.` : `${ch.name} disabled. Monitors using it will not notify.`);
},
onError: toast.error,
});
if (editing) {
@@ -125,7 +145,6 @@ function ChannelRow({ ch }: { ch: NotificationChannel }) {
<Badge variant="neutral">{ch.type}</Badge>
{!ch.enabled && <Badge variant="warning">disabled</Badge>}
</div>
{testMsg && <p className="mt-1 text-xs text-text-secondary">{testMsg}</p>}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" loading={testing} onClick={() => test()}>
@@ -137,10 +156,32 @@ function ChannelRow({ ch }: { ch: NotificationChannel }) {
<Button variant="ghost" size="sm" onClick={() => toggle(!ch.enabled)}>
{ch.enabled ? "Disable" : "Enable"}
</Button>
<Button variant="danger" size="sm" onClick={() => remove()}>
Delete
<Button variant="danger" size="sm" onClick={() => setConfirming(true)}>
Delete<span className="sr-only"> {ch.name}</span>
</Button>
</div>
{/*
* Not typed, unlike a server or a secret: a channel is a name and a URL
* and can be recreated in a minute. What it does need is any confirmation
* at all Delete sat inches from Test and Disable and fired on one
* click, silently detaching every monitor that alerted through it.
*/}
<ConfirmDialog
open={confirming}
title="Delete channel"
confirmLabel="Delete channel"
loading={removing}
error={removeError ? friendlyMessage(removeError) : null}
onClose={() => setConfirming(false)}
onConfirm={() => remove()}
body={
<p>
<span className="font-mono text-text-primary">{ch.name}</span> is deleted. Monitors alerting through it stop
notifying until another channel is attached.
</p>
}
/>
</div>
);
}
+13 -6
View File
@@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Button, Card } from "@/components/ui";
import { Button, Card, useToast } from "@/components/ui";
import { Field } from "@/components/settings/Field";
import { Group } from "@/components/settings/Group";
import { SectionCard } from "@/components/settings/SectionCard";
@@ -65,6 +65,7 @@ function KeyIcon() {
function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedAt?: string }) {
const queryClient = useQueryClient();
const toast = useToast();
const [token, setToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const readUrl = typeof window !== "undefined" ? `${window.location.origin}/api/secrets/<group>/values` : "/api/secrets/<group>/values";
@@ -74,7 +75,11 @@ function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedA
onSuccess: (res) => {
setToken(res.token);
queryClient.invalidateQueries({ queryKey: ["settings"] });
// The new token is shown once, in the card. The toast says the old
// one is dead, which is the half that affects things off this page.
toast.success("Token rotated. The previous token no longer reads secrets.");
},
onError: toast.error,
});
async function copy() {
@@ -139,7 +144,7 @@ export default function SettingsPage() {
const [thresholdMinutes, setThresholdMinutes] = useState(5);
const [logRetentionDays, setLogRetentionDays] = useState(30);
const [offlineChannelIds, setOfflineChannelIds] = useState<string[]>([]);
const [saved, setSaved] = useState(false);
const toast = useToast();
useEffect(() => {
if (!settings) return;
@@ -169,9 +174,12 @@ export default function SettingsPage() {
mutationFn: (payload: Parameters<typeof api.saveSettings>[0]) => api.saveSettings(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
toast.success("Settings saved.");
},
// This form had no error path at all: a rejected save left the button
// idle and the old values on screen, which reads exactly like a save
// that worked.
onError: toast.error,
});
function handleSubmit(e: React.FormEvent) {
@@ -288,9 +296,8 @@ export default function SettingsPage() {
<div className="mt-6 flex items-center gap-3">
<Button type="submit" variant="primary" loading={isPending}>
{saved ? "Saved!" : "Save settings"}
Save settings
</Button>
{saved && <span className="text-sm text-success">Settings saved successfully.</span>}
</div>
</form>
</Group>
+8 -12
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 { AsyncBoundary, Button, Card, EmptyState, TableSkeleton } from "@/components/ui";
import { AsyncBoundary, Button, Card, EmptyState, TableSkeleton, useToast } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { EditStepModal } from "@/components/workflows/EditStepModal";
@@ -27,8 +27,7 @@ export default function StepsPage() {
const [editing, setEditing] = useState<WorkflowStep | null>(null);
const [importing, setImporting] = useState(false);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const toast = useToast();
const fileRef = useRef<HTMLInputElement>(null);
const rows = useMemo(() => {
@@ -58,14 +57,15 @@ export default function StepsPage() {
const file = e.target.files?.[0];
if (!file) return;
setImporting(true);
setError(null);
try {
const doc = JSON.parse(await file.text());
await api.importStep(doc);
qc.invalidateQueries({ queryKey: ["steps"] });
setNotice("Step imported.");
toast.success(`Imported ${doc?.name ?? "step"}.`);
} catch (err) {
setError((err as Error).message);
// Covers the JSON parse as well as the request: a malformed file
// and a rejected step are the same failure to the person here.
toast.error(err);
} finally {
setImporting(false);
e.target.value = "";
@@ -74,13 +74,12 @@ export default function StepsPage() {
const onSync = async () => {
setSyncing(true);
setError(null);
try {
const { created, updated } = await api.seedDefaults();
qc.invalidateQueries({ queryKey: ["steps"] });
setNotice(`${created} created, ${updated} updated`);
toast.success(`Default steps synced: ${created} created, ${updated} updated.`);
} catch (err) {
setError((err as Error).message);
toast.error(err);
} finally {
setSyncing(false);
}
@@ -112,9 +111,6 @@ export default function StepsPage() {
</div>
</div>
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
{notice && <div className="mb-4 rounded-lg border border-accent/30 bg-accent/10 px-3 py-2 text-sm text-accent">{notice}</div>}
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<input className={`${inputClass} sm:max-w-sm`} placeholder="Search steps…" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="flex flex-wrap gap-1.5">
+21 -3
View File
@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, vulnerabilities, type FindingState, type Severity, type VulnFinding } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Button, Card, Pagination, usePagination } from "@/components/ui";
import { Button, Card, Pagination, usePagination, useToast } from "@/components/ui";
import { AcceptDialog } from "@/components/vulnerabilities/AcceptDialog";
import { DBFreshness } from "@/components/vulnerabilities/DBFreshness";
import { PackageRow } from "@/components/vulnerabilities/PackageRow";
@@ -79,9 +79,17 @@ export default function VulnerabilitiesPage() {
qc.invalidateQueries({ queryKey: ["vulnerabilities"] });
};
const toast = useToast();
const rescan = useMutation({
mutationFn: () => vulnerabilities.rescan(),
onSuccess: invalidate,
onSuccess: () => {
invalidate();
// "Queued", not "complete": matching happens on the leader's next
// tick, so findings arrive after this response, not with it.
toast.success("Rescan queued. Findings update once the scheduler runs.");
},
onError: toast.error,
});
const accept = useMutation({
@@ -89,16 +97,26 @@ export default function VulnerabilitiesPage() {
onSuccess: () => {
setAccepting(null);
invalidate();
toast.success("Finding accepted. It reopens automatically when the acceptance expires.");
},
onError: toast.error,
});
const unaccept = useMutation({
mutationFn: (id: string) => vulnerabilities.unaccept(id),
onSuccess: invalidate,
onSuccess: () => {
invalidate();
toast.success("Acceptance withdrawn. The finding is open again.");
},
onError: toast.error,
});
const applyUpdates = useMutation({
mutationFn: (serverId: string) => api.applyUpdates(serverId),
// This one had no feedback of any kind: the button dispatched a patch
// run to a whole server and the page did not change in any way.
onSuccess: (_data, serverId) => toast.success(`Update command sent to ${serverName(serverId)}.`),
onError: toast.error,
});
const counts = summary.data?.counts ?? {};
+12 -4
View File
@@ -5,7 +5,7 @@ import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { api, Workflow, WorkflowStep, WorkflowStepRef, SecretGroupSummary } from "@/lib/api";
import { Button } from "@/components/ui";
import { Button, useToast } from "@/components/ui";
import { EditWorkflowModal } from "@/components/workflows/EditWorkflowModal";
import { StepPickerModal } from "@/components/workflows/StepPickerModal";
import { resolveTargets } from "@/lib/targets";
@@ -56,6 +56,10 @@ export default function WorkflowBuilder() {
const savingRef = useRef(false);
const wfRef = useRef<Workflow | null>(null);
const [running, setRunning] = useState(false);
const toast = useToast();
// Kept as page state, not toasted: this is the standing condition "your
// edits are not saved", which has to persist until a save succeeds. The
// autosave has no button to report back to.
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [importingInline, setImportingInline] = useState(false);
@@ -174,7 +178,11 @@ export default function WorkflowBuilder() {
const { run_id } = await api.runWorkflow(id);
router.push(`/workflows/${id}/runs/${run_id}`);
} catch (e) {
setError((e as Error).message);
// Toasted rather than banner-ed: no targets, no steps, or an
// offline agent all fail here, and the designer is a scrolling
// canvas where a banner at the top may be nowhere near the Run
// button that was just pressed.
toast.error(e);
setRunning(false);
}
};
@@ -599,13 +607,13 @@ export default function WorkflowBuilder() {
onImportAdhoc={async (file) => {
setPickerOpen(false);
setImportingInline(true);
setError(null);
try {
const doc = JSON.parse(await file.text());
const step = await api.parseStep(doc);
appendRef({ inline: step, order: wf.steps.length, on_failure: "stop", max_retries: 0 });
toast.success(`Added ${step.name} to this workflow.`);
} catch (err) {
setError((err as Error).message);
toast.error(err);
} finally {
setImportingInline(false);
}
+5 -5
View File
@@ -5,14 +5,14 @@ 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 { AsyncBoundary, Button, Card, EmptyState, TableSkeleton } from "@/components/ui";
import { AsyncBoundary, Button, Card, EmptyState, TableSkeleton, useToast } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { resolveTargets } from "@/lib/targets";
export default function WorkflowsPage() {
const qc = useQueryClient();
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const toast = useToast();
const {
data: workflows,
@@ -35,7 +35,9 @@ export default function WorkflowsPage() {
qc.invalidateQueries({ queryKey: ["workflows"] });
router.push(`/workflows/${workflow.workflow_id}`);
},
onError: (err) => setError((err as Error).message),
// Toasted rather than held in page state: this mutation navigates on
// success, so its error banner lived on a page that was on its way out.
onError: toast.error,
});
return (
@@ -55,8 +57,6 @@ export default function WorkflowsPage() {
</Button>
</div>
{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}>
<AsyncBoundary
isLoading={isLoading}
+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>
);
+56 -7
View File
@@ -3,7 +3,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type AuthProvider, type AuthPreset } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { Button, Card, ConfirmDialog, friendlyMessage, useToast } from "@/components/ui";
import { Field, inputClass } from "./Field";
import { SectionCard } from "./SectionCard";
import { ProviderIcon } from "./ProviderIcon";
@@ -42,6 +42,7 @@ function CallbackRow({ url }: { url: string }) {
}
function AddProviderForm({ presets, onDone }: { presets: AuthPreset[]; onDone: () => void }) {
const toast = useToast();
const [preset, setPreset] = useState<string>("google");
const [name, setName] = useState("");
const [issuerInput, setIssuerInput] = useState("");
@@ -60,7 +61,11 @@ function AddProviderForm({ presets, onDone }: { presets: AuthPreset[]; onDone: (
client_secret: clientSecret,
enabled: true,
}),
onSuccess: onDone,
onSuccess: () => {
toast.success(`${name || chosen?.label || "Provider"} added. Register its callback URL with your identity provider before signing in.`);
onDone();
},
// Error stays inline on the form: a rejected issuer is corrected here.
});
return (
@@ -116,28 +121,46 @@ function AddProviderForm({ presets, onDone }: { presets: AuthPreset[]; onDone: (
function ProviderRow({ p }: { p: AuthProvider }) {
const queryClient = useQueryClient();
const toast = useToast();
const [confirming, setConfirming] = useState(false);
const [secret, setSecret] = useState("");
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["auth-providers"] });
const { mutate: update, error: updateError } = useMutation({
mutationFn: (patch: Parameters<typeof api.updateAuthProvider>[1]) => api.updateAuthProvider(p.provider_id, patch),
onSuccess: () => {
onSuccess: (_data, patch) => {
setSecret("");
invalidate();
// The lockout guard answers 409 here — the last way in cannot be
// switched off — so the outcome of this toggle is worth stating
// rather than leaving to a checkbox that may have sprung back.
toast.success(patch.client_secret ? `Client secret updated for ${p.name}.` : patch.enabled ? `${p.name} enabled.` : `${p.name} disabled.`);
},
});
const { mutate: remove, error: deleteError } = useMutation({
const {
mutate: remove,
isPending: removing,
error: deleteError,
} = useMutation({
mutationFn: () => api.deleteAuthProvider(p.provider_id),
onSuccess: invalidate,
onSuccess: () => {
invalidate();
toast.success(`Removed ${p.name}.`);
setConfirming(false);
},
});
const { mutate: test, isPending: testing } = useMutation({
mutationFn: () => api.testAuthProvider(p.provider_id),
// Stays inline: this one carries a diagnostic worth re-reading against
// the fields beside it, which is not what a toast that expires is for.
onSuccess: setTestResult,
onError: toast.error,
});
const { mutate: ack } = useMutation({
mutationFn: () => api.ackAuthProviderNotice(p.provider_id),
onSuccess: invalidate,
onError: toast.error,
});
const error = (updateError ?? deleteError) as Error | null;
@@ -198,10 +221,36 @@ function ProviderRow({ p }: { p: AuthProvider }) {
<Button type="button" variant="ghost" size="sm" loading={testing} onClick={() => test()}>
Test connection
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => remove()}>
Remove
<Button type="button" variant="ghost" size="sm" onClick={() => setConfirming(true)}>
Remove<span className="sr-only"> {p.name}</span>
</Button>
</div>
{/*
* One click used to remove a working sign-in method for everyone in
* the instance. Typed, unlike a channel: the client secret is not
* recoverable from here afterwards, so putting this back means
* going to the identity provider for a new one.
*/}
<ConfirmDialog
open={confirming}
title="Remove sign-in provider"
confirmLabel="Remove provider"
requireTyped={p.name}
loading={removing}
error={deleteError ? friendlyMessage(deleteError) : null}
onClose={() => setConfirming(false)}
onConfirm={() => remove()}
body={
<>
<p>
<span className="font-mono text-text-primary">{p.name}</span> is removed and anyone signing in
through it loses that route into this instance.
</p>
<p>The stored client secret goes with it; restoring this provider means issuing a new one.</p>
</>
}
/>
</div>
);
}
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Badge, Button, Card } from "@/components/ui";
import { Badge, Button, Card, ConfirmDialog, friendlyMessage, useToast } from "@/components/ui";
import { api, vulnerabilities, type Severity, type VulnAlertRule } from "@/lib/api";
import { SEVERITY_ORDER, SeverityBadge } from "./SeverityVisuals";
@@ -20,7 +20,9 @@ const labelClass = "mb-1.5 block text-sm font-medium text-text-secondary";
export function VulnAlertRulesCard() {
const qc = useQueryClient();
const toast = useToast();
const [adding, setAdding] = useState(false);
const [confirming, setConfirming] = useState<VulnAlertRule | null>(null);
const rules = useQuery({ queryKey: ["vuln-rules"], queryFn: () => vulnerabilities.listRules() });
const channels = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
@@ -29,7 +31,11 @@ export function VulnAlertRulesCard() {
const remove = useMutation({
mutationFn: (id: string) => vulnerabilities.deleteRule(id),
onSuccess: invalidate,
onSuccess: (_data, id) => {
invalidate();
toast.success(`Deleted ${rules.data?.find((r) => r.id === id)?.name ?? "the rule"}.`);
setConfirming(null);
},
});
const toggle = useMutation({
@@ -41,7 +47,11 @@ export function VulnAlertRulesCard() {
tags: r.tags,
channel_ids: r.channel_ids,
}),
onSuccess: invalidate,
onSuccess: (_data, r) => {
invalidate();
toast.success(r.enabled ? `${r.name} disabled.` : `${r.name} enabled.`);
},
onError: toast.error,
});
const channelName = (id: string) => channels.data?.find((c) => c.channel_id === id)?.name ?? id;
@@ -91,20 +101,41 @@ export function VulnAlertRulesCard() {
<Button size="sm" variant="ghost" onClick={() => toggle.mutate(r)}>
{r.enabled ? "Disable" : "Enable"}
</Button>
<Button size="sm" variant="ghost" onClick={() => remove.mutate(r.id)}>
Delete
<Button size="sm" variant="ghost" onClick={() => setConfirming(r)}>
Delete<span className="sr-only"> {r.name}</span>
</Button>
</div>
</li>
))}
</ul>
)}
{/* Untyped, like the channel it points at: a rule is four fields
and is rebuilt in a minute. The confirmation is here because
deleting one silently stops alerts nobody then misses until a
critical finding goes unreported. */}
<ConfirmDialog
open={confirming !== null}
title="Delete alert rule"
confirmLabel="Delete rule"
loading={remove.isPending}
error={remove.error ? friendlyMessage(remove.error) : null}
onClose={() => setConfirming(null)}
onConfirm={() => confirming && remove.mutate(confirming.id)}
body={
<p>
<span className="font-mono text-text-primary">{confirming?.name}</span> is deleted. Findings at or
above {confirming?.min_severity} stop being announced through its channels.
</p>
}
/>
</Card>
);
}
function RuleForm({ onDone }: { onDone: () => void }) {
const qc = useQueryClient();
const toast = useToast();
const channels = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
const [name, setName] = useState("");
@@ -123,8 +154,10 @@ function RuleForm({ onDone }: { onDone: () => void }) {
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["vuln-rules"] });
toast.success(`Created ${name.trim()}.`);
onDone();
},
// Error stays inline under the form, which does not close on failure.
});
const canSave = name.trim().length > 0 && selected.length > 0 && !create.isPending;
+5 -1
View File
@@ -3,7 +3,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, Workflow } from "@/lib/api";
import { Button } from "@/components/ui";
import { Button, useToast } from "@/components/ui";
/*
* Presets write cron underneath rather than being their own storage format:
@@ -23,6 +23,7 @@ const ZONES = ["UTC", "Europe/London", "Europe/Berlin", "America/New_York", "Ame
export function ScheduleCard({ workflow }: { workflow: Workflow }) {
const queryClient = useQueryClient();
const toast = useToast();
const [enabled, setEnabled] = useState(workflow.schedule?.enabled ?? false);
const [cron, setCron] = useState(workflow.schedule?.cron ?? "0 2 * * 0");
const [tz, setTz] = useState(workflow.schedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
@@ -42,7 +43,10 @@ export function ScheduleCard({ workflow }: { workflow: Workflow }) {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["workflows"] });
setError(null);
toast.success(enabled ? "Schedule saved. The next run is shown below." : "Schedule saved and disabled.");
},
// Inline, beside the cron field: a rejected expression or zone name is
// corrected here, and this editor does not close on failure.
onError: (e: Error) => setError(e.message),
});
+21 -12
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button, Card } from "@/components/ui";
import { Button, Card, useToast } from "@/components/ui";
import { workloads, type Workload, type WorkloadAction, type WorkloadKind } from "@/lib/api";
import { WorkloadRow } from "./WorkloadRow";
import { LogDialog } from "./LogDialog";
@@ -37,9 +37,17 @@ function group(list: Workload[]) {
};
}
/* The agent has acted by the time the call returns, so these read as done
* rather than as sent unlike an OS update, which it only accepts. */
const ACTION_PAST: Record<WorkloadAction, string> = {
start: "Started",
stop: "Stopped",
restart: "Restarted",
};
export function WorkloadList({ serverId, canControl }: { serverId: string; canControl: boolean }) {
const qc = useQueryClient();
const [error, setError] = useState<string | null>(null);
const toast = useToast();
const [logTarget, setLogTarget] = useState<Workload | null>(null);
const snapshot = useQuery({
@@ -51,20 +59,23 @@ export function WorkloadList({ serverId, canControl }: { serverId: string; canCo
mutationFn: () => workloads.refresh(serverId),
// The refresh returns no data — the agent reports through the normal
// path, so the only correct move is to refetch the stored document.
onSuccess: () => {
setError(null);
setTimeout(() => qc.invalidateQueries({ queryKey: ["workloads", serverId] }), 1500);
},
onError: (e: Error) => setError(e.message),
onSuccess: () => setTimeout(() => qc.invalidateQueries({ queryKey: ["workloads", serverId] }), 1500),
// Silent when it works: this fires on every panel open, and a toast
// saying so on arrival is noise about something nobody asked for.
onError: toast.error,
});
const control = useMutation({
mutationFn: ({ w, action }: { w: Workload; action: WorkloadAction }) => workloads.control(serverId, w.kind as WorkloadKind, w.id, action),
onSuccess: () => {
setError(null);
onSuccess: (_data, { w, action }) => {
qc.invalidateQueries({ queryKey: ["workloads", serverId] });
toast.success(`${ACTION_PAST[action]} ${w.name}.`);
},
onError: (e: Error) => setError(e.message),
// Toasted rather than banner-ed. The 409 for a protected workload — the
// agent refusing to stop itself — is the one an operator most needs to
// read, and it arrives from a button that may be scrolled well away
// from where the banner sat.
onError: toast.error,
});
// Opening the panel asks for a fresh list: this page carries a Restart
@@ -101,8 +112,6 @@ export function WorkloadList({ serverId, canControl }: { serverId: string; canCo
</div>
<div className="px-6 py-4">
{error && <p className="mb-3 text-sm text-danger">{error}</p>}
{snapshot.isLoading ? (
<p className="text-sm text-text-secondary">Loading</p>
) : !data ? (
File diff suppressed because one or more lines are too long