import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { stacksApi } from "@/api/stacks"; import { apiErrorMessage } from "@/api/client"; export type StackActionPhase = "running" | "success" | "error"; export type StackActionStatus = { id: string; label: string; phase: StackActionPhase; message?: string; at: number; }; /** 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 [busy, setBusy] = useState>({}); const [statusMap, setStatusMap] = useState>({}); const timers = useRef>({}); useEffect( () => () => { Object.values(timers.current).forEach(window.clearTimeout); }, [] ); const run = async ( id: string, label: string, fn: (id: string) => Promise ) => { 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 }); 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] }); qc.invalidateQueries({ queryKey: ["stack-updates"] }); } catch (err) { const message = apiErrorMessage(err); toast.error(message, { id: t }); setStatusMap((s) => ({ ...s, [id]: { id, label, phase: "error", message, at: Date.now() } })); } finally { 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 { 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), pull: (id: string) => run(id, "Pulling", stacksApi.pull), updateImages: (id: string) => run(id, "Updating", stacksApi.update_images), down: (id: string) => run(id, "Tearing down", stacksApi.down), }; } function omit(obj: Record, key: string): Record { const { [key]: _dropped, ...rest } = obj; return rest; }