From dc4593c8c941eed3dbe0d4ad8b9672cb6b7124fe Mon Sep 17 00:00:00 2001 From: lftobs Date: Mon, 20 Jul 2026 23:19:24 +0100 Subject: [PATCH 001/131] feat(deployments): track deployment completion time - Add `finished_at` column to deployments table - Implement status-based timestamping for deployment completion - Update runtime reconciliation to skip non-existent containers - Fix Ko-fi popup mounting in dashboard sidebar - Update docker-compose configuration for local builds --- apps/api/src/api/deployments/index.ts | 9 ++++--- apps/api/src/db/migrate.ts | 13 ++++++++++ apps/api/src/db/repo/deployments.ts | 24 +++++++++++++++++++ apps/api/src/db/schema.ts | 1 + apps/api/src/orchestrator/runtime.ts | 14 ++++++++++- apps/api/src/types.ts | 1 + apps/web/src/components/layout/Sidebar.tsx | 2 +- .../src/components/layout/SupportSection.tsx | 20 ++++++++++++++++ .../project/deployments/deployment-logs.tsx | 3 ++- apps/web/src/index.css | 2 -- apps/web/src/types/index.ts | 1 + docker-compose.yml | 4 ++-- scripts/dequel | 4 ++-- 13 files changed, 86 insertions(+), 12 deletions(-) diff --git a/apps/api/src/api/deployments/index.ts b/apps/api/src/api/deployments/index.ts index 99b1cf0..7cf7d66 100644 --- a/apps/api/src/api/deployments/index.ts +++ b/apps/api/src/api/deployments/index.ts @@ -5,6 +5,7 @@ import { createDeployment, countDeployments, getDeploymentById, + getProjectById, getLogs, listDeployments, } from "../../db/repo"; @@ -66,6 +67,7 @@ export const deploymentsRoutes = new Elysia() String(form.get("commitSha") ?? "").trim() || undefined; const clearCache = form.get("clearCache") === "true"; + const resolvedBranch = branch || (projectId ? (await getProjectById(projectId))?.repoBranch || undefined : undefined); if ( sourceType !== "git" && sourceType !== "upload" && @@ -88,7 +90,7 @@ export const deploymentsRoutes = new Elysia() projectId, sourceType: "git", sourceRef: gitUrl, - branch, + branch: resolvedBranch, environment, commitSha, clearCache, @@ -122,7 +124,7 @@ export const deploymentsRoutes = new Elysia() projectId, sourceType: "upload", sourceRef: uploadPath, - branch, + branch: resolvedBranch, environment, clearCache, }); @@ -186,11 +188,12 @@ export const deploymentsRoutes = new Elysia() error: "Cannot redeploy an image-based (rollback) deployment — rollback to an earlier source deployment instead", }; } + const project = original.projectId ? await getProjectById(original.projectId) : null; const deployment = await createDeployment({ projectId: original.projectId || undefined, sourceType: original.sourceType, sourceRef: original.sourceRef, - branch: original.branch || undefined, + branch: original.branch || project?.repoBranch || undefined, environment: original.environment || undefined, }); orchestrator.enqueue(deployment.id); diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts index 70797f7..f7e8cc4 100644 --- a/apps/api/src/db/migrate.ts +++ b/apps/api/src/db/migrate.ts @@ -30,6 +30,7 @@ export const migrate = async () => { drizzleMigrate(db, { migrationsFolder }); await addClearCacheColumn(db); + await addFinishedAtColumn(db); await seedFromConfig(); }; @@ -45,6 +46,18 @@ const addClearCacheColumn = async (db: ReturnType) => { } }; +const addFinishedAtColumn = async (db: ReturnType) => { + try { + db.run(sql`ALTER TABLE deployments ADD COLUMN finished_at text`); + console.log("[Migrate] Added finished_at column to deployments table"); + } catch (err) { + const cause = err instanceof Error && "cause" in err ? err.cause : err; + if (cause instanceof Error && cause.message.includes("duplicate column name")) return; + console.error("[Migrate] Failed to add finished_at column:", err); + throw err; + } +}; + const seedFromConfig = async () => { if (config.githubClientId && config.githubClientSecret) { const existing = await getGithubIntegration(); diff --git a/apps/api/src/db/repo/deployments.ts b/apps/api/src/db/repo/deployments.ts index bdaf080..843ac82 100644 --- a/apps/api/src/db/repo/deployments.ts +++ b/apps/api/src/db/repo/deployments.ts @@ -21,6 +21,7 @@ const mapDeployment = (row: typeof deployments.$inferSelect): Deployment => ({ environment: row.environment, failureReason: row.failureReason, clearCache: Boolean(row.clearCache), + finishedAt: row.finishedAt ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, }); @@ -74,18 +75,41 @@ export const updateDeploymentCommitSha = async (id: string, commitSha: string) = db.update(deployments).set({ commitSha, updatedAt: now() }).where(eq(deployments.id, id)).run(); }; +const ACTIVE_STATUSES: DeploymentStatus[] = [ + "pending", + "building", + "deploying", +]; + +const STAMP_FINISHED_UNCONDITIONALLY: DeploymentStatus[] = [ + "running", + "failed", +]; + export const updateDeploymentStatus = async ( id: string, status: DeploymentStatus, patch: Partial> = {}, ) => { const db = await getDrizzle(); + const existing = db + .select({ finishedAt: deployments.finishedAt }) + .from(deployments) + .where(eq(deployments.id, id)) + .get(); const updates: Record = { status, updatedAt: now() }; if (patch.imageTag !== undefined) updates.imageTag = patch.imageTag; if (patch.containerName !== undefined) updates.containerName = patch.containerName; if (patch.liveUrl !== undefined) updates.liveUrl = patch.liveUrl; if (patch.failureReason !== undefined) updates.failureReason = patch.failureReason; if (patch.replicas !== undefined) updates.replicas = patch.replicas; + if (!ACTIVE_STATUSES.includes(status)) { + if (!existing?.finishedAt) { + updates.finishedAt = now(); + } else if (STAMP_FINISHED_UNCONDITIONALLY.includes(status)) { + updates.finishedAt = now(); + } + } db.update(deployments).set(updates).where(eq(deployments.id, id)).run(); }; diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 25a2f4f..30b309d 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -44,6 +44,7 @@ export const deployments = sqliteTable("deployments", { environment: text(), failureReason: text("failure_reason"), clearCache: integer("clear_cache").notNull().default(0), + finishedAt: text("finished_at"), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }); diff --git a/apps/api/src/orchestrator/runtime.ts b/apps/api/src/orchestrator/runtime.ts index 3592523..10ca24f 100644 --- a/apps/api/src/orchestrator/runtime.ts +++ b/apps/api/src/orchestrator/runtime.ts @@ -54,7 +54,14 @@ const waitForRunningContainer = async ( ) => { for (let attempt = 0; attempt < retries; attempt++) { try { - const status = (await run(dockerBin, ['inspect', '-f', '{{.State.Status}}', containerName])).trim(); + const exists = await run(dockerBin, ['inspect', '-f', '{{.Id}}', containerName]) + .then(() => true) + .catch(() => false); + if (!exists) { + await new Promise(r => setTimeout(r, 500)); + continue; + } + const status = (await run(dockerBin, ['inspect', '-f', '{{.State.Status}}', containerName])).trim(); if (status === 'running') { await new Promise(r => setTimeout(r, 2000)); const stabilityStatus = (await run(dockerBin, ['inspect', '-f', '{{.State.Status}}', containerName])).trim(); @@ -83,6 +90,11 @@ const waitForRunningContainer = async ( export const ensureContainerRunning = async (containerName: string) => { try { + const exists = await run(dockerBin, ['inspect', '-f', '{{.Id}}', containerName]).then(() => true).catch(() => false); + if (!exists) { + console.warn(`Container ${containerName} not found — skipping reconciliation`); + return; + } const status = (await run(dockerBin, ['inspect', '-f', '{{.State.Status}}', containerName])).trim(); if (status !== 'running') { await tryRun(dockerBin, ['network', 'disconnect', '-f', config.dockerNetwork, containerName]); diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts index e7bb643..21ca1b4 100644 --- a/apps/api/src/types.ts +++ b/apps/api/src/types.ts @@ -222,6 +222,7 @@ export interface Deployment { environment: string | null; failureReason: string | null; clearCache: boolean; + finishedAt: string | null; createdAt: string; updatedAt: string; } diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index 6a8b82b..8de0c47 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -48,7 +48,7 @@ export function Sidebar({