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
239 lines
8.9 KiB
TypeScript
239 lines
8.9 KiB
TypeScript
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" />
|
||
Couldn’t 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>
|
||
);
|
||
}
|