Files
stackpilot/frontend/src/pages/Dashboard.tsx
T
menzeljandClaude Opus 5 1e8d4248fd
CI / build-and-push (push) Successful in 1m55s
Stream update progress into a bar on the stack's row (0.42.0)
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
2026-08-31 00:33:34 +02:00

239 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Clock, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@/components/ui";
import { HostHeader } from "@/components/hosts/HostHeader";
import { StacksTable } from "@/components/stacks/StacksTable";
import { AttentionStrip } from "@/components/dashboard/AttentionStrip";
import { FleetKpiRow } from "@/components/dashboard/FleetKpiRow";
import { StackStatusBar } from "@/components/dashboard/StackStatusBar";
import { HostResourceTable } from "@/components/dashboard/HostResourceTable";
import { stacksApi } from "@/api/stacks";
import { systemApi } from "@/api/system";
import { agentsApi } from "@/api/agents";
import { dashboardApi } from "@/api/dashboard";
import { apiErrorMessage } from "@/api/client";
import { cn, relativeTime } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
import type { Agent } from "@/types";
export function Dashboard() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const { isBusy, statusFor, dismissStatus, start, stop, restart } = useStackActions();
const qc = useQueryClient();
const [refreshing, setRefreshing] = useState(false);
const fleet = useQuery({
queryKey: ["dashboard-fleet"],
queryFn: () => dashboardApi.fleet(),
refetchInterval: 20000,
});
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
const stats = useQuery({ queryKey: ["stack-stats"], queryFn: stacksApi.stats, refetchInterval: 5000 });
const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 30000 });
const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 });
const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list(), refetchInterval: 15000 });
const hasAgents = (agents.data?.length ?? 0) > 0;
const refreshFleet = async () => {
setRefreshing(true);
try {
const data = await dashboardApi.fleet(true);
qc.setQueryData(["dashboard-fleet"], data);
} catch (e) {
toast.error(apiErrorMessage(e));
} finally {
setRefreshing(false);
}
};
return (
<div className="space-y-8">
{/* ---- Header ---- */}
<div className="flex flex-wrap items-end justify-between gap-4">
<h1 className="sp-display text-[40px] leading-none sm:text-[50px]">Overview</h1>
<button
onClick={refreshFleet}
className="flex items-center gap-1.5 rounded-pill border border-sp-border bg-sp-surface px-3 py-1.5 text-xs text-sp-text-2 hover:text-sp-text-1"
title="Refresh now"
>
<RefreshCw className={cn("h-3.5 w-3.5", refreshing && "animate-spin")} />
{fleet.data ? relativeTime(fleet.data.as_of) : "…"}
</button>
</div>
{/* ---- Fleet cockpit (attention + KPIs + status + hosts) ---- */}
{fleet.isError && !fleet.data ? (
<div className="flex items-center justify-between gap-3 rounded-card border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-300">
<span className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 shrink-0" />
Couldnt load fleet overview: {apiErrorMessage(fleet.error)}
</span>
<button
onClick={refreshFleet}
className="shrink-0 rounded-pill border border-red-300 px-3 py-1 text-xs font-medium hover:bg-red-100 dark:border-red-800 dark:hover:bg-red-900/40"
>
Retry
</button>
</div>
) : (
<>
{/* Needs attention */}
<AttentionStrip items={fleet.data?.attention} loading={fleet.isLoading} />
{/* Fleet KPIs */}
{fleet.data ? (
<FleetKpiRow kpis={fleet.data.kpis} />
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="sp-skeleton h-24" />
))}
</div>
)}
{/* Status + hosts */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[340px_1fr]">
{fleet.data ? (
<StackStatusBar totals={fleet.data.status_totals} unhealthy={fleet.data.kpis.unhealthy} />
) : (
<div className="sp-skeleton h-40" />
)}
{fleet.data ? (
<HostResourceTable hosts={fleet.data.hosts} />
) : (
<div className="sp-skeleton h-40" />
)}
</div>
</>
)}
{/* ---- Local host stacks ---- */}
<section>
{hasAgents ? <HostHeader /> : <h2 className="sp-label mb-3">This host</h2>}
<StacksTable
stacks={stacks.data}
stats={stats.data}
hostCpus={info.data?.cpu_cores ?? 0}
hostMem={info.data?.ram.total ?? 0}
isAdmin={isAdmin}
isBusy={isBusy}
statusFor={statusFor}
onDismissStatus={dismissStatus}
loading={stacks.isLoading}
onStart={start}
onStop={stop}
onRestart={restart}
emptyText="No stacks yet. Create one from the Stacks page."
/>
</section>
{/* ---- Remote host stacks ---- */}
{agents.data?.map((agent) => (
<AgentDashboardSection key={agent.id} agent={agent} isAdmin={isAdmin} />
))}
{/* ---- Recent activity ---- */}
<section>
<h2 className="sp-label mb-3 flex items-center gap-2">
<Clock className="h-4 w-4" /> Recent activity
</h2>
<Card>
{audit.data && audit.data.length > 0 ? (
<ul className="divide-y divide-slate-100 text-sm dark:divide-slate-700">
{audit.data.map((a) => (
<li key={a.id} className="flex items-center justify-between py-2">
<span>
<span className="font-medium">{a.user}</span>{" "}
<span className="text-slate-500">{a.action}</span>{" "}
<span className="font-mono text-xs text-accent dark:text-accent-dark">{a.target}</span>
</span>
<span className="text-xs text-slate-400">{relativeTime(a.timestamp)}</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-slate-500">No activity yet.</p>
)}
</Card>
</section>
</div>
);
}
/* ---------------------------------------------------------------------- */
/* Remote host stacks section */
/* ---------------------------------------------------------------------- */
function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: boolean }) {
const qc = useQueryClient();
const online = agent.status === "online";
const [busyId, setBusyId] = useState<string | null>(null);
const stacks = useQuery({
queryKey: ["agent-stacks", agent.id],
queryFn: () => agentsApi.stacks(agent.id),
enabled: online,
refetchInterval: 8000,
});
const stats = useQuery({
queryKey: ["agent-stack-stats", agent.id],
queryFn: () => agentsApi.stackStats(agent.id),
enabled: online,
refetchInterval: 5000,
});
const sys = useQuery({
queryKey: ["agent-system", agent.id],
queryFn: () => agentsApi.system(agent.id),
enabled: online,
refetchInterval: 30000,
});
const run = async (action: string, label: string, id: string) => {
setBusyId(id);
const t = toast.loading(`${label} ${id} on ${agent.name}…`);
try {
await agentsApi.action(agent.id, id, action);
toast.success(`${label} ${id} ✓`, { id: t });
qc.invalidateQueries({ queryKey: ["agent-stacks", agent.id] });
qc.invalidateQueries({ queryKey: ["agent-stack-stats", agent.id] });
} catch (e) {
toast.error(apiErrorMessage(e), { id: t });
} finally {
setBusyId(null);
}
};
return (
<section>
<HostHeader agent={agent} />
{!online ? (
<Card>
<p className="text-sm text-slate-500">
Host is {agent.status}. Check it under Settings Remote hosts.
</p>
</Card>
) : (
<StacksTable
stacks={stacks.data}
stats={stats.data}
hostCpus={sys.data?.cpu_cores ?? 0}
hostMem={sys.data?.mem_total ?? 0}
isAdmin={isAdmin}
isBusy={(id) => busyId === id}
loading={stacks.isLoading}
linkBase={`/hosts/${agent.id}/stacks`}
onStart={(id) => run("start", "Starting", id)}
onStop={(id) => run("stop", "Stopping", id)}
onRestart={(id) => run("restart", "Restarting", id)}
emptyText="No stacks on this host."
/>
)}
</section>
);
}