Stream update progress into a bar on the stack's row (0.42.0)
CI / build-and-push (push) Successful in 1m55s
CI / build-and-push (push) Successful in 1m55s
Update ran as a blocking POST with nothing to show but a spinner, so the
status added in 86c67df could only sit above the table as a banner.
Adds /ws/update/{stack_id}, streaming `compose pull` then `up -d` with
--progress json, and feeds it through the existing DeployTracker — the
same weighting the deploy console uses. The result renders as a progress
bar inside the stack's own row: percentage, phase label, and layer/byte
detail. Non-streaming actions (start/stop/restart/pull/down) reuse the
bar in its indeterminate form, so every row action looks consistent.
compose_service gains _stream_phase, shared by stream_up and the new
stream_update; a failed pull short-circuits before `up`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pMmFFkdfxkoYjcEcpZTa5
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.41.0",
|
||||
"version": "0.42.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -8,6 +8,83 @@ const TONE = {
|
||||
"border-red-300 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300",
|
||||
} as const;
|
||||
|
||||
const BAR_TONE = {
|
||||
running: "bg-accent dark:bg-accent-dark",
|
||||
success: "bg-sp-green",
|
||||
error: "bg-red-500",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Progress bar for a running stack action, sized to sit inside a stacks-list
|
||||
* row. Update streams real per-layer pull progress; the other actions have
|
||||
* nothing to measure and show a sliding bar instead.
|
||||
*/
|
||||
export function ActionProgressBar({
|
||||
status,
|
||||
onDismiss,
|
||||
}: {
|
||||
status: StackActionStatus;
|
||||
onDismiss?: (id: string) => void;
|
||||
}) {
|
||||
const { phase, progress } = status;
|
||||
const done = phase !== "running";
|
||||
const pct = phase === "success" ? 100 : Math.min(progress?.pct ?? 0, 100);
|
||||
// No stream (or nothing measurable yet) — slide instead of showing a lie.
|
||||
const indeterminate = !done && (progress?.indeterminate ?? true);
|
||||
|
||||
const label = done
|
||||
? phase === "success"
|
||||
? `${status.label} — done`
|
||||
: `${status.label} failed`
|
||||
: (progress?.label ?? `${status.label}…`);
|
||||
const detail = phase === "error" ? (status.message ?? "") : (progress?.detail ?? "");
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 w-full">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-sp-surface-2 ring-1 ring-inset ring-sp-border">
|
||||
{indeterminate ? (
|
||||
<div className="h-full w-1/3 rounded-full bg-accent animate-indeterminate dark:bg-accent-dark" />
|
||||
) : (
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] duration-300 ease-out ${BAR_TONE[phase]}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline gap-2 text-[11px]">
|
||||
<span
|
||||
className={
|
||||
phase === "error"
|
||||
? "shrink-0 font-medium text-red-600 dark:text-red-400"
|
||||
: phase === "success"
|
||||
? "shrink-0 font-medium text-sp-green"
|
||||
: "shrink-0 text-sp-text-2"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{!done && !indeterminate && (
|
||||
<span className="shrink-0 tabular-nums text-sp-text-2">{Math.round(pct)}%</span>
|
||||
)}
|
||||
{detail && (
|
||||
<span className="truncate tabular-nums text-sp-text-3" title={detail}>
|
||||
{detail}
|
||||
</span>
|
||||
)}
|
||||
{done && onDismiss && (
|
||||
<button
|
||||
onClick={() => onDismiss(status.id)}
|
||||
className="ml-auto shrink-0 opacity-60 transition-opacity hover:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Persistent status line for one stack action (start/stop/pull/update/…),
|
||||
* shown in addition to the transient toast so the outcome doesn't vanish
|
||||
* with it — and so a failure stays readable until acknowledged. */
|
||||
@@ -20,27 +97,32 @@ export function ActionStatusBanner({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-card border px-3 py-2 text-sm ${TONE[status.phase]}`}
|
||||
className={`rounded-card border px-3 py-2 text-sm ${TONE[status.phase]}`}
|
||||
>
|
||||
{status.phase === "running" && (
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-accent dark:text-accent-dark" />
|
||||
)}
|
||||
{status.phase === "success" && <CheckCircle2 className="h-4 w-4 shrink-0" />}
|
||||
{status.phase === "error" && <XCircle className="h-4 w-4 shrink-0" />}
|
||||
<span className="flex-1">
|
||||
{status.phase === "running" && `${status.label} ${status.id}…`}
|
||||
{status.phase === "success" && `${status.label} ${status.id} — done`}
|
||||
{status.phase === "error" &&
|
||||
`${status.label} ${status.id} — failed${status.message ? `: ${status.message}` : ""}`}
|
||||
</span>
|
||||
{status.phase !== "running" && (
|
||||
<button
|
||||
onClick={() => onDismiss(status.id)}
|
||||
className="shrink-0 opacity-60 transition-opacity hover:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{status.phase === "running" && (
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-accent dark:text-accent-dark" />
|
||||
)}
|
||||
{status.phase === "success" && <CheckCircle2 className="h-4 w-4 shrink-0" />}
|
||||
{status.phase === "error" && <XCircle className="h-4 w-4 shrink-0" />}
|
||||
<span className="flex-1">
|
||||
{status.phase === "running" && `${status.label} ${status.id}…`}
|
||||
{status.phase === "success" && `${status.label} ${status.id} — done`}
|
||||
{status.phase === "error" &&
|
||||
`${status.label} ${status.id} — failed${status.message ? `: ${status.message}` : ""}`}
|
||||
</span>
|
||||
{status.phase !== "running" && (
|
||||
<button
|
||||
onClick={() => onDismiss(status.id)}
|
||||
className="shrink-0 opacity-60 transition-opacity hover:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{status.phase === "running" && status.progress && (
|
||||
<ActionProgressBar status={status} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,8 @@ import { formatBytes } from "@/lib/utils";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import type { StackStats, StackSummary, StackUpdateInfo } from "@/types";
|
||||
import type { StackActionStatus } from "@/hooks/useStackActions";
|
||||
import { ActionProgressBar } from "./ActionStatusBanner";
|
||||
|
||||
/** Stack list rendered as a table with live CPU/memory usage meters and inline
|
||||
* start/stop/restart, shared by the Dashboard and the Stacks page. */
|
||||
@@ -20,6 +22,8 @@ export function StacksTable({
|
||||
hostMem,
|
||||
isAdmin,
|
||||
isBusy,
|
||||
statusFor,
|
||||
onDismissStatus,
|
||||
loading,
|
||||
linkBase = "/stacks",
|
||||
showEdit = false,
|
||||
@@ -37,6 +41,9 @@ export function StacksTable({
|
||||
hostMem: number;
|
||||
isAdmin: boolean;
|
||||
isBusy: (id: string) => boolean;
|
||||
/** Live status of an action running on this stack, if any. */
|
||||
statusFor?: (id: string) => StackActionStatus | undefined;
|
||||
onDismissStatus?: (id: string) => void;
|
||||
loading: boolean;
|
||||
linkBase?: string;
|
||||
showEdit?: boolean;
|
||||
@@ -77,6 +84,8 @@ export function StacksTable({
|
||||
hostMem={hostMem}
|
||||
isAdmin={isAdmin}
|
||||
busy={isBusy(s.id)}
|
||||
status={statusFor?.(s.id)}
|
||||
onDismissStatus={onDismissStatus}
|
||||
linkBase={linkBase}
|
||||
showEdit={showEdit}
|
||||
showDelete={showDelete}
|
||||
@@ -100,6 +109,8 @@ function StackRow({
|
||||
hostMem,
|
||||
isAdmin,
|
||||
busy,
|
||||
status,
|
||||
onDismissStatus,
|
||||
linkBase,
|
||||
showEdit,
|
||||
showDelete,
|
||||
@@ -115,6 +126,8 @@ function StackRow({
|
||||
hostMem: number;
|
||||
isAdmin: boolean;
|
||||
busy: boolean;
|
||||
status?: StackActionStatus;
|
||||
onDismissStatus?: (id: string) => void;
|
||||
linkBase: string;
|
||||
showEdit: boolean;
|
||||
showDelete: boolean;
|
||||
@@ -155,7 +168,7 @@ function StackRow({
|
||||
<span className="text-xs text-slate-400">
|
||||
{stack.running_count}/{stack.service_count} svc
|
||||
</span>
|
||||
{updateAvailable && (
|
||||
{updateAvailable && !status && (
|
||||
<span
|
||||
title={
|
||||
update?.stale_images?.length
|
||||
@@ -168,6 +181,7 @@ function StackRow({
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
{status && <ActionProgressBar status={status} onDismiss={onDismissStatus} />}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{running && stats ? (
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { streamCompose, type ProgressSnapshot } from "@/lib/composeStream";
|
||||
|
||||
export type StackActionPhase = "running" | "success" | "error";
|
||||
|
||||
@@ -12,6 +14,9 @@ export type StackActionStatus = {
|
||||
phase: StackActionPhase;
|
||||
message?: string;
|
||||
at: number;
|
||||
/** Only set for streamed actions (update); plain REST actions have no
|
||||
* progress to report and render an indeterminate bar instead. */
|
||||
progress?: ProgressSnapshot;
|
||||
};
|
||||
|
||||
/** Success rows self-clear so they don't pile up; errors stay until dismissed
|
||||
@@ -25,9 +30,13 @@ const SUCCESS_CLEAR_MS = 5000;
|
||||
* 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.
|
||||
*
|
||||
* Update runs over `/ws/update/{id}` rather than the REST endpoint, because
|
||||
* only the stream carries the per-layer pull progress the row's bar shows.
|
||||
*/
|
||||
export function useStackActions() {
|
||||
const qc = useQueryClient();
|
||||
const token = useAuthStore((s) => s.accessToken);
|
||||
const [busy, setBusy] = useState<Record<string, true>>({});
|
||||
const [statusMap, setStatusMap] = useState<Record<string, StackActionStatus>>({});
|
||||
const timers = useRef<Record<string, number>>({});
|
||||
@@ -39,31 +48,63 @@ export function useStackActions() {
|
||||
[]
|
||||
);
|
||||
|
||||
const begin = (id: string, label: string) => {
|
||||
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 succeed = (id: string, label: string) => {
|
||||
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"] });
|
||||
};
|
||||
|
||||
const fail = (id: string, label: string, message: string) =>
|
||||
setStatusMap((s) => ({
|
||||
...s,
|
||||
[id]: { id, label, phase: "error", message, at: Date.now() },
|
||||
}));
|
||||
|
||||
const run = async (
|
||||
id: string,
|
||||
label: string,
|
||||
fn: (id: string) => Promise<unknown>
|
||||
) => {
|
||||
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() } }));
|
||||
begin(id, label);
|
||||
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"] });
|
||||
succeed(id, label);
|
||||
} catch (err) {
|
||||
const message = apiErrorMessage(err);
|
||||
toast.error(message, { id: t });
|
||||
setStatusMap((s) => ({ ...s, [id]: { id, label, phase: "error", message, at: Date.now() } }));
|
||||
fail(id, label, message);
|
||||
} finally {
|
||||
setBusy((b) => omit(b, id));
|
||||
}
|
||||
};
|
||||
|
||||
const runStreamed = async (id: string, label: string, path: string) => {
|
||||
begin(id, label);
|
||||
const t = toast.loading(`${label} ${id}…`);
|
||||
try {
|
||||
await streamCompose(path, token!, (progress) =>
|
||||
setStatusMap((s) => (s[id] ? { ...s, [id]: { ...s[id], progress } } : s))
|
||||
);
|
||||
toast.success(`${label} ${id} ✓`, { id: t });
|
||||
succeed(id, label);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(`${label} ${id} failed: ${message}`, { id: t });
|
||||
fail(id, label, message);
|
||||
} finally {
|
||||
setBusy((b) => omit(b, id));
|
||||
}
|
||||
@@ -76,6 +117,7 @@ export function useStackActions() {
|
||||
);
|
||||
|
||||
const isBusy = useCallback((id: string) => busy[id] === true, [busy]);
|
||||
const statusFor = useCallback((id: string) => statusMap[id], [statusMap]);
|
||||
|
||||
const dismissStatus = useCallback((id: string) => {
|
||||
window.clearTimeout(timers.current[id]);
|
||||
@@ -86,12 +128,18 @@ export function useStackActions() {
|
||||
return {
|
||||
isBusy,
|
||||
statuses,
|
||||
statusFor,
|
||||
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),
|
||||
// Without a token the socket can't authenticate — fall back to the REST
|
||||
// endpoint, which still updates, just without progress.
|
||||
updateImages: (id: string) =>
|
||||
token
|
||||
? runStreamed(id, "Updating", `/ws/update/${id}`)
|
||||
: run(id, "Updating", stacksApi.update_images),
|
||||
down: (id: string) => run(id, "Tearing down", stacksApi.down),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Drives a streaming compose WebSocket (`/ws/update/{id}`, `/ws/deploy/{id}`)
|
||||
* and folds its output into a progress snapshot via {@link DeployTracker}.
|
||||
*
|
||||
* The deploy console renders the same stream with its full log view; this is
|
||||
* the headless counterpart, for places that only want the progress bar — the
|
||||
* stacks list, where an update needs to be legible at a glance in one row.
|
||||
*/
|
||||
import { DeployTracker, parseDeployLine } from "./deployProgress";
|
||||
|
||||
/** Compose emits status changes far faster than the UI needs to repaint. */
|
||||
const FLUSH_MS = 150;
|
||||
|
||||
export type ProgressSnapshot = {
|
||||
pct: number;
|
||||
indeterminate: boolean;
|
||||
label: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
function snapshot(tracker: DeployTracker): ProgressSnapshot {
|
||||
const s = tracker.snapshot();
|
||||
return { pct: s.pct, indeterminate: s.indeterminate, label: s.label, detail: s.detail };
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens `path`, reporting progress until compose finishes. Resolves on exit
|
||||
* code 0, rejects with the failure reason otherwise. The compose subprocess
|
||||
* keeps running server-side even if this socket is closed early.
|
||||
*/
|
||||
export function streamCompose(
|
||||
path: string,
|
||||
token: string,
|
||||
onProgress: (p: ProgressSnapshot) => void
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tracker = new DeployTracker();
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(
|
||||
`${proto}://${window.location.host}${path}?token=${encodeURIComponent(token)}`
|
||||
);
|
||||
|
||||
let dirty = false;
|
||||
let settled = false;
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
if (!dirty) return;
|
||||
dirty = false;
|
||||
onProgress(snapshot(tracker));
|
||||
}, FLUSH_MS);
|
||||
|
||||
const settle = (err?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.clearInterval(timer);
|
||||
tracker.finish(!err);
|
||||
onProgress(snapshot(tracker));
|
||||
ws.close();
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "log") {
|
||||
const parsed = parseDeployLine(msg.line as string);
|
||||
if (parsed?.kind === "event") {
|
||||
tracker.apply(parsed.event);
|
||||
dirty = true;
|
||||
}
|
||||
} else if (msg.type === "done") {
|
||||
if (msg.returncode === 0) settle();
|
||||
else settle(new Error(`compose exited with code ${msg.returncode}`));
|
||||
} else if (msg.type === "error") {
|
||||
settle(new Error(msg.detail || "update failed"));
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed frames */
|
||||
}
|
||||
};
|
||||
ws.onerror = () => settle(new Error("connection to the server failed"));
|
||||
// Reached normally after settle() closes the socket; the guard makes this
|
||||
// a no-op then, so it only fires for a stream that dropped mid-update.
|
||||
ws.onclose = () => settle(new Error("connection closed before the update finished"));
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { toast } from "sonner";
|
||||
import { Card } from "@/components/ui";
|
||||
import { HostHeader } from "@/components/hosts/HostHeader";
|
||||
import { StacksTable } from "@/components/stacks/StacksTable";
|
||||
import { ActionStatusList } from "@/components/stacks/ActionStatusBanner";
|
||||
import { AttentionStrip } from "@/components/dashboard/AttentionStrip";
|
||||
import { FleetKpiRow } from "@/components/dashboard/FleetKpiRow";
|
||||
import { StackStatusBar } from "@/components/dashboard/StackStatusBar";
|
||||
@@ -22,7 +21,7 @@ import type { Agent } from "@/types";
|
||||
|
||||
export function Dashboard() {
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||
const { isBusy, statuses, dismissStatus, start, stop, restart } = useStackActions();
|
||||
const { isBusy, statusFor, dismissStatus, start, stop, restart } = useStackActions();
|
||||
const qc = useQueryClient();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
@@ -113,8 +112,6 @@ export function Dashboard() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<ActionStatusList statuses={statuses} onDismiss={dismissStatus} />
|
||||
|
||||
{/* ---- Local host stacks ---- */}
|
||||
<section>
|
||||
{hasAgents ? <HostHeader /> : <h2 className="sp-label mb-3">This host</h2>}
|
||||
@@ -125,6 +122,8 @@ export function Dashboard() {
|
||||
hostMem={info.data?.ram.total ?? 0}
|
||||
isAdmin={isAdmin}
|
||||
isBusy={isBusy}
|
||||
statusFor={statusFor}
|
||||
onDismissStatus={dismissStatus}
|
||||
loading={stacks.isLoading}
|
||||
onStart={start}
|
||||
onStop={stop}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Plus, Search, HardDrive } from "lucide-react";
|
||||
import { Button, Input } from "@/components/ui";
|
||||
import { StacksTable } from "@/components/stacks/StacksTable";
|
||||
import { ActionStatusList } from "@/components/stacks/ActionStatusBanner";
|
||||
import { RestoreButton } from "@/components/stacks/BackupRestore";
|
||||
import { AgentStacksSection } from "@/components/stacks/AgentStacksSection";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
@@ -24,7 +23,7 @@ const STATUS_FILTERS: Record<string, StatusFilter> = {
|
||||
|
||||
export function Stacks() {
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||
const { isBusy, statuses, dismissStatus, start, stop, restart, updateImages } =
|
||||
const { isBusy, statusFor, dismissStatus, start, stop, restart, updateImages } =
|
||||
useStackActions();
|
||||
// The dashboard explore bar deep-links here with ?q= / ?filter=.
|
||||
const [params] = useSearchParams();
|
||||
@@ -125,8 +124,6 @@ export function Stacks() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ActionStatusList statuses={statuses} onDismiss={dismissStatus} />
|
||||
|
||||
<section>
|
||||
{hasAgents && (
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||
@@ -141,6 +138,8 @@ export function Stacks() {
|
||||
hostMem={info.data?.ram.total ?? 0}
|
||||
isAdmin={isAdmin}
|
||||
isBusy={isBusy}
|
||||
statusFor={statusFor}
|
||||
onDismissStatus={dismissStatus}
|
||||
loading={isLoading}
|
||||
showEdit
|
||||
showDelete
|
||||
|
||||
Reference in New Issue
Block a user