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 @@
{{ 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/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
-
+
{{ 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; + } });