Remove the remote-host (agent) integration (0.48.0)
CI / check (push) Successful in 7m17s
CI / build-and-push (push) Successful in 1m45s

StackPilot now manages exactly one Docker host: the one it runs on. The
stackpilot-agent sidecar and everything that proxied to it are gone — 4721
lines deleted against 657 added.

Deleted outright: agent/ (image, compose, env), agent_app.py, models/agent.py,
routers/agents.py (1200 lines), services/agent_service.py, the agent API client,
RemoteStackDetail, the host components and AgentStacksSection. That removes 57
API routes and the three /ws/agent-* proxies.

Threaded out everywhere else, which was the bulk of the work. Every API module
carried an optional agentId that switched the base path; every page that listed
Docker objects rendered one section per host behind a HostHeader; Files had a
host switcher; the New Stack editor and the template dialog had host selectors;
schedules, auto-update policies and stack summaries carried agent_id. All of it
is gone, and the typechecker drove the sweep — 85 files touched, tsc and the
build clean.

Two things the removal exposed as dead weight rather than merely unused:

compose_service kept an in-process busy set purely because the agent needed a
lock and has no database. With the agent gone that was a second source of truth
next to the real DB lock, so it is deleted; compute_status now reports only what
the containers say and the two callers that want "updating" overlay the lock.
StacksTable's linkBase prop only ever existed to point at /hosts/{id}/stacks.

The dashboard's "Hosts 1/1 online" KPI can no longer say anything else, so the
tile and the KPIs behind it are gone and the row is five wide.

Upgrading matters here. An existing install still has an agent table holding
each remote host's URL and bearer token — full Docker control of that host,
sitting in the database with nothing left to use it. _drop_removed_schema drops
it on first start, and drops the agent_id columns where the SQLite build
supports DROP COLUMN. Each statement runs in its own transaction on purpose: a
failed DDL poisons the transaction it is in, so sharing one would let an
unsupported column drop take the table drop down with it. test_agent_removal
covers both branches plus the fresh-install and idempotent cases, and an
end-to-end run against a seeded pre-0.48 database confirms the table is gone and
every /api/agents route answers 404.

Docstrings that justified a design by "shared with the agent, which has no
database" were rewritten rather than left lying: update_service's persistence
callback and image_status_store are still the right split (registry logic stays
testable without a database), but for that reason now, not the old one. The
README's multi-host sections are removed and an upgrade note explains what to do
with running agent containers; ROADMAP keeps its history behind a note saying
the feature it describes no longer exists.

CI no longer builds or pushes stackpilot-agent.

735 tests pass, ruff and tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
This commit is contained in:
menzelj
2026-08-31 14:11:54 +02:00
co-authored by Claude Opus 5
parent 09bed274eb
commit 51d1998307
85 changed files with 653 additions and 4717 deletions
-2
View File
@@ -6,7 +6,6 @@ import { Dashboard } from "@/pages/Dashboard";
import { Stacks } from "@/pages/Stacks";
import { StackDetail } from "@/pages/StackDetail";
import { StackEditor } from "@/pages/StackEditor";
import { RemoteStackDetail } from "@/pages/RemoteStackDetail";
import { Images } from "@/pages/Images";
import { Files } from "@/pages/Files";
import { Volumes } from "@/pages/Volumes";
@@ -63,7 +62,6 @@ export default function App() {
<Route path="/stacks/new" element={<StackEditor />} />
<Route path="/stacks/:id" element={<StackDetail />} />
<Route path="/stacks/:id/edit" element={<StackEditor />} />
<Route path="/hosts/:agentId/stacks/:id" element={<RemoteStackDetail />} />
<Route path="/networks" element={<Networks />} />
<Route path="/images" element={<Images />} />
<Route path="/volumes" element={<Volumes />} />
-132
View File
@@ -1,132 +0,0 @@
import api from "./client";
import { backupParams, readReport } from "./backups";
import type { BackupInventory, BackupOptions, RestoreResult } from "./backups";
import type { Agent, StackDetail, StackStats, StackSummary, StackUpdateInfo } from "@/types";
export type RemoteStackSummary = StackSummary & { agent_id: number; agent_name: string };
export type RemoteStackDetail = StackDetail & { agent_id: number; agent_name: string };
export interface AgentSystem {
hostname: string;
docker_version: string;
host_os: string;
cpu_cores: number;
mem_total: number;
mem_used: number;
disk_total: number;
disk_used: number;
containers_running: number;
containers_total: number;
compose_running?: number; // agents < 0.31.1 don't report it
}
export const agentsApi = {
list: (refresh = true) =>
api.get<Agent[]>(`/api/agents?refresh=${refresh}`).then((r) => r.data),
create: (body: { name: string; url: string; token: string }) =>
api.post<Agent>("/api/agents", body).then((r) => r.data),
update: (id: number, body: { name?: string; url?: string; token?: string }) =>
api.put<Agent>(`/api/agents/${id}`, body).then((r) => r.data),
remove: (id: number) => api.delete(`/api/agents/${id}`).then((r) => r.data),
ping: (id: number) =>
api.post<{ status: string; hostname?: string }>(`/api/agents/${id}/ping`).then((r) => r.data),
system: (id: number) =>
api.get<AgentSystem>(`/api/agents/${id}/system`).then((r) => r.data),
stacks: (id: number) =>
api.get<RemoteStackSummary[]>(`/api/agents/${id}/stacks`).then((r) => r.data),
stackStats: (id: number) =>
api.get<Record<string, StackStats>>(`/api/agents/${id}/stacks/stats`).then((r) => r.data),
stackUpdates: (id: number) =>
api
.get<Record<string, StackUpdateInfo>>(`/api/agents/${id}/stacks/updates`)
.then((r) => r.data),
createStack: (id: number, body: { name: string; yaml: string; env?: string }) =>
api
.post<{ id: string; name: string }>(`/api/agents/${id}/stacks`, body)
.then((r) => r.data),
stack: (id: number, stackId: string) =>
api.get<RemoteStackDetail>(`/api/agents/${id}/stacks/${stackId}`).then((r) => r.data),
logs: (id: number, stackId: string, tail = 200) =>
api
.get<{ logs: string }>(`/api/agents/${id}/stacks/${stackId}/logs?tail=${tail}`)
.then((r) => r.data),
update_stack: (id: number, stackId: string, body: { yaml?: string; env?: string }) =>
api.put(`/api/agents/${id}/stacks/${stackId}`, body).then((r) => r.data),
action: (id: number, stackId: string, action: string) =>
api.post(`/api/agents/${id}/stacks/${stackId}/${action}`).then((r) => r.data),
backupInventory: (id: number, stackId: string) =>
api
.get<BackupInventory>(`/api/agents/${id}/stacks/${stackId}/backup/inventory`)
.then((r) => r.data),
backupDownload: async (id: number, stackId: string, opts: BackupOptions) => {
const res = await api.get(`/api/agents/${id}/stacks/${stackId}/backup`, {
params: backupParams(opts),
responseType: "blob",
});
const cd = res.headers["content-disposition"] as string | undefined;
const name = cd?.match(/filename="?([^"]+)"?/)?.[1] ?? `backup-${stackId}.tar.gz`;
const url = URL.createObjectURL(res.data as Blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
return readReport(res.headers);
},
backupPush: (
id: number,
stackId: string,
body: {
destination_id: number;
include_volumes: boolean;
include_binds?: boolean;
stop_first: boolean;
binds?: string[];
volumes?: string[];
}
) =>
api
.post<{ ok: boolean; destination: string; name: string }>(
`/api/agents/${id}/stacks/${stackId}/backup/push`,
body
)
.then((r) => r.data),
restoreUpload: (
id: number,
file: File,
opts: {
targetId?: string;
overwrite: boolean;
restoreVolumes: boolean;
restoreBinds?: boolean;
}
) => {
const form = new FormData();
form.append("file", file);
if (opts.targetId) form.append("target_id", opts.targetId);
form.append("overwrite", String(opts.overwrite));
form.append("restore_volumes", String(opts.restoreVolumes));
form.append("restore_binds", String(opts.restoreBinds ?? true));
return api
.post<RestoreResult>(`/api/agents/${id}/stacks/restore`, form)
.then((r) => r.data);
},
restoreFrom: (
id: number,
body: {
destination_id: number;
name: string;
target_id?: string;
overwrite: boolean;
restore_volumes: boolean;
restore_binds?: boolean;
}
) =>
api
.post<RestoreResult>(`/api/agents/${id}/stacks/restore-from`, body)
.then((r) => r.data),
};
+6 -13
View File
@@ -3,8 +3,6 @@ import api from "./client";
export interface AutoUpdatePolicy {
id: number | null;
stack_id: string;
agent_id: number | null;
agent_name: string | null;
enabled: boolean;
redeploy: boolean;
last_run: string | null;
@@ -12,17 +10,12 @@ export interface AutoUpdatePolicy {
last_result: string | null;
}
// Local host, or a remote agent's stack when agentId is given.
const base = (stackId: string, agentId?: number) =>
agentId != null
? `/api/agents/${agentId}/stacks/${stackId}/auto-update`
: `/api/stacks/${stackId}/auto-update`;
const base = (stackId: string) => `/api/stacks/${stackId}/auto-update`;
export const autoUpdateApi = {
get: (stackId: string, agentId?: number) =>
api.get<AutoUpdatePolicy>(base(stackId, agentId)).then((r) => r.data),
set: (stackId: string, body: { enabled: boolean; redeploy: boolean }, agentId?: number) =>
api.put<AutoUpdatePolicy>(base(stackId, agentId), body).then((r) => r.data),
run: (stackId: string, agentId?: number) =>
api.post<AutoUpdatePolicy>(`${base(stackId, agentId)}/run`).then((r) => r.data),
get: (stackId: string) => api.get<AutoUpdatePolicy>(base(stackId)).then((r) => r.data),
set: (stackId: string, body: { enabled: boolean; redeploy: boolean }) =>
api.put<AutoUpdatePolicy>(base(stackId), body).then((r) => r.data),
run: (stackId: string) =>
api.post<AutoUpdatePolicy>(`${base(stackId)}/run`).then((r) => r.data),
};
+1 -1
View File
@@ -67,7 +67,7 @@ export interface BackupOptions {
volumes?: string[];
}
/** Query params shared by the local and the agent-proxied backup endpoints. */
/** Query params for the backup endpoints. */
export function backupParams(opts: BackupOptions) {
return {
include_volumes: opts.includeVolumes,
+4 -7
View File
@@ -31,13 +31,10 @@ export interface ContainerDetail {
export type ContainerAction = "start" | "stop" | "restart";
// Base path for the local host or, when agentId is given, a remote agent.
const base = (agentId?: number) =>
agentId != null ? `/api/agents/${agentId}/containers` : "/api/containers";
const base = "/api/containers";
export const containersApi = {
inspect: (id: string, agentId?: number) =>
api.get<ContainerDetail>(`${base(agentId)}/${id}`).then((r) => r.data),
action: (id: string, action: ContainerAction, agentId?: number) =>
api.post(`${base(agentId)}/${id}/${action}`).then((r) => r.data),
inspect: (id: string) => api.get<ContainerDetail>(`${base}/${id}`).then((r) => r.data),
action: (id: string, action: ContainerAction) =>
api.post(`${base}/${id}/${action}`).then((r) => r.data),
};
+1 -3
View File
@@ -9,7 +9,7 @@ export interface StackBuckets {
}
export interface FleetHost {
id: string | number; // "local" or an agent id
id: string | number;
name: string;
online: boolean;
status: string;
@@ -26,8 +26,6 @@ export interface FleetHost {
}
export interface FleetKpis {
hosts_online: number;
hosts_total: number;
stacks_running: number;
stacks_partial: number;
stacks_total: number;
+21 -25
View File
@@ -20,51 +20,48 @@ function triggerDownload(blob: Blob, filename: string) {
URL.revokeObjectURL(url);
}
// Base path for the local host or, when agentId is given, a remote agent.
const base = (agentId?: number) =>
agentId != null ? `/api/agents/${agentId}/files` : "/api/files";
const base = "/api/files";
export const filesApi = {
list: (path: string, showHidden = false, agentId?: number) =>
list: (path: string, showHidden = false) =>
api
.get<HostPathResult>(`${base(agentId)}/list`, { params: { path, show_hidden: showHidden } })
.get<HostPathResult>(`${base}/list`, { params: { path, show_hidden: showHidden } })
.then((r) => r.data),
read: (path: string, agentId?: number) =>
api.get<FileContent>(`${base(agentId)}/read`, { params: { path } }).then((r) => r.data),
read: (path: string) =>
api.get<FileContent>(`${base}/read`, { params: { path } }).then((r) => r.data),
write: (path: string, content: string, agentId?: number) =>
api.put<{ path: string; size: number }>(`${base(agentId)}/write`, { path, content }).then((r) => r.data),
write: (path: string, content: string) =>
api.put<{ path: string; size: number }>(`${base}/write`, { path, content }).then((r) => r.data),
mkdir: (path: string, name: string, agentId?: number) =>
api.post<{ path: string }>(`${base(agentId)}/mkdir`, { path, name }).then((r) => r.data),
mkdir: (path: string, name: string) =>
api.post<{ path: string }>(`${base}/mkdir`, { path, name }).then((r) => r.data),
touch: (path: string, name: string, agentId?: number) =>
api.post<{ path: string }>(`${base(agentId)}/touch`, { path, name }).then((r) => r.data),
touch: (path: string, name: string) =>
api.post<{ path: string }>(`${base}/touch`, { path, name }).then((r) => r.data),
rename: (path: string, newName: string, agentId?: number) =>
api.post<{ path: string }>(`${base(agentId)}/rename`, { path, new_name: newName }).then((r) => r.data),
rename: (path: string, newName: string) =>
api.post<{ path: string }>(`${base}/rename`, { path, new_name: newName }).then((r) => r.data),
remove: (path: string, recursive = false, agentId?: number) =>
api.delete(base(agentId), { params: { path, recursive } }).then((r) => r.data),
remove: (path: string, recursive = false) =>
api.delete(base, { params: { path, recursive } }).then((r) => r.data),
copy: (src: string, destDir: string, overwrite = false, agentId?: number) =>
copy: (src: string, destDir: string, overwrite = false) =>
api
.post<{ path: string }>(`${base(agentId)}/copy`, { src, dest_dir: destDir, overwrite })
.post<{ path: string }>(`${base}/copy`, { src, dest_dir: destDir, overwrite })
.then((r) => r.data),
move: (src: string, destDir: string, overwrite = false, agentId?: number) =>
move: (src: string, destDir: string, overwrite = false) =>
api
.post<{ path: string }>(`${base(agentId)}/move`, { src, dest_dir: destDir, overwrite })
.post<{ path: string }>(`${base}/move`, { src, dest_dir: destDir, overwrite })
.then((r) => r.data),
download: async (
path: string,
filename: string,
agentId?: number,
onProgress?: (loaded: number, total: number | undefined) => void,
) => {
const res = await api.get(`${base(agentId)}/download`, {
const res = await api.get(`${base}/download`, {
params: { path },
responseType: "blob",
onDownloadProgress: onProgress
@@ -79,7 +76,6 @@ export const filesApi = {
file: File,
overwrite = false,
relPath = "",
agentId?: number,
onProgress?: (pct: number) => void,
) => {
const form = new FormData();
@@ -87,7 +83,7 @@ export const filesApi = {
form.append("overwrite", String(overwrite));
if (relPath) form.append("rel_path", relPath);
form.append("file", file);
const res = await api.post<{ ok: boolean; name: string }>(`${base(agentId)}/upload`, form, {
const res = await api.post<{ ok: boolean; name: string }>(`${base}/upload`, form, {
onUploadProgress: onProgress
? (e) => {
const pct = e.total ? (e.loaded / e.total) * 100 : (e.progress ?? 0) * 100;
+8 -14
View File
@@ -18,22 +18,16 @@ export interface ImageRow {
update: UpdateStatus | null;
}
// Base path for the local host or, when agentId is given, a remote agent.
const base = (agentId?: number) =>
agentId != null ? `/api/agents/${agentId}/images` : "/api/images";
const base = "/api/images";
export const imagesApi = {
list: (agentId?: number) => api.get<ImageRow[]>(base(agentId)).then((r) => r.data),
updates: (agentId?: number) =>
api.get<Record<string, UpdateStatus>>(`${base(agentId)}/updates`).then((r) => r.data),
check: (agentId?: number) =>
api.post<Record<string, UpdateStatus>>(`${base(agentId)}/check`).then((r) => r.data),
prune: (allUnused: boolean, agentId?: number) =>
list: () => api.get<ImageRow[]>(base).then((r) => r.data),
updates: () => api.get<Record<string, UpdateStatus>>(`${base}/updates`).then((r) => r.data),
check: () => api.post<Record<string, UpdateStatus>>(`${base}/check`).then((r) => r.data),
prune: (allUnused: boolean) =>
api
.post<{ ImagesDeleted: unknown[]; SpaceReclaimed: number }>(
`${base(agentId)}/prune`,
null,
{ params: { all: allUnused } }
)
.post<{ ImagesDeleted: unknown[]; SpaceReclaimed: number }>(`${base}/prune`, null, {
params: { all: allUnused },
})
.then((r) => r.data),
};
+13 -19
View File
@@ -34,25 +34,19 @@ export interface NetworkContainer {
connected: boolean;
}
// Base path for the local host or, when agentId is given, a remote agent.
const base = (agentId?: number) =>
agentId != null ? `/api/agents/${agentId}/networks` : "/api/networks";
const base = "/api/networks";
export const networksApi = {
list: (agentId?: number) =>
api.get<NetworkInfo[]>(base(agentId)).then((r) => r.data),
inspect: (id: string, agentId?: number) =>
api.get<NetworkInfo>(`${base(agentId)}/${id}`).then((r) => r.data),
containers: (id: string, agentId?: number) =>
api.get<NetworkContainer[]>(`${base(agentId)}/${id}/containers`).then((r) => r.data),
create: (body: NetworkCreate, agentId?: number) =>
api.post<NetworkInfo>(base(agentId), body).then((r) => r.data),
connect: (id: string, container: string, aliases?: string[], agentId?: number) =>
api.post(`${base(agentId)}/${id}/connect`, { container, aliases }).then((r) => r.data),
disconnect: (id: string, container: string, force = false, agentId?: number) =>
api.post(`${base(agentId)}/${id}/disconnect`, { container, force }).then((r) => r.data),
remove: (id: string, agentId?: number) =>
api.delete(`${base(agentId)}/${id}`).then((r) => r.data),
prune: (agentId?: number) =>
api.post<{ NetworksDeleted: string[] | null }>(`${base(agentId)}/prune`).then((r) => r.data),
list: () => api.get<NetworkInfo[]>(base).then((r) => r.data),
inspect: (id: string) => api.get<NetworkInfo>(`${base}/${id}`).then((r) => r.data),
containers: (id: string) =>
api.get<NetworkContainer[]>(`${base}/${id}/containers`).then((r) => r.data),
create: (body: NetworkCreate) => api.post<NetworkInfo>(base, body).then((r) => r.data),
connect: (id: string, container: string, aliases?: string[]) =>
api.post(`${base}/${id}/connect`, { container, aliases }).then((r) => r.data),
disconnect: (id: string, container: string, force = false) =>
api.post(`${base}/${id}/disconnect`, { container, force }).then((r) => r.data),
remove: (id: string) => api.delete(`${base}/${id}`).then((r) => r.data),
prune: () =>
api.post<{ NetworksDeleted: string[] | null }>(`${base}/prune`).then((r) => r.data),
};
-3
View File
@@ -5,8 +5,6 @@ export interface BackupSchedule {
stack_id: string;
destination_id: number;
destination_name: string | null;
agent_id: number | null;
agent_name: string | null;
frequency: "hourly" | "daily" | "weekly";
hour: number;
minute: number;
@@ -24,7 +22,6 @@ export interface BackupSchedule {
export interface ScheduleInput {
stack_id: string;
destination_id: number;
agent_id?: number | null;
frequency: string;
hour: number;
minute: number;
+9 -21
View File
@@ -9,30 +9,18 @@ export interface SecretEntry {
modified: number;
}
// Local host, or a remote agent's stack when agentId is given.
const base = (stackId: string, agentId?: number) =>
agentId != null
? `/api/agents/${agentId}/stacks/${stackId}/secrets`
: `/api/stacks/${stackId}/secrets`;
const base = (stackId: string) => `/api/stacks/${stackId}/secrets`;
export const secretsApi = {
list: (stackId: string, agentId?: number) =>
api.get<SecretEntry[]>(base(stackId, agentId)).then((r) => r.data),
write: (
stackId: string,
body: { kind: SecretKind; name: string; content: string },
agentId?: number,
) => api.put(base(stackId, agentId), body).then((r) => r.data),
remove: (stackId: string, kind: SecretKind, name: string, agentId?: number) =>
api.delete(`${base(stackId, agentId)}/${kind}/${name}`).then((r) => r.data),
list: (stackId: string) => api.get<SecretEntry[]>(base(stackId)).then((r) => r.data),
write: (stackId: string, body: { kind: SecretKind; name: string; content: string }) =>
api.put(base(stackId), body).then((r) => r.data),
remove: (stackId: string, kind: SecretKind, name: string) =>
api.delete(`${base(stackId)}/${kind}/${name}`).then((r) => r.data),
attach: (
stackId: string,
body: { kind: SecretKind; name: string; service: string; target?: string },
agentId?: number,
) => api.post(`${base(stackId, agentId)}/attach`, body).then((r) => r.data),
detach: (
stackId: string,
body: { kind: SecretKind; name: string; service: string },
agentId?: number,
) => api.post(`${base(stackId, agentId)}/detach`, body).then((r) => r.data),
) => api.post(`${base(stackId)}/attach`, body).then((r) => r.data),
detach: (stackId: string, body: { kind: SecretKind; name: string; service: string }) =>
api.post(`${base(stackId)}/detach`, body).then((r) => r.data),
};
+2 -5
View File
@@ -19,12 +19,9 @@ export const templatesApi = {
list: () => api.get<TemplateSummary[]>("/api/templates").then((r) => r.data),
get: (id: string) =>
api.get<TemplateDetail>(`/api/templates/${id}`).then((r) => r.data),
instantiate: (id: string, name: string, agentId?: number | null) =>
instantiate: (id: string, name: string) =>
api
.post<{ id: string; name: string; agent_id: number | null }>(
`/api/templates/${id}/instantiate`,
{ name, agent_id: agentId ?? null }
)
.post<{ id: string; name: string }>(`/api/templates/${id}/instantiate`, { name })
.then((r) => r.data),
save: (body: {
name: string;
+8 -11
View File
@@ -1,21 +1,18 @@
import api from "./client";
import type { HostPathResult, VolumeInfo } from "@/types";
// Base path for the local host or, when agentId is given, a remote agent.
const base = (agentId?: number) =>
agentId != null ? `/api/agents/${agentId}/volumes` : "/api/volumes";
const base = "/api/volumes";
export const volumesApi = {
list: (agentId?: number) =>
api.get<VolumeInfo[]>(base(agentId)).then((r) => r.data),
sizes: (force = false, agentId?: number) =>
list: () => api.get<VolumeInfo[]>(base).then((r) => r.data),
sizes: (force = false) =>
api
.get<Record<string, number | null>>(`${base(agentId)}/sizes`, { params: { force } })
.get<Record<string, number | null>>(`${base}/sizes`, { params: { force } })
.then((r) => r.data),
remove: (name: string, force = false, agentId?: number) =>
api.delete(`${base(agentId)}/${name}?force=${force}`).then((r) => r.data),
prune: (agentId?: number) =>
api.post<{ VolumesDeleted: string[] | null }>(`${base(agentId)}/prune`).then((r) => r.data),
remove: (name: string, force = false) =>
api.delete(`${base}/${name}?force=${force}`).then((r) => r.data),
prune: () =>
api.post<{ VolumesDeleted: string[] | null }>(`${base}/prune`).then((r) => r.data),
generateYaml: (spec: Record<string, unknown>) =>
api
.post<{ yaml: string }>("/api/volumes/generate-yaml", spec)
@@ -3,7 +3,6 @@ import {
AlertTriangle,
AlertCircle,
CheckCircle2,
ServerOff,
HeartPulse,
ArrowUpCircle,
HardDrive,
@@ -15,7 +14,6 @@ import type { AttentionItem } from "@/api/dashboard";
import { cn } from "@/lib/utils";
const KIND_ICON: Record<string, React.ComponentType<{ className?: string }>> = {
agent_offline: ServerOff,
unhealthy: HeartPulse,
stack_error: AlertTriangle,
stack_partial: AlertCircle,
@@ -1,5 +1,5 @@
import { Link } from "react-router-dom";
import { Server, Boxes, Container, HeartPulse, ArrowUpCircle, Archive } from "lucide-react";
import { Boxes, Container, HeartPulse, ArrowUpCircle, Archive } from "lucide-react";
import type { FleetKpis } from "@/api/dashboard";
import { cn } from "@/lib/utils";
@@ -47,14 +47,7 @@ function Kpi({
export function FleetKpiRow({ kpis }: { kpis: FleetKpis }) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Kpi
icon={<Server />}
label="Hosts"
value={`${kpis.hosts_online}/${kpis.hosts_total}`}
sub="online"
tone={kpis.hosts_online < kpis.hosts_total ? "error" : "default"}
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<Kpi
icon={<Boxes />}
label="Stacks"
@@ -32,7 +32,7 @@ export function HostResourceTable({ hosts }: { hosts: FleetHost[] }) {
return (
<div className="sp-card overflow-hidden p-0">
<div className="border-b border-sp-border px-4 py-2.5">
<h2 className="sp-label">Hosts</h2>
<h2 className="sp-label">Host resources</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
-17
View File
@@ -1,17 +0,0 @@
import { cn } from "@/lib/utils";
const color: Record<string, string> = {
online: "bg-green-500",
offline: "bg-red-500",
unauthorized: "bg-amber-500",
unknown: "bg-slate-400",
};
export function HostDot({ status }: { status: string }) {
return (
<span
className={cn("inline-block h-2.5 w-2.5 rounded-full", color[status] ?? color.unknown)}
title={status}
/>
);
}
@@ -1,30 +0,0 @@
import type { ReactNode } from "react";
import { HardDrive, Server } from "lucide-react";
import { HostDot } from "@/components/hosts/HostDot";
import type { Agent } from "@/types";
/**
* Section header for a host (local or a remote agent). `children` is rendered
* on the right for per-host action buttons.
*/
export function HostHeader({
agent,
children,
}: {
agent?: Agent;
children?: ReactNode;
}) {
return (
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
{agent ? <Server className="h-4 w-4" /> : <HardDrive className="h-4 w-4" />}
{agent ? agent.name : "This host"}
{agent && <HostDot status={agent.status} />}
{agent?.hostname && (
<span className="font-mono text-xs normal-case text-slate-400">{agent.hostname}</span>
)}
</h2>
<div className="flex flex-wrap gap-2">{children}</div>
</div>
);
}
-25
View File
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { NavLink, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
LayoutDashboard,
Boxes,
@@ -21,7 +20,6 @@ import {
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme";
import { agentsApi } from "@/api/agents";
import { VersionBadge } from "./VersionBadge";
type NavItem = {
@@ -91,14 +89,6 @@ export function TopNav() {
const signOutEverywhere = useAuthStore((s) => s.signOutEverywhere);
const { theme, toggle } = useThemeStore();
const agents = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list(),
refetchInterval: 30000,
});
const agentCount = agents.data?.length ?? 0;
const agentsOnline = agents.data?.filter((a) => a.status === "online").length ?? 0;
// Close avatar menu on outside click.
useEffect(() => {
if (!menuOpen) return;
@@ -159,21 +149,6 @@ export function TopNav() {
{/* Right cluster */}
<div className="ml-auto flex shrink-0 items-center gap-2 lg:ml-0">
<VersionBadge />
{agentCount > 0 && (
<button
onClick={() => navigate("/settings")}
className="hidden items-center gap-1.5 rounded-pill border border-sp-border bg-sp-surface px-2.5 py-1 text-xs font-medium text-sp-text-2 hover:text-sp-text-1 sm:flex"
title={`${agentsOnline}/${agentCount} remote hosts online`}
>
<span
className={cn(
"h-2 w-2 rounded-full",
agentsOnline === agentCount ? "bg-sp-green" : "bg-sp-amber"
)}
/>
{agentsOnline}/{agentCount}
</button>
)}
<button
onClick={toggle}
className="rounded-pill border border-sp-border bg-sp-surface p-2 text-sp-text-2 hover:text-sp-text-1"
@@ -1,98 +0,0 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Server } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@/components/ui";
import { StacksTable } from "@/components/stacks/StacksTable";
import { RestoreButton } from "@/components/stacks/BackupRestore";
import { HostDot } from "@/components/hosts/HostDot";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
import type { Agent } from "@/types";
export function AgentStacksSection({ agent, isAdmin }: { agent: Agent; isAdmin: boolean }) {
const qc = useQueryClient();
const [busyId, setBusyId] = useState<string | null>(null);
const online = agent.status === "online";
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 updates = useQuery({
queryKey: ["agent-stack-updates", agent.id],
queryFn: () => agentsApi.stackUpdates(agent.id),
enabled: online,
refetchInterval: 60000,
});
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-updates", agent.id] });
} catch (e) {
toast.error(apiErrorMessage(e), { id: t });
} finally {
setBusyId(null);
}
};
return (
<section>
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<Server className="h-4 w-4" />
{agent.name}
<HostDot status={agent.status} />
{agent.hostname && (
<span className="font-mono text-xs normal-case text-slate-400">{agent.hostname}</span>
)}
</h2>
{isAdmin && online && <RestoreButton agentId={agent.id} />}
</div>
{!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}
updates={updates.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)}
onUpdate={isAdmin ? (id) => run("update", "Updating", id) : undefined}
emptyText="No stacks on this host."
/>
)}
</section>
);
}
@@ -14,36 +14,34 @@ const STATUS_TONE: Record<string, string> = {
};
/**
* Watchtower-style auto-update control for one stack (local or, with agentId,
* a remote agent's stack). When a newer image digest is found by the background
* Watchtower-style auto-update control for one stack. When a newer image
* digest is found by the background
* check, the stack is pulled + redeployed or merely flagged, per the policy.
*/
export function AutoUpdatePanel({
stackId,
agentId,
isAdmin,
}: {
stackId: string;
agentId?: number;
isAdmin: boolean;
}) {
const qc = useQueryClient();
const key = ["auto-update", agentId ?? "local", stackId];
const key = ["auto-update", stackId];
const { data, isLoading } = useQuery({
queryKey: key,
queryFn: () => autoUpdateApi.get(stackId, agentId),
queryFn: () => autoUpdateApi.get(stackId),
});
const save = useMutation({
mutationFn: (body: { enabled: boolean; redeploy: boolean }) =>
autoUpdateApi.set(stackId, body, agentId),
autoUpdateApi.set(stackId, body),
onSuccess: (p) => qc.setQueryData(key, p),
onError: (e) => toast.error(apiErrorMessage(e)),
});
const runNow = useMutation({
mutationFn: () => autoUpdateApi.run(stackId, agentId),
mutationFn: () => autoUpdateApi.run(stackId),
onSuccess: (p) => {
qc.setQueryData(key, p);
toast.success(`Auto-update: ${p.last_status ?? "done"}`);
@@ -84,7 +82,7 @@ export function AutoUpdatePanel({
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={`mode-${agentId ?? "l"}-${stackId}`}
name={`mode-${stackId}`}
checked={p.redeploy}
onChange={() => save.mutate({ enabled: true, redeploy: true })}
/>
@@ -93,7 +91,7 @@ export function AutoUpdatePanel({
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={`mode-${agentId ?? "l"}-${stackId}`}
name={`mode-${stackId}`}
checked={!p.redeploy}
onChange={() => save.mutate({ enabled: true, redeploy: false })}
/>
@@ -5,7 +5,6 @@ import { toast } from "sonner";
import { Button } from "@/components/ui";
import { backupsApi, destinationsApi } from "@/api/backups";
import type { BackupReport } from "@/api/backups";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
import { formatBytes } from "@/lib/utils";
@@ -117,13 +116,7 @@ function AssetRow({
);
}
export function BackupButton({
stackId,
agentId,
}: {
stackId: string;
agentId?: number;
}) {
export function BackupButton({ stackId }: { stackId: string }) {
const [open, setOpen] = useState(false);
const [stopFirst, setStopFirst] = useState(true);
const [target, setTarget] = useState("download"); // "download" | destination id
@@ -138,11 +131,8 @@ export function BackupButton({
enabled: open,
});
const inventory = useQuery({
queryKey: ["backup-inventory", agentId ?? "local", stackId],
queryFn: () =>
agentId != null
? agentsApi.backupInventory(agentId, stackId)
: backupsApi.inventory(stackId),
queryKey: ["backup-inventory", stackId],
queryFn: () => backupsApi.inventory(stackId),
enabled: open,
});
@@ -175,10 +165,7 @@ export function BackupButton({
volumes: volSel.length ? volSel : undefined,
};
if (target === "download") {
const report =
agentId != null
? await agentsApi.backupDownload(agentId, stackId, opts)
: await backupsApi.download(stackId, opts);
const report = await backupsApi.download(stackId, opts);
toast.success(describe(report), { id: tid });
} else {
const body = {
@@ -189,10 +176,7 @@ export function BackupButton({
binds: opts.binds,
volumes: opts.volumes,
};
const res =
agentId != null
? await agentsApi.backupPush(agentId, stackId, body)
: await backupsApi.push(stackId, body);
const res = await backupsApi.push(stackId, body);
toast.success(`Pushed to ${res.destination}`, { id: tid });
}
setOpen(false);
@@ -310,7 +294,7 @@ export function BackupButton({
);
}
export function RestoreButton({ agentId }: { agentId?: number }) {
export function RestoreButton() {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<"upload" | "destination">("upload");
@@ -351,10 +335,7 @@ export function RestoreButton({ agentId }: { agentId?: number }) {
restoreVolumes,
restoreBinds,
};
res =
agentId != null
? await agentsApi.restoreUpload(agentId, file, opts)
: await backupsApi.restore(file, opts);
res = await backupsApi.restore(file, opts);
} else {
if (!destId || !remoteName) {
toast.error("Pick a destination and a backup", { id: tid });
@@ -369,15 +350,12 @@ export function RestoreButton({ agentId }: { agentId?: number }) {
restore_volumes: restoreVolumes,
restore_binds: restoreBinds,
};
res =
agentId != null
? await agentsApi.restoreFrom(agentId, body)
: await backupsApi.restoreFrom(body);
res = await backupsApi.restoreFrom(body);
}
const bits = [`${res.volumes_restored} volume(s)`];
if (res.binds_restored) bits.push(`${res.binds_restored} folder(s)`);
toast.success(`Restored '${res.stack_id}' — ${bits.join(", ")}`, { id: tid });
qc.invalidateQueries({ queryKey: agentId != null ? ["agent-stacks", agentId] : ["stacks"] });
qc.invalidateQueries({ queryKey: ["stacks"] });
setOpen(false);
setFile(null);
setTargetId("");
@@ -11,13 +11,11 @@ import type { ContainerInfo } from "@/types";
export function ContainerCard({
container,
agentId,
host,
isAdmin,
onChanged,
}: {
container: ContainerInfo;
agentId?: number;
host?: string;
isAdmin: boolean;
onChanged?: () => void;
@@ -28,8 +26,8 @@ export function ContainerCard({
const running = container.state === "running";
const detail = useQuery({
queryKey: ["container", agentId ?? "local", container.id],
queryFn: () => containersApi.inspect(container.id, agentId),
queryKey: ["container", container.id],
queryFn: () => containersApi.inspect(container.id),
enabled: open,
});
@@ -37,7 +35,7 @@ export function ContainerCard({
setBusy(true);
const t = toast.loading(`${action} ${container.service}`);
try {
await containersApi.action(container.id, action, agentId);
await containersApi.action(container.id, action);
toast.success(`${container.service}: ${action} ok`, { id: t });
onChanged?.();
if (open) detail.refetch();
@@ -153,7 +151,6 @@ export function ContainerCard({
<ContainerTerminal
containerId={container.id}
service={container.service}
agentId={agentId}
onClose={() => setTermOpen(false)}
/>
)}
@@ -8,7 +8,7 @@ export type ContainerPort = {
const WILDCARD_IPS = new Set(["", "0.0.0.0", "::"]);
/** Pick the host to link to: an explicit override (remote agent), the bound
/** Pick the host to link to: an explicit override, the bound
* host IP if it's a concrete address, otherwise the host we're viewing from. */
function linkHost(hostIp: string | undefined, override?: string): string {
if (override) return override;
@@ -25,7 +25,7 @@ function scheme(hostPort: string, container: string): "http" | "https" {
}
/** Clickable chips for a container's published ports. `host` overrides the
* link target (used for remote agent stacks). Renders nothing if unpublished. */
* link target. Renders nothing if unpublished. */
export function ContainerPorts({
ports,
host,
@@ -12,19 +12,17 @@ const SHELLS = ["/bin/sh", "/bin/bash", "/bin/ash"];
/**
* Interactive terminal modal: opens an exec session into a compose-managed
* container over `/ws/exec/{id}` (or `/ws/agent-exec/{aid}/{id}` for a remote
* agent) and wires it to an xterm.js terminal. Admin-only on the backend; a
* container over `/ws/exec/{id}` and wires it to an xterm.js terminal.
* Admin-only on the backend; a
* 4403 close surfaces as an "admin only" error.
*/
export function ContainerTerminal({
containerId,
service,
agentId,
onClose,
}: {
containerId: string;
service: string;
agentId?: number;
onClose: () => void;
}) {
const token = useAuthStore((s) => s.accessToken);
@@ -50,12 +48,8 @@ export function ContainerTerminal({
fit.fit();
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const path =
agentId != null
? `/ws/agent-exec/${agentId}/${containerId}`
: `/ws/exec/${containerId}`;
const url =
`${proto}://${window.location.host}${path}` +
`${proto}://${window.location.host}/ws/exec/${containerId}` +
`?token=${encodeURIComponent(token)}&cmd=${encodeURIComponent(shell)}`;
const ws = new WebSocket(url);
@@ -120,7 +114,7 @@ export function ContainerTerminal({
ws.close();
term.dispose();
};
}, [containerId, agentId, shell, token]);
}, [containerId, shell, token]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
@@ -29,11 +29,9 @@ const EMPTY_STATS: DeployStats = {
*/
export function DeployConsole({
stackId,
agentId,
onClose,
}: {
stackId: string;
agentId?: number;
onClose: () => void;
}) {
const [lines, setLines] = useState<LogLine[]>([]);
@@ -89,11 +87,7 @@ export function DeployConsole({
};
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const path =
agentId != null
? `/ws/agent-deploy/${agentId}/${stackId}`
: `/ws/deploy/${stackId}`;
const url = `${proto}://${window.location.host}${path}?token=${token}`;
const url = `${proto}://${window.location.host}/ws/deploy/${stackId}?token=${token}`;
const ws = new WebSocket(url);
ws.onmessage = (ev) => {
try {
@@ -136,7 +130,7 @@ export function DeployConsole({
window.clearInterval(timer);
ws.close();
};
}, [stackId, agentId, token, tracker]);
}, [stackId, token, tracker]);
useEffect(() => {
if (boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight;
+5 -9
View File
@@ -87,7 +87,7 @@ type LogLine = {
level: Level;
};
export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: number }) {
export function LogViewer({ stackId }: { stackId: string }) {
const [lines, setLines] = useState<LogLine[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const [connected, setConnected] = useState(false);
@@ -102,18 +102,14 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num
setError(null);
let gotError = false;
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const path =
agentId != null
? `/ws/agent-logs/${agentId}/${stackId}`
: `/ws/logs/${stackId}`;
const url = `${proto}://${window.location.host}${path}?token=${token}`;
const url = `${proto}://${window.location.host}/ws/logs/${stackId}?token=${token}`;
const ws = new WebSocket(url);
ws.onopen = () => setConnected(true);
ws.onclose = (ev) => {
setConnected(false);
// Auth rejection from the proxy/agent (JWT or agent token) closes 4401.
// An expired or revoked session closes 4401.
if (!gotError && ev.code === 4401) {
setError("Not authorized to stream logs (session or agent token).");
setError("Not authorized to stream logs sign in again.");
}
};
ws.onmessage = (ev) => {
@@ -137,7 +133,7 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num
}
};
return () => ws.close();
}, [stackId, agentId, token]);
}, [stackId, token]);
// Unique services seen so far, for the container filter.
const services = useMemo(() => {
@@ -15,26 +15,24 @@ import { apiErrorMessage } from "@/api/client";
export function SecretsPanel({
stackId,
yaml,
agentId,
isAdmin,
onChanged,
}: {
stackId: string;
yaml: string;
agentId?: number;
isAdmin: boolean;
onChanged?: () => void;
}) {
const qc = useQueryClient();
const key = ["secrets", agentId ?? "local", stackId];
const key = ["secrets", stackId];
const list = useQuery({
queryKey: key,
queryFn: () => secretsApi.list(stackId, agentId),
queryFn: () => secretsApi.list(stackId),
enabled: isAdmin,
});
const services = useQuery({
queryKey: ["editor-services", stackId, agentId, yaml.length],
queryKey: ["editor-services", stackId, yaml.length],
queryFn: () => editorApi.services(yaml),
enabled: isAdmin,
});
@@ -46,7 +44,7 @@ export function SecretsPanel({
const invalidate = () => qc.invalidateQueries({ queryKey: key });
const create = useMutation({
mutationFn: () => secretsApi.write(stackId, { kind, name: name.trim(), content }, agentId),
mutationFn: () => secretsApi.write(stackId, { kind, name: name.trim(), content }),
onSuccess: () => {
toast.success(`${kind} "${name}" saved`);
setName(""); setContent("");
@@ -56,21 +54,21 @@ export function SecretsPanel({
});
const remove = useMutation({
mutationFn: (s: SecretEntry) => secretsApi.remove(stackId, s.kind, s.name, agentId),
mutationFn: (s: SecretEntry) => secretsApi.remove(stackId, s.kind, s.name),
onSuccess: () => { toast.success("Deleted"); invalidate(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
const attach = useMutation({
mutationFn: (v: { s: SecretEntry; service: string; target?: string }) =>
secretsApi.attach(stackId, { kind: v.s.kind, name: v.s.name, service: v.service, target: v.target }, agentId),
secretsApi.attach(stackId, { kind: v.s.kind, name: v.s.name, service: v.service, target: v.target }),
onSuccess: () => { toast.success("Attached — redeploy the stack to apply"); onChanged?.(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
const detach = useMutation({
mutationFn: (v: { s: SecretEntry; service: string }) =>
secretsApi.detach(stackId, { kind: v.s.kind, name: v.s.name, service: v.service }, agentId),
secretsApi.detach(stackId, { kind: v.s.kind, name: v.s.name, service: v.service }),
onSuccess: () => { toast.success("Detached — redeploy the stack to apply"); onChanged?.(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
@@ -25,7 +25,6 @@ export function StacksTable({
statusFor,
onDismissStatus,
loading,
linkBase = "/stacks",
showEdit = false,
showDelete = false,
onStart,
@@ -45,7 +44,6 @@ export function StacksTable({
statusFor?: (id: string) => StackActionStatus | undefined;
onDismissStatus?: (id: string) => void;
loading: boolean;
linkBase?: string;
showEdit?: boolean;
showDelete?: boolean;
onStart: (id: string) => void;
@@ -86,7 +84,6 @@ export function StacksTable({
busy={isBusy(s.id)}
status={statusFor?.(s.id)}
onDismissStatus={onDismissStatus}
linkBase={linkBase}
showEdit={showEdit}
showDelete={showDelete}
onStart={onStart}
@@ -111,7 +108,6 @@ function StackRow({
busy,
status,
onDismissStatus,
linkBase,
showEdit,
showDelete,
onStart,
@@ -128,7 +124,6 @@ function StackRow({
busy: boolean;
status?: StackActionStatus;
onDismissStatus?: (id: string) => void;
linkBase: string;
showEdit: boolean;
showDelete: boolean;
onStart: (id: string) => void;
@@ -139,7 +134,7 @@ function StackRow({
const qc = useQueryClient();
const running = stack.running_count > 0;
const updateAvailable = update?.update_available ?? false;
const canDelete = showDelete && !stack.agent_id;
const canDelete = showDelete;
const [confirming, setConfirming] = useState(false);
const [deleting, setDeleting] = useState(false);
@@ -165,7 +160,7 @@ function StackRow({
to the CPU column, so the row never changes height. */}
<div className="flex items-center gap-2">
<Link
to={`${linkBase}/${stack.id}`}
to={`/stacks/${stack.id}`}
className="flex shrink-0 items-center gap-2"
>
<StatusDot status={stack.status} />
@@ -259,7 +254,7 @@ function StackRow({
)}
{showEdit && (
<Link
to={`${linkBase}/${stack.id}/edit`}
to={`/stacks/${stack.id}/edit`}
title="Edit"
className="rounded-lg p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
>
+2 -2
View File
@@ -4,7 +4,7 @@
*
* Compose is run with `--progress json` (compose ≥ 2.36), which emits one JSON
* object per status change — including per-layer `current`/`total` bytes, so a
* real percentage can be computed. Older compose (and older StackPilot agents)
* real percentage can be computed. Older compose releases
* emit the plain text form ` <id> <Status> <details>`; that is parsed too, but
* without byte totals the bar falls back to layer/container counts.
*/
@@ -192,7 +192,7 @@ export type ImageRow = {
pct: number;
done: boolean;
/** False when the stream carries no per-layer bytes for this image (old
* compose / old agent): show the state instead of a misleading bar. */
* compose): show the state instead of a misleading bar. */
measured: boolean;
error?: string;
detail: string;
+2 -84
View File
@@ -3,7 +3,6 @@ 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";
@@ -11,13 +10,11 @@ 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");
@@ -41,9 +38,7 @@ export function Dashboard() {
refetchInterval: 10000,
enabled: isAdmin,
});
const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list(), refetchInterval: 15000 });
const hasAgents = (agents.data?.length ?? 0) > 0;
const refreshFleet = async () => {
setRefreshing(true);
@@ -118,9 +113,9 @@ export function Dashboard() {
</>
)}
{/* ---- Local host stacks ---- */}
{/* ---- Stacks ---- */}
<section>
{hasAgents ? <HostHeader /> : <h2 className="sp-label mb-3">This host</h2>}
<h2 className="sp-label mb-3">Stacks</h2>
<StacksTable
stacks={stacks.data}
stats={stats.data}
@@ -138,11 +133,6 @@ export function Dashboard() {
/>
</section>
{/* ---- Remote host stacks ---- */}
{agents.data?.map((agent) => (
<AgentDashboardSection key={agent.id} agent={agent} isAdmin={isAdmin} />
))}
{/* ---- Recent activity (admin only, like the audit log itself) ---- */}
{isAdmin && (
<section>
@@ -172,75 +162,3 @@ export function Dashboard() {
</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>
);
}
+13 -64
View File
@@ -21,14 +21,12 @@ import {
Copy,
Scissors,
ClipboardPaste,
Server,
} from "lucide-react";
import { toast } from "sonner";
import Editor from "@monaco-editor/react";
import { Button, Card, Input, Spinner } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { filesApi } from "@/api/files";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme";
@@ -74,7 +72,6 @@ function crumbs(path: string): { label: string; path: string }[] {
export function Files() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const qc = useQueryClient();
const [host, setHost] = useState<number | undefined>(undefined); // undefined = local
const [path, setPath] = useState("/");
// Persist "Show hidden" across reloads — otherwise an uploaded dotfile (.env)
// becomes invisible again after a refresh and looks like it was lost.
@@ -117,24 +114,9 @@ export function Files() {
}
}, []);
const agents = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list(),
refetchInterval: 15000,
});
const onlineAgents = (agents.data ?? []).filter((a) => a.status === "online");
// Switch host: reset workspace state so we never mix paths/clipboards across hosts.
const switchHost = (h: number | undefined) => {
setHost(h);
setPath("/");
setEditing(null);
setClip(null);
};
const { data, isLoading, isFetching, error } = useQuery({
queryKey: ["files", host ?? "local", path, showHidden],
queryFn: () => filesApi.list(path, showHidden, host),
queryKey: ["files", path, showHidden],
queryFn: () => filesApi.list(path, showHidden),
});
const refresh = () => qc.invalidateQueries({ queryKey: ["files"] });
@@ -150,7 +132,7 @@ export function Files() {
const upload = useMutation({
mutationFn: ({ file, overwrite }: { file: File; overwrite: boolean }) => {
setProgress({ label: file.name, pct: 0, kind: "upload" });
return filesApi.upload(path, file, overwrite, "", host, (pct) =>
return filesApi.upload(path, file, overwrite, "", (pct) =>
setProgress({
label: file.name,
pct,
@@ -202,7 +184,7 @@ export function Files() {
const f = files[i];
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name;
try {
await filesApi.upload(path, f, true, rel, host, (filePct) => {
await filesApi.upload(path, f, true, rel, (filePct) => {
const sent = doneBytes + (filePct / 100) * f.size;
setProgress({
label: f.name,
@@ -240,7 +222,7 @@ export function Files() {
const paste = useMutation({
mutationFn: (overwrite: boolean) => {
const op = clip!.mode === "copy" ? filesApi.copy : filesApi.move;
return op(clip!.src, path, overwrite, host);
return op(clip!.src, path, overwrite);
},
onSuccess: () => {
toast.success(clip!.mode === "copy" ? "Copied" : "Moved");
@@ -258,7 +240,7 @@ export function Files() {
});
const remove = useMutation({
mutationFn: (e: HostPathEntry) => filesApi.remove(join(path, e.name), e.type === "dir", host),
mutationFn: (e: HostPathEntry) => filesApi.remove(join(path, e.name), e.type === "dir"),
onSuccess: () => {
toast.success("Deleted");
setDeleting(null);
@@ -276,7 +258,6 @@ export function Files() {
.download(
join(path, e.name),
isDir ? `${e.name}.zip` : e.name,
host,
(loaded, total) =>
setProgress({
label: e.name,
@@ -292,29 +273,6 @@ export function Files() {
return (
<div className="space-y-4">
{/* Host switcher (only when remote hosts are registered) */}
{(agents.data?.length ?? 0) > 0 && (
<div className="flex items-center gap-2 text-sm">
<Server className="h-4 w-4 text-slate-400" />
<span className="text-slate-500">Host</span>
<select
value={host ?? "local"}
onChange={(e) => switchHost(e.target.value === "local" ? undefined : Number(e.target.value))}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm dark:border-slate-600 dark:bg-slate-800"
>
<option value="local">This host</option>
{onlineAgents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
{host != null && !onlineAgents.some((a) => a.id === host) && (
<span className="text-xs text-amber-500">selected host is offline</span>
)}
</div>
)}
{/* Roots + actions */}
<div className="flex flex-wrap items-center gap-2">
{data?.roots.map((r) => (
@@ -584,7 +542,6 @@ export function Files() {
path={join(path, editing.name)}
name={editing.name}
isAdmin={isAdmin}
agentId={host}
onClose={() => setEditing(null)}
onSaved={refresh}
/>
@@ -593,7 +550,6 @@ export function Files() {
<NewEntryDialog
kind={newKind}
dir={path}
agentId={host}
onCancel={() => setNewKind(null)}
onDone={() => {
setNewKind(null);
@@ -605,7 +561,6 @@ export function Files() {
<RenameDialog
entry={renaming}
dir={path}
agentId={host}
onCancel={() => setRenaming(null)}
onDone={() => {
setRenaming(null);
@@ -661,14 +616,12 @@ function FileEditor({
path,
name,
isAdmin,
agentId,
onClose,
onSaved,
}: {
path: string;
name: string;
isAdmin: boolean;
agentId?: number;
onClose: () => void;
onSaved: () => void;
}) {
@@ -676,8 +629,8 @@ function FileEditor({
const [content, setContent] = useState("");
const [dirty, setDirty] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ["file-content", agentId ?? "local", path],
queryFn: () => filesApi.read(path, agentId),
queryKey: ["file-content", path],
queryFn: () => filesApi.read(path),
});
useEffect(() => {
@@ -685,7 +638,7 @@ function FileEditor({
}, [data]);
const save = useMutation({
mutationFn: () => filesApi.write(path, content, agentId),
mutationFn: () => filesApi.write(path, content),
onSuccess: () => {
toast.success("Saved");
setDirty(false);
@@ -735,7 +688,7 @@ function FileEditor({
? "This looks like a binary file and can't be edited here."
: `File is too large to edit (${formatBytes(data?.size ?? 0)}).`}
</p>
<Button variant="outline" onClick={() => filesApi.download(path, name, agentId)}>
<Button variant="outline" onClick={() => filesApi.download(path, name)}>
<Download className="h-4 w-4" /> Download instead
</Button>
</div>
@@ -767,13 +720,11 @@ function FileEditor({
function NewEntryDialog({
kind,
dir,
agentId,
onCancel,
onDone,
}: {
kind: "dir" | "file";
dir: string;
agentId?: number;
onCancel: () => void;
onDone: () => void;
}) {
@@ -781,8 +732,8 @@ function NewEntryDialog({
const create = useMutation({
mutationFn: () =>
kind === "dir"
? filesApi.mkdir(dir, name.trim(), agentId)
: filesApi.touch(dir, name.trim(), agentId),
? filesApi.mkdir(dir, name.trim())
: filesApi.touch(dir, name.trim()),
onSuccess: () => {
toast.success(kind === "dir" ? "Folder created" : "File created");
onDone();
@@ -812,19 +763,17 @@ function NewEntryDialog({
function RenameDialog({
entry,
dir,
agentId,
onCancel,
onDone,
}: {
entry: HostPathEntry;
dir: string;
agentId?: number;
onCancel: () => void;
onDone: () => void;
}) {
const [name, setName] = useState(entry.name);
const rename = useMutation({
mutationFn: () => filesApi.rename(join(dir, entry.name), name.trim(), agentId),
mutationFn: () => filesApi.rename(join(dir, entry.name), name.trim()),
onSuccess: () => {
toast.success("Renamed");
onDone();
+18 -56
View File
@@ -4,13 +4,10 @@ import { RefreshCw, ArrowUpCircle, CheckCircle2, HelpCircle, Eraser } from "luci
import { toast } from "sonner";
import { Button, Card, Spinner } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { HostHeader } from "@/components/hosts/HostHeader";
import { imagesApi, type ImageRow } from "@/api/images";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
import { formatBytes, relativeTime } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import type { Agent } from "@/types";
function UpdateBadge({ row }: { row: ImageRow }) {
const u = row.update;
@@ -31,50 +28,25 @@ function UpdateBadge({ row }: { row: ImageRow }) {
export function Images() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const agents = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list(),
refetchInterval: 15000,
});
const hasAgents = (agents.data?.length ?? 0) > 0;
return (
<div className="space-y-8">
<ImagesSection isAdmin={isAdmin} showHostLabel={hasAgents} />
{agents.data?.map((agent) => (
<ImagesSection key={agent.id} agent={agent} isAdmin={isAdmin} showHostLabel />
))}
</div>
);
return <ImagesSection isAdmin={isAdmin} />;
}
function ImagesSection({
agent,
isAdmin,
showHostLabel,
}: {
agent?: Agent;
isAdmin: boolean;
showHostLabel: boolean;
}) {
const agentId = agent?.id;
const online = !agent || agent.status === "online";
function ImagesSection({ isAdmin }: { isAdmin: boolean }) {
const qc = useQueryClient();
const [checking, setChecking] = useState(false);
const [pruneOpen, setPruneOpen] = useState(false);
const [pruneAll, setPruneAll] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["images", agentId ?? "local"],
queryFn: () => imagesApi.list(agentId),
enabled: online,
queryKey: ["images"],
queryFn: () => imagesApi.list(),
});
const check = async () => {
setChecking(true);
const t = toast.loading("Checking for updates…");
try {
await imagesApi.check(agentId);
await qc.invalidateQueries({ queryKey: ["images", agentId ?? "local"] });
await imagesApi.check();
await qc.invalidateQueries({ queryKey: ["images"] });
toast.success("Update check complete", { id: t });
} catch (e) {
toast.error(apiErrorMessage(e), { id: t });
@@ -84,7 +56,7 @@ function ImagesSection({
};
const prune = useMutation({
mutationFn: () => imagesApi.prune(pruneAll, agentId),
mutationFn: () => imagesApi.prune(pruneAll),
onSuccess: (r) => {
const n = r.ImagesDeleted?.length ?? 0;
toast.success(
@@ -94,35 +66,25 @@ function ImagesSection({
);
setPruneOpen(false);
setPruneAll(false);
qc.invalidateQueries({ queryKey: ["images", agentId ?? "local"] });
qc.invalidateQueries({ queryKey: ["images"] });
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
return (
<section>
{(showHostLabel || (isAdmin && online)) && (
<HostHeader agent={agent}>
{isAdmin && online && (
<>
<Button variant="outline" onClick={() => setPruneOpen(true)}>
<Eraser className="h-4 w-4" /> Prune
</Button>
<Button onClick={check} loading={checking}>
<RefreshCw className="h-4 w-4" /> Check updates
</Button>
</>
)}
</HostHeader>
{isAdmin && (
<div className="mb-3 flex flex-wrap items-center justify-end gap-2">
<Button variant="outline" onClick={() => setPruneOpen(true)}>
<Eraser className="h-4 w-4" /> Prune
</Button>
<Button onClick={check} loading={checking}>
<RefreshCw className="h-4 w-4" /> Check updates
</Button>
</div>
)}
{!online ? (
<Card>
<p className="text-sm text-slate-500">
Host is {agent?.status}. Check it under Settings Remote hosts.
</p>
</Card>
) : isLoading ? (
{isLoading ? (
<Spinner />
) : (
<Card className="overflow-x-auto p-0">
+28 -76
View File
@@ -13,61 +13,33 @@ import {
import { toast } from "sonner";
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { HostHeader } from "@/components/hosts/HostHeader";
import { networksApi, type NetworkInfo } from "@/api/networks";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import type { Agent } from "@/types";
const selectClass =
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800";
export function Networks() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const agents = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list(),
refetchInterval: 15000,
});
const hasAgents = (agents.data?.length ?? 0) > 0;
return (
<div className="space-y-8">
<NetworksSection isAdmin={isAdmin} showHostLabel={hasAgents} />
{agents.data?.map((agent) => (
<NetworksSection key={agent.id} agent={agent} isAdmin={isAdmin} showHostLabel />
))}
</div>
);
return <NetworksSection isAdmin={isAdmin} />;
}
function NetworksSection({
agent,
isAdmin,
showHostLabel,
}: {
agent?: Agent;
isAdmin: boolean;
showHostLabel: boolean;
}) {
const agentId = agent?.id;
const online = !agent || agent.status === "online";
function NetworksSection({ isAdmin }: { isAdmin: boolean }) {
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["networks", agentId ?? "local"],
queryFn: () => networksApi.list(agentId),
queryKey: ["networks"],
queryFn: () => networksApi.list(),
refetchInterval: 10000,
enabled: online,
});
const [creating, setCreating] = useState(false);
const [toDelete, setToDelete] = useState<NetworkInfo | null>(null);
const [expanded, setExpanded] = useState<string | null>(null);
const invalidate = () => qc.invalidateQueries({ queryKey: ["networks", agentId ?? "local"] });
const invalidate = () => qc.invalidateQueries({ queryKey: ["networks"] });
const colSpan = isAdmin ? 6 : 5;
const prune = useMutation({
mutationFn: () => networksApi.prune(agentId),
mutationFn: () => networksApi.prune(),
onSuccess: (r) => {
const n = r.NetworksDeleted?.length ?? 0;
toast.success(n ? `Pruned ${n} network(s)` : "No unused networks");
@@ -76,7 +48,7 @@ function NetworksSection({
onError: (e) => toast.error(apiErrorMessage(e)),
});
const remove = useMutation({
mutationFn: (id: string) => networksApi.remove(id, agentId),
mutationFn: (id: string) => networksApi.remove(id),
onSuccess: () => {
toast.success("Network deleted");
setToDelete(null);
@@ -85,32 +57,20 @@ function NetworksSection({
onError: (e) => toast.error(apiErrorMessage(e)),
});
const header = (
<HostHeader agent={agent}>
{isAdmin && online && (
<>
return (
<section>
{isAdmin && (
<div className="mb-3 flex flex-wrap items-center justify-end gap-2">
<Button variant="outline" onClick={() => prune.mutate()} loading={prune.isPending}>
<Eraser className="h-4 w-4" /> Prune unused
</Button>
<Button onClick={() => setCreating(true)}>
<Plus className="h-4 w-4" /> Create network
</Button>
</>
</div>
)}
</HostHeader>
);
return (
<section>
{(showHostLabel || (isAdmin && online)) && header}
{!online ? (
<Card>
<p className="text-sm text-slate-500">
Host is {agent?.status}. Check it under Settings Remote hosts.
</p>
</Card>
) : isLoading ? (
{isLoading ? (
<Spinner />
) : (
<Card className="overflow-x-auto p-0">
@@ -175,7 +135,7 @@ function NetworksSection({
{expanded === n.id && (
<tr>
<td colSpan={colSpan} className="bg-slate-50 px-4 py-3 dark:bg-slate-800/40">
<NetworkDetail network={n} isAdmin={isAdmin} agentId={agentId} />
<NetworkDetail network={n} isAdmin={isAdmin} />
</td>
</tr>
)}
@@ -195,7 +155,6 @@ function NetworksSection({
{creating && (
<CreateNetworkDialog
agentId={agentId}
onDone={() => { setCreating(false); invalidate(); }}
onCancel={() => setCreating(false)}
/>
@@ -222,31 +181,29 @@ function NetworksSection({
function NetworkDetail({
network,
isAdmin,
agentId,
}: {
network: NetworkInfo;
isAdmin: boolean;
agentId?: number;
}) {
const qc = useQueryClient();
const [pick, setPick] = useState("");
const { data, isLoading } = useQuery({
queryKey: ["network-containers", agentId ?? "local", network.id],
queryFn: () => networksApi.containers(network.id, agentId),
queryKey: ["network-containers", network.id],
queryFn: () => networksApi.containers(network.id),
refetchInterval: 10000,
});
const refresh = () => {
qc.invalidateQueries({ queryKey: ["network-containers", agentId ?? "local", network.id] });
qc.invalidateQueries({ queryKey: ["networks", agentId ?? "local"] });
qc.invalidateQueries({ queryKey: ["network-containers", network.id] });
qc.invalidateQueries({ queryKey: ["networks"] });
};
const connect = useMutation({
mutationFn: (container: string) => networksApi.connect(network.id, container, undefined, agentId),
mutationFn: (container: string) => networksApi.connect(network.id, container),
onSuccess: () => { toast.success("Container connected"); setPick(""); refresh(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
const disconnect = useMutation({
mutationFn: (container: string) => networksApi.disconnect(network.id, container, false, agentId),
mutationFn: (container: string) => networksApi.disconnect(network.id, container, false),
onSuccess: () => { toast.success("Container disconnected"); refresh(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
@@ -336,11 +293,9 @@ function Meta({ label, value, mono }: { label: string; value: string; mono?: boo
}
function CreateNetworkDialog({
agentId,
onDone,
onCancel,
}: {
agentId?: number;
onDone: () => void;
onCancel: () => void;
}) {
@@ -356,17 +311,14 @@ function CreateNetworkDialog({
const create = useMutation({
mutationFn: () =>
networksApi.create(
{
name: form.name,
driver: form.driver,
subnet: form.subnet.trim() || null,
gateway: form.gateway.trim() || null,
internal: form.internal,
attachable: form.attachable,
},
agentId
),
networksApi.create({
name: form.name,
driver: form.driver,
subnet: form.subnet.trim() || null,
gateway: form.gateway.trim() || null,
internal: form.internal,
attachable: form.attachable,
}),
onSuccess: () => { toast.success("Network created"); onDone(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
-299
View File
@@ -1,299 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Play,
Square,
RotateCw,
DownloadCloud,
ArrowUpCircle,
Power,
ArrowLeft,
Save,
} from "lucide-react";
import { toast } from "sonner";
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
import { HostDot } from "@/components/hosts/HostDot";
import { LogViewer } from "@/components/stacks/LogViewer";
import { ContainerCard } from "@/components/stacks/ContainerCard";
import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
import { SecretsPanel } from "@/components/stacks/SecretsPanel";
import { BackupButton } from "@/components/stacks/BackupRestore";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import type { ContainerInfo } from "@/types";
const TABS = ["Overview", "Logs", "Environment", "Compose", "Secrets"] as const;
type Tab = (typeof TABS)[number];
export function RemoteStackDetail() {
const { agentId = "", id = "" } = useParams();
const aid = Number(agentId);
const qc = useQueryClient();
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const [tab, setTab] = useState<Tab>("Overview");
const [busy, setBusy] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["agent-stack", aid, id],
queryFn: () => agentsApi.stack(aid, id),
refetchInterval: 5000,
});
// Port links on a remote stack must target the agent host, not this host.
const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list(false) });
const agentHost = useMemo(() => {
const a = agents.data?.find((x) => x.id === aid);
if (!a) return undefined;
try {
return new URL(a.url).hostname;
} catch {
return undefined;
}
}, [agents.data, aid]);
const run = async (action: string, label: string) => {
setBusy(true);
const t = toast.loading(`${label} ${id}`);
try {
await agentsApi.action(aid, id, action);
toast.success(`${label} ${id}`, { id: t });
qc.invalidateQueries({ queryKey: ["agent-stack", aid, id] });
} catch (e) {
toast.error(apiErrorMessage(e), { id: t });
} finally {
setBusy(false);
}
};
if (isLoading || !data) return <Spinner />;
return (
<div className="flex h-full flex-col space-y-4">
<Link
to="/stacks"
className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
<ArrowLeft className="h-4 w-4" /> All stacks
</Link>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<StatusDot status={data.status} />
<h1 className="sp-heading text-xl">{data.name}</h1>
<Badge status={data.status}>{data.status}</Badge>
</div>
<p className="mt-1 flex items-center gap-1 text-sm text-slate-500">
on <span className="font-medium">{data.agent_name}</span>
<HostDot status="online" />
</p>
</div>
{isAdmin && (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => run("start", "Starting")} loading={busy}>
<Play className="h-4 w-4 text-green-500" /> Start
</Button>
<Button variant="outline" onClick={() => run("stop", "Stopping")} loading={busy}>
<Square className="h-4 w-4 text-red-500" /> Stop
</Button>
<Button variant="outline" onClick={() => run("restart", "Restarting")} loading={busy}>
<RotateCw className="h-4 w-4 text-sky-500" /> Restart
</Button>
<Button variant="outline" onClick={() => run("pull", "Pulling")} loading={busy}>
<DownloadCloud className="h-4 w-4" /> Pull
</Button>
<Button variant="outline" onClick={() => run("update", "Updating")} loading={busy}>
<ArrowUpCircle className="h-4 w-4" /> Update
</Button>
<Button variant="outline" onClick={() => run("down", "Tearing down")} loading={busy}>
<Power className="h-4 w-4" /> Down
</Button>
<BackupButton stackId={id} agentId={aid} />
</div>
)}
</div>
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
{TABS.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={
tab === t
? "border-b-2 border-accent px-4 py-2 text-sm font-medium text-accent dark:border-accent-dark dark:text-accent-dark"
: "px-4 py-2 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
}
>
{t}
</button>
))}
</div>
<div className="flex-1 overflow-hidden">
{tab === "Overview" && (
<Overview
stackId={id}
containers={data.containers}
host={agentHost}
agentId={aid}
isAdmin={isAdmin}
onChanged={() => qc.invalidateQueries({ queryKey: ["agent-stack", aid, id] })}
/>
)}
{tab === "Logs" && (
<Card className="h-full overflow-hidden">
<LogViewer stackId={id} agentId={aid} />
</Card>
)}
{tab === "Environment" && (
<RemoteEditor
agentId={aid}
stackId={id}
field="env"
value={data.env}
canEdit={isAdmin}
queryKey={["agent-stack", aid, id]}
/>
)}
{tab === "Compose" && (
<RemoteEditor
agentId={aid}
stackId={id}
field="yaml"
value={data.yaml}
canEdit={isAdmin}
queryKey={["agent-stack", aid, id]}
/>
)}
{tab === "Secrets" && (
<SecretsPanel
stackId={id}
yaml={data.yaml}
agentId={aid}
isAdmin={isAdmin}
onChanged={() => qc.invalidateQueries({ queryKey: ["agent-stack", aid, id] })}
/>
)}
</div>
</div>
);
}
function Overview({
stackId,
containers,
host,
agentId,
isAdmin,
onChanged,
}: {
stackId: string;
containers: ContainerInfo[];
host?: string;
agentId: number;
isAdmin: boolean;
onChanged: () => void;
}) {
return (
<div className="space-y-2 overflow-auto">
<AutoUpdatePanel stackId={stackId} agentId={agentId} isAdmin={isAdmin} />
{containers.length === 0 && (
<Card>
<p className="text-sm text-slate-500">No containers running.</p>
</Card>
)}
{containers.map((c) => (
<ContainerCard
key={c.id}
container={c}
agentId={agentId}
host={host}
isAdmin={isAdmin}
onChanged={onChanged}
/>
))}
</div>
);
}
function RemoteEditor({
agentId,
stackId,
field,
value,
canEdit,
queryKey,
}: {
agentId: number;
stackId: string;
field: "yaml" | "env";
value: string;
canEdit: boolean;
queryKey: unknown[];
}) {
const qc = useQueryClient();
const [text, setText] = useState(value);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!editing) setText(value);
}, [value, editing]);
const save = async () => {
setSaving(true);
try {
const body = field === "yaml" ? { yaml: text } : { env: text };
await agentsApi.update_stack(agentId, stackId, body);
toast.success("Saved. Restart or update the stack to apply.");
setEditing(false);
qc.invalidateQueries({ queryKey });
} catch (e) {
toast.error(apiErrorMessage(e));
} finally {
setSaving(false);
}
};
if (!editing) {
return (
<Card className="flex h-full flex-col overflow-hidden">
{canEdit && (
<div className="mb-2 flex justify-end">
<Button variant="outline" onClick={() => setEditing(true)}>
Edit
</Button>
</div>
)}
{value ? (
<pre className="flex-1 overflow-auto whitespace-pre-wrap font-mono text-xs">{value}</pre>
) : (
<p className="text-sm text-slate-500">
{field === "env" ? "No .env file for this stack." : "Empty compose file."}
</p>
)}
</Card>
);
}
return (
<Card className="flex h-full flex-col gap-2 overflow-hidden">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
className="flex-1 resize-none rounded-lg border border-slate-300 bg-white p-3 font-mono text-xs outline-none focus:border-accent dark:border-slate-600 dark:bg-slate-900"
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditing(false)}>
Cancel
</Button>
<Button onClick={save} loading={saving}>
<Save className="h-4 w-4" /> Save
</Button>
</div>
</Card>
);
}
+5 -166
View File
@@ -9,7 +9,6 @@ import {
Users as UsersIcon,
ShieldCheck,
Power,
Server,
RefreshCw,
HardDrive,
CalendarClock,
@@ -26,12 +25,10 @@ import {
import { destinationsApi, type BackupDestination } from "@/api/backups";
import { schedulesApi, type BackupSchedule } from "@/api/schedules";
import { stacksApi } from "@/api/stacks";
import { agentsApi } from "@/api/agents";
import { HostDot } from "@/components/hosts/HostDot";
import { apiErrorMessage } from "@/api/client";
import { relativeTime } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import type { Agent, User } from "@/types";
import type { User } from "@/types";
export function Settings() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
@@ -51,7 +48,6 @@ export function Settings() {
return (
<div className="mx-auto max-w-3xl space-y-6">
<GeneralSection />
<HostsSection />
<DestinationsSection />
<SchedulesSection />
<NotificationsSection />
@@ -78,7 +74,6 @@ function SchedulesSection() {
const { data, isLoading } = useQuery({ queryKey: ["schedules"], queryFn: schedulesApi.list });
const destinations = useQuery({ queryKey: ["destinations"], queryFn: destinationsApi.list });
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list });
const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list() });
const [adding, setAdding] = useState(false);
const invalidate = () => qc.invalidateQueries({ queryKey: ["schedules"] });
const noDest = (destinations.data?.length ?? 0) === 0;
@@ -104,7 +99,6 @@ function SchedulesSection() {
<ScheduleForm
stacks={stacks.data ?? []}
destinations={destinations.data ?? []}
agents={agents.data ?? []}
onDone={() => { setAdding(false); invalidate(); }}
onCancel={() => setAdding(false)}
/>
@@ -145,7 +139,6 @@ function ScheduleRow({ schedule, onChange }: { schedule: BackupSchedule; onChang
<Card className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
{schedule.agent_name && <Badge>{schedule.agent_name}</Badge>}
<span className="font-mono text-sm font-medium">{schedule.stack_id}</span>
<span className="text-slate-400"></span>
<Badge>{schedule.destination_name ?? `dest ${schedule.destination_id}`}</Badge>
@@ -181,17 +174,14 @@ function ScheduleRow({ schedule, onChange }: { schedule: BackupSchedule; onChang
function ScheduleForm({
stacks,
destinations,
agents,
onDone,
onCancel,
}: {
stacks: { id: string; name: string }[];
destinations: BackupDestination[];
agents: Agent[];
onDone: () => void;
onCancel: () => void;
}) {
const [host, setHost] = useState("local"); // "local" | agent id (string)
const [form, setForm] = useState({
stack_id: stacks[0]?.id ?? "",
destination_id: destinations[0]?.id ?? 0,
@@ -206,30 +196,17 @@ function ScheduleForm({
});
const set = (k: string, v: unknown) => setForm((f) => ({ ...f, [k]: v }));
const isRemote = host !== "local";
const agentId = isRemote ? Number(host) : undefined;
const stackOptions = stacks;
// When a remote host is selected, pull its stacks for the picker.
const remoteStacks = useQuery({
queryKey: ["agent-stacks", agentId],
queryFn: () => agentsApi.stacks(agentId!),
enabled: isRemote,
});
const stackOptions = isRemote
? (remoteStacks.data ?? []).map((s) => ({ id: s.id, name: s.name }))
: stacks;
// Keep stack_id valid as host/options change.
// Keep stack_id valid as the options change.
useEffect(() => {
if (stackOptions.length && !stackOptions.some((s) => s.id === form.stack_id)) {
set("stack_id", stackOptions[0].id);
}
}, [stackOptions]); // eslint-disable-line react-hooks/exhaustive-deps
const onlineAgents = agents.filter((a) => a.status === "online");
const create = useMutation({
mutationFn: () => schedulesApi.create({ ...form, agent_id: agentId ?? null }),
mutationFn: () => schedulesApi.create(form),
onSuccess: () => { toast.success("Schedule added"); onDone(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
@@ -237,19 +214,10 @@ function ScheduleForm({
return (
<Card className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<label className="space-y-1">
<span className="text-xs font-medium text-slate-500">Host</span>
<select className={selectClass} value={host} onChange={(e) => setHost(e.target.value)}>
<option value="local">This host</option>
{onlineAgents.map((a) => (
<option key={a.id} value={String(a.id)}>{a.name}</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-slate-500">Stack</span>
<select className={selectClass} value={form.stack_id} onChange={(e) => set("stack_id", e.target.value)}>
{stackOptions.length === 0 && <option value="">{isRemote ? "no stacks" : "—"}</option>}
{stackOptions.length === 0 && <option value=""></option>}
{stackOptions.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
@@ -485,135 +453,6 @@ function DestinationForm({ onDone, onCancel }: { onDone: () => void; onCancel: (
);
}
/* -------------------------------------------------------------------------- */
/* Remote hosts (agents) */
/* -------------------------------------------------------------------------- */
function HostsSection() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list(),
refetchInterval: 15000,
});
const [adding, setAdding] = useState(false);
const invalidate = () => qc.invalidateQueries({ queryKey: ["agents"] });
return (
<section>
<SectionTitle icon={<Server className="h-4 w-4" />}>Remote hosts</SectionTitle>
<div className="space-y-3">
{isLoading ? (
<Spinner />
) : (
data?.map((a) => <HostRow key={a.id} agent={a} onChange={invalidate} />)
)}
{data?.length === 0 && !adding && (
<Card>
<p className="text-sm text-slate-500">
No remote hosts. Deploy <code>stackpilot-agent</code> on another host and
add it here to manage its stacks from this dashboard.
</p>
</Card>
)}
{adding ? (
<AddHostForm onDone={() => { setAdding(false); invalidate(); }} onCancel={() => setAdding(false)} />
) : (
<Button variant="outline" onClick={() => setAdding(true)}>
<Plus className="h-4 w-4" /> Add host
</Button>
)}
</div>
</section>
);
}
function HostRow({ agent, onChange }: { agent: Agent; onChange: () => void }) {
const ping = useMutation({
mutationFn: () => agentsApi.ping(agent.id),
onSuccess: (r) => {
toast[r.status === "online" ? "success" : "error"](`Host is ${r.status}`);
onChange();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
const remove = useMutation({
mutationFn: () => agentsApi.remove(agent.id),
onSuccess: () => { toast.success("Host removed"); onChange(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
return (
<Card className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<HostDot status={agent.status} />
<span className="font-medium">{agent.name}</span>
<span className="text-xs text-slate-400">{agent.status}</span>
{agent.hostname && (
<span className="font-mono text-xs text-slate-400">({agent.hostname})</span>
)}
</div>
<p className="break-all font-mono text-xs text-slate-500">{agent.url}</p>
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => ping.mutate()} loading={ping.isPending}>
<RefreshCw className="h-4 w-4" /> Check
</Button>
<Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</Card>
);
}
function AddHostForm({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [token, setToken] = useState("");
const create = useMutation({
mutationFn: () => agentsApi.create({ name, url, token }),
onSuccess: (a) => {
toast[a.status === "online" ? "success" : "error"](
a.status === "online" ? "Host added and reachable" : `Host added but ${a.status}`
);
onDone();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
return (
<Card className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className="text-xs font-medium text-slate-500">Name</span>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="nas" />
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-slate-500">Agent URL</span>
<Input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="http://10.0.0.5:5010" />
</label>
</div>
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Shared token (AGENT_TOKEN)</span>
<Input type="password" value={token} onChange={(e) => setToken(e.target.value)} />
</label>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button
onClick={() => create.mutate()}
loading={create.isPending}
disabled={!name.trim() || !url.trim() || !token}
>
Add host
</Button>
</div>
</Card>
);
}
/* -------------------------------------------------------------------------- */
/* General */
/* -------------------------------------------------------------------------- */
+3 -57
View File
@@ -9,7 +9,6 @@ import { EnvEditor } from "@/components/env/EnvEditor";
import { PortConflictDialog } from "@/components/stacks/PortConflictDialog";
import { DeployConsole } from "@/components/stacks/DeployConsole";
import { stacksApi } from "@/api/stacks";
import { agentsApi } from "@/api/agents";
import { editorApi } from "@/api/editor";
import { portsApi, type PortConflict } from "@/api/ports";
import { apiErrorMessage } from "@/api/client";
@@ -41,9 +40,7 @@ export function StackEditor() {
const [runCmd, setRunCmd] = useState("");
const [conflicts, setConflicts] = useState<PortConflict[] | null>(null);
const [checking, setChecking] = useState(false);
const [host, setHost] = useState("local");
const [deployId, setDeployId] = useState<string | null>(null);
const [deployAgentId, setDeployAgentId] = useState<number | undefined>(undefined);
const [validating, setValidating] = useState(false);
const [validation, setValidation] = useState<{ ok: boolean; errors: string } | null>(null);
const [showDiff, setShowDiff] = useState(false);
@@ -54,13 +51,6 @@ export function StackEditor() {
enabled: !isNew,
});
const agents = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list(),
enabled: isNew,
});
const onlineAgents = (agents.data ?? []).filter((a) => a.status === "online");
const remote = isNew && host !== "local";
useEffect(() => {
if (existing.data) {
@@ -78,22 +68,6 @@ export function StackEditor() {
}
setSaving(true);
try {
// Remote host: create the stack on the agent, then optionally start it.
if (remote) {
const aid = Number(host);
const created = await agentsApi.createStack(aid, { name, yaml, env });
qc.invalidateQueries({ queryKey: ["agent-stacks", aid] });
toast.success("Saved");
if (deploy) {
// Stream the remote deploy live through the agent-deploy WS proxy.
setDeployAgentId(aid);
setDeployId(created.id);
return;
}
navigate(`/hosts/${aid}/stacks/${created.id}`);
return;
}
let stackId = id;
if (isNew) {
const created = await stacksApi.create({ name, description, yaml, env });
@@ -119,11 +93,6 @@ export function StackEditor() {
};
const onDeploy = async () => {
// The local port-conflict check doesn't apply to remote hosts.
if (remote) {
save(true);
return;
}
setChecking(true);
try {
const found = await portsApi.conflicts(yaml, id);
@@ -188,21 +157,6 @@ export function StackEditor() {
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
{isNew && onlineAgents.length > 0 && (
<select
value={host}
onChange={(e) => setHost(e.target.value)}
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
title="Target host"
>
<option value="local">This host</option>
{onlineAgents.map((a) => (
<option key={a.id} value={String(a.id)}>
{a.name}
</option>
))}
</select>
)}
<Button variant="outline" onClick={() => setConvertOpen((v) => !v)}>
<Wand2 className="h-4 w-4" /> Convert docker run
</Button>
@@ -312,20 +266,12 @@ export function StackEditor() {
{deployId && (
<DeployConsole
stackId={deployId}
agentId={deployAgentId}
onClose={() => {
const sid = deployId;
const aid = deployAgentId;
setDeployId(null);
setDeployAgentId(undefined);
if (aid != null) {
qc.invalidateQueries({ queryKey: ["agent-stacks", aid] });
navigate(`/hosts/${aid}/stacks/${sid}`);
} else {
qc.invalidateQueries({ queryKey: ["stacks"] });
qc.invalidateQueries({ queryKey: ["stack", sid] });
navigate(`/stacks/${sid}`);
}
qc.invalidateQueries({ queryKey: ["stacks"] });
qc.invalidateQueries({ queryKey: ["stack", sid] });
navigate(`/stacks/${sid}`);
}}
/>
)}
+1 -18
View File
@@ -1,14 +1,12 @@
import { useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Plus, Search, HardDrive } from "lucide-react";
import { Plus, Search } from "lucide-react";
import { Button, Input } from "@/components/ui";
import { StacksTable } from "@/components/stacks/StacksTable";
import { RestoreButton } from "@/components/stacks/BackupRestore";
import { AgentStacksSection } from "@/components/stacks/AgentStacksSection";
import { stacksApi } from "@/api/stacks";
import { systemApi } from "@/api/system";
import { agentsApi } from "@/api/agents";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
@@ -55,12 +53,6 @@ export function Stacks() {
refetchInterval: 5000,
});
const agents = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list(),
refetchInterval: 15000,
});
const hasAgents = (agents.data?.length ?? 0) > 0;
const filtered = useMemo(() => {
let list = (data ?? []).filter(
@@ -125,11 +117,6 @@ export function Stacks() {
</div>
<section>
{hasAgents && (
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<HardDrive className="h-4 w-4" /> This host
</h2>
)}
<StacksTable
stacks={filtered}
stats={stats.data}
@@ -150,10 +137,6 @@ export function Stacks() {
emptyText={q ? "No stacks match your search." : "No stacks yet. Create one with “New Stack”."}
/>
</section>
{agents.data?.map((agent) => (
<AgentStacksSection key={agent.id} agent={agent} isAdmin={isAdmin} />
))}
</div>
);
}
+2 -26
View File
@@ -5,14 +5,10 @@ import { LayoutTemplate, Cpu, Package, Trash2, FileCode, Search } from "lucide-r
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { templatesApi, type TemplateDetail, type TemplateSummary } from "@/api/templates";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import { toast } from "sonner";
const selectClass =
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800";
export function Templates() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const queryClient = useQueryClient();
@@ -198,12 +194,8 @@ function UseTemplateDialog({
}) {
const navigate = useNavigate();
const [name, setName] = useState(template.name);
const [host, setHost] = useState("local");
const [busy, setBusy] = useState(false);
const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list() });
const onlineAgents = (agents.data ?? []).filter((a) => a.status === "online");
const create = async () => {
if (!name.trim()) {
toast.error("Stack name required");
@@ -211,11 +203,9 @@ function UseTemplateDialog({
}
setBusy(true);
try {
const agentId = host === "local" ? null : Number(host);
const res = await templatesApi.instantiate(template.id, name, agentId);
const res = await templatesApi.instantiate(template.id, name);
toast.success(`Stack '${res.name}' created`);
if (res.agent_id != null) navigate(`/hosts/${res.agent_id}/stacks/${res.id}`);
else navigate(`/stacks/${res.id}/edit`);
navigate(`/stacks/${res.id}/edit`);
} catch (e) {
toast.error(apiErrorMessage(e));
} finally {
@@ -232,20 +222,6 @@ function UseTemplateDialog({
<span className="text-xs font-medium text-slate-500">Stack name</span>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</label>
{onlineAgents.length > 0 && (
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Deploy to host</span>
<select className={selectClass} value={host} onChange={(e) => setHost(e.target.value)}>
<option value="local">This host</option>
{onlineAgents.map((a) => (
<option key={a.id} value={String(a.id)}>
{a.name}
</option>
))}
</select>
</label>
)}
{template.files.length > 0 && (
<div className="space-y-1">
<span className="text-xs font-medium text-slate-500">Files</span>
+35 -72
View File
@@ -4,66 +4,39 @@ import { Database, Trash2, Eraser, HardDrive } from "lucide-react";
import { toast } from "sonner";
import { Badge, Button, Card, Spinner } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { HostHeader } from "@/components/hosts/HostHeader";
import { volumesApi } from "@/api/volumes";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import { formatBytes } from "@/lib/utils";
import type { Agent, VolumeInfo } from "@/types";
import type { VolumeInfo } from "@/types";
export function Volumes() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const agents = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list(),
refetchInterval: 15000,
});
const hasAgents = (agents.data?.length ?? 0) > 0;
return (
<div className="space-y-8">
<VolumesSection isAdmin={isAdmin} showHostLabel={hasAgents} />
{agents.data?.map((agent) => (
<VolumesSection key={agent.id} agent={agent} isAdmin={isAdmin} showHostLabel />
))}
</div>
);
return <VolumesSection isAdmin={isAdmin} />;
}
function VolumesSection({
agent,
isAdmin,
showHostLabel,
}: {
agent?: Agent;
isAdmin: boolean;
showHostLabel: boolean;
}) {
const agentId = agent?.id;
const online = !agent || agent.status === "online";
function VolumesSection({ isAdmin }: { isAdmin: boolean }) {
const qc = useQueryClient();
const [onlyUnused, setOnlyUnused] = useState(false);
const [toDelete, setToDelete] = useState<VolumeInfo | null>(null);
const [force, setForce] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["volumes", agentId ?? "local"],
queryFn: () => volumesApi.list(agentId),
queryKey: ["volumes"],
queryFn: () => volumesApi.list(),
refetchInterval: 10000,
enabled: online,
});
// Sizes are expensive (docker system df walks volume contents), so they are
// loaded on demand via the "Compute sizes" button rather than polled.
const sizes = useQuery({
queryKey: ["volume-sizes", agentId ?? "local"],
queryFn: () => volumesApi.sizes(false, agentId),
queryKey: ["volume-sizes"],
queryFn: () => volumesApi.sizes(false),
enabled: false,
});
const invalidate = () => qc.invalidateQueries({ queryKey: ["volumes", agentId ?? "local"] });
const invalidate = () => qc.invalidateQueries({ queryKey: ["volumes"] });
const prune = useMutation({
mutationFn: () => volumesApi.prune(agentId),
mutationFn: () => volumesApi.prune(),
onSuccess: (r) => {
const n = r.VolumesDeleted?.length ?? 0;
toast.success(n ? `Pruned ${n} volume(s)` : "No unused volumes");
@@ -72,7 +45,7 @@ function VolumesSection({
onError: (e) => toast.error(apiErrorMessage(e)),
});
const remove = useMutation({
mutationFn: (v: VolumeInfo) => volumesApi.remove(v.name, force, agentId),
mutationFn: (v: VolumeInfo) => volumesApi.remove(v.name, force),
onSuccess: () => {
toast.success("Volume deleted");
setToDelete(null);
@@ -87,42 +60,32 @@ function VolumesSection({
return (
<section>
{(showHostLabel || (isAdmin && online)) && (
<HostHeader agent={agent}>
<label className="flex items-center gap-1.5 text-xs text-slate-500">
<input
type="checkbox"
checked={onlyUnused}
onChange={(e) => setOnlyUnused(e.target.checked)}
className="h-3.5 w-3.5"
/>
Only unused
</label>
{online && (
<Button
variant="outline"
onClick={() => sizes.refetch()}
loading={sizes.isFetching}
title="Runs docker system df — can take a few seconds"
>
<HardDrive className="h-4 w-4" /> Compute sizes
</Button>
)}
{isAdmin && online && (
<Button variant="outline" onClick={() => prune.mutate()} loading={prune.isPending}>
<Eraser className="h-4 w-4" /> Prune unused
</Button>
)}
</HostHeader>
)}
<div className="mb-3 flex flex-wrap items-center justify-end gap-2">
<label className="flex items-center gap-1.5 text-xs text-slate-500">
<input
type="checkbox"
checked={onlyUnused}
onChange={(e) => setOnlyUnused(e.target.checked)}
className="h-3.5 w-3.5"
/>
Only unused
</label>
<Button
variant="outline"
onClick={() => sizes.refetch()}
loading={sizes.isFetching}
title="Runs docker system df — can take a few seconds"
>
<HardDrive className="h-4 w-4" /> Compute sizes
</Button>
{isAdmin && (
<Button variant="outline" onClick={() => prune.mutate()} loading={prune.isPending}>
<Eraser className="h-4 w-4" /> Prune unused
</Button>
)}
</div>
{!online ? (
<Card>
<p className="text-sm text-slate-500">
Host is {agent?.status}. Check it under Settings Remote hosts.
</p>
</Card>
) : isLoading ? (
{isLoading ? (
<Spinner />
) : (
<Card className="overflow-x-auto p-0">
-14
View File
@@ -15,9 +15,6 @@ export interface StackSummary {
running_count: number;
created_at: string;
updated_at: string;
// present on stacks proxied from a remote host
agent_id?: number;
agent_name?: string;
}
export interface StackUpdateInfo {
@@ -33,17 +30,6 @@ export interface StackStats {
containers: number;
}
export interface Agent {
id: number;
name: string;
url: string;
status: "online" | "offline" | "unauthorized" | "unknown";
hostname?: string | null;
last_seen?: string | null;
created_at: string;
token_set: boolean;
}
export interface ContainerInfo {
id: string;
name: string;