diff --git a/src/assets/design-system.css b/src/assets/design-system.css
index bdad7a8..1580d63 100644
--- a/src/assets/design-system.css
+++ b/src/assets/design-system.css
@@ -175,6 +175,34 @@
--shadow-xl: 0 10px 25px rgba(0, 0, 0, 0.5);
--shadow-2xl: 0 25px 50px rgba(0, 0, 0, 0.6);
--ring-color: rgba(96, 165, 250, 0.25);
+
+ /* Status tints adapted for dark surfaces: the -50/-100/-200 tints become
+ translucent fills and the -700 text shade lightens, so tinted panels read
+ correctly on dark. The saturated -500/-600 shades are left untouched for
+ solid fills, dots, and icons. */
+ --color-primary-50: rgba(59, 130, 246, 0.15);
+ --color-primary-100: rgba(59, 130, 246, 0.22);
+ --color-primary-700: #93c5fd;
+
+ --color-success-50: rgba(34, 197, 94, 0.15);
+ --color-success-100: rgba(34, 197, 94, 0.22);
+ --color-success-200: rgba(34, 197, 94, 0.3);
+ --color-success-700: #86efac;
+
+ --color-warning-50: rgba(245, 158, 11, 0.15);
+ --color-warning-100: rgba(245, 158, 11, 0.22);
+ --color-warning-200: rgba(245, 158, 11, 0.3);
+ --color-warning-700: #fcd34d;
+
+ --color-danger-50: rgba(239, 68, 68, 0.15);
+ --color-danger-100: rgba(239, 68, 68, 0.22);
+ --color-danger-200: rgba(239, 68, 68, 0.3);
+ --color-danger-700: #fca5a5;
+
+ --color-info-50: rgba(59, 130, 246, 0.15);
+ --color-info-100: rgba(59, 130, 246, 0.22);
+ --color-info-200: rgba(59, 130, 246, 0.3);
+ --color-info-700: #93c5fd;
}
/* Component Base Styles */
diff --git a/src/components/OperationModal.vue b/src/components/OperationModal.vue
index 62c4e93..a4df37d 100644
--- a/src/components/OperationModal.vue
+++ b/src/components/OperationModal.vue
@@ -4,20 +4,15 @@
-
-
-
-
-
-
-
- {{ statusLabel }}
-
- {{ elapsedTime }}
+
-
+
{
start: "Starting",
stop: "Stopping",
restart: "Restarting",
+ rebuild: "Rebuilding",
};
return `${ops[props.operation]} ${props.deploymentName}`;
});
@@ -82,16 +79,9 @@ const statusClass = computed(() => {
return "pending";
});
-const statusIcon = computed(() => {
- if (props.isRunning) return "pi pi-spin pi-spinner";
- if (props.isSuccess === true) return "pi pi-check";
- if (props.isSuccess === false) return "pi pi-times";
- return "pi pi-clock";
-});
-
const statusLabel = computed(() => {
- if (props.isRunning) return "In Progress...";
- if (props.isSuccess === true) return "Completed Successfully";
+ if (props.isRunning) return "Running";
+ if (props.isSuccess === true) return "Done";
if (props.isSuccess === false) return "Failed";
return "Pending";
});
@@ -139,6 +129,42 @@ const handleClose = () => {
gap: var(--space-3);
}
+.header-status {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin-left: auto;
+ padding: var(--space-1) var(--space-3);
+ border-radius: var(--radius-full);
+ font-size: var(--text-sm);
+ font-weight: var(--font-medium);
+}
+
+.header-status.running {
+ background: var(--color-info-50);
+ color: var(--color-info-700);
+}
+
+.header-status.success {
+ background: var(--color-success-50);
+ color: var(--color-success-700);
+}
+
+.header-status.error {
+ background: var(--color-danger-50);
+ color: var(--color-danger-700);
+}
+
+.header-status.pending {
+ background: var(--surface-inset);
+ color: var(--text-muted);
+}
+
+.header-elapsed {
+ font-variant-numeric: tabular-nums;
+ opacity: 0.75;
+}
+
.operation-badge {
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
@@ -163,6 +189,11 @@ const handleClose = () => {
color: var(--color-info-700);
}
+.operation-badge.rebuild {
+ background: var(--color-warning-50);
+ color: var(--color-warning-700);
+}
+
.header-info h3 {
font-size: var(--text-xl);
font-weight: var(--font-semibold);
@@ -170,68 +201,7 @@ const handleClose = () => {
margin: 0;
}
-.status-section {
- display: flex;
- align-items: center;
- gap: var(--space-4);
- padding: var(--space-5);
- background: linear-gradient(135deg, var(--surface-sunken), var(--surface-raised));
- border: 1px solid var(--border-subtle);
- border-radius: var(--radius-md);
- margin-bottom: var(--space-6);
-}
-
-.status-indicator {
- width: 56px;
- height: 56px;
- border-radius: var(--radius-xl);
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 1.5rem;
- transition: all 0.3s ease;
-}
-
-.status-indicator.pending {
- background: var(--surface-inset);
- color: var(--text-muted);
-}
-
-.status-indicator.running {
- background: linear-gradient(135deg, var(--color-primary-50), var(--color-info-50));
- color: var(--color-primary-600);
- box-shadow: 0 0 0 4px var(--color-primary-50);
-}
-
-.status-indicator.success {
- background: var(--color-success-50);
- color: var(--color-success-600);
-}
-
-.status-indicator.error {
- background: var(--color-danger-50);
- color: var(--color-danger-600);
-}
-
-.status-text {
- display: flex;
- flex-direction: column;
- gap: var(--space-1);
-}
-
-.status-label {
- font-size: var(--text-lg);
- font-weight: var(--font-semibold);
- color: var(--text);
-}
-
-.status-time {
- font-size: var(--text-sm);
- color: var(--text-muted);
-}
-
.output-section {
- margin-top: var(--space-4);
border-radius: var(--radius-sm);
overflow: hidden;
}
diff --git a/src/components/base/SplitActionButton.vue b/src/components/base/SplitActionButton.vue
index 40a1b5f..22c1738 100644
--- a/src/components/base/SplitActionButton.vue
+++ b/src/components/base/SplitActionButton.vue
@@ -118,7 +118,7 @@ onBeforeUnmount(() => document.removeEventListener("click", onDocumentClick));
}
.menu-item:hover {
- background: var(--surface-hover, #f1f5f9);
+ background: var(--surface-inset);
}
.menu-item i {
diff --git a/src/composables/useDeploymentJob.test.ts b/src/composables/useDeploymentJob.test.ts
new file mode 100644
index 0000000..2236acc
--- /dev/null
+++ b/src/composables/useDeploymentJob.test.ts
@@ -0,0 +1,105 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { flushPromises } from "@vue/test-utils";
+import { effectScope } from "vue";
+
+vi.mock("@/services/api", () => ({
+ deploymentsApi: {
+ start: vi.fn(),
+ stop: vi.fn(),
+ restart: vi.fn(),
+ rebuild: vi.fn(),
+ getJob: vi.fn(),
+ },
+ deploymentJobWsUrl: vi.fn().mockReturnValue("ws://localhost/stream"),
+}));
+
+import { deploymentsApi } from "@/services/api";
+import { useDeploymentJob } from "./useDeploymentJob";
+
+const start = deploymentsApi.start as ReturnType;
+const restart = deploymentsApi.restart as ReturnType;
+const getJob = deploymentsApi.getJob as ReturnType;
+
+// Force the websocket constructor to throw so the composable falls back to
+// polling deterministically, the path available in a jsdom environment.
+function disableWebSocket() {
+ vi.stubGlobal("WebSocket", function () {
+ throw new Error("no websocket in test");
+ });
+}
+
+function withScope(fn: () => T): T {
+ const scope = effectScope();
+ return scope.run(fn) as T;
+}
+
+describe("useDeploymentJob", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ disableWebSocket();
+ });
+
+ it("enqueues an action and settles to success via the poll fallback", async () => {
+ start.mockResolvedValue({ data: { job_id: "job-1", status: "pending" } });
+ getJob.mockResolvedValue({ data: { status: "succeeded", output: "Started", lines: [] } });
+
+ const settled = vi.fn();
+ const { state, run } = withScope(() => useDeploymentJob(settled));
+
+ await run("start", "app");
+ await flushPromises();
+
+ expect(start).toHaveBeenCalledWith("app");
+ expect(getJob).toHaveBeenCalledWith("app", "job-1");
+ expect(state.isRunning).toBe(false);
+ expect(state.isSuccess).toBe(true);
+ expect(state.output).toBe("Started");
+ expect(settled).toHaveBeenCalledTimes(1);
+ expect(localStorage.getItem("deployment_active_jobs")).toBe("{}");
+ });
+
+ it("surfaces a failed job", async () => {
+ restart.mockResolvedValue({ data: { job_id: "job-2", status: "pending" } });
+ getJob.mockResolvedValue({ data: { status: "failed", output: "boom", error: "exit 1", lines: [] } });
+
+ const { state, run } = withScope(() => useDeploymentJob());
+ await run("restart", "app");
+ await flushPromises();
+
+ expect(state.isSuccess).toBe(false);
+ expect(state.output).toBe("boom");
+ });
+
+ it("attaches to the in-flight job when the action is rejected as concurrent", async () => {
+ start.mockRejectedValue({ response: { status: 409, data: { active_job_id: "job-running" } } });
+ getJob.mockResolvedValue({ data: { status: "succeeded", output: "ok", lines: [] } });
+
+ const { state, run } = withScope(() => useDeploymentJob());
+ await run("start", "app");
+ await flushPromises();
+
+ expect(getJob).toHaveBeenCalledWith("app", "job-running");
+ expect(state.isSuccess).toBe(true);
+ });
+
+ it("resumes a persisted job after a reload", async () => {
+ localStorage.setItem("deployment_active_jobs", JSON.stringify({ app: { jobId: "job-3", operation: "restart" } }));
+ getJob.mockResolvedValue({ data: { status: "succeeded", output: "resumed", lines: [] } });
+
+ const { state, resume } = withScope(() => useDeploymentJob());
+ const found = await resume("app");
+ await flushPromises();
+
+ expect(found).toBe(true);
+ expect(state.visible).toBe(true);
+ expect(state.operation).toBe("restart");
+ expect(state.isSuccess).toBe(true);
+ expect(state.output).toBe("resumed");
+ });
+
+ it("reports no job to resume when none was persisted", async () => {
+ const { resume } = withScope(() => useDeploymentJob());
+ expect(await resume("app")).toBe(false);
+ });
+});
diff --git a/src/composables/useDeploymentJob.ts b/src/composables/useDeploymentJob.ts
new file mode 100644
index 0000000..1dc95f0
--- /dev/null
+++ b/src/composables/useDeploymentJob.ts
@@ -0,0 +1,249 @@
+import { reactive, onScopeDispose } from "vue";
+import { deploymentsApi, deploymentJobWsUrl, type DeploymentActionStatus } from "@/services/api";
+
+export type DeploymentOperation = "start" | "stop" | "restart" | "rebuild";
+
+export interface DeploymentJobState {
+ visible: boolean;
+ operation: DeploymentOperation;
+ deploymentName: string;
+ jobId: string;
+ output: string;
+ isRunning: boolean;
+ isSuccess: boolean | null;
+}
+
+interface PersistedJob {
+ jobId: string;
+ operation: DeploymentOperation;
+}
+
+const STORAGE_KEY = "deployment_active_jobs";
+
+function loadPersisted(): Record {
+ try {
+ return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
+ } catch {
+ return {};
+ }
+}
+
+function persist(name: string, job: PersistedJob) {
+ const all = loadPersisted();
+ all[name] = job;
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
+}
+
+function clearPersisted(name: string) {
+ const all = loadPersisted();
+ delete all[name];
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
+}
+
+// useDeploymentJob drives one deployment action at a time: it enqueues the
+// action, streams output over a websocket, falls back to polling if the socket
+// drops, and can resume a job left running across a page reload.
+export function useDeploymentJob(onSettled?: (state: DeploymentJobState) => void) {
+ const state = reactive({
+ visible: false,
+ operation: "start",
+ deploymentName: "",
+ jobId: "",
+ output: "",
+ isRunning: false,
+ isSuccess: null,
+ });
+
+ let socket: WebSocket | null = null;
+ let pollTimer: ReturnType | null = null;
+ let settled = false;
+
+ function teardown() {
+ if (socket) {
+ socket.onclose = null;
+ socket.close();
+ socket = null;
+ }
+ if (pollTimer) {
+ clearTimeout(pollTimer);
+ pollTimer = null;
+ }
+ }
+
+ function begin(operation: DeploymentOperation, name: string) {
+ teardown();
+ settled = false;
+ state.visible = true;
+ state.operation = operation;
+ state.deploymentName = name;
+ state.jobId = "";
+ state.output = "";
+ state.isRunning = true;
+ state.isSuccess = null;
+ }
+
+ function appendLine(line: string) {
+ state.output = state.output ? `${state.output}\n${line}` : line;
+ }
+
+ function settle(status: DeploymentActionStatus, error?: string) {
+ if (settled) return;
+ settled = true;
+ teardown();
+ state.isRunning = false;
+ state.isSuccess = status === "succeeded";
+ if (!state.output && error) {
+ appendLine(error);
+ }
+ clearPersisted(state.deploymentName);
+ onSettled?.(state);
+ }
+
+ async function enqueue(operation: DeploymentOperation, name: string) {
+ switch (operation) {
+ case "start":
+ return deploymentsApi.start(name);
+ case "stop":
+ return deploymentsApi.stop(name);
+ case "restart":
+ return deploymentsApi.restart(name);
+ case "rebuild":
+ return deploymentsApi.rebuild(name);
+ }
+ }
+
+ async function run(operation: DeploymentOperation, name: string) {
+ begin(operation, name);
+ try {
+ const res = await enqueue(operation, name);
+ attach(res.data.job_id);
+ } catch (e: any) {
+ const activeId = e?.response?.data?.active_job_id;
+ if (e?.response?.status === 409 && activeId) {
+ attach(activeId);
+ return;
+ }
+ settle("failed", e?.response?.data?.error || e?.message || "Request failed");
+ }
+ }
+
+ function attach(jobId: string) {
+ state.jobId = jobId;
+ persist(state.deploymentName, { jobId, operation: state.operation });
+ openStream(jobId);
+ }
+
+ function openStream(jobId: string) {
+ let authed = false;
+ const token = localStorage.getItem("auth_token");
+
+ try {
+ socket = new WebSocket(deploymentJobWsUrl(state.deploymentName, jobId));
+ } catch {
+ pollUntilDone(jobId);
+ return;
+ }
+
+ socket.onopen = () => {
+ if (token) {
+ socket?.send(JSON.stringify({ type: "auth", token }));
+ } else {
+ authed = true;
+ }
+ };
+
+ socket.onmessage = (ev) => {
+ let frame: any;
+ try {
+ frame = JSON.parse(ev.data);
+ } catch {
+ return;
+ }
+
+ if (!authed) {
+ if (frame.type === "auth_success") {
+ authed = true;
+ return;
+ }
+ if (frame.type === "error") {
+ socket?.close();
+ pollUntilDone(jobId);
+ return;
+ }
+ }
+
+ if (frame.type === "line") {
+ appendLine(frame.data ?? "");
+ } else if (frame.type === "result") {
+ settle(frame.status, frame.error);
+ } else if (frame.type === "error") {
+ socket?.close();
+ pollUntilDone(jobId);
+ }
+ };
+
+ socket.onclose = () => {
+ if (!settled) {
+ pollUntilDone(jobId);
+ }
+ };
+ }
+
+ function pollUntilDone(jobId: string) {
+ if (settled) return;
+
+ const tick = async () => {
+ try {
+ const { data } = await deploymentsApi.getJob(state.deploymentName, jobId);
+ if (data.output) {
+ state.output = data.output;
+ }
+ if (data.status === "succeeded" || data.status === "failed") {
+ settle(data.status, data.error);
+ return;
+ }
+ } catch (e: any) {
+ if (e?.response?.status === 404) {
+ settle("failed", "Job is no longer available");
+ return;
+ }
+ }
+ pollTimer = setTimeout(tick, 1500);
+ };
+
+ tick();
+ }
+
+ async function resume(name: string): Promise {
+ const persisted = loadPersisted()[name];
+ if (!persisted) return false;
+
+ begin(persisted.operation, name);
+ state.jobId = persisted.jobId;
+
+ try {
+ const { data } = await deploymentsApi.getJob(name, persisted.jobId);
+ state.output = data.output || "";
+ if (data.status === "succeeded" || data.status === "failed") {
+ settle(data.status, data.error);
+ return true;
+ }
+ openStream(persisted.jobId);
+ return true;
+ } catch {
+ clearPersisted(name);
+ state.visible = false;
+ teardown();
+ return false;
+ }
+ }
+
+ function close() {
+ state.visible = false;
+ teardown();
+ }
+
+ onScopeDispose(teardown);
+
+ return { state, run, resume, close };
+}
diff --git a/src/composables/useServiceJobs.test.ts b/src/composables/useServiceJobs.test.ts
new file mode 100644
index 0000000..9e4ad37
--- /dev/null
+++ b/src/composables/useServiceJobs.test.ts
@@ -0,0 +1,104 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { flushPromises } from "@vue/test-utils";
+import { effectScope } from "vue";
+
+vi.mock("@/services/api", () => ({
+ deploymentsApi: {
+ serviceActionJob: vi.fn(),
+ getJob: vi.fn(),
+ },
+ deploymentJobWsUrl: vi.fn().mockReturnValue("ws://localhost/stream"),
+}));
+
+import { deploymentsApi } from "@/services/api";
+import { useServiceJobs } from "./useServiceJobs";
+
+const serviceActionJob = deploymentsApi.serviceActionJob as ReturnType;
+const getJob = deploymentsApi.getJob as ReturnType;
+
+// Force the websocket constructor to throw so the composable falls back to
+// polling deterministically, the path available in a jsdom environment.
+function disableWebSocket() {
+ vi.stubGlobal("WebSocket", function () {
+ throw new Error("no websocket in test");
+ });
+}
+
+function withScope(fn: () => T): T {
+ const scope = effectScope();
+ return scope.run(fn) as T;
+}
+
+describe("useServiceJobs", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ disableWebSocket();
+ });
+
+ it("streams a per-service action and settles via the poll fallback", async () => {
+ serviceActionJob.mockResolvedValue({ data: { job_id: "job-1", status: "pending" } });
+ getJob.mockResolvedValue({ data: { status: "succeeded", output: "Recreated web", lines: [] } });
+
+ const settled = vi.fn();
+ const { states, run } = withScope(() => useServiceJobs(() => "app", settled));
+
+ await run("web", "rebuild");
+ await flushPromises();
+
+ expect(serviceActionJob).toHaveBeenCalledWith("app", "web", "rebuild");
+ expect(getJob).toHaveBeenCalledWith("app", "job-1");
+ expect(states.web.action).toBe("rebuild");
+ expect(states.web.isRunning).toBe(false);
+ expect(states.web.isSuccess).toBe(true);
+ expect(states.web.output).toBe("Recreated web");
+ expect(settled).toHaveBeenCalledTimes(1);
+ });
+
+ it("tracks services independently", async () => {
+ serviceActionJob.mockImplementation((_n: string, service: string) =>
+ Promise.resolve({ data: { job_id: `job-${service}`, status: "pending" } }),
+ );
+ getJob.mockResolvedValue({ data: { status: "succeeded", output: "ok", lines: [] } });
+
+ const { states, run } = withScope(() => useServiceJobs(() => "app"));
+
+ await run("web", "restart");
+ await run("db", "stop");
+ await flushPromises();
+
+ expect(states.web.action).toBe("restart");
+ expect(states.db.action).toBe("stop");
+ expect(states.web.isSuccess).toBe(true);
+ expect(states.db.isSuccess).toBe(true);
+ });
+
+ it("surfaces a failed action and can be dismissed", async () => {
+ serviceActionJob.mockResolvedValue({ data: { job_id: "job-2", status: "pending" } });
+ getJob.mockResolvedValue({ data: { status: "failed", output: "boom", error: "exit 1", lines: [] } });
+
+ const { states, run, dismiss } = withScope(() => useServiceJobs(() => "app"));
+
+ await run("web", "start");
+ await flushPromises();
+
+ expect(states.web.isSuccess).toBe(false);
+ expect(states.web.output).toBe("boom");
+
+ dismiss("web");
+ expect(states.web).toBeUndefined();
+ });
+
+ it("attaches to the in-flight job when rejected as concurrent", async () => {
+ serviceActionJob.mockRejectedValue({ response: { status: 409, data: { active_job_id: "job-running" } } });
+ getJob.mockResolvedValue({ data: { status: "succeeded", output: "ok", lines: [] } });
+
+ const { states, run } = withScope(() => useServiceJobs(() => "app"));
+
+ await run("web", "restart");
+ await flushPromises();
+
+ expect(getJob).toHaveBeenCalledWith("app", "job-running");
+ expect(states.web.isSuccess).toBe(true);
+ });
+});
diff --git a/src/composables/useServiceJobs.ts b/src/composables/useServiceJobs.ts
new file mode 100644
index 0000000..09939e1
--- /dev/null
+++ b/src/composables/useServiceJobs.ts
@@ -0,0 +1,185 @@
+import { reactive, onScopeDispose } from "vue";
+import { deploymentsApi, deploymentJobWsUrl, type DeploymentActionStatus } from "@/services/api";
+
+export type ServiceAction = "start" | "stop" | "restart" | "rebuild" | "pull";
+
+export interface ServiceJobState {
+ service: string;
+ action: ServiceAction;
+ output: string;
+ isRunning: boolean;
+ isSuccess: boolean | null;
+}
+
+interface Controller {
+ socket: WebSocket | null;
+ timer: ReturnType | null;
+ settled: boolean;
+}
+
+// useServiceJobs streams per-service action output inline, keyed by service
+// name, reusing the deployment job endpoints. Several services can run at once.
+export function useServiceJobs(getDeployment: () => string, onSettled?: (s: ServiceJobState) => void) {
+ const states = reactive>({});
+ const controllers: Record = {};
+
+ function ensure(service: string, action: ServiceAction = "restart"): ServiceJobState {
+ if (!states[service]) {
+ states[service] = { service, action, output: "", isRunning: false, isSuccess: null };
+ }
+ return states[service];
+ }
+
+ function teardown(service: string) {
+ const c = controllers[service];
+ if (!c) return;
+ if (c.socket) {
+ c.socket.onclose = null;
+ c.socket.close();
+ c.socket = null;
+ }
+ if (c.timer) {
+ clearTimeout(c.timer);
+ c.timer = null;
+ }
+ }
+
+ function append(service: string, line: string) {
+ const s = ensure(service);
+ s.output = s.output ? `${s.output}\n${line}` : line;
+ }
+
+ function settle(service: string, status: DeploymentActionStatus, error?: string) {
+ const c = controllers[service];
+ if (c?.settled) return;
+ if (c) c.settled = true;
+ teardown(service);
+ const s = ensure(service);
+ s.isRunning = false;
+ s.isSuccess = status === "succeeded";
+ if (!s.output && error) append(service, error);
+ onSettled?.(s);
+ }
+
+ async function run(service: string, action: ServiceAction) {
+ const name = getDeployment();
+ teardown(service);
+ controllers[service] = { socket: null, timer: null, settled: false };
+ const s = ensure(service, action);
+ s.action = action;
+ s.output = "";
+ s.isRunning = true;
+ s.isSuccess = null;
+
+ try {
+ const res = await deploymentsApi.serviceActionJob(name, service, action);
+ openStream(service, res.data.job_id);
+ } catch (e: any) {
+ const activeId = e?.response?.data?.active_job_id;
+ if (e?.response?.status === 409 && activeId) {
+ openStream(service, activeId);
+ return;
+ }
+ settle(service, "failed", e?.response?.data?.error || e?.message || "Request failed");
+ }
+ }
+
+ function openStream(service: string, jobId: string) {
+ const name = getDeployment();
+ const c = controllers[service];
+ if (!c) return;
+
+ let authed = false;
+ const token = localStorage.getItem("auth_token");
+
+ try {
+ c.socket = new WebSocket(deploymentJobWsUrl(name, jobId));
+ } catch {
+ pollUntilDone(service, jobId);
+ return;
+ }
+
+ c.socket.onopen = () => {
+ if (token) {
+ c.socket?.send(JSON.stringify({ type: "auth", token }));
+ } else {
+ authed = true;
+ }
+ };
+
+ c.socket.onmessage = (ev) => {
+ let frame: any;
+ try {
+ frame = JSON.parse(ev.data);
+ } catch {
+ return;
+ }
+
+ if (!authed) {
+ if (frame.type === "auth_success") {
+ authed = true;
+ return;
+ }
+ if (frame.type === "error") {
+ c.socket?.close();
+ pollUntilDone(service, jobId);
+ return;
+ }
+ }
+
+ if (frame.type === "line") {
+ append(service, frame.data ?? "");
+ } else if (frame.type === "result") {
+ settle(service, frame.status, frame.error);
+ } else if (frame.type === "error") {
+ c.socket?.close();
+ pollUntilDone(service, jobId);
+ }
+ };
+
+ c.socket.onclose = () => {
+ if (!c.settled) {
+ pollUntilDone(service, jobId);
+ }
+ };
+ }
+
+ function pollUntilDone(service: string, jobId: string) {
+ const c = controllers[service];
+ if (!c || c.settled) return;
+ const name = getDeployment();
+
+ const tick = async () => {
+ try {
+ const { data } = await deploymentsApi.getJob(name, jobId);
+ if (data.output) {
+ ensure(service).output = data.output;
+ }
+ if (data.status === "succeeded" || data.status === "failed") {
+ settle(service, data.status, data.error);
+ return;
+ }
+ } catch (e: any) {
+ if (e?.response?.status === 404) {
+ settle(service, "failed", "Job is no longer available");
+ return;
+ }
+ }
+ c.timer = setTimeout(tick, 1500);
+ };
+
+ tick();
+ }
+
+ function dismiss(service: string) {
+ teardown(service);
+ delete states[service];
+ delete controllers[service];
+ }
+
+ onScopeDispose(() => {
+ Object.keys(controllers).forEach(teardown);
+ });
+
+ return { states, run, dismiss };
+}
diff --git a/src/services/api.ts b/src/services/api.ts
index 5a16923..9e2e520 100755
--- a/src/services/api.ts
+++ b/src/services/api.ts
@@ -92,6 +92,38 @@ const withPlanQuery = (url: string, opts?: PlanOpts) => {
return url + (url.includes("?") ? "&" : "?") + "plan=true";
};
+export type DeploymentActionStatus = "pending" | "running" | "succeeded" | "failed";
+
+export interface ActionJobResponse {
+ job_id: string;
+ deployment: string;
+ action: string;
+ status: DeploymentActionStatus;
+}
+
+export interface DeploymentJob {
+ id: string;
+ deployment: string;
+ action: string;
+ status: DeploymentActionStatus;
+ output: string;
+ lines: string[];
+ error?: string;
+ started_at: string;
+ finished_at?: string;
+}
+
+export const deploymentJobWsUrl = (name: string, jobId: string): string => {
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const apiUrl = import.meta.env.VITE_API_URL || "";
+ const path = `/api/deployments/${name}/jobs/${jobId}/stream`;
+ if (apiUrl.startsWith("http")) {
+ const url = new URL(apiUrl);
+ return `${protocol}//${url.host}${path}`;
+ }
+ return `${protocol}//${window.location.host}${path}`;
+};
+
export const deploymentsApi = {
list: () => apiClient.get<{ deployments: Deployment[] }>("/deployments"),
get: (name: string) => apiClient.get(`/deployments/${name}`),
@@ -127,10 +159,14 @@ export const deploymentsApi = {
apiClient.post<{ message: string; name: string; service: string; output: string }>(
withPlanQuery(`/deployments/${name}/services/${service}/${action}`, opts),
),
- start: (name: string) => apiClient.post(`/deployments/${name}/start`),
- stop: (name: string) => apiClient.post(`/deployments/${name}/stop`),
- restart: (name: string) => apiClient.post(`/deployments/${name}/restart`),
- rebuild: (name: string) => apiClient.post(`/deployments/${name}/rebuild`),
+ start: (name: string) => apiClient.post(`/deployments/${name}/start`),
+ stop: (name: string) => apiClient.post(`/deployments/${name}/stop`),
+ restart: (name: string) => apiClient.post(`/deployments/${name}/restart`),
+ rebuild: (name: string) => apiClient.post(`/deployments/${name}/rebuild`),
+ getJob: (name: string, jobId: string) => apiClient.get(`/deployments/${name}/jobs/${jobId}`),
+ getActiveJob: (name: string) => apiClient.get(`/deployments/${name}/jobs/active`),
+ serviceActionJob: (name: string, service: string, action: string) =>
+ apiClient.post(`/deployments/${name}/services/${service}/job`, { action }),
pullImage: (name: string, onlyLatest: boolean = false) =>
apiClient.post<{ message: string; name: string; output: string }>(`/deployments/${name}/pull`, {
only_latest: onlyLatest,
diff --git a/src/views/DeploymentDetailView.vue b/src/views/DeploymentDetailView.vue
index e63e16a..f8130ff 100755
--- a/src/views/DeploymentDetailView.vue
+++ b/src/views/DeploymentDetailView.vue
@@ -322,7 +322,12 @@
No services configured
-
+
+
+
+
+
+
+
+
+ {{ serviceJobLabel(serviceJobs.states[service.name]) }}
+
+
+
+
+
+
+
{{
+ serviceJobs.states[service.name].output
+ }}
+
@@ -1184,6 +1221,16 @@
+
+
@@ -1391,12 +1438,27 @@
changes. A simple restart does not use newly pulled images.
+
+
Or rebuild a single service
+
+ {{ service.name }}
+
+
+
@@ -1735,19 +1797,20 @@ import ContainerResourcesModal from "@/components/ContainerResourcesModal.vue";
import { extractComposeMounts, extractComposeServiceNames } from "@/utils/compose";
import { matchTypeHints, describeBlockedRule } from "@/utils/protectedMode";
import { usePlanFlow } from "@/composables/usePlanFlow";
-import { useActionRunner } from "@/composables/useActionRunner";
import SplitActionButton from "@/components/base/SplitActionButton.vue";
import SubTabs from "@/components/base/SubTabs.vue";
import InlineAssist from "@/components/ai/InlineAssist.vue";
import { useAssistStore } from "@/stores/assist";
import Icon from "@/components/base/Icon.vue";
+import OperationModal from "@/components/OperationModal.vue";
+import { useDeploymentJob, type DeploymentOperation } from "@/composables/useDeploymentJob";
+import { useServiceJobs } from "@/composables/useServiceJobs";
const route = useRoute();
const router = useRouter();
const notifications = useNotificationsStore();
const authStore = useAuthStore();
const { runGuarded } = usePlanFlow();
-const actions = useActionRunner();
const assistStore = useAssistStore();
const configAssistOpen = ref(false);
@@ -1949,6 +2012,21 @@ const operationSuccess = ref(false);
const operationError = ref("");
const operationOutput = ref("");
+const deploymentJob = useDeploymentJob((state) => {
+ const op = state.operation;
+ if (state.isSuccess) {
+ notifications.success(`${op} successful`, `${state.deploymentName} ${op} completed`);
+ } else {
+ notifications.error(`${op} failed`, `${state.deploymentName} ${op} failed`);
+ }
+ fetchDeployment();
+});
+
+const serviceJobs = useServiceJobs(
+ () => route.params.name as string,
+ () => fetchDeployment(),
+);
+
const showDeleteDeploymentModal = ref(false);
const deletingDeployment = ref(false);
const deleteOptions = ref({
@@ -2419,7 +2497,12 @@ const fetchStats = async () => {
};
const handleOperation = async (operation: string, onlyLatest: boolean = false) => {
- operationTitle.value = `${operation.charAt(0).toUpperCase() + operation.slice(1)} Deployment`;
+ if (operation !== "pull") {
+ deploymentJob.run(operation as DeploymentOperation, route.params.name as string);
+ return;
+ }
+
+ operationTitle.value = "Pull Deployment";
operationRunning.value = true;
operationSuccess.value = false;
operationError.value = "";
@@ -2427,19 +2510,7 @@ const handleOperation = async (operation: string, onlyLatest: boolean = false) =
showOperationModal.value = true;
try {
- let response;
- if (operation === "start") {
- response = await deploymentsApi.start(route.params.name as string);
- } else if (operation === "stop") {
- response = await deploymentsApi.stop(route.params.name as string);
- } else if (operation === "restart") {
- response = await deploymentsApi.restart(route.params.name as string);
- } else if (operation === "rebuild") {
- response = await deploymentsApi.rebuild(route.params.name as string);
- } else if (operation === "pull") {
- response = await deploymentsApi.pullImage(route.params.name as string, onlyLatest);
- }
-
+ const response = await deploymentsApi.pullImage(route.params.name as string, onlyLatest);
operationOutput.value = response?.data?.output || "Operation completed";
operationSuccess.value = true;
await fetchDeployment();
@@ -2658,6 +2729,30 @@ const viewServiceLogs = (service: any) => {
const serviceNames = computed(() => services.value.map((s) => s.name));
+const serviceActionVerb: Record = {
+ start: { running: "Starting", done: "started", failed: "start failed" },
+ stop: { running: "Stopping", done: "stopped", failed: "stop failed" },
+ restart: { running: "Restarting", done: "restarted", failed: "restart failed" },
+ rebuild: { running: "Rebuilding", done: "rebuilt", failed: "rebuild failed" },
+ pull: { running: "Pulling", done: "image pulled", failed: "pull failed" },
+};
+
+const serviceJobLabel = (st: { service: string; action: string; isRunning: boolean; isSuccess: boolean | null }) => {
+ const v = serviceActionVerb[st.action] || serviceActionVerb.restart;
+ if (st.isRunning) return `${v.running} ${st.service}…`;
+ return st.isSuccess ? `${st.service} ${v.done}` : `${st.service} ${v.failed}`;
+};
+
+const runServiceJob = (service: string, action: "start" | "stop" | "restart" | "rebuild" | "pull") => {
+ serviceJobs.run(service, action);
+ activeTab.value = "overview";
+};
+
+const rebuildSingleService = (service: string) => {
+ showRebuildModal.value = false;
+ runServiceJob(service, "rebuild");
+};
+
const handleScopedAction = (action: "start" | "stop" | "restart" | "rebuild" | "pull", scope: string) => {
if (scope === "deployment") {
if (action === "rebuild") {
@@ -2669,7 +2764,7 @@ const handleScopedAction = (action: "start" | "stop" | "restart" | "rebuild" | "
}
return;
}
- runServiceAction({ name: scope }, action);
+ runServiceJob(scope, action);
};
const logsAssistContext = computed(() => ({
@@ -2702,27 +2797,6 @@ const operationAssistContext = computed(() => ({
seedContext: `\`\`\`\n${operationOutput.value || operationError.value || "(no output captured)"}\n\`\`\``,
}));
-const serviceActionPastTense: Record = {
- start: "started",
- stop: "stopped",
- restart: "restarted",
- rebuild: "rebuilt",
- pull: "image pulled",
-};
-
-const runServiceAction = (service: { name: string }, action: "start" | "stop" | "restart" | "rebuild" | "pull") =>
- actions.run(`${service.name}:${action}`, async () => {
- const name = route.params.name as string;
- const result = await runGuarded(
- () => deploymentsApi.serviceAction(name, service.name, action),
- () => deploymentsApi.serviceAction(name, service.name, action, { plan: true }),
- "Service Action Failed",
- );
- if (result === false) return;
- notifications.success("Service Updated", `${service.name} ${serviceActionPastTense[action]}`);
- await fetchDeployment();
- });
-
const terminalRef = ref | null>(null);
const connectTerminal = async () => {
@@ -3041,6 +3115,7 @@ watch(activeTab, (newTab) => {
onMounted(() => {
fetchDeployment();
+ deploymentJob.resume(route.params.name as string);
credentialsApi
.list()
.then((response) => {
@@ -3618,6 +3693,110 @@ onUnmounted(() => {
gap: var(--space-2);
}
+.service-item--active {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent);
+}
+
+.service-progress {
+ margin-top: var(--space-3);
+ border: 1px solid var(--border);
+ border-left-width: 3px;
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+}
+
+.service-progress.running {
+ border-left-color: var(--color-info-600);
+}
+
+.service-progress.success {
+ border-left-color: var(--color-success-600);
+}
+
+.service-progress.error {
+ border-left-color: var(--color-danger-600);
+}
+
+.service-progress-head {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-3);
+ font-size: var(--text-md);
+ font-weight: var(--font-semibold);
+}
+
+.service-progress.running .service-progress-head {
+ background: var(--color-info-50);
+ color: var(--color-info-700);
+}
+
+.service-progress.success .service-progress-head {
+ background: var(--color-success-50);
+ color: var(--color-success-700);
+}
+
+.service-progress.error .service-progress-head {
+ background: var(--color-danger-50);
+ color: var(--color-danger-700);
+}
+
+.service-progress-label {
+ flex: 1;
+}
+
+.service-progress-dismiss {
+ border: none;
+ background: transparent;
+ color: inherit;
+ opacity: 0.75;
+ cursor: pointer;
+ font-size: var(--text-sm);
+ font-weight: var(--font-medium);
+}
+
+.service-progress-dismiss:hover {
+ opacity: 1;
+}
+
+.service-progress-bar {
+ height: 3px;
+ background: var(--color-info-100);
+ overflow: hidden;
+}
+
+.service-progress-bar span {
+ display: block;
+ width: 40%;
+ height: 100%;
+ background: var(--color-info-600);
+ animation: serviceProgressSlide 1.1s ease-in-out infinite;
+}
+
+@keyframes serviceProgressSlide {
+ 0% {
+ transform: translateX(-100%);
+ }
+ 100% {
+ transform: translateX(350%);
+ }
+}
+
+.service-progress-output {
+ margin: 0;
+ max-height: 200px;
+ overflow: auto;
+ padding: var(--space-3);
+ font-family: var(--font-mono, monospace);
+ font-size: var(--text-xs);
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: var(--text-muted);
+ background: var(--surface-sunken);
+}
+
.action-btn {
width: 28px;
height: 28px;
@@ -4428,6 +4607,41 @@ onUnmounted(() => {
max-width: 500px;
}
+.rebuild-services {
+ margin-top: var(--space-4);
+ border-top: 1px solid var(--border-subtle);
+ padding-top: var(--space-3);
+}
+
+.rebuild-services-label {
+ display: block;
+ font-size: var(--text-sm);
+ font-weight: var(--font-semibold);
+ color: var(--text-muted);
+ margin-bottom: var(--space-2);
+}
+
+.rebuild-service-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-2) 0;
+}
+
+.rebuild-service-row + .rebuild-service-row {
+ border-top: 1px solid var(--border-subtle);
+}
+
+.rebuild-service-name {
+ font-size: var(--text-md);
+ color: var(--text);
+}
+
+.btn-sm {
+ padding: var(--space-1) var(--space-3);
+ font-size: var(--text-sm);
+}
+
.rebuild-modal .modal-header {
display: flex;
justify-content: space-between;
diff --git a/src/views/DeploymentsView.test.ts b/src/views/DeploymentsView.test.ts
index 02ebe62..167b381 100644
--- a/src/views/DeploymentsView.test.ts
+++ b/src/views/DeploymentsView.test.ts
@@ -69,11 +69,15 @@ vi.mock("@/services/api", () => ({
],
},
}),
- start: vi.fn().mockResolvedValue({ data: { output: "Started" } }),
- stop: vi.fn().mockResolvedValue({ data: { output: "Stopped" } }),
- restart: vi.fn().mockResolvedValue({ data: { output: "Restarted" } }),
+ start: vi.fn().mockResolvedValue({ data: { job_id: "job-1", status: "pending" } }),
+ stop: vi.fn().mockResolvedValue({ data: { job_id: "job-1", status: "pending" } }),
+ restart: vi.fn().mockResolvedValue({ data: { job_id: "job-1", status: "pending" } }),
+ rebuild: vi.fn().mockResolvedValue({ data: { job_id: "job-1", status: "pending" } }),
+ getJob: vi.fn().mockResolvedValue({ data: { status: "succeeded", output: "Done", lines: [] } }),
+ getActiveJob: vi.fn().mockRejectedValue({ response: { status: 404 } }),
logs: vi.fn().mockResolvedValue({ data: { logs: "Container logs..." } }),
},
+ deploymentJobWsUrl: vi.fn().mockReturnValue("ws://localhost/api/deployments/x/jobs/job-1/stream"),
}));
const mockPush = vi.fn();
diff --git a/src/views/DeploymentsView.vue b/src/views/DeploymentsView.vue
index 247186b..31d54ed 100644
--- a/src/views/DeploymentsView.vue
+++ b/src/views/DeploymentsView.vue
@@ -306,6 +306,7 @@ import type { Deployment } from "@/types";
import DataTable from "@/components/DataTable.vue";
import DeploymentCard from "@/components/DeploymentCard.vue";
import OperationModal from "@/components/OperationModal.vue";
+import { useDeploymentJob, type DeploymentOperation } from "@/composables/useDeploymentJob";
import LogsModal from "@/components/LogsModal.vue";
import NewDeploymentModal from "@/components/NewDeploymentModal.vue";
import {
@@ -338,14 +339,16 @@ const deployments = ref([]);
const loading = ref(true);
const showNewDeploymentModal = ref(false);
-const operationModal = ref({
- visible: false,
- operation: "start" as "start" | "stop" | "restart",
- deploymentName: "",
- output: "",
- isRunning: false,
- isSuccess: null as boolean | null,
+const deploymentJob = useDeploymentJob((state) => {
+ const op = state.operation;
+ if (state.isSuccess) {
+ notifications.success(`${op} successful`, `${state.deploymentName} ${op} completed`);
+ } else {
+ notifications.error(`${op} failed`, `${state.deploymentName} ${op} failed`);
+ }
+ fetchDeployments();
});
+const operationModal = deploymentJob.state;
const logsModal = ref({
visible: false,
@@ -379,44 +382,12 @@ const refreshDeployments = () => {
notifications.info("Refreshing", "Fetching latest deployment status");
};
-const handleOperation = async (op: "start" | "stop" | "restart", name: string) => {
- operationModal.value = {
- visible: true,
- operation: op,
- deploymentName: name,
- output: "",
- isRunning: true,
- isSuccess: null,
- };
-
- try {
- let response;
- if (op === "start") {
- response = await deploymentsApi.start(name);
- } else if (op === "stop") {
- response = await deploymentsApi.stop(name);
- } else {
- response = await deploymentsApi.restart(name);
- }
-
- operationModal.value.output = response?.data?.output || "Operation completed";
- operationModal.value.isSuccess = true;
- operationModal.value.isRunning = false;
-
- notifications.success(`${op} successful`, `${name} ${op}ed successfully`);
- await fetchDeployments();
- } catch (e: any) {
- const errorMsg = e.response?.data?.output || e.response?.data?.error || e.message;
- operationModal.value.output = errorMsg;
- operationModal.value.isSuccess = false;
- operationModal.value.isRunning = false;
-
- notifications.error(`${op} failed`, errorMsg);
- }
+const handleOperation = (op: DeploymentOperation, name: string) => {
+ deploymentJob.run(op, name);
};
const closeOperationModal = () => {
- operationModal.value.visible = false;
+ deploymentJob.close();
};
const viewLogs = async (name: string) => {
@@ -697,8 +668,11 @@ const formatRelativeTime = (dateStr: string) => {
return formatDate(dateStr);
};
-onMounted(() => {
- fetchDeployments();
+onMounted(async () => {
+ await fetchDeployments();
+ for (const d of deployments.value) {
+ if (await deploymentJob.resume(d.name)) break;
+ }
});