Show action status on the stacks list and dashboard, not just detail (0.41.0)
CI / build-and-push (push) Successful in 1m56s
CI / build-and-push (push) Successful in 1m56s
The status banner added in9d28e12only rendered on the stack detail page, but Update is most often clicked from the stacks list — so in practice the status was invisible. Render it on the stacks list and dashboard too. Actions on different stacks run concurrently from the list, so busy state and status are now keyed by stack id instead of a single value: previously the first action to finish cleared every row's spinner, and each new action overwrote the previous one's status. StacksTable takes an isBusy(id) predicate in place of the single busyId prop. Also bumps the version so the newly version-tagged CI images (f8bfc91) actually differ from the running release — self-update compares tags against APP_VERSION, so shipping without a bump shows no update. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016pMmFFkdfxkoYjcEcpZTa5
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
@@ -14,31 +14,48 @@ export type StackActionStatus = {
|
||||
at: number;
|
||||
};
|
||||
|
||||
/** Success banners self-clear so they don't linger forever; errors stay until
|
||||
* dismissed or the next action overwrites them. */
|
||||
/** Success rows self-clear so they don't pile up; errors stay until dismissed
|
||||
* or until the same stack is acted on again. */
|
||||
const SUCCESS_CLEAR_MS = 5000;
|
||||
|
||||
/**
|
||||
* Runs stack lifecycle actions and tracks their progress.
|
||||
*
|
||||
* Actions on different stacks run concurrently (the stacks list lets you hit
|
||||
* Update on several rows), so both busy state and status are keyed by stack id
|
||||
* rather than held as a single value — otherwise the first action to finish
|
||||
* would clear the other rows' spinners and status.
|
||||
*/
|
||||
export function useStackActions() {
|
||||
const qc = useQueryClient();
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<StackActionStatus | null>(null);
|
||||
const clearTimer = useRef<number>();
|
||||
const [busy, setBusy] = useState<Record<string, true>>({});
|
||||
const [statusMap, setStatusMap] = useState<Record<string, StackActionStatus>>({});
|
||||
const timers = useRef<Record<string, number>>({});
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
Object.values(timers.current).forEach(window.clearTimeout);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const run = async (
|
||||
id: string,
|
||||
label: string,
|
||||
fn: (id: string) => Promise<unknown>
|
||||
) => {
|
||||
window.clearTimeout(clearTimer.current);
|
||||
setBusyId(id);
|
||||
setStatus({ id, label, phase: "running", at: Date.now() });
|
||||
window.clearTimeout(timers.current[id]);
|
||||
delete timers.current[id];
|
||||
setBusy((b) => ({ ...b, [id]: true }));
|
||||
setStatusMap((s) => ({ ...s, [id]: { id, label, phase: "running", at: Date.now() } }));
|
||||
const t = toast.loading(`${label} ${id}…`);
|
||||
try {
|
||||
await fn(id);
|
||||
toast.success(`${label} ${id} ✓`, { id: t });
|
||||
setStatus({ id, label, phase: "success", at: Date.now() });
|
||||
clearTimer.current = window.setTimeout(() => {
|
||||
setStatus((s) => (s?.phase === "success" ? null : s));
|
||||
setStatusMap((s) => ({ ...s, [id]: { id, label, phase: "success", at: Date.now() } }));
|
||||
timers.current[id] = window.setTimeout(() => {
|
||||
delete timers.current[id];
|
||||
setStatusMap((s) => (s[id]?.phase === "success" ? omit(s, id) : s));
|
||||
}, SUCCESS_CLEAR_MS);
|
||||
qc.invalidateQueries({ queryKey: ["stacks"] });
|
||||
qc.invalidateQueries({ queryKey: ["stack", id] });
|
||||
@@ -46,19 +63,30 @@ export function useStackActions() {
|
||||
} catch (err) {
|
||||
const message = apiErrorMessage(err);
|
||||
toast.error(message, { id: t });
|
||||
setStatus({ id, label, phase: "error", message, at: Date.now() });
|
||||
setStatusMap((s) => ({ ...s, [id]: { id, label, phase: "error", message, at: Date.now() } }));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
setBusy((b) => omit(b, id));
|
||||
}
|
||||
};
|
||||
|
||||
// Oldest first, so a row doesn't jump around as other actions finish.
|
||||
const statuses = useMemo(
|
||||
() => Object.values(statusMap).sort((a, b) => a.at - b.at),
|
||||
[statusMap]
|
||||
);
|
||||
|
||||
const isBusy = useCallback((id: string) => busy[id] === true, [busy]);
|
||||
|
||||
const dismissStatus = useCallback((id: string) => {
|
||||
window.clearTimeout(timers.current[id]);
|
||||
delete timers.current[id];
|
||||
setStatusMap((s) => omit(s, id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
busyId,
|
||||
status,
|
||||
dismissStatus: () => {
|
||||
window.clearTimeout(clearTimer.current);
|
||||
setStatus(null);
|
||||
},
|
||||
isBusy,
|
||||
statuses,
|
||||
dismissStatus,
|
||||
start: (id: string) => run(id, "Starting", stacksApi.start),
|
||||
stop: (id: string) => run(id, "Stopping", stacksApi.stop),
|
||||
restart: (id: string) => run(id, "Restarting", stacksApi.restart),
|
||||
@@ -67,3 +95,8 @@ export function useStackActions() {
|
||||
down: (id: string) => run(id, "Tearing down", stacksApi.down),
|
||||
};
|
||||
}
|
||||
|
||||
function omit<T>(obj: Record<string, T>, key: string): Record<string, T> {
|
||||
const { [key]: _dropped, ...rest } = obj;
|
||||
return rest;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user