From 61988db39027d4fe5b89f57d54141d1f50b113a6 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 29 Jun 2026 16:10:20 +0100 Subject: [PATCH 1/2] feat(ui): Stream deployment and service action progress Starting, stopping, restarting and rebuilding a deployment or a single service showed only a spinner until the request finished, and the outcome vanished on reload. The dashboard now enqueues the action, streams its compose output live, and falls back to polling if the socket drops. The job id is remembered so reopening the page resumes an action still in flight and shows the final result. Single-service actions render their progress inline under the service, and the rebuild dialog can rebuild one service at a time. The operation dialog's status moves into a compact header indicator since the streamed output now carries the detail. --- src/components/OperationModal.vue | 136 ++++------ src/composables/useDeploymentJob.test.ts | 105 ++++++++ src/composables/useDeploymentJob.ts | 249 ++++++++++++++++++ src/composables/useServiceJobs.test.ts | 104 ++++++++ src/composables/useServiceJobs.ts | 185 +++++++++++++ src/services/api.ts | 44 +++- src/views/DeploymentDetailView.vue | 318 +++++++++++++++++++---- src/views/DeploymentsView.test.ts | 10 +- src/views/DeploymentsView.vue | 62 ++--- 9 files changed, 1027 insertions(+), 186 deletions(-) create mode 100644 src/composables/useDeploymentJob.test.ts create mode 100644 src/composables/useDeploymentJob.ts create mode 100644 src/composables/useServiceJobs.test.ts create mode 100644 src/composables/useServiceJobs.ts 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 @@
{{ operation }}

{{ title }}

-
- - -
-
- -
-
- {{ statusLabel }} - - {{ elapsedTime }} + + + + + {{ 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/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
-
+
{{ service.name }} {{ service.status }} @@ -351,41 +356,73 @@ v-if="service.status !== 'running'" class="action-btn" title="Start" - :disabled="actions.isBusy(`${service.name}:start`)" - @click="runServiceAction(service, 'start')" + :disabled="serviceJobs.states[service.name]?.isRunning" + @click="serviceJobs.run(service.name, 'start')" > - +
+ +
+
+ + + + + {{ serviceJobLabel(serviceJobs.states[service.name]) }} + + +
+
+ +
+
{{
+                        serviceJobs.states[service.name].output
+                      }}
+
@@ -1184,6 +1221,16 @@
+ +
@@ -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; + } }); From 1495208df1ee554a655f37a0f55fcaf6001da670 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 29 Jun 2026 16:10:28 +0100 Subject: [PATCH 2/2] fix(ui): Correct dark mode for status tints and dropdown hover Tinted surfaces (modals, badges, status panels) stayed light in dark mode because the status colour tints were never adapted for the dark theme, and the split action menu fell back to a light hover colour. The dark theme now remaps the tint and on-tint text shades so every tinted surface reads correctly, and the menu hover uses a themed surface. --- src/assets/design-system.css | 28 +++++++++++++++++++++++ src/components/base/SplitActionButton.vue | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) 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/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 {