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:
@@ -34,6 +34,13 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
|||||||
console shows a **real progress bar** (download bytes per layer, weighted by
|
console shows a **real progress bar** (download bytes per layer, weighted by
|
||||||
layer size, then container create/start) plus a per-image bar; the raw output
|
layer size, then container create/start) plus a per-image bar; the raw output
|
||||||
is kept below it and updates one line per layer instead of scrolling past.
|
is kept below it and updates one line per layer instead of scrolling past.
|
||||||
|
- **Inline update progress (0.42.0)** — hitting Update on a stack streams
|
||||||
|
`compose pull && up -d` over `/ws/update/{stack_id}` and folds it into a
|
||||||
|
**progress bar inside that stack's row** (same `--progress json` weighting as
|
||||||
|
the deploy console: "Pulling images · 3/7 layers · 88 MB / 190 MB"). Several
|
||||||
|
stacks can update at once — busy state and progress are tracked per stack, so
|
||||||
|
the rows advance independently. The REST `POST /api/stacks/{id}/update` stays
|
||||||
|
for non-interactive callers and as the fallback when no token is available.
|
||||||
- **Monaco editor** — YAML editing with an `.env` tab and a **`docker run` →
|
- **Monaco editor** — YAML editing with an `.env` tab and a **`docker run` →
|
||||||
compose** converter.
|
compose** converter.
|
||||||
- **Dashboard** — system resource bar, stack grid with quick actions, and a
|
- **Dashboard** — system resource bar, stack grid with quick actions, and a
|
||||||
@@ -456,6 +463,7 @@ POST /api/stacks/convert (docker run → compose)
|
|||||||
GET /api/system/info | gpus | devices GET /api/audit
|
GET /api/system/info | gpus | devices GET /api/audit
|
||||||
GET /api/system/update POST /api/system/update (self-update)
|
GET /api/system/update POST /api/system/update (self-update)
|
||||||
WS /ws/logs/{stack_id}[/{service}] WS /ws/events
|
WS /ws/logs/{stack_id}[/{service}] WS /ws/events
|
||||||
|
WS /ws/deploy/{stack_id} WS /ws/update/{stack_id}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Phase 2 endpoints
|
### Phase 2 endpoints
|
||||||
|
|||||||
+68
-2
@@ -16,8 +16,14 @@ from sqlmodel import Session
|
|||||||
from auth import decode_token
|
from auth import decode_token
|
||||||
from database import engine
|
from database import engine
|
||||||
from models.agent import Agent
|
from models.agent import Agent
|
||||||
from models.setting import EVENT_STACK_ERROR, EVENT_STACK_START
|
from models.setting import EVENT_PULL_FAILED, EVENT_STACK_ERROR, EVENT_STACK_START
|
||||||
from services import audit_service, compose_service, exec_service, notify_service
|
from services import (
|
||||||
|
audit_service,
|
||||||
|
compose_service,
|
||||||
|
exec_service,
|
||||||
|
notify_service,
|
||||||
|
update_service,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("stackpilot.ws")
|
logger = logging.getLogger("stackpilot.ws")
|
||||||
|
|
||||||
@@ -241,6 +247,66 @@ async def ws_agent_logs(
|
|||||||
await websocket.close()
|
await websocket.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/update/{stack_id}")
|
||||||
|
async def ws_update(
|
||||||
|
websocket: WebSocket,
|
||||||
|
stack_id: str,
|
||||||
|
token: str | None = Query(default=None),
|
||||||
|
):
|
||||||
|
"""Run `docker compose pull && up -d` and stream its output, so the stacks
|
||||||
|
list can render real update progress. Same audit/notify contract as the
|
||||||
|
REST `/update` endpoint, which stays for non-interactive callers."""
|
||||||
|
await websocket.accept()
|
||||||
|
if not await _authorize_admin(websocket, token):
|
||||||
|
return
|
||||||
|
username = decode_token(token, "access").get("sub", "unknown") if token else "unknown"
|
||||||
|
|
||||||
|
rc: int | None = None
|
||||||
|
disconnected = False
|
||||||
|
compose_service.mark_busy(stack_id)
|
||||||
|
try:
|
||||||
|
async for kind, payload in compose_service.stream_update(stack_id):
|
||||||
|
if kind == "log":
|
||||||
|
await websocket.send_text(json.dumps({"type": "log", "line": payload}))
|
||||||
|
else:
|
||||||
|
rc = payload
|
||||||
|
await websocket.send_text(json.dumps({"type": "done", "returncode": rc}))
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
# Client navigated away; compose keeps running so the update finishes.
|
||||||
|
disconnected = True
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await websocket.send_text(json.dumps({"type": "error", "detail": str(exc)}))
|
||||||
|
finally:
|
||||||
|
compose_service.clear_busy(stack_id)
|
||||||
|
|
||||||
|
ok = rc in (0, None)
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
audit_service.record(
|
||||||
|
session, user=username, action="stack.update", target=stack_id,
|
||||||
|
detail=f"rc={rc} (update stream)", ip="ws",
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
await notify_service.notify(
|
||||||
|
EVENT_STACK_START, f"Stack '{stack_id}' updated",
|
||||||
|
"compose pull + up completed successfully.", session,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await notify_service.notify(
|
||||||
|
EVENT_PULL_FAILED, f"Stack '{stack_id}' update failed",
|
||||||
|
"compose pull/up returned a non-zero exit code.", session,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 - audit/notify are best-effort
|
||||||
|
pass
|
||||||
|
if not disconnected:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await websocket.close()
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
update_service.refresh_stack_local(stack_id)
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/agent-deploy/{agent_id}/{stack_id}")
|
@router.websocket("/ws/agent-deploy/{agent_id}/{stack_id}")
|
||||||
async def ws_agent_deploy(
|
async def ws_agent_deploy(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
|
|||||||
@@ -387,17 +387,16 @@ async def supports_json_progress() -> bool:
|
|||||||
return _json_progress
|
return _json_progress
|
||||||
|
|
||||||
|
|
||||||
async def stream_up(stack_id: str, override: Optional[str] = None):
|
async def _stream_phase(
|
||||||
"""Run `compose up -d` streaming combined output, so the deploy console can
|
stack_id: str, args: list[str], override: Optional[str], json_progress: bool
|
||||||
show image-pull and container-create progress live.
|
):
|
||||||
|
"""One compose subcommand, streamed. Yields ``("log", line)`` per output
|
||||||
Yields ``("log", line)`` for each output line, then ``("done", returncode)``.
|
line, then ``("rc", returncode)`` exactly once."""
|
||||||
"""
|
|
||||||
cmd = _compose_base_cmd(stack_id, override)
|
cmd = _compose_base_cmd(stack_id, override)
|
||||||
if await supports_json_progress():
|
if json_progress:
|
||||||
# Global flag, must precede the subcommand.
|
# Global flag, must precede the subcommand.
|
||||||
cmd += ["--progress", "json"]
|
cmd += ["--progress", "json"]
|
||||||
cmd += ["up", "-d", "--remove-orphans"]
|
cmd += args
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
@@ -407,7 +406,41 @@ async def stream_up(stack_id: str, override: Optional[str] = None):
|
|||||||
async for raw in proc.stdout:
|
async for raw in proc.stdout:
|
||||||
yield ("log", raw.decode("utf-8", "replace").rstrip("\n"))
|
yield ("log", raw.decode("utf-8", "replace").rstrip("\n"))
|
||||||
await proc.wait()
|
await proc.wait()
|
||||||
yield ("done", proc.returncode)
|
yield ("rc", proc.returncode)
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_up(stack_id: str, override: Optional[str] = None):
|
||||||
|
"""Run `compose up -d` streaming combined output, so the deploy console can
|
||||||
|
show image-pull and container-create progress live.
|
||||||
|
|
||||||
|
Yields ``("log", line)`` for each output line, then ``("done", returncode)``.
|
||||||
|
"""
|
||||||
|
json_progress = await supports_json_progress()
|
||||||
|
async for kind, payload in _stream_phase(
|
||||||
|
stack_id, ["up", "-d", "--remove-orphans"], override, json_progress
|
||||||
|
):
|
||||||
|
yield ("done", payload) if kind == "rc" else ("log", payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_update(stack_id: str, override: Optional[str] = None):
|
||||||
|
"""Run `compose pull` then `compose up -d`, streaming both phases, so the
|
||||||
|
stacks list can show real update progress instead of a spinner.
|
||||||
|
|
||||||
|
Yields ``("log", line)`` for each output line of either phase, then
|
||||||
|
``("done", returncode)`` once. A failed pull short-circuits: recreating
|
||||||
|
containers on images that never came down would only make things worse.
|
||||||
|
"""
|
||||||
|
json_progress = await supports_json_progress()
|
||||||
|
rc = 0
|
||||||
|
for args in (["pull"], ["up", "-d", "--remove-orphans"]):
|
||||||
|
async for kind, payload in _stream_phase(stack_id, args, override, json_progress):
|
||||||
|
if kind == "log":
|
||||||
|
yield ("log", payload)
|
||||||
|
else:
|
||||||
|
rc = payload
|
||||||
|
if rc != 0:
|
||||||
|
break
|
||||||
|
yield ("done", rc)
|
||||||
|
|
||||||
|
|
||||||
# Convenience lifecycle wrappers ------------------------------------------------
|
# Convenience lifecycle wrappers ------------------------------------------------
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
"""Single source of truth for the StackPilot release version."""
|
"""Single source of truth for the StackPilot release version."""
|
||||||
|
|
||||||
APP_VERSION = "0.41.0"
|
APP_VERSION = "0.42.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.41.0",
|
"version": "0.42.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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",
|
"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;
|
} 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/…),
|
/** Persistent status line for one stack action (start/stop/pull/update/…),
|
||||||
* shown in addition to the transient toast so the outcome doesn't vanish
|
* shown in addition to the transient toast so the outcome doesn't vanish
|
||||||
* with it — and so a failure stays readable until acknowledged. */
|
* with it — and so a failure stays readable until acknowledged. */
|
||||||
@@ -20,27 +97,32 @@ export function ActionStatusBanner({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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" && (
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-accent dark:text-accent-dark" />
|
{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" />}
|
{status.phase === "success" && <CheckCircle2 className="h-4 w-4 shrink-0" />}
|
||||||
<span className="flex-1">
|
{status.phase === "error" && <XCircle className="h-4 w-4 shrink-0" />}
|
||||||
{status.phase === "running" && `${status.label} ${status.id}…`}
|
<span className="flex-1">
|
||||||
{status.phase === "success" && `${status.label} ${status.id} — done`}
|
{status.phase === "running" && `${status.label} ${status.id}…`}
|
||||||
{status.phase === "error" &&
|
{status.phase === "success" && `${status.label} ${status.id} — done`}
|
||||||
`${status.label} ${status.id} — failed${status.message ? `: ${status.message}` : ""}`}
|
{status.phase === "error" &&
|
||||||
</span>
|
`${status.label} ${status.id} — failed${status.message ? `: ${status.message}` : ""}`}
|
||||||
{status.phase !== "running" && (
|
</span>
|
||||||
<button
|
{status.phase !== "running" && (
|
||||||
onClick={() => onDismiss(status.id)}
|
<button
|
||||||
className="shrink-0 opacity-60 transition-opacity hover:opacity-100"
|
onClick={() => onDismiss(status.id)}
|
||||||
aria-label="Dismiss"
|
className="shrink-0 opacity-60 transition-opacity hover:opacity-100"
|
||||||
>
|
aria-label="Dismiss"
|
||||||
<X className="h-4 w-4" />
|
>
|
||||||
</button>
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{status.phase === "running" && status.progress && (
|
||||||
|
<ActionProgressBar status={status} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { formatBytes } from "@/lib/utils";
|
|||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import type { StackStats, StackSummary, StackUpdateInfo } from "@/types";
|
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
|
/** 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. */
|
* start/stop/restart, shared by the Dashboard and the Stacks page. */
|
||||||
@@ -20,6 +22,8 @@ export function StacksTable({
|
|||||||
hostMem,
|
hostMem,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isBusy,
|
isBusy,
|
||||||
|
statusFor,
|
||||||
|
onDismissStatus,
|
||||||
loading,
|
loading,
|
||||||
linkBase = "/stacks",
|
linkBase = "/stacks",
|
||||||
showEdit = false,
|
showEdit = false,
|
||||||
@@ -37,6 +41,9 @@ export function StacksTable({
|
|||||||
hostMem: number;
|
hostMem: number;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isBusy: (id: string) => 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;
|
loading: boolean;
|
||||||
linkBase?: string;
|
linkBase?: string;
|
||||||
showEdit?: boolean;
|
showEdit?: boolean;
|
||||||
@@ -77,6 +84,8 @@ export function StacksTable({
|
|||||||
hostMem={hostMem}
|
hostMem={hostMem}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
busy={isBusy(s.id)}
|
busy={isBusy(s.id)}
|
||||||
|
status={statusFor?.(s.id)}
|
||||||
|
onDismissStatus={onDismissStatus}
|
||||||
linkBase={linkBase}
|
linkBase={linkBase}
|
||||||
showEdit={showEdit}
|
showEdit={showEdit}
|
||||||
showDelete={showDelete}
|
showDelete={showDelete}
|
||||||
@@ -100,6 +109,8 @@ function StackRow({
|
|||||||
hostMem,
|
hostMem,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
busy,
|
busy,
|
||||||
|
status,
|
||||||
|
onDismissStatus,
|
||||||
linkBase,
|
linkBase,
|
||||||
showEdit,
|
showEdit,
|
||||||
showDelete,
|
showDelete,
|
||||||
@@ -115,6 +126,8 @@ function StackRow({
|
|||||||
hostMem: number;
|
hostMem: number;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
status?: StackActionStatus;
|
||||||
|
onDismissStatus?: (id: string) => void;
|
||||||
linkBase: string;
|
linkBase: string;
|
||||||
showEdit: boolean;
|
showEdit: boolean;
|
||||||
showDelete: boolean;
|
showDelete: boolean;
|
||||||
@@ -155,7 +168,7 @@ function StackRow({
|
|||||||
<span className="text-xs text-slate-400">
|
<span className="text-xs text-slate-400">
|
||||||
{stack.running_count}/{stack.service_count} svc
|
{stack.running_count}/{stack.service_count} svc
|
||||||
</span>
|
</span>
|
||||||
{updateAvailable && (
|
{updateAvailable && !status && (
|
||||||
<span
|
<span
|
||||||
title={
|
title={
|
||||||
update?.stale_images?.length
|
update?.stale_images?.length
|
||||||
@@ -168,6 +181,7 @@ function StackRow({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
{status && <ActionProgressBar status={status} onDismiss={onDismissStatus} />}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
{running && stats ? (
|
{running && stats ? (
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
import { streamCompose, type ProgressSnapshot } from "@/lib/composeStream";
|
||||||
|
|
||||||
export type StackActionPhase = "running" | "success" | "error";
|
export type StackActionPhase = "running" | "success" | "error";
|
||||||
|
|
||||||
@@ -12,6 +14,9 @@ export type StackActionStatus = {
|
|||||||
phase: StackActionPhase;
|
phase: StackActionPhase;
|
||||||
message?: string;
|
message?: string;
|
||||||
at: number;
|
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
|
/** 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
|
* 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
|
* rather than held as a single value — otherwise the first action to finish
|
||||||
* would clear the other rows' spinners and status.
|
* 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() {
|
export function useStackActions() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const token = useAuthStore((s) => s.accessToken);
|
||||||
const [busy, setBusy] = useState<Record<string, true>>({});
|
const [busy, setBusy] = useState<Record<string, true>>({});
|
||||||
const [statusMap, setStatusMap] = useState<Record<string, StackActionStatus>>({});
|
const [statusMap, setStatusMap] = useState<Record<string, StackActionStatus>>({});
|
||||||
const timers = useRef<Record<string, number>>({});
|
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 (
|
const run = async (
|
||||||
id: string,
|
id: string,
|
||||||
label: string,
|
label: string,
|
||||||
fn: (id: string) => Promise<unknown>
|
fn: (id: string) => Promise<unknown>
|
||||||
) => {
|
) => {
|
||||||
window.clearTimeout(timers.current[id]);
|
begin(id, label);
|
||||||
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}…`);
|
const t = toast.loading(`${label} ${id}…`);
|
||||||
try {
|
try {
|
||||||
await fn(id);
|
await fn(id);
|
||||||
toast.success(`${label} ${id} ✓`, { id: t });
|
toast.success(`${label} ${id} ✓`, { id: t });
|
||||||
setStatusMap((s) => ({ ...s, [id]: { id, label, phase: "success", at: Date.now() } }));
|
succeed(id, label);
|
||||||
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) {
|
} catch (err) {
|
||||||
const message = apiErrorMessage(err);
|
const message = apiErrorMessage(err);
|
||||||
toast.error(message, { id: t });
|
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 {
|
} finally {
|
||||||
setBusy((b) => omit(b, id));
|
setBusy((b) => omit(b, id));
|
||||||
}
|
}
|
||||||
@@ -76,6 +117,7 @@ export function useStackActions() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isBusy = useCallback((id: string) => busy[id] === true, [busy]);
|
const isBusy = useCallback((id: string) => busy[id] === true, [busy]);
|
||||||
|
const statusFor = useCallback((id: string) => statusMap[id], [statusMap]);
|
||||||
|
|
||||||
const dismissStatus = useCallback((id: string) => {
|
const dismissStatus = useCallback((id: string) => {
|
||||||
window.clearTimeout(timers.current[id]);
|
window.clearTimeout(timers.current[id]);
|
||||||
@@ -86,12 +128,18 @@ export function useStackActions() {
|
|||||||
return {
|
return {
|
||||||
isBusy,
|
isBusy,
|
||||||
statuses,
|
statuses,
|
||||||
|
statusFor,
|
||||||
dismissStatus,
|
dismissStatus,
|
||||||
start: (id: string) => run(id, "Starting", stacksApi.start),
|
start: (id: string) => run(id, "Starting", stacksApi.start),
|
||||||
stop: (id: string) => run(id, "Stopping", stacksApi.stop),
|
stop: (id: string) => run(id, "Stopping", stacksApi.stop),
|
||||||
restart: (id: string) => run(id, "Restarting", stacksApi.restart),
|
restart: (id: string) => run(id, "Restarting", stacksApi.restart),
|
||||||
pull: (id: string) => run(id, "Pulling", stacksApi.pull),
|
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),
|
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 { Card } from "@/components/ui";
|
||||||
import { HostHeader } from "@/components/hosts/HostHeader";
|
import { HostHeader } from "@/components/hosts/HostHeader";
|
||||||
import { StacksTable } from "@/components/stacks/StacksTable";
|
import { StacksTable } from "@/components/stacks/StacksTable";
|
||||||
import { ActionStatusList } from "@/components/stacks/ActionStatusBanner";
|
|
||||||
import { AttentionStrip } from "@/components/dashboard/AttentionStrip";
|
import { AttentionStrip } from "@/components/dashboard/AttentionStrip";
|
||||||
import { FleetKpiRow } from "@/components/dashboard/FleetKpiRow";
|
import { FleetKpiRow } from "@/components/dashboard/FleetKpiRow";
|
||||||
import { StackStatusBar } from "@/components/dashboard/StackStatusBar";
|
import { StackStatusBar } from "@/components/dashboard/StackStatusBar";
|
||||||
@@ -22,7 +21,7 @@ import type { Agent } from "@/types";
|
|||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
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 qc = useQueryClient();
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
@@ -113,8 +112,6 @@ export function Dashboard() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ActionStatusList statuses={statuses} onDismiss={dismissStatus} />
|
|
||||||
|
|
||||||
{/* ---- Local host stacks ---- */}
|
{/* ---- Local host stacks ---- */}
|
||||||
<section>
|
<section>
|
||||||
{hasAgents ? <HostHeader /> : <h2 className="sp-label mb-3">This host</h2>}
|
{hasAgents ? <HostHeader /> : <h2 className="sp-label mb-3">This host</h2>}
|
||||||
@@ -125,6 +122,8 @@ export function Dashboard() {
|
|||||||
hostMem={info.data?.ram.total ?? 0}
|
hostMem={info.data?.ram.total ?? 0}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
|
statusFor={statusFor}
|
||||||
|
onDismissStatus={dismissStatus}
|
||||||
loading={stacks.isLoading}
|
loading={stacks.isLoading}
|
||||||
onStart={start}
|
onStart={start}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { Plus, Search, HardDrive } from "lucide-react";
|
import { Plus, Search, HardDrive } from "lucide-react";
|
||||||
import { Button, Input } from "@/components/ui";
|
import { Button, Input } from "@/components/ui";
|
||||||
import { StacksTable } from "@/components/stacks/StacksTable";
|
import { StacksTable } from "@/components/stacks/StacksTable";
|
||||||
import { ActionStatusList } from "@/components/stacks/ActionStatusBanner";
|
|
||||||
import { RestoreButton } from "@/components/stacks/BackupRestore";
|
import { RestoreButton } from "@/components/stacks/BackupRestore";
|
||||||
import { AgentStacksSection } from "@/components/stacks/AgentStacksSection";
|
import { AgentStacksSection } from "@/components/stacks/AgentStacksSection";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
@@ -24,7 +23,7 @@ const STATUS_FILTERS: Record<string, StatusFilter> = {
|
|||||||
|
|
||||||
export function Stacks() {
|
export function Stacks() {
|
||||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
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();
|
useStackActions();
|
||||||
// The dashboard explore bar deep-links here with ?q= / ?filter=.
|
// The dashboard explore bar deep-links here with ?q= / ?filter=.
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
@@ -125,8 +124,6 @@ export function Stacks() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ActionStatusList statuses={statuses} onDismiss={dismissStatus} />
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
{hasAgents && (
|
{hasAgents && (
|
||||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
<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}
|
hostMem={info.data?.ram.total ?? 0}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
|
statusFor={statusFor}
|
||||||
|
onDismissStatus={dismissStatus}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
showEdit
|
showEdit
|
||||||
showDelete
|
showDelete
|
||||||
|
|||||||
Reference in New Issue
Block a user