diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9912ea0..738efa0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,11 +54,13 @@ jobs: with: context: apps/api push: true + build-args: | + BUILD_TIME=${{ github.sha }} tags: | ${{ env.API_IMAGE }}:${{ steps.version.outputs.VERSION }} ${{ steps.version.outputs.IS_PRERELEASE == 'false' && format('{0}:latest', env.API_IMAGE) || format('{0}:next', env.API_IMAGE) }} - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=api-${{ github.sha }} + cache-to: type=gha,scope=api-${{ github.sha }},mode=max - name: Build and push Web image uses: docker/build-push-action@v6 @@ -67,22 +69,25 @@ jobs: push: true build-args: | DEQUEL_VERSION=${{ steps.version.outputs.VERSION }} + BUILD_TIME=${{ github.sha }} tags: | ${{ env.WEB_IMAGE }}:${{ steps.version.outputs.VERSION }} ${{ steps.version.outputs.IS_PRERELEASE == 'false' && format('{0}:latest', env.WEB_IMAGE) || format('{0}:next', env.WEB_IMAGE) }} - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=web-${{ github.sha }} + cache-to: type=gha,scope=web-${{ github.sha }},mode=max - name: Build and push Agent image uses: docker/build-push-action@v6 with: context: apps/agent push: true + build-args: | + BUILD_TIME=${{ github.sha }} tags: | ${{ env.AGENT_IMAGE }}:${{ steps.version.outputs.VERSION }} ${{ steps.version.outputs.IS_PRERELEASE == 'false' && format('{0}:latest', env.AGENT_IMAGE) || format('{0}:next', env.AGENT_IMAGE) }} - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=agent-${{ github.sha }} + cache-to: type=gha,scope=agent-${{ github.sha }},mode=max - name: Build config tarball run: | diff --git a/.gitignore b/.gitignore index 870d67c..0790b02 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ apps/docs/.blume/ FEATURES.md docs/plans/ .agents/skills/verify-dequel/ +.worktrees/ +artifacts diff --git a/VERSION b/VERSION index 62bdd75..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0-rc.8 +0.3.0 diff --git a/apps/agent/Dockerfile b/apps/agent/Dockerfile index 7be03ac..957c474 100644 --- a/apps/agent/Dockerfile +++ b/apps/agent/Dockerfile @@ -1,5 +1,7 @@ FROM oven/bun:1 +ARG BUILD_TIME=0 + RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git caddy gnupg iproute2 wireguard-tools \ && install -m 0755 -d /etc/apt/keyrings \ && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ diff --git a/apps/agent/package.json b/apps/agent/package.json index 6600f49..da8553e 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -1,6 +1,6 @@ { "name": "dequel-agent", - "version": "0.3.0-rc.8", + "version": "0.3.0", "private": true, "type": "module", "scripts": { diff --git a/apps/agent/src/config.ts b/apps/agent/src/config.ts index 264174c..363c38f 100644 --- a/apps/agent/src/config.ts +++ b/apps/agent/src/config.ts @@ -24,4 +24,4 @@ export const config = { export const requireControlPlaneUrl = () => { if (!config.controlPlaneUrl) throw new Error("DEQUEL_CONTROL_PLANE is required"); return config.controlPlaneUrl; -}; \ No newline at end of file +}; diff --git a/apps/agent/src/executor.ts b/apps/agent/src/executor.ts index ccf478e..89da0a6 100644 --- a/apps/agent/src/executor.ts +++ b/apps/agent/src/executor.ts @@ -417,4 +417,4 @@ const applyRoute = async (payload: RemoteRoutePayload, signal: AbortSignal): Pro return { routeFile: payload.routeFile, status: payload.action === "add" ? "active" : "removed" }; }; -export const validateRoutePayload = validateRoutePayloadImpl; \ No newline at end of file +export const validateRoutePayload = validateRoutePayloadImpl; diff --git a/apps/agent/src/protocol.ts b/apps/agent/src/protocol.ts index 59f14e4..dfad720 100644 --- a/apps/agent/src/protocol.ts +++ b/apps/agent/src/protocol.ts @@ -55,4 +55,4 @@ export const parseP2PResponse = (raw: unknown): P2PResponse | null => { } catch { return null; } -}; \ No newline at end of file +}; diff --git a/apps/agent/src/stats.ts b/apps/agent/src/stats.ts index a3737d0..5f90c07 100644 --- a/apps/agent/src/stats.ts +++ b/apps/agent/src/stats.ts @@ -55,4 +55,4 @@ const parseMemToMb = (mem: string): number => { case "KiB": case "KB": return val / 1024; default: return val; } -}; \ No newline at end of file +}; diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index e5f2833..69d785f 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,5 +1,7 @@ FROM oven/bun:1 +ARG BUILD_TIME=0 + RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ @@ -11,6 +13,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* RUN curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz | tar -xz -C /usr/local/bin --strip-components=1 && \ + mkdir -p /usr/local/lib/docker/cli-plugins && \ + curl -fsSL "https://github.com/docker/compose/releases/download/v2.32.4/docker-compose-linux-x86_64" -o /usr/local/lib/docker/cli-plugins/docker-compose && \ + chmod +x /usr/local/lib/docker/cli-plugins/docker-compose && \ curl -sSL https://railpack.com/install.sh | sh -s -- --bin-dir /usr/local/bin diff --git a/apps/api/package.json b/apps/api/package.json index 12ffc2e..77411ee 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "dequel-api", - "version": "0.3.0-rc.8", + "version": "0.3.0", "private": true, "type": "module", "scripts": { @@ -19,8 +19,10 @@ "yaml": "^2.9.0" }, "devDependencies": { + "@esbuild/linux-x64": "^0.28.2", "@types/nodemailer": "^8.0.0", "@types/pg": "^8.23.1", - "drizzle-kit": "^0.31.10" + "drizzle-kit": "^0.31.10", + "esbuild": "^0.28.2" } } diff --git a/apps/api/src/agents/job-channel.ts b/apps/api/src/agents/job-channel.ts index 1f8c50d..aa7e84d 100644 --- a/apps/api/src/agents/job-channel.ts +++ b/apps/api/src/agents/job-channel.ts @@ -119,4 +119,4 @@ export const processAgentJobUpdate = async (serverId: string, update: Exclude { const job = await leaseNextAgentJob(serverId); return { jobs: job ? [job] : [], cancelJobIds: await listCancelledJobIds(serverId) }; -}; \ No newline at end of file +}; diff --git a/apps/api/src/agents/stats-cache.ts b/apps/api/src/agents/stats-cache.ts index ebe84e2..9574e2d 100644 --- a/apps/api/src/agents/stats-cache.ts +++ b/apps/api/src/agents/stats-cache.ts @@ -48,4 +48,4 @@ class AgentStatsCache { } } -export const agentStatsCache = new AgentStatsCache(); \ No newline at end of file +export const agentStatsCache = new AgentStatsCache(); diff --git a/apps/api/src/api/github/index.ts b/apps/api/src/api/github/index.ts index b031e95..718df5d 100644 --- a/apps/api/src/api/github/index.ts +++ b/apps/api/src/api/github/index.ts @@ -1,33 +1,9 @@ import { Elysia } from "elysia"; -import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { join } from "node:path"; -import { getGithubIntegration, setGithubIntegration, createDeployment, listProjects } from "../../db/repo"; +import { getGithubIntegration, setGithubIntegration, createDeployment, listProjects, getGithubSession, createGithubSession, deleteGithubSession } from "../../db/repo"; import { orchestrator } from "../../orchestrator"; import { config } from "../../utils/config"; import { ok, fail } from "../response"; -const SESSIONS_FILE = join(process.env.DATA_DIR ?? "./data", ".github-sessions.json"); - -let SESSIONS = new Map(); - -const loadSessions = () => { - try { - const raw = readFileSync(SESSIONS_FILE, "utf-8"); - const entries: [string, { token: string }][] = JSON.parse(raw); - SESSIONS = new Map(entries); - } catch {} -}; - -const saveSessions = () => { - try { - const dir = SESSIONS_FILE.substring(0, SESSIONS_FILE.lastIndexOf("/")); - mkdirSync(dir, { recursive: true }); - writeFileSync(SESSIONS_FILE, JSON.stringify([...SESSIONS]), "utf-8"); - } catch {} -}; - -loadSessions(); - const validateToken = async (token: string): Promise => { try { const res = await fetch("https://api.github.com/user", { @@ -43,21 +19,19 @@ const getSession = async (cookie: string | null): Promise => { if (!cookie) return null; const match = cookie.match(/github_session=([^;]+)/); if (!match) return null; - const session = SESSIONS.get(match[1]); - if (!session) return null; - const valid = await validateToken(session.token); + const token = await getGithubSession(match[1]); + if (!token) return null; + const valid = await validateToken(token); if (!valid) { - SESSIONS.delete(match[1]); - saveSessions(); + await deleteGithubSession(match[1]); return null; } - return session.token; + return token; }; -const createSession = (token: string): string => { +const createSession = async (token: string): Promise => { const id = crypto.randomUUID(); - SESSIONS.set(id, { token }); - saveSessions(); + await createGithubSession(id, token); return id; }; @@ -188,7 +162,7 @@ export const githubRoutes = new Elysia({ prefix: "/github" }) set.headers["Location"] = `${origin}/?github=error=${msg}`; return; } - const sessionId = createSession(data.access_token); + const sessionId = await createSession(data.access_token); set.status = 302; set.headers["Set-Cookie"] = `github_session=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=315360000`; set.headers["Location"] = `${origin}/?github=connected`; @@ -324,8 +298,7 @@ export const githubRoutes = new Elysia({ prefix: "/github" }) const cookie = request.headers.get("cookie"); const match = cookie?.match(/github_session=([^;]+)/); if (match) { - SESSIONS.delete(match[1]); - saveSessions(); + await deleteGithubSession(match[1]); } set.headers["Set-Cookie"] = "github_session=; Path=/; Max-Age=0"; return ok(null, "GitHub disconnected"); diff --git a/apps/api/src/db/migrations/0021_add_github_sessions.sql b/apps/api/src/db/migrations/0021_add_github_sessions.sql new file mode 100644 index 0000000..56a5c2a --- /dev/null +++ b/apps/api/src/db/migrations/0021_add_github_sessions.sql @@ -0,0 +1,8 @@ +-- GitHub sessions table with encrypted access tokens +CREATE TABLE IF NOT EXISTS github_sessions ( + id text PRIMARY KEY, + access_token_encrypted text NOT NULL, + access_token_iv text NOT NULL, + access_token_tag text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/apps/api/src/db/repo/github-sessions.ts b/apps/api/src/db/repo/github-sessions.ts new file mode 100644 index 0000000..64e709d --- /dev/null +++ b/apps/api/src/db/repo/github-sessions.ts @@ -0,0 +1,28 @@ +import { eq } from "drizzle-orm"; +import { getDb } from "../db-provider"; +import { githubSessions } from "../schema"; +import { encryptValue, decryptValue } from "../../utils/crypto"; +import { config } from "../../utils/config"; + +export const getGithubSession = async (id: string): Promise => { + const db = await getDb(); + const [row] = await db.select().from(githubSessions).where(eq(githubSessions.id, id)).execute(); + if (!row) return null; + return decryptValue(row.accessTokenEncrypted, row.accessTokenIv, row.accessTokenTag, config.envEncryptionKey); +}; + +export const createGithubSession = async (id: string, accessToken: string): Promise => { + const db = await getDb(); + const enc = encryptValue(accessToken, config.envEncryptionKey); + await db.insert(githubSessions).values({ + id, + accessTokenEncrypted: enc.encrypted, + accessTokenIv: enc.iv, + accessTokenTag: enc.tag, + }).execute(); +}; + +export const deleteGithubSession = async (id: string): Promise => { + const db = await getDb(); + await db.delete(githubSessions).where(eq(githubSessions.id, id)).execute(); +}; diff --git a/apps/api/src/db/repo/index.ts b/apps/api/src/db/repo/index.ts index 693df53..12a09a8 100644 --- a/apps/api/src/db/repo/index.ts +++ b/apps/api/src/db/repo/index.ts @@ -53,10 +53,12 @@ export { createAlert, listAlerts, getAlertById, updateAlertEnabled, deleteAlert export { getGithubIntegration, setGithubIntegration } from "./github"; +export { getGithubSession, createGithubSession, deleteGithubSession } from "./github-sessions"; + export { getSmtpSettings, upsertSmtpSettings } from "./settings"; export type { SmtpSettingsData } from "./settings"; -export { upsertRoute, getRouteByHostname, listRoutes, listIngressRoutes, updateRouteStatus, deleteRouteByHostname, deleteRoute, deleteRoutesByDeployment } from "./routes"; +export { upsertRoute, getRouteByHostname, listRoutes, listIngressRoutes, listRoutesByDeployment, updateRouteStatus, deleteRouteByHostname, deleteRoute, deleteRoutesByDeployment } from "./routes"; export { getPlatformSettings, setIngressServer } from "./platform-settings"; export type { Route } from "../../types"; diff --git a/apps/api/src/db/repo/routes.ts b/apps/api/src/db/repo/routes.ts index d656549..d5951ef 100644 --- a/apps/api/src/db/repo/routes.ts +++ b/apps/api/src/db/repo/routes.ts @@ -142,6 +142,12 @@ export const deleteRoutesByDeployment = async (deploymentId: string): Promise => { + const db = await getDb(); + const rows = await db.select().from(routes).where(eq(routes.deploymentId, deploymentId)).execute(); + return rows.map(mapRoute); +}; + export const deleteRoute = async (id: string): Promise => { const db = await getDb(); await db.delete(routes).where(eq(routes.id, id)).execute(); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 7970481..78f46dd 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1,5 +1,13 @@ import { pgTable, text, integer, real, boolean, serial, jsonb, timestamp, foreignKey, uniqueIndex, index } from "drizzle-orm/pg-core"; +export const githubSessions = pgTable("github_sessions", { + id: text().primaryKey(), + accessTokenEncrypted: text("access_token_encrypted").notNull(), + accessTokenIv: text("access_token_iv").notNull(), + accessTokenTag: text("access_token_tag").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + export const githubIntegrations = pgTable("github_integrations", { id: text().primaryKey(), clientId: text("client_id").notNull(), @@ -68,7 +76,7 @@ export const deploymentLogs = pgTable("deployment_logs", { message: text().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => [ - foreignKey({ columns: [table.deploymentId], foreignColumns: [deployments.id] }), + foreignKey({ columns: [table.deploymentId], foreignColumns: [deployments.id], onDelete: "cascade" }), uniqueIndex("idx_logs_dep_seq").on(table.deploymentId, table.sequence), ]); diff --git a/apps/api/src/executors/agent.ts b/apps/api/src/executors/agent.ts index 2d38968..c19487c 100644 --- a/apps/api/src/executors/agent.ts +++ b/apps/api/src/executors/agent.ts @@ -100,4 +100,4 @@ export const agentExecutor: DeploymentExecutor = { await updateDeploymentStatus(deployment.id, "failed", { failureReason: "Cancelled" }); await appendLog(deployment.id, "system", "Deployment cancelled by user"); }, -}; \ No newline at end of file +}; diff --git a/apps/api/src/executors/ssh-compose-script.ts b/apps/api/src/executors/ssh-compose-script.ts new file mode 100644 index 0000000..69457c2 --- /dev/null +++ b/apps/api/src/executors/ssh-compose-script.ts @@ -0,0 +1,125 @@ +export interface RemoteComposeScriptInput { + deploymentId: string; + workspaceRoot: string; + gitUrl: string; + branch?: string | null; + commitSha?: string | null; + projectName: string; + dockerNetwork: string; + environmentVariables: { key: string; value: string }[]; + sourceDir?: string | null; +} + +const sh = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`; + +export const buildRemoteComposeScript = (input: RemoteComposeScriptInput): string => { + const checkoutSha = input.commitSha + ? `git fetch --depth 1 origin ${sh(input.commitSha)} && git checkout ${sh(input.commitSha)}` + : input.branch + ? `git checkout ${sh(input.branch)}` + : "true"; + + const remoteWorkspace = input.workspaceRoot.startsWith("/app") + ? "$HOME/.dequel/workspace" + : input.workspaceRoot; + + const envLines = input.environmentVariables + .map((v) => `${v.key}=${v.value}`) + .join("\n"); + + const sourceDirLine = input.sourceDir ? `cd ${sh(input.sourceDir)}` : ""; + + return [ + "set -euo pipefail", + "", + `WORKSPACE="${remoteWorkspace}"`, + `DEPLOY_DIR="$WORKSPACE/${input.deploymentId}"`, + 'echo "[compose] Preparing workspace"', + 'rm -rf "$DEPLOY_DIR"', + 'mkdir -p "$DEPLOY_DIR"', + 'cd "$DEPLOY_DIR"', + "", + 'if ! command -v docker >/dev/null 2>&1; then', + ' echo "[compose] ERROR: docker is not installed"', + " exit 1", + "fi", + 'if ! docker compose version >/dev/null 2>&1; then', + ' echo "[compose] ERROR: docker compose plugin is not installed"', + " exit 1", + "fi", + "", + `echo "[compose] Cloning repository ${input.gitUrl}"`, + `git clone --depth 1 ${sh(input.gitUrl)} .`, + checkoutSha, + "", + sourceDirLine, + "", + ...(envLines + ? [ + 'cat > .env << \'ENVEOF\'', + envLines, + "ENVEOF", + "", + ] + : []), + 'COMPOSE_FILE=""', + 'for f in docker-compose.yml docker-compose.yaml compose.yml compose.yaml; do', + ' if [ -f "$f" ]; then COMPOSE_FILE="$f"; break; fi', + "done", + 'if [ -z "$COMPOSE_FILE" ]; then echo "[compose] ERROR: no compose file found"; exit 1; fi', + "", + `echo "[compose] Building and starting stack (project: deploy-${input.deploymentId})"`, + `docker compose -f "$COMPOSE_FILE" -p deploy-${input.deploymentId} build`, + `docker compose -f "$COMPOSE_FILE" -p deploy-${input.deploymentId} up -d`, + "", + 'echo "[compose] Connecting containers to network ' + input.dockerNetwork + '"', + `for cid in $(docker compose -p deploy-${input.deploymentId} ps -q); do`, + ` docker network connect ${input.dockerNetwork} "$cid" || true`, + "done", + "", + 'echo "[compose] Collecting container names"', + `docker compose -p deploy-${input.deploymentId} ps --format '{{.Service}}|{{.Name}}|{{.Ports}}'`, + "", + `echo "DONE:deploy-${input.deploymentId}"`, + ].filter((line) => line !== undefined).join("\n"); +}; + +export interface RemoteComposeResult { + projectName: string; + containers: Record; + ports: Record; +} + +export const parseRemoteComposeResult = (stdout: string): RemoteComposeResult | null => { + const doneLine = stdout.split("\n").reverse().find((l) => l.startsWith("DONE:")); + if (!doneLine) return null; + + const projectName = doneLine.slice("DONE:".length).trim(); + + const containers: Record = {}; + const ports: Record = {}; + for (const line of stdout.split("\n")) { + const parts = line.split("|"); + if (parts.length >= 2 && parts[0] && parts[1]) { + const svcName = parts[0].trim(); + containers[svcName] = parts[1].trim(); + if (parts[2]) { + const portMatch = parts[2].trim().match(/:(\d+)->(\d+)/); + if (portMatch) { + ports[svcName] = Number(portMatch[2]); + } + } + } + } + + return { projectName, containers, ports }; +}; + +export const buildRemoteComposeDestroyScript = (projectName: string): string => { + return [ + "set -euo pipefail", + `echo "[compose] Destroying stack ${projectName}"`, + `docker compose -p ${sh(projectName)} down -v --remove-orphans`, + `echo "[compose] Stack ${projectName} destroyed"`, + ].join("\n"); +}; diff --git a/apps/api/src/executors/ssh.ts b/apps/api/src/executors/ssh.ts index 5d097af..7472c03 100644 --- a/apps/api/src/executors/ssh.ts +++ b/apps/api/src/executors/ssh.ts @@ -1,6 +1,7 @@ import { config } from "../utils/config"; -import { removeRemoteCaddyRoute, runRemoteScript } from "../utils/ssh"; +import { removeRemoteCaddyRoute, runRemoteScript, syncRemoteCaddyRoute } from "../utils/ssh"; import { buildRemoteDeployScript, parseRemoteBuildResult } from "./ssh-build-script"; +import { buildRemoteComposeScript, parseRemoteComposeResult, buildRemoteComposeDestroyScript } from "./ssh-compose-script"; import { emitLog } from "./logging"; import { summarizeDeploymentError } from "../orchestrator/deployment-errors"; import type { Deployment, Project, Server } from "../types"; @@ -70,6 +71,172 @@ const deployFromImage = async ( return runtime; }; +const deployComposeRemote = async ( + deployment: Deployment, + project: Project, + server: Server, +) => { + const { listEnvironmentVariablesForDeploy, listDeployments, updateDeploymentStatus } = await getRepo(); + await updateDeploymentStatus(deployment.id, "building", { failureReason: null }); + await emitLog(deployment.id, "system", `Deploying compose stack on server ${server.name} over SSH`); + + const envVars = await listEnvironmentVariablesForDeploy(project.id, deployment.environment ?? undefined); + + const script = buildRemoteComposeScript({ + deploymentId: deployment.id, + workspaceRoot: config.workspaceRoot, + gitUrl: deployment.sourceRef, + branch: deployment.branch, + commitSha: deployment.commitSha, + projectName: project.name, + dockerNetwork: config.dockerNetwork, + environmentVariables: envVars, + sourceDir: project.sourceDir, + }); + + const result = await runRemoteScript(server, script, { + onLog: async (line) => { await emitLog(deployment.id, "build", line); }, + }); + if (result.code !== 0) throw new Error(result.stderr || result.stdout || "Remote compose build failed"); + + const composeResult = parseRemoteComposeResult(result.stdout); + if (!composeResult) throw new Error("Remote compose completed without a result marker"); + + await updateDeploymentStatus(deployment.id, "deploying"); + await emitLog(deployment.id, "system", "Compose stack started — configuring routes"); + + const all = await listDeployments(project.id); + const current = all.find((d) => d.status === "running" && d.id !== deployment.id); + + const slug = slugify(project.name); + const primaryServiceName = project.composeService || Object.keys(composeResult.containers)[0]; + const primaryContainer = composeResult.containers[primaryServiceName] || `deploy-${deployment.id}-${primaryServiceName}-1`; + + let customMappings: { serviceName: string; port: number | string; subdomain?: string }[] = []; + if (project.composeServices) { + if (typeof project.composeServices === "string") { + try { customMappings = JSON.parse(project.composeServices); } catch {} + } else if (Array.isArray(project.composeServices)) { + customMappings = project.composeServices; + } + } + + const webServices: { name: string; container: string; port: number }[] = []; + for (const [svcName, svcContainer] of Object.entries(composeResult.containers)) { + const mapping = customMappings.find((c) => c.serviceName === svcName); + if (mapping) { + webServices.push({ name: svcName, container: svcContainer, port: Number(mapping.port) || 3000 }); + } else if (svcName === primaryServiceName) { + webServices.push({ name: svcName, container: svcContainer, port: project.composePort || composeResult.ports[svcName] || 3000 }); + } else { + webServices.push({ name: svcName, container: svcContainer, port: composeResult.ports[svcName] || 3000 }); + } + } + + const { buildCaddySnippet } = await import("../utils/domain-verifier"); + const { shouldRouteViaIngress, syncIngressRoute, upsertIngressRoute } = await import("../utils/ingress"); + const { baseDomainFor } = await import("../utils/routes"); + const { getIngressServer } = await import("../utils/ingress"); + const { upsertRoute } = await import("../db/repo"); + + const ingressServer = await getIngressServer(); + const viaIngress = shouldRouteViaIngress(server, ingressServer); + const primary = webServices.find((s) => s.name === primaryServiceName) || webServices[0]; + + let snippet = await buildCaddySnippet(slug, primary.container, project.id, undefined, primary.port); + + const rawBaseDomain = config.caddyBaseDomain || "localhost"; + const baseDomainForCaddy = rawBaseDomain === "localhost" ? `${rawBaseDomain}:80` : rawBaseDomain; + + if (!snippet.trim() || snippet.trim().startsWith(':')) { + const fallbackDomain = `${slug}.${rawBaseDomain === 'localhost' ? 'localhost' : rawBaseDomain}`; + const domain = rawBaseDomain === 'localhost' ? `${fallbackDomain}:80` : fallbackDomain; + snippet = `${domain} {\n log {\n output stdout\n format json\n }\n reverse_proxy ${primary.container}:${primary.port} {\n header_up Host {upstream_hostport}\n }\n}\n`; + } + const { DB_SERVICE_NAMES } = await import("../utils/compose-ingress"); + for (const svc of webServices) { + if (svc.name === primaryServiceName) continue; + if (DB_SERVICE_NAMES.has(svc.name)) continue; + const customMatch = customMappings.find((c) => c.serviceName === svc.name); + const domains: string[] = []; + if (customMatch?.subdomain?.trim()) { + domains.push(`${customMatch.subdomain.trim()}.${slug}.${baseDomainForCaddy}`); + } else { + domains.push(`${svc.name}.${slug}.${baseDomainForCaddy}`); + if (svc.name === "server" && !domains.includes(`api.${slug}.${baseDomainForCaddy}`)) { + domains.push(`api.${slug}.${baseDomainForCaddy}`); + } + } + snippet += `\n${domains.join(", ")} {\n log {\n output stdout\n format json\n }\n reverse_proxy ${svc.container}:${svc.port} {\n header_up Host {upstream_hostport}\n }\n}\n`; + } + + const hostname = `${slug}.${baseDomainFor()}`; + const primaryPort = primary.port; + const allContainerNames = webServices.map((s) => s.container); + + let effectiveSnippet: string; + if (viaIngress) { + const blockRegex = /^([^\n]+?)\s*\{\n([\s\S]*?)\n\}\s*$/gm; + effectiveSnippet = snippet.replace(blockRegex, (_match, domainLine: string, body: string) => { + const domains = domainLine.split(",").map((d: string) => d.trim()); + const portedDomains = domains.map((d: string) => { + const stripped = d.replace(/:\d+$/, ""); + return `${stripped}:80`; + }); + return `${portedDomains.join(", ")} {\n${body}\n}`; + }); + } else { + effectiveSnippet = snippet; + } + + if (server.mode === "ssh" || server.mode === "docker_tcp") { + await syncRemoteCaddyRoute(server, `${slug}.caddy`, effectiveSnippet); + await upsertRoute({ + serverId: server.id, + deploymentId: deployment.id, + projectId: project.id, + hostname, + routeFile: `${slug}.caddy`, + port: primaryPort, + targetContainers: allContainerNames, + status: "active", + }); + } + + if (viaIngress && ingressServer) { + const { computeComposeIngressHostnames, syncComposeIngressRoutes } = await import("../utils/compose-ingress"); + const allHostnames = computeComposeIngressHostnames( + webServices.map((s) => ({ name: s.name, port: s.port })), + primaryServiceName, + slug, + baseDomainFor(), + customMappings, + ); + await emitLog( + deployment.id, + "system", + `Registering ${allHostnames.length} ingress route(s) on ${ingressServer.name}: ${allHostnames.map((h) => h.hostname).join(", ")}`, + ); + await syncComposeIngressRoutes(ingressServer, server, deployment, project, allHostnames); + } + + const scheme = rawBaseDomain === "localhost" ? "http" : "https"; + const liveUrl = `${scheme}://${hostname}`; + + await updateDeploymentStatus(deployment.id, "running", { + containerName: composeResult.projectName, + liveUrl, + }); + await emitLog(deployment.id, "system", `Deployment is running at ${liveUrl}`); + + if (current) { + await updateDeploymentStatus(current.id, "inactive", { + failureReason: `Superseded by deployment ${deployment.id.slice(0, 8)}`, + }); + await emitLog(current.id, "system", `Marked inactive (superseded by ${deployment.id.slice(0, 8)})`); + } +}; + const markFailed = async (deploymentId: string, error: unknown) => { const { updateDeploymentStatus } = await getRepo(); const message = summarizeDeploymentError(error); @@ -84,6 +251,11 @@ export const sshExecutor: DeploymentExecutor = { if (deployment.sourceType !== "git") throw new Error("SSH mode currently supports Git deployments only"); if (!project) throw new Error("Deployment requires a project"); + if (project.buildType === "compose") { + await deployComposeRemote(deployment, project, server); + return; + } + const { listEnvironmentVariablesForDeploy, listDeployments, updateDeploymentStatus } = await getRepo(); await updateDeploymentStatus(deployment.id, "building", { failureReason: null }); await emitLog(deployment.id, "system", `Deploying on server ${server.name} over SSH (build runs on the target machine)`); @@ -128,6 +300,9 @@ export const sshExecutor: DeploymentExecutor = { }, async rollback({ deployment, project, server, imageTag }: ExecutorRollbackInput) { + if (project?.buildType === "compose") { + throw new Error("Rollback is not supported for Docker Compose deployments. Redeploy with the desired commit instead."); + } const { getProjectById, listDeployments, updateDeploymentStatus } = await getRepo(); await updateDeploymentStatus(deployment.id, "deploying"); await emitLog(deployment.id, "system", `Rolling back to this version (image: ${imageTag})`); @@ -152,7 +327,13 @@ export const sshExecutor: DeploymentExecutor = { async destroy({ deployment, project, server }) { const { deleteDeploymentAndLogs, deleteRoutesByDeployment } = await getRepo(); const { tryRun } = await getRuntime(); - if (deployment.containerName) { + + if (project?.buildType === "compose" && deployment.containerName) { + const script = buildRemoteComposeDestroyScript(deployment.containerName); + await runRemoteScript(server, script, { + onLog: async (line) => { await emitLog(deployment.id, "system", line); }, + }); + } else if (deployment.containerName) { await tryRun("docker", ["stop", "-t", "5", deployment.containerName], server); await tryRun("docker", ["rm", "-f", deployment.containerName], server); } @@ -164,8 +345,19 @@ export const sshExecutor: DeploymentExecutor = { const { getIngressServer, removeIngressRouteFile } = await import("../utils/ingress"); const ingressServer = await getIngressServer(); if (ingressServer && ingressServer.id !== server.id) { - const { baseDomainFor } = await import("../utils/routes"); - await removeIngressRouteFile(ingressServer, { hostname: `${slug}.${baseDomainFor()}`, routeFile: `${slug}.caddy` }); + const { listRoutesByDeployment } = await getRepo(); + const deploymentRoutes = await listRoutesByDeployment(deployment.id); + for (const route of deploymentRoutes) { + if (route.routeFile !== `${slug}.caddy`) { + await removeRemoteCaddyRoute(server, route.routeFile); + } + if (route.upstreamHost) { + await removeIngressRouteFile(ingressServer, { + hostname: route.hostname, + routeFile: route.routeFile, + }); + } + } } await deleteRoutesByDeployment(deployment.id); await deleteDeploymentAndLogs(deployment.id); @@ -177,4 +369,4 @@ export const sshExecutor: DeploymentExecutor = { await updateDeploymentStatus(deployment.id, "failed", { failureReason: "Cancelled" }); await emitLog(deployment.id, "system", "Deployment cancelled by user (remote build may continue on the server)"); }, -}; \ No newline at end of file +}; diff --git a/apps/api/src/utils/compose-ingress.ts b/apps/api/src/utils/compose-ingress.ts new file mode 100644 index 0000000..c4cbfec --- /dev/null +++ b/apps/api/src/utils/compose-ingress.ts @@ -0,0 +1,102 @@ +import { syncIngressRoute, upsertIngressRoute, removeIngressRouteFile, type IngressRouteInfo } from "./ingress"; + +export interface ComposeIngressHostname { + hostname: string; + routeFile: string; + isPrimary: boolean; +} + +export const DB_SERVICE_NAMES = new Set(["db", "postgres", "mysql", "redis", "mongo", "database"]); + +export const computeComposeIngressHostnames = ( + webServices: { name: string; port: number }[], + primaryServiceName: string, + slug: string, + baseDomain: string, + customMappings: { serviceName: string; subdomain?: string }[], +): ComposeIngressHostname[] => { + const hostnames: ComposeIngressHostname[] = []; + + hostnames.push({ + hostname: `${slug}.${baseDomain}`, + routeFile: `${slug}.caddy`, + isPrimary: true, + }); + + const seen = new Set(); + + for (const svc of webServices) { + if (svc.name === primaryServiceName) continue; + + const customMatch = customMappings.find((c) => c.serviceName === svc.name); + if (!customMatch && DB_SERVICE_NAMES.has(svc.name)) continue; + + const subdomainPrefix = customMatch?.subdomain?.trim() || svc.name; + + const primaryHostname = `${subdomainPrefix}.${slug}.${baseDomain}`; + if (!seen.has(primaryHostname)) { + seen.add(primaryHostname); + hostnames.push({ + hostname: primaryHostname, + routeFile: `${subdomainPrefix}-${slug}.caddy`, + isPrimary: false, + }); + } + + if (svc.name === "server" && !customMatch?.subdomain?.trim()) { + const apiHostname = `api.${slug}.${baseDomain}`; + if (!seen.has(apiHostname)) { + seen.add(apiHostname); + hostnames.push({ + hostname: apiHostname, + routeFile: `api-${slug}.caddy`, + isPrimary: false, + }); + } + } + } + + return hostnames; +}; + +export const syncComposeIngressRoutes = async ( + ingressServer: { id: string; mode: string }, + workerServer: { id: string; host: string; mode: string }, + deployment: { id: string; projectId: string | null }, + project: { id: string | null }, + hostnames: ComposeIngressHostname[], +): Promise => { + for (const entry of hostnames) { + const routeInfo: IngressRouteInfo = { + hostname: entry.hostname, + routeFile: entry.routeFile, + port: 80, + containers: [], + }; + + await syncIngressRoute(ingressServer, workerServer.host, routeInfo); + await upsertIngressRoute( + ingressServer.id, + project.id, + deployment.id, + workerServer.host, + routeInfo, + ); + } +}; + +export const removeComposeIngressRoutes = async ( + ingressServer: { id: string; mode: string }, + deploymentId: string, +): Promise => { + const { listRoutesByDeployment } = await import("../db/repo"); + const depRoutes = await listRoutesByDeployment(deploymentId); + for (const route of depRoutes) { + if (route.upstreamHost) { + await removeIngressRouteFile(ingressServer, { + hostname: route.hostname, + routeFile: route.routeFile, + }); + } + } +}; diff --git a/apps/api/src/utils/domain-verifier.ts b/apps/api/src/utils/domain-verifier.ts index dfb035f..c84d058 100644 --- a/apps/api/src/utils/domain-verifier.ts +++ b/apps/api/src/utils/domain-verifier.ts @@ -209,6 +209,22 @@ export const buildCaddySnippet = async ( } } + if (defaultDomains.length === 0 || defaultDomains.every(d => !d.trim())) { + defaultDomains = [`${slug}.${baseDomain}`]; + } + + const allDomains = [...defaultDomains, ...customBlocks.flatMap((b) => { + const m = b.match(/^([^\n{]+?)\s*\{/); + return m ? m[1].split(',').map((d) => d.trim()) : []; + })]; + for (const d of allDomains) { + if (/^:\d+$/.test(d)) { + console.error(`buildCaddySnippet: rejected bare catch-all domain "${d}" — falling back to ${slug}.${baseDomain}`); + defaultDomains = [`${slug}.${baseDomain}`]; + break; + } + } + const primaryBlock = `${defaultDomains.join(', ')} {\n log {\n output stdout\n format json\n }\n reverse_proxy ${containerName}:${port} {\n header_up Host {upstream_hostport}\n }\n}\n`; return [primaryBlock, ...customBlocks].join('\n'); diff --git a/apps/api/src/utils/ssh.ts b/apps/api/src/utils/ssh.ts index 96b5015..c36b735 100644 --- a/apps/api/src/utils/ssh.ts +++ b/apps/api/src/utils/ssh.ts @@ -120,6 +120,20 @@ export const syncRemoteCaddyRoute = ( resolve(false); return; } + + const blockPattern = /^([^\n{]+?)\s*\{/gm; + let match: RegExpExecArray | null; + while ((match = blockPattern.exec(content)) !== null) { + const domainLine = match[1].trim(); + const domains = domainLine.split(',').map((d) => d.trim()); + for (const d of domains) { + if (/^:\d+$/.test(d)) { + console.error(`Rejected catch-all Caddy route in ${filename}: "${d}" — must include a hostname`); + resolve(false); + return; + } + } + } const keyPath = ensureSshKey(server); const keyArgs = keyPath ? ["-i", keyPath, "-o", "IdentitiesOnly=yes"] : []; const user = server.sshUser || "root"; diff --git a/apps/api/src/utils/validate.ts b/apps/api/src/utils/validate.ts index 6d7ee93..c35bce7 100644 --- a/apps/api/src/utils/validate.ts +++ b/apps/api/src/utils/validate.ts @@ -33,7 +33,7 @@ export const validateComposeServiceMapping = (mapping: unknown): string | null = if (typeof m.serviceName !== "string" || !SERVICE_NAME_RE.test(m.serviceName)) { return "serviceName may only contain letters, numbers, underscores and hyphens"; } - if (m.subdomain !== undefined && m.subdomain !== null) { + if (m.subdomain !== undefined && m.subdomain !== null && m.subdomain !== "") { if (typeof m.subdomain !== "string" || !SUBDOMAIN_RE.test(m.subdomain)) { return "subdomain must be a valid hostname label (lowercase letters, numbers, hyphens)"; } diff --git a/apps/docs/package.json b/apps/docs/package.json index ab35072..d5066f1 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -1,7 +1,7 @@ { "name": "dequel-docs", "type": "module", - "version": "0.3.0-rc.8", + "version": "0.3.0", "scripts": { "dev": "astro dev", "start": "astro dev", diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 5459ced..007fe90 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,6 +1,7 @@ FROM oven/bun:1 AS build ARG DEQUEL_VERSION +ARG BUILD_TIME=0 ENV DEQUEL_VERSION=$DEQUEL_VERSION WORKDIR /app diff --git a/apps/web/package.json b/apps/web/package.json index a7dc44e..f74681c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "dequel-web", - "version": "0.3.0-rc.8", + "version": "0.3.0", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/github/RepoPicker.tsx b/apps/web/src/components/github/RepoPicker.tsx index 277f19f..3c08602 100644 --- a/apps/web/src/components/github/RepoPicker.tsx +++ b/apps/web/src/components/github/RepoPicker.tsx @@ -22,12 +22,14 @@ interface RepoPickerProps { onSelect: (repo: GithubRepo) => void; selected: GithubRepo | null; onDisconnect: () => void; + onConnect?: () => void; } export function RepoPicker({ onSelect, selected, onDisconnect, + onConnect, }: RepoPickerProps) { const [repos, setRepos] = useState< GithubRepo[] @@ -204,15 +206,28 @@ export function RepoPicker({

{error}

- +
+ {onConnect && ( + + )} + +
) : filtered.length === 0 ? (
diff --git a/apps/web/src/components/project/create/SourceSelectionSection.tsx b/apps/web/src/components/project/create/SourceSelectionSection.tsx index cb3dc32..0eff1a3 100644 --- a/apps/web/src/components/project/create/SourceSelectionSection.tsx +++ b/apps/web/src/components/project/create/SourceSelectionSection.tsx @@ -150,6 +150,7 @@ export function SourceSelectionSection({ selected={selectedRepo} onSelect={onSelectRepo} onDisconnect={onDisconnectGithub} + onConnect={onConnectGithub} /> ) : (
diff --git a/apps/web/src/components/settings/ApiKeysSection.tsx b/apps/web/src/components/settings/ApiKeysSection.tsx new file mode 100644 index 0000000..1cefaca --- /dev/null +++ b/apps/web/src/components/settings/ApiKeysSection.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"; +import { Trash2, Key } from "lucide-react"; +import * as api from "../../api/client"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../ui/dialog"; + +export function ApiKeysSection() { + const { data: apiKeys = [], refetch } = useQuery({ + queryKey: ["api-keys"], + queryFn: () => api.listApiKeys().catch(() => []), + }); + const [name, setName] = useState(""); + const [newKey, setNewKey] = useState(""); + const [deletingKeyId, setDeletingKeyId] = useState(null); + + const handleDeleteKey = async () => { + if (!deletingKeyId) return; + await api.deleteApiKey(deletingKeyId); + setDeletingKeyId(null); + refetch(); + }; + + const add = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + const result = await api.createApiKey({ name: name.trim() }); + setNewKey(result.rawKey || ""); + setName(""); + refetch(); + }; + + return ( + + +
+ + API Keys +
+
+ + {newKey && ( +
+ API Key created — copy it now: + {newKey} +
+ )} +
+
+ + setName(e.target.value)} className="w-56" /> +
+ +
+ {apiKeys.length > 0 && ( +
+ + + NameKey HashCreated + + + {apiKeys.map((k) => ( + + {k.name} + {k.keyHash?.slice(0, 12)}... + {new Date(k.createdAt).toLocaleDateString()} + + + + + ))} + +
+
+ )} +
+ + { if (!open) setDeletingKeyId(null); }}> + + + Delete API Key + + Are you sure you want to delete this API key? Any services using it will lose access immediately. + + + + + + + + +
+ ); +} diff --git a/apps/web/src/components/settings/DeleteProjectsSection.tsx b/apps/web/src/components/settings/DeleteProjectsSection.tsx new file mode 100644 index 0000000..1c6322f --- /dev/null +++ b/apps/web/src/components/settings/DeleteProjectsSection.tsx @@ -0,0 +1,251 @@ +import { useState, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Badge } from "../ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../ui/dialog"; +import { Trash2, AlertTriangle, Search, FolderX, Globe, GitBranch } from "lucide-react"; +import * as api from "../../api/client"; +import type { Project } from "../../types"; + +export function DeleteProjectsSection() { + const queryClient = useQueryClient(); + const { data: projects = [], isLoading, refetch } = useQuery({ + queryKey: ["projects"], + queryFn: () => api.listProjects().catch(() => []), + }); + + const [searchQuery, setSearchQuery] = useState(""); + const [deletingProject, setDeletingProject] = useState(null); + const [confirmNameInput, setConfirmNameInput] = useState(""); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const [deleteSuccess, setDeleteSuccess] = useState(null); + + const filteredProjects = useMemo(() => { + if (!searchQuery.trim()) return projects; + const q = searchQuery.toLowerCase(); + return projects.filter( + (p) => + p.name.toLowerCase().includes(q) || + (p.repoUrl && p.repoUrl.toLowerCase().includes(q)) || + (p.baseDomain && p.baseDomain.toLowerCase().includes(q)) + ); + }, [projects, searchQuery]); + + const handleDelete = async () => { + if (!deletingProject) return; + setIsDeleting(true); + setDeleteError(null); + try { + await api.deleteProject(deletingProject.id); + const deletedName = deletingProject.name; + setDeletingProject(null); + setConfirmNameInput(""); + setDeleteSuccess(`Project "${deletedName}" and all associated resources have been deleted.`); + queryClient.invalidateQueries({ queryKey: ["projects"] }); + refetch(); + setTimeout(() => setDeleteSuccess(null), 5000); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : "Failed to delete project"); + } finally { + setIsDeleting(false); + } + }; + + const openDeleteModal = (project: Project) => { + setDeletingProject(project); + setConfirmNameInput(""); + setDeleteError(null); + }; + + return ( + + +
+
+
+ +
+
+ Delete Projects +

+ Permanently remove projects, containers, volumes, databases, routes, and custom domains. +

+
+
+ {projects.length > 3 && ( +
+ + setSearchQuery(e.target.value)} + className="h-8 pl-8 text-xs bg-background/50 border-border" + /> +
+ )} +
+
+ + + {deleteSuccess && ( +
+ + {deleteSuccess} +
+ )} + + {isLoading ? ( +
+ Loading projects... +
+ ) : projects.length === 0 ? ( +
+ +

No projects found on this platform.

+
+ ) : filteredProjects.length === 0 ? ( +
+ No projects matching “{searchQuery}”. +
+ ) : ( +
+ + + + Project + Type / Source + Branch / Domain + Created + + + + + {filteredProjects.map((p) => ( + + +
+ {p.name} + {p.description && ( +

+ {p.description} +

+ )} +
+
+ +
+ + {p.projectType || "web"} + + {p.repoUrl ? ( + + {p.repoUrl.replace(/https?:\/\/github\.com\//, "")} + + ) : ( + Upload / Archive + )} +
+
+ +
+ {p.repoBranch && ( +
+ + {p.repoBranch} +
+ )} + {p.baseDomain && ( +
+ + {p.baseDomain} +
+ )} + {!p.repoBranch && !p.baseDomain && ( + + )} +
+
+ + {p.createdAt ? new Date(p.createdAt).toLocaleDateString() : "—"} + + + + +
+ ))} +
+
+
+ )} +
+ + { if (!open) setDeletingProject(null); }}> + + +
+ +
+ + Delete Project “{deletingProject?.name}”? + + + This action is permanent and irreversible. Deleting this project will stop and remove all associated Docker containers, volumes, database instances, custom routes, and deployment history. + +
+ +
+
+ + setConfirmNameInput(e.target.value)} + className="h-9 text-xs bg-background/60 border-border font-medium" + autoFocus + /> +
+ + {deleteError && ( +

+ {deleteError} +

+ )} +
+ + + + + +
+
+
+ ); +} diff --git a/apps/web/src/components/settings/GithubIntegrationSection.tsx b/apps/web/src/components/settings/GithubIntegrationSection.tsx new file mode 100644 index 0000000..64a1e38 --- /dev/null +++ b/apps/web/src/components/settings/GithubIntegrationSection.tsx @@ -0,0 +1,86 @@ +import { useState, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import * as api from "../../api/client"; + +export function GithubIntegrationSection() { + const { data, refetch } = useQuery({ + queryKey: ["github-integration"], + queryFn: () => api.getGithubIntegration(), + }); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [appName, setAppName] = useState(""); + const [webhookSecret, setWebhookSecret] = useState(""); + const [saveResult, setSaveResult] = useState(null); + + useEffect(() => { + if (data?.configured) { + setClientId(data.clientId || ""); + setAppName(data.appName || ""); + } + }, [data]); + + const save = async (e: React.FormEvent) => { + e.preventDefault(); + setSaveResult(null); + try { + await api.setGithubIntegration({ + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + appName: appName.trim() || undefined, + webhookSecret: webhookSecret.trim() || undefined, + }); + setClientSecret(""); + setWebhookSecret(""); + refetch(); + setSaveResult("Settings saved"); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + setSaveResult("error: " + message); + } + }; + + const icon = ( + + + + ); + + return ( + + +
{icon}GitHub Integration
+
+ +
+
+ + setClientId(e.target.value)} className="w-56" /> +
+
+ + setClientSecret(e.target.value)} className="w-64" /> +
+
+ + setAppName(e.target.value)} className="w-36" /> +
+
+ + setWebhookSecret(e.target.value)} className="w-48" /> +
+ +
+ {!data?.configured && ( +

GitHub is not configured. Add your OAuth App credentials to enable the repo picker.

+ )} + {saveResult && ( +

{saveResult}

+ )} +
+
+ ); +} diff --git a/apps/web/src/components/settings/ServersSection.tsx b/apps/web/src/components/settings/ServersSection.tsx new file mode 100644 index 0000000..bf0ff66 --- /dev/null +++ b/apps/web/src/components/settings/ServersSection.tsx @@ -0,0 +1,253 @@ +import { useState, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"; +import { StatusBadge } from "../StatusBadge"; +import { Trash2, Server, Copy } from "lucide-react"; +import * as api from "../../api/client"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../ui/dialog"; + +export function ServersSection() { + const { data: servers = [], refetch } = useQuery({ + queryKey: ["servers"], + queryFn: () => api.listServers().catch(() => []), + }); + const [name, setName] = useState(""); + const [host, setHost] = useState(""); + const [port, setPort] = useState("22"); + const [sshUser, setSshUser] = useState("root"); + const [sshKey, setSshKey] = useState(""); + const [agentName, setAgentName] = useState(""); + const [registrationCommand, setRegistrationCommand] = useState(""); + const [registrationError, setRegistrationError] = useState(""); + + const [deletingServerId, setDeletingServerId] = useState(null); + const [preparingId, setPreparingId] = useState(null); + const [prepareLogs, setPrepareLogs] = useState<{ stage: string; message: string }[]>([]); + const [prepareDone, setPrepareDone] = useState(false); + const [prepareError, setPrepareError] = useState(null); + + const handlePrepare = async (serverId: string) => { + setPreparingId(serverId); + setPrepareLogs([]); + setPrepareDone(false); + setPrepareError(null); + try { + await api.prepareServer(serverId); + } catch (err) { + setPrepareError(err instanceof Error ? err.message : "Could not start preparation"); + } + }; + + useEffect(() => { + if (!preparingId) return; + const source = new EventSource(api.serverPrepareStreamUrl(preparingId)); + source.addEventListener("log", (e) => { + try { + const event = JSON.parse((e as MessageEvent).data); + setPrepareLogs((prev) => [...prev, { stage: event.stage, message: event.message }]); + } catch {} + }); + source.addEventListener("done", (e) => { + try { + const event = JSON.parse((e as MessageEvent).data); + setPrepareDone(true); + setPrepareError(event.ok ? null : (event.error || "Preparation failed")); + setPreparingId(null); + refetch(); + } catch {} + }); + source.addEventListener("error", () => { + setPrepareDone(true); + setPreparingId(null); + }); + return () => source.close(); + }, [preparingId, refetch]); + + const handleDeleteServer = async () => { + if (!deletingServerId) return; + await api.deleteServer(deletingServerId); + setDeletingServerId(null); + refetch(); + }; + + const addSshServer = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !host.trim()) return; + await api.createServer({ + name: name.trim(), + host: host.trim(), + port: Number(port) || 22, + mode: "ssh", + sshUser: sshUser.trim() || "root", + sshKey: sshKey.trim() || undefined, + }); + setName(""); + setHost(""); + setPort("22"); + setSshUser("root"); + setSshKey(""); + refetch(); + }; + + const createRegistration = async (e: React.FormEvent) => { + e.preventDefault(); + if (!agentName.trim()) return; + setRegistrationError(""); + try { + const result = await api.createAgentRegistrationToken({ name: agentName.trim() }); + const controlPlane = window.location.origin; + setRegistrationCommand(`docker run -d --name dequel-agent --cap-add=NET_ADMIN --device /dev/net/tun --restart unless-stopped -e DEQUEL_CONTROL_PLANE=${controlPlane} -e DEQUEL_REGISTRATION_TOKEN=${result.token} -v dequel-agent-data:/root/.dequel -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/lftobs/dequel/agent:latest`); + } catch (err) { + setRegistrationError(err instanceof Error ? err.message : "Could not create registration token"); + } + }; + + return ( + + +
+ + Servers +
+
+ +
+
+ +
+

Connect a Server (Direct SSH)

+

Add any remote cloud VPS (Hetzner, DigitalOcean, AWS). Dequel deploys over SSH directly without installing software on the target server.

+
+
+
+
+ + setName(e.target.value)} /> +
+
+ + setHost(e.target.value)} /> +
+
+ + setPort(e.target.value)} /> +
+
+ + setSshUser(e.target.value)} /> +
+
+
+ +