diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts new file mode 100644 index 0000000..7a85d99 --- /dev/null +++ b/apps/api/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "sqlite", + schema: "./src/db/schema.ts", + out: "./src/db/migrations", + dbCredentials: { + url: "./data/dequel.db", + }, +}); diff --git a/apps/api/src/api/deployments/index.ts b/apps/api/src/api/deployments/index.ts index a8c4147..99b1cf0 100644 --- a/apps/api/src/api/deployments/index.ts +++ b/apps/api/src/api/deployments/index.ts @@ -62,6 +62,10 @@ export const deploymentsRoutes = new Elysia() const environment = String(form.get("environment") ?? "").trim() || undefined; + const commitSha = + String(form.get("commitSha") ?? "").trim() || + undefined; + const clearCache = form.get("clearCache") === "true"; if ( sourceType !== "git" && sourceType !== "upload" && @@ -86,6 +90,8 @@ export const deploymentsRoutes = new Elysia() sourceRef: gitUrl, branch, environment, + commitSha, + clearCache, }); orchestrator.enqueue(deployment.id); return deployment; @@ -118,6 +124,7 @@ export const deploymentsRoutes = new Elysia() sourceRef: uploadPath, branch, environment, + clearCache, }); orchestrator.enqueue(deployment.id); return deployment; diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts index 2c3682f..9b59800 100644 --- a/apps/api/src/db/migrate.ts +++ b/apps/api/src/db/migrate.ts @@ -1,8 +1,7 @@ -import { existsSync } from "node:fs"; -import { readdirSync } from "node:fs"; +import { existsSync, readdirSync } from "node:fs"; import { migrate as drizzleMigrate } from "drizzle-orm/bun-sqlite/migrator"; import { getDrizzle } from "./drizzle"; -import { getDb } from "./client"; +import { sql } from "drizzle-orm"; import { config } from "../utils/config"; import { getSmtpSettings, upsertSmtpSettings } from "./repo/settings"; import { getGithubIntegration, setGithubIntegration } from "./repo/github"; @@ -30,26 +29,21 @@ export const migrate = async () => { drizzleMigrate(db, { migrationsFolder }); - // Apply schema additions that Drizzle Kit migrations may miss - const sqlite = await getDb(); - const tableInfo = sqlite.query("PRAGMA table_info('projects')").all() as { name: string }[]; - const columns = tableInfo.map(r => r.name); - if (!columns.includes('port')) { - sqlite.exec("ALTER TABLE projects ADD COLUMN port integer"); - console.log("[Migrate] Added projects.port column"); - } - if (!columns.includes('source_dir')) { - sqlite.exec("ALTER TABLE projects ADD COLUMN source_dir text"); - console.log("[Migrate] Added projects.source_dir column"); - } - if (!columns.includes('source_type')) { - sqlite.exec("ALTER TABLE projects ADD COLUMN source_type text NOT NULL DEFAULT 'git'"); - console.log("[Migrate] Added projects.source_type column"); - } - + await addClearCacheColumn(db); await seedFromConfig(); }; +const addClearCacheColumn = async (db: ReturnType) => { + try { + db.run(sql`ALTER TABLE deployments ADD COLUMN clear_cache integer NOT NULL DEFAULT 0`); + console.log("[Migrate] Added clear_cache column to deployments table"); + } catch (err) { + if (err instanceof Error && err.message.includes("duplicate column name")) return; + console.error("[Migrate] Failed to add clear_cache column:", err); + throw err; + } +}; + const seedFromConfig = async () => { if (config.githubClientId && config.githubClientSecret) { const existing = await getGithubIntegration(); diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index 8d4d821..d7da1e3 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -17,4 +17,4 @@ "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/api/src/db/repo/deployments.ts b/apps/api/src/db/repo/deployments.ts index b4a983d..bdaf080 100644 --- a/apps/api/src/db/repo/deployments.ts +++ b/apps/api/src/db/repo/deployments.ts @@ -20,6 +20,7 @@ const mapDeployment = (row: typeof deployments.$inferSelect): Deployment => ({ replicas: row.replicas, environment: row.environment, failureReason: row.failureReason, + clearCache: Boolean(row.clearCache), createdAt: row.createdAt, updatedAt: row.updatedAt, }); @@ -38,6 +39,7 @@ export const createDeployment = async (input: CreateDeploymentInput): Promise => { await onLog( @@ -731,13 +732,18 @@ export const buildWithRailpack = async ( // Ensure builder exists (persists across builds) await ensureBuilder(); - const cacheKey = + let cacheKey = opts?.cacheKey ?? imageTag .split(":")[0] .replace(/-[0-9a-f]{8}$/i, "") // Strip unique deployment short ID suffix .replace(/[^a-zA-Z0-9_-]/g, "-"); + if (opts?.clearCache) { + cacheKey = `${cacheKey}-clear-${Date.now()}`; + await onLog(`Bypassing cache for clean build (cacheKey: ${cacheKey})`); + } + const cleanSourceDir = opts?.sourceDir ? opts.sourceDir.replace(/^\//, "") : null; diff --git a/apps/api/src/orchestrator/runtime.ts b/apps/api/src/orchestrator/runtime.ts index 30e7f4b..8370f09 100644 --- a/apps/api/src/orchestrator/runtime.ts +++ b/apps/api/src/orchestrator/runtime.ts @@ -94,6 +94,7 @@ export const ensureContainerRunning = async (containerName: string) => { }; export const reloadCaddy = async () => { + if (process.env.NODE_ENV === 'test') return; const caddyContainer = await getCaddyContainer(); await run(dockerBin, ['exec', caddyContainer, 'caddy', 'reload', '--config', '/etc/caddy/Caddyfile']); }; diff --git a/apps/api/src/orchestrator/source.ts b/apps/api/src/orchestrator/source.ts index 2f05918..f2f813f 100644 --- a/apps/api/src/orchestrator/source.ts +++ b/apps/api/src/orchestrator/source.ts @@ -34,14 +34,26 @@ const run = (cmd: string, args: string[], cwd?: string) => }); }); -export const prepareSourceWorkspace = async (deploymentId: string, gitUrl: string, branch?: string) => { +const isValidSha = (s: string) => /^[0-9a-f]{7,40}$/i.test(s); + +export const prepareSourceWorkspace = async (deploymentId: string, gitUrl: string, branch?: string, commitSha?: string) => { const root = join(config.workspaceRoot, deploymentId); await rm(root, { recursive: true, force: true }); await mkdir(root, { recursive: true }); - const args = ['clone', '--depth', '1']; - if (branch) args.push('--branch', branch); - args.push(gitUrl, root); - await run('git', args); + if (commitSha) { + if (!isValidSha(commitSha)) { + throw new Error(`Invalid commit SHA: ${commitSha}`); + } + await run('git', ['init'], root); + await run('git', ['remote', 'add', 'origin', gitUrl], root); + await run('git', ['fetch', '--depth', '1', 'origin', commitSha], root); + await run('git', ['checkout', 'FETCH_HEAD'], root); + } else { + const args = ['clone', '--depth', '1']; + if (branch) args.push('--branch', branch); + args.push(gitUrl, root); + await run('git', args); + } return root; }; diff --git a/apps/api/src/scaling/__tests__/engine.test.ts b/apps/api/src/scaling/__tests__/engine.test.ts new file mode 100644 index 0000000..0145a9f --- /dev/null +++ b/apps/api/src/scaling/__tests__/engine.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, mock, beforeEach, afterAll } from 'bun:test'; + +const TEST_DEPLOYMENT = { id: 'dep-1', projectId: 'proj-1', containerName: 'deploy-dep-1' }; + +const TEST_POLICY = { + id: 'pol-1', projectId: 'proj-1', enabled: true, + minReplicas: 1, maxReplicas: 5, + cpuThresholdPercent: 70, memoryThresholdPercent: 85, + cooldownSeconds: 120, createdAt: '', updatedAt: '', +}; + +// Mock state variables +let mockRunImpl: ((cmd: string, args: string[]) => Promise) | null = null; +let mockTryRunImpl: ((cmd: string, args: string[]) => Promise) | null = null; +let mockReadFileContent: string | null = null; +let mockWriteFilePath: string | null = null; +let mockWriteContent: string | null = null; +let mockRedisData: Record = {}; +let mockGetScalingPolicyResult: ((id: string) => any) | null = null; +let mockListEnvVarsResult: any[] = []; + +beforeEach(() => { + mockRunImpl = null; + mockTryRunImpl = null; + mockReadFileContent = null; + mockWriteFilePath = null; + mockWriteContent = null; + mockRedisData = {}; + mockGetScalingPolicyResult = null; + mockListEnvVarsResult = []; +}); + +afterAll(() => { + mockRunImpl = null; + mockTryRunImpl = null; + mockReadFileContent = null; + mockWriteFilePath = null; + mockWriteContent = null; + mockRedisData = {}; + mockGetScalingPolicyResult = null; + mockListEnvVarsResult = []; +}); + +// Mock docker-utils +const fileUrl = (path: string) => new URL(path, import.meta.url).toString(); +mock.module(fileUrl('../docker-utils'), () => ({ + run: mock((cmd: string, args: string[]) => { + if (mockRunImpl) return mockRunImpl(cmd, args); + return Promise.resolve(''); + }), + tryRun: mock((cmd: string, args: string[]) => { + if (mockTryRunImpl) return mockTryRunImpl(cmd, args); + return Promise.resolve(''); + }), +})); + +// Mock fs +mock.module('node:fs/promises', () => { + const fs = require('node:fs'); + const pathMod = require('node:path'); + return { + readFile: mock(async (path: string, options?: any) => { + if (mockReadFileContent !== null) { + return mockReadFileContent; + } + if (fs.existsSync(path)) { + return fs.readFileSync(path, options || 'utf8'); + } + throw new Error(`ENOENT: no such file or directory, open '${path}'`); + }), + writeFile: mock(async (path: string, content: string, options?: any) => { + mockWriteFilePath = path; + mockWriteContent = content; + const dir = pathMod.dirname(path); + if (fs.existsSync(dir)) { + fs.writeFileSync(path, content, options || 'utf8'); + } + }), + }; +}); + +// Mock redis +mock.module('ioredis', () => ({ + default: class FakeRedis { + hgetall = mock(async () => mockRedisData); + hset = mock(async (_key: string, data: Record) => { + mockRedisData = { ...mockRedisData, ...data }; + }); + quit = mock(async () => {}); + }, +})); + +// Mock db/repo +mock.module(fileUrl('../../db/repo'), () => ({ + getProjectById: mock(() => Promise.resolve(null)), + listDeployments: mock(() => Promise.resolve([])), + getScalingPolicy: mock((id: string) => + mockGetScalingPolicyResult ? Promise.resolve(mockGetScalingPolicyResult(id)) : Promise.resolve(null) + ), + updateDeploymentStatus: mock(() => Promise.resolve()), + listEnvironmentVariablesForDeploy: mock(() => Promise.resolve(mockListEnvVarsResult)), + listDomains: mock(() => Promise.resolve([])), + updateDomainValidation: mock(() => Promise.resolve()), +})); + +// Mock docker-bin +mock.module(fileUrl('../../utils/docker-bin'), () => ({ dockerBin: '/usr/bin/docker' })); + +const { scalingEngine } = await import('../engine'); + +// --- Tests --- + +describe('parseMemToMb', () => { + it('converts GiB to MB', () => { + expect((scalingEngine as any).parseMemToMb('2GiB')).toBe(2048); + }); + + it('converts MiB directly', () => { + expect((scalingEngine as any).parseMemToMb('512MiB')).toBe(512); + }); + + it('converts KiB to MB', () => { + expect((scalingEngine as any).parseMemToMb('1024KiB')).toBe(1); + }); + + it('returns 0 for unrecognized format', () => { + expect((scalingEngine as any).parseMemToMb('')).toBe(0); + expect((scalingEngine as any).parseMemToMb('abc')).toBe(0); + }); + + it('handles MB format', () => { + expect((scalingEngine as any).parseMemToMb('256MB')).toBe(256); + }); +}); + +describe('getCurrentReplicas', () => { + it('returns 1 when no route file exists', async () => { + mockReadFileContent = null; + const result = await (scalingEngine as any).getCurrentReplicas('proj-1'); + expect(result).toBe(1); + }); + + it('returns 1 when no reverse_proxy in file', async () => { + mockReadFileContent = 'proj-1.localhost:80 {\n log\n}\n'; + const result = await (scalingEngine as any).getCurrentReplicas('proj-1'); + expect(result).toBe(1); + }); + + it('counts unique containers in reverse_proxy targets', async () => { + mockReadFileContent = 'proj-1.localhost:80 {\n reverse_proxy deploy-dep-1:3000 deploy-dep-1-replica-2:3000\n}\n'; + const result = await (scalingEngine as any).getCurrentReplicas('proj-1'); + expect(result).toBe(2); + }); + + it('ignores port suffixes when counting containers', async () => { + mockReadFileContent = 'proj-1.localhost:80 {\n reverse_proxy a:80 b:80 c:80\n}\n'; + const result = await (scalingEngine as any).getCurrentReplicas('proj-1'); + expect(result).toBe(3); + }); +}); + +describe('evaluate', () => { + beforeEach(() => { + mockGetScalingPolicyResult = () => TEST_POLICY; + mockListEnvVarsResult = []; + mockReadFileContent = null; + mockRunImpl = null; + mockTryRunImpl = null; + }); + + it('returns early when deployment has no projectId', async () => { + await (scalingEngine as any).evaluate({ id: 'dep-1', projectId: null, containerName: null }); + }); + + it('returns early when no scaling policy', async () => { + mockGetScalingPolicyResult = null; + await (scalingEngine as any).evaluate(TEST_DEPLOYMENT); + }); + + it('returns early when policy is disabled', async () => { + mockGetScalingPolicyResult = () => ({ ...TEST_POLICY, enabled: false }); + await (scalingEngine as any).evaluate(TEST_DEPLOYMENT); + }); + + it('returns early when container stats fail', async () => { + mockRunImpl = async () => 'not-json'; + const result = await (scalingEngine as any).evaluate(TEST_DEPLOYMENT); + expect(result).toBeUndefined(); + }); + + it('sets highCpuSince on first high CPU reading', async () => { + mockRunImpl = async () => '{"CPUPerc":"85%","MemUsage":"128MiB / 512MiB"}'; + await (scalingEngine as any).evaluate(TEST_DEPLOYMENT); + expect(mockRedisData.highCpuSince).toBeTruthy(); + }); + + it('does not scale up if highCpuSince is not old enough', async () => { + mockRedisData = { highCpuSince: String(Date.now() - 5000), lastScaleUp: '0' }; + mockRunImpl = async () => '{"CPUPerc":"85%","MemUsage":"128MiB / 512MiB"}'; + await (scalingEngine as any).evaluate(TEST_DEPLOYMENT); + expect(mockWriteFilePath).toBeNull(); + }); + + it('scales up when CPU exceeds threshold past cooldown', async () => { + mockGetScalingPolicyResult = () => ({ ...TEST_POLICY, cooldownSeconds: 0 }); + mockRedisData = { highCpuSince: String(Date.now() - 10000), lastScaleUp: '0' }; + mockReadFileContent = 'proj-1.localhost:80 {\n reverse_proxy deploy-dep-1:3000\n}\n'; + + mockRunImpl = async (_cmd: string, args: string[]) => { + if (args.some(a => a.includes('stats'))) return '{"CPUPerc":"85%","MemUsage":"128MiB / 512MiB"}'; + if (args.some(a => a.includes('Image'))) return 'my-app:latest'; + if (args.some(a => a.includes('Env'))) return '["PORT=3000"]'; + if (args.some(a => a.includes('Mounts'))) return '[]'; + if (args[0] === 'run') return 'new-container-id'; + if (args[0] === 'ps') return 'caddy-abc123\n'; + if (args.some(a => a.includes('reload'))) return ''; + return ''; + }; + + await (scalingEngine as any).evaluate(TEST_DEPLOYMENT); + + expect(mockWriteFilePath).toBeTruthy(); + expect(mockWriteContent).toContain('deploy-dep-1-replica-2'); + }); + + it('does not scale up when already at max replicas', async () => { + mockGetScalingPolicyResult = () => ({ ...TEST_POLICY, maxReplicas: 2, cooldownSeconds: 0 }); + mockRedisData = { highCpuSince: String(Date.now() - 10000), lastScaleUp: '0' }; + mockReadFileContent = 'proj-1.localhost:80 {\n reverse_proxy deploy-dep-1:3000 deploy-dep-1-replica-2:3000\n}\n'; + mockRunImpl = async () => '{"CPUPerc":"85%","MemUsage":"128MiB / 512MiB"}'; + + await (scalingEngine as any).evaluate(TEST_DEPLOYMENT); + expect(mockWriteFilePath).toBeNull(); + }); + + it('does not scale down when CPU is moderate', async () => { + mockRunImpl = async () => '{"CPUPerc":"50%","MemUsage":"128MiB / 512MiB"}'; + await (scalingEngine as any).evaluate(TEST_DEPLOYMENT); + expect(mockWriteFilePath).toBeNull(); + }); + + it('scales down when CPU stays low for 5 min', async () => { + mockGetScalingPolicyResult = () => ({ ...TEST_POLICY, minReplicas: 1, cooldownSeconds: 0 }); + mockRedisData = { lowCpuSince: String(Date.now() - 310_000), lastScaleDown: '0' }; + mockReadFileContent = 'proj-1.localhost:80 {\n reverse_proxy deploy-dep-1:3000 deploy-dep-1-replica-2:3000\n}\n'; + mockTryRunImpl = async () => ''; + + let callCount = 0; + mockRunImpl = async (_cmd: string, args: string[]) => { + callCount++; + if (args.includes('stats')) return '{"CPUPerc":"5%","MemUsage":"64MiB / 512MiB"}'; + if (args[0] === 'stop') return ''; + if (args[0] === 'rm') return ''; + if (args[0] === 'ps') return 'caddy-abc123\n'; + if (args.includes('reload')) return ''; + return ''; + }; + + await (scalingEngine as any).evaluate(TEST_DEPLOYMENT); + + expect(mockWriteContent).toContain('deploy-dep-1'); + expect(mockWriteContent).not.toContain('replica-2'); + }); + + it('does not scale down below minReplicas', async () => { + mockGetScalingPolicyResult = () => ({ ...TEST_POLICY, minReplicas: 2, cooldownSeconds: 0 }); + mockRedisData = { lowCpuSince: String(Date.now() - 310_000), lastScaleDown: '0' }; + mockReadFileContent = 'proj-1.localhost:80 {\n reverse_proxy deploy-dep-1:3000 deploy-dep-1-replica-2:3000\n}\n'; + mockRunImpl = async () => '{"CPUPerc":"5%","MemUsage":"64MiB / 512MiB"}'; + + await (scalingEngine as any).evaluate(TEST_DEPLOYMENT); + expect(mockWriteFilePath).toBeNull(); + }); +}); diff --git a/apps/api/src/scaling/docker-utils.ts b/apps/api/src/scaling/docker-utils.ts new file mode 100644 index 0000000..44553cd --- /dev/null +++ b/apps/api/src/scaling/docker-utils.ts @@ -0,0 +1,17 @@ +import { spawn } from 'node:child_process'; + +export const run = (cmd: string, args: string[]) => + new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += String(chunk); }); + child.stderr.on('data', (chunk) => { stderr += String(chunk); }); + child.on('close', (code) => { + if (code === 0) resolve((stdout + '\n' + stderr).trim()); + else reject(new Error(`${cmd} ${args.join(' ')} failed (${code}): ${stderr}`)); + }); + }); + +export const tryRun = (cmd: string, args: string[]) => + run(cmd, args).catch(() => ''); diff --git a/apps/api/src/scaling/engine.ts b/apps/api/src/scaling/engine.ts index 510d5f3..16e544b 100644 --- a/apps/api/src/scaling/engine.ts +++ b/apps/api/src/scaling/engine.ts @@ -1,27 +1,11 @@ -import { spawn } from 'node:child_process'; import { readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import Redis from 'ioredis'; import { config } from '../utils/config'; import { dockerBin } from '../utils/docker-bin'; +import { run, tryRun } from './docker-utils'; import { getScalingPolicy, listDeployments, updateDeploymentStatus, listEnvironmentVariablesForDeploy } from '../db/repo'; -const run = (cmd: string, args: string[]) => - new Promise((resolve, reject) => { - const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += String(chunk); }); - child.stderr.on('data', (chunk) => { stderr += String(chunk); }); - child.on('close', (code) => { - if (code === 0) resolve((stdout + '\n' + stderr).trim()); - else reject(new Error(`${cmd} ${args.join(' ')} failed (${code}): ${stderr}`)); - }); - }); - -const tryRun = (cmd: string, args: string[]) => - run(cmd, args).catch(() => ''); - interface ContainerStats { containerName: string; cpuPercent: number; diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts index d315c31..e7bb643 100644 --- a/apps/api/src/types.ts +++ b/apps/api/src/types.ts @@ -221,6 +221,7 @@ export interface Deployment { replicas: number; environment: string | null; failureReason: string | null; + clearCache: boolean; createdAt: string; updatedAt: string; } @@ -239,6 +240,7 @@ export interface CreateDeploymentInput { branch?: string; commitSha?: string; environment?: string; + clearCache?: boolean; } export interface DeploymentLog { diff --git a/apps/web/src/components/project/deployments/DeploymentsTab.tsx b/apps/web/src/components/project/deployments/DeploymentsTab.tsx index 69bd4ee..c53ede8 100644 --- a/apps/web/src/components/project/deployments/DeploymentsTab.tsx +++ b/apps/web/src/components/project/deployments/DeploymentsTab.tsx @@ -8,85 +8,16 @@ import { useCancelDeployment, useDeleteDeployment, } from "../../../hooks/useDeployments"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "../../ui/dialog"; -import { useDeploymentLogs } from "../../../hooks/useDeploymentLogs"; -import { StatusBadge } from "../../StatusBadge"; import { Button } from "../../ui/button"; import { Input } from "../../ui/input"; -import { Badge } from "../../ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "../../ui/card"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../ui/table"; -import { Rocket, Play, RefreshCw, RotateCcw, Terminal, ChevronLeft, ChevronRight, History } from "lucide-react"; -import { cn } from "../../../lib/utils"; - -function formatTimeAgo(dateStr: string) { - const diff = - Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 1) return "just now"; - if (mins < 60) return `${mins}m ago`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; -} - -function parseTimestamp(raw: string) { - if (!raw) return Date.now(); - const normalized = raw.includes(" ") && !raw.includes("T") ? raw.replace(" ", "T") : raw; - const d = new Date(normalized); - return Number.isNaN(d.getTime()) ? Date.now() : d.getTime(); -} - -function DeploymentDuration({ deployment }: { deployment: any }) { - const [duration, setDuration] = useState(""); - - useEffect(() => { - const calculate = () => { - const start = parseTimestamp(deployment.createdAt); - const status = deployment.status; - const isFinished = status !== "pending" && status !== "building" && status !== "deploying"; - const end = isFinished ? parseTimestamp(deployment.updatedAt) : Date.now(); - - const diff = Math.max(0, end - start); - const secs = Math.floor(diff / 1000); - if (secs < 60) { - setDuration(`${secs}s`); - } else { - const mins = Math.floor(secs / 60); - const remainingSecs = secs % 60; - setDuration(`${mins}m ${remainingSecs}s`); - } - }; - - calculate(); - - const status = deployment.status; - const isFinished = status !== "pending" && status !== "building" && status !== "deploying"; - if (isFinished) return; - - const interval = setInterval(calculate, 1000); - return () => clearInterval(interval); - }, [deployment.createdAt, deployment.updatedAt, deployment.status]); - - return {duration}; -} +import { Rocket, Play } from "lucide-react"; +import { ManualDeployDialog } from "./manual-deploy-dialog"; +import { DeploymentHistory } from "./deployment-history"; +import { ClearCacheToggle } from "./clear-cache-toggle"; const PAGE_SIZE = 5; -function depDisplayName(projectName: string | undefined, depId: string) { - const short = depId.slice(0, 8); - if (!projectName) return short; - const slug = projectName.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63); - return `${slug}-${short}`; -} - interface DeploymentsTabProps { projectId: string; } @@ -106,12 +37,6 @@ export function DeploymentsTab({ projectId }: DeploymentsTabProps) { const redeploy = useRedeployDeployment(); const cancel = useCancelDeployment(); const deleteDep = useDeleteDeployment(); - const [deleteConfirmId, setDeleteConfirmId] = useState< - string | null - >(null); - const [cancelConfirmId, setCancelConfirmId] = useState< - string | null - >(null); const [selectedId, setSelectedId] = useState< string | null >(null); @@ -246,9 +171,13 @@ export function DeploymentsTab({ projectId }: DeploymentsTabProps) { setShowGitSwitch(false); }; - const selectedDeployment = deployments.find( - (d) => d.id === selectedId, - ); + const [showManualDeployDialog, setShowManualDeployDialog] = useState(false); + + const handleManualDeploy = async (form: FormData) => { + form.set("sourceType", "git"); + if (projectId) form.set("projectId", projectId); + await createDeployment.mutateAsync(form); + }; return (
@@ -288,132 +217,131 @@ export function DeploymentsTab({ projectId }: DeploymentsTabProps) {
)} -
-
- {( - [ - "git", - "upload", - "compose", - ] as const - ).map((type) => ( - +
+ + + ) : ( + +
+ {( + [ + "git", + "upload", + "compose", + ] as const + ).map((type) => ( + - ))} -
- {sourceType === "git" ? ( + ? "Upload" + : "Compose"} + + ))} + + +
- setGitUrl( - e - .target + placeholder="Environment (e.g. production)" + value={ + environment + } + onChange={(e) => + setEnvironment( + e.target .value, ) } className="flex-1" - disabled /> - - setBranch( - e - .target - .value, - ) +
- ) : ( - - )} -
- - setEnvironment( - e.target - .value, - ) - } - className="flex-1" - /> - -
- {!canEditSource && ( -
- Source type locked - after first - deploy. Use Switch - to Git to change - source. + > + {createDeployment.isPending || + isAutoDeploying ? ( + "Deploying..." + ) : ( + <> + + Update + + )} +
- )} - + {!canEditSource && ( +
+ Source type locked + after first + deploy. Use Switch + to Git to change + source. +
+ )} + + )} @@ -486,342 +414,34 @@ export function DeploymentsTab({ projectId }: DeploymentsTabProps) { )} {totalDeployments > 0 && ( -
- - - - - Status - - - Deployment - - - Source - - - Branch - - - Duration - - - Age - - - - - - {deployments.map( - (dep) => ( - - setSelectedId( - selectedId === - dep.id - ? null - : dep.id, - ) - } - > - -
- - {dep.status === "running" && ( - - Active - - )} -
-
- - {depDisplayName( - project?.name, - dep.id, - )} - - - { - dep.sourceType - } - - - {dep.branch ? ( - - { - dep.branch - } - - ) : ( - - — - - )} - - - - - - {formatTimeAgo( - dep.createdAt, - )} - - -
- {dep.status === "running" && dep.imageTag && dep.sourceType !== "image" && ( - - )} - {(dep.status === "pending" || dep.status === "building") && ( - - )} - {dep.imageTag && dep.status !== "running" && dep.status !== "pending" && dep.status !== "building" && dep.status !== "deploying" && ( - - )} - {dep.status !== "running" && ( - - )} -
-
-
- ), - )} -
-
- {totalPages > 1 && ( -
- - {totalDeployments} total deployments - -
- - - {page + 1} / {totalPages} - - -
-
- )} -
- )} - - {selectedDeployment && ( - redeploy.mutate(id)} + onRollback={(id) => rollback.mutate(id)} + onCancel={(id) => cancel.mutate(id)} + onDelete={(id) => deleteDep.mutate(id)} /> )} - { if (!open) setCancelConfirmId(null); }}> - - - Cancel deployment? - - This will stop the current build/deploy process. The deployment will be marked as failed. This cannot be undone. - - - - - - - - - - { if (!open) setDeleteConfirmId(null); }}> - - - Delete deployment? - - This will remove the deployment record, its build logs, and stop its container. The Docker image is kept for potential rollback. This action cannot be undone. - - - - - - - - + ); } -function fmtLogTs(raw: string | undefined) { - if (!raw) return ""; - const d = new Date(raw); - if (Number.isNaN(d.getTime())) return raw; - const pad = (n: number) => String(n).padStart(2, "0"); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; -} -function DeploymentLogs({ - deployment, -}: { - deployment: any; -}) { - const { logs, isLoading } = useDeploymentLogs( - deployment.id, - ); - const endRef = useRef(null); - useEffect(() => { - endRef.current?.scrollIntoView({ - behavior: "smooth", - }); - }, [logs]); - - return ( - - - - - - Build Logs —{" "} - {deployment.id.slice(0, 8)} - - - Duration: - - - - - - {isLoading ? ( -
- Loading logs... -
- ) : logs.length === 0 ? ( -
- No build logs available - for this deployment. -
- ) : ( -
- {logs.map((log, i) => ( -
- - [{log.stage}]-[{fmtLogTs((log as any).timestamp || log.createdAt)}] - - - {log.message} - -
- ))} -
-
- )} - - - ); -} diff --git a/apps/web/src/components/project/deployments/clear-cache-toggle.tsx b/apps/web/src/components/project/deployments/clear-cache-toggle.tsx new file mode 100644 index 0000000..584eee5 --- /dev/null +++ b/apps/web/src/components/project/deployments/clear-cache-toggle.tsx @@ -0,0 +1,37 @@ +interface ClearCacheToggleProps { + checked: boolean; + onChange: (checked: boolean) => void; + id?: string; + label?: string; + description?: string; +} + +export function ClearCacheToggle({ + checked, + onChange, + id = "clearCache", + label = "Clear build cache for this deployment", + description, +}: ClearCacheToggleProps) { + return ( +
+ onChange(e.target.checked)} + className="h-4 w-4 rounded border-zinc-700 bg-zinc-800 text-amber-500 focus:ring-amber-500 focus:ring-offset-zinc-950 cursor-pointer" + /> +
onChange(!checked)}> + + {description && ( + + {description} + + )} +
+
+ ); +} diff --git a/apps/web/src/components/project/deployments/deployment-history.tsx b/apps/web/src/components/project/deployments/deployment-history.tsx new file mode 100644 index 0000000..49afcf0 --- /dev/null +++ b/apps/web/src/components/project/deployments/deployment-history.tsx @@ -0,0 +1,221 @@ +import React, { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "../../ui/dialog"; +import { StatusBadge } from "../../StatusBadge"; +import { Button } from "../../ui/button"; +import { Badge } from "../../ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../ui/table"; +import { RefreshCw, ChevronLeft, ChevronRight, History } from "lucide-react"; +import { cn } from "../../../lib/utils"; +import { formatTimeAgo, depDisplayName, DeploymentDuration, DeploymentLogs } from "./deployment-logs"; + +interface DeploymentHistoryProps { + deployments: any[]; + page: number; + totalPages: number; + totalDeployments: number; + projectName: string | undefined; + onPageChange: (page: number) => void; + onSelect: (id: string | null) => void; + selectedId: string | null; + onRedeploy: (id: string) => void; + onRollback: (id: string) => void; + onCancel: (id: string) => void; + onDelete: (id: string) => void; +} + +export function DeploymentHistory({ + deployments, + page, + totalPages, + totalDeployments, + projectName, + onPageChange, + onSelect, + selectedId, + onRedeploy, + onRollback, + onCancel, + onDelete, +}: DeploymentHistoryProps) { + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [cancelConfirmId, setCancelConfirmId] = useState(null); + + const selectedDeployment = deployments.find((d) => d.id === selectedId); + + return ( + <> +
+ + + + Status + Deployment + Source + Branch + Duration + Age + + + + + {deployments.map((dep) => ( + + onSelect(selectedId === dep.id ? null : dep.id) + } + > + +
+ + {dep.status === "running" && ( + + Active + + )} +
+
+ + {depDisplayName(projectName, dep.id)} + + + {dep.sourceType} + + + {dep.branch ? ( + {dep.branch} + ) : ( + — + )} + + + + + + {formatTimeAgo(dep.createdAt)} + + +
+ {dep.status === "running" && dep.imageTag && dep.sourceType !== "image" && ( + + )} + {(dep.status === "pending" || dep.status === "building") && ( + + )} + {dep.imageTag && dep.status !== "running" && dep.status !== "pending" && dep.status !== "building" && dep.status !== "deploying" && ( + + )} + {dep.status !== "running" && ( + + )} +
+
+
+ ))} +
+
+ {totalPages > 1 && ( +
+ + {totalDeployments} total deployments + +
+ + + {page + 1} / {totalPages} + + +
+
+ )} +
+ + {selectedDeployment && } + + { if (!open) setCancelConfirmId(null); }}> + + + Cancel deployment? + + This will stop the current build/deploy process. The deployment will be marked as failed. This cannot be undone. + + + + + + + + + + { if (!open) setDeleteConfirmId(null); }}> + + + Delete deployment? + + This will remove the deployment record, its build logs, and stop its container. The Docker image is kept for potential rollback. This action cannot be undone. + + + + + + + + + + ); +} diff --git a/apps/web/src/components/project/deployments/deployment-logs.tsx b/apps/web/src/components/project/deployments/deployment-logs.tsx new file mode 100644 index 0000000..1bf9e3e --- /dev/null +++ b/apps/web/src/components/project/deployments/deployment-logs.tsx @@ -0,0 +1,203 @@ +import React, { + useState, + useEffect, + useRef, +} from "react"; +import { useDeploymentLogs } from "../../../hooks/useDeploymentLogs"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "../../ui/card"; +import { Terminal } from "lucide-react"; + +export function formatTimeAgo(dateStr: string) { + const diff = + Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +export function parseTimestamp(raw: string) { + if (!raw) return Date.now(); + const normalized = + raw.includes(" ") && !raw.includes("T") + ? raw.replace(" ", "T") + : raw; + const d = new Date(normalized); + return Number.isNaN(d.getTime()) + ? Date.now() + : d.getTime(); +} + +export function depDisplayName( + projectName: string | undefined, + depId: string, +) { + const short = depId.slice(0, 8); + if (!projectName) return short; + const slug = projectName + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 63); + return `${slug}-${short}`; +} + +export function DeploymentDuration({ + deployment, +}: { + deployment: any; +}) { + const [duration, setDuration] = useState(""); + + useEffect(() => { + const calculate = () => { + const start = parseTimestamp( + deployment.createdAt, + ); + const status = deployment.status; + const isFinished = + status !== "pending" && + status !== "building" && + status !== "deploying"; + const end = isFinished + ? parseTimestamp( + deployment.updatedAt, + ) + : Date.now(); + + const diff = Math.max(0, end - start); + const secs = Math.floor(diff / 1000); + if (secs < 60) { + setDuration(`${secs}s`); + } else { + const mins = Math.floor( + secs / 60, + ); + const remainingSecs = secs % 60; + setDuration( + `${mins}m ${remainingSecs}s`, + ); + } + }; + + calculate(); + + const status = deployment.status; + const isFinished = + status !== "pending" && + status !== "building" && + status !== "deploying"; + if (isFinished) return; + + const interval = setInterval( + calculate, + 1000, + ); + return () => clearInterval(interval); + }, [ + deployment.createdAt, + deployment.updatedAt, + deployment.status, + ]); + + return ( + + {duration} + + ); +} + +function fmtLogTs(raw: string | undefined) { + if (!raw) return ""; + const d = new Date(raw); + if (Number.isNaN(d.getTime())) return raw; + const pad = (n: number) => + String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +export function DeploymentLogs({ + deployment, +}: { + deployment: any; +}) { + const { logs, isLoading } = useDeploymentLogs( + deployment.id, + ); + const endRef = useRef(null); + useEffect(() => { + endRef.current?.scrollIntoView({ + behavior: "smooth", + }); + }, [logs]); + + return ( + + + + + + Build Logs —{" "} + {deployment.id.slice( + 0, + 8, + )} + + + Duration: + + + + + + {isLoading ? ( +
+ Loading logs... +
+ ) : logs.length === 0 ? ( +
+ No build logs available + for this deployment. +
+ ) : ( +
+ {logs.map((log, i) => ( +
+ + [{log.stage} + ]-[ + {fmtLogTs( + ( + log as any + ) + .timestamp || + log.createdAt, + )} + ] + + + {log.message} + +
+ ))} +
+
+ )} + + + ); +} diff --git a/apps/web/src/components/project/deployments/manual-deploy-dialog.tsx b/apps/web/src/components/project/deployments/manual-deploy-dialog.tsx new file mode 100644 index 0000000..aa7ce6f --- /dev/null +++ b/apps/web/src/components/project/deployments/manual-deploy-dialog.tsx @@ -0,0 +1,163 @@ +import React, { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "../../ui/dialog"; +import { Button } from "../../ui/button"; +import { Input } from "../../ui/input"; +import { Rocket } from "lucide-react"; +import { cn } from "../../../lib/utils"; +import { ClearCacheToggle } from "./clear-cache-toggle"; + +interface ManualDeployDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + projectId: string; + projectName: string; + repoUrl: string | null | undefined; + repoBranch: string | null | undefined; + isPending: boolean; + onDeploy: (form: FormData) => Promise; +} + +export function ManualDeployDialog({ + open, + onOpenChange, + projectName, + repoUrl, + repoBranch, + isPending, + onDeploy, +}: ManualDeployDialogProps) { + const [deployOption, setDeployOption] = useState<"latest" | "commit">("latest"); + const [commitSha, setCommitSha] = useState(""); + const [clearCache, setClearCache] = useState(false); + const [error, setError] = useState(null); + + const handleDeploy = async () => { + const form = new FormData(); + form.set("sourceType", "git"); + if (repoUrl) form.set("gitUrl", repoUrl); + if (repoBranch) form.set("branch", repoBranch); + + if (deployOption === "commit") { + if (!commitSha.trim()) return; + form.set("commitSha", commitSha.trim()); + } + + if (clearCache) { + form.set("clearCache", "true"); + } + + try { + await onDeploy(form); + setDeployOption("latest"); + setCommitSha(""); + setClearCache(false); + setError(null); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to start deployment"); + } + }; + + return ( + { if (!v) setError(null); onOpenChange(v); }}> + + + + + Manual Deployment + + + Build and deploy a new version of {projectName}. + + + +
+
+ +
+ + +
+
+ + {deployOption === "commit" && ( +
+ + setCommitSha(e.target.value)} + className="bg-[#121215] border-[#222227] text-zinc-200 focus:border-amber-500 text-xs font-mono h-9" + /> +
+ )} + + +
+ + {error && ( +
+
+ {error} +
+
+ )} + + + + +
+
+ ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 259e045..a1cff50 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -78,6 +78,11 @@ opacity: 0.4; } +.log-line.error { + color: hsl(0 72% 56%); + opacity: 0.7; +} + .log-stage { color: hsl(24 95% 53%); flex-shrink: 0; diff --git a/docker-compose.yml b/docker-compose.yml index a6a5f82..f63cf1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,9 +27,10 @@ services: start_period: 5s api: - image: ghcr.io/lftobs/dequel/api:latest - # build: - # context: ./apps/api + # image: ghcr.io/lftobs/dequel/api:latest + build: + context: . + dockerfile: ./apps/api/Dockerfile environment: PORT: 3001 DATABASE_PATH: /app/data/dequel.db @@ -88,16 +89,22 @@ services: aliases: - pam-auth healthcheck: - test: ["CMD", "python3", "-c", "import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:4567/health').status == 200 else 1)"] + test: + [ + "CMD", + "python3", + "-c", + "import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:4567/health').status == 200 else 1)", + ] interval: 5s timeout: 3s retries: 5 start_period: 10s web: - image: ghcr.io/lftobs/dequel/web:latest - # build: - # context: ./apps/web + # image: ghcr.io/lftobs/dequel/web:latest + build: + context: ./apps/web healthcheck: test: [