From 9f9d1ca59e73d55f3baac4be2e3af0989e4ed06b Mon Sep 17 00:00:00 2001 From: lftobs Date: Tue, 9 Jun 2026 09:50:28 +0100 Subject: [PATCH 01/12] feat: add deployment and release infrastructure - Add GitHub Actions for documentation deployment and container releases - Create `install.sh` and `dequel` CLI for platform management - Introduce root `VERSION` file and `CHANGELOG.md` - Configure Vercel deployment for docs - Update documentation with installation instructions --- .github/workflows/deploy-docs.yml | 38 ++++++ .github/workflows/release.yml | 69 ++++++++++ .gitignore | 1 + CHANGELOG.md | 41 ++++++ README.md | 38 +++++- VERSION | 1 + apps/docs/.astro/astro/content.d.ts | 14 ++ apps/docs/package.json | 2 +- apps/docs/src/components/CTA.astro | 4 +- apps/docs/src/content/docs/changelog.md | 39 ++++++ apps/docs/src/content/docs/installation.md | 104 +++++++++++++++ apps/docs/src/layouts/Layout.astro | 98 ++++---------- apps/docs/src/pages/index.astro | 12 +- apps/docs/src/styles/global.css | 12 +- apps/docs/vercel.json | 6 + docker-compose.yml | 2 + package.json | 9 ++ scripts/dequel | 80 ++++++++++++ scripts/install.sh | 141 +++++++++++++++++++++ 19 files changed, 620 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/deploy-docs.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 VERSION create mode 100644 apps/docs/src/content/docs/changelog.md create mode 100644 apps/docs/src/content/docs/installation.md create mode 100644 apps/docs/vercel.json create mode 100644 package.json create mode 100755 scripts/dequel create mode 100755 scripts/install.sh diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..1cf5103 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,38 @@ +name: Deploy Docs + +on: + push: + branches: + - main + - dev + paths: + - "apps/docs/**" + +jobs: + deploy: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/docs + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Build + run: bun run build + + - name: Deploy to Vercel + uses: amondnet/vercel-action@v25 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} + vercel-args: ${{ github.ref == 'refs/heads/main' && '--prod' || '' }} + working-directory: apps/docs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..109f62d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +name: Release + +on: + push: + tags: + - "v*" + +env: + REGISTRY: ghcr.io + API_IMAGE: ghcr.io/${{ github.repository }}/api + WEB_IMAGE: ghcr.io/${{ github.repository }}/web + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Extract version from tag + id: version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push API image + uses: docker/build-push-action@v6 + with: + context: apps/api + push: true + tags: | + ${{ env.API_IMAGE }}:${{ steps.version.outputs.VERSION }} + ${{ env.API_IMAGE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build and push Web image + uses: docker/build-push-action@v6 + with: + context: apps/web + push: true + tags: | + ${{ env.WEB_IMAGE }}:${{ steps.version.outputs.VERSION }} + ${{ env.WEB_IMAGE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: v${{ steps.version.outputs.VERSION }} + body_path: CHANGELOG.md + generate_release_notes: true + files: | + docker-compose.yml + scripts/install.sh + scripts/dequel diff --git a/.gitignore b/.gitignore index 75924fa..192ad57 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules dist .DS_Store *.log +.env data workspace .specs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f781212 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-06-08 + +### Added + +- Initial deployment platform with Git, ZIP, and Docker Compose source deploy +- Automatic build detection via Railpack +- Managed PostgreSQL and MySQL database provisioning +- Custom domain attachment with automatic SSL via Caddy/Let's Encrypt +- CPU-threshold based horizontal auto-scaling with configurable cooldown +- Per-project environment variable management with redeploy hooks +- Persistent Docker volume attachments +- Full observability stack: Prometheus, Loki, Grafana, cAdvisor +- CPU/memory threshold alerts via email or webhook +- API key management for programmatic access +- Job queue via Redis for async operations +- Deployment rollback support +- Boot-time reconciliation of container state +- Unified project versioning via root `VERSION` file and `sync-versions` script +- `CHANGELOG.md` for tracking releases +- One-command install script (`install.sh`) for quick setup +- Automated release pipeline via GitHub Actions (builds Docker images, publishes to GitHub Container Registry, creates GitHub Releases) +- Changelog page in documentation site +- Vercel deployment configuration for documentation site + +### Changed + +- `docker-compose.yml` now references versioned images from `ghcr.io/dequel/*` with local build as fallback +- README updated with new install flow + +### Fixed + +- Railpack build timeout handling and log scrolling + +[0.1.0]: https://github.com/tobshub/dequel/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 6cbc03a..6bbcfb6 100644 --- a/README.md +++ b/README.md @@ -53,19 +53,42 @@ Services run in Docker Compose: 5. **Redis**: Job queue for async operations. 6. **cAdvisor / Prometheus / Loki / Grafana**: Monitoring and observability. -## Prerequisites +## Documentation -- Docker and Docker Compose installed on your system -- Optional: Bun installed globally for local development +Documentation is available at the [docs site](https://dequel-docs.vercel.app) +and in the `apps/docs/` directory of this repository. ## Quick Start ```bash -docker compose up -d --build +curl -fsSL https://raw.githubusercontent.com/Lftobs/dequel/main/scripts/install.sh | sh +``` + +Once installed, manage Dequel with the `dequel` CLI: + +```bash +dequel start # Start all services +dequel status # Show service status +dequel logs # Follow logs +dequel stop # Stop all services +dequel update # Pull latest images and restart ``` Open `http://localhost` to see the dashboard. +## Prerequisites + +- Docker and Docker Compose v2 installed on your system +- Optional: Bun installed globally for local development + +## Installing from a Release + +1. Download the latest release from the [Releases page](https://github.com/Lftobs/dequel/releases). +2. Run the install script above, or download and run `scripts/install.sh` directly. +3. The script will download `docker-compose.yml`, monitoring configs, and start all services. + +Pre-built Docker images are published to `ghcr.io` for each release. The `docker-compose.yml` references these images; pass `--build` to build from source instead. + ## Development Run services locally (without Docker) for faster iteration: @@ -88,6 +111,13 @@ bun apps/web/src/main.tsx - `apps/api/` -- Backend orchestrator (ElysiaJS/Bun). Routes, workers, database provisioning, scaling engine, server manager. - `apps/web/` -- Frontend dashboard (React/Vite/TanStack). +- `apps/docs/` -- Documentation site (Astro), deployed to Vercel. +- `scripts/` -- Utility scripts: + - `install.sh` -- One-command install script. + - `dequel` -- CLI script for managing the Dequel stack (start/stop/status/logs/update). +- `VERSION` -- Single source of truth for the project version. +- `CHANGELOG.md` -- Release history. +- `.github/workflows/release.yml` -- Automated release pipeline. - `infra/caddy/` -- Caddyfile config and deployment-specific route files. - `infra/monitoring/` -- Prometheus, Loki, Promtail, Grafana configs. - `data/` -- SQLite database, persisted across restarts. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/apps/docs/.astro/astro/content.d.ts b/apps/docs/.astro/astro/content.d.ts index 351650d..dc0a0ce 100644 --- a/apps/docs/.astro/astro/content.d.ts +++ b/apps/docs/.astro/astro/content.d.ts @@ -141,6 +141,13 @@ declare module 'astro:content' { type ContentEntryMap = { "docs": { +"changelog.md": { + id: "changelog.md"; + slug: "changelog"; + body: string; + collection: "docs"; + data: any +} & { render(): Render[".md"] }; "configuration.md": { id: "configuration.md"; slug: "configuration"; @@ -183,6 +190,13 @@ declare module 'astro:content' { collection: "docs"; data: any } & { render(): Render[".md"] }; +"installation.md": { + id: "installation.md"; + slug: "installation"; + body: string; + collection: "docs"; + data: any +} & { render(): Render[".md"] }; "quickstart.md": { id: "quickstart.md"; slug: "quickstart"; diff --git a/apps/docs/package.json b/apps/docs/package.json index 987f928..cc2b340 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -1,7 +1,7 @@ { "name": "dequel-docs", "type": "module", - "version": "0.0.1", + "version": "0.1.0", "scripts": { "dev": "astro dev", "start": "astro dev", diff --git a/apps/docs/src/components/CTA.astro b/apps/docs/src/components/CTA.astro index af3e386..826c9dc 100644 --- a/apps/docs/src/components/CTA.astro +++ b/apps/docs/src/components/CTA.astro @@ -22,8 +22,8 @@ Read the docs - - Open Console Dashboard + + Install Dequel diff --git a/apps/docs/src/content/docs/changelog.md b/apps/docs/src/content/docs/changelog.md new file mode 100644 index 0000000..fdc5455 --- /dev/null +++ b/apps/docs/src/content/docs/changelog.md @@ -0,0 +1,39 @@ +--- +title: Changelog +category: Release +description: All notable changes to Dequel, tracked per release. +slug: changelog +--- + +## 0.1.0 — 2026-06-08 + +### Added + +- Initial deployment platform with Git, ZIP, and Docker Compose source deploy +- Automatic build detection via Railpack +- Managed PostgreSQL and MySQL database provisioning +- Custom domain attachment with automatic SSL via Caddy / Let's Encrypt +- CPU-threshold based horizontal auto-scaling with configurable cooldown +- Per-project environment variable management with redeploy hooks +- Persistent Docker volume attachments +- Full observability stack: Prometheus, Loki, Grafana, cAdvisor +- CPU / memory threshold alerts via email or webhook +- API key management for programmatic access +- Job queue via Redis for async operations +- Deployment rollback support +- Boot-time reconciliation of container state +- Unified project versioning via root `VERSION` file and sync script +- `CHANGELOG.md` for tracking releases +- One-command install script (`install.sh`) for quick setup +- Automated release pipeline via GitHub Actions +- Changelog page in documentation site +- Vercel deployment configuration for documentation site + +### Changed + +- `docker-compose.yml` now references images from `ghcr.io/dequel/*` with local build as fallback +- README updated with new install flow + +### Fixed + +- Railpack build timeout handling and log scrolling diff --git a/apps/docs/src/content/docs/installation.md b/apps/docs/src/content/docs/installation.md new file mode 100644 index 0000000..fea16c9 --- /dev/null +++ b/apps/docs/src/content/docs/installation.md @@ -0,0 +1,104 @@ +--- +title: Installation +category: Getting Started +description: Install Dequel on your own infrastructure. +slug: installation +--- + +## Prerequisites + +- **Docker** installed on your system +- **Docker Compose v2** (`docker compose` command) + +## Install Script + +The quickest way to get Dequel running: + +```bash +curl -fsSL https://raw.githubusercontent.com/Lftobs/dequel/main/scripts/install.sh | sh +``` + +This will: + +1. Check that Docker and Docker Compose are installed +2. Create a config directory at `~/.dequel` +3. Download `docker-compose.yml` and monitoring configs +4. Pull pre-built Docker images from GitHub Container Registry +5. Install the `dequel` CLI to `/usr/local/bin` + +After installation, start the platform: + +```bash +dequel start +``` + +Open `http://localhost` to access the dashboard. + +## Manual Setup with Docker Compose + +Clone the repository and run: + +```bash +git clone https://github.com/Lftobs/dequel.git +cd dequel +docker compose up -d +``` + +## The `dequel` CLI + +The `dequel` command manages the platform lifecycle: + +| Command | Description | +|---------|-------------| +| `dequel start` | Start all Dequel services | +| `dequel stop` | Stop all services | +| `dequel status` | Show service status | +| `dequel logs` | Follow service logs | +| `dequel update` | Pull latest images and restart | +| `dequel restart` | Restart all services | +| `dequel --help` | Show all commands | + +## Configuration Directory + +Dequel stores its configuration in `~/.dequel` (or `$DEQUEL_HOME` if set): + +``` +~/.dequel/ +├── docker-compose.yml +├── .env # Optional environment overrides +├── data/ # SQLite database +├── workspace/ # Build staging area +└── infra/ + ├── caddy/ + │ ├── Caddyfile + │ └── routes/ + └── monitoring/ + ├── prometheus.yml + ├── loki-config.yml + └── grafana/ +``` + +## Building from Source + +For local development, run the API and web dashboard directly: + +```bash +# Terminal 1 — API +export DATABASE_PATH=./data/dequel.db \ + WORKSPACE_ROOT=./workspace \ + CADDY_ROUTES_DIR=./infra/caddy/routes \ + DOCKER_NETWORK=dequel_net \ + APP_INTERNAL_PORT=3000 +bun apps/api/src/index.ts + +# Terminal 2 — Web +bun apps/web/src/main.tsx +``` + +## Updating + +```bash +dequel update +``` + +This pulls the latest images from GitHub Container Registry and recreates the services. diff --git a/apps/docs/src/layouts/Layout.astro b/apps/docs/src/layouts/Layout.astro index e45cf1d..41f0636 100644 --- a/apps/docs/src/layouts/Layout.astro +++ b/apps/docs/src/layouts/Layout.astro @@ -1,5 +1,6 @@ --- import "../styles/global.css"; +import { getCollection } from "astro:content"; interface Props { title?: string; @@ -32,78 +33,27 @@ const description = frontmatter?.description || ""; -const docMenu = [ - { - title: "Getting Started", - items: [ - { - title: "Introduction", - href: "/docs", - slug: "docs", - }, - { - title: "Quickstart", - href: "/docs/quickstart", - slug: "quickstart", - }, - { - title: "Configuration", - href: "/docs/configuration", - slug: "configuration", - }, - ], - }, - { - title: "Core Architecture", - items: [ - { - title: "Deployments", - href: "/docs/deployments", - slug: "deployments", - }, - { - title: "Environment Variables", - href: "/docs/env-vars", - slug: "env-vars", - }, - { - title: "Scaling Policies", - href: "/docs/scaling", - slug: "scaling", - }, - ], - }, - { - title: "Cluster Storage & Data", - items: [ - { - title: "Managed Databases", - href: "/docs/databases", - slug: "databases", - }, - { - title: "Persistent Volumes", - href: "/docs/volumes", - slug: "volumes", - }, - ], - }, - { - title: "Networking & Security", - items: [ - { - title: "Custom Domains", - href: "/docs/domains", - slug: "domains", - }, - { - title: "SSL Certificates", - href: "/docs/ssl", - slug: "ssl", - }, - ], - }, +const allDocs = await getCollection("docs"); +const categoryOrder = [ + "Getting Started", + "Core Architecture", + "Cluster Storage & Data", + "Networking & Security", + "Release", ]; +const grouped: Record = {}; +for (const entry of allDocs) { + const cat = entry.data.category; + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push({ + title: entry.data.title, + href: entry.data.slug === "docs" ? "/docs" : `/docs/${entry.data.slug}`, + slug: entry.data.slug, + }); +} +const docMenu = categoryOrder + .filter((cat) => grouped[cat]) + .map((cat) => ({ title: cat, items: grouped[cat] })); --- @@ -226,13 +176,11 @@ const docMenu = [ >Home - Console diff --git a/apps/docs/src/pages/index.astro b/apps/docs/src/pages/index.astro index f354e29..d6e9cd4 100644 --- a/apps/docs/src/pages/index.astro +++ b/apps/docs/src/pages/index.astro @@ -118,12 +118,10 @@ import CTA from "../components/CTA.astro"; >Docs - Console @@ -212,11 +210,9 @@ import CTA from "../components/CTA.astro"; >Documentation ConsoleInstall
+
+
Loading Workspace Projects...
+
+ ); + } + + return ( +
+ + {/* Header section with page title & New Project button */} +
+
+

+ Overview +

+

Manage, deploy and monitor your workspace cluster services.

+
+
+ +
+
+ + {/* Two Column Layout: Left Column (Stats/Previews) & Right Column (Projects) */} +
+ + {/* Left Side: Stats/Recent Previews */} +
+ + {/* Usage Metrics Panel */} +
+

+ + Node Allocation +

+ +
+
+
+ API Traffic / Capacity + {metricsLoading ? '...' : (metrics?.requestsTotal ?? 0)} reqs +
+
+
+
+
+ +
+
+ Active Deployments + {metricsLoading ? '...' : (metrics?.activeDeployments ?? 0)} running +
+
+
+
+
+ +
+
+ Cluster Ingress Uptime + {metricsLoading ? '...' : formatUptime(metrics?.uptimeSeconds)} +
+
+
+
+
+
+
+ + {/* Anomaly Alerts Pro Callout Box */} +
+
+ +
+
+ + Get Alerted For Anomalies +
+

+ Automatically monitor container memory leaks and network request latency spikes. +

+ +
+ + {/* Recent Previews List */} +
+

+ + Recent Previews +

+ + {deploymentsLoading ? ( +
Loading Previews...
+ ) : allDeployments.length === 0 ? ( +
No recent builds.
+ ) : ( +
+ {allDeployments + .filter(dep => dep.projectId && projects.some(p => p.id === dep.projectId)) + .slice(0, 5) + .map(dep => { + const project = projects.find(p => p.id === dep.projectId); + return ( + +
+
+ + {project ? project.name : 'Unknown Project'} + + + {formatTimeAgo(dep.createdAt)} + +
+

+ {dep.branch ? `branch: ${dep.branch}` : 'file upload'} +

+
+ + {dep.id.slice(0, 8)} + + +
+
+ + ); + })} +
+ )} +
+
+ + {/* Right Side: Search bar & Projects Grid */} +
+ + {/* Controls: Search, Layout Filter Toggle */} +
+
+ + setSearchTerm(e.target.value)} + className="bg-transparent border-0 ring-0 focus:ring-0 focus:ring-offset-0 text-xs pl-9 placeholder-zinc-500 h-9 w-full shadow-none text-zinc-200" + /> +
+
+ + ⌘K + +
+ + {/* Project List / Grid */} + {filteredProjects.length === 0 ? ( +
+
+ +
+

No Projects Found

+

+ Create a new project deployment to see container orchestration on the cluster. +

+ +
+ ) : ( +
+ {filteredProjects.map(project => ( + deleteProject.mutate(project.id)} + /> + ))} +
+ )} +
+
+
+ ); +} + +function ProjectGridCard({ project, onDelete }: { project: any; onDelete: () => void }) { + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + + // Query to get the latest deployment for this project + const { data: deploymentsData } = useQuery({ + queryKey: ['deployments-preview', project.id], + queryFn: () => api.listDeployments(project.id), + refetchInterval: 10000, + }); + const deployments = deploymentsData?.items ?? []; + + const latest = deployments[0]; + const runningCount = deployments.filter(d => d.status === 'running').length; + + return ( + <> + + + {/* Top Background Gradient Highlight */} +
+ + {/* Delete button shown only on card hover */} + + +
+ + {/* Project Header Info */} +
+
+ {project.name.charAt(0).toUpperCase()} +
+
+

+ {project.name} +

+ {project.description ? ( +

{project.description}

+ ) : ( +

No description provided

+ )} +
+
+ + {/* Live URL or Branch Status */} +
+ {latest ? ( +
+ + + {latest.status} + + {runningCount > 1 && ( + + {runningCount} running + + )} +
+ ) : ( + No deployment created + )} +
+ + {/* Bottom Metadata: Ingress domain, Git Commit & Deploys count */} +
+ {project.baseDomain ? ( + + + {project.baseDomain} + + ) : latest?.liveUrl ? ( + + + {latest.liveUrl.replace('http://', '').replace('https://', '')} + + ) : ( + + + localhost + + )} + + {latest?.branch && ( + + + {latest.branch} + + )} + + {deployments.length > 0 && ( + + {deployments.length} deploys + + )} +
+
+ + + + + + + Delete Project + + + Are you sure you want to delete {project.name}? This action cannot be undone and will permanently remove all associated container deployments, environments, and logs. + + + + + + + + + + ); +} diff --git a/apps/web/src/routes/ProjectDetail.tsx b/apps/web/src/routes/ProjectDetail.tsx new file mode 100644 index 0000000..7d9d251 --- /dev/null +++ b/apps/web/src/routes/ProjectDetail.tsx @@ -0,0 +1,187 @@ +import React from "react"; +import { useProject } from "../hooks/useProjects"; +import { useDeployments } from "../hooks/useDeployments"; +import { useNavigate, useLocation } from "@tanstack/react-router"; +import { Button } from "../components/ui/button"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "../components/ui/tabs"; +import { ArrowLeft, FolderKanban } from "lucide-react"; + +import { DeploymentsTab } from "../components/project/DeploymentsTab"; +import { EnvVarsTab } from "../components/project/EnvVarsTab"; +import { VolumesTab } from "../components/project/VolumesTab"; +import { DatabasesTab } from "../components/project/DatabasesTab"; +import { DomainsTab } from "../components/project/DomainsTab"; +import { ScalingTab } from "../components/project/ScalingTab"; +import { AlertsTab } from "../components/project/AlertsTab"; +import { ObservabilityTab } from "../components/project/ObservabilityTab"; +import { LogsTab } from "../components/project/LogsTab"; + +export function ProjectDetail({ + projectId, +}: { + projectId: string; +}) { + const { data: project, isLoading } = + useProject(projectId); + const { data: deploymentsData } = useDeployments(projectId, 0, 10); + const navigate = useNavigate(); + const location = useLocation(); + const activeTab = new URLSearchParams(location.search).get("tab") || "deployments"; + + if (isLoading) + return ( +
+
+
Loading Service Config...
+
+ ); + if (!project) + return ( +
+
+
+ +
+

Project Not Found

+

+ The project you are looking for does not exist, has been deleted, or you might not have access to it in this workspace. +

+ +
+ ); + + const activeDeployment = deploymentsData?.items?.find(d => d.status === 'running'); + const liveUrl = activeDeployment?.liveUrl; + + return ( +
+
+ +
+ {project.name + .charAt(0) + .toUpperCase()} +
+
+
+

+ {project.name} +

+ {liveUrl && ( + + {liveUrl} ↗ + + )} +
+ {project.description && ( +

+ {project.description} +

+ )} +
+
+ + navigate({ search: { tab: val } as any })} className="space-y-4"> + + + Deployments + + + Env Vars + + + Volumes + + + Databases + + + Domains + + + Scaling + + + Alerts + + + Observability + + + Logs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/apps/web/src/routes/Settings.tsx b/apps/web/src/routes/Settings.tsx new file mode 100644 index 0000000..e050437 --- /dev/null +++ b/apps/web/src/routes/Settings.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Button } from '../components/ui/button'; +import { Input } from '../components/ui/input'; +import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../components/ui/table'; +import { StatusBadge } from '../components/StatusBadge'; +import { Trash2, Server, Key } from 'lucide-react'; +import * as api from '../api/client'; + +export function Settings() { + return ( +
+

Settings

+
+ + +
+
+ ); +} + +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('2376'); + const [authToken, setAuthToken] = useState(''); + + const add = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !host.trim()) return; + await api.createServer({ name: name.trim(), host: host.trim(), port: Number(port), authToken: authToken.trim() }); + setName(''); setHost(''); setPort('2376'); setAuthToken(''); refetch(); + }; + + return ( + + +
Servers
+
+ +
+
+ + setName(e.target.value)} className="w-36" /> +
+
+ + setHost(e.target.value)} className="w-44" /> +
+
+ + setPort(e.target.value)} className="w-20" /> +
+ +
+ {servers.length > 0 && ( +
+ + + NameHostStatus + + + {servers.map(s => ( + + {s.name} + {s.host}:{s.port} + + + + + + ))} + +
+
+ )} +
+
+ ); +} + +function ApiKeysSection() { + const { data: apiKeys = [], refetch } = useQuery({ + queryKey: ['api-keys'], queryFn: () => api.listApiKeys().catch(() => []), + }); + const [name, setName] = useState(''); + const [newKey, setNewKey] = useState(''); + + 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()} + + + + + ))} + +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx new file mode 100644 index 0000000..e33483f --- /dev/null +++ b/apps/web/src/routes/index.tsx @@ -0,0 +1,49 @@ +import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'; +import { Layout } from '../components/Layout'; +import { Dashboard } from './Dashboard'; +import { Settings } from './Settings'; +import { ProjectDetail } from './ProjectDetail'; + +const rootRoute = createRootRoute({ + component: () => ( + + + + ), +}); + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: Dashboard, +}); + +const settingsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/settings', + component: Settings, +}); + +const ProjectPage = () => { + const { projectId } = projectRoute.useParams(); + return ; +}; + +const projectRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/project/$projectId', + component: ProjectPage, + validateSearch: (search: Record) => ({ + tab: (search.tab as string) || 'deployments', + }), +}); + +const routeTree = rootRoute.addChildren([indexRoute, settingsRoute, projectRoute]); + +export const router = createRouter({ routeTree }); + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router; + } +} From 84042177ff16e30afa5df3f0b605668a16bd0a33 Mon Sep 17 00:00:00 2001 From: lftobs Date: Wed, 10 Jun 2026 03:06:11 +0100 Subject: [PATCH 07/12] feat: add GitHub integration for repository deployments - Add GitHub authentication flow and repository picker - Implement Drizzle ORM for database schema management - Refactor project database repository into modular structure - Update project creation flow to support repository selection - Add environment variable management component refactor --- .gitignore | 1 + AGENTS.md | 194 +++ apps/api/bun.lock | 180 +++ apps/api/package.json | 4 +- apps/api/src/api/github/index.ts | 168 +++ apps/api/src/api/index.ts | 4 +- apps/api/src/db/drizzle.ts | 13 + apps/api/src/db/migrate.ts | 184 +-- apps/api/src/db/migrations/0000_baseline.sql | 166 +++ apps/api/src/db/migrations/meta/_journal.json | 13 + apps/api/src/db/repo.ts | 766 +----------- apps/api/src/db/repo/alerts.ts | 62 + apps/api/src/db/repo/api-keys.ts | 59 + apps/api/src/db/repo/databases.ts | 86 ++ apps/api/src/db/repo/deployments.ts | 138 +++ apps/api/src/db/repo/domains.ts | 63 + apps/api/src/db/repo/env-vars.ts | 109 ++ apps/api/src/db/repo/github.ts | 46 + apps/api/src/db/repo/helpers.ts | 1 + apps/api/src/db/repo/index.ts | 41 + apps/api/src/db/repo/projects.ts | 143 +++ apps/api/src/db/repo/scaling.ts | 57 + apps/api/src/db/repo/servers.ts | 74 ++ apps/api/src/db/repo/volumes.ts | 48 + apps/api/src/db/schema.ts | 173 +++ apps/api/src/types.ts | 12 + apps/web/index.html | 2 +- apps/web/src/api/client.ts | 30 + apps/web/src/components/Layout.tsx | 41 +- apps/web/src/components/github/RepoPicker.tsx | 162 +++ .../project/CreateProjectDialog.tsx | 263 ----- .../web/src/components/project/EnvVarsTab.tsx | 1044 ----------------- apps/web/src/components/project/LogsTab.tsx | 443 ------- .../web/src/components/project/StepBasics.tsx | 104 -- .../project/{ => alerts}/AlertsTab.tsx | 60 +- .../project/create/CreateProjectDialog.tsx | 507 ++++++++ .../{ => create}/CreationStatusOverlay.tsx | 2 +- .../components/project/create/StepBasics.tsx | 218 ++++ .../project/{ => create}/StepEnvironment.tsx | 6 +- .../project/{ => create}/StepResources.tsx | 4 +- .../project/{ => databases}/DatabasesTab.tsx | 16 +- .../{ => deployments}/DeploymentsTab.tsx | 22 +- .../project/{ => domains}/DomainsTab.tsx | 22 +- .../project/envtab/AddEnvVarSheet.tsx | 208 ++++ .../project/envtab/DeleteEnvVarDialog.tsx | 53 + .../project/envtab/EmptyEnvState.tsx | 41 + .../components/project/envtab/EnvVarRow.tsx | 183 +++ .../components/project/envtab/EnvVarTable.tsx | 123 ++ .../components/project/envtab/EnvVarsTab.tsx | 211 ++++ .../project/envtab/ImportEnvFileDialog.tsx | 206 ++++ .../project/envtab/RedeployBanner.tsx | 68 ++ .../src/components/project/logs/LogsTab.tsx | 793 +++++++++++++ .../{ => observability}/ObservabilityTab.tsx | 6 +- .../project/{ => scaling}/ScalingTab.tsx | 14 +- .../project/{ => volumes}/VolumesTab.tsx | 16 +- apps/web/src/routes/Dashboard.tsx | 4 +- apps/web/src/routes/ProjectDetail.tsx | 18 +- apps/web/src/routes/Settings.tsx | 57 +- apps/web/src/types/index.ts | 20 + apps/web/tsconfig.json | 6 +- apps/web/vite.config.ts | 8 +- 61 files changed, 4897 insertions(+), 2889 deletions(-) create mode 100644 AGENTS.md create mode 100644 apps/api/src/api/github/index.ts create mode 100644 apps/api/src/db/drizzle.ts create mode 100644 apps/api/src/db/migrations/0000_baseline.sql create mode 100644 apps/api/src/db/migrations/meta/_journal.json create mode 100644 apps/api/src/db/repo/alerts.ts create mode 100644 apps/api/src/db/repo/api-keys.ts create mode 100644 apps/api/src/db/repo/databases.ts create mode 100644 apps/api/src/db/repo/deployments.ts create mode 100644 apps/api/src/db/repo/domains.ts create mode 100644 apps/api/src/db/repo/env-vars.ts create mode 100644 apps/api/src/db/repo/github.ts create mode 100644 apps/api/src/db/repo/helpers.ts create mode 100644 apps/api/src/db/repo/index.ts create mode 100644 apps/api/src/db/repo/projects.ts create mode 100644 apps/api/src/db/repo/scaling.ts create mode 100644 apps/api/src/db/repo/servers.ts create mode 100644 apps/api/src/db/repo/volumes.ts create mode 100644 apps/api/src/db/schema.ts create mode 100644 apps/web/src/components/github/RepoPicker.tsx delete mode 100644 apps/web/src/components/project/CreateProjectDialog.tsx delete mode 100644 apps/web/src/components/project/EnvVarsTab.tsx delete mode 100644 apps/web/src/components/project/LogsTab.tsx delete mode 100644 apps/web/src/components/project/StepBasics.tsx rename apps/web/src/components/project/{ => alerts}/AlertsTab.tsx (85%) create mode 100644 apps/web/src/components/project/create/CreateProjectDialog.tsx rename apps/web/src/components/project/{ => create}/CreationStatusOverlay.tsx (99%) create mode 100644 apps/web/src/components/project/create/StepBasics.tsx rename apps/web/src/components/project/{ => create}/StepEnvironment.tsx (98%) rename apps/web/src/components/project/{ => create}/StepResources.tsx (98%) rename apps/web/src/components/project/{ => databases}/DatabasesTab.tsx (98%) rename apps/web/src/components/project/{ => deployments}/DeploymentsTab.tsx (97%) rename apps/web/src/components/project/{ => domains}/DomainsTab.tsx (97%) create mode 100644 apps/web/src/components/project/envtab/AddEnvVarSheet.tsx create mode 100644 apps/web/src/components/project/envtab/DeleteEnvVarDialog.tsx create mode 100644 apps/web/src/components/project/envtab/EmptyEnvState.tsx create mode 100644 apps/web/src/components/project/envtab/EnvVarRow.tsx create mode 100644 apps/web/src/components/project/envtab/EnvVarTable.tsx create mode 100644 apps/web/src/components/project/envtab/EnvVarsTab.tsx create mode 100644 apps/web/src/components/project/envtab/ImportEnvFileDialog.tsx create mode 100644 apps/web/src/components/project/envtab/RedeployBanner.tsx create mode 100644 apps/web/src/components/project/logs/LogsTab.tsx rename apps/web/src/components/project/{ => observability}/ObservabilityTab.tsx (98%) rename apps/web/src/components/project/{ => scaling}/ScalingTab.tsx (98%) rename apps/web/src/components/project/{ => volumes}/VolumesTab.tsx (97%) diff --git a/.gitignore b/.gitignore index 012b99b..3b0d81f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ workspace infra/caddy/routes/ bugs apps/api/index +docker-compose.yml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7640621 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,194 @@ +# Dequel + +Self-hosted deployment platform. Deploy apps from Git, ZIP, or Docker Compose with zero infrastructure setup. + +## Tech Stack + +- **Runtime**: Bun +- **Backend**: ElysiaJS (`apps/api/`) — TypeScript, port 3001 +- **Frontend**: React 18 + Vite + TanStack Router + TanStack Query (`apps/web/`) — port 3000 +- **Docs**: Astro 4 + Tailwind CSS (`apps/docs/`) — deployed to Vercel +- **Database**: SQLite (`data/dequel.db`) — raw SQL +- **Queue**: Redis (`ioredis`) for async job queue +- **Container build**: Railpack CLI + BuildKit daemon +- **Container runtime**: Docker Engine API (mounted Docker socket) +- **Ingress**: Caddy (dynamic route files + auto SSL) + +## Architecture + +``` +Caddy ──▶ API ──▶ Buildkit + │ │ + ▼ ▼ + Web SQLite Redis + +Observability: cAdvisor → Prometheus → Grafana / Loki +``` + +Services run in Docker Compose: Caddy, API, Web, Buildkit, Redis, cAdvisor, Prometheus, Loki, Promtail, Grafana. + +## Directory Structure + +``` +├── apps/ +│ ├── api/ # Backend orchestrator (Bun + ElysiaJS) +│ │ ├── src/ +│ │ │ ├── api/ # Route handlers +│ │ │ ├── db/ # Database migrations + queries +│ │ │ ├── orchestrator/ # Deployment orchestration logic +│ │ │ ├── scaling/ # Auto-scaling engine +│ │ │ ├── monitoring/ # Alert evaluation, Prometheus metrics +│ │ │ ├── servers/ # Server management +│ │ │ └── git/ # Git operations +│ │ └── Dockerfile +│ ├── web/ # React dashboard (Vite + TanStack) +│ │ ├── src/ +│ │ │ ├── routes/ # TanStack Router route definitions +│ │ │ ├── components/ # Shared UI components +│ │ │ ├── api/ # API client +│ │ │ └── hooks/ # Custom hooks +│ │ └── Dockerfile +│ └── docs/ # Documentation site (Astro) +│ ├── src/ +│ │ ├── content/docs/ # Markdown doc pages (content collection) +│ │ ├── layouts/ # Layout component with sidebar +│ │ ├── components/ # Landing page components +│ │ └── styles/ # Global CSS +│ └── vercel.json +├── infra/ +│ ├── caddy/ # Caddyfile + dynamic route files +│ └── monitoring/ # Prometheus, Loki, Grafana configs +├── scripts/ +│ ├── install.sh # One-command install script +│ └── dequel # CLI for managing the platform +├── data/ # SQLite database (persisted) +├── workspace/ # Build staging area +├── docker-compose.yml +├── VERSION # Single source of truth for version +├── CHANGELOG.md # Release history +└── AGENTS.md # This file +``` + +## Key Files + +| File | Purpose | +|------|---------| +| `apps/api/src/index.ts` | API entry point — bootstraps DB, queue, scaling engine, etc. | +| `apps/api/src/db/schema.ts` | Drizzle ORM schema definitions (all tables) | +| `apps/api/src/db/drizzle.ts` | Drizzle client wrapper (wraps `bun:sqlite`) | +| `apps/api/src/db/migrations/` | Drizzle Kit migration files (`drizzle-kit generate` outputs here) | +| `apps/api/drizzle.config.ts` | Drizzle Kit configuration | +| `apps/web/src/main.tsx` | Frontend entry point | +| `apps/web/src/routes/index.tsx` | TanStack Router tree definition | +| `apps/web/src/routes/Dashboard.tsx` | Main dashboard page | +| `apps/web/src/components/Layout.tsx` | Shared app layout (sidebar, header) | +| `apps/docs/src/layouts/Layout.astro` | Docs layout with sidebar (auto-generated from content collection) | +| `apps/docs/src/pages/docs/[...slug].astro` | Catch-all route rendering content collection entries | +| `apps/docs/src/content.config.ts` | Astro content collection schema | +| `scripts/install.sh` | Install script — downloads configs, pulls images, installs CLI | +| `scripts/dequel` | CLI tool — `start`, `stop`, `status`, `logs`, `update`, `uninstall` | +| `.github/workflows/release.yml` | On `v*` tag: build Docker images → ghcr.io, create GitHub Release | +| `.github/workflows/deploy-docs.yml` | On push to `main`/`dev` (docs changes): deploy to Vercel | + +## Commands + +```bash +# Development (API) +bun apps/api/src/index.ts + +# Development (Web) +bun apps/web/src/main.tsx + +# Inside apps/web/ +bun run build # Vite build +bun run dev # Vite dev server + +# Inside apps/api/ +bun test # Run tests + +# Inside apps/docs/ +bun run dev # Astro dev server +bun run build # Astro build + +# Docker +docker compose up -d # Start full stack +docker compose up -d --build # Rebuild and start + +# Install / Manage +curl -fsSL https://raw.githubusercontent.com/Lftobs/dequel/main/scripts/install.sh | sh +scripts/dequel start # Start platform +scripts/dequel uninstall # Remove everything (prompts) + +# Drizzle migrations (run from apps/api/) +bunx drizzle-kit generate # Generate migration from schema changes +bunx drizzle-kit push # Push schema directly (dev only) + +# Version sync +bun run sync-versions # Syncs VERSION → sub-package.json files +``` + +## Code Conventions + +- No comments in source code unless absolutely necessary +- no file should be above 500 lines of code...if it really is, refactor and split into smaller files properly managed in a folder not scattered across the codebase (proper feature grouping). +- Named exports over default exports +- No emojis in code or UI unless explicitly requested +- Functional components with hooks (React) +- Tailwind CSS for styling (both web and docs) +- Astro content collections for docs + +## Adding a Doc Page + +1. Create `.md` file in `apps/docs/src/content/docs/` with frontmatter: + ```yaml + --- + title: Page Title + category: Category Name + description: Short description. + slug: page-slug + --- + ``` +2. If it's a new category, add it to `categoryOrder` array in `apps/docs/src/layouts/Layout.astro`. +3. The sidebar updates automatically from the content collection. + +## Release Process + +```bash +git tag vX.Y.Z +git push origin vX.Y.Z +``` + +CI builds Docker images → `ghcr.io/lftobs/dequel/{api,web}:X.Y.Z` and creates a GitHub Release with `docker-compose.yml`, `scripts/install.sh`, `scripts/dequel` as assets. + +## Vercel Deploy (Docs) + +`.github/workflows/deploy-docs.yml` deploys docs on push to `main` or `dev`: + +| Branch | Domain | +|--------|--------| +| `main` | `dequel.intrep.xyz` | +| `dev` | `dev.dequel.intrep.xyz` | + +Requires secrets: `VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID` + +## Environment Variables (API) + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `3001` | API listen port | +| `DATABASE_PATH` | `./data/dequel.db` | SQLite database | +| `WORKSPACE_ROOT` | `./workspace` | Build staging | +| `CADDY_ROUTES_DIR` | `./infra/caddy/routes` | Caddy route output | +| `DOCKER_NETWORK` | `dequel_net` | Docker network for deployments | +| `BUILDKIT_HOST` | `tcp://buildkit:1234` | Buildkit daemon | +| `RAILPACK_BUILD_TIMEOUT_MS` | `1200000` | Build timeout | + +## Boundaries + +- Never commit secrets or `.env` files +- Drizzle ORM for migrations; raw SQL is still used in `repo.ts` for queries (may be migrated incrementally) +- Database: SQLite with `bun:sqlite` (future: Drizzle ORM + PostgreSQL) +- `.gitignore` ignores `infra/caddy/routes/`, NOT `apps/web/src/routes/` +- Always run `bun test` in `apps/api/` before committing API changes +- Docs landing page (`index.astro`) is standalone — no shared layout +- `set -euo pipefail` in all bash scripts; use functions, not flat code diff --git a/apps/api/bun.lock b/apps/api/bun.lock index d076418..671d0a8 100644 --- a/apps/api/bun.lock +++ b/apps/api/bun.lock @@ -5,20 +5,80 @@ "name": "dequel-api", "dependencies": { "@elysiajs/cors": "^1.1.1", + "drizzle-orm": "^0.45.2", "elysia": "^1.1.26", "ioredis": "^5.4.1", "nodemailer": "^8.0.10", }, "devDependencies": { "@types/nodemailer": "^8.0.0", + "drizzle-kit": "^0.31.10", }, }, }, "packages": { "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="], "@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], @@ -31,6 +91,8 @@ "@types/nodemailer": ["@types/nodemailer@8.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], @@ -39,14 +101,24 @@ "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + "elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], "file-type": ["file-type@22.0.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ioredis": ["ioredis@5.11.0", "", { "dependencies": { "@ioredis/commands": "1.10.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "redis-parser": "3.0.0", "standard-as-callback": "2.1.0" } }, "sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg=="], @@ -63,14 +135,122 @@ "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + + "tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], } } diff --git a/apps/api/package.json b/apps/api/package.json index 8e8cb21..210da08 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,11 +10,13 @@ }, "dependencies": { "@elysiajs/cors": "^1.1.1", + "drizzle-orm": "^0.45.2", "elysia": "^1.1.26", "ioredis": "^5.4.1", "nodemailer": "^8.0.10" }, "devDependencies": { - "@types/nodemailer": "^8.0.0" + "@types/nodemailer": "^8.0.0", + "drizzle-kit": "^0.31.10" } } diff --git a/apps/api/src/api/github/index.ts b/apps/api/src/api/github/index.ts new file mode 100644 index 0000000..4aa5f4d --- /dev/null +++ b/apps/api/src/api/github/index.ts @@ -0,0 +1,168 @@ +import { Elysia } from "elysia"; +import { getGithubIntegration, setGithubIntegration } from "../../db/repo"; +import { config } from "../../utils/config"; + +const SESSIONS = new Map(); +const SESSION_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours + +const getSession = (cookie: string | null): string | null => { + if (!cookie) return null; + const match = cookie.match(/github_session=([^;]+)/); + if (!match) return null; + const session = SESSIONS.get(match[1]); + if (!session || session.expiresAt < Date.now()) { + if (session) SESSIONS.delete(match[1]); + return null; + } + return session.token; +}; + +const createSession = (token: string): string => { + const id = crypto.randomUUID(); + SESSIONS.set(id, { token, expiresAt: Date.now() + SESSION_TTL_MS }); + return id; +}; + +const fetchGitHub = async (path: string, token: string) => { + const res = await fetch(`https://api.github.com${path}`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "dequel", + }, + }); + if (!res.ok) { + const err = await res.text(); + throw new Error(`GitHub API error ${res.status}: ${err}`); + } + return res.json(); +}; + +export const githubRoutes = new Elysia({ prefix: "/github" }) + .get("/integration", async () => { + const integration = await getGithubIntegration(); + if (!integration) return { configured: false }; + return { configured: true, clientId: integration.clientId, appName: integration.appName, hasWebhookSecret: !!integration.webhookSecret }; + }) + + .put("/integration", async ({ body, set }: any) => { + if (!body?.clientId || !body?.clientSecret) { + set.status = 400; + return { error: "clientId and clientSecret are required" }; + } + await setGithubIntegration({ + clientId: body.clientId, + clientSecret: body.clientSecret, + appName: body.appName, + webhookSecret: body.webhookSecret, + }); + return { ok: true }; + }) + .get("/auth-url", async ({ request, set }) => { + const integration = await getGithubIntegration(); + if (!integration) { + set.status = 400; + return { error: "GitHub integration not configured" }; + } + const url = new URL(request.url); + const redirectUri = `${url.protocol}//${url.host}/api/github/callback`; + const state = crypto.randomUUID(); + const params = new URLSearchParams({ + client_id: integration.clientId, + redirect_uri: redirectUri, + scope: "repo,read:org,admin:repo_hook", + state, + }); + return { url: `https://github.com/login/oauth/authorize?${params}` }; + }) + + .get("/callback", async ({ query, set }) => { + const { code, state } = query as Record; + if (!code) { + set.status = 400; + return { error: "Missing code parameter" }; + } + const integration = await getGithubIntegration(); + if (!integration) { + set.status = 400; + return { error: "GitHub integration not configured" }; + } + const url = new URL(config.caddyIngressBase); + const redirectUri = `${url.protocol}//${url.host}/api/github/callback`; + + const res = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: integration.clientId, + client_secret: integration.clientSecret, + code, + redirect_uri: redirectUri, + }), + }); + const data = await res.json() as Record; + if (data.error) { + set.status = 400; + return { error: data.error_description ?? data.error }; + } + const sessionId = createSession(data.access_token); + set.headers["Set-Cookie"] = `github_session=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL_MS / 1000}`; + set.redirect = `${config.caddyIngressBase}/?github=connected`; + }) + + .get("/user", async ({ request, set }) => { + const token = getSession(request.headers.get("cookie")); + if (!token) { + set.status = 401; + return { error: "Not authenticated" }; + } + return fetchGitHub("/user", token); + }) + + .get("/repos", async ({ request, set }) => { + const token = getSession(request.headers.get("cookie")); + if (!token) { + set.status = 401; + return { error: "Not authenticated" }; + } + const repos = await fetchGitHub("/user/repos?per_page=100&sort=updated&type=all", token); + const user = await fetchGitHub("/user", token) as { login: string }; + const orgs = await fetchGitHub("/user/orgs", token) as { login: string }[]; + const allRepos = Array.isArray(repos) ? repos : []; + const orgRepos: any[] = []; + for (const org of Array.isArray(orgs) ? orgs : []) { + try { + const orgR = await fetchGitHub(`/orgs/${org.login}/repos?per_page=100&sort=updated&type=all`, token); + if (Array.isArray(orgR)) orgRepos.push(...orgR); + } catch {} + } + const seen = new Set(); + const merged = [...allRepos, ...orgRepos].filter((r) => { + if (seen.has(r.id)) return false; + seen.add(r.id); + return true; + }); + return merged.map((r: any) => ({ + id: r.id, + name: r.name, + fullName: r.full_name, + cloneUrl: r.clone_url, + sshUrl: r.ssh_url, + description: r.description, + language: r.language, + private: r.private, + defaultBranch: r.default_branch, + owner: { login: r.owner.login, avatarUrl: r.owner.avatar_url }, + })); + }) + + .post("/disconnect", async ({ set, request }) => { + const cookie = request.headers.get("cookie"); + const match = cookie?.match(/github_session=([^;]+)/); + if (match) SESSIONS.delete(match[1]); + set.headers["Set-Cookie"] = "github_session=; Path=/; Max-Age=0"; + return { ok: true }; + }); diff --git a/apps/api/src/api/index.ts b/apps/api/src/api/index.ts index 60ce426..262a1ed 100644 --- a/apps/api/src/api/index.ts +++ b/apps/api/src/api/index.ts @@ -5,6 +5,7 @@ import { databasesRoutes } from "./databases"; import { deploymentsRoutes } from "./deployments"; import { domainsRoutes } from "./domains"; import { envVarsRoutes } from "./env-vars"; +import { githubRoutes } from "./github"; import { healthRoutes } from "./health"; import { projectsRoutes } from "./projects"; import { prometheusRoutes } from "./prometheus"; @@ -47,4 +48,5 @@ export const apiRoutes = new Elysia({ .use(serverInfoRoutes) .use(apiKeysRoutes) .use(prometheusRoutes) - .use(alertsRoutes); + .use(alertsRoutes) + .use(githubRoutes); diff --git a/apps/api/src/db/drizzle.ts b/apps/api/src/db/drizzle.ts new file mode 100644 index 0000000..01b3531 --- /dev/null +++ b/apps/api/src/db/drizzle.ts @@ -0,0 +1,13 @@ +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { getDb } from "./client"; +import * as schema from "./schema"; + +let instance: ReturnType> | null = null; + +export const getDrizzle = async () => { + if (!instance) { + const sqlite = await getDb(); + instance = drizzle(sqlite, { schema }); + } + return instance; +}; diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts index 42630b7..d76b7c1 100644 --- a/apps/api/src/db/migrate.ts +++ b/apps/api/src/db/migrate.ts @@ -1,183 +1,7 @@ -import { getDb } from './client'; +import { migrate as drizzleMigrate } from "drizzle-orm/bun-sqlite/migrator"; +import { getDrizzle } from "./drizzle"; export const migrate = async () => { - const db = await getDb(); - // Safe migration: add columns only if they don't exist - const tableColumns = (table: string) => db.query(`PRAGMA table_info(${table})`).all() as any[]; - const projectCols = tableColumns('projects'); - const hasRepoUrl = projectCols.some((c: any) => c.name === 'repo_url'); - const hasRepoBranch = projectCols.some((c: any) => c.name === 'repo_branch'); - const hasCpuLimit = projectCols.some((c: any) => c.name === 'cpu_limit'); - const hasMemoryLimit = projectCols.some((c: any) => c.name === 'memory_limit_mb'); - if (!hasRepoUrl) db.exec('ALTER TABLE projects ADD COLUMN repo_url TEXT'); - if (!hasRepoBranch) db.exec('ALTER TABLE projects ADD COLUMN repo_branch TEXT'); - if (!hasCpuLimit) db.exec('ALTER TABLE projects ADD COLUMN cpu_limit REAL'); - if (!hasMemoryLimit) db.exec('ALTER TABLE projects ADD COLUMN memory_limit_mb INTEGER'); - - const envCols = tableColumns('environment_variables'); - const hasEncValue = envCols.some((c: any) => c.name === 'value_encrypted'); - const hasEncIv = envCols.some((c: any) => c.name === 'value_iv'); - const hasEncTag = envCols.some((c: any) => c.name === 'value_tag'); - if (!hasEncValue) db.exec('ALTER TABLE environment_variables ADD COLUMN value_encrypted TEXT'); - if (!hasEncIv) db.exec('ALTER TABLE environment_variables ADD COLUMN value_iv TEXT'); - if (!hasEncTag) db.exec('ALTER TABLE environment_variables ADD COLUMN value_tag TEXT'); - - const dbCols = tableColumns('databases'); - const hasDbVersion = dbCols.some((c: any) => c.name === 'version'); - const hasDbCpu = dbCols.some((c: any) => c.name === 'cpu_limit'); - const hasDbMemory = dbCols.some((c: any) => c.name === 'memory_limit_mb'); - if (!hasDbVersion) db.exec('ALTER TABLE databases ADD COLUMN version TEXT'); - if (!hasDbCpu) db.exec('ALTER TABLE databases ADD COLUMN cpu_limit REAL'); - if (!hasDbMemory) db.exec('ALTER TABLE databases ADD COLUMN memory_limit_mb INTEGER'); - - db.exec(` - CREATE TABLE IF NOT EXISTS deployments ( - id TEXT PRIMARY KEY, - project_id TEXT, - source_type TEXT NOT NULL, - source_ref TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - image_tag TEXT, - container_name TEXT, - route_path TEXT, - live_url TEXT, - branch TEXT, - commit_sha TEXT, - replicas INTEGER NOT NULL DEFAULT 1, - environment TEXT, - failure_reason TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS deployment_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - deployment_id TEXT NOT NULL, - sequence INTEGER NOT NULL, - stage TEXT NOT NULL, - message TEXT NOT NULL, - created_at TEXT NOT NULL, - FOREIGN KEY(deployment_id) REFERENCES deployments(id) - ); - - CREATE UNIQUE INDEX IF NOT EXISTS idx_logs_dep_seq - ON deployment_logs(deployment_id, sequence); - - CREATE TABLE IF NOT EXISTS projects ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - repo_url TEXT, - repo_branch TEXT, - base_domain TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS environment_variables ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - key TEXT NOT NULL, - value TEXT NOT NULL, - environment TEXT NOT NULL DEFAULT 'production', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_env_vars_project - ON environment_variables(project_id, environment); - - CREATE TABLE IF NOT EXISTS volumes ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - mount_path TEXT NOT NULL DEFAULT '/app/data', - size_mb INTEGER, - docker_volume_name TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS databases ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - type TEXT NOT NULL, - database_name TEXT NOT NULL, - username TEXT NOT NULL, - password TEXT NOT NULL, - internal_host TEXT NOT NULL, - internal_port INTEGER NOT NULL, - connection_string TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'provisioning', - container_name TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS domains ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - domain TEXT NOT NULL, - type TEXT NOT NULL DEFAULT 'custom', - validation_status TEXT NOT NULL DEFAULT 'pending', - ssl_status TEXT NOT NULL DEFAULT 'pending', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS scaling_policies ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL UNIQUE, - min_replicas INTEGER NOT NULL DEFAULT 1, - max_replicas INTEGER NOT NULL DEFAULT 5, - cpu_threshold_percent INTEGER NOT NULL DEFAULT 70, - memory_threshold_percent INTEGER NOT NULL DEFAULT 85, - cooldown_seconds INTEGER NOT NULL DEFAULT 120, - enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS servers ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - host TEXT NOT NULL, - port INTEGER NOT NULL DEFAULT 2375, - auth_token TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending', - cpu_total INTEGER, - memory_total_mb INTEGER, - disk_total_mb INTEGER, - cpu_used_percent REAL, - memory_used_mb INTEGER, - last_heartbeat TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS api_keys ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - key_hash TEXT NOT NULL, - permissions TEXT NOT NULL DEFAULT 'deploy:read', - created_at TEXT NOT NULL, - last_used_at TEXT - ); - - CREATE TABLE IF NOT EXISTS alerts ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - type TEXT NOT NULL, - threshold REAL, - duration_seconds INTEGER, - channel TEXT NOT NULL DEFAULT 'email', - destination TEXT, - enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE - ); - `); + const db = await getDrizzle(); + drizzleMigrate(db, { migrationsFolder: import.meta.dirname + "/migrations" }); }; diff --git a/apps/api/src/db/migrations/0000_baseline.sql b/apps/api/src/db/migrations/0000_baseline.sql new file mode 100644 index 0000000..0f93c5d --- /dev/null +++ b/apps/api/src/db/migrations/0000_baseline.sql @@ -0,0 +1,166 @@ +CREATE TABLE IF NOT EXISTS `github_integrations` ( + `id` text PRIMARY KEY NOT NULL, + `client_id` text NOT NULL, + `client_secret` text NOT NULL, + `app_name` text NOT NULL DEFAULT 'Dequel', + `webhook_secret` text, + `created_at` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `projects` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `description` text, + `repo_url` text, + `repo_branch` text, + `base_domain` text, + `cpu_limit` real, + `memory_limit_mb` integer, + `github_token_encrypted` text, + `github_token_iv` text, + `github_token_tag` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `deployments` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text, + `source_type` text NOT NULL, + `source_ref` text NOT NULL, + `status` text NOT NULL DEFAULT 'pending', + `image_tag` text, + `container_name` text, + `route_path` text, + `live_url` text, + `branch` text, + `commit_sha` text, + `replicas` integer NOT NULL DEFAULT 1, + `environment` text, + `failure_reason` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `deployment_logs` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `deployment_id` text NOT NULL, + `sequence` integer NOT NULL, + `stage` text NOT NULL, + `message` text NOT NULL, + `created_at` text NOT NULL, + FOREIGN KEY (`deployment_id`) REFERENCES `deployments`(`id`) +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `idx_logs_dep_seq` ON `deployment_logs` (`deployment_id`, `sequence`); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `environment_variables` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `key` text NOT NULL, + `value` text NOT NULL, + `value_encrypted` text, + `value_iv` text, + `value_tag` text, + `environment` text NOT NULL DEFAULT 'production', + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_env_vars_project` ON `environment_variables` (`project_id`, `environment`); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `volumes` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `mount_path` text NOT NULL DEFAULT '/app/data', + `size_mb` integer, + `docker_volume_name` text, + `created_at` text NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `databases` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `type` text NOT NULL, + `version` text, + `database_name` text NOT NULL, + `username` text NOT NULL, + `password` text NOT NULL, + `internal_host` text NOT NULL, + `internal_port` integer NOT NULL, + `cpu_limit` real, + `memory_limit_mb` integer, + `connection_string` text NOT NULL, + `status` text NOT NULL DEFAULT 'provisioning', + `container_name` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `domains` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `domain` text NOT NULL, + `type` text NOT NULL DEFAULT 'custom', + `validation_status` text NOT NULL DEFAULT 'pending', + `ssl_status` text NOT NULL DEFAULT 'pending', + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `scaling_policies` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL UNIQUE, + `min_replicas` integer NOT NULL DEFAULT 1, + `max_replicas` integer NOT NULL DEFAULT 5, + `cpu_threshold_percent` integer NOT NULL DEFAULT 70, + `memory_threshold_percent` integer NOT NULL DEFAULT 85, + `cooldown_seconds` integer NOT NULL DEFAULT 120, + `enabled` integer NOT NULL DEFAULT 1, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `servers` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `host` text NOT NULL, + `port` integer NOT NULL DEFAULT 2375, + `auth_token` text NOT NULL DEFAULT '', + `status` text NOT NULL DEFAULT 'pending', + `cpu_total` integer, + `memory_total_mb` integer, + `disk_total_mb` integer, + `cpu_used_percent` real, + `memory_used_mb` integer, + `last_heartbeat` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `api_keys` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `key_hash` text NOT NULL, + `permissions` text NOT NULL DEFAULT 'deploy:read', + `created_at` text NOT NULL, + `last_used_at` text +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `alerts` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `type` text NOT NULL, + `threshold` real, + `duration_seconds` integer, + `channel` text NOT NULL DEFAULT 'email', + `destination` text, + `enabled` integer NOT NULL DEFAULT 1, + `created_at` text NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE +); diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json new file mode 100644 index 0000000..d15851e --- /dev/null +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "6", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1718000000000, + "tag": "0000_baseline", + "breakpoints": true + } + ] +} diff --git a/apps/api/src/db/repo.ts b/apps/api/src/db/repo.ts index 1993d61..9d3b5f8 100644 --- a/apps/api/src/db/repo.ts +++ b/apps/api/src/db/repo.ts @@ -1,765 +1 @@ -import { randomUUID } from 'node:crypto'; -import { getDb } from './client'; -import { config } from '../utils/config'; -import { decryptValue, encryptValue } from '../utils/crypto'; -import type { - Alert, ApiKey, CreateAlertInput, CreateApiKeyInput, CreateDatabaseInput, - CreateDeploymentInput, CreateDomainInput, CreateEnvironmentVariableInput, - CreateProjectInput, CreateScalingPolicyInput, CreateServerInput, CreateVolumeInput, - Database, DatabaseStatus, Deployment, DeploymentLog, DeploymentStatus, - Domain, DomainValidationStatus, EnvironmentVariable, LogEvent, PaginatedResult, Project, - ScalingPolicy, Server, ServerStatus, SslStatus, Volume, -} from '../types'; - -const now = () => new Date().toISOString(); - -// ─── Mappers ──────────────────────────────────────────── - -const mapDeployment = (row: any): Deployment => ({ - id: row.id, - projectId: row.project_id, - sourceType: row.source_type, - sourceRef: row.source_ref, - status: row.status, - imageTag: row.image_tag, - containerName: row.container_name, - routePath: row.route_path, - liveUrl: row.live_url, - branch: row.branch, - commitSha: row.commit_sha, - replicas: row.replicas ?? 1, - environment: row.environment, - failureReason: row.failure_reason, - createdAt: row.created_at, - updatedAt: row.updated_at, -}); - -const mapProject = (row: any): Project => ({ - id: row.id, - name: row.name, - description: row.description, - repoUrl: row.repo_url, - repoBranch: row.repo_branch, - baseDomain: row.base_domain, - cpuLimit: row.cpu_limit, - memoryLimitMb: row.memory_limit_mb, - createdAt: row.created_at, - updatedAt: row.updated_at, -}); - -const mapEnvVar = (row: any): EnvironmentVariable => ({ - id: row.id, - projectId: row.project_id, - key: row.key, - value: row.value ?? '', - environment: row.environment, - createdAt: row.created_at, - updatedAt: row.updated_at, -}); - -const mapVolume = (row: any): Volume => ({ - id: row.id, - projectId: row.project_id, - mountPath: row.mount_path, - sizeMb: row.size_mb, - dockerVolumeName: row.docker_volume_name, - createdAt: row.created_at, -}); - -const mapDatabase = (row: any): Database => ({ - id: row.id, - projectId: row.project_id, - type: row.type, - version: row.version, - databaseName: row.database_name, - username: row.username, - password: row.password, - internalHost: row.internal_host, - internalPort: row.internal_port, - cpuLimit: row.cpu_limit, - memoryLimitMb: row.memory_limit_mb, - connectionString: row.connection_string, - status: row.status, - containerName: row.container_name, - createdAt: row.created_at, - updatedAt: row.updated_at, -}); - -const mapDomain = (row: any): Domain => ({ - id: row.id, - projectId: row.project_id, - domain: row.domain, - type: row.type, - validationStatus: row.validation_status, - sslStatus: row.ssl_status, - createdAt: row.created_at, - updatedAt: row.updated_at, -}); - -const mapScalingPolicy = (row: any): ScalingPolicy => ({ - id: row.id, - projectId: row.project_id, - minReplicas: row.min_replicas, - maxReplicas: row.max_replicas, - cpuThresholdPercent: row.cpu_threshold_percent, - memoryThresholdPercent: row.memory_threshold_percent, - cooldownSeconds: row.cooldown_seconds, - enabled: row.enabled === 1, - createdAt: row.created_at, - updatedAt: row.updated_at, -}); - -const mapServer = (row: any): Server => ({ - id: row.id, - name: row.name, - host: row.host, - port: row.port, - authToken: row.auth_token, - status: row.status, - cpuTotal: row.cpu_total, - memoryTotalMb: row.memory_total_mb, - diskTotalMb: row.disk_total_mb, - cpuUsedPercent: row.cpu_used_percent, - memoryUsedMb: row.memory_used_mb, - lastHeartbeat: row.last_heartbeat, - createdAt: row.created_at, - updatedAt: row.updated_at, -}); - -const mapApiKey = (row: any): ApiKey => ({ - id: row.id, - name: row.name, - keyHash: row.key_hash, - permissions: row.permissions, - createdAt: row.created_at, - lastUsedAt: row.last_used_at, -}); - -const mapAlert = (row: any): Alert => ({ - id: row.id, - projectId: row.project_id, - type: row.type, - threshold: row.threshold, - durationSeconds: row.duration_seconds, - channel: row.channel, - destination: row.destination, - enabled: row.enabled === 1, - createdAt: row.created_at, -}); - -// ─── Deployments ──────────────────────────────────────── - -export const createDeployment = async (input: CreateDeploymentInput): Promise => { - const db = await getDb(); - const id = randomUUID(); - const timestamp = now(); - db.query( - `INSERT INTO deployments - (id, project_id, source_type, source_ref, status, route_path, branch, commit_sha, environment, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - id, input.projectId ?? null, input.sourceType, input.sourceRef, - 'pending', `/apps/${id}`, input.branch ?? null, input.commitSha ?? null, - input.environment ?? null, timestamp, timestamp, - ); - return mapDeployment(db.query('SELECT * FROM deployments WHERE id = ?').get(id)); -}; - -export const listDeployments = async (projectId?: string, offset = 0, limit = 50): Promise => { - const db = await getDb(); - const rows = projectId - ? db.query('SELECT * FROM deployments WHERE project_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?').all(projectId, limit, offset) - : db.query('SELECT * FROM deployments ORDER BY created_at DESC LIMIT ? OFFSET ?').all(limit, offset); - return rows.map(mapDeployment); -}; - -export const countDeployments = async (projectId?: string): Promise => { - const db = await getDb(); - const row = projectId - ? db.query('SELECT COUNT(*) as count FROM deployments WHERE project_id = ?').get(projectId) as any - : db.query('SELECT COUNT(*) as count FROM deployments').get() as any; - return row.count; -}; - -export const getDeploymentById = async (id: string): Promise => { - const db = await getDb(); - const row = db.query('SELECT * FROM deployments WHERE id = ?').get(id); - return row ? mapDeployment(row) : null; -}; - -export const updateDeploymentCommitSha = async (id: string, commitSha: string) => { - const db = await getDb(); - db.query('UPDATE deployments SET commit_sha = ?, updated_at = ? WHERE id = ?').run(commitSha, now(), id); -}; - -export const updateDeploymentStatus = async ( - id: string, - status: DeploymentStatus, - patch: Partial> = {}, -) => { - const db = await getDb(); - db.query( - `UPDATE deployments - SET status = ?, - image_tag = COALESCE(?, image_tag), - container_name = COALESCE(?, container_name), - live_url = COALESCE(?, live_url), - failure_reason = COALESCE(?, failure_reason), - replicas = COALESCE(?, replicas), - updated_at = ? - WHERE id = ?`, - ).run( - status, - patch.imageTag ?? null, - patch.containerName ?? null, - patch.liveUrl ?? null, - patch.failureReason ?? null, - patch.replicas ?? null, - now(), - id, - ); -}; - -export const deleteDeploymentAndLogs = async (id: string): Promise => { - const db = await getDb(); - db.query('DELETE FROM deployment_logs WHERE deployment_id = ?').run(id); - const result = db.query('DELETE FROM deployments WHERE id = ?').run(id); - return result.changes > 0; -}; - -export const appendLog = async ( - deploymentId: string, - stage: LogEvent['stage'], - message: string, -): Promise => { - const db = await getDb(); - const createdAt = now(); - const row = db.query( - 'SELECT COALESCE(MAX(sequence), 0) as max_sequence FROM deployment_logs WHERE deployment_id = ?', - ).get(deploymentId); - const sequence = Number(row.max_sequence) + 1; - const result = db.query( - 'INSERT INTO deployment_logs (deployment_id, sequence, stage, message, created_at) VALUES (?, ?, ?, ?, ?)', - ).run(deploymentId, sequence, stage, message, createdAt); - return { - id: Number(result.lastInsertRowid), - deploymentId, sequence, stage, message, createdAt, - }; -}; - -export const getLogs = async (deploymentId: string): Promise => { - const db = await getDb(); - const rows = db.query( - 'SELECT id, deployment_id, sequence, stage, message, created_at FROM deployment_logs WHERE deployment_id = ? ORDER BY sequence ASC', - ).all(deploymentId); - return rows.map((row: any) => ({ - id: row.id, - deploymentId: row.deployment_id, - sequence: row.sequence, - stage: row.stage, - message: row.message, - createdAt: row.created_at, - })); -}; - -// ─── Projects ────────────────────────────────────────── - -export const createProject = async (input: CreateProjectInput): Promise => { - const db = await getDb(); - const id = randomUUID(); - const timestamp = now(); - db.query( - 'INSERT INTO projects (id, name, description, repo_url, repo_branch, base_domain, cpu_limit, memory_limit_mb, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - ).run( - id, - input.name, - input.description ?? null, - input.repoUrl ?? null, - input.repoBranch ?? null, - input.baseDomain ?? null, - input.cpuLimit ?? null, - input.memoryLimitMb ?? null, - timestamp, - timestamp, - ); - return mapProject(db.query('SELECT * FROM projects WHERE id = ?').get(id)); -}; - -export const listProjects = async (): Promise => { - const db = await getDb(); - return db.query('SELECT * FROM projects ORDER BY name ASC').all().map(mapProject); -}; - -export const getProjectById = async (id: string): Promise => { - const db = await getDb(); - const row = db.query('SELECT * FROM projects WHERE id = ?').get(id); - return row ? mapProject(row) : null; -}; - -export const updateProject = async (id: string, patch: Partial): Promise => { - const db = await getDb(); - const existing = await getProjectById(id); - if (!existing) return null; - const name = patch.name ?? existing.name; - const description = patch.description !== undefined ? patch.description : existing.description; - const repoUrl = patch.repoUrl !== undefined ? patch.repoUrl : existing.repoUrl; - const repoBranch = patch.repoBranch !== undefined ? patch.repoBranch : existing.repoBranch; - const baseDomain = patch.baseDomain !== undefined ? patch.baseDomain : existing.baseDomain; - const cpuLimit = patch.cpuLimit !== undefined ? patch.cpuLimit : existing.cpuLimit; - const memoryLimitMb = patch.memoryLimitMb !== undefined ? patch.memoryLimitMb : existing.memoryLimitMb; - db.query( - 'UPDATE projects SET name = ?, description = ?, repo_url = ?, repo_branch = ?, base_domain = ?, cpu_limit = ?, memory_limit_mb = ?, updated_at = ? WHERE id = ?', - ).run(name, description, repoUrl, repoBranch, baseDomain, cpuLimit ?? null, memoryLimitMb ?? null, now(), id); - return getProjectById(id); -}; - -export const deleteProject = async (id: string): Promise => { - const db = await getDb(); - const result = db.query('DELETE FROM projects WHERE id = ?').run(id); - return result.changes > 0; -}; - -// ─── Cascade Delete ──────────────────────────────────── - -export interface ProjectCleanupInfo { - deploymentContainerNames: string[]; - deploymentImageTags: string[]; - databaseContainerNames: string[]; - databaseVolumeNames: string[]; - volumeDockerNames: string[]; - domains: { domain: string; projectName: string }[]; - slug: string; - projectName: string; -} - -export const deleteProjectCascade = async (id: string): Promise => { - const db = await getDb(); - const project = await getProjectById(id); - if (!project) return null; - - const slug = project.name - ? project.name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63) - : id; - - // Collect deployments - const deployments = db.query('SELECT id, container_name, image_tag FROM deployments WHERE project_id = ?').all(id) as any[]; - const deploymentContainerNames = deployments.filter(d => d.container_name).map(d => d.container_name); - const deploymentImageTags = deployments.filter(d => d.image_tag).map(d => d.image_tag); - - // Delete deployment_logs then deployments - for (const dep of deployments) { - db.query('DELETE FROM deployment_logs WHERE deployment_id = ?').run(dep.id); - } - db.query('DELETE FROM deployments WHERE project_id = ?').run(id); - - // Delete environment_variables - db.query('DELETE FROM environment_variables WHERE project_id = ?').run(id); - - // Collect volumes - const volumeRows = db.query('SELECT docker_volume_name FROM volumes WHERE project_id = ?').all(id) as any[]; - const volumeDockerNames = volumeRows.filter(v => v.docker_volume_name).map(v => v.docker_volume_name); - db.query('DELETE FROM volumes WHERE project_id = ?').run(id); - - // Collect databases - const dbRows = db.query('SELECT container_name, internal_host, id FROM databases WHERE project_id = ?').all(id) as any[]; - const databaseContainerNames = dbRows.filter(d => d.container_name).map(d => d.container_name); - const databaseVolumeNames = dbRows.map(d => `db-${d.id.slice(0, 12)}`); - db.query('DELETE FROM databases WHERE project_id = ?').run(id); - - // Collect domains - const domainRows = db.query('SELECT domain FROM domains WHERE project_id = ?').all(id) as any[]; - const domains = domainRows.map(d => ({ domain: d.domain, projectName: project.name ?? id })); - db.query('DELETE FROM domains WHERE project_id = ?').run(id); - - // Delete scaling policies - db.query('DELETE FROM scaling_policies WHERE project_id = ?').run(id); - - // Delete alerts - db.query('DELETE FROM alerts WHERE project_id = ?').run(id); - - // Delete the project itself - db.query('DELETE FROM projects WHERE id = ?').run(id); - - return { - deploymentContainerNames, - deploymentImageTags, - databaseContainerNames, - databaseVolumeNames, - volumeDockerNames, - domains, - slug, - projectName: project.name ?? id, - }; -}; - -// ─── Environment Variables ────────────────────────────── - -export const createEnvironmentVariable = async (input: CreateEnvironmentVariableInput): Promise => { - const db = await getDb(); - const id = randomUUID(); - const timestamp = now(); - const env = input.environment ?? 'production'; - const encrypted = encryptValue(input.value, config.envEncryptionKey); - db.query( - 'INSERT INTO environment_variables (id, project_id, key, value, value_encrypted, value_iv, value_tag, environment, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - ).run(id, input.projectId, input.key, '', encrypted.encrypted, encrypted.iv, encrypted.tag, env, timestamp, timestamp); - return mapEnvVar(db.query('SELECT * FROM environment_variables WHERE id = ?').get(id)); -}; - -export const listEnvironmentVariables = async (projectId: string, environment?: string): Promise => { - const db = await getDb(); - const rows = environment - ? db.query('SELECT * FROM environment_variables WHERE project_id = ? AND environment = ? ORDER BY key ASC').all(projectId, environment) - : db.query('SELECT * FROM environment_variables WHERE project_id = ? ORDER BY key ASC').all(projectId); - return rows.map(mapEnvVar); -}; - -export const getEnvironmentVariablePlaintext = async (id: string): Promise => { - const db = await getDb(); - const row = db.query('SELECT value, value_encrypted, value_iv, value_tag FROM environment_variables WHERE id = ?').get(id) as any; - if (!row) return null; - if (row.value_encrypted && row.value_iv && row.value_tag) { - return decryptValue(row.value_encrypted, row.value_iv, row.value_tag, config.envEncryptionKey); - } - return row.value ?? null; -}; - -export const getEnvironmentVariableById = async (id: string): Promise => { - const db = await getDb(); - const row = db.query('SELECT * FROM environment_variables WHERE id = ?').get(id); - return row ? mapEnvVar(row) : null; -}; - -export const updateEnvironmentVariable = async (id: string, value: string): Promise => { - const db = await getDb(); - const existing = await getEnvironmentVariableById(id); - if (!existing) return null; - const encrypted = encryptValue(value, config.envEncryptionKey); - db.query('UPDATE environment_variables SET value = ?, value_encrypted = ?, value_iv = ?, value_tag = ?, updated_at = ? WHERE id = ?', - ).run('', encrypted.encrypted, encrypted.iv, encrypted.tag, now(), id); - return getEnvironmentVariableById(id); -}; - -export const listEnvironmentVariablesForDeploy = async (projectId: string, environment?: string): Promise<{ key: string; value: string }[]> => { - const db = await getDb(); - const rows = environment - ? db.query('SELECT key, value, value_encrypted, value_iv, value_tag FROM environment_variables WHERE project_id = ? AND environment = ? ORDER BY key ASC').all(projectId, environment) - : db.query('SELECT key, value, value_encrypted, value_iv, value_tag FROM environment_variables WHERE project_id = ? ORDER BY key ASC').all(projectId); - return rows.map((row: any) => { - if (row.value_encrypted && row.value_iv && row.value_tag) { - return { key: row.key, value: decryptValue(row.value_encrypted, row.value_iv, row.value_tag, config.envEncryptionKey) }; - } - return { key: row.key, value: row.value ?? '' }; - }); -}; - -export const deleteEnvironmentVariable = async (id: string): Promise => { - const db = await getDb(); - return db.query('DELETE FROM environment_variables WHERE id = ?').run(id).changes > 0; -}; - -// ─── Volumes ──────────────────────────────────────────── - -export const createVolume = async (input: CreateVolumeInput): Promise => { - const db = await getDb(); - const id = randomUUID(); - const timestamp = now(); - const mountPath = input.mountPath ?? '/app/data'; - const volumeName = `vol-${id.slice(0, 8)}`; - db.query( - 'INSERT INTO volumes (id, project_id, mount_path, docker_volume_name, created_at) VALUES (?, ?, ?, ?, ?)', - ).run(id, input.projectId, mountPath, volumeName, timestamp); - return mapVolume(db.query('SELECT * FROM volumes WHERE id = ?').get(id)); -}; - -export const listVolumes = async (projectId: string): Promise => { - const db = await getDb(); - return db.query('SELECT * FROM volumes WHERE project_id = ? ORDER BY created_at DESC').all(projectId).map(mapVolume); -}; - -export const getVolumeById = async (id: string): Promise => { - const db = await getDb(); - const row = db.query('SELECT * FROM volumes WHERE id = ?').get(id); - return row ? mapVolume(row) : null; -}; - -export const deleteVolume = async (id: string): Promise => { - const db = await getDb(); - return db.query('DELETE FROM volumes WHERE id = ?').run(id).changes > 0; -}; - -// ─── Databases ───────────────────────────────────────── - -export const createDatabase = async (input: CreateDatabaseInput): Promise => { - const db = await getDb(); - const id = randomUUID(); - const timestamp = now(); - const dbName = `db_${id.slice(0, 8)}`; - const username = `user_${id.slice(0, 8)}`; - const password = randomUUID().replace(/-/g, '').slice(0, 24); - const internalHost = `db-${id.slice(0, 8)}`; - const internalPort = input.type === 'mysql' ? 3306 : 5432; - const version = input.version ?? null; - const cpuLimit = input.cpuLimit ?? null; - const memoryLimitMb = input.memoryLimitMb ?? null; - const connStr = input.type === 'mysql' - ? `mysql://${username}:${password}@${internalHost}:${internalPort}/${dbName}` - : `postgresql://${username}:${password}@${internalHost}:${internalPort}/${dbName}`; - db.query( - `INSERT INTO databases (id, project_id, type, version, database_name, username, password, internal_host, internal_port, cpu_limit, memory_limit_mb, connection_string, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - id, - input.projectId, - input.type, - version, - dbName, - username, - password, - internalHost, - internalPort, - cpuLimit, - memoryLimitMb, - connStr, - 'provisioning', - timestamp, - timestamp, - ); - return mapDatabase(db.query('SELECT * FROM databases WHERE id = ?').get(id)); -}; - -export const listAllDatabases = async (): Promise => { - const db = await getDb(); - return db.query('SELECT * FROM databases ORDER BY created_at DESC').all().map(mapDatabase); -}; - -export const listDatabases = async (projectId: string): Promise => { - const db = await getDb(); - return db.query('SELECT * FROM databases WHERE project_id = ? ORDER BY created_at DESC').all(projectId).map(mapDatabase); -}; - -export const getDatabaseById = async (id: string): Promise => { - const db = await getDb(); - const row = db.query('SELECT * FROM databases WHERE id = ?').get(id); - return row ? mapDatabase(row) : null; -}; - -export const updateDatabaseStatus = async (id: string, status: DatabaseStatus, containerName?: string): Promise => { - const db = await getDb(); - db.query( - 'UPDATE databases SET status = ?, container_name = COALESCE(?, container_name), updated_at = ? WHERE id = ?', - ).run(status, containerName ?? null, now(), id); -}; - -export const deleteDatabase = async (id: string): Promise => { - const db = await getDb(); - return db.query('DELETE FROM databases WHERE id = ?').run(id).changes > 0; -}; - -// ─── Domains ──────────────────────────────────────────── - -export const createDomain = async (input: CreateDomainInput): Promise => { - const db = await getDb(); - const id = randomUUID(); - const timestamp = now(); - db.query( - 'INSERT INTO domains (id, project_id, domain, type, validation_status, ssl_status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - ).run(id, input.projectId, input.domain, input.type, 'pending', 'pending', timestamp, timestamp); - return mapDomain(db.query('SELECT * FROM domains WHERE id = ?').get(id)); -}; - -export const listDomains = async (projectId: string): Promise => { - const db = await getDb(); - return db.query('SELECT * FROM domains WHERE project_id = ? ORDER BY created_at DESC').all(projectId).map(mapDomain); -}; - -export const getDomainById = async (id: string): Promise => { - const db = await getDb(); - const row = db.query('SELECT * FROM domains WHERE id = ?').get(id); - return row ? mapDomain(row) : null; -}; - -export const updateDomainValidation = async (id: string, validationStatus: DomainValidationStatus, sslStatus?: SslStatus): Promise => { - const db = await getDb(); - db.query( - 'UPDATE domains SET validation_status = ?, ssl_status = COALESCE(?, ssl_status), updated_at = ? WHERE id = ?', - ).run(validationStatus, sslStatus ?? null, now(), id); -}; - -export const updateDomainSslStatus = async (id: string, sslStatus: SslStatus): Promise => { - const db = await getDb(); - db.query('UPDATE domains SET ssl_status = ?, updated_at = ? WHERE id = ?').run(sslStatus, now(), id); -}; - -export const deleteDomain = async (id: string): Promise => { - const db = await getDb(); - return db.query('DELETE FROM domains WHERE id = ?').run(id).changes > 0; -}; - -// ─── Scaling Policies ─────────────────────────────────── - -export const upsertScalingPolicy = async (input: CreateScalingPolicyInput): Promise => { - const db = await getDb(); - const existing = db.query('SELECT * FROM scaling_policies WHERE project_id = ?').get(input.projectId) as any; - const timestamp = now(); - if (existing) { - const minReplicas = input.minReplicas ?? existing.min_replicas; - const maxReplicas = input.maxReplicas ?? existing.max_replicas; - const cpuThreshold = input.cpuThresholdPercent ?? existing.cpu_threshold_percent; - const memThreshold = input.memoryThresholdPercent ?? existing.memory_threshold_percent; - db.query( - `UPDATE scaling_policies SET min_replicas = ?, max_replicas = ?, cpu_threshold_percent = ?, memory_threshold_percent = ?, updated_at = ? WHERE project_id = ?`, - ).run(minReplicas, maxReplicas, cpuThreshold, memThreshold, timestamp, input.projectId); - return mapScalingPolicy(db.query('SELECT * FROM scaling_policies WHERE project_id = ?').get(input.projectId)); - } - const id = randomUUID(); - db.query( - `INSERT INTO scaling_policies (id, project_id, min_replicas, max_replicas, cpu_threshold_percent, memory_threshold_percent, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - id, input.projectId, - input.minReplicas ?? 1, input.maxReplicas ?? 5, - input.cpuThresholdPercent ?? 70, input.memoryThresholdPercent ?? 85, - timestamp, timestamp, - ); - return mapScalingPolicy(db.query('SELECT * FROM scaling_policies WHERE id = ?').get(id)); -}; - -export const getScalingPolicy = async (projectId: string): Promise => { - const db = await getDb(); - const row = db.query('SELECT * FROM scaling_policies WHERE project_id = ?').get(projectId); - return row ? mapScalingPolicy(row) : null; -}; - -export const deleteScalingPolicy = async (projectId: string): Promise => { - const db = await getDb(); - return db.query('DELETE FROM scaling_policies WHERE project_id = ?').run(projectId).changes > 0; -}; - -// ─── Servers ──────────────────────────────────────────── - -export const createServer = async (input: CreateServerInput): Promise => { - const db = await getDb(); - const id = randomUUID(); - const timestamp = now(); - db.query( - 'INSERT INTO servers (id, name, host, port, auth_token, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - ).run(id, input.name, input.host, input.port ?? 2375, input.authToken, 'pending', timestamp, timestamp); - return mapServer(db.query('SELECT * FROM servers WHERE id = ?').get(id)); -}; - -export const listServers = async (): Promise => { - const db = await getDb(); - return db.query('SELECT * FROM servers ORDER BY name ASC').all().map(mapServer); -}; - -export const getServerById = async (id: string): Promise => { - const db = await getDb(); - const row = db.query('SELECT * FROM servers WHERE id = ?').get(id); - return row ? mapServer(row) : null; -}; - -export const updateServerStatus = async (id: string, status: ServerStatus, resources?: { - cpuTotal?: number; memoryTotalMb?: number; diskTotalMb?: number; - cpuUsedPercent?: number; memoryUsedMb?: number; -}): Promise => { - const db = await getDb(); - db.query( - `UPDATE servers SET status = ?, cpu_total = COALESCE(?, cpu_total), memory_total_mb = COALESCE(?, memory_total_mb), - disk_total_mb = COALESCE(?, disk_total_mb), cpu_used_percent = COALESCE(?, cpu_used_percent), - memory_used_mb = COALESCE(?, memory_used_mb), last_heartbeat = COALESCE(?, last_heartbeat), updated_at = ? - WHERE id = ?`, - ).run( - status, - resources?.cpuTotal ?? null, resources?.memoryTotalMb ?? null, resources?.diskTotalMb ?? null, - resources?.cpuUsedPercent ?? null, resources?.memoryUsedMb ?? null, - resources ? now() : null, now(), id, - ); -}; - -export const deleteServer = async (id: string): Promise => { - const db = await getDb(); - return db.query('DELETE FROM servers WHERE id = ?').run(id).changes > 0; -}; - -// ─── API Keys ─────────────────────────────────────────── - -export const createApiKey = async (input: CreateApiKeyInput): Promise<{ key: ApiKey; rawKey: string }> => { - const db = await getDb(); - const id = randomUUID(); - const timestamp = now(); - const rawKey = `dql_${randomUUID().replace(/-/g, '')}${randomUUID().replace(/-/g, '')}`; - - // Simple SHA-256 hash (Bun has built-in crypto) - const encoder = new TextEncoder(); - const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(rawKey)); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const keyHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - - db.query( - 'INSERT INTO api_keys (id, name, key_hash, permissions, created_at) VALUES (?, ?, ?, ?, ?)', - ).run(id, input.name, keyHash, input.permissions ?? 'deploy:read', timestamp); - return { - key: mapApiKey(db.query('SELECT * FROM api_keys WHERE id = ?').get(id)), - rawKey, - }; -}; - -export const listApiKeys = async (): Promise => { - const db = await getDb(); - return db.query('SELECT * FROM api_keys ORDER BY created_at DESC').all().map(mapApiKey); -}; - -export const deleteApiKey = async (id: string): Promise => { - const db = await getDb(); - return db.query('DELETE FROM api_keys WHERE id = ?').run(id).changes > 0; -}; - -export const validateApiKey = async (rawKey: string): Promise => { - const db = await getDb(); - const encoder = new TextEncoder(); - const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(rawKey)); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const keyHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - const row = db.query('SELECT * FROM api_keys WHERE key_hash = ?').get(keyHash); - if (!row) return null; - db.query('UPDATE api_keys SET last_used_at = ? WHERE id = ?').run(now(), row.id); - return mapApiKey(row); -}; - -// ─── Alerts ───────────────────────────────────────────── - -export const createAlert = async (input: CreateAlertInput): Promise => { - const db = await getDb(); - const id = randomUUID(); - const timestamp = now(); - db.query( - 'INSERT INTO alerts (id, project_id, type, threshold, duration_seconds, channel, destination, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - ).run(id, input.projectId, input.type, input.threshold ?? null, input.durationSeconds ?? null, input.channel, input.destination ?? null, timestamp); - return mapAlert(db.query('SELECT * FROM alerts WHERE id = ?').get(id)); -}; - -export const listAlerts = async (projectId?: string): Promise => { - const db = await getDb(); - const rows = projectId - ? db.query('SELECT * FROM alerts WHERE project_id = ? ORDER BY created_at DESC').all(projectId) - : db.query('SELECT * FROM alerts ORDER BY created_at DESC').all(); - return rows.map(mapAlert); -}; - -export const getAlertById = async (id: string): Promise => { - const db = await getDb(); - const row = db.query('SELECT * FROM alerts WHERE id = ?').get(id); - return row ? mapAlert(row) : null; -}; - -export const updateAlertEnabled = async (id: string, enabled: boolean): Promise => { - const db = await getDb(); - db.query('UPDATE alerts SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, id); - return getAlertById(id); -}; - -export const deleteAlert = async (id: string): Promise => { - const db = await getDb(); - return db.query('DELETE FROM alerts WHERE id = ?').run(id).changes > 0; -}; +export * from "./repo/index"; diff --git a/apps/api/src/db/repo/alerts.ts b/apps/api/src/db/repo/alerts.ts new file mode 100644 index 0000000..7fe513c --- /dev/null +++ b/apps/api/src/db/repo/alerts.ts @@ -0,0 +1,62 @@ +import { eq, desc } from "drizzle-orm"; +import { getDrizzle } from "../drizzle"; +import { alerts } from "../schema"; +import type { Alert, CreateAlertInput } from "../../types"; +import { randomUUID } from "node:crypto"; +import { now } from "./helpers"; + +const mapAlert = (row: typeof alerts.$inferSelect): Alert => ({ + id: row.id, + projectId: row.projectId, + type: row.type as Alert["type"], + threshold: row.threshold, + durationSeconds: row.durationSeconds, + channel: row.channel as Alert["channel"], + destination: row.destination, + enabled: row.enabled === 1, + createdAt: row.createdAt, +}); + +export const createAlert = async (input: CreateAlertInput): Promise => { + const id = randomUUID(); + const timestamp = now(); + const db = await getDrizzle(); + db.insert(alerts).values({ + id, + projectId: input.projectId, + type: input.type, + threshold: input.threshold ?? null, + durationSeconds: input.durationSeconds ?? null, + channel: input.channel, + destination: input.destination ?? null, + createdAt: timestamp, + }).run(); + const row = db.select().from(alerts).where(eq(alerts.id, id)).get()!; + return mapAlert(row); +}; + +export const listAlerts = async (projectId?: string): Promise => { + const db = await getDrizzle(); + const cond = projectId ? eq(alerts.projectId, projectId) : undefined; + const rows = cond + ? db.select().from(alerts).where(cond).orderBy(desc(alerts.createdAt)).all() + : db.select().from(alerts).orderBy(desc(alerts.createdAt)).all(); + return rows.map(mapAlert); +}; + +export const getAlertById = async (id: string): Promise => { + const db = await getDrizzle(); + const row = db.select().from(alerts).where(eq(alerts.id, id)).get(); + return row ? mapAlert(row) : null; +}; + +export const updateAlertEnabled = async (id: string, enabled: boolean): Promise => { + const db = await getDrizzle(); + db.update(alerts).set({ enabled: enabled ? 1 : 0 }).where(eq(alerts.id, id)).run(); + return getAlertById(id); +}; + +export const deleteAlert = async (id: string): Promise => { + const db = await getDrizzle(); + return db.delete(alerts).where(eq(alerts.id, id)).run().changes > 0; +}; diff --git a/apps/api/src/db/repo/api-keys.ts b/apps/api/src/db/repo/api-keys.ts new file mode 100644 index 0000000..cd45d34 --- /dev/null +++ b/apps/api/src/db/repo/api-keys.ts @@ -0,0 +1,59 @@ +import { eq, desc } from "drizzle-orm"; +import { getDrizzle } from "../drizzle"; +import { apiKeys } from "../schema"; +import type { ApiKey, CreateApiKeyInput } from "../../types"; +import { randomUUID } from "node:crypto"; +import { now } from "./helpers"; + +const mapApiKey = (row: typeof apiKeys.$inferSelect): ApiKey => ({ + id: row.id, + name: row.name, + keyHash: row.keyHash, + permissions: row.permissions, + createdAt: row.createdAt, + lastUsedAt: row.lastUsedAt, +}); + +export const createApiKey = async (input: CreateApiKeyInput): Promise<{ key: ApiKey; rawKey: string }> => { + const id = randomUUID(); + const timestamp = now(); + const rawKey = `dql_${randomUUID().replace(/-/g, "")}${randomUUID().replace(/-/g, "")}`; + const encoder = new TextEncoder(); + const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(rawKey)); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const keyHash = hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); + const db = await getDrizzle(); + db.insert(apiKeys).values({ + id, + name: input.name, + keyHash, + permissions: input.permissions ?? "deploy:read", + createdAt: timestamp, + }).run(); + return { + key: mapApiKey(db.select().from(apiKeys).where(eq(apiKeys.id, id)).get()!), + rawKey, + }; +}; + +export const listApiKeys = async (): Promise => { + const db = await getDrizzle(); + return db.select().from(apiKeys).orderBy(desc(apiKeys.createdAt)).all().map(mapApiKey); +}; + +export const deleteApiKey = async (id: string): Promise => { + const db = await getDrizzle(); + return db.delete(apiKeys).where(eq(apiKeys.id, id)).run().changes > 0; +}; + +export const validateApiKey = async (rawKey: string): Promise => { + const encoder = new TextEncoder(); + const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(rawKey)); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const keyHash = hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); + const db = await getDrizzle(); + const row = db.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash)).get(); + if (!row) return null; + db.update(apiKeys).set({ lastUsedAt: now() }).where(eq(apiKeys.id, row.id)).run(); + return mapApiKey(row); +}; diff --git a/apps/api/src/db/repo/databases.ts b/apps/api/src/db/repo/databases.ts new file mode 100644 index 0000000..0e0df08 --- /dev/null +++ b/apps/api/src/db/repo/databases.ts @@ -0,0 +1,86 @@ +import { eq, desc } from "drizzle-orm"; +import { getDrizzle } from "../drizzle"; +import { databases } from "../schema"; +import type { Database, CreateDatabaseInput, DatabaseStatus } from "../../types"; +import { randomUUID } from "node:crypto"; +import { now } from "./helpers"; + +const mapDatabase = (row: typeof databases.$inferSelect): Database => ({ + id: row.id, + projectId: row.projectId, + type: row.type as Database["type"], + version: row.version, + databaseName: row.databaseName, + username: row.username, + password: row.password, + internalHost: row.internalHost, + internalPort: row.internalPort, + cpuLimit: row.cpuLimit, + memoryLimitMb: row.memoryLimitMb, + connectionString: row.connectionString, + status: row.status as DatabaseStatus, + containerName: row.containerName, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +}); + +export const createDatabase = async (input: CreateDatabaseInput): Promise => { + const id = randomUUID(); + const timestamp = now(); + const dbName = `db_${id.slice(0, 8)}`; + const username = `user_${id.slice(0, 8)}`; + const password = randomUUID().replace(/-/g, "").slice(0, 24); + const internalHost = `db-${id.slice(0, 8)}`; + const internalPort = input.type === "mysql" ? 3306 : 5432; + const connStr = input.type === "mysql" + ? `mysql://${username}:${password}@${internalHost}:${internalPort}/${dbName}` + : `postgresql://${username}:${password}@${internalHost}:${internalPort}/${dbName}`; + const db = await getDrizzle(); + db.insert(databases).values({ + id, + projectId: input.projectId, + type: input.type, + version: input.version ?? null, + databaseName: dbName, + username, + password, + internalHost, + internalPort, + cpuLimit: input.cpuLimit ?? null, + memoryLimitMb: input.memoryLimitMb ?? null, + connectionString: connStr, + status: "provisioning", + createdAt: timestamp, + updatedAt: timestamp, + }).run(); + const row = db.select().from(databases).where(eq(databases.id, id)).get()!; + return mapDatabase(row); +}; + +export const listAllDatabases = async (): Promise => { + const db = await getDrizzle(); + return db.select().from(databases).orderBy(desc(databases.createdAt)).all().map(mapDatabase); +}; + +export const listDatabases = async (projectId: string): Promise => { + const db = await getDrizzle(); + return db.select().from(databases).where(eq(databases.projectId, projectId)).orderBy(desc(databases.createdAt)).all().map(mapDatabase); +}; + +export const getDatabaseById = async (id: string): Promise => { + const db = await getDrizzle(); + const row = db.select().from(databases).where(eq(databases.id, id)).get(); + return row ? mapDatabase(row) : null; +}; + +export const updateDatabaseStatus = async (id: string, status: DatabaseStatus, containerName?: string): Promise => { + const db = await getDrizzle(); + const updates: Record = { status, updatedAt: now() }; + if (containerName !== undefined) updates.containerName = containerName; + db.update(databases).set(updates).where(eq(databases.id, id)).run(); +}; + +export const deleteDatabase = async (id: string): Promise => { + const db = await getDrizzle(); + return db.delete(databases).where(eq(databases.id, id)).run().changes > 0; +}; diff --git a/apps/api/src/db/repo/deployments.ts b/apps/api/src/db/repo/deployments.ts new file mode 100644 index 0000000..b4a983d --- /dev/null +++ b/apps/api/src/db/repo/deployments.ts @@ -0,0 +1,138 @@ +import { eq, desc } from "drizzle-orm"; +import { getDrizzle } from "../drizzle"; +import { deployments, deploymentLogs } from "../schema"; +import type { Deployment, DeploymentLog, CreateDeploymentInput, DeploymentStatus, LogEvent } from "../../types"; +import { randomUUID } from "node:crypto"; +import { now } from "./helpers"; + +const mapDeployment = (row: typeof deployments.$inferSelect): Deployment => ({ + id: row.id, + projectId: row.projectId, + sourceType: row.sourceType as Deployment["sourceType"], + sourceRef: row.sourceRef, + status: row.status as DeploymentStatus, + imageTag: row.imageTag, + containerName: row.containerName, + routePath: row.routePath, + liveUrl: row.liveUrl, + branch: row.branch, + commitSha: row.commitSha, + replicas: row.replicas, + environment: row.environment, + failureReason: row.failureReason, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +}); + +export const createDeployment = async (input: CreateDeploymentInput): Promise => { + const id = randomUUID(); + const timestamp = now(); + const db = await getDrizzle(); + db.insert(deployments).values({ + id, + projectId: input.projectId ?? null, + sourceType: input.sourceType, + sourceRef: input.sourceRef, + status: "pending", + routePath: `/apps/${id}`, + branch: input.branch ?? null, + commitSha: input.commitSha ?? null, + environment: input.environment ?? null, + createdAt: timestamp, + updatedAt: timestamp, + }).run(); + const row = db.select().from(deployments).where(eq(deployments.id, id)).get()!; + return mapDeployment(row); +}; + +export const listDeployments = async (projectId?: string, offset = 0, limit = 50): Promise => { + const db = await getDrizzle(); + const rows = projectId + ? db.select().from(deployments).where(eq(deployments.projectId, projectId)).orderBy(desc(deployments.createdAt)).limit(limit).offset(offset).all() + : db.select().from(deployments).orderBy(desc(deployments.createdAt)).limit(limit).offset(offset).all(); + return rows.map(mapDeployment); +}; + +export const countDeployments = async (projectId?: string): Promise => { + const db = await getDrizzle(); + const rows = projectId + ? db.select().from(deployments).where(eq(deployments.projectId, projectId)).all() + : db.select().from(deployments).all(); + return rows.length; +}; + +export const getDeploymentById = async (id: string): Promise => { + const db = await getDrizzle(); + const row = db.select().from(deployments).where(eq(deployments.id, id)).get(); + return row ? mapDeployment(row) : null; +}; + +export const updateDeploymentCommitSha = async (id: string, commitSha: string) => { + const db = await getDrizzle(); + db.update(deployments).set({ commitSha, updatedAt: now() }).where(eq(deployments.id, id)).run(); +}; + +export const updateDeploymentStatus = async ( + id: string, + status: DeploymentStatus, + patch: Partial> = {}, +) => { + const db = await getDrizzle(); + 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; + db.update(deployments).set(updates).where(eq(deployments.id, id)).run(); +}; + +export const deleteDeploymentAndLogs = async (id: string): Promise => { + const db = await getDrizzle(); + db.delete(deploymentLogs).where(eq(deploymentLogs.deploymentId, id)).run(); + const result = db.delete(deployments).where(eq(deployments.id, id)).run(); + return result.changes > 0; +}; + +export const appendLog = async ( + deploymentId: string, + stage: LogEvent["stage"], + message: string, +): Promise => { + const db = await getDrizzle(); + const row = db.select({ maxSeq: deploymentLogs.sequence }).from(deploymentLogs) + .where(eq(deploymentLogs.deploymentId, deploymentId)) + .orderBy(desc(deploymentLogs.sequence)).limit(1).get(); + const sequence = (row?.maxSeq ?? 0) + 1; + const createdAt = now(); + const result = db.insert(deploymentLogs).values({ + deploymentId, + sequence, + stage, + message, + createdAt, + }).run(); + return { + id: Number(result.lastInsertRowid), + deploymentId, + sequence, + stage: stage as DeploymentLog["stage"], + message, + createdAt, + }; +}; + +export const getLogs = async (deploymentId: string): Promise => { + const db = await getDrizzle(); + const rows = db.select().from(deploymentLogs) + .where(eq(deploymentLogs.deploymentId, deploymentId)) + .orderBy(deploymentLogs.sequence).all(); + return rows.map((r) => ({ + id: r.id, + deploymentId: r.deploymentId, + sequence: r.sequence, + stage: r.stage as DeploymentLog["stage"], + message: r.message, + createdAt: r.createdAt, + })); +}; diff --git a/apps/api/src/db/repo/domains.ts b/apps/api/src/db/repo/domains.ts new file mode 100644 index 0000000..3885059 --- /dev/null +++ b/apps/api/src/db/repo/domains.ts @@ -0,0 +1,63 @@ +import { eq, desc } from "drizzle-orm"; +import { getDrizzle } from "../drizzle"; +import { domains } from "../schema"; +import type { Domain, CreateDomainInput, DomainValidationStatus, SslStatus } from "../../types"; +import { randomUUID } from "node:crypto"; +import { now } from "./helpers"; + +const mapDomain = (row: typeof domains.$inferSelect): Domain => ({ + id: row.id, + projectId: row.projectId, + domain: row.domain, + type: row.type as Domain["type"], + validationStatus: row.validationStatus as DomainValidationStatus, + sslStatus: row.sslStatus as SslStatus, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +}); + +export const createDomain = async (input: CreateDomainInput): Promise => { + const id = randomUUID(); + const timestamp = now(); + const db = await getDrizzle(); + db.insert(domains).values({ + id, + projectId: input.projectId, + domain: input.domain, + type: input.type, + validationStatus: "pending", + sslStatus: "pending", + createdAt: timestamp, + updatedAt: timestamp, + }).run(); + const row = db.select().from(domains).where(eq(domains.id, id)).get()!; + return mapDomain(row); +}; + +export const listDomains = async (projectId: string): Promise => { + const db = await getDrizzle(); + return db.select().from(domains).where(eq(domains.projectId, projectId)).orderBy(desc(domains.createdAt)).all().map(mapDomain); +}; + +export const getDomainById = async (id: string): Promise => { + const db = await getDrizzle(); + const row = db.select().from(domains).where(eq(domains.id, id)).get(); + return row ? mapDomain(row) : null; +}; + +export const updateDomainValidation = async (id: string, validationStatus: DomainValidationStatus, sslStatus?: SslStatus): Promise => { + const db = await getDrizzle(); + const updates: Record = { validationStatus, updatedAt: now() }; + if (sslStatus !== undefined) updates.sslStatus = sslStatus; + db.update(domains).set(updates).where(eq(domains.id, id)).run(); +}; + +export const updateDomainSslStatus = async (id: string, sslStatus: SslStatus): Promise => { + const db = await getDrizzle(); + db.update(domains).set({ sslStatus, updatedAt: now() }).where(eq(domains.id, id)).run(); +}; + +export const deleteDomain = async (id: string): Promise => { + const db = await getDrizzle(); + return db.delete(domains).where(eq(domains.id, id)).run().changes > 0; +}; diff --git a/apps/api/src/db/repo/env-vars.ts b/apps/api/src/db/repo/env-vars.ts new file mode 100644 index 0000000..a1b3ae3 --- /dev/null +++ b/apps/api/src/db/repo/env-vars.ts @@ -0,0 +1,109 @@ +import { eq, and, asc } from "drizzle-orm"; +import { getDrizzle } from "../drizzle"; +import { environmentVariables } from "../schema"; +import type { EnvironmentVariable, CreateEnvironmentVariableInput } from "../../types"; +import { randomUUID } from "node:crypto"; +import { config } from "../../utils/config"; +import { encryptValue, decryptValue } from "../../utils/crypto"; +import { now } from "./helpers"; + +const mapEnvVar = (row: typeof environmentVariables.$inferSelect): EnvironmentVariable => ({ + id: row.id, + projectId: row.projectId, + key: row.key, + value: row.value ?? "", + environment: row.environment, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +}); + +export const createEnvironmentVariable = async (input: CreateEnvironmentVariableInput): Promise => { + const id = randomUUID(); + const timestamp = now(); + const env = input.environment ?? "production"; + const encrypted = encryptValue(input.value, config.envEncryptionKey); + const db = await getDrizzle(); + db.insert(environmentVariables).values({ + id, + projectId: input.projectId, + key: input.key, + value: "", + valueEncrypted: encrypted.encrypted, + valueIv: encrypted.iv, + valueTag: encrypted.tag, + environment: env, + createdAt: timestamp, + updatedAt: timestamp, + }).run(); + const row = db.select().from(environmentVariables).where(eq(environmentVariables.id, id)).get()!; + return mapEnvVar(row); +}; + +export const listEnvironmentVariables = async (projectId: string, environment?: string): Promise => { + const db = await getDrizzle(); + const cond = environment + ? and(eq(environmentVariables.projectId, projectId), eq(environmentVariables.environment, environment)) + : eq(environmentVariables.projectId, projectId); + return db.select().from(environmentVariables).where(cond).orderBy(asc(environmentVariables.key)).all().map(mapEnvVar); +}; + +export const getEnvironmentVariablePlaintext = async (id: string): Promise => { + const db = await getDrizzle(); + const row = db.select({ + value: environmentVariables.value, + valueEncrypted: environmentVariables.valueEncrypted, + valueIv: environmentVariables.valueIv, + valueTag: environmentVariables.valueTag, + }).from(environmentVariables).where(eq(environmentVariables.id, id)).get(); + if (!row) return null; + if (row.valueEncrypted && row.valueIv && row.valueTag) { + return decryptValue(row.valueEncrypted, row.valueIv, row.valueTag, config.envEncryptionKey); + } + return row.value ?? null; +}; + +export const getEnvironmentVariableById = async (id: string): Promise => { + const db = await getDrizzle(); + const row = db.select().from(environmentVariables).where(eq(environmentVariables.id, id)).get(); + return row ? mapEnvVar(row) : null; +}; + +export const updateEnvironmentVariable = async (id: string, value: string): Promise => { + const existing = await getEnvironmentVariableById(id); + if (!existing) return null; + const encrypted = encryptValue(value, config.envEncryptionKey); + const db = await getDrizzle(); + db.update(environmentVariables).set({ + value: "", + valueEncrypted: encrypted.encrypted, + valueIv: encrypted.iv, + valueTag: encrypted.tag, + updatedAt: now(), + }).where(eq(environmentVariables.id, id)).run(); + return getEnvironmentVariableById(id); +}; + +export const listEnvironmentVariablesForDeploy = async (projectId: string, environment?: string): Promise<{ key: string; value: string }[]> => { + const db = await getDrizzle(); + const cond = environment + ? and(eq(environmentVariables.projectId, projectId), eq(environmentVariables.environment, environment)) + : eq(environmentVariables.projectId, projectId); + const rows = db.select({ + key: environmentVariables.key, + value: environmentVariables.value, + valueEncrypted: environmentVariables.valueEncrypted, + valueIv: environmentVariables.valueIv, + valueTag: environmentVariables.valueTag, + }).from(environmentVariables).where(cond).orderBy(asc(environmentVariables.key)).all(); + return rows.map((row) => { + if (row.valueEncrypted && row.valueIv && row.valueTag) { + return { key: row.key, value: decryptValue(row.valueEncrypted, row.valueIv, row.valueTag, config.envEncryptionKey) }; + } + return { key: row.key, value: row.value ?? "" }; + }); +}; + +export const deleteEnvironmentVariable = async (id: string): Promise => { + const db = await getDrizzle(); + return db.delete(environmentVariables).where(eq(environmentVariables.id, id)).run().changes > 0; +}; diff --git a/apps/api/src/db/repo/github.ts b/apps/api/src/db/repo/github.ts new file mode 100644 index 0000000..b8dc908 --- /dev/null +++ b/apps/api/src/db/repo/github.ts @@ -0,0 +1,46 @@ +import { eq, desc } from "drizzle-orm"; +import { getDrizzle } from "../drizzle"; +import { githubIntegrations } from "../schema"; +import type { GithubIntegration } from "../../types"; +import { randomUUID } from "node:crypto"; +import { now } from "./helpers"; + +const mapGithubIntegration = (row: typeof githubIntegrations.$inferSelect): GithubIntegration => ({ + id: row.id, + clientId: row.clientId, + clientSecret: row.clientSecret, + appName: row.appName, + webhookSecret: row.webhookSecret ?? null, + createdAt: row.createdAt, +}); + +export const getGithubIntegration = async (): Promise => { + const db = await getDrizzle(); + const row = db.select().from(githubIntegrations).orderBy(desc(githubIntegrations.createdAt)).limit(1).get(); + return row ? mapGithubIntegration(row) : null; +}; + +export const setGithubIntegration = async (input: { clientId: string; clientSecret: string; appName?: string; webhookSecret?: string }): Promise => { + const existing = await getGithubIntegration(); + const timestamp = now(); + const db = await getDrizzle(); + if (existing) { + db.update(githubIntegrations).set({ + clientId: input.clientId, + clientSecret: input.clientSecret, + appName: input.appName ?? "Dequel", + webhookSecret: input.webhookSecret ?? null, + }).where(eq(githubIntegrations.id, existing.id)).run(); + return getGithubIntegration() as Promise; + } + const id = randomUUID(); + db.insert(githubIntegrations).values({ + id, + clientId: input.clientId, + clientSecret: input.clientSecret, + appName: input.appName ?? "Dequel", + webhookSecret: input.webhookSecret ?? null, + createdAt: timestamp, + }).run(); + return getGithubIntegration() as Promise; +}; diff --git a/apps/api/src/db/repo/helpers.ts b/apps/api/src/db/repo/helpers.ts new file mode 100644 index 0000000..8adc759 --- /dev/null +++ b/apps/api/src/db/repo/helpers.ts @@ -0,0 +1 @@ +export const now = () => new Date().toISOString(); diff --git a/apps/api/src/db/repo/index.ts b/apps/api/src/db/repo/index.ts new file mode 100644 index 0000000..9cf3264 --- /dev/null +++ b/apps/api/src/db/repo/index.ts @@ -0,0 +1,41 @@ +export { + createDeployment, listDeployments, countDeployments, getDeploymentById, + updateDeploymentCommitSha, updateDeploymentStatus, deleteDeploymentAndLogs, + appendLog, getLogs, +} from "./deployments"; + +export { + createProject, updateProjectGithubToken, listProjects, getProjectById, + updateProject, deleteProject, deleteProjectCascade, +} from "./projects"; +export type { ProjectCleanupInfo } from "./projects"; + +export { + createEnvironmentVariable, listEnvironmentVariables, getEnvironmentVariablePlaintext, + getEnvironmentVariableById, updateEnvironmentVariable, listEnvironmentVariablesForDeploy, + deleteEnvironmentVariable, +} from "./env-vars"; + +export { createVolume, listVolumes, getVolumeById, deleteVolume } from "./volumes"; + +export { + createDatabase, listAllDatabases, listDatabases, getDatabaseById, + updateDatabaseStatus, deleteDatabase, +} from "./databases"; + +export { + createDomain, listDomains, getDomainById, updateDomainValidation, + updateDomainSslStatus, deleteDomain, +} from "./domains"; + +export { upsertScalingPolicy, getScalingPolicy, deleteScalingPolicy } from "./scaling"; + +export { + createServer, listServers, getServerById, updateServerStatus, deleteServer, +} from "./servers"; + +export { createApiKey, listApiKeys, deleteApiKey, validateApiKey } from "./api-keys"; + +export { createAlert, listAlerts, getAlertById, updateAlertEnabled, deleteAlert } from "./alerts"; + +export { getGithubIntegration, setGithubIntegration } from "./github"; diff --git a/apps/api/src/db/repo/projects.ts b/apps/api/src/db/repo/projects.ts new file mode 100644 index 0000000..dddd881 --- /dev/null +++ b/apps/api/src/db/repo/projects.ts @@ -0,0 +1,143 @@ +import { eq } from "drizzle-orm"; +import { getDrizzle } from "../drizzle"; +import { projects, deployments, deploymentLogs, environmentVariables, volumes, databases, domains, scalingPolicies, alerts } from "../schema"; +import type { Project, CreateProjectInput } from "../../types"; +import { randomUUID } from "node:crypto"; +import { now } from "./helpers"; + +export interface ProjectCleanupInfo { + deploymentContainerNames: string[]; + deploymentImageTags: string[]; + databaseContainerNames: string[]; + databaseVolumeNames: string[]; + volumeDockerNames: string[]; + domains: { domain: string; projectName: string }[]; + slug: string; + projectName: string; +} + +const mapProject = (row: typeof projects.$inferSelect): Project => ({ + id: row.id, + name: row.name, + description: row.description, + repoUrl: row.repoUrl, + repoBranch: row.repoBranch, + baseDomain: row.baseDomain, + cpuLimit: row.cpuLimit, + memoryLimitMb: row.memoryLimitMb, + githubTokenEncrypted: row.githubTokenEncrypted ?? null, + githubTokenIv: row.githubTokenIv ?? null, + githubTokenTag: row.githubTokenTag ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +}); + +export const createProject = async (input: CreateProjectInput): Promise => { + const id = randomUUID(); + const timestamp = now(); + const db = await getDrizzle(); + db.insert(projects).values({ + id, + name: input.name, + description: input.description ?? null, + repoUrl: input.repoUrl ?? null, + repoBranch: input.repoBranch ?? null, + baseDomain: input.baseDomain ?? null, + cpuLimit: input.cpuLimit ?? null, + memoryLimitMb: input.memoryLimitMb ?? null, + createdAt: timestamp, + updatedAt: timestamp, + }).run(); + const row = db.select().from(projects).where(eq(projects.id, id)).get()!; + return mapProject(row); +}; + +export const updateProjectGithubToken = async (id: string, encrypted: string | null, iv: string | null, tag: string | null): Promise => { + const db = await getDrizzle(); + db.update(projects).set({ githubTokenEncrypted: encrypted, githubTokenIv: iv, githubTokenTag: tag }).where(eq(projects.id, id)).run(); +}; + +export const listProjects = async (): Promise => { + const db = await getDrizzle(); + return db.select().from(projects).orderBy(projects.name).all().map(mapProject); +}; + +export const getProjectById = async (id: string): Promise => { + const db = await getDrizzle(); + const row = db.select().from(projects).where(eq(projects.id, id)).get(); + return row ? mapProject(row) : null; +}; + +export const updateProject = async (id: string, patch: Partial): Promise => { + const existing = await getProjectById(id); + if (!existing) return null; + const db = await getDrizzle(); + const updates: Record = { updatedAt: now() }; + if (patch.name !== undefined) updates.name = patch.name; + if (patch.description !== undefined) updates.description = patch.description; + if (patch.repoUrl !== undefined) updates.repoUrl = patch.repoUrl; + if (patch.repoBranch !== undefined) updates.repoBranch = patch.repoBranch; + if (patch.baseDomain !== undefined) updates.baseDomain = patch.baseDomain; + if (patch.cpuLimit !== undefined) updates.cpuLimit = patch.cpuLimit; + if (patch.memoryLimitMb !== undefined) updates.memoryLimitMb = patch.memoryLimitMb; + db.update(projects).set(updates).where(eq(projects.id, id)).run(); + return getProjectById(id); +}; + +export const deleteProject = async (id: string): Promise => { + const db = await getDrizzle(); + const result = db.delete(projects).where(eq(projects.id, id)).run(); + return result.changes > 0; +}; + +export const deleteProjectCascade = async (id: string): Promise => { + const project = await getProjectById(id); + if (!project) return null; + + const slug = project.name + ? project.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63) + : id; + + const db = await getDrizzle(); + + const depRows = db.select({ id: deployments.id, containerName: deployments.containerName, imageTag: deployments.imageTag }) + .from(deployments).where(eq(deployments.projectId, id)).all(); + const deploymentContainerNames = depRows.filter(d => d.containerName).map(d => d.containerName!); + const deploymentImageTags = depRows.filter(d => d.imageTag).map(d => d.imageTag!); + + for (const dep of depRows) { + db.delete(deploymentLogs).where(eq(deploymentLogs.deploymentId, dep.id)).run(); + } + db.delete(deployments).where(eq(deployments.projectId, id)).run(); + db.delete(environmentVariables).where(eq(environmentVariables.projectId, id)).run(); + + const volRows = db.select({ dockerVolumeName: volumes.dockerVolumeName }) + .from(volumes).where(eq(volumes.projectId, id)).all(); + const volumeDockerNames = volRows.filter(v => v.dockerVolumeName).map(v => v.dockerVolumeName!); + db.delete(volumes).where(eq(volumes.projectId, id)).run(); + + const dbRows = db.select({ containerName: databases.containerName, id: databases.id }) + .from(databases).where(eq(databases.projectId, id)).all(); + const databaseContainerNames = dbRows.filter(d => d.containerName).map(d => d.containerName!); + const databaseVolumeNames = dbRows.map(d => `db-${d.id.slice(0, 12)}`); + db.delete(databases).where(eq(databases.projectId, id)).run(); + + const domainRows = db.select({ domain: domains.domain }).from(domains).where(eq(domains.projectId, id)).all(); + const domainInfo = domainRows.map(d => ({ domain: d.domain, projectName: project.name ?? id })); + db.delete(domains).where(eq(domains.projectId, id)).run(); + + db.delete(scalingPolicies).where(eq(scalingPolicies.projectId, id)).run(); + db.delete(alerts).where(eq(alerts.projectId, id)).run(); + db.delete(projects).where(eq(projects.id, id)).run(); + + return { + deploymentContainerNames, + deploymentImageTags, + databaseContainerNames, + databaseVolumeNames, + volumeDockerNames, + domains: domainInfo, + slug, + projectName: project.name ?? id, + }; +}; diff --git a/apps/api/src/db/repo/scaling.ts b/apps/api/src/db/repo/scaling.ts new file mode 100644 index 0000000..fd83b12 --- /dev/null +++ b/apps/api/src/db/repo/scaling.ts @@ -0,0 +1,57 @@ +import { eq } from "drizzle-orm"; +import { getDrizzle } from "../drizzle"; +import { scalingPolicies } from "../schema"; +import type { ScalingPolicy, CreateScalingPolicyInput } from "../../types"; +import { randomUUID } from "node:crypto"; +import { now } from "./helpers"; + +const mapScalingPolicy = (row: typeof scalingPolicies.$inferSelect): ScalingPolicy => ({ + id: row.id, + projectId: row.projectId, + minReplicas: row.minReplicas, + maxReplicas: row.maxReplicas, + cpuThresholdPercent: row.cpuThresholdPercent, + memoryThresholdPercent: row.memoryThresholdPercent, + cooldownSeconds: row.cooldownSeconds, + enabled: row.enabled === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +}); + +export const upsertScalingPolicy = async (input: CreateScalingPolicyInput): Promise => { + const db = await getDrizzle(); + const existing = db.select().from(scalingPolicies).where(eq(scalingPolicies.projectId, input.projectId)).get(); + const timestamp = now(); + if (existing) { + const updates: Record = { updatedAt: timestamp }; + if (input.minReplicas !== undefined) updates.minReplicas = input.minReplicas; + if (input.maxReplicas !== undefined) updates.maxReplicas = input.maxReplicas; + if (input.cpuThresholdPercent !== undefined) updates.cpuThresholdPercent = input.cpuThresholdPercent; + if (input.memoryThresholdPercent !== undefined) updates.memoryThresholdPercent = input.memoryThresholdPercent; + db.update(scalingPolicies).set(updates).where(eq(scalingPolicies.projectId, input.projectId)).run(); + return mapScalingPolicy(db.select().from(scalingPolicies).where(eq(scalingPolicies.projectId, input.projectId)).get()!); + } + const id = randomUUID(); + db.insert(scalingPolicies).values({ + id, + projectId: input.projectId, + minReplicas: input.minReplicas ?? 1, + maxReplicas: input.maxReplicas ?? 5, + cpuThresholdPercent: input.cpuThresholdPercent ?? 70, + memoryThresholdPercent: input.memoryThresholdPercent ?? 85, + createdAt: timestamp, + updatedAt: timestamp, + }).run(); + return mapScalingPolicy(db.select().from(scalingPolicies).where(eq(scalingPolicies.id, id)).get()!); +}; + +export const getScalingPolicy = async (projectId: string): Promise => { + const db = await getDrizzle(); + const row = db.select().from(scalingPolicies).where(eq(scalingPolicies.projectId, projectId)).get(); + return row ? mapScalingPolicy(row) : null; +}; + +export const deleteScalingPolicy = async (projectId: string): Promise => { + const db = await getDrizzle(); + return db.delete(scalingPolicies).where(eq(scalingPolicies.projectId, projectId)).run().changes > 0; +}; diff --git a/apps/api/src/db/repo/servers.ts b/apps/api/src/db/repo/servers.ts new file mode 100644 index 0000000..7fbafc2 --- /dev/null +++ b/apps/api/src/db/repo/servers.ts @@ -0,0 +1,74 @@ +import { eq, desc } from "drizzle-orm"; +import { getDrizzle } from "../drizzle"; +import { servers } from "../schema"; +import type { Server, CreateServerInput, ServerStatus } from "../../types"; +import { randomUUID } from "node:crypto"; +import { now } from "./helpers"; + +const mapServer = (row: typeof servers.$inferSelect): Server => ({ + id: row.id, + name: row.name, + host: row.host, + port: row.port, + authToken: row.authToken, + status: row.status as ServerStatus, + cpuTotal: row.cpuTotal, + memoryTotalMb: row.memoryTotalMb, + diskTotalMb: row.diskTotalMb, + cpuUsedPercent: row.cpuUsedPercent, + memoryUsedMb: row.memoryUsedMb, + lastHeartbeat: row.lastHeartbeat, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +}); + +export const createServer = async (input: CreateServerInput): Promise => { + const id = randomUUID(); + const timestamp = now(); + const db = await getDrizzle(); + db.insert(servers).values({ + id, + name: input.name, + host: input.host, + port: input.port ?? 2375, + authToken: input.authToken, + status: "pending", + createdAt: timestamp, + updatedAt: timestamp, + }).run(); + const row = db.select().from(servers).where(eq(servers.id, id)).get()!; + return mapServer(row); +}; + +export const listServers = async (): Promise => { + const db = await getDrizzle(); + return db.select().from(servers).orderBy(servers.name).all().map(mapServer); +}; + +export const getServerById = async (id: string): Promise => { + const db = await getDrizzle(); + const row = db.select().from(servers).where(eq(servers.id, id)).get(); + return row ? mapServer(row) : null; +}; + +export const updateServerStatus = async (id: string, status: ServerStatus, resources?: { + cpuTotal?: number; memoryTotalMb?: number; diskTotalMb?: number; + cpuUsedPercent?: number; memoryUsedMb?: number; +}): Promise => { + const db = await getDrizzle(); + const updates: Record = { status, updatedAt: now() }; + if (resources) { + if (resources.cpuTotal !== undefined) updates.cpuTotal = resources.cpuTotal; + if (resources.memoryTotalMb !== undefined) updates.memoryTotalMb = resources.memoryTotalMb; + if (resources.diskTotalMb !== undefined) updates.diskTotalMb = resources.diskTotalMb; + if (resources.cpuUsedPercent !== undefined) updates.cpuUsedPercent = resources.cpuUsedPercent; + if (resources.memoryUsedMb !== undefined) updates.memoryUsedMb = resources.memoryUsedMb; + updates.lastHeartbeat = now(); + } + db.update(servers).set(updates).where(eq(servers.id, id)).run(); +}; + +export const deleteServer = async (id: string): Promise => { + const db = await getDrizzle(); + return db.delete(servers).where(eq(servers.id, id)).run().changes > 0; +}; diff --git a/apps/api/src/db/repo/volumes.ts b/apps/api/src/db/repo/volumes.ts new file mode 100644 index 0000000..9ffa82b --- /dev/null +++ b/apps/api/src/db/repo/volumes.ts @@ -0,0 +1,48 @@ +import { eq, desc } from "drizzle-orm"; +import { getDrizzle } from "../drizzle"; +import { volumes } from "../schema"; +import type { Volume, CreateVolumeInput } from "../../types"; +import { randomUUID } from "node:crypto"; +import { now } from "./helpers"; + +const mapVolume = (row: typeof volumes.$inferSelect): Volume => ({ + id: row.id, + projectId: row.projectId, + mountPath: row.mountPath, + sizeMb: row.sizeMb, + dockerVolumeName: row.dockerVolumeName, + createdAt: row.createdAt, +}); + +export const createVolume = async (input: CreateVolumeInput): Promise => { + const id = randomUUID(); + const timestamp = now(); + const mountPath = input.mountPath ?? "/app/data"; + const volumeName = `vol-${id.slice(0, 8)}`; + const db = await getDrizzle(); + db.insert(volumes).values({ + id, + projectId: input.projectId, + mountPath, + dockerVolumeName: volumeName, + createdAt: timestamp, + }).run(); + const row = db.select().from(volumes).where(eq(volumes.id, id)).get()!; + return mapVolume(row); +}; + +export const listVolumes = async (projectId: string): Promise => { + const db = await getDrizzle(); + return db.select().from(volumes).where(eq(volumes.projectId, projectId)).orderBy(desc(volumes.createdAt)).all().map(mapVolume); +}; + +export const getVolumeById = async (id: string): Promise => { + const db = await getDrizzle(); + const row = db.select().from(volumes).where(eq(volumes.id, id)).get(); + return row ? mapVolume(row) : null; +}; + +export const deleteVolume = async (id: string): Promise => { + const db = await getDrizzle(); + return db.delete(volumes).where(eq(volumes.id, id)).run().changes > 0; +}; diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts new file mode 100644 index 0000000..4ad0ca1 --- /dev/null +++ b/apps/api/src/db/schema.ts @@ -0,0 +1,173 @@ +import { sqliteTable, text, integer, real, foreignKey, uniqueIndex, index } from "drizzle-orm/sqlite-core"; + +export const githubIntegrations = sqliteTable("github_integrations", { + id: text().primaryKey(), + clientId: text("client_id").notNull(), + clientSecret: text("client_secret").notNull(), + appName: text("app_name").notNull().default("Dequel"), + webhookSecret: text("webhook_secret"), + createdAt: text("created_at").notNull(), +}); + +export const projects = sqliteTable("projects", { + id: text().primaryKey(), + name: text().notNull(), + description: text(), + repoUrl: text("repo_url"), + repoBranch: text("repo_branch"), + baseDomain: text("base_domain"), + cpuLimit: real("cpu_limit"), + memoryLimitMb: integer("memory_limit_mb"), + githubTokenEncrypted: text("github_token_encrypted"), + githubTokenIv: text("github_token_iv"), + githubTokenTag: text("github_token_tag"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}); + +export const deployments = sqliteTable("deployments", { + id: text().primaryKey(), + projectId: text("project_id"), + sourceType: text("source_type").notNull(), + sourceRef: text("source_ref").notNull(), + status: text().notNull().default("pending"), + imageTag: text("image_tag"), + containerName: text("container_name"), + routePath: text("route_path"), + liveUrl: text("live_url"), + branch: text(), + commitSha: text("commit_sha"), + replicas: integer().notNull().default(1), + environment: text(), + failureReason: text("failure_reason"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}); + +export const deploymentLogs = sqliteTable("deployment_logs", { + id: integer().primaryKey({ autoIncrement: true }), + deploymentId: text("deployment_id").notNull(), + sequence: integer().notNull(), + stage: text().notNull(), + message: text().notNull(), + createdAt: text("created_at").notNull(), +}, (table) => [ + foreignKey({ columns: [table.deploymentId], foreignColumns: [deployments.id] }), + uniqueIndex("idx_logs_dep_seq").on(table.deploymentId, table.sequence), +]); + +export const environmentVariables = sqliteTable("environment_variables", { + id: text().primaryKey(), + projectId: text("project_id").notNull(), + key: text().notNull(), + value: text().notNull(), + valueEncrypted: text("value_encrypted"), + valueIv: text("value_iv"), + valueTag: text("value_tag"), + environment: text().notNull().default("production"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}, (table) => [ + foreignKey({ columns: [table.projectId], foreignColumns: [projects.id], onDelete: "cascade" }), + index("idx_env_vars_project").on(table.projectId, table.environment), +]); + +export const volumes = sqliteTable("volumes", { + id: text().primaryKey(), + projectId: text("project_id").notNull(), + mountPath: text("mount_path").notNull().default("/app/data"), + sizeMb: integer("size_mb"), + dockerVolumeName: text("docker_volume_name"), + createdAt: text("created_at").notNull(), +}, (table) => [ + foreignKey({ columns: [table.projectId], foreignColumns: [projects.id], onDelete: "cascade" }), +]); + +export const databases = sqliteTable("databases", { + id: text().primaryKey(), + projectId: text("project_id").notNull(), + type: text().notNull(), + version: text(), + databaseName: text("database_name").notNull(), + username: text().notNull(), + password: text().notNull(), + internalHost: text("internal_host").notNull(), + internalPort: integer("internal_port").notNull(), + cpuLimit: real("cpu_limit"), + memoryLimitMb: integer("memory_limit_mb"), + connectionString: text("connection_string").notNull(), + status: text().notNull().default("provisioning"), + containerName: text("container_name"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}, (table) => [ + foreignKey({ columns: [table.projectId], foreignColumns: [projects.id], onDelete: "cascade" }), +]); + +export const domains = sqliteTable("domains", { + id: text().primaryKey(), + projectId: text("project_id").notNull(), + domain: text().notNull(), + type: text().notNull().default("custom"), + validationStatus: text("validation_status").notNull().default("pending"), + sslStatus: text("ssl_status").notNull().default("pending"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}, (table) => [ + foreignKey({ columns: [table.projectId], foreignColumns: [projects.id], onDelete: "cascade" }), +]); + +export const scalingPolicies = sqliteTable("scaling_policies", { + id: text().primaryKey(), + projectId: text("project_id").notNull().unique(), + minReplicas: integer("min_replicas").notNull().default(1), + maxReplicas: integer("max_replicas").notNull().default(5), + cpuThresholdPercent: integer("cpu_threshold_percent").notNull().default(70), + memoryThresholdPercent: integer("memory_threshold_percent").notNull().default(85), + cooldownSeconds: integer("cooldown_seconds").notNull().default(120), + enabled: integer().notNull().default(1), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}, (table) => [ + foreignKey({ columns: [table.projectId], foreignColumns: [projects.id], onDelete: "cascade" }), +]); + +export const servers = sqliteTable("servers", { + id: text().primaryKey(), + name: text().notNull(), + host: text().notNull(), + port: integer().notNull().default(2375), + authToken: text("auth_token").notNull().default(""), + status: text().notNull().default("pending"), + cpuTotal: integer("cpu_total"), + memoryTotalMb: integer("memory_total_mb"), + diskTotalMb: integer("disk_total_mb"), + cpuUsedPercent: real("cpu_used_percent"), + memoryUsedMb: integer("memory_used_mb"), + lastHeartbeat: text("last_heartbeat"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}); + +export const apiKeys = sqliteTable("api_keys", { + id: text().primaryKey(), + name: text().notNull(), + keyHash: text("key_hash").notNull(), + permissions: text().notNull().default("deploy:read"), + createdAt: text("created_at").notNull(), + lastUsedAt: text("last_used_at"), +}); + +export const alerts = sqliteTable("alerts", { + id: text().primaryKey(), + projectId: text("project_id").notNull(), + type: text().notNull(), + threshold: real(), + durationSeconds: integer("duration_seconds"), + channel: text().notNull().default("email"), + destination: text(), + enabled: integer().notNull().default(1), + createdAt: text("created_at").notNull(), +}, (table) => [ + foreignKey({ columns: [table.projectId], foreignColumns: [projects.id], onDelete: "cascade" }), +]); diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts index 0aae457..598fba8 100644 --- a/apps/api/src/types.ts +++ b/apps/api/src/types.ts @@ -19,10 +19,22 @@ export interface Project { baseDomain: string | null; cpuLimit: number | null; memoryLimitMb: number | null; + githubTokenEncrypted: string | null; + githubTokenIv: string | null; + githubTokenTag: string | null; createdAt: string; updatedAt: string; } +export interface GithubIntegration { + id: string; + clientId: string; + clientSecret: string; + appName: string; + webhookSecret: string | null; + createdAt: string; +} + export interface CreateProjectInput { name: string; description?: string; diff --git a/apps/web/index.html b/apps/web/index.html index 99dc0fc..a4b0d76 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -6,7 +6,7 @@ Dequel Deployments - + diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 4faf27f..1fb7021 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -10,6 +10,8 @@ import type { ApiKey, Alert, Log, + GithubRepo, + GithubIntegrationStatus, } from "../types"; const BASE = "/api"; @@ -441,3 +443,31 @@ export const deleteAlert = (id: string) => apiFetch<{ ok: boolean }>(`/alerts/${id}`, { method: "DELETE", }); + +// ─── GitHub OAuth ─────────────────────────────────────── + +export const getGithubAuthUrl = () => + apiFetch<{ url: string }>("/github/auth-url"); + +export const getGithubUser = () => + apiFetch<{ login: string; avatar_url: string }>("/github/user"); + +export const getGithubRepos = () => + apiFetch("/github/repos"); + +export const disconnectGithub = () => + apiFetch<{ ok: boolean }>("/github/disconnect", { method: "POST" }); + +export const getGithubIntegration = () => + apiFetch("/github/integration"); + +export const setGithubIntegration = (data: { + clientId: string; + clientSecret: string; + appName?: string; + webhookSecret?: string; +}) => + apiFetch<{ ok: boolean }>("/github/integration", { + method: "PUT", + body: JSON.stringify(data), + }); diff --git a/apps/web/src/components/Layout.tsx b/apps/web/src/components/Layout.tsx index c9d2016..b8d4a87 100644 --- a/apps/web/src/components/Layout.tsx +++ b/apps/web/src/components/Layout.tsx @@ -25,7 +25,8 @@ import { FolderGit, Search, ExternalLink, - Laptop + Laptop, + Coffee } from 'lucide-react'; export function Layout({ children }: { children: React.ReactNode }) { @@ -345,18 +346,40 @@ export function Layout({ children }: { children: React.ReactNode }) {
- {/* Upgrade Alert banner */} -
-
- -
+ {/* Buy me a coffee banner */} +
- - Upgrade to Pro + + Support Dequel

- Unlock advanced metrics, anomaly alerts, and CDN optimization. + Help keep Dequel open source and support its development!

+ + + + + + + + + + + + + + + + + + Buy me a coffee +
diff --git a/apps/web/src/components/github/RepoPicker.tsx b/apps/web/src/components/github/RepoPicker.tsx new file mode 100644 index 0000000..26a1fbb --- /dev/null +++ b/apps/web/src/components/github/RepoPicker.tsx @@ -0,0 +1,162 @@ +import { useState, useEffect } from "react"; +import { getGithubRepos, disconnectGithub } from "../../api/client"; +import type { GithubRepo } from "../../types"; +import { Input } from "../ui/input"; +import { Button } from "../ui/button"; +import { Search, X, GitFork, Lock, Globe, ExternalLink, RefreshCw } from "lucide-react"; +import { cn } from "../../lib/utils"; + +interface RepoPickerProps { + onSelect: (repo: GithubRepo) => void; + selected: GithubRepo | null; + onDisconnect: () => void; +} + +export function RepoPicker({ onSelect, selected, onDisconnect }: RepoPickerProps) { + const [repos, setRepos] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [error, setError] = useState(""); + + const fetchRepos = async () => { + setLoading(true); + setError(""); + try { + const data = await getGithubRepos(); + setRepos(data); + } catch (err) { + setError("Failed to load repositories. Reconnect GitHub."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchRepos(); }, []); + + const filtered = repos.filter((r) => + r.fullName.toLowerCase().includes(search.toLowerCase()), + ); + + if (selected) { + return ( +
+
+
+ +
+
+
+ {selected.fullName} +
+
+ {selected.private ? "Private" : "Public"} · {selected.defaultBranch} +
+
+ +
+
+ ); + } + + return ( +
+
+ + setSearch(e.target.value)} + className="h-9 pl-8 bg-[#141418] border-[#222227] focus:border-amber-500 text-zinc-200 text-xs" + /> +
+ + {loading ? ( +
+ + Loading repositories... +
+ ) : error ? ( +
+

{error}

+ +
+ ) : filtered.length === 0 ? ( +
+ {search ? "No matching repositories." : "No repositories found."} +
+ ) : ( +
+ {filtered.map((repo) => ( + + ))} +
+ )} + +
+ + {repos.length > 0 && ( + + {filtered.length} of {repos.length} repos + + )} +
+
+ ); +} diff --git a/apps/web/src/components/project/CreateProjectDialog.tsx b/apps/web/src/components/project/CreateProjectDialog.tsx deleted file mode 100644 index 1a2e335..0000000 --- a/apps/web/src/components/project/CreateProjectDialog.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { useState } from 'react'; -import { useCreateProject } from '../../hooks/useProjects'; -import { Button } from '../ui/button'; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog'; -import { cn } from '../../lib/utils'; -import { Plus } from 'lucide-react'; -import * as api from '../../api/client'; - -import { StepBasics } from './StepBasics'; -import { StepEnvironment } from './StepEnvironment'; -import { StepResources } from './StepResources'; -import { CreationStatusOverlay } from './CreationStatusOverlay'; - -interface CreateProjectDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogProps) { - const createProject = useCreateProject(); - const [step, setStep] = useState(1); - - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [baseDomain, setBaseDomain] = useState(''); - const [repoUrl, setRepoUrl] = useState(''); - const [repoBranch, setRepoBranch] = useState(''); - - const [stagedEnvs, setStagedEnvs] = useState>([]); - - const [cpuLimit, setCpuLimit] = useState(''); - const [memoryLimitMb, setMemoryLimitMb] = useState(''); - const [provisionDb, setProvisionDb] = useState(false); - const [dbType, setDbType] = useState<'postgresql' | 'mysql'>('postgresql'); - const [dbVersion, setDbVersion] = useState(''); - const [dbCpu, setDbCpu] = useState(''); - const [dbMemory, setDbMemory] = useState(''); - - const [submittingStatus, setSubmittingStatus] = useState<'idle' | 'creating_project' | 'creating_envs' | 'creating_db' | 'done' | 'error'>('idle'); - const [errorMessage, setErrorMessage] = useState(''); - - const handleOpenChange = (isOpen: boolean) => { - onOpenChange(isOpen); - if (!isOpen) { - setStep(1); - setName(''); - setDescription(''); - setBaseDomain(''); - setRepoUrl(''); - setRepoBranch(''); - setCpuLimit(''); - setMemoryLimitMb(''); - setStagedEnvs([]); - setProvisionDb(false); - setDbType('postgresql'); - setDbVersion(''); - setDbCpu(''); - setDbMemory(''); - setSubmittingStatus('idle'); - setErrorMessage(''); - } - }; - - const handleCreate = async (e: React.FormEvent) => { - e.preventDefault(); - if (!name.trim()) return; - - setSubmittingStatus('creating_project'); - setErrorMessage(''); - - try { - const project = await createProject.mutateAsync({ - name: name.trim(), - description: description.trim() || undefined, - baseDomain: baseDomain.trim() || undefined, - repoUrl: repoUrl.trim() || undefined, - repoBranch: repoBranch.trim() || undefined, - cpuLimit: cpuLimit.trim() ? Number(cpuLimit) : undefined, - memoryLimitMb: memoryLimitMb.trim() ? Number(memoryLimitMb) : undefined, - }); - - if (stagedEnvs.length > 0) { - setSubmittingStatus('creating_envs'); - await Promise.all( - stagedEnvs.map(env => - api.createEnvVar(project.id, { - key: env.key.trim(), - value: env.value.trim(), - environment: env.environment || 'production' - }) - ) - ); - } - - if (provisionDb) { - setSubmittingStatus('creating_db'); - await api.createDatabase(project.id, dbType, { - version: dbVersion.trim() || undefined, - cpuLimit: dbCpu.trim() ? Number(dbCpu) : null, - memoryLimitMb: dbMemory.trim() ? Number(dbMemory) : null, - }); - } - - setSubmittingStatus('done'); - - setTimeout(() => { - handleOpenChange(false); - }, 1000); - - } catch (err: any) { - console.error(err); - setErrorMessage(err.message || 'An unexpected error occurred during creation.'); - setSubmittingStatus('error'); - } - }; - - return ( - - - - - - {submittingStatus !== 'idle' ? ( - 0} - hasDb={provisionDb} - dbType={dbType} - onRetry={() => setSubmittingStatus('idle')} - /> - ) : ( -
- - Create New Project - - Set up your project details, configure variables, and optionally provision a database instance. - - - -
-
- 1 - Basics & Git -
-
-
- 2 - Environment -
-
-
- 3 - Resources & DB -
-
- - {step === 1 && ( - - )} - - {step === 2 && ( - - )} - - {step === 3 && ( - - )} - - -
- {step === 1 && 'Step 1 of 3: General & Git settings'} - {step === 2 && 'Step 2 of 3: Staging env variables'} - {step === 3 && 'Step 3 of 3: Allocation limits & Databases'} -
-
- {step > 1 && ( - - )} - {step === 1 && ( - - )} - - {step < 3 ? ( - - ) : ( - - )} -
-
- - )} - -
- ); -} diff --git a/apps/web/src/components/project/EnvVarsTab.tsx b/apps/web/src/components/project/EnvVarsTab.tsx deleted file mode 100644 index b0f9ff1..0000000 --- a/apps/web/src/components/project/EnvVarsTab.tsx +++ /dev/null @@ -1,1044 +0,0 @@ -import React, { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { useNavigate } from "@tanstack/react-router"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetDescription, - SheetFooter, -} from "../ui/sheet"; -import * as api from "../../api/client"; -import { - useDeployments, - useRedeployDeployment, -} from "../../hooks/useDeployments"; -import { Card, CardContent } from "../ui/card"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "../ui/table"; -import { Input } from "../ui/input"; -import { Button } from "../ui/button"; -import { Badge } from "../ui/badge"; -import { - Upload, - Check, - Pencil, - Trash2, - KeyRound, - Eye, - EyeOff, - Copy, - Plus, - Lock, - RefreshCw, -} from "lucide-react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from "../ui/dialog"; - -interface EnvVarsTabProps { - projectId: string; -} - -export function EnvVarsTab({ - projectId, -}: EnvVarsTabProps) { - const { data: envVars = [], refetch } = - useQuery({ - queryKey: ["env-vars", projectId], - queryFn: () => - api.listEnvVars(projectId), - }); - - const { data: deploymentsData } = - useDeployments(projectId, 0, 5); - const redeploy = useRedeployDeployment(); - const navigate = useNavigate(); - const runningDeployment = - deploymentsData?.items?.find((d) => d.status === "running"); - - const [isAddOpen, setIsAddOpen] = - useState(false); - const [isImportOpen, setIsImportOpen] = - useState(false); - - const [ - showRedeployPrompt, - setShowRedeployPrompt, - ] = useState(false); - - const [newVars, setNewVars] = useState<{ key: string; value: string; env: string }[]>([ - { key: "", value: "", env: "" }, - ]); - - const addRow = () => { - setNewVars((prev) => [...prev, { key: "", value: "", env: "" }]); - }; - - const removeRow = (index: number) => { - setNewVars((prev) => prev.filter((_, i) => i !== index)); - }; - - const updateRow = (index: number, field: "key" | "value" | "env", val: string) => { - setNewVars((prev) => - prev.map((item, i) => (i === index ? { ...item, [field]: val } : item)) - ); - }; - const [fileError, setFileError] = - useState(""); - const [parsedFileVars, setParsedFileVars] = - useState< - | { key: string; value: string }[] - | null - >(null); - const [importStatus, setImportStatus] = - useState<"idle" | "importing" | "done">( - "idle", - ); - const [fileInputKey, setFileInputKey] = - useState(0); - const [editingId, setEditingId] = useState< - string | null - >(null); - const [editingValue, setEditingValue] = - useState(""); - const [savingEdit, setSavingEdit] = useState< - Record - >({}); - const [revealValues, setRevealValues] = - useState>({}); - const [revealing, setRevealing] = useState< - Record - >({}); - const [copiedId, setCopiedId] = useState< - string | null - >(null); - - const parseEnvLines = (text: string) => { - const lines = text - .split(/\r?\n/) - .map((line) => line.trim()) - .filter( - (line) => - line && !line.startsWith("#"), - ); - return lines - .map((line) => { - const idx = line.indexOf("="); - if (idx <= 0) return null; - const k = line - .slice(0, idx) - .trim(); - let v = line - .slice(idx + 1) - .trim(); - if ( - (v.startsWith('"') && - v.endsWith('"')) || - (v.startsWith("'") && - v.endsWith("'")) - ) { - v = v.slice(1, -1); - } - if (!k) return null; - return { key: k, value: v }; - }) - .filter(Boolean) as { - key: string; - value: string; - }[]; - }; - - const handleFileSelect = async ( - e: React.ChangeEvent, - ) => { - setFileError(""); - setImportStatus("idle"); - const file = e.target.files?.[0]; - if (!file) { - setParsedFileVars(null); - return; - } - const text = await file.text(); - const vars = parseEnvLines(text); - if (!vars.length) { - setFileError( - "No valid KEY=VALUE pairs found in file.", - ); - setParsedFileVars(null); - setFileInputKey((k) => k + 1); - e.target.value = ""; - return; - } - setParsedFileVars(vars); - }; - - const handleImport = async () => { - if (!parsedFileVars?.length) return; - setImportStatus("importing"); - setFileError(""); - try { - await Promise.all( - parsedFileVars.map((entry) => - api.createEnvVar( - projectId, - entry, - ), - ), - ); - await refetch(); - setImportStatus("done"); - setParsedFileVars(null); - setFileInputKey((k) => k + 1); - setShowRedeployPrompt(true); - setTimeout(() => { - setImportStatus("idle"); - setIsImportOpen(false); - }, 1500); - } catch { - setFileError( - "Import failed. Try again.", - ); - setImportStatus("idle"); - } - }; - - const startEdit = async (id: string) => { - setEditingId(id); - setEditingValue(""); - setSavingEdit((prev) => ({ - ...prev, - [id]: true, - })); - try { - const data = - await api.revealEnvVar(id); - setEditingValue(data.value); - setRevealValues((prev) => ({ - ...prev, - [id]: data.value, - })); - } finally { - setSavingEdit((prev) => ({ - ...prev, - [id]: false, - })); - } - }; - - const saveEdit = async (id: string) => { - setSavingEdit((prev) => ({ - ...prev, - [id]: true, - })); - try { - await api.updateEnvVar( - id, - editingValue, - ); - setEditingId(null); - setEditingValue(""); - setRevealValues((prev) => ({ - ...prev, - [id]: editingValue, - })); - setShowRedeployPrompt(true); - await refetch(); - } finally { - setSavingEdit((prev) => ({ - ...prev, - [id]: false, - })); - } - }; - - const copyToClipboard = async ( - id: string, - ) => { - try { - let targetValue = revealValues[id]; - if (!targetValue) { - setRevealing((prev) => ({ - ...prev, - [id]: true, - })); - const data = - await api.revealEnvVar(id); - targetValue = data.value; - setRevealing((prev) => ({ - ...prev, - [id]: false, - })); - } - await navigator.clipboard.writeText( - targetValue, - ); - setCopiedId(id); - setTimeout( - () => setCopiedId(null), - 1500, - ); - } catch (err) { - console.error("Failed to copy", err); - } - }; - - const toggleReveal = async (id: string) => { - if (revealValues[id]) { - setRevealValues((prev) => { - const next = { ...prev }; - delete next[id]; - return next; - }); - return; - } - - setRevealing((prev) => ({ - ...prev, - [id]: true, - })); - try { - const data = - await api.revealEnvVar(id); - setRevealValues((prev) => ({ - ...prev, - [id]: data.value, - })); - } finally { - setRevealing((prev) => ({ - ...prev, - [id]: false, - })); - } - }; - - const add = async (e: React.FormEvent) => { - e.preventDefault(); - const validVars = newVars.filter((v) => v.key.trim() && v.value.trim()); - if (validVars.length === 0) return; - - try { - await Promise.all( - validVars.map((v) => - api.createEnvVar(projectId, { - key: v.key.trim(), - value: v.value.trim(), - environment: v.env.trim() || undefined, - }) - ) - ); - setNewVars([{ key: "", value: "", env: "" }]); - setIsAddOpen(false); - setShowRedeployPrompt(true); - refetch(); - } catch (err) { - console.error("Failed to add variables", err); - } - }; - - return ( -
- {/* Redeployment Warning Banner */} - {showRedeployPrompt && - runningDeployment && ( -
-
-
- -
-
-

- Redeployment - Required -

-

- You have added - or updated - environment - variables. - Redeploy the - active - configuration - to apply the - new settings. -

-
-
-
- - -
-
- )} - - {envVars.length === 0 ? ( -
-
-
-
- -
-

- No Environment Variables -

-

- Store encrypted - configuration secrets, API - credentials, and database - URIs injected dynamically - into your container at - runtime. -

-
- - -
-
- ) : ( -
-
-
-

- Environment - Variables -

-

- Secure, encrypted - settings injected - into deployments. -

-
-
- - -
-
- -
- - - - - Key - - - Value - - - Environment - - - - - - {envVars.map( - (ev) => ( - - - - { - ev.key - } - - - {editingId === - ev.id ? ( - - setEditingValue( - e - .target - .value, - ) - } - className="h-8 bg-[#09090c] border-input text-xs font-mono" - /> - ) : ( - - {revealValues[ - ev - .id - ] ?? - "••••••••"} - - )} - - - {ev.environment ? ( - - { - ev.environment - } - - ) : ( - - all - (default) - - )} - - -
- {editingId === - ev.id ? ( - <> - - - - ) : ( - <> - - - - - )} - -
-
-
- ), - )} -
-
-
-
- )} - - {/* Add Variable Sheet */} - - - - - Add Environment Variables - - - Store configuration keys that will be injected into your service deployments. You can add multiple variables at once. - - - -
-
- {newVars.map((v, index) => ( -
- {newVars.length > 1 && ( - - )} -
- Variable #{index + 1} -
-
- - - updateRow(index, "key", e.target.value) - } - className="h-9 bg-[#0d0d11] border-input focus:ring-1 focus:ring-primary text-xs font-semibold rounded-lg font-mono" - required - /> -
- -
- - - updateRow(index, "value", e.target.value) - } - className="h-9 bg-[#0d0d11] border-input focus:ring-1 focus:ring-primary text-xs font-semibold rounded-lg" - required - /> -
- -
- - -
-
- ))} - - -
- - - - - -
-
-
- - {/* Import .env File Dialog */} - - - - - Import .env File - - - Upload a local - configuration file to - quickly populate keys. - - - -
- {/* Dashed drop zone */} -
- -

- Select a file or - drag here -

-

- Supports .env or - .txt key-value - files -

- -
- - {parsedFileVars && - importStatus !== - "done" && ( -
-
- - Parsed - variables: - - - { - parsedFileVars.length - }{" "} - found - -
- {/* Mini Scrollable Preview list */} -
- {parsedFileVars - .slice( - 0, - 10, - ) - .map( - ( - v, - idx, - ) => ( -
- - { - v.key - } - - - { - v.value - } - -
- ), - )} - {parsedFileVars.length > - 10 && ( -
- +{" "} - {parsedFileVars.length - - 10}{" "} - more - variables -
- )} -
-
- )} - - {fileError && ( -
- {fileError} -
- )} - - {importStatus === - "done" && ( -
- {" "} - Successfully - Imported - Variables! -
- )} - -
- - {parsedFileVars && - importStatus !== - "done" && ( - - )} -
-
-
-
-
- ); -} diff --git a/apps/web/src/components/project/LogsTab.tsx b/apps/web/src/components/project/LogsTab.tsx deleted file mode 100644 index d4e5f34..0000000 --- a/apps/web/src/components/project/LogsTab.tsx +++ /dev/null @@ -1,443 +0,0 @@ -import React, { useState } from "react"; -import { useDeployments } from "../../hooks/useDeployments"; -import { useRuntimeLogs, useRequestLogs } from "../../hooks/useDeploymentLogs"; -import { Button } from "../ui/button"; -import { Input } from "../ui/input"; -import { Search, RefreshCw, X } from "lucide-react"; -import { cn } from "../../lib/utils"; - -function stripAnsi(str: string): string { - // Strip full ANSI escape sequences (ESC + bracket + params + terminator) - let s = str.replace(/[\u001b\u009b]\[[\d;]*[A-Za-z]/g, ""); - // Strip orphaned bracket sequences where ESC byte was already removed (e.g. "[35m", "[0m") - s = s.replace(/\[(\d+;)*\d*m/g, ""); - return s; -} - -interface LogsTabProps { - projectId: string; -} - -export function LogsTab({ projectId }: LogsTabProps) { - const { data } = useDeployments(projectId); - const deployments = data?.items ?? []; - const latest = deployments.find((d) => d.status === "running") ?? deployments[0]; - const [isLive, setIsLive] = useState(true); - const [logSource, setLogSource] = useState<"runtime" | "request">("request"); - - const [startDate, setStartDate] = useState(""); - const [endDate, setEndDate] = useState(""); - - const startMs = startDate ? new Date(startDate).getTime() : null; - const endMs = endDate ? new Date(endDate).getTime() : null; - - const { logs: runtimeLogs, isLoading: isRuntimeLoading, refetch: refetchRuntime } = useRuntimeLogs(latest?.id || null, isLive && logSource === "runtime"); - const { logs: requestLogs, isLoading: isRequestLoading, refetch: refetchRequest } = useRequestLogs(projectId, isLive && logSource === "request", startMs, endMs); - - const logs = logSource === "runtime" ? runtimeLogs : requestLogs; - const isLoading = logSource === "runtime" ? isRuntimeLoading : isRequestLoading; - const refetch = logSource === "runtime" ? refetchRuntime : refetchRequest; - - const [searchQuery, setSearchQuery] = useState(""); - const [showInfo, setShowInfo] = useState(true); - const [showWarning, setShowWarning] = useState(true); - const [showError, setShowError] = useState(true); - - // Drawer state - const [selectedLog, setSelectedLog] = useState(null); - - // Parse logs - const parsedLogs = logs.map((log) => { - let message = stripAnsi(log.message); - let level = "info"; - let status = ""; - let host = "localhost"; - let request = ""; - let raw = log.message; - - if (message.startsWith("{") && message.endsWith("}")) { - try { - const obj = JSON.parse(message); - if (obj.level) level = obj.level.toLowerCase(); - if (obj.status) { - status = String(obj.status); - const statusNum = Number(obj.status); - if (statusNum >= 500) level = "error"; - else if (statusNum >= 400) level = "warning"; - } - if (obj.request) { - host = obj.request.host || host; - request = `${obj.request.method || ""} ${obj.request.uri || ""}`; - const durMs = obj.duration ? `${(obj.duration * 1000).toFixed(2)}ms` : "0ms"; - const bytes = obj.size ? `${obj.size} B` : "0 B"; - message = `Status: ${obj.status || "—"} • Duration: ${durMs} • Size: ${bytes}`; - } else { - message = obj.msg || obj.message || message; - } - raw = JSON.stringify(obj, null, 2); - } catch {} - } else { - const upper = message.toUpperCase(); - if (upper.includes("ERROR") || upper.includes("CRITICAL") || upper.includes("FAIL")) level = "error"; - else if (upper.includes("WARN")) level = "warning"; - } - - return { - ...log, - parsedMessage: message, - level, - status, - host, - request, - raw, - }; - }); - - // Filter logs - const filteredLogs = parsedLogs.filter((log) => { - if (searchQuery && !log.message.toLowerCase().includes(searchQuery.toLowerCase())) { - return false; - } - if (log.level === "error" && !showError) return false; - if (log.level === "warning" && !showWarning) return false; - if (log.level === "info" && !showInfo) return false; - return true; - }); - - // Group logs into 1-minute bins for the last 30 minutes to make a histogram - const makeHistogram = () => { - const nowMs = Date.now(); - const bins = Array.from({ length: 30 }, (_, idx) => { - const binStart = nowMs - (30 - idx) * 60000; - const binEnd = binStart + 60000; - return { - start: binStart, - end: binEnd, - count: 0, - }; - }); - - for (const log of filteredLogs) { - const time = new Date((log as any).timestamp || log.createdAt).getTime(); - for (const bin of bins) { - if (time >= bin.start && time < bin.end) { - bin.count++; - break; - } - } - } - return bins; - }; - - const bins = makeHistogram(); - const maxCount = Math.max(...bins.map((b) => b.count), 1); - - return ( -
- {/* Top Filters Header */} -
-
- {/* Log Source Selector */} -
- - -
- - {/* Search message */} -
- - setSearchQuery(e.target.value)} - className="h-8 pl-8 bg-[#141417] border-[#222227] focus:border-amber-500 text-zinc-200 text-xs w-full shadow-none" - /> -
- - {/* Severity Checkboxes */} -
- - - -
-
- -
- - -
-
- - {/* Date Range Selection (Only for Request Monitoring) */} - {logSource === "request" && ( -
-
- Date Filter: -
-
-
- From - setStartDate(e.target.value)} - className="bg-[#141417] border border-[#222227] rounded-md px-2.5 py-1 text-zinc-300 text-xs focus:outline-none focus:border-amber-500 font-mono" - /> -
-
- To - setEndDate(e.target.value)} - className="bg-[#141417] border border-[#222227] rounded-md px-2.5 py-1 text-zinc-300 text-xs focus:outline-none focus:border-amber-500 font-mono" - /> -
- {(startDate || endDate) && ( -
- - - Streaming Paused - -
- )} -
-
- )} - - {/* Log count Timeline Chart */} -
-
- {logSource === "request" ? "Request Count Distribution (Last 30 Minutes)" : "Log Count Distribution (Last 30 Minutes)"} -
-
- {bins.map((bin, idx) => ( -
-
- {bin.count} logs -
-
- ))} -
-
- - {/* Logs Table Layout */} -
-
-
- - - - - - {logSource === "request" && ( - <> - - - - )} - - - - - {isLoading ? ( - - - - ) : filteredLogs.length === 0 ? ( - - - - ) : ( - filteredLogs.map((log, idx) => ( - setSelectedLog(log)} - className={cn( - "hover:bg-[#141418] cursor-pointer transition-colors border-l-2", - log.level === "error" - ? "border-l-red-500 hover:border-l-red-400" - : log.level === "warning" - ? "border-l-amber-500 hover:border-l-amber-400" - : "border-l-transparent hover:border-l-zinc-700", - selectedLog?.id === log.id && "bg-[#16161b] hover:bg-[#16161b]", - )} - > - - - {logSource === "request" && ( - <> - - - - )} - - - )) - )} - -
TimeLevelHostRequestMessage
- Loading logs... -
- {logSource === "request" ? "No request logs found." : "No runtime logs found."} -
- {new Date( - (log as any).timestamp || log.createdAt, - ).toLocaleTimeString()} - - - {log.level} - - - {log.host} - - {log.request} - - {log.parsedMessage} -
-
-
- - {selectedLog && ( -
- - -
-

- Log Event Details -

- - {selectedLog.level} - -
- -
-
-
Timestamp
-
- {new Date( - (selectedLog as any).timestamp || selectedLog.createdAt, - ).toLocaleString()} -
-
- -
-
Message
-
{selectedLog.parsedMessage}
-
- -
-
Raw JSON payload
-
-									{selectedLog.raw}
-								
-
-
-
- )} -
-
- ); -} diff --git a/apps/web/src/components/project/StepBasics.tsx b/apps/web/src/components/project/StepBasics.tsx deleted file mode 100644 index 4a17bc7..0000000 --- a/apps/web/src/components/project/StepBasics.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { Input } from '../ui/input'; -import { Sliders, GitBranch } from 'lucide-react'; - -interface StepBasicsProps { - name: string; - setName: (v: string) => void; - description: string; - setDescription: (v: string) => void; - baseDomain: string; - setBaseDomain: (v: string) => void; - repoUrl: string; - setRepoUrl: (v: string) => void; - repoBranch: string; - setRepoBranch: (v: string) => void; -} - -export function StepBasics({ - name, - setName, - description, - setDescription, - baseDomain, - setBaseDomain, - repoUrl, - setRepoUrl, - repoBranch, - setRepoBranch -}: StepBasicsProps) { - return ( -
-
-

- - General Settings -

-
-
- - setName(e.target.value)} - autoFocus - /> -
-
- - setDescription(e.target.value)} - /> -
-
- - setBaseDomain(e.target.value)} - /> - - Leave empty to auto-assign a default hostname on localhost caddy ingress router. - -
-
-
- -
-

- - Git Repository Details -

-
-
- - setRepoUrl(e.target.value)} - /> -
-
- - setRepoBranch(e.target.value)} - /> -
-
-
-
- ); -} diff --git a/apps/web/src/components/project/AlertsTab.tsx b/apps/web/src/components/project/alerts/AlertsTab.tsx similarity index 85% rename from apps/web/src/components/project/AlertsTab.tsx rename to apps/web/src/components/project/alerts/AlertsTab.tsx index c38b21e..1f5b2b0 100644 --- a/apps/web/src/components/project/AlertsTab.tsx +++ b/apps/web/src/components/project/alerts/AlertsTab.tsx @@ -1,10 +1,10 @@ import React, { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import * as api from "../../api/client"; -import { Card, CardContent } from "../ui/card"; -import { Input } from "../ui/input"; -import { Button } from "../ui/button"; -import { Badge } from "../ui/badge"; +import * as api from "../../../api/client"; +import { Card, CardContent } from "../../ui/card"; +import { Input } from "../../ui/input"; +import { Button } from "../../ui/button"; +import { Badge } from "../../ui/badge"; import { Trash2, BellOff, @@ -21,7 +21,8 @@ import { DialogHeader, DialogTitle, DialogDescription, -} from "../ui/dialog"; + DialogFooter, +} from "../../ui/dialog"; interface AlertsTabProps { projectId: string; @@ -70,6 +71,15 @@ export function AlertsTab({ projectId }: AlertsTabProps) { const [threshold, setThreshold] = useState("80"); const [channel, setChannel] = useState("email"); + const [deletingAlertId, setDeletingAlertId] = useState(null); + + const handleDeleteAlert = async () => { + if (!deletingAlertId) return; + await api.deleteAlert(deletingAlertId); + setDeletingAlertId(null); + refetch(); + }; + const add = async (e: React.FormEvent) => { e.preventDefault(); await api.createAlert(projectId, { @@ -200,9 +210,7 @@ export function AlertsTab({ projectId }: AlertsTabProps) { size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-lg opacity-80 group-hover:opacity-100 transition-all" onClick={() => - api - .deleteAlert(a.id) - .then(() => refetch()) + setDeletingAlertId(a.id) } > @@ -307,6 +315,40 @@ export function AlertsTab({ projectId }: AlertsTabProps) { + + { + if (!open) setDeletingAlertId(null); + }} + > + + + + Delete Alert Rule + + + Are you sure you want to delete this alert rule? You will + no longer receive notifications for this condition. + + + + + + + +
); } diff --git a/apps/web/src/components/project/create/CreateProjectDialog.tsx b/apps/web/src/components/project/create/CreateProjectDialog.tsx new file mode 100644 index 0000000..5b9ce65 --- /dev/null +++ b/apps/web/src/components/project/create/CreateProjectDialog.tsx @@ -0,0 +1,507 @@ +import { useState, useEffect } from "react"; +import { useCreateProject } from "../../../hooks/useProjects"; +import { Button } from "../../ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../../ui/dialog"; +import { cn } from "../../../lib/utils"; +import { Plus } from "lucide-react"; +import * as api from "../../../api/client"; +import { getGithubIntegration } from "../../../api/client"; +import type { GithubRepo } from "../../../types"; + +import { StepBasics } from "./StepBasics"; +import { StepEnvironment } from "./StepEnvironment"; +import { StepResources } from "./StepResources"; +import { CreationStatusOverlay } from "./CreationStatusOverlay"; + +interface CreateProjectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CreateProjectDialog({ + open, + onOpenChange, +}: CreateProjectDialogProps) { + const createProject = useCreateProject(); + const [step, setStep] = useState(1); + + const [name, setName] = useState(""); + const [description, setDescription] = + useState(""); + const [baseDomain, setBaseDomain] = + useState(""); + const [repoUrl, setRepoUrl] = useState(""); + const [repoBranch, setRepoBranch] = + useState(""); + const [selectedRepo, setSelectedRepo] = + useState(null); + const [githubConnected, setGithubConnected] = + useState(false); + + const [stagedEnvs, setStagedEnvs] = useState< + Array<{ + key: string; + value: string; + environment?: string; + }> + >([]); + + const [cpuLimit, setCpuLimit] = useState(""); + const [memoryLimitMb, setMemoryLimitMb] = + useState(""); + const [provisionDb, setProvisionDb] = + useState(false); + const [dbType, setDbType] = useState< + "postgresql" | "mysql" + >("postgresql"); + const [dbVersion, setDbVersion] = + useState(""); + const [dbCpu, setDbCpu] = useState(""); + const [dbMemory, setDbMemory] = useState(""); + + const [ + submittingStatus, + setSubmittingStatus, + ] = useState< + | "idle" + | "creating_project" + | "creating_envs" + | "creating_db" + | "done" + | "error" + >("idle"); + const [errorMessage, setErrorMessage] = + useState(""); + + useEffect(() => { + getGithubIntegration() + .then((status) => { + if ((status as any).configured) { + api.getGithubUser() + .then(() => + setGithubConnected( + true, + ), + ) + .catch(() => {}); + } + }) + .catch(() => {}); + }, [open]); + + useEffect(() => { + const params = new URLSearchParams( + window.location.search, + ); + if ( + params.get("github") === "connected" + ) { + setGithubConnected(true); + window.history.replaceState( + {}, + "", + window.location.pathname, + ); + } + }, []); + + const handleOpenChange = ( + isOpen: boolean, + ) => { + onOpenChange(isOpen); + if (!isOpen) { + setStep(1); + setName(""); + setDescription(""); + setBaseDomain(""); + setRepoUrl(""); + setRepoBranch(""); + setSelectedRepo(null); + } + }; + + const handleCreate = async ( + e: React.FormEvent, + ) => { + e.preventDefault(); + if (!name.trim()) return; + + setSubmittingStatus("creating_project"); + setErrorMessage(""); + + try { + const project = + await createProject.mutateAsync({ + name: name.trim(), + description: + description.trim() || + undefined, + baseDomain: + baseDomain.trim() || + undefined, + repoUrl: + repoUrl.trim() || + undefined, + repoBranch: + repoBranch.trim() || + undefined, + }); + + if (stagedEnvs.length > 0) { + setSubmittingStatus( + "creating_envs", + ); + await Promise.all( + stagedEnvs.map((env) => + api.createEnvVar( + project.id, + { + key: env.key.trim(), + value: env.value.trim(), + environment: + env.environment || + "production", + }, + ), + ), + ); + } + + if (provisionDb) { + setSubmittingStatus( + "creating_db", + ); + await api.createDatabase( + project.id, + dbType, + { + version: + dbVersion.trim() || + undefined, + cpuLimit: dbCpu.trim() + ? Number(dbCpu) + : null, + memoryLimitMb: + dbMemory.trim() + ? Number(dbMemory) + : null, + }, + ); + } + + setSubmittingStatus("done"); + + setTimeout(() => { + handleOpenChange(false); + }, 1000); + } catch (err: any) { + console.error(err); + setErrorMessage( + err.message || + "An unexpected error occurred during creation.", + ); + setSubmittingStatus("error"); + } + }; + + return ( + + + + + + {submittingStatus !== "idle" ? ( + 0 + } + hasDb={provisionDb} + dbType={dbType} + onRetry={() => + setSubmittingStatus( + "idle", + ) + } + /> + ) : ( +
+ + + Create New Project + + + Set up your + project details, + configure + variables, and + optionally + provision a + database instance. + + + +
+
+ + 1 + + + Basics & Git + +
+
+
+ + 2 + + + Environment + +
+
+
+ + 3 + + + Resources & DB + +
+
+ + {step === 1 && ( + + githubConnected + } + /> + )} + + {step === 2 && ( + + )} + + {step === 3 && ( + + )} + + +
+ {step === 1 && + "Step 1 of 3: General & Git settings"} + {step === 2 && + "Step 2 of 3: Staging env variables"} + {step === 3 && + "Step 3 of 3: Allocation limits & Databases"} +
+
+ {step > 1 && ( + + )} + {step === 1 && ( + + )} + + {step < 3 ? ( + + ) : ( + + )} +
+
+ + )} + +
+ ); +} diff --git a/apps/web/src/components/project/CreationStatusOverlay.tsx b/apps/web/src/components/project/create/CreationStatusOverlay.tsx similarity index 99% rename from apps/web/src/components/project/CreationStatusOverlay.tsx rename to apps/web/src/components/project/create/CreationStatusOverlay.tsx index d797076..3643ff0 100644 --- a/apps/web/src/components/project/CreationStatusOverlay.tsx +++ b/apps/web/src/components/project/create/CreationStatusOverlay.tsx @@ -1,4 +1,4 @@ -import { Button } from '../ui/button'; +import { Button } from '../../ui/button'; import { Loader2, CheckCircle2, XCircle, AlertCircle } from 'lucide-react'; interface CreationStatusOverlayProps { diff --git a/apps/web/src/components/project/create/StepBasics.tsx b/apps/web/src/components/project/create/StepBasics.tsx new file mode 100644 index 0000000..765982e --- /dev/null +++ b/apps/web/src/components/project/create/StepBasics.tsx @@ -0,0 +1,218 @@ +import { useState } from 'react'; +import { Input } from '../../ui/input'; +import { Sliders, GitBranch, ExternalLink } from 'lucide-react'; +import { getGithubAuthUrl } from '../../../api/client'; +import { RepoPicker } from '../../github/RepoPicker'; +import type { GithubRepo } from '../../../types'; + +interface StepBasicsProps { + name: string; + setName: (v: string) => void; + description: string; + setDescription: (v: string) => void; + baseDomain: string; + setBaseDomain: (v: string) => void; + repoUrl: string; + setRepoUrl: (v: string) => void; + repoBranch: string; + setRepoBranch: (v: string) => void; + selectedRepo: GithubRepo | null; + setSelectedRepo: (v: GithubRepo | null) => void; + onGithubConnected: () => boolean; +} + +export function StepBasics({ + name, + setName, + description, + setDescription, + baseDomain, + setBaseDomain, + repoUrl, + setRepoUrl, + repoBranch, + setRepoBranch, + selectedRepo, + setSelectedRepo, + onGithubConnected, +}: StepBasicsProps) { + const [showManual, setShowManual] = useState(false); + const connected = onGithubConnected(); + + const handleConnectGithub = async () => { + try { + const { url } = await getGithubAuthUrl(); + window.location.href = url; + } catch (err) { + console.error("GitHub not configured", err); + } + }; + + const handleSelectRepo = (repo: GithubRepo | null) => { + setSelectedRepo(repo); + if (repo) { + setRepoUrl(repo.cloneUrl); + setRepoBranch(repo.defaultBranch); + } else { + setRepoUrl(""); + setRepoBranch(""); + } + }; + + return ( +
+
+

+ + General Settings +

+
+
+ + setName(e.target.value)} + autoFocus + /> +
+
+ + setDescription(e.target.value)} + /> +
+
+ + setBaseDomain(e.target.value)} + /> + + Leave empty to auto-assign a default hostname on localhost caddy ingress router. + +
+
+
+ +
+

+ + Git Repository +

+ + {connected ? ( +
+ { + setSelectedRepo(null); + setRepoUrl(""); + setRepoBranch(""); + }} + /> +
+ +
+ {showManual && ( +
+
+ + { + setRepoUrl(e.target.value); + setSelectedRepo(null); + }} + /> +
+
+ + setRepoBranch(e.target.value)} + /> +
+
+ )} +
+ ) : ( +
+ + +
+ +
+ + {showManual && ( +
+
+ + setRepoUrl(e.target.value)} + /> +
+
+ + setRepoBranch(e.target.value)} + /> +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/project/StepEnvironment.tsx b/apps/web/src/components/project/create/StepEnvironment.tsx similarity index 98% rename from apps/web/src/components/project/StepEnvironment.tsx rename to apps/web/src/components/project/create/StepEnvironment.tsx index 7146a01..347a063 100644 --- a/apps/web/src/components/project/StepEnvironment.tsx +++ b/apps/web/src/components/project/create/StepEnvironment.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; -import { Button } from '../ui/button'; -import { Input } from '../ui/input'; -import { cn } from '../../lib/utils'; +import { Button } from '../../ui/button'; +import { Input } from '../../ui/input'; +import { cn } from '../../../lib/utils'; import { Server, Upload, Trash2 } from 'lucide-react'; interface StagedEnv { diff --git a/apps/web/src/components/project/StepResources.tsx b/apps/web/src/components/project/create/StepResources.tsx similarity index 98% rename from apps/web/src/components/project/StepResources.tsx rename to apps/web/src/components/project/create/StepResources.tsx index ddb0b03..76ca7b0 100644 --- a/apps/web/src/components/project/StepResources.tsx +++ b/apps/web/src/components/project/create/StepResources.tsx @@ -1,5 +1,5 @@ -import { Input } from '../ui/input'; -import { cn } from '../../lib/utils'; +import { Input } from '../../ui/input'; +import { cn } from '../../../lib/utils'; import { Sliders, Database } from 'lucide-react'; interface StepResourcesProps { diff --git a/apps/web/src/components/project/DatabasesTab.tsx b/apps/web/src/components/project/databases/DatabasesTab.tsx similarity index 98% rename from apps/web/src/components/project/DatabasesTab.tsx rename to apps/web/src/components/project/databases/DatabasesTab.tsx index 27a1816..b0fe66b 100644 --- a/apps/web/src/components/project/DatabasesTab.tsx +++ b/apps/web/src/components/project/databases/DatabasesTab.tsx @@ -3,17 +3,17 @@ import React, { useEffect, } from "react"; import { useQuery } from "@tanstack/react-query"; -import * as api from "../../api/client"; +import * as api from "../../../api/client"; import { Card, CardContent, CardHeader, CardTitle, -} from "../ui/card"; -import { Input } from "../ui/input"; -import { Button } from "../ui/button"; -import { Badge } from "../ui/badge"; -import { StatusBadge } from "../StatusBadge"; +} from "../../ui/card"; +import { Input } from "../../ui/input"; +import { Button } from "../../ui/button"; +import { Badge } from "../../ui/badge"; +import { StatusBadge } from "../../StatusBadge"; import { Database, Trash2, @@ -30,14 +30,14 @@ import { DialogTitle, DialogDescription, DialogFooter, -} from "../ui/dialog"; +} from "../../ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "../ui/select"; +} from "../../ui/select"; interface DatabasesTabProps { projectId: string; diff --git a/apps/web/src/components/project/DeploymentsTab.tsx b/apps/web/src/components/project/deployments/DeploymentsTab.tsx similarity index 97% rename from apps/web/src/components/project/DeploymentsTab.tsx rename to apps/web/src/components/project/deployments/DeploymentsTab.tsx index 1eeb9e4..acf1417 100644 --- a/apps/web/src/components/project/DeploymentsTab.tsx +++ b/apps/web/src/components/project/deployments/DeploymentsTab.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from "react"; -import { useProject } from "../../hooks/useProjects"; +import { useProject } from "../../../hooks/useProjects"; import { useDeployments, useCreateDeployment, @@ -7,7 +7,7 @@ import { useRedeployDeployment, useCancelDeployment, useDeleteDeployment, -} from "../../hooks/useDeployments"; +} from "../../../hooks/useDeployments"; import { Dialog, DialogContent, @@ -15,16 +15,16 @@ import { 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"; +} 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"; +import { cn } from "../../../lib/utils"; function formatTimeAgo(dateStr: string) { const diff = diff --git a/apps/web/src/components/project/DomainsTab.tsx b/apps/web/src/components/project/domains/DomainsTab.tsx similarity index 97% rename from apps/web/src/components/project/DomainsTab.tsx rename to apps/web/src/components/project/domains/DomainsTab.tsx index 28ee8c0..3cef41e 100644 --- a/apps/web/src/components/project/DomainsTab.tsx +++ b/apps/web/src/components/project/domains/DomainsTab.tsx @@ -1,8 +1,8 @@ import React, { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useProject } from "../../hooks/useProjects"; -import * as api from "../../api/client"; -import { Card, CardContent } from "../ui/card"; +import { useProject } from "../../../hooks/useProjects"; +import * as api from "../../../api/client"; +import { Card, CardContent } from "../../ui/card"; import { Table, TableBody, @@ -10,11 +10,11 @@ import { TableHead, TableHeader, TableRow, -} from "../ui/table"; -import { Input } from "../ui/input"; -import { Button } from "../ui/button"; -import { Badge } from "../ui/badge"; -import { StatusBadge } from "../StatusBadge"; +} from "../../ui/table"; +import { Input } from "../../ui/input"; +import { Button } from "../../ui/button"; +import { Badge } from "../../ui/badge"; +import { StatusBadge } from "../../StatusBadge"; import { Trash2, X, @@ -30,7 +30,7 @@ import { DialogTitle, DialogDescription, DialogFooter, -} from "../ui/dialog"; +} from "../../ui/dialog"; interface DomainsTabProps { projectId: string; @@ -270,7 +270,7 @@ export function DomainsTab({ IP:{" "} {serverIp?.ip ?? - "..."} + "../.."}

@@ -315,7 +315,7 @@ export function DomainsTab({ {serverIp?.ip ?? - "..."} + "../.."} {serverIp?.ip && ( + )} +
+ Variable #{index + 1} +
+
+ + + updateRow( + index, + "key", + e.target.value, + ) + } + className="h-9 bg-[#0d0d11] border-input focus:ring-1 focus:ring-primary text-xs font-semibold rounded-lg font-mono" + required + /> +
+ +
+ + + updateRow( + index, + "value", + e.target.value, + ) + } + className="h-9 bg-[#0d0d11] border-input focus:ring-1 focus:ring-primary text-xs font-semibold rounded-lg" + required + /> +
+ +
+ + +
+
+ ))} + + +
+ + + + + + + + + ); +} diff --git a/apps/web/src/components/project/envtab/DeleteEnvVarDialog.tsx b/apps/web/src/components/project/envtab/DeleteEnvVarDialog.tsx new file mode 100644 index 0000000..97cd004 --- /dev/null +++ b/apps/web/src/components/project/envtab/DeleteEnvVarDialog.tsx @@ -0,0 +1,53 @@ +import { Button } from "../../ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "../../ui/dialog"; + +interface DeleteEnvVarDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => Promise; +} + +export function DeleteEnvVarDialog({ + open, + onOpenChange, + onConfirm, +}: DeleteEnvVarDialogProps) { + return ( + + + + + Delete Variable + + + Are you sure you want to delete this environment variable? + Your application may need to be redeployed for changes to + take effect. + + + + + + + + + ); +} diff --git a/apps/web/src/components/project/envtab/EmptyEnvState.tsx b/apps/web/src/components/project/envtab/EmptyEnvState.tsx new file mode 100644 index 0000000..8e1b9be --- /dev/null +++ b/apps/web/src/components/project/envtab/EmptyEnvState.tsx @@ -0,0 +1,41 @@ +import { Button } from "../../ui/button"; +import { KeyRound, Plus, Upload } from "lucide-react"; + +interface EmptyEnvStateProps { + onAdd: () => void; + onImport: () => void; +} + +export function EmptyEnvState({ onAdd, onImport }: EmptyEnvStateProps) { + return ( +
+
+
+
+ +
+

+ No Environment Variables +

+

+ Store encrypted configuration secrets, API credentials, and database + URIs injected dynamically into your container at runtime. +

+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/project/envtab/EnvVarRow.tsx b/apps/web/src/components/project/envtab/EnvVarRow.tsx new file mode 100644 index 0000000..b6c7478 --- /dev/null +++ b/apps/web/src/components/project/envtab/EnvVarRow.tsx @@ -0,0 +1,183 @@ +import { useState } from "react"; +import { Button } from "../../ui/button"; +import { Input } from "../../ui/input"; +import { Badge } from "../../ui/badge"; +import { + Lock, + Pencil, + Eye, + EyeOff, + Copy, + Check, + Trash2, +} from "lucide-react"; +import * as api from "../../../api/client"; + +interface EnvVar { + id: string; + key: string; + value: string | null; + environment: string; +} + +interface EnvVarRowProps { + variable: EnvVar; + revealed: string | undefined; + revealing: boolean; + editing: boolean; + editingValue: string; + saving: boolean; + copied: boolean; + projectId: string; + onDelete: (id: string) => void; + onStartEdit: (id: string) => void; + onCancelEdit: () => void; + onSaveEdit: (id: string) => Promise; + onReveal: (id: string) => Promise; + onHide: (id: string) => void; + onCopy: (id: string) => Promise; + onEditingValueChange: (value: string) => void; +} + +export function EnvVarRow({ + variable: ev, + revealed, + revealing, + editing, + editingValue, + saving, + copied, + onDelete, + onStartEdit, + onCancelEdit, + onSaveEdit, + onReveal, + onHide, + onCopy, + onEditingValueChange, +}: EnvVarRowProps) { + return ( + + + + {ev.key} + + + {editing ? ( + + onEditingValueChange(e.target.value) + } + className="h-8 bg-[#09090c] border-input text-xs font-mono" + /> + ) : ( + + {revealed ?? "••••••••"} + + )} + + + {ev.environment ? ( + + {ev.environment} + + ) : ( + + all (default) + + )} + + +
+ {editing ? ( + <> + + + + ) : ( + <> + + + + + )} + +
+ + + ); +} diff --git a/apps/web/src/components/project/envtab/EnvVarTable.tsx b/apps/web/src/components/project/envtab/EnvVarTable.tsx new file mode 100644 index 0000000..f6c4314 --- /dev/null +++ b/apps/web/src/components/project/envtab/EnvVarTable.tsx @@ -0,0 +1,123 @@ +import { Button } from "../../ui/button"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "../../ui/table"; +import { Plus, Upload } from "lucide-react"; +import { EnvVarRow } from "./EnvVarRow"; + +interface EnvVar { + id: string; + key: string; + value: string | null; + environment: string; +} + +interface EnvVarTableProps { + envVars: EnvVar[]; + editingId: string | null; + editingValue: string; + savingEdit: Record; + revealValues: Record; + revealing: Record; + copiedId: string | null; + onAdd: () => void; + onImport: () => void; + onDelete: (id: string) => void; + onStartEdit: (id: string) => void; + onCancelEdit: () => void; + onSaveEdit: (id: string) => Promise; + onReveal: (id: string) => Promise; + onHide: (id: string) => void; + onCopy: (id: string) => Promise; + onEditingValueChange: (value: string) => void; +} + +export function EnvVarTable({ + envVars, + editingId, + editingValue, + savingEdit, + revealValues, + revealing, + copiedId, + onAdd, + onImport, + onDelete, + onStartEdit, + onCancelEdit, + onSaveEdit, + onReveal, + onHide, + onCopy, + onEditingValueChange, +}: EnvVarTableProps) { + return ( +
+
+
+

+ Environment Variables +

+

+ Secure, encrypted settings injected into deployments. +

+
+
+ + +
+
+ +
+ + + + + Key + + + Value + + + Environment + + + + + + {envVars.map((ev) => ( + + ))} + +
+
+
+ ); +} diff --git a/apps/web/src/components/project/envtab/EnvVarsTab.tsx b/apps/web/src/components/project/envtab/EnvVarsTab.tsx new file mode 100644 index 0000000..557ed75 --- /dev/null +++ b/apps/web/src/components/project/envtab/EnvVarsTab.tsx @@ -0,0 +1,211 @@ +import React, { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import * as api from "../../../api/client"; +import { + useDeployments, + useRedeployDeployment, +} from "../../../hooks/useDeployments"; +import { RedeployBanner } from "./RedeployBanner"; +import { EmptyEnvState } from "./EmptyEnvState"; +import { EnvVarTable } from "./EnvVarTable"; +import { AddEnvVarSheet } from "./AddEnvVarSheet"; +import { ImportEnvFileDialog } from "./ImportEnvFileDialog"; +import { DeleteEnvVarDialog } from "./DeleteEnvVarDialog"; + +interface EnvVarsTabProps { + projectId: string; +} + +export function EnvVarsTab({ projectId }: EnvVarsTabProps) { + const { data: envVars = [], refetch } = useQuery({ + queryKey: ["env-vars", projectId], + queryFn: () => api.listEnvVars(projectId), + }); + + const { data: deploymentsData } = useDeployments(projectId, 0, 5); + const redeploy = useRedeployDeployment(); + const navigate = useNavigate(); + const runningDeployment = + deploymentsData?.items?.find((d) => d.status === "running"); + + const [isAddOpen, setIsAddOpen] = useState(false); + const [isImportOpen, setIsImportOpen] = useState(false); + const [showRedeployPrompt, setShowRedeployPrompt] = useState(false); + const [deletingEvId, setDeletingEvId] = useState(null); + const [editingId, setEditingId] = useState(null); + const [editingValue, setEditingValue] = useState(""); + const [savingEdit, setSavingEdit] = useState>({}); + const [revealValues, setRevealValues] = useState>({}); + const [revealing, setRevealing] = useState>({}); + const [copiedId, setCopiedId] = useState(null); + + const handleDeleteEnvVar = async () => { + if (!deletingEvId) return; + await api.deleteEnvVar(deletingEvId); + setDeletingEvId(null); + setShowRedeployPrompt(true); + refetch(); + }; + + const startEdit = async (id: string) => { + setEditingId(id); + setEditingValue(""); + setSavingEdit((prev) => ({ ...prev, [id]: true })); + try { + const data = await api.revealEnvVar(id); + setEditingValue(data.value); + setRevealValues((prev) => ({ ...prev, [id]: data.value })); + } finally { + setSavingEdit((prev) => ({ ...prev, [id]: false })); + } + }; + + const saveEdit = async (id: string) => { + setSavingEdit((prev) => ({ ...prev, [id]: true })); + try { + await api.updateEnvVar(id, editingValue); + setEditingId(null); + setEditingValue(""); + setRevealValues((prev) => ({ ...prev, [id]: editingValue })); + setShowRedeployPrompt(true); + await refetch(); + } finally { + setSavingEdit((prev) => ({ ...prev, [id]: false })); + } + }; + + const copyToClipboard = async (id: string) => { + try { + let targetValue = revealValues[id]; + if (!targetValue) { + setRevealing((prev) => ({ ...prev, [id]: true })); + const data = await api.revealEnvVar(id); + targetValue = data.value; + setRevealing((prev) => ({ ...prev, [id]: false })); + } + await navigator.clipboard.writeText(targetValue); + setCopiedId(id); + setTimeout(() => setCopiedId(null), 1500); + } catch (err) { + console.error("Failed to copy", err); + } + }; + + const handleReveal = async (id: string) => { + setRevealing((prev) => ({ ...prev, [id]: true })); + try { + const data = await api.revealEnvVar(id); + setRevealValues((prev) => ({ ...prev, [id]: data.value })); + } finally { + setRevealing((prev) => ({ ...prev, [id]: false })); + } + }; + + const handleHide = (id: string) => { + setRevealValues((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + }; + + const handleAddVars = async ( + vars: { key: string; value: string; env: string }[], + ) => { + await Promise.all( + vars.map((v) => + api.createEnvVar(projectId, { + key: v.key.trim(), + value: v.value.trim(), + environment: v.env.trim() || undefined, + }), + ), + ); + setIsAddOpen(false); + setShowRedeployPrompt(true); + refetch(); + }; + + const handleImportVars = async ( + vars: { key: string; value: string }[], + ) => { + await Promise.all( + vars.map((entry) => api.createEnvVar(projectId, entry)), + ); + await refetch(); + setShowRedeployPrompt(true); + }; + + const handleRedeploy = async () => { + try { + await redeploy.mutateAsync(runningDeployment!.id); + setShowRedeployPrompt(false); + navigate({ search: { tab: "deployments" } as any }); + } catch (err) { + console.error("Failed to redeploy", err); + } + }; + + return ( +
+ setShowRedeployPrompt(false)} + onRedeploy={handleRedeploy} + /> + + {envVars.length === 0 ? ( + setIsAddOpen(true)} + onImport={() => setIsImportOpen(true)} + /> + ) : ( + setIsAddOpen(true)} + onImport={() => setIsImportOpen(true)} + onDelete={(id) => setDeletingEvId(id)} + onStartEdit={startEdit} + onCancelEdit={() => { + setEditingId(null); + setEditingValue(""); + }} + onSaveEdit={saveEdit} + onReveal={handleReveal} + onHide={handleHide} + onCopy={copyToClipboard} + onEditingValueChange={setEditingValue} + /> + )} + + + + + + { + if (!open) setDeletingEvId(null); + }} + onConfirm={handleDeleteEnvVar} + /> +
+ ); +} diff --git a/apps/web/src/components/project/envtab/ImportEnvFileDialog.tsx b/apps/web/src/components/project/envtab/ImportEnvFileDialog.tsx new file mode 100644 index 0000000..b286915 --- /dev/null +++ b/apps/web/src/components/project/envtab/ImportEnvFileDialog.tsx @@ -0,0 +1,206 @@ +import { useState } from "react"; +import { Button } from "../../ui/button"; +import { Input } from "../../ui/input"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "../../ui/dialog"; +import { Upload, Check } from "lucide-react"; + +interface ImportEnvFileDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onImport: (vars: { key: string; value: string }[]) => Promise; +} + +export function ImportEnvFileDialog({ + open, + onOpenChange, + onImport, +}: ImportEnvFileDialogProps) { + const [parsedFileVars, setParsedFileVars] = useState< + { key: string; value: string }[] | null + >(null); + const [importStatus, setImportStatus] = useState< + "idle" | "importing" | "done" + >("idle"); + const [fileError, setFileError] = useState(""); + const [fileInputKey, setFileInputKey] = useState(0); + + const parseEnvLines = (text: string) => { + const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")); + return lines + .map((line) => { + const idx = line.indexOf("="); + if (idx <= 0) return null; + const k = line.slice(0, idx).trim(); + let v = line.slice(idx + 1).trim(); + if ( + (v.startsWith('"') && v.endsWith('"')) || + (v.startsWith("'") && v.endsWith("'")) + ) { + v = v.slice(1, -1); + } + if (!k) return null; + return { key: k, value: v }; + }) + .filter(Boolean) as { key: string; value: string }[]; + }; + + const handleFileSelect = async ( + e: React.ChangeEvent, + ) => { + setFileError(""); + setImportStatus("idle"); + const file = e.target.files?.[0]; + if (!file) { + setParsedFileVars(null); + return; + } + const text = await file.text(); + const vars = parseEnvLines(text); + if (!vars.length) { + setFileError("No valid KEY=VALUE pairs found in file."); + setParsedFileVars(null); + setFileInputKey((k) => k + 1); + e.target.value = ""; + return; + } + setParsedFileVars(vars); + }; + + const handleImport = async () => { + if (!parsedFileVars?.length) return; + setImportStatus("importing"); + setFileError(""); + try { + await onImport(parsedFileVars); + setImportStatus("done"); + setParsedFileVars(null); + setFileInputKey((k) => k + 1); + setTimeout(() => { + setImportStatus("idle"); + onOpenChange(false); + }, 1500); + } catch { + setFileError("Import failed. Try again."); + setImportStatus("idle"); + } + }; + + const close = () => { + onOpenChange(false); + setParsedFileVars(null); + setFileError(""); + }; + + return ( + + + + + Import .env File + + + Upload a local configuration file to quickly populate keys. + + + +
+
+ +

+ Select a file or drag here +

+

+ Supports .env or .txt key-value files +

+ +
+ + {parsedFileVars && importStatus !== "done" && ( +
+
+ + Parsed variables: + + + {parsedFileVars.length} found + +
+
+ {parsedFileVars.slice(0, 10).map((v, idx) => ( +
+ + {v.key} + + + {v.value} + +
+ ))} + {parsedFileVars.length > 10 && ( +
+ +{parsedFileVars.length - 10} more + variables +
+ )} +
+
+ )} + + {fileError && ( +
+ {fileError} +
+ )} + + {importStatus === "done" && ( +
+ Successfully Imported + Variables! +
+ )} + +
+ + {parsedFileVars && importStatus !== "done" && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/project/envtab/RedeployBanner.tsx b/apps/web/src/components/project/envtab/RedeployBanner.tsx new file mode 100644 index 0000000..92db6c3 --- /dev/null +++ b/apps/web/src/components/project/envtab/RedeployBanner.tsx @@ -0,0 +1,68 @@ +import { Button } from "../../ui/button"; +import { RefreshCw } from "lucide-react"; + +interface RedeployBannerProps { + show: boolean; + runningDeploymentId: string | undefined; + isPending: boolean; + onDismiss: () => void; + onRedeploy: () => Promise; +} + +export function RedeployBanner({ + show, + runningDeploymentId, + isPending, + onDismiss, + onRedeploy, +}: RedeployBannerProps) { + if (!show || !runningDeploymentId) return null; + + return ( +
+
+
+ +
+
+

+ Redeployment Required +

+

+ You have added or updated environment variables. Redeploy the active + configuration to apply the new settings. +

+
+
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/project/logs/LogsTab.tsx b/apps/web/src/components/project/logs/LogsTab.tsx new file mode 100644 index 0000000..c92cd40 --- /dev/null +++ b/apps/web/src/components/project/logs/LogsTab.tsx @@ -0,0 +1,793 @@ +import React, { useState } from "react"; +import { useDeployments } from "../../../hooks/useDeployments"; +import { + useRuntimeLogs, + useRequestLogs, +} from "../../../hooks/useDeploymentLogs"; +import { Button } from "../../ui/button"; +import { Input } from "../../ui/input"; +import { + Search, + RefreshCw, + X, +} from "lucide-react"; +import { cn } from "../../../lib/utils"; + +function stripAnsi(str: string): string { + // Strip full ANSI escape sequences (ESC + bracket + params + terminator) + let s = str.replace( + /[\u001b\u009b]\[[\d;]*[A-Za-z]/g, + "", + ); + // Strip orphaned bracket sequences where ESC byte was already removed (e.g. "[35m", "[0m") + s = s.replace(/\[(\d+;)*\d*m/g, ""); + return s; +} + +interface LogsTabProps { + projectId: string; +} + +export function LogsTab({ + projectId, +}: LogsTabProps) { + const { data } = useDeployments(projectId); + const deployments = data?.items ?? []; + const latest = + deployments.find( + (d) => d.status === "running", + ) ?? deployments[0]; + const [isLive, setIsLive] = useState(true); + const [logSource, setLogSource] = useState< + "runtime" | "request" + >("request"); + + const [startDate, setStartDate] = + useState(""); + const [endDate, setEndDate] = + useState(""); + + const startMs = startDate + ? new Date(startDate).getTime() + : null; + const endMs = endDate + ? new Date(endDate).getTime() + : null; + + const { + logs: runtimeLogs, + isLoading: isRuntimeLoading, + refetch: refetchRuntime, + } = useRuntimeLogs( + latest?.id || null, + isLive && logSource === "runtime", + ); + const { + logs: requestLogs, + isLoading: isRequestLoading, + refetch: refetchRequest, + } = useRequestLogs( + projectId, + isLive && logSource === "request", + startMs, + endMs, + ); + + const logs = + logSource === "runtime" + ? runtimeLogs + : requestLogs; + const isLoading = + logSource === "runtime" + ? isRuntimeLoading + : isRequestLoading; + const refetch = + logSource === "runtime" + ? refetchRuntime + : refetchRequest; + + const [searchQuery, setSearchQuery] = + useState(""); + const [showInfo, setShowInfo] = + useState(true); + const [showWarning, setShowWarning] = + useState(true); + const [showError, setShowError] = + useState(true); + + // Drawer state + const [selectedLog, setSelectedLog] = + useState(null); + + // Parse logs + const parsedLogs = logs.map((log) => { + let message = stripAnsi(log.message); + let level = "info"; + let status = ""; + let host = "localhost"; + let request = ""; + let duration: string | null = null; + let size: string | null = null; + let raw = log.message; + + if ( + message.startsWith("{") && + message.endsWith("}") + ) { + try { + const obj = JSON.parse(message); + if (obj.level) + level = + obj.level.toLowerCase(); + if (obj.status) { + status = String(obj.status); + const statusNum = Number( + obj.status, + ); + if (statusNum >= 500) + level = "error"; + else if (statusNum >= 400) + level = "warning"; + } + if (obj.request) { + host = + obj.request.host || host; + request = `${obj.request.method || ""} ${obj.request.uri || ""}`; + duration = obj.duration + ? `${(obj.duration * 1000).toFixed(2)}ms` + : null; + size = obj.size + ? `${obj.size} B` + : null; + message = + obj.msg || + obj.message || + obj.error || + message; + if ( + !message || + message === '""' + ) { + message = `${obj.request.method || ""} ${obj.request.uri || ""}`; + } + } else { + message = + obj.msg || + obj.message || + message; + } + raw = JSON.stringify( + obj, + null, + 2, + ); + } catch {} + } else { + const upper = message.toUpperCase(); + if ( + upper.includes("ERROR") || + upper.includes("CRITICAL") || + upper.includes("FAIL") + ) + level = "error"; + else if (upper.includes("WARN")) + level = "warning"; + } + + return { + ...log, + parsedMessage: message, + level, + status, + host, + request, + duration, + size, + raw, + }; + }); + + const filteredLogs = parsedLogs.filter( + (log) => { + if ( + searchQuery && + !log.message + .toLowerCase() + .includes( + searchQuery.toLowerCase(), + ) + ) { + return false; + } + if ( + log.level === "error" && + !showError + ) + return false; + if ( + log.level === "warning" && + !showWarning + ) + return false; + if (log.level === "info" && !showInfo) + return false; + return true; + }, + ); + + const makeHistogram = () => { + const nowMs = Date.now(); + const bins = Array.from( + { length: 30 }, + (_, idx) => { + const binStart = + nowMs - (30 - idx) * 60000; + const binEnd = binStart + 60000; + return { + start: binStart, + end: binEnd, + count: 0, + }; + }, + ); + + for (const log of filteredLogs) { + const time = new Date( + (log as any).timestamp || + log.createdAt, + ).getTime(); + for (const bin of bins) { + if ( + time >= bin.start && + time < bin.end + ) { + bin.count++; + break; + } + } + } + return bins; + }; + + const bins = makeHistogram(); + const maxCount = Math.max( + ...bins.map((b) => b.count), + 1, + ); + + return ( +
+ {/* Top Filters Header */} +
+
+ {/* Log Source Selector */} +
+ + +
+ + {/* Search message */} +
+ + + setSearchQuery( + e.target + .value, + ) + } + className="h-8 pl-8 bg-[#141417] border-[#222227] focus:border-amber-500 text-zinc-200 text-xs w-full shadow-none" + /> +
+ + {/* Severity Checkboxes */} +
+ + + +
+
+ +
+ + +
+
+ + {/* Date Range Selection (Only for Request Monitoring) */} + {logSource === "request" && ( +
+
+ + Date Filter: + +
+
+
+ + From + + + setStartDate( + e.target + .value, + ) + } + className="bg-[#141417] border border-[#222227] rounded-md px-2.5 py-1 text-zinc-300 text-xs focus:outline-none focus:border-amber-500 font-mono" + /> +
+
+ + To + + + setEndDate( + e.target + .value, + ) + } + className="bg-[#141417] border border-[#222227] rounded-md px-2.5 py-1 text-zinc-300 text-xs focus:outline-none focus:border-amber-500 font-mono" + /> +
+ {(startDate || + endDate) && ( +
+ + + Streaming + Paused + +
+ )} +
+
+ )} + + {/* Log count Timeline Chart */} +
+
+ {logSource === "request" + ? "Request Count Distribution (Last 30 Minutes)" + : "Log Count Distribution (Last 30 Minutes)"} +
+
+ {bins.map((bin, idx) => ( +
+
+ {bin.count} logs +
+
+ ))} +
+
+ + {/* Logs Table Layout */} +
+
+
+ + + + + + {logSource === + "request" && ( + <> + + + + + )} + + + + + {isLoading ? ( + + + + ) : filteredLogs.length === + 0 ? ( + + + + ) : ( + filteredLogs.map( + ( + log, + idx, + ) => ( + + setSelectedLog( + log, + ) + } + className={cn( + "hover:bg-[#141418] cursor-pointer transition-colors border-l-2", + log.level === + "error" + ? "border-l-red-500 hover:border-l-red-400" + : log.level === + "warning" + ? "border-l-amber-500 hover:border-l-amber-400" + : "border-l-transparent hover:border-l-zinc-700", + selectedLog?.id === + log.id && + "bg-[#16161b] hover:bg-[#16161b]", + )} + > + + + {logSource === + "request" && ( + <> + + + + + )} + + + ), + ) + )} + +
+ Time + + Level + + Status + + Host + + Request + + Message +
+ Loading + logs... +
+ {logSource === + "request" + ? "No request logs found." + : "No runtime logs found."} +
+ {new Date( + ( + log as any + ) + .timestamp || + log.createdAt, + ).toLocaleTimeString()} + + + { + log.level + } + + + {log.status ? ( + = 500 + ? "bg-red-500/10 text-red-400 border border-red-500/20" + : Number(log.status) >= 400 + ? "bg-amber-500/10 text-amber-400 border border-amber-500/20" + : "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20", + )}> + {log.status} + + ) : ( + + )} + + { + log.host + } + + { + log.request + } + + { + log.parsedMessage + } +
+
+
+ + {selectedLog && ( +
+ + +
+

+ Log Event Details +

+ + { + selectedLog.level + } + +
+ +
+
+
+ Timestamp +
+
+ {new Date( + ( + selectedLog as any + ) + .timestamp || + selectedLog.createdAt, + ).toLocaleString()} +
+
+ + {logSource === "request" && selectedLog.status && ( +
+
+ Status +
+
+ {selectedLog.status} +
+
+ )} + + {logSource === "request" && selectedLog.request && ( +
+
+ Request +
+
+ {selectedLog.request} +
+
+ )} + + {logSource === "request" && selectedLog.duration && ( +
+
+ Duration +
+
+ {selectedLog.duration} +
+
+ )} + + {logSource === "request" && selectedLog.size && ( +
+
+ Size +
+
+ {selectedLog.size} +
+
+ )} + +
+
+ Message +
+
+ { + selectedLog.parsedMessage + } +
+
+ +
+
+ Raw JSON + payload +
+
+									{
+										selectedLog.raw
+									}
+								
+
+
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/project/ObservabilityTab.tsx b/apps/web/src/components/project/observability/ObservabilityTab.tsx similarity index 98% rename from apps/web/src/components/project/ObservabilityTab.tsx rename to apps/web/src/components/project/observability/ObservabilityTab.tsx index a3632bb..0e05536 100644 --- a/apps/web/src/components/project/ObservabilityTab.tsx +++ b/apps/web/src/components/project/observability/ObservabilityTab.tsx @@ -1,8 +1,8 @@ import React from "react"; import { useQuery } from "@tanstack/react-query"; -import { useDeployments } from "../../hooks/useDeployments"; -import * as api from "../../api/client"; -import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { useDeployments } from "../../../hooks/useDeployments"; +import * as api from "../../../api/client"; +import { Card, CardContent, CardHeader, CardTitle } from "../../ui/card"; function promValue( data: diff --git a/apps/web/src/components/project/ScalingTab.tsx b/apps/web/src/components/project/scaling/ScalingTab.tsx similarity index 98% rename from apps/web/src/components/project/ScalingTab.tsx rename to apps/web/src/components/project/scaling/ScalingTab.tsx index 65d151b..3a357ad 100644 --- a/apps/web/src/components/project/ScalingTab.tsx +++ b/apps/web/src/components/project/scaling/ScalingTab.tsx @@ -3,17 +3,17 @@ import React, { useEffect, } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useProject } from "../../hooks/useProjects"; -import * as api from "../../api/client"; +import { useProject } from "../../../hooks/useProjects"; +import * as api from "../../../api/client"; import { Card, CardContent, CardHeader, CardTitle, -} from "../ui/card"; -import { Input } from "../ui/input"; -import { Button } from "../ui/button"; -import { Badge } from "../ui/badge"; +} from "../../ui/card"; +import { Input } from "../../ui/input"; +import { Button } from "../../ui/button"; +import { Badge } from "../../ui/badge"; import { Dialog, DialogContent, @@ -21,7 +21,7 @@ import { DialogTitle, DialogDescription, DialogFooter, -} from "../ui/dialog"; +} from "../../ui/dialog"; import { Cpu, Database, diff --git a/apps/web/src/components/project/VolumesTab.tsx b/apps/web/src/components/project/volumes/VolumesTab.tsx similarity index 97% rename from apps/web/src/components/project/VolumesTab.tsx rename to apps/web/src/components/project/volumes/VolumesTab.tsx index 6bc0157..d269a75 100644 --- a/apps/web/src/components/project/VolumesTab.tsx +++ b/apps/web/src/components/project/volumes/VolumesTab.tsx @@ -1,11 +1,11 @@ import React, { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import * as api from "../../api/client"; +import * as api from "../../../api/client"; import { useDeployments, useRedeployDeployment, -} from "../../hooks/useDeployments"; -import { Card, CardContent } from "../ui/card"; +} from "../../../hooks/useDeployments"; +import { Card, CardContent } from "../../ui/card"; import { Table, TableBody, @@ -13,10 +13,10 @@ import { TableHead, TableHeader, TableRow, -} from "../ui/table"; -import { Input } from "../ui/input"; -import { Button } from "../ui/button"; -import { Badge } from "../ui/badge"; +} from "../../ui/table"; +import { Input } from "../../ui/input"; +import { Button } from "../../ui/button"; +import { Badge } from "../../ui/badge"; import { HardDrive, Trash2, @@ -30,7 +30,7 @@ import { DialogTitle, DialogDescription, DialogFooter, -} from "../ui/dialog"; +} from "../../ui/dialog"; interface VolumesTabProps { projectId: string; diff --git a/apps/web/src/routes/Dashboard.tsx b/apps/web/src/routes/Dashboard.tsx index c25fdfa..c187a1a 100644 --- a/apps/web/src/routes/Dashboard.tsx +++ b/apps/web/src/routes/Dashboard.tsx @@ -8,7 +8,7 @@ import { Input } from '../components/ui/input'; import { Card, CardContent } from '../components/ui/card'; import { formatUptime, parseMetrics } from '../lib/metrics'; import { cn } from '../lib/utils'; -import { CreateProjectDialog } from '../components/project/CreateProjectDialog'; +import { CreateProjectDialog } from '../components/project/create/CreateProjectDialog'; import { Dialog, DialogContent, @@ -191,7 +191,7 @@ export function Dashboard() { ) : (
{allDeployments - .filter(dep => dep.projectId && projects.some(p => p.id === dep.projectId)) + .filter(dep => dep.status === 'running' && dep.projectId && projects.some(p => p.id === dep.projectId)) .slice(0, 5) .map(dep => { const project = projects.find(p => p.id === dep.projectId); diff --git a/apps/web/src/routes/ProjectDetail.tsx b/apps/web/src/routes/ProjectDetail.tsx index 7d9d251..f1e69db 100644 --- a/apps/web/src/routes/ProjectDetail.tsx +++ b/apps/web/src/routes/ProjectDetail.tsx @@ -11,15 +11,15 @@ import { } from "../components/ui/tabs"; import { ArrowLeft, FolderKanban } from "lucide-react"; -import { DeploymentsTab } from "../components/project/DeploymentsTab"; -import { EnvVarsTab } from "../components/project/EnvVarsTab"; -import { VolumesTab } from "../components/project/VolumesTab"; -import { DatabasesTab } from "../components/project/DatabasesTab"; -import { DomainsTab } from "../components/project/DomainsTab"; -import { ScalingTab } from "../components/project/ScalingTab"; -import { AlertsTab } from "../components/project/AlertsTab"; -import { ObservabilityTab } from "../components/project/ObservabilityTab"; -import { LogsTab } from "../components/project/LogsTab"; +import { DeploymentsTab } from "../components/project/deployments/DeploymentsTab"; +import { EnvVarsTab } from "../components/project/envtab/EnvVarsTab"; +import { VolumesTab } from "../components/project/volumes/VolumesTab"; +import { DatabasesTab } from "../components/project/databases/DatabasesTab"; +import { DomainsTab } from "../components/project/domains/DomainsTab"; +import { ScalingTab } from "../components/project/scaling/ScalingTab"; +import { AlertsTab } from "../components/project/alerts/AlertsTab"; +import { ObservabilityTab } from "../components/project/observability/ObservabilityTab"; +import { LogsTab } from "../components/project/logs/LogsTab"; export function ProjectDetail({ projectId, diff --git a/apps/web/src/routes/Settings.tsx b/apps/web/src/routes/Settings.tsx index e050437..00fc41b 100644 --- a/apps/web/src/routes/Settings.tsx +++ b/apps/web/src/routes/Settings.tsx @@ -7,6 +7,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '. import { StatusBadge } from '../components/StatusBadge'; import { Trash2, Server, Key } from 'lucide-react'; import * as api from '../api/client'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '../components/ui/dialog'; export function Settings() { return ( @@ -29,6 +30,15 @@ function ServersSection() { const [port, setPort] = useState('2376'); const [authToken, setAuthToken] = useState(''); + const [deletingServerId, setDeletingServerId] = useState(null); + + const handleDeleteServer = async () => { + if (!deletingServerId) return; + await api.deleteServer(deletingServerId); + setDeletingServerId(null); + refetch(); + }; + const add = async (e: React.FormEvent) => { e.preventDefault(); if (!name.trim() || !host.trim()) return; @@ -71,7 +81,7 @@ function ServersSection() { @@ -82,6 +92,23 @@ function ServersSection() {
)} + + { if (!open) setDeletingServerId(null); }}> + + + Remove Server + + Are you sure you want to remove this server from the cluster? This cannot be undone. + + + + + + + + ); } @@ -93,6 +120,15 @@ function ApiKeysSection() { 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; @@ -134,7 +170,7 @@ function ApiKeysSection() { {new Date(k.createdAt).toLocaleDateString()} @@ -145,6 +181,23 @@ function ApiKeysSection() {
)} + + { 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/types/index.ts b/apps/web/src/types/index.ts index 11c7c04..0514cf9 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -151,3 +151,23 @@ export interface Log { message: string; createdAt: string; } + +export interface GithubRepo { + id: number; + name: string; + fullName: string; + cloneUrl: string; + sshUrl: string; + description: string | null; + language: string | null; + private: boolean; + defaultBranch: string; + owner: { login: string; avatarUrl: string }; +} + +export interface GithubIntegrationStatus { + configured: boolean; + clientId?: string; + appName?: string; + hasWebhookSecret?: boolean; +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 73141d5..51c75cd 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -8,7 +8,11 @@ "skipLibCheck": true, "moduleResolution": "Bundler", "resolveJsonModule": true, - "noEmit": true + "noEmit": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } }, "include": ["src"] } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 8a19aa9..95d423e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import path from 'path'; export default defineConfig({ - plugins: [react()] + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, }); From 38365c0dafa947e3b27e76034d2462d8878460d6 Mon Sep 17 00:00:00 2001 From: lftobs Date: Fri, 12 Jun 2026 02:22:53 +0100 Subject: [PATCH 08/12] feat(api): add SMTP configuration and system settings - Implement SMTP settings repository, routes, and testing functionality - Add `dequel.json` configuration loader to seed system settings on startup - Update Docker release workflow to package system configuration files - add config error banner - Add system configuration documentation and update installation instructions --- .github/workflows/release.yml | 18 +- apps/api/src/api/index.ts | 4 +- apps/api/src/api/settings/index.ts | 61 +++++++ apps/api/src/db/migrate.ts | 55 +++++- apps/api/src/db/migrations/0000_baseline.sql | 13 ++ apps/api/src/db/repo/github.ts | 41 +++-- apps/api/src/db/repo/index.ts | 3 + apps/api/src/db/repo/settings.ts | 74 ++++++++ apps/api/src/db/schema.ts | 13 ++ apps/api/src/utils/config-loader.ts | 57 ++++++ apps/api/src/utils/config.ts | 62 ++++--- apps/docs/.astro/astro/content.d.ts | 7 + apps/docs/src/content/docs/installation.md | 38 +++- apps/docs/src/content/docs/system-config.md | 92 ++++++++++ apps/web/src/api/client.ts | 21 +++ apps/web/src/components/ConfigWarnings.tsx | 65 +++++++ apps/web/src/routes/Dashboard.tsx | 5 +- apps/web/src/routes/Settings.tsx | 177 ++++++++++++++++++- apps/web/src/types/index.ts | 8 + docker-compose.yml | 44 ++++- scripts/install.sh | 53 ++++-- 21 files changed, 841 insertions(+), 70 deletions(-) create mode 100644 apps/api/src/api/settings/index.ts create mode 100644 apps/api/src/db/repo/settings.ts create mode 100644 apps/api/src/utils/config-loader.ts create mode 100644 apps/docs/src/content/docs/system-config.md create mode 100644 apps/web/src/components/ConfigWarnings.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f46bdcd..19c64a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,21 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max + - name: Build config tarball + run: | + VERSION="${{ steps.version.outputs.VERSION }}" + TAR_DIR=_tar + mkdir -p "$TAR_DIR/infra/caddy" "$TAR_DIR/infra/monitoring/grafana/datasources" + cp docker-compose.yml "$TAR_DIR/" + cp scripts/dequel "$TAR_DIR/dequel" + cp infra/caddy/Caddyfile "$TAR_DIR/infra/caddy/" + cp infra/monitoring/prometheus.yml "$TAR_DIR/infra/monitoring/" + cp infra/monitoring/loki-config.yml "$TAR_DIR/infra/monitoring/" + cp infra/monitoring/promtail-config.yml "$TAR_DIR/infra/monitoring/" + cp infra/monitoring/grafana/datasources/loki.yml "$TAR_DIR/infra/monitoring/grafana/datasources/" + cp infra/monitoring/grafana/datasources/prometheus.yml "$TAR_DIR/infra/monitoring/grafana/datasources/" + cd "$TAR_DIR" && tar -czf "../dequel-config-${VERSION}.tar.gz" . + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: @@ -70,6 +85,5 @@ jobs: body_path: CHANGELOG.md generate_release_notes: true files: | - docker-compose.yml scripts/install.sh - scripts/dequel + dequel-config-${{ steps.version.outputs.VERSION }}.tar.gz diff --git a/apps/api/src/api/index.ts b/apps/api/src/api/index.ts index 262a1ed..a47c08a 100644 --- a/apps/api/src/api/index.ts +++ b/apps/api/src/api/index.ts @@ -13,6 +13,7 @@ import { scalingRoutes } from "./scaling"; import { serverInfoRoutes } from "./server-info"; import { serversRoutes } from "./servers"; import { volumesRoutes } from "./volumes"; +import { settingsRoutes } from "./settings"; const authMiddleware = (app: Elysia) => app.onBeforeHandle(async ({ request, set }) => { @@ -49,4 +50,5 @@ export const apiRoutes = new Elysia({ .use(apiKeysRoutes) .use(prometheusRoutes) .use(alertsRoutes) - .use(githubRoutes); + .use(githubRoutes) + .use(settingsRoutes); diff --git a/apps/api/src/api/settings/index.ts b/apps/api/src/api/settings/index.ts new file mode 100644 index 0000000..dc19309 --- /dev/null +++ b/apps/api/src/api/settings/index.ts @@ -0,0 +1,61 @@ +import { Elysia } from "elysia"; +import { getSmtpSettings, upsertSmtpSettings } from "../../db/repo"; +import nodemailer from "nodemailer"; + +export const settingsRoutes = new Elysia({ prefix: "/settings" }) + .get("/smtp", async ({ set }: any) => { + const settings = await getSmtpSettings(); + if (!settings) { + return { configured: false }; + } + return { + configured: true, + host: settings.host, + port: settings.port, + user: settings.user, + fromAddress: settings.fromAddress, + }; + }) + + .put("/smtp", async ({ body, set }: any) => { + if (!body?.host) { + set.status = 400; + return { error: "host is required" }; + } + await upsertSmtpSettings({ + host: body.host, + port: body.port ?? 587, + user: body.user ?? "", + pass: body.pass ?? "", + fromAddress: body.fromAddress ?? "dequel@localhost", + }); + return { ok: true }; + }) + + .post("/smtp/test", async ({ set }: any) => { + const settings = await getSmtpSettings(); + if (!settings || !settings.host) { + set.status = 400; + return { error: "SMTP not configured" }; + } + try { + const transporter = nodemailer.createTransport({ + host: settings.host, + port: settings.port, + secure: settings.port === 465, + auth: settings.user && settings.pass + ? { user: settings.user, pass: settings.pass } + : undefined, + }); + await transporter.sendMail({ + from: settings.fromAddress, + to: settings.fromAddress, + subject: "[Dequel] SMTP Test Email", + text: "This is a test email from Dequel. Your SMTP settings are working correctly.", + }); + return { ok: true }; + } catch (err: any) { + set.status = 400; + return { error: err.message }; + } + }); diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts index d76b7c1..13c43f3 100644 --- a/apps/api/src/db/migrate.ts +++ b/apps/api/src/db/migrate.ts @@ -1,7 +1,60 @@ +import { existsSync } from "node:fs"; +import { readdirSync } from "node:fs"; import { migrate as drizzleMigrate } from "drizzle-orm/bun-sqlite/migrator"; import { getDrizzle } from "./drizzle"; +import { config } from "../utils/config"; +import { getSmtpSettings, upsertSmtpSettings } from "./repo/settings"; +import { getGithubIntegration, setGithubIntegration } from "./repo/github"; export const migrate = async () => { const db = await getDrizzle(); - drizzleMigrate(db, { migrationsFolder: import.meta.dirname + "/migrations" }); + const migrationsFolder = import.meta.dirname + "/migrations"; + const journalPath = migrationsFolder + "/meta/_journal.json"; + + if (!existsSync(journalPath)) { + throw new Error( + `Migration journal not found at ${journalPath}. ` + + "Ensure migration files are present. " + + "If running from a pre-built image, verify the build includes src/db/migrations/." + ); + } + + const files = readdirSync(migrationsFolder).filter(f => f.endsWith(".sql")); + if (files.length === 0) { + throw new Error( + `No migration SQL files found in ${migrationsFolder}. ` + + "The database schema cannot be initialized." + ); + } + + drizzleMigrate(db, { migrationsFolder }); + await seedFromConfig(); +}; + +const seedFromConfig = async () => { + if (config.githubClientId && config.githubClientSecret) { + const existing = await getGithubIntegration(); + if (!existing) { + await setGithubIntegration({ + clientId: config.githubClientId, + clientSecret: config.githubClientSecret, + appName: config.githubAppName, + webhookSecret: config.githubWebhookSecret || undefined, + }); + console.log("[Config] Seeded GitHub integration from config file"); + } + } + if (config.smtpHost) { + const existing = await getSmtpSettings(); + if (!existing) { + await upsertSmtpSettings({ + host: config.smtpHost, + port: config.smtpPort, + user: config.smtpUser, + pass: config.smtpPass, + fromAddress: config.smtpFrom, + }); + console.log("[Config] Seeded SMTP settings from config file"); + } + } }; diff --git a/apps/api/src/db/migrations/0000_baseline.sql b/apps/api/src/db/migrations/0000_baseline.sql index 0f93c5d..d261b8c 100644 --- a/apps/api/src/db/migrations/0000_baseline.sql +++ b/apps/api/src/db/migrations/0000_baseline.sql @@ -152,6 +152,19 @@ CREATE TABLE IF NOT EXISTS `api_keys` ( `last_used_at` text ); --> statement-breakpoint +CREATE TABLE IF NOT EXISTS `smtp_settings` ( + `id` text PRIMARY KEY NOT NULL, + `host` text NOT NULL, + `port` integer NOT NULL DEFAULT 587, + `user` text NOT NULL DEFAULT '', + `pass_encrypted` text, + `pass_iv` text, + `pass_tag` text, + `from_address` text NOT NULL DEFAULT 'dequel@localhost', + `created_at` text NOT NULL, + `updated_at` text NOT NULL +); +--> statement-breakpoint CREATE TABLE IF NOT EXISTS `alerts` ( `id` text PRIMARY KEY NOT NULL, `project_id` text NOT NULL, diff --git a/apps/api/src/db/repo/github.ts b/apps/api/src/db/repo/github.ts index b8dc908..f460e35 100644 --- a/apps/api/src/db/repo/github.ts +++ b/apps/api/src/db/repo/github.ts @@ -21,26 +21,33 @@ export const getGithubIntegration = async (): Promise }; export const setGithubIntegration = async (input: { clientId: string; clientSecret: string; appName?: string; webhookSecret?: string }): Promise => { - const existing = await getGithubIntegration(); - const timestamp = now(); const db = await getDrizzle(); - if (existing) { - db.update(githubIntegrations).set({ + + return db.transaction((tx) => { + const existing = tx.select().from(githubIntegrations).orderBy(desc(githubIntegrations.createdAt)).limit(1).get(); + const timestamp = now(); + + if (existing) { + tx.update(githubIntegrations).set({ + clientId: input.clientId, + clientSecret: input.clientSecret, + appName: input.appName ?? "Dequel", + webhookSecret: input.webhookSecret ?? null, + }).where(eq(githubIntegrations.id, existing.id)).run(); + const updated = tx.select().from(githubIntegrations).where(eq(githubIntegrations.id, existing.id)).get()!; + return mapGithubIntegration(updated); + } + + const id = randomUUID(); + tx.insert(githubIntegrations).values({ + id, clientId: input.clientId, clientSecret: input.clientSecret, appName: input.appName ?? "Dequel", webhookSecret: input.webhookSecret ?? null, - }).where(eq(githubIntegrations.id, existing.id)).run(); - return getGithubIntegration() as Promise; - } - const id = randomUUID(); - db.insert(githubIntegrations).values({ - id, - clientId: input.clientId, - clientSecret: input.clientSecret, - appName: input.appName ?? "Dequel", - webhookSecret: input.webhookSecret ?? null, - createdAt: timestamp, - }).run(); - return getGithubIntegration() as Promise; + createdAt: timestamp, + }).run(); + const inserted = tx.select().from(githubIntegrations).where(eq(githubIntegrations.id, id)).get()!; + return mapGithubIntegration(inserted); + }); }; diff --git a/apps/api/src/db/repo/index.ts b/apps/api/src/db/repo/index.ts index 9cf3264..5e6a53f 100644 --- a/apps/api/src/db/repo/index.ts +++ b/apps/api/src/db/repo/index.ts @@ -39,3 +39,6 @@ export { createApiKey, listApiKeys, deleteApiKey, validateApiKey } from "./api-k export { createAlert, listAlerts, getAlertById, updateAlertEnabled, deleteAlert } from "./alerts"; export { getGithubIntegration, setGithubIntegration } from "./github"; + +export { getSmtpSettings, upsertSmtpSettings } from "./settings"; +export type { SmtpSettingsData } from "./settings"; diff --git a/apps/api/src/db/repo/settings.ts b/apps/api/src/db/repo/settings.ts new file mode 100644 index 0000000..3c2b008 --- /dev/null +++ b/apps/api/src/db/repo/settings.ts @@ -0,0 +1,74 @@ +import { eq, desc } from "drizzle-orm"; +import { getDrizzle } from "../drizzle"; +import { smtpSettings } from "../schema"; +import { encryptValue, decryptValue } from "../../utils/crypto"; +import { config } from "../../utils/config"; +import { randomUUID } from "node:crypto"; +import { now } from "./helpers"; + +export interface SmtpSettingsData { + host: string; + port: number; + user: string; + pass: string; + fromAddress: string; +} + +const mapRow = (row: typeof smtpSettings.$inferSelect): SmtpSettingsData => ({ + host: row.host, + port: row.port, + user: row.user, + pass: row.passEncrypted && row.passIv && row.passTag + ? decryptValue(row.passEncrypted, row.passIv, row.passTag, config.envEncryptionKey) + : "", + fromAddress: row.fromAddress, +}); + +export const getSmtpSettings = async (): Promise => { + const db = await getDrizzle(); + const row = db.select().from(smtpSettings).orderBy(desc(smtpSettings.createdAt)).limit(1).get(); + return row ? mapRow(row) : null; +}; + +export const upsertSmtpSettings = async (input: SmtpSettingsData): Promise => { + const db = await getDrizzle(); + const encrypted = input.pass + ? encryptValue(input.pass, config.envEncryptionKey) + : null; + + return db.transaction((tx) => { + const existing = tx.select().from(smtpSettings).orderBy(desc(smtpSettings.createdAt)).limit(1).get(); + const timestamp = now(); + + if (existing) { + tx.update(smtpSettings).set({ + host: input.host, + port: input.port, + user: input.user, + passEncrypted: encrypted?.encrypted ?? existing.passEncrypted, + passIv: encrypted?.iv ?? existing.passIv, + passTag: encrypted?.tag ?? existing.passTag, + fromAddress: input.fromAddress, + updatedAt: timestamp, + }).where(eq(smtpSettings.id, existing.id)).run(); + const updated = tx.select().from(smtpSettings).where(eq(smtpSettings.id, existing.id)).get()!; + return mapRow(updated); + } + + const id = randomUUID(); + tx.insert(smtpSettings).values({ + id, + host: input.host, + port: input.port, + user: input.user, + passEncrypted: encrypted?.encrypted ?? null, + passIv: encrypted?.iv ?? null, + passTag: encrypted?.tag ?? null, + fromAddress: input.fromAddress, + createdAt: timestamp, + updatedAt: timestamp, + }).run(); + const inserted = tx.select().from(smtpSettings).where(eq(smtpSettings.id, id)).get()!; + return mapRow(inserted); + }); +}; diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 4ad0ca1..2ea2da1 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -171,3 +171,16 @@ export const alerts = sqliteTable("alerts", { }, (table) => [ foreignKey({ columns: [table.projectId], foreignColumns: [projects.id], onDelete: "cascade" }), ]); + +export const smtpSettings = sqliteTable("smtp_settings", { + id: text().primaryKey(), + host: text().notNull(), + port: integer().notNull().default(587), + user: text().notNull().default(""), + passEncrypted: text("pass_encrypted"), + passIv: text("pass_iv"), + passTag: text("pass_tag"), + fromAddress: text("from_address").notNull().default("dequel@localhost"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}); diff --git a/apps/api/src/utils/config-loader.ts b/apps/api/src/utils/config-loader.ts new file mode 100644 index 0000000..515ab90 --- /dev/null +++ b/apps/api/src/utils/config-loader.ts @@ -0,0 +1,57 @@ +import { readFileSync, existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { homedir } from "node:os"; + +const XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME || `${homedir()}/.config`; + +export interface FileConfig { + port?: number; + databasePath?: string; + workspaceRoot?: string; + caddyRoutesDir?: string; + caddyIngressBase?: string; + dockerNetwork?: string; + appInternalPort?: number; + buildkitHost?: string; + envEncryptionKey?: string; + redisUrl?: string; + queueConcurrency?: number; + queueRetryMax?: number; + queueRetryBaseMs?: number; + smtpHost?: string; + smtpPort?: number; + smtpUser?: string; + smtpPass?: string; + smtpFrom?: string; + alertEvalIntervalMs?: number; + githubClientId?: string; + githubClientSecret?: string; + githubAppName?: string; + githubWebhookSecret?: string; +} + +const searchPaths = (): string[] => { + const explicit = process.env.DEQUEL_CONFIG; + if (explicit) return [resolve(explicit)]; + return [ + resolve(`${XDG_CONFIG_HOME}/dequel/dequel.json`), + resolve("./dequel.json"), + resolve("./data/dequel.json"), + ]; +}; + +export const loadFileConfig = (): FileConfig => { + for (const path of searchPaths()) { + if (existsSync(path)) { + try { + const raw = readFileSync(path, "utf-8"); + const parsed = JSON.parse(raw) as FileConfig; + console.log(`[Config] Loaded config from ${path}`); + return parsed; + } catch (err) { + console.warn(`[Config] Failed to parse ${path}:`, err); + } + } + } + return {}; +}; diff --git a/apps/api/src/utils/config.ts b/apps/api/src/utils/config.ts index cd84a21..cb7abb0 100644 --- a/apps/api/src/utils/config.ts +++ b/apps/api/src/utils/config.ts @@ -1,29 +1,43 @@ -const get = (key: string, fallback?: string): string => { - const value = process.env[key] ?? fallback; - if (!value) { - throw new Error(`Missing required env var: ${key}`); +import { loadFileConfig, type FileConfig } from "./config-loader"; + +const fileConfig = loadFileConfig(); + +const withFile = ( + key: string, + envDefault: string, + transform?: (v: string) => T, +): T => { + const envVal = process.env[key]; + if (envVal !== undefined) { + return transform ? transform(envVal) : (envVal as unknown as T); } - return value; + const fileVal = (fileConfig as Record)[key]; + if (fileVal !== undefined) return fileVal as T; + return transform ? transform(envDefault) : (envDefault as unknown as T); }; export const config = { - port: Number(get('PORT', '3001')), - databasePath: get('DATABASE_PATH', '/app/data/dequel.db'), - workspaceRoot: get('WORKSPACE_ROOT', '/app/workspace'), - caddyRoutesDir: get('CADDY_ROUTES_DIR', '/caddy/routes'), - caddyIngressBase: get('CADDY_INGRESS_BASE', 'http://localhost'), - dockerNetwork: get('DOCKER_NETWORK', 'dequel_net'), - appInternalPort: Number(get('APP_INTERNAL_PORT', '3000')), - buildkitHost: get('BUILDKIT_HOST', 'tcp://buildkit:1234'), - envEncryptionKey: get('ENV_ENCRYPTION_KEY', 'dev-env-key-change-me'), - redisUrl: get('REDIS_URL', 'redis://redis:6379'), - queueConcurrency: Number(get('QUEUE_CONCURRENCY', '1')), - queueRetryMax: Number(get('QUEUE_RETRY_MAX', '5')), - queueRetryBaseMs: Number(get('QUEUE_RETRY_BASE_MS', '5000')), - smtpHost: process.env.SMTP_HOST || '', - smtpPort: Number(process.env.SMTP_PORT || '587'), - smtpUser: process.env.SMTP_USER || '', - smtpPass: process.env.SMTP_PASS || '', - smtpFrom: process.env.SMTP_FROM || 'dequel@localhost', - alertEvalIntervalMs: Number(get('ALERT_EVAL_INTERVAL_MS', '60000')), + port: withFile("PORT", "3001", Number), + databasePath: withFile("DATABASE_PATH", "/app/data/dequel.db"), + workspaceRoot: withFile("WORKSPACE_ROOT", "/app/workspace"), + caddyRoutesDir: withFile("CADDY_ROUTES_DIR", "/caddy/routes"), + caddyIngressBase: withFile("CADDY_INGRESS_BASE", "http://localhost"), + dockerNetwork: withFile("DOCKER_NETWORK", "dequel_net"), + appInternalPort: withFile("APP_INTERNAL_PORT", "3000", Number), + buildkitHost: withFile("BUILDKIT_HOST", "tcp://buildkit:1234"), + envEncryptionKey: withFile("ENV_ENCRYPTION_KEY", "dev-env-key-change-me"), + redisUrl: withFile("REDIS_URL", "redis://redis:6379"), + queueConcurrency: withFile("QUEUE_CONCURRENCY", "1", Number), + queueRetryMax: withFile("QUEUE_RETRY_MAX", "5", Number), + queueRetryBaseMs: withFile("QUEUE_RETRY_BASE_MS", "5000", Number), + smtpHost: withFile("SMTP_HOST", ""), + smtpPort: withFile("SMTP_PORT", "587", Number), + smtpUser: withFile("SMTP_USER", ""), + smtpPass: withFile("SMTP_PASS", ""), + smtpFrom: withFile("SMTP_FROM", "dequel@localhost"), + alertEvalIntervalMs: withFile("ALERT_EVAL_INTERVAL_MS", "60000", Number), + githubClientId: withFile("GITHUB_CLIENT_ID", ""), + githubClientSecret: withFile("GITHUB_CLIENT_SECRET", ""), + githubAppName: withFile("GITHUB_APP_NAME", "Dequel"), + githubWebhookSecret: withFile("GITHUB_WEBHOOK_SECRET", ""), }; diff --git a/apps/docs/.astro/astro/content.d.ts b/apps/docs/.astro/astro/content.d.ts index dc0a0ce..a3ac838 100644 --- a/apps/docs/.astro/astro/content.d.ts +++ b/apps/docs/.astro/astro/content.d.ts @@ -218,6 +218,13 @@ declare module 'astro:content' { collection: "docs"; data: any } & { render(): Render[".md"] }; +"system-config.md": { + id: "system-config.md"; + slug: "system-config"; + body: string; + collection: "docs"; + data: any +} & { render(): Render[".md"] }; "volumes.md": { id: "volumes.md"; slug: "volumes"; diff --git a/apps/docs/src/content/docs/installation.md b/apps/docs/src/content/docs/installation.md index fea16c9..509fca0 100644 --- a/apps/docs/src/content/docs/installation.md +++ b/apps/docs/src/content/docs/installation.md @@ -34,14 +34,46 @@ dequel start Open `http://localhost` to access the dashboard. -## Manual Setup with Docker Compose +## Manual Setup (no install script) -Clone the repository and run: +If the installer fails, set up the platform manually with just Docker Compose and the config files: + +```bash +# Create the installation directory +mkdir -p ~/.dequel/data ~/.dequel/workspace \ + ~/.dequel/infra/caddy/routes \ + ~/.dequel/infra/monitoring/grafana/datasources + +# Download config files +curl -fsSL https://raw.githubusercontent.com/Lftobs/dequel/main/docker-compose.yml \ + -o ~/.dequel/docker-compose.yml +curl -fsSL https://raw.githubusercontent.com/Lftobs/dequel/main/infra/caddy/Caddyfile \ + -o ~/.dequel/infra/caddy/Caddyfile +curl -fsSL https://raw.githubusercontent.com/Lftobs/dequel/main/infra/monitoring/prometheus.yml \ + -o ~/.dequel/infra/monitoring/prometheus.yml +curl -fsSL https://raw.githubusercontent.com/Lftobs/dequel/main/infra/monitoring/loki-config.yml \ + -o ~/.dequel/infra/monitoring/loki-config.yml +curl -fsSL https://raw.githubusercontent.com/Lftobs/dequel/main/infra/monitoring/promtail-config.yml \ + -o ~/.dequel/infra/monitoring/promtail-config.yml +curl -fsSL https://raw.githubusercontent.com/Lftobs/dequel/main/infra/monitoring/grafana/datasources/loki.yml \ + -o ~/.dequel/infra/monitoring/grafana/datasources/loki.yml +curl -fsSL https://raw.githubusercontent.com/Lftobs/dequel/main/infra/monitoring/grafana/datasources/prometheus.yml \ + -o ~/.dequel/infra/monitoring/grafana/datasources/prometheus.yml + +# Start all services +docker compose -f ~/.dequel/docker-compose.yml up -d +``` + +The compose file uses pre-built images from GitHub Container Registry, so no source code checkout is needed. Access the dashboard at `http://localhost`. + +## Manual Setup with Docker Compose (with source) + +Clone the repository and build from source: ```bash git clone https://github.com/Lftobs/dequel.git cd dequel -docker compose up -d +docker compose up -d --build ``` ## The `dequel` CLI diff --git a/apps/docs/src/content/docs/system-config.md b/apps/docs/src/content/docs/system-config.md new file mode 100644 index 0000000..7e08cca --- /dev/null +++ b/apps/docs/src/content/docs/system-config.md @@ -0,0 +1,92 @@ +--- +title: System Configuration +category: Core Architecture +description: Configure the Dequel platform via environment variables or the dequel.json config file. +slug: system-config +--- + +Dequel's platform-level settings are configured through environment variables or a `dequel.json` config file. Environment variables take precedence over file values. + +## Config File Location + +Dequel looks for `dequel.json` in the following order: + +1. `DEQUEL_CONFIG` environment variable pointing to an explicit path +2. `~/.config/dequel/dequel.json` +3. `./dequel.json` (next to the binary) +4. `./data/dequel.json` + +## Reference + +### Security + +| Variable | Default | Description | +|----------|---------|-------------| +| `ENV_ENCRYPTION_KEY` | `dev-env-key-change-me` | Key used to encrypt environment variable values and SMTP passwords at rest | + +### GitHub Integration + +Set these to enable the repo picker in the project creation dialog: + +| Variable | Default | Description | +|----------|---------|-------------| +| `GITHUB_CLIENT_ID` | `""` | GitHub OAuth App client ID | +| `GITHUB_CLIENT_SECRET` | `""` | GitHub OAuth App client secret | +| `GITHUB_APP_NAME` | `Dequel` | GitHub App name displayed during OAuth | +| `GITHUB_WEBHOOK_SECRET` | `""` | Secret for GitHub webhook verification | + +Config file equivalent: + +```json +{ + "githubClientId": "Iv1...", + "githubClientSecret": "...", + "githubAppName": "Dequel", + "githubWebhookSecret": "..." +} +``` + +On boot, these values seed the `github_integrations` table. You can also update them from the Settings page in the dashboard. + +### SMTP + +Set these to enable email alerts and notifications: + +| Variable | Default | Description | +|----------|---------|-------------| +| `SMTP_HOST` | `""` | SMTP server hostname | +| `SMTP_PORT` | `587` | SMTP server port | +| `SMTP_USER` | `""` | SMTP username | +| `SMTP_PASS` | `""` | SMTP password | +| `SMTP_FROM` | `dequel@localhost` | From address for outgoing emails | + +Config file equivalent: + +```json +{ + "smtpHost": "smtp.sendgrid.net", + "smtpPort": 587, + "smtpUser": "apikey", + "smtpPass": "...", + "smtpFrom": "dequel@example.com" +} +``` + +On boot, these values seed the `smtp_settings` table. The password is encrypted at rest using `ENV_ENCRYPTION_KEY`. You can also update these from the Settings page in the dashboard, and send a test email to verify the configuration. + +## Full Config File Example + +```json +{ + "githubClientId": "Iv1...", + "githubClientSecret": "...", + "githubAppName": "MyDequel", + "githubWebhookSecret": "...", + "smtpHost": "smtp.sendgrid.net", + "smtpPort": 587, + "smtpUser": "apikey", + "smtpPass": "...", + "smtpFrom": "alerts@example.com", + "envEncryptionKey": "your-secure-key-here" +} +``` diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 1fb7021..c77b9ac 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -12,6 +12,7 @@ import type { Log, GithubRepo, GithubIntegrationStatus, + SmtpSettingsStatus, } from "../types"; const BASE = "/api"; @@ -471,3 +472,23 @@ export const setGithubIntegration = (data: { method: "PUT", body: JSON.stringify(data), }); + +export const getSmtpSettings = () => + apiFetch("/settings/smtp"); + +export const setSmtpSettings = (data: { + host: string; + port: number; + user?: string; + pass?: string; + fromAddress?: string; +}) => + apiFetch<{ ok: boolean }>("/settings/smtp", { + method: "PUT", + body: JSON.stringify(data), + }); + +export const testSmtpSettings = () => + apiFetch<{ ok: boolean } | { error: string }>("/settings/smtp/test", { + method: "POST", + }); diff --git a/apps/web/src/components/ConfigWarnings.tsx b/apps/web/src/components/ConfigWarnings.tsx new file mode 100644 index 0000000..3113b87 --- /dev/null +++ b/apps/web/src/components/ConfigWarnings.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Button } from './ui/button'; +import { Mail, GitBranch, type LucideIcon } from 'lucide-react'; +import * as api from '../api/client'; + +export function ConfigWarnings() { + const [dismissed, setDismissed] = useState([]); + + const smtp = useQuery({ + queryKey: ['smtp-settings'], queryFn: () => api.getSmtpSettings(), + }); + const github = useQuery({ + queryKey: ['github-integration'], queryFn: () => api.getGithubIntegration(), + }); + + const missing: { key: string; icon: LucideIcon; title: string; desc: string }[] = []; + if (smtp.data && !smtp.data.configured) { + missing.push({ + key: 'smtp', + icon: Mail, + title: 'SMTP not configured', + desc: 'Set up SMTP to enable email alerts and notifications.', + }); + } + if (github.data && !github.data.configured) { + missing.push({ + key: 'github', + icon: GitBranch, + title: 'GitHub integration not configured', + desc: 'Connect a GitHub OAuth App to enable the repo picker and auto-deploy.', + }); + } + + if (missing.length === 0) return null; + + return ( +
+ {missing.filter(m => !dismissed.includes(m.key)).map(m => ( +
+
+
+ +
+
+

{m.title}

+

{m.desc}

+
+
+ +
+ ))} +
+ ); +} diff --git a/apps/web/src/routes/Dashboard.tsx b/apps/web/src/routes/Dashboard.tsx index c187a1a..3ec3c77 100644 --- a/apps/web/src/routes/Dashboard.tsx +++ b/apps/web/src/routes/Dashboard.tsx @@ -35,6 +35,7 @@ import { Laptop } from 'lucide-react'; import * as api from '../api/client'; +import { ConfigWarnings } from '../components/ConfigWarnings'; function formatTimeAgo(dateString?: string) { if (!dateString) return '—'; @@ -91,7 +92,9 @@ export function Dashboard() { return (
- + + + {/* Header section with page title & New Project button */}
diff --git a/apps/web/src/routes/Settings.tsx b/apps/web/src/routes/Settings.tsx index 00fc41b..fb16b15 100644 --- a/apps/web/src/routes/Settings.tsx +++ b/apps/web/src/routes/Settings.tsx @@ -1,21 +1,25 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Button } from '../components/ui/button'; import { Input } from '../components/ui/input'; import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../components/ui/table'; import { StatusBadge } from '../components/StatusBadge'; -import { Trash2, Server, Key } from 'lucide-react'; +import { Trash2, Server, Key, Mail } from 'lucide-react'; import * as api from '../api/client'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '../components/ui/dialog'; +import { ConfigWarnings } from '../components/ConfigWarnings'; export function Settings() { return (

Settings

+
+ +
); @@ -113,6 +117,175 @@ function ServersSection() { ); } +function SmtpSection() { + const { data, refetch } = useQuery({ + queryKey: ['smtp-settings'], queryFn: () => api.getSmtpSettings(), + }); + const [host, setHost] = useState(''); + const [port, setPort] = useState('587'); + const [user, setUser] = useState(''); + const [pass, setPass] = useState(''); + const [fromAddress, setFromAddress] = useState(''); + const [testResult, setTestResult] = useState(null); + + useEffect(() => { + if (data?.configured) { + setHost(data.host || ''); + setPort(String(data.port || 587)); + setUser(data.user || ''); + setFromAddress(data.fromAddress || ''); + } + }, [data]); + + const [saveResult, setSaveResult] = useState(null); + + const save = async (e: React.FormEvent) => { + e.preventDefault(); + setSaveResult(null); + try { + await api.setSmtpSettings({ host: host.trim(), port: Number(port), user, pass, fromAddress }); + setPass(''); + refetch(); + setSaveResult('Settings saved'); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setSaveResult('error: ' + message); + } + }; + + const test = async () => { + setTestResult(null); + try { + await api.testSmtpSettings(); + setTestResult('Test email sent successfully'); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setTestResult('error: ' + message); + } + }; + + return ( + + +
SMTP Settings
+
+ +
+
+ + setHost(e.target.value)} className="w-44" /> +
+
+ + setPort(e.target.value)} className="w-20" /> +
+
+ + setUser(e.target.value)} className="w-36" /> +
+
+ + setPass(e.target.value)} className="w-36" /> +
+
+ + setFromAddress(e.target.value)} className="w-44" /> +
+
+ + +
+
+ {saveResult && ( +

{saveResult}

+ )} + {testResult && ( +

{testResult}

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

+ )} +
+
+ ); +} + function ApiKeysSection() { const { data: apiKeys = [], refetch } = useQuery({ queryKey: ['api-keys'], queryFn: () => api.listApiKeys().catch(() => []), diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts index 0514cf9..41382cb 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -165,6 +165,14 @@ export interface GithubRepo { owner: { login: string; avatarUrl: string }; } +export interface SmtpSettingsStatus { + configured: boolean; + host?: string; + port?: number; + user?: string; + fromAddress?: string; +} + export interface GithubIntegrationStatus { configured: boolean; clientId?: string; diff --git a/docker-compose.yml b/docker-compose.yml index 076a110..55f1167 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,8 +31,8 @@ services: api: image: ghcr.io/lftobs/dequel/api:latest - build: - context: ./apps/api + # build: + # context: ./apps/api environment: PORT: 3001 DATABASE_PATH: /app/data/dequel.db @@ -72,8 +72,8 @@ services: web: image: ghcr.io/lftobs/dequel/web:latest - build: - context: ./apps/web + # build: + # context: ./apps/web healthcheck: test: [ @@ -147,7 +147,14 @@ services: aliases: - cadvisor healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/metrics"] + test: + [ + "CMD", + "wget", + "--spider", + "-q", + "http://localhost:8080/metrics", + ] interval: 10s timeout: 3s retries: 3 @@ -172,7 +179,14 @@ services: aliases: - prometheus healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/-/ready"] + test: + [ + "CMD", + "wget", + "--spider", + "-q", + "http://localhost:9090/-/ready", + ] interval: 10s timeout: 3s retries: 3 @@ -190,7 +204,14 @@ services: aliases: - loki healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:3100/ready"] + test: + [ + "CMD", + "wget", + "--spider", + "-q", + "http://localhost:3100/ready", + ] interval: 10s timeout: 3s retries: 3 @@ -231,7 +252,14 @@ services: aliases: - grafana healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"] + test: + [ + "CMD", + "wget", + "--spider", + "-q", + "http://localhost:3000/api/health", + ] interval: 10s timeout: 3s retries: 5 diff --git a/scripts/install.sh b/scripts/install.sh index 15e5fae..14dde29 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -23,7 +23,10 @@ download_if_missing() { if [ -f "$dst" ]; then info " Skipping (exists): $dst" else - curl -fsSL "$src" -o "$dst" || warn "Could not download $src" + http_code=$(curl -fsSL -w "%{http_code}" "$src" -o "$dst" 2>/dev/null) || { + warn "Failed to download $src (HTTP $http_code)" + return 1 + } success "Downloaded: $dst" fi } @@ -57,40 +60,70 @@ setup_directories() { resolve_base_url() { header "Downloading configuration" + TAG="" if [ "$VERSION" = "latest" ]; then local release_url="https://api.github.com/repos/$REPO/releases/latest" info "Fetching latest release..." - local tag - tag=$(curl -fsSL "$release_url" | grep '"tag_name"' | head -1 | sed -E 's/.*"([^"]+)".*/\1/') - if [ -z "$tag" ]; then + TAG=$(curl -fsSL "$release_url" | grep '"tag_name"' | head -1 | sed -E 's/.*"([^"]+)".*/\1/') || true + if [ -z "$TAG" ]; then warn "Could not determine latest release. Falling back to 'main' branch." BASE_URL="https://raw.githubusercontent.com/$REPO/main" else - BASE_URL="https://raw.githubusercontent.com/$REPO/$tag" - success "Latest release: $tag" + BASE_URL="https://raw.githubusercontent.com/$REPO/$TAG" + success "Latest release: $TAG" fi else - BASE_URL="https://raw.githubusercontent.com/$REPO/v$VERSION" + TAG="v$VERSION" + BASE_URL="https://raw.githubusercontent.com/$REPO/$TAG" fi } download_configs() { resolve_base_url + if [ -n "$TAG" ]; then + local version="${TAG#v}" + local tarball_url="https://github.com/$REPO/releases/download/$TAG/dequel-config-$version.tar.gz" + local tarball_path=$(mktemp) + info "Downloading config bundle..." + set +e + http_code=$(curl -fsSL -w "%{http_code}" "$tarball_url" -o "$tarball_path" 2>/dev/null) + curl_ok=$? + set -e + if [ "$curl_ok" -eq 0 ] && [ "$http_code" = "200" ]; then + tar -xzf "$tarball_path" -C "$INSTALL_DIR" + rm -f "$tarball_path" + chmod +x "$INSTALL_DIR/dequel" 2>/dev/null || true + success "Downloaded and extracted config bundle" + return + fi + rm -f "$tarball_path" + warn "Could not download config bundle (HTTP $http_code), falling back to individual files" + fi + download_if_missing "$BASE_URL/docker-compose.yml" "$INSTALL_DIR/docker-compose.yml" download_if_missing "$BASE_URL/infra/caddy/Caddyfile" "$INSTALL_DIR/infra/caddy/Caddyfile" + download_if_missing "$BASE_URL/scripts/dequel" "$INSTALL_DIR/dequel" for f in prometheus.yml loki-config.yml promtail-config.yml; do download_if_missing "$BASE_URL/infra/monitoring/$f" "$INSTALL_DIR/infra/monitoring/$f" done - download_if_missing "$BASE_URL/infra/monitoring/grafana/datasources/datasources.yml" "$INSTALL_DIR/infra/monitoring/grafana/datasources/datasources.yml" + for f in loki.yml prometheus.yml; do + download_if_missing "$BASE_URL/infra/monitoring/grafana/datasources/$f" "$INSTALL_DIR/infra/monitoring/grafana/datasources/$f" + done } prompt_config() { header "Configuration" + if [ ! -t 0 ]; then + warn "Non-interactive mode: skipping configuration prompt" + warn "Set CADDY_EMAIL and CADDY_BASE_DOMAIN manually in $INSTALL_DIR/.env" + return + fi + read -r -p " Admin email (for SSL notifications, optional): " ADMIN_EMAIL read -r -p " Hostname (e.g. dequel.example.com, optional): " HOSTNAME @@ -112,9 +145,7 @@ pull_images() { install_cli() { header "Installing dequel CLI" local cli_src="$INSTALL_DIR/dequel" - if [ -f "$cli_src" ]; then - info "CLI already downloaded." - else + if [ ! -f "$cli_src" ]; then download_if_missing "$BASE_URL/scripts/dequel" "$cli_src" fi chmod +x "$cli_src" From ba59d2946569e1ada2d741a6f312f7f92d2977a8 Mon Sep 17 00:00:00 2001 From: lftobs Date: Sun, 14 Jun 2026 11:46:19 +0100 Subject: [PATCH 09/12] feat(api,web): add project source and port configuration - Added `port`, `sourceDir`, and `sourceType` fields to project schema. - Updated API project creation/retrieval to support new fields. - Refactored frontend UI components for project creation and layout. - Added `mise` to API Dockerfile for improved runtime management. - Added support for displaying notifications in the web dashboard. --- apps/api/Dockerfile | 4 + apps/api/src/api/github/index.ts | 106 ++- apps/api/src/api/projects/index.ts | 5 + apps/api/src/db/migrate.ts | 35 +- apps/api/src/db/migrations/0000_baseline.sql | 3 + apps/api/src/db/migrations/meta/_journal.json | 4 +- apps/api/src/db/repo/projects.ts | 8 + apps/api/src/db/schema.ts | 3 + apps/api/src/orchestrator/pipeline.ts | 32 +- apps/api/src/orchestrator/runtime.ts | 5 +- apps/api/src/types.ts | 6 + apps/api/src/utils/domain-verifier.ts | 3 +- apps/web/src/api/client.ts | 6 + apps/web/src/components/Layout.tsx | 518 +++-------- apps/web/src/components/github/RepoPicker.tsx | 106 ++- apps/web/src/components/layout/Header.tsx | 41 + .../src/components/layout/NodeStatusCard.tsx | 44 + .../components/layout/NotificationBanner.tsx | 45 + .../src/components/layout/ProjectSelector.tsx | 97 ++ apps/web/src/components/layout/Sidebar.tsx | 71 ++ apps/web/src/components/layout/SidebarNav.tsx | 250 +++++ .../src/components/layout/SupportSection.tsx | 116 +++ .../project/create/CreateProjectDialog.tsx | 97 +- .../components/project/create/StepBasics.tsx | 855 +++++++++++++----- .../project/create/StepEnvironment.tsx | 2 +- .../project/create/StepResources.tsx | 4 +- .../project/deployments/DeploymentsTab.tsx | 30 +- apps/web/src/components/ui/alert.tsx | 60 ++ apps/web/src/index.css | 70 ++ apps/web/src/routes/Dashboard.tsx | 92 +- apps/web/src/types/index.ts | 3 + 31 files changed, 1962 insertions(+), 759 deletions(-) create mode 100644 apps/web/src/components/layout/Header.tsx create mode 100644 apps/web/src/components/layout/NodeStatusCard.tsx create mode 100644 apps/web/src/components/layout/NotificationBanner.tsx create mode 100644 apps/web/src/components/layout/ProjectSelector.tsx create mode 100644 apps/web/src/components/layout/Sidebar.tsx create mode 100644 apps/web/src/components/layout/SidebarNav.tsx create mode 100644 apps/web/src/components/layout/SupportSection.tsx create mode 100644 apps/web/src/components/ui/alert.tsx diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 6cc3291..4392ef5 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -25,6 +25,10 @@ RUN docker --version && docker buildx version # Install railpack RUN curl -sSL https://railpack.com/install.sh | sh -s -- --bin-dir /usr/local/bin + +RUN curl -sSL https://mise.jdx.dev/install.sh | sh \ + && ln -sf /root/.local/bin/mise /usr/local/bin/mise + WORKDIR /app COPY package.json ./ diff --git a/apps/api/src/api/github/index.ts b/apps/api/src/api/github/index.ts index 4aa5f4d..6ca5039 100644 --- a/apps/api/src/api/github/index.ts +++ b/apps/api/src/api/github/index.ts @@ -1,5 +1,6 @@ import { Elysia } from "elysia"; -import { getGithubIntegration, setGithubIntegration } from "../../db/repo"; +import { getGithubIntegration, setGithubIntegration, createDeployment, listProjects } from "../../db/repo"; +import { orchestrator } from "../../orchestrator"; import { config } from "../../utils/config"; const SESSIONS = new Map(); @@ -76,7 +77,7 @@ export const githubRoutes = new Elysia({ prefix: "/github" }) return { url: `https://github.com/login/oauth/authorize?${params}` }; }) - .get("/callback", async ({ query, set }) => { + .get("/callback", async ({ request, query, set }) => { const { code, state } = query as Record; if (!code) { set.status = 400; @@ -87,8 +88,8 @@ export const githubRoutes = new Elysia({ prefix: "/github" }) set.status = 400; return { error: "GitHub integration not configured" }; } - const url = new URL(config.caddyIngressBase); - const redirectUri = `${url.protocol}//${url.host}/api/github/callback`; + const origin = new URL(request.url).origin; + const redirectUri = `${origin}/api/github/callback`; const res = await fetch("https://github.com/login/oauth/access_token", { method: "POST", @@ -105,12 +106,15 @@ export const githubRoutes = new Elysia({ prefix: "/github" }) }); const data = await res.json() as Record; if (data.error) { - set.status = 400; - return { error: data.error_description ?? data.error }; + const msg = encodeURIComponent(data.error_description ?? data.error); + set.status = 302; + set.headers["Location"] = `${origin}/?github=error=${msg}`; + return; } const sessionId = createSession(data.access_token); + set.status = 302; set.headers["Set-Cookie"] = `github_session=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL_MS / 1000}`; - set.redirect = `${config.caddyIngressBase}/?github=connected`; + set.headers["Location"] = `${origin}/?github=connected`; }) .get("/user", async ({ request, set }) => { @@ -165,4 +169,92 @@ export const githubRoutes = new Elysia({ prefix: "/github" }) if (match) SESSIONS.delete(match[1]); set.headers["Set-Cookie"] = "github_session=; Path=/; Max-Age=0"; return { ok: true }; + }) + + .post("/webhook", async ({ request, set }) => { + const integration = await getGithubIntegration(); + if (!integration?.webhookSecret) { + set.status = 400; + return { error: "GitHub webhook not configured" }; + } + + const signature = request.headers.get("x-hub-signature-256") || ""; + const event = request.headers.get("x-github-event") || ""; + const delivery = request.headers.get("x-github-delivery") || ""; + const rawBody = await request.text(); + + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(integration.webhookSecret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const expectedSigRaw = await crypto.subtle.sign("HMAC", key, encoder.encode(rawBody)); + const expectedSig = "sha256=" + Array.from(new Uint8Array(expectedSigRaw)).map(b => b.toString(16).padStart(2, "0")).join(""); + + if (signature.length !== expectedSig.length) { + set.status = 401; + return { error: "Invalid signature" }; + } + if (!crypto.timingSafeEqual(Buffer.from(expectedSig), Buffer.from(signature))) { + set.status = 401; + return { error: "Invalid signature" }; + } + + if (event !== "push") { + return { ok: true, ignored: `unsupported event: ${event}` }; + } + + let payload: any; + try { + payload = JSON.parse(rawBody); + } catch { + set.status = 400; + return { error: "Invalid JSON payload" }; + } + + const repoUrl = payload?.repository?.clone_url; + if (!repoUrl) { + set.status = 400; + return { error: "Missing repository.clone_url in payload" }; + } + + const branch = payload?.ref?.replace("refs/heads/", "") || "main"; + const commitSha = payload?.after || ""; + + const projects = await listProjects(); + const project = projects.find(p => + p.repoUrl && ( + p.repoUrl === repoUrl || + p.repoUrl.replace(/\.git$/, "") === repoUrl.replace(/\.git$/, "") + ) + ); + + if (!project) { + return { ok: true, ignored: `no project found for repo: ${repoUrl}` }; + } + + if (project.repoBranch && project.repoBranch !== branch) { + return { ok: true, ignored: `branch "${branch}" does not match project branch "${project.repoBranch}"` }; + } + + if (!commitSha || commitSha === "0000000000000000000000000000000000000000") { + return { ok: true, ignored: "deletion event, no commit to deploy" }; + } + + const dep = await createDeployment({ + projectId: project.id, + sourceType: "git", + sourceRef: repoUrl, + branch, + commitSha, + }); + + orchestrator.enqueue(dep.id); + + console.log(`[GitWebhook] Auto-deploy triggered for ${project.name} (${branch}) — commit ${commitSha.slice(0, 7)}`); + + return { ok: true, deploymentId: dep.id }; }); diff --git a/apps/api/src/api/projects/index.ts b/apps/api/src/api/projects/index.ts index d1c2a47..4b2f226 100644 --- a/apps/api/src/api/projects/index.ts +++ b/apps/api/src/api/projects/index.ts @@ -42,6 +42,9 @@ export const projectsRoutes = new Elysia() repoBranch: body.repoBranch, cpuLimit: body.cpuLimit, memoryLimitMb: body.memoryLimitMb, + port: body.port ? Number(body.port) : null, + sourceDir: body.sourceDir || null, + sourceType: body.sourceType || "git", }); }, ) @@ -56,6 +59,8 @@ export const projectsRoutes = new Elysia() repoBranch: body?.repoBranch, cpuLimit: body?.cpuLimit, memoryLimitMb: body?.memoryLimitMb, + port: body?.port ? Number(body.port) : body?.port === null ? null : undefined, + sourceDir: body?.sourceDir ?? undefined, }); if (!project) { set.status = 404; diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts index 13c43f3..2c3682f 100644 --- a/apps/api/src/db/migrate.ts +++ b/apps/api/src/db/migrate.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { readdirSync } from "node:fs"; import { migrate as drizzleMigrate } from "drizzle-orm/bun-sqlite/migrator"; import { getDrizzle } from "./drizzle"; +import { getDb } from "./client"; import { config } from "../utils/config"; import { getSmtpSettings, upsertSmtpSettings } from "./repo/settings"; import { getGithubIntegration, setGithubIntegration } from "./repo/github"; @@ -28,13 +29,45 @@ 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 seedFromConfig(); }; const seedFromConfig = async () => { if (config.githubClientId && config.githubClientSecret) { const existing = await getGithubIntegration(); - if (!existing) { + if (existing) { + if ( + existing.clientId !== config.githubClientId || + existing.clientSecret !== config.githubClientSecret || + (config.githubWebhookSecret && existing.webhookSecret !== config.githubWebhookSecret) + ) { + await setGithubIntegration({ + clientId: config.githubClientId, + clientSecret: config.githubClientSecret, + appName: config.githubAppName, + webhookSecret: config.githubWebhookSecret || undefined, + }); + console.log("[Config] Synced GitHub integration from config file"); + } + } else { await setGithubIntegration({ clientId: config.githubClientId, clientSecret: config.githubClientSecret, diff --git a/apps/api/src/db/migrations/0000_baseline.sql b/apps/api/src/db/migrations/0000_baseline.sql index d261b8c..226818d 100644 --- a/apps/api/src/db/migrations/0000_baseline.sql +++ b/apps/api/src/db/migrations/0000_baseline.sql @@ -16,6 +16,9 @@ CREATE TABLE IF NOT EXISTS `projects` ( `base_domain` text, `cpu_limit` real, `memory_limit_mb` integer, + `port` integer, + `source_dir` text, + `source_type` text NOT NULL DEFAULT 'git', `github_token_encrypted` text, `github_token_iv` text, `github_token_tag` text, diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index d15851e..df2ce5b 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -2,8 +2,8 @@ "version": "6", "dialect": "sqlite", "entries": [ - { - "idx": 0, +{ + "idx": 0, "version": "6", "when": 1718000000000, "tag": "0000_baseline", diff --git a/apps/api/src/db/repo/projects.ts b/apps/api/src/db/repo/projects.ts index dddd881..27fbee5 100644 --- a/apps/api/src/db/repo/projects.ts +++ b/apps/api/src/db/repo/projects.ts @@ -25,6 +25,9 @@ const mapProject = (row: typeof projects.$inferSelect): Project => ({ baseDomain: row.baseDomain, cpuLimit: row.cpuLimit, memoryLimitMb: row.memoryLimitMb, + port: row.port ?? null, + sourceDir: row.sourceDir ?? null, + sourceType: row.sourceType, githubTokenEncrypted: row.githubTokenEncrypted ?? null, githubTokenIv: row.githubTokenIv ?? null, githubTokenTag: row.githubTokenTag ?? null, @@ -45,6 +48,9 @@ export const createProject = async (input: CreateProjectInput): Promise baseDomain: input.baseDomain ?? null, cpuLimit: input.cpuLimit ?? null, memoryLimitMb: input.memoryLimitMb ?? null, + port: input.port ?? null, + sourceDir: input.sourceDir ?? null, + sourceType: input.sourceType ?? "git", createdAt: timestamp, updatedAt: timestamp, }).run(); @@ -80,6 +86,8 @@ export const updateProject = async (id: string, patch: Partial { - await emitLog( - deploymentId, - "build", - line, - ); - }, - { cacheKey }, - ); + const project = deployment.projectId ? await getProjectById(deployment.projectId) : null; + const buildDir = project?.sourceDir ? workspacePath + '/' + project.sourceDir.replace(/^\//, '') : workspacePath; + await buildWithRailpack( + buildDir, + imageTag, + async (line) => { + await emitLog( + deploymentId, + "build", + line, + ); + }, + { cacheKey }, + ); } else { await emitLog( deploymentId, @@ -463,6 +465,10 @@ export class PipelineOrchestrator { | number | null | undefined; + let appPort: + | number + | null + | undefined; if (deployment.projectId) { const proj = await getProjectById( deployment.projectId, @@ -472,6 +478,7 @@ export class PipelineOrchestrator { cpuLimit = proj.cpuLimit; memoryLimitMb = proj.memoryLimitMb; + appPort = proj.port; } } @@ -495,6 +502,7 @@ export class PipelineOrchestrator { volumes, cpuLimit, memoryLimitMb, + appPort: appPort ?? undefined, }, ); diff --git a/apps/api/src/orchestrator/runtime.ts b/apps/api/src/orchestrator/runtime.ts index f4d2d5f..95c3d96 100644 --- a/apps/api/src/orchestrator/runtime.ts +++ b/apps/api/src/orchestrator/runtime.ts @@ -15,6 +15,7 @@ export interface RuntimeOpts { replicas?: number; cpuLimit?: number | null; memoryLimitMb?: number | null; + appPort?: number; } export const run = (cmd: string, args: string[]) => @@ -110,7 +111,7 @@ export const deployContainer = async ( 'run', '-d', '--name', containerName, '--network', config.dockerNetwork, - '-e', `PORT=${config.appInternalPort}`, + '-e', `PORT=${opts.appPort ?? config.appInternalPort}`, ]; if (opts.cpuLimit && opts.cpuLimit > 0) { @@ -155,7 +156,7 @@ export const deployContainer = async ( const caddyRouteFile = join(config.caddyRoutesDir, `${slug}.caddy`); const { buildCaddySnippet } = await import('../utils/domain-verifier'); - const caddySnippet = await buildCaddySnippet(slug, containerName, opts.projectId); + const caddySnippet = await buildCaddySnippet(slug, containerName, opts.projectId, undefined, opts.appPort); await onLog(`Writing Caddy route file: ${caddyRouteFile}`); await writeFile(caddyRouteFile, caddySnippet, 'utf8'); diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts index 598fba8..d315c31 100644 --- a/apps/api/src/types.ts +++ b/apps/api/src/types.ts @@ -19,6 +19,9 @@ export interface Project { baseDomain: string | null; cpuLimit: number | null; memoryLimitMb: number | null; + port: number | null; + sourceDir: string | null; + sourceType: string; githubTokenEncrypted: string | null; githubTokenIv: string | null; githubTokenTag: string | null; @@ -43,6 +46,9 @@ export interface CreateProjectInput { baseDomain?: string; cpuLimit?: number | null; memoryLimitMb?: number | null; + port?: number | null; + sourceDir?: string | null; + sourceType?: string; } export interface EnvironmentVariable { diff --git a/apps/api/src/utils/domain-verifier.ts b/apps/api/src/utils/domain-verifier.ts index f01e402..99360c5 100644 --- a/apps/api/src/utils/domain-verifier.ts +++ b/apps/api/src/utils/domain-verifier.ts @@ -116,9 +116,10 @@ export const buildCaddySnippet = async ( containerName: string, projectId?: string, listDomainsFn: typeof listDomains = listDomains, + appPort?: number, ): Promise => { let domains = [`${slug}.localhost:80`]; - let port = config.appInternalPort; + let port = appPort ?? config.appInternalPort; if (projectId) { const projectDomains = await listDomainsFn(projectId); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index c77b9ac..a630cfe 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -84,6 +84,9 @@ export const createProject = (data: { repoBranch?: string; cpuLimit?: number; memoryLimitMb?: number; + port?: number; + sourceDir?: string; + sourceType?: string; }) => apiFetch("/projects", { method: "POST", @@ -94,6 +97,9 @@ export const updateProject = ( data: Partial & { repoUrl?: string | null; repoBranch?: string | null; + baseDomain?: string | null; + sourceDir?: string | null; + port?: number | null; }, ) => apiFetch(`/projects/${id}`, { diff --git a/apps/web/src/components/Layout.tsx b/apps/web/src/components/Layout.tsx index b8d4a87..542aeae 100644 --- a/apps/web/src/components/Layout.tsx +++ b/apps/web/src/components/Layout.tsx @@ -1,419 +1,105 @@ -import { useState } from 'react'; -import { Link, useLocation, useNavigate } from '@tanstack/react-router'; -import { useProjects } from '../hooks/useProjects'; -import { useQuery } from '@tanstack/react-query'; -import * as api from '../api/client'; -import { parseMetrics } from '../lib/metrics'; -import { cn } from '../lib/utils'; -import { DequelLogo } from './DequelLogo'; -import { - Box, - Settings, - ChevronRight, - ChevronDown, - Activity, - Terminal, - Sliders, - Globe, - Database, - HardDrive, - Bell, - Sparkles, - Layers, - ArrowUpRight, - TrendingUp, - FolderGit, - Search, - ExternalLink, - Laptop, - Coffee -} from 'lucide-react'; +import { useState, useEffect } from "react"; +import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useProjects } from "../hooks/useProjects"; +import { useQuery } from "@tanstack/react-query"; +import * as api from "../api/client"; +import { parseMetrics } from "../lib/metrics"; +import { Sidebar } from "./layout/Sidebar"; +import { Header } from "./layout/Header"; +import { NotificationBanner } from "./layout/NotificationBanner"; export function Layout({ children }: { children: React.ReactNode }) { - const location = useLocation(); - const navigate = useNavigate(); - const { data: projects = [] } = useProjects(); - const [projectSelectorOpen, setProjectSelectorOpen] = useState(false); - - // Fetch metrics for sidebar usage stats - const { data: metricsText } = useQuery({ - queryKey: ['metrics'], - queryFn: () => api.getMetrics(), - refetchInterval: 15000, - }); - - const metrics = metricsText ? parseMetrics(metricsText) : null; - - // Extract active project id if we are inside a project route - const match = location.pathname.match(/\/project\/([^/]+)/); - const currentProjectId = match ? match[1] : null; - const currentProject = projects.find(p => p.id === currentProjectId); - - // Helper to check if a navigation item is active - const isTabActive = (tabName: string) => { - const activeTab = new URLSearchParams(location.search).get('tab') || 'deployments'; - return activeTab === tabName; - }; - - return ( -
- {/* Sidebar */} - - - {/* Main Content Area */} -
- {/* Header Breadcrumbs */} -
- Workspace - - {currentProject ? ( - <> - - {currentProject.name} - - - - {new URLSearchParams(location.search).get('tab') || 'deployments'} - - - ) : ( - Dashboard - )} -
- - {/* Scrollable Main Wrapper */} -
- {children} -
-
-
- ); + const location = useLocation(); + const navigate = useNavigate(); + const { data: projects = [] } = useProjects(); + const [projectSelectorOpen, setProjectSelectorOpen] = useState(false); + + const { data: metricsText } = useQuery({ + queryKey: ["metrics"], + queryFn: () => api.getMetrics(), + refetchInterval: 15000, + }); + + const metrics = metricsText ? parseMetrics(metricsText) : null; + + const [notification, setNotification] = useState<{ + type: "success" | "error"; + message: string; + } | null>(null); + + useEffect(() => { + const params = new URLSearchParams(location.search); + const ghConnected = params.get("github"); + if (ghConnected === "connected") { + setNotification({ + type: "success", + message: "GitHub account connected successfully", + }); + } else if (ghConnected?.startsWith("error=")) { + setNotification({ + type: "error", + message: decodeURIComponent(ghConnected.replace("error=", "")), + }); + } + if (ghConnected) { + const newParams = new URLSearchParams(location.search); + newParams.delete("github"); + const qs = newParams.toString(); + navigate({ + to: location.pathname, + search: qs ? Object.fromEntries(newParams) : {}, + } as any); + } + + const onNotification = (e: Event) => { + const detail = (e as CustomEvent).detail; + setNotification({ + type: detail.type, + message: detail.message, + }); + }; + window.addEventListener("opencode:notification", onNotification); + return () => + window.removeEventListener("opencode:notification", onNotification); + }, [location.search, location.pathname, navigate]); + + useEffect(() => { + if (!notification) return; + const t = setTimeout(() => setNotification(null), 6000); + return () => clearTimeout(t); + }, [notification]); + + const match = location.pathname.match(/\/project\/([^/]+)/); + const currentProjectId = match ? match[1] : null; + const currentProject = projects.find((p) => p.id === currentProjectId); + + return ( +
+ + +
+
+ + setNotification(null)} + /> + +
{children}
+
+
+ ); } diff --git a/apps/web/src/components/github/RepoPicker.tsx b/apps/web/src/components/github/RepoPicker.tsx index 26a1fbb..347298f 100644 --- a/apps/web/src/components/github/RepoPicker.tsx +++ b/apps/web/src/components/github/RepoPicker.tsx @@ -1,9 +1,20 @@ import { useState, useEffect } from "react"; -import { getGithubRepos, disconnectGithub } from "../../api/client"; +import { + getGithubRepos, + disconnectGithub, +} from "../../api/client"; import type { GithubRepo } from "../../types"; import { Input } from "../ui/input"; import { Button } from "../ui/button"; -import { Search, X, GitFork, Lock, Globe, ExternalLink, RefreshCw } from "lucide-react"; +import { + Search, + X, + GitFork, + Lock, + Globe, + ExternalLink, + RefreshCw, +} from "lucide-react"; import { cn } from "../../lib/utils"; interface RepoPickerProps { @@ -12,8 +23,14 @@ interface RepoPickerProps { onDisconnect: () => void; } -export function RepoPicker({ onSelect, selected, onDisconnect }: RepoPickerProps) { - const [repos, setRepos] = useState([]); +export function RepoPicker({ + onSelect, + selected, + onDisconnect, +}: RepoPickerProps) { + const [repos, setRepos] = useState< + GithubRepo[] + >([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); const [error, setError] = useState(""); @@ -25,37 +42,58 @@ export function RepoPicker({ onSelect, selected, onDisconnect }: RepoPickerProps const data = await getGithubRepos(); setRepos(data); } catch (err) { - setError("Failed to load repositories. Reconnect GitHub."); + setError( + "Failed to load repositories. Reconnect GitHub.", + ); } finally { setLoading(false); } }; - useEffect(() => { fetchRepos(); }, []); + useEffect(() => { + fetchRepos(); + }, []); const filtered = repos.filter((r) => - r.fullName.toLowerCase().includes(search.toLowerCase()), + r.fullName + .toLowerCase() + .includes(search.toLowerCase()), ); if (selected) { return ( -
+
- + + +
{selected.fullName}
- {selected.private ? "Private" : "Public"} · {selected.defaultBranch} + {selected.private + ? "Private" + : "Public"}{" "} + ·{" "} + { + selected.defaultBranch + }
{repos.length > 0 && ( - {filtered.length} of {repos.length} repos + {filtered.length} of{" "} + {repos.length} repos )}
diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx new file mode 100644 index 0000000..7b686c5 --- /dev/null +++ b/apps/web/src/components/layout/Header.tsx @@ -0,0 +1,41 @@ +import { Link } from "@tanstack/react-router"; +import { ChevronRight } from "lucide-react"; + +interface HeaderProps { + currentProject: { name: string } | undefined; + currentProjectId: string | null; + location: { pathname: string; search: any }; +} + +export function Header({ + currentProject, + currentProjectId, + location, +}: HeaderProps) { + return ( +
+ + Workspace + + + {currentProject ? ( + <> + + {currentProject.name} + + + + {new URLSearchParams(location.search).get("tab") || "deployments"} + + + ) : ( + Dashboard + )} +
+ ); +} diff --git a/apps/web/src/components/layout/NodeStatusCard.tsx b/apps/web/src/components/layout/NodeStatusCard.tsx new file mode 100644 index 0000000..e1bb171 --- /dev/null +++ b/apps/web/src/components/layout/NodeStatusCard.tsx @@ -0,0 +1,44 @@ +interface NodeStatusCardProps { + metrics: { + activeDeployments?: number; + requestsTotal?: number; + } | null; +} + +export function NodeStatusCard({ metrics }: NodeStatusCardProps) { + return ( +
+
+ + Node Status + + + + Live + +
+ +
+
+ + + Active Services + + + {metrics?.activeDeployments ?? 0} + +
+ +
+ + + API Traffic + + + {metrics?.requestsTotal ?? 0} + +
+
+
+ ); +} diff --git a/apps/web/src/components/layout/NotificationBanner.tsx b/apps/web/src/components/layout/NotificationBanner.tsx new file mode 100644 index 0000000..35755d6 --- /dev/null +++ b/apps/web/src/components/layout/NotificationBanner.tsx @@ -0,0 +1,45 @@ +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +import { AlertCircle, CheckCircle2, X } from "lucide-react"; + +interface NotificationBannerProps { + notification: { + type: "success" | "error"; + message: string; + } | null; + onClose: () => void; +} + +export function NotificationBanner({ + notification, + onClose, +}: NotificationBannerProps) { + if (!notification) return null; + + const Icon = notification.type === "success" ? CheckCircle2 : AlertCircle; + + return ( +
+ + +
+ + {notification.type} + + + {notification.message} + +
+ +
+
+ ); +} diff --git a/apps/web/src/components/layout/ProjectSelector.tsx b/apps/web/src/components/layout/ProjectSelector.tsx new file mode 100644 index 0000000..eef38f5 --- /dev/null +++ b/apps/web/src/components/layout/ProjectSelector.tsx @@ -0,0 +1,97 @@ +import { Link } from "@tanstack/react-router"; +import { ChevronDown } from "lucide-react"; +import { cn } from "../../lib/utils"; +import { DequelLogo } from "../DequelLogo"; + +interface Project { + id: string; + name: string; +} + +interface ProjectSelectorProps { + projects: Project[]; + currentProject: Project | undefined; + currentProjectId: string | null; + isOpen: boolean; + setIsOpen: (open: boolean) => void; +} + +export function ProjectSelector({ + projects, + currentProject, + currentProjectId, + isOpen, + setIsOpen, +}: ProjectSelectorProps) { + return ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} + /> +
+ setIsOpen(false)} + className="flex items-center gap-2 px-3 py-2 text-xs text-zinc-400 hover:text-zinc-100 hover:bg-[#1a1a1e] transition-colors" + > + + All Projects + +
+ {projects.map((p) => ( + setIsOpen(false)} + className={cn( + "flex items-center justify-between px-3 py-2 text-xs transition-colors", + p.id === currentProjectId + ? "bg-amber-500/10 text-amber-400 font-medium" + : "text-zinc-400 hover:text-zinc-100 hover:bg-[#1a1a1e]", + )} + > +
+
+ {p.name.charAt(0).toUpperCase()} +
+ {p.name} +
+ + ))} +
+ + )} +
+ ); +} diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..14260aa --- /dev/null +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -0,0 +1,71 @@ +import { DequelLogo } from "../DequelLogo"; +import { ProjectSelector } from "./ProjectSelector"; +import { SidebarNav } from "./SidebarNav"; +import { NodeStatusCard } from "./NodeStatusCard"; +import { SupportSection } from "./SupportSection"; + +interface Project { + id: string; + name: string; +} + +interface SidebarProps { + projects: Project[]; + currentProject: Project | undefined; + currentProjectId: string | null; + projectSelectorOpen: boolean; + setProjectSelectorOpen: (open: boolean) => void; + metrics: any; + location: { pathname: string; search: any }; + navigate: (opts: any) => void; +} + +export function Sidebar({ + projects, + currentProject, + currentProjectId, + projectSelectorOpen, + setProjectSelectorOpen, + metrics, + location, + navigate, +}: SidebarProps) { + return ( + + ); +} diff --git a/apps/web/src/components/layout/SidebarNav.tsx b/apps/web/src/components/layout/SidebarNav.tsx new file mode 100644 index 0000000..5e6682d --- /dev/null +++ b/apps/web/src/components/layout/SidebarNav.tsx @@ -0,0 +1,250 @@ +import { Link } from "@tanstack/react-router"; +import { + Box, + Settings, + Activity, + Terminal, + Sliders, + Globe, + Database, + Bell, + Layers, + ArrowUpRight, + TrendingUp, + Laptop, +} from "lucide-react"; +import { cn } from "../../lib/utils"; + +interface SidebarNavProps { + currentProjectId: string | null; + currentProject: { name: string } | undefined; + location: { pathname: string; search: any }; + navigate: (opts: any) => void; +} + +export function SidebarNav({ + currentProjectId, + currentProject, + location, + navigate, +}: SidebarNavProps) { + const isTabActive = (tabName: string) => { + const activeTab = + new URLSearchParams(location.search).get("tab") || "deployments"; + return activeTab === tabName; + }; + + return ( +
+
+

+ Dashboards +

+ +
+ + {currentProject && ( +
+
+

+ Project Control +

+ + Active + +
+ +
+ )} + + {currentProject && ( +
+

+ Observability +

+ +
+ )} +
+ ); +} diff --git a/apps/web/src/components/layout/SupportSection.tsx b/apps/web/src/components/layout/SupportSection.tsx new file mode 100644 index 0000000..b2de942 --- /dev/null +++ b/apps/web/src/components/layout/SupportSection.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from "react"; +import { Coffee } from "lucide-react"; + +export function SupportSection() { + const [failedToLoad, setFailedToLoad] = useState(false); + + useEffect(() => { + let active = true; + + const draw = () => { + if (!active) return; + const widget = (window as any).kofiWidgetOverlay; + if (!widget?.draw) { + setFailedToLoad(true); + return; + } + try { + const container = document.getElementById("kofi-widget-container"); + if (container) { + container.innerHTML = ""; + } + widget.draw( + "rm_rf", + { + type: "floating-chat", + "floating-chat.donateButton.text": "Buy me a coffee", + "floating-chat.donateButton.background-color": "#FFDD00", + "floating-chat.donateButton.text-color": "#000000", + }, + "kofi-widget-container", + ); + setFailedToLoad(false); + } catch (e) { + setFailedToLoad(true); + } + }; + + const widget = (window as any).kofiWidgetOverlay; + if (widget?.draw) { + const t = setTimeout(draw, 0); + return () => { + active = false; + clearTimeout(t); + }; + } + + let script = document.getElementById("kofi-widget-overlay") as HTMLScriptElement | null; + if (!script) { + script = document.createElement("script"); + script.id = "kofi-widget-overlay"; + script.src = "https://storage.ko-fi.com/cdn/scripts/overlay-widget.js"; + script.async = true; + document.body.appendChild(script); + } + + const handleLoad = () => { + setTimeout(draw, 0); + }; + const handleError = () => { + if (active) setFailedToLoad(true); + }; + + script.addEventListener("load", handleLoad); + script.addEventListener("error", handleError); + + const timeoutId = setTimeout(() => { + if (active) { + const w = (window as any).kofiWidgetOverlay; + if (!w?.draw) { + setFailedToLoad(true); + } else { + draw(); + } + } + }, 6000); + + return () => { + active = false; + clearTimeout(timeoutId); + if (script) { + script.removeEventListener("load", handleLoad); + script.removeEventListener("error", handleError); + } + }; + }, []); + + return ( +
+
+ + Support Dequel +
+

+ Help keep Dequel open source and support its development! +

+ +
+ {failedToLoad && ( + + Buy me a coffee + + )} +
+
+ ); +} diff --git a/apps/web/src/components/project/create/CreateProjectDialog.tsx b/apps/web/src/components/project/create/CreateProjectDialog.tsx index 5b9ce65..b00e9a4 100644 --- a/apps/web/src/components/project/create/CreateProjectDialog.tsx +++ b/apps/web/src/components/project/create/CreateProjectDialog.tsx @@ -45,6 +45,10 @@ export function CreateProjectDialog({ useState(null); const [githubConnected, setGithubConnected] = useState(false); + const [ + githubConfigured, + setGithubConfigured, + ] = useState(false); const [stagedEnvs, setStagedEnvs] = useState< Array<{ @@ -54,9 +58,14 @@ export function CreateProjectDialog({ }> >([]); + const [sourceType, setSourceType] = + useState("git"); const [cpuLimit, setCpuLimit] = useState(""); const [memoryLimitMb, setMemoryLimitMb] = useState(""); + const [port, setPort] = useState(""); + const [sourceDir, setSourceDir] = + useState(""); const [provisionDb, setProvisionDb] = useState(false); const [dbType, setDbType] = useState< @@ -85,34 +94,26 @@ export function CreateProjectDialog({ getGithubIntegration() .then((status) => { if ((status as any).configured) { + setGithubConfigured(true); api.getGithubUser() .then(() => setGithubConnected( true, ), ) - .catch(() => {}); + .catch(() => { }); + } else { + setGithubConfigured(false); + setSourceType((prev) => + prev === "git" + ? "upload" + : prev, + ); } }) - .catch(() => {}); + .catch(() => { }); }, [open]); - useEffect(() => { - const params = new URLSearchParams( - window.location.search, - ); - if ( - params.get("github") === "connected" - ) { - setGithubConnected(true); - window.history.replaceState( - {}, - "", - window.location.pathname, - ); - } - }, []); - const handleOpenChange = ( isOpen: boolean, ) => { @@ -124,7 +125,10 @@ export function CreateProjectDialog({ setBaseDomain(""); setRepoUrl(""); setRepoBranch(""); + setSourceDir(""); setSelectedRepo(null); + setSourceType("git"); + setPort(""); } }; @@ -137,8 +141,10 @@ export function CreateProjectDialog({ setSubmittingStatus("creating_project"); setErrorMessage(""); + let project: any = null; + try { - const project = + project = await createProject.mutateAsync({ name: name.trim(), description: @@ -153,6 +159,13 @@ export function CreateProjectDialog({ repoBranch: repoBranch.trim() || undefined, + port: port.trim() + ? Number(port) + : undefined, + sourceDir: + sourceDir.trim() || + undefined, + sourceType, }); if (stagedEnvs.length > 0) { @@ -203,10 +216,21 @@ export function CreateProjectDialog({ handleOpenChange(false); }, 1000); } catch (err: any) { + if (project) { + api.deleteProject(project.id).catch(() => { }); + } else { + // switch to a better approach + api.listProjects() + .then((all) => { + const orphan = all.find((p) => p.name === name.trim()); + if (orphan) api.deleteProject(orphan.id).catch(() => { }); + }) + .catch(() => { }); + } console.error(err); setErrorMessage( err.message || - "An unexpected error occurred during creation.", + "An unexpected error occurred during creation.", ); setSubmittingStatus("error"); } @@ -223,7 +247,7 @@ export function CreateProjectDialog({ New Project - + {submittingStatus !== "idle" ? (
{step > 1 && ( @@ -444,7 +485,7 @@ export function CreateProjectDialog({ onClick={() => setStep( step - - 1, + 1, ) } > @@ -472,13 +513,13 @@ export function CreateProjectDialog({ onClick={() => setStep( step + - 1, + 1, ) } className="bg-amber-500 hover:bg-amber-600 text-white font-medium h-9" disabled={ step === - 1 && + 1 && !name.trim() } > diff --git a/apps/web/src/components/project/create/StepBasics.tsx b/apps/web/src/components/project/create/StepBasics.tsx index 765982e..90dffd3 100644 --- a/apps/web/src/components/project/create/StepBasics.tsx +++ b/apps/web/src/components/project/create/StepBasics.tsx @@ -1,218 +1,651 @@ -import { useState } from 'react'; -import { Input } from '../../ui/input'; -import { Sliders, GitBranch, ExternalLink } from 'lucide-react'; -import { getGithubAuthUrl } from '../../../api/client'; -import { RepoPicker } from '../../github/RepoPicker'; -import type { GithubRepo } from '../../../types'; +import { useState } from "react"; +import { Input } from "../../ui/input"; +import { + GitBranch, + Globe, + Upload, + Container, + Box, + Lock, +} from "lucide-react"; +import { getGithubAuthUrl } from "../../../api/client"; +import { RepoPicker } from "../../github/RepoPicker"; +import type { GithubRepo } from "../../../types"; +import { cn } from "../../../lib/utils"; interface StepBasicsProps { - name: string; - setName: (v: string) => void; - description: string; - setDescription: (v: string) => void; - baseDomain: string; - setBaseDomain: (v: string) => void; - repoUrl: string; - setRepoUrl: (v: string) => void; - repoBranch: string; - setRepoBranch: (v: string) => void; - selectedRepo: GithubRepo | null; - setSelectedRepo: (v: GithubRepo | null) => void; - onGithubConnected: () => boolean; + name: string; + setName: (v: string) => void; + description: string; + setDescription: (v: string) => void; + baseDomain: string; + setBaseDomain: (v: string) => void; + repoUrl: string; + setRepoUrl: (v: string) => void; + repoBranch: string; + setRepoBranch: (v: string) => void; + sourceDir: string; + setSourceDir: (v: string) => void; + selectedRepo: GithubRepo | null; + setSelectedRepo: ( + v: GithubRepo | null, + ) => void; + onGithubConnected: () => boolean; + githubConfigured: boolean; + sourceType: string; + setSourceType: (v: string) => void; + port: string; + setPort: (v: string) => void; } +const sourceOptions = [ + { + value: "git", + label: "Git Repository", + icon: GitBranch, + desc: "Clone from GitHub or any Git URL", + }, + { + value: "upload", + label: "ZIP Upload", + icon: Upload, + desc: "Upload your source code as a ZIP archive", + }, + { + value: "compose", + label: "Docker Compose", + icon: Container, + desc: "Deploy from a docker-compose.yml file", + }, +]; + export function StepBasics({ - name, - setName, - description, - setDescription, - baseDomain, - setBaseDomain, - repoUrl, - setRepoUrl, - repoBranch, - setRepoBranch, - selectedRepo, - setSelectedRepo, - onGithubConnected, + name, + setName, + description, + setDescription, + baseDomain, + setBaseDomain, + repoUrl, + setRepoUrl, + repoBranch, + setRepoBranch, + sourceDir, + setSourceDir, + selectedRepo, + setSelectedRepo, + onGithubConnected, + githubConfigured, + sourceType, + setSourceType, + port, + setPort, }: StepBasicsProps) { - const [showManual, setShowManual] = useState(false); - const connected = onGithubConnected(); - - const handleConnectGithub = async () => { - try { - const { url } = await getGithubAuthUrl(); - window.location.href = url; - } catch (err) { - console.error("GitHub not configured", err); - } - }; - - const handleSelectRepo = (repo: GithubRepo | null) => { - setSelectedRepo(repo); - if (repo) { - setRepoUrl(repo.cloneUrl); - setRepoBranch(repo.defaultBranch); - } else { - setRepoUrl(""); - setRepoBranch(""); - } - }; - - return ( -
-
-

- - General Settings -

-
-
- - setName(e.target.value)} - autoFocus - /> -
-
- - setDescription(e.target.value)} - /> -
-
- - setBaseDomain(e.target.value)} - /> - - Leave empty to auto-assign a default hostname on localhost caddy ingress router. - -
-
-
- -
-

- - Git Repository -

- - {connected ? ( -
- { - setSelectedRepo(null); - setRepoUrl(""); - setRepoBranch(""); - }} - /> -
- -
- {showManual && ( -
-
- - { - setRepoUrl(e.target.value); - setSelectedRepo(null); - }} - /> -
-
- - setRepoBranch(e.target.value)} - /> -
-
- )} -
- ) : ( -
- - -
- -
- - {showManual && ( -
-
- - setRepoUrl(e.target.value)} - /> -
-
- - setRepoBranch(e.target.value)} - /> -
-
- )} -
- )} -
-
- ); + const [showManual, setShowManual] = + useState(false); + const connected = onGithubConnected(); + + const handleConnectGithub = async () => { + try { + const { url } = + await getGithubAuthUrl(); + window.location.href = url; + } catch (err: any) { + window.dispatchEvent( + new CustomEvent( + "opencode:notification", + { + detail: { + type: "error", + message: + err.message || + "GitHub integration is not configured. Add your credentials in Settings.", + }, + }, + ), + ); + } + }; + + const handleSelectRepo = ( + repo: GithubRepo | null, + ) => { + setSelectedRepo(repo); + if (repo) { + setRepoUrl(repo.cloneUrl); + setRepoBranch(repo.defaultBranch); + } else { + setRepoUrl(""); + setRepoBranch(""); + } + }; + + return ( +
+
+

+ + General Settings +

+
+
+ + + setName( + e.target + .value, + ) + } + autoFocus + /> +
+
+ + + setDescription( + e.target + .value, + ) + } + /> +
+
+ + + setBaseDomain( + e.target + .value, + ) + } + /> + + Leave empty to + auto-assign a default + hostname on localhost + caddy ingress router. + +
+
+
+ +
+

+ + Deployment Source +

+
+ {sourceOptions.map((opt) => { + const Icon = opt.icon; + const active = + sourceType === + opt.value; + const disabled = + opt.value === "git" && + !githubConfigured; + const isCompose = + opt.value === + "compose"; + return ( + + ); + })} +
+
+ + {sourceType === "git" && ( +
+

+ + Git Repository +

+ + {connected ? ( +
+ { + setSelectedRepo( + null, + ); + setRepoUrl( + "", + ); + setRepoBranch( + "", + ); + }} + /> +
+ +
+ {showManual && ( + + )} + {!showManual && ( + + )} + +
+ ) : ( +
+ + +
+ +
+ + {showManual && ( + + )} + +
+ )} +
+ )} + + {sourceType !== "git" && ( +
+

+ + Source Details +

+ {sourceType === "upload" && ( +

+ Upload your source + code as a ZIP archive + after creating the + project. +

+ )} + {sourceType === "compose" && ( +

+ Provide a + docker-compose.yml + file URL or upload + after project + creation. +

+ )} + {sourceType === "image" && ( +
+
+ + + setRepoUrl( + e + .target + .value, + ) + } + /> +
+
+ )} +
+ )} + +
+

+ + Container Port +

+
+
+ + + setPort( + e.target + .value, + ) + } + /> +
+
+
+ + Important: + {" "} + Set this to the port your + application listens on inside + the container. If the port + doesn't match, you'll get a{" "} + + 502 Bad Gateway + {" "} + error from the reverse proxy. +
+
+
+ ); +} + +function ManualGitInputs({ + repoUrl, + setRepoUrl, + repoBranch, + setRepoBranch, + showBranch, +}: { + repoUrl: string; + setRepoUrl: (v: string) => void; + repoBranch: string; + setRepoBranch: (v: string) => void; + showBranch: boolean; +}) { + return ( +
+
+ + { + setRepoUrl( + e.target.value, + ); + }} + /> +
+ {showBranch && ( +
+ + + setRepoBranch( + e.target.value, + ) + } + /> +
+ )} +
+ ); +} + +function SourceDirInput({ + sourceDir, + setSourceDir, +}: { + sourceDir: string; + setSourceDir: (v: string) => void; +}) { + return ( +
+ + + setSourceDir(e.target.value) + } + /> + + Leave empty if the app is at the + repository root. + +
+ ); +} + +function BranchInput({ + repoBranch, + setRepoBranch, +}: { + repoBranch: string; + setRepoBranch: (v: string) => void; +}) { + return ( +
+
+ + + setRepoBranch( + e.target.value, + ) + } + /> +
+
+ ); } diff --git a/apps/web/src/components/project/create/StepEnvironment.tsx b/apps/web/src/components/project/create/StepEnvironment.tsx index 347a063..9449504 100644 --- a/apps/web/src/components/project/create/StepEnvironment.tsx +++ b/apps/web/src/components/project/create/StepEnvironment.tsx @@ -119,7 +119,7 @@ export function StepEnvironment({ stagedEnvs, setStagedEnvs }: StepEnvironmentPr }; return ( -
+

diff --git a/apps/web/src/components/project/create/StepResources.tsx b/apps/web/src/components/project/create/StepResources.tsx index 76ca7b0..bc07294 100644 --- a/apps/web/src/components/project/create/StepResources.tsx +++ b/apps/web/src/components/project/create/StepResources.tsx @@ -36,13 +36,13 @@ export function StepResources({ setDbMemory }: StepResourcesProps) { return ( -
+

Cluster Resource Limits

-
+
(null); + const [cancelConfirmId, setCancelConfirmId] = useState< + string | null + >(null); const [selectedId, setSelectedId] = useState< string | null >(null); @@ -559,7 +562,7 @@ export function DeploymentsTab({ projectId }: DeploymentsTabProps) { e, ) => { e.stopPropagation(); - cancel.mutate( + setCancelConfirmId( dep.id, ); }} @@ -652,6 +655,31 @@ export function DeploymentsTab({ projectId }: DeploymentsTabProps) { /> )} + { 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); }}> diff --git a/apps/web/src/components/ui/alert.tsx b/apps/web/src/components/ui/alert.tsx new file mode 100644 index 0000000..64636e4 --- /dev/null +++ b/apps/web/src/components/ui/alert.tsx @@ -0,0 +1,60 @@ +import { forwardRef, type HTMLAttributes } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "../../lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + success: + "border-emerald-500/50 text-emerald-600 dark:text-emerald-400 dark:border-emerald-500/50 [&>svg]:text-emerald-600 dark:[&>svg]:text-emerald-400", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = forwardRef< + HTMLDivElement, + HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = forwardRef< + HTMLParagraphElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = forwardRef< + HTMLParagraphElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index dd59e75..259e045 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -90,4 +90,74 @@ .log-msg { word-break: break-all; +} + +/* Ko-fi overlay widget inside container */ +#kofi-widget-container { + display: block; + width: 100%; + height: 40px; +} + +#kofi-widget-container .floatingchat-container-wrap, +#kofi-widget-container .floatingchat-container-wrap-mobi { + position: static !important; + width: 100% !important; + max-width: none !important; + height: 100% !important; + opacity: 1 !important; + transform: none !important; + animation: none !important; + z-index: auto !important; + margin: 0 !important; + padding: 0 !important; +} + +#kofi-widget-container .floatingchat-container, +#kofi-widget-container .floatingchat-container-mobi { + position: static !important; + display: block !important; + /* Give the iframe a virtual width slightly wider than its default 195px + then scale it down so the full button text fits the sidebar card */ + width: 220px !important; + height: 100% !important; + opacity: 1 !important; + border: none !important; + z-index: auto !important; + margin: 0 !important; + padding: 0 !important; + transform: scale(0.9) !important; + transform-origin: left center !important; +} + +/* Show desktop button on large screens, mobile button on small */ +@media only screen and (max-device-width: 1000px) { + #kofi-widget-container .floatingchat-container-wrap { + display: none !important; + } + #kofi-widget-container .floatingchat-container-wrap-mobi { + display: block !important; + } +} +@media only screen and (min-device-width: 1001px) { + #kofi-widget-container .floatingchat-container-wrap { + display: block !important; + } + #kofi-widget-container .floatingchat-container-wrap-mobi { + display: none !important; + } +} + +/* Popup floats over the page at a comfortable height */ +.floating-chat-kofi-popup-iframe, +.floating-chat-kofi-popup-iframe-mobi { + position: fixed !important; + z-index: 999999 !important; +} + +.floating-chat-kofi-popup-iframe[style*="opacity:1"], +.floating-chat-kofi-popup-iframe[style*="opacity: 1"], +.floating-chat-kofi-popup-iframe-mobi[style*="opacity:1"], +.floating-chat-kofi-popup-iframe-mobi[style*="opacity: 1"] { + min-height: 650px !important; } \ No newline at end of file diff --git a/apps/web/src/routes/Dashboard.tsx b/apps/web/src/routes/Dashboard.tsx index 3ec3c77..ca35726 100644 --- a/apps/web/src/routes/Dashboard.tsx +++ b/apps/web/src/routes/Dashboard.tsx @@ -113,73 +113,63 @@ export function Dashboard() { {/* Left Side: Stats/Recent Previews */}
- - {/* Usage Metrics Panel */} -
+ {/* Usage Metrics Panel */} +

Node Allocation

-
-
-
- API Traffic / Capacity - {metricsLoading ? '...' : (metrics?.requestsTotal ?? 0)} reqs -
-
-
+
+ {/* API Traffic */} +
+
+
+ API Traffic +
+ {metricsLoading ? '...' : (metrics?.requestsTotal ?? 0)} + requests +
+
+
+ +
-
-
- Active Deployments - {metricsLoading ? '...' : (metrics?.activeDeployments ?? 0)} running -
-
-
+ {/* Active Deployments */} +
+
+
+ Active Deployments +
+ {metricsLoading ? '...' : (metrics?.activeDeployments ?? 0)} + running +
+
+
+ +
-
-
- Cluster Ingress Uptime - {metricsLoading ? '...' : formatUptime(metrics?.uptimeSeconds)} -
-
-
+ {/* Cluster Ingress Uptime */} +
+
+
+ Cluster Uptime +
+ {metricsLoading ? '...' : formatUptime(metrics?.uptimeSeconds)} +
+
+
+ +
- {/* Anomaly Alerts Pro Callout Box */} -
-
- -
-
- - Get Alerted For Anomalies -
-

- Automatically monitor container memory leaks and network request latency spikes. -

- -
- {/* Recent Previews List */}

diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts index 41382cb..7c889fa 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -19,6 +19,9 @@ export interface Project { baseDomain: string | null; cpuLimit: number | null; memoryLimitMb: number | null; + port: number | null; + sourceDir: string | null; + sourceType: string; createdAt: string; updatedAt: string; } From 54d2a2f3d523b0cff4736ebd4b6381a3b49813f9 Mon Sep 17 00:00:00 2001 From: lftobs Date: Mon, 15 Jun 2026 05:40:23 +0100 Subject: [PATCH 10/12] feat(api): add GitHub webhook management and instance telemetry - Add API endpoints to list, register, and delete GitHub webhooks for repositories - Implement telemetry tracking for instances via PostHog - Add instance identity generation during installation - Add duration display to deployments list in UI --- .github/workflows/release.yml | 4 + apps/api/src/api/github/index.ts | 76 +++++++++++++++++++ apps/api/src/api/projects/index.ts | 8 +- apps/api/src/index.ts | 1 - apps/api/src/orchestrator/pipeline.ts | 36 ++++++++- apps/api/src/utils/config.ts | 1 + apps/web/src/api/client.ts | 15 ++++ apps/web/src/components/github/RepoPicker.tsx | 74 +++++++++++++++++- .../project/create/CreateProjectDialog.tsx | 25 +++++- .../components/project/create/StepBasics.tsx | 63 +++++++++++++-- .../project/deployments/DeploymentsTab.tsx | 61 ++++++++++++++- scripts/install.sh | 49 ++++++++++++ 12 files changed, 389 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19c64a9..d6119e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,6 +78,10 @@ jobs: cp infra/monitoring/grafana/datasources/prometheus.yml "$TAR_DIR/infra/monitoring/grafana/datasources/" cd "$TAR_DIR" && tar -czf "../dequel-config-${VERSION}.tar.gz" . + - name: Inject PostHog key into install script + run: | + sed -i "s|__POSTHOG_API_KEY__|${{ secrets.POSTHOG_API_KEY }}|g" scripts/install.sh + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: diff --git a/apps/api/src/api/github/index.ts b/apps/api/src/api/github/index.ts index 6ca5039..9d45c9a 100644 --- a/apps/api/src/api/github/index.ts +++ b/apps/api/src/api/github/index.ts @@ -39,6 +39,25 @@ const fetchGitHub = async (path: string, token: string) => { return res.json(); }; +const fetchGitHubWithBody = async (path: string, token: string, method: string, body?: unknown) => { + const res = await fetch(`https://api.github.com${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "Content-Type": "application/json", + "User-Agent": "dequel", + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + const err = await res.text(); + throw new Error(`GitHub API error ${res.status}: ${err}`); + } + if (res.status === 204) return null; + return res.json(); +}; + export const githubRoutes = new Elysia({ prefix: "/github" }) .get("/integration", async () => { const integration = await getGithubIntegration(); @@ -163,6 +182,63 @@ export const githubRoutes = new Elysia({ prefix: "/github" }) })); }) + .get("/repos/:owner/:repo/hooks", async ({ request, set, params }) => { + const token = getSession(request.headers.get("cookie")); + if (!token) { + set.status = 401; + return { error: "Not authenticated" }; + } + const hooks = await fetchGitHub(`/repos/${params.owner}/${params.repo}/hooks`, token); + return Array.isArray(hooks) ? hooks.map((h: any) => ({ id: h.id, url: h.config.url, active: h.active, events: h.events })) : []; + }) + + .post("/repos/:owner/:repo/hook", async ({ request, set, params }) => { + const token = getSession(request.headers.get("cookie")); + if (!token) { + set.status = 401; + return { error: "Not authenticated" }; + } + const webhookUrl = `${config.publicUrl}/api/github/webhook`; + const integration = await getGithubIntegration(); + const secret = integration?.webhookSecret || config.githubWebhookSecret; + + const hooks = await fetchGitHub(`/repos/${params.owner}/${params.repo}/hooks`, token); + const existing = Array.isArray(hooks) ? hooks.find((h: any) => h.config?.url === webhookUrl) : null; + + if (existing) { + return { id: existing.id, created: false, url: webhookUrl }; + } + + const hook = await fetchGitHubWithBody(`/repos/${params.owner}/${params.repo}/hooks`, token, "POST", { + name: "web", + active: true, + events: ["push"], + config: { + url: webhookUrl, + content_type: "json", + secret, + insecure_ssl: "0", + }, + }); + return { id: hook.id, created: true, url: webhookUrl }; + }) + + .delete("/repos/:owner/:repo/hook", async ({ request, set, params }) => { + const token = getSession(request.headers.get("cookie")); + if (!token) { + set.status = 401; + return { error: "Not authenticated" }; + } + const webhookUrl = `${config.publicUrl}/api/github/webhook`; + const hooks = await fetchGitHub(`/repos/${params.owner}/${params.repo}/hooks`, token); + const existing = Array.isArray(hooks) ? hooks.find((h: any) => h.config?.url === webhookUrl) : null; + if (!existing) { + return { ok: true, removed: false }; + } + await fetchGitHubWithBody(`/repos/${params.owner}/${params.repo}/hooks/${existing.id}`, token, "DELETE"); + return { ok: true, removed: true }; + }) + .post("/disconnect", async ({ set, request }) => { const cookie = request.headers.get("cookie"); const match = cookie?.match(/github_session=([^;]+)/); diff --git a/apps/api/src/api/projects/index.ts b/apps/api/src/api/projects/index.ts index 4b2f226..d7f0885 100644 --- a/apps/api/src/api/projects/index.ts +++ b/apps/api/src/api/projects/index.ts @@ -3,11 +3,10 @@ import { join } from 'node:path'; import { Elysia } from "elysia"; import { createProject, - deleteProjectCascade, - getProjectById, listProjects, + getProjectById, updateProject, - listDomains, + deleteProject, } from "../../db/repo"; import { tryRun, reloadCaddy } from "../../orchestrator/runtime"; import { removeFromCaddyRoute } from "../../utils/domain-verifier"; @@ -34,7 +33,7 @@ export const projectsRoutes = new Elysia() set.status = 400; return { error: "name is required" }; } - return createProject({ + const project = await createProject({ name: body.name, description: body.description, baseDomain: body.baseDomain, @@ -46,6 +45,7 @@ export const projectsRoutes = new Elysia() sourceDir: body.sourceDir || null, sourceType: body.sourceType || "git", }); + return project; }, ) .patch( diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5a13897..3906e06 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -12,7 +12,6 @@ import { serverManager } from './servers/manager'; import { startGitWatcher } from './git/watcher'; import { startDomainPolling } from './utils/domain-verifier'; import { alertEvaluator } from './monitoring/evaluator'; - const bootstrap = async () => { await mkdir(dirname(config.databasePath), { recursive: true }); await mkdir(config.workspaceRoot, { recursive: true }); diff --git a/apps/api/src/orchestrator/pipeline.ts b/apps/api/src/orchestrator/pipeline.ts index ef7a3b8..34367ad 100644 --- a/apps/api/src/orchestrator/pipeline.ts +++ b/apps/api/src/orchestrator/pipeline.ts @@ -13,7 +13,7 @@ import { listEnvironmentVariablesForDeploy } from "../db/repo"; import { listVolumes } from "../db/repo"; import { logBus } from "./log-bus"; import { DeploymentQueue } from "./queue"; -import { buildWithRailpack } from "./railpack"; +import { buildWithRailpack, CancelledError } from "./railpack"; import { prepareSourceWorkspace, prepareUploadWorkspace, @@ -56,11 +56,19 @@ const emitLog = async ( export class PipelineOrchestrator { private queue: DeploymentQueue; private started = false; + private abortControllers = new Map(); constructor() { this.queue = new DeploymentQueue(); } + private async checkCancelled(deploymentId: string) { + const dep = await getDeploymentById(deploymentId); + if (dep?.status === "failed") { + throw new CancelledError(); + } + } + startWorker() { if (this.started) return; this.started = true; @@ -99,6 +107,12 @@ export class PipelineOrchestrator { ) return; + const controller = this.abortControllers.get(deploymentId); + if (controller) { + controller.abort(); + this.abortControllers.delete(deploymentId); + } + await Promise.all([ this.queue.remove(deploymentId), updateDeploymentStatus( @@ -269,6 +283,9 @@ export class PipelineOrchestrator { if (deployment.status === "failed") return true; + const controller = new AbortController(); + this.abortControllers.set(deploymentId, controller); + let workspacePath = ""; let uploadedArchivePath: string | null = null; @@ -343,6 +360,8 @@ export class PipelineOrchestrator { ); } + await this.checkCancelled(deploymentId); + await emitLog( deploymentId, "build", @@ -352,9 +371,8 @@ export class PipelineOrchestrator { deployment.projectId || deploymentId; const project = deployment.projectId ? await getProjectById(deployment.projectId) : null; - const buildDir = project?.sourceDir ? workspacePath + '/' + project.sourceDir.replace(/^\//, '') : workspacePath; await buildWithRailpack( - buildDir, + workspacePath, imageTag, async (line) => { await emitLog( @@ -363,7 +381,7 @@ export class PipelineOrchestrator { line, ); }, - { cacheKey }, + { cacheKey, sourceDir: project?.sourceDir, signal: controller.signal }, ); } else { await emitLog( @@ -373,6 +391,8 @@ export class PipelineOrchestrator { ); } + await this.checkCancelled(deploymentId); + await updateDeploymentStatus( deploymentId, "deploying", @@ -384,6 +404,8 @@ export class PipelineOrchestrator { "Starting container deployment", ); + await this.checkCancelled(deploymentId); + let envVars: | Record | undefined; @@ -456,6 +478,8 @@ export class PipelineOrchestrator { } } + await this.checkCancelled(deploymentId); + let projectName: string | undefined; let cpuLimit: | number @@ -555,6 +579,9 @@ export class PipelineOrchestrator { ); return true; } catch (error) { + if (error instanceof CancelledError) { + return true; + } const message = error instanceof Error ? error.message @@ -598,6 +625,7 @@ export class PipelineOrchestrator { } return false; } finally { + this.abortControllers.delete(deploymentId); if (workspacePath) await cleanupWorkspace( workspacePath, diff --git a/apps/api/src/utils/config.ts b/apps/api/src/utils/config.ts index cb7abb0..c6194ca 100644 --- a/apps/api/src/utils/config.ts +++ b/apps/api/src/utils/config.ts @@ -36,6 +36,7 @@ export const config = { smtpPass: withFile("SMTP_PASS", ""), smtpFrom: withFile("SMTP_FROM", "dequel@localhost"), alertEvalIntervalMs: withFile("ALERT_EVAL_INTERVAL_MS", "60000", Number), + publicUrl: withFile("PUBLIC_URL", "http://localhost"), githubClientId: withFile("GITHUB_CLIENT_ID", ""), githubClientSecret: withFile("GITHUB_CLIENT_SECRET", ""), githubAppName: withFile("GITHUB_APP_NAME", "Dequel"), diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index a630cfe..05e1c67 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -498,3 +498,18 @@ export const testSmtpSettings = () => apiFetch<{ ok: boolean } | { error: string }>("/settings/smtp/test", { method: "POST", }); + +// ─── GitHub Webhook ─────────────────────────────────────── + +export const getRepoHooks = (owner: string, repo: string) => + apiFetch>(`/github/repos/${owner}/${repo}/hooks`); + +export const registerRepoHook = (owner: string, repo: string) => + apiFetch<{ id: number; created: boolean; url: string }>(`/github/repos/${owner}/${repo}/hook`, { + method: "POST", + }); + +export const removeRepoHook = (owner: string, repo: string) => + apiFetch<{ ok: boolean; removed: boolean }>(`/github/repos/${owner}/${repo}/hook`, { + method: "DELETE", + }); diff --git a/apps/web/src/components/github/RepoPicker.tsx b/apps/web/src/components/github/RepoPicker.tsx index 347298f..02ac518 100644 --- a/apps/web/src/components/github/RepoPicker.tsx +++ b/apps/web/src/components/github/RepoPicker.tsx @@ -2,17 +2,18 @@ import { useState, useEffect } from "react"; import { getGithubRepos, disconnectGithub, + getRepoHooks, + registerRepoHook, + removeRepoHook, } from "../../api/client"; import type { GithubRepo } from "../../types"; import { Input } from "../ui/input"; import { Button } from "../ui/button"; import { Search, - X, GitFork, Lock, Globe, - ExternalLink, RefreshCw, } from "lucide-react"; import { cn } from "../../lib/utils"; @@ -34,6 +35,9 @@ export function RepoPicker({ const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); const [error, setError] = useState(""); + const [webhookActive, setWebhookActive] = useState(false); + const [webhookLoading, setWebhookLoading] = useState(false); + const [webhookChecked, setWebhookChecked] = useState(false); const fetchRepos = async () => { setLoading(true); @@ -54,6 +58,45 @@ export function RepoPicker({ fetchRepos(); }, []); + useEffect(() => { + if (!selected) { + setWebhookActive(false); + setWebhookChecked(false); + return; + } + const checkWebhook = async () => { + try { + const [owner, repo] = selected.fullName.split("/"); + const hooks = await getRepoHooks(owner, repo); + setWebhookActive(hooks.length > 0); + } catch { + setWebhookActive(false); + } finally { + setWebhookChecked(true); + } + }; + checkWebhook(); + }, [selected]); + + const toggleWebhook = async () => { + if (!selected) return; + setWebhookLoading(true); + try { + const [owner, repo] = selected.fullName.split("/"); + if (webhookActive) { + await removeRepoHook(owner, repo); + setWebhookActive(false); + } else { + await registerRepoHook(owner, repo); + setWebhookActive(true); + } + } catch { + setWebhookActive(false); + } finally { + setWebhookLoading(false); + } + }; + const filtered = repos.filter((r) => r.fullName .toLowerCase() @@ -99,6 +142,33 @@ export function RepoPicker({ Change

+ {webhookChecked && ( + + )}
); } diff --git a/apps/web/src/components/project/create/CreateProjectDialog.tsx b/apps/web/src/components/project/create/CreateProjectDialog.tsx index b00e9a4..c83dae1 100644 --- a/apps/web/src/components/project/create/CreateProjectDialog.tsx +++ b/apps/web/src/components/project/create/CreateProjectDialog.tsx @@ -60,6 +60,8 @@ export function CreateProjectDialog({ const [sourceType, setSourceType] = useState("git"); + const [zipFile, setZipFile] = + useState(null); const [cpuLimit, setCpuLimit] = useState(""); const [memoryLimitMb, setMemoryLimitMb] = useState(""); @@ -129,6 +131,7 @@ export function CreateProjectDialog({ setSelectedRepo(null); setSourceType("git"); setPort(""); + setZipFile(null); } }; @@ -138,6 +141,11 @@ export function CreateProjectDialog({ e.preventDefault(); if (!name.trim()) return; + if (step < 3) { + setStep((prev) => prev + 1); + return; + } + setSubmittingStatus("creating_project"); setErrorMessage(""); @@ -168,6 +176,15 @@ export function CreateProjectDialog({ sourceType, }); + if (zipFile && sourceType === "upload") { + setSubmittingStatus("creating_project"); + const form = new FormData(); + form.append("sourceType", "upload"); + form.append("projectId", project.id); + form.append("archive", zipFile); + await api.createDeployment(form); + } + if (stagedEnvs.length > 0) { setSubmittingStatus( "creating_envs", @@ -408,9 +425,11 @@ export function CreateProjectDialog({ setSourceType={ setSourceType } - port={port} - setPort={setPort} - /> + port={port} + setPort={setPort} + zipFile={zipFile} + setZipFile={setZipFile} + /> )} {step === 2 && ( diff --git a/apps/web/src/components/project/create/StepBasics.tsx b/apps/web/src/components/project/create/StepBasics.tsx index 90dffd3..278a1b3 100644 --- a/apps/web/src/components/project/create/StepBasics.tsx +++ b/apps/web/src/components/project/create/StepBasics.tsx @@ -36,6 +36,8 @@ interface StepBasicsProps { setSourceType: (v: string) => void; port: string; setPort: (v: string) => void; + zipFile: File | null; + setZipFile: (v: File | null) => void; } const sourceOptions = [ @@ -80,6 +82,8 @@ export function StepBasics({ setSourceType, port, setPort, + zipFile, + setZipFile, }: StepBasicsProps) { const [showManual, setShowManual] = useState(false); @@ -312,6 +316,7 @@ export function StepBasics({ />
{sourceType === "upload" && ( -

- Upload your source - code as a ZIP archive - after creating the - project. -

+
+
+ + + { + const file = e.target.files?.[0] || null; + setZipFile(file); + }} + /> + {zipFile && ( + + )} + + Upload your source code as a ZIP archive. It will be deployed automatically after project creation. + +
+
)} {sourceType === "compose" && (

diff --git a/apps/web/src/components/project/deployments/DeploymentsTab.tsx b/apps/web/src/components/project/deployments/DeploymentsTab.tsx index e854bf8..69bd4ee 100644 --- a/apps/web/src/components/project/deployments/DeploymentsTab.tsx +++ b/apps/web/src/components/project/deployments/DeploymentsTab.tsx @@ -37,6 +37,47 @@ function formatTimeAgo(dateStr: string) { 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}; +} + const PAGE_SIZE = 5; function depDisplayName(projectName: string | undefined, depId: string) { @@ -461,6 +502,9 @@ export function DeploymentsTab({ projectId }: DeploymentsTabProps) { Branch + + Duration + Age @@ -528,6 +572,9 @@ export function DeploymentsTab({ projectId }: DeploymentsTabProps) { )} + + + {formatTimeAgo( dep.createdAt, @@ -734,10 +781,16 @@ function DeploymentLogs({ return ( - - - Build Logs —{" "} - {deployment.id.slice(0, 8)} + + + + Build Logs —{" "} + {deployment.id.slice(0, 8)} + + + Duration: + + diff --git a/scripts/install.sh b/scripts/install.sh index 14dde29..4eba720 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -58,6 +58,50 @@ setup_directories() { info "Installing to: $INSTALL_DIR" } +sample_machine_id() { + if [ -f /etc/machine-id ]; then + cat /etc/machine-id + elif command -v hostname &>/dev/null; then + hostname + else + uuidgen 2>/dev/null || echo "unknown" + fi +} + +posthog_track_install() { + local version="$1" + local install_marker="$INSTALL_DIR/.install-id" + if [ -f "$install_marker" ]; then + return + fi + + local mid + mid="$(sample_machine_id)" + local distinct_id="install-$(printf '%s' "$mid" | sha256sum 2>/dev/null | cut -c1-32)" + if [ -z "$distinct_id" ]; then + distinct_id="install-$(uuidgen 2>/dev/null || date +%s)" + fi + + local key="${POSTHOG_API_KEY:-__POSTHOG_API_KEY__}" + # If the placeholder wasn't replaced at release time, skip silently + if echo "$key" | grep -q "^__"; then + echo "$distinct_id" > "$install_marker" + return + fi + if [ -z "$key" ]; then + echo "$distinct_id" > "$install_marker" + return + fi + + # Fire and forget — one-shot at install time, never again + curl -fsSL -X POST "https://us.i.posthog.com/capture" \ + -H "Content-Type: application/json" \ + -d "{\"api_key\":\"$key\",\"event\":\"dequel_installed\",\"distinct_id\":\"$distinct_id\",\"properties\":{\"version\":\"$version\"}}" \ + >/dev/null 2>&1 || true + + echo "$distinct_id" > "$install_marker" +} + resolve_base_url() { header "Downloading configuration" TAG="" @@ -189,6 +233,11 @@ main() { prompt_config pull_images install_cli + + local version="${TAG#v}" + [ -z "$version" ] && version="latest" + posthog_track_install "$version" + print_summary } From f6708b42ab03a6eeba40fe6c606e12da2672263b Mon Sep 17 00:00:00 2001 From: lftobs Date: Mon, 15 Jun 2026 05:40:55 +0100 Subject: [PATCH 11/12] feat(api): add dynamic railpack.json generation and abort support Implement dynamic railpack.json generation to support monorepos, caching, and language-specific build steps (Node.js, Rust, Go). Add AbortSignal support to build processes to allow for graceful cancellation. --- apps/api/src/orchestrator/railpack.ts | 616 ++++++++++++++++++++++++-- 1 file changed, 587 insertions(+), 29 deletions(-) diff --git a/apps/api/src/orchestrator/railpack.ts b/apps/api/src/orchestrator/railpack.ts index ac199bc..2f15b40 100644 --- a/apps/api/src/orchestrator/railpack.ts +++ b/apps/api/src/orchestrator/railpack.ts @@ -1,4 +1,6 @@ import { spawn } from "node:child_process"; +import { join } from "node:path"; +import { readdir } from "node:fs/promises"; import { dockerBin } from "../utils/docker-bin"; export interface RailpackBuildResult { @@ -10,44 +12,89 @@ const buildTimeoutMs = Number( "600000", ); -const cleanBuildkitLine = (raw: string): string => { +const cleanBuildkitLine = ( + raw: string, +): string => { const line = raw.trim(); if (!line) return ""; // Drop Docker layer download/upload/extract progress lines (sha256 digests with byte counts) - if (/^(sha256:)?[0-9a-f]{40,}\s+[\d.]+\s*(B|KB|MB|GB)\s*\/\s*[\d.]+\s*(B|KB|MB|GB)/i.test(line)) return ""; + if ( + /^(sha256:)?[0-9a-f]{40,}\s+[\d.]+\s*(B|KB|MB|GB)\s*\/\s*[\d.]+\s*(B|KB|MB|GB)/i.test( + line, + ) + ) + return ""; // Drop resolver/registry metadata lines - if (/^(resolve|resolving)\s+(docker|image)/i.test(line)) return ""; - if (/^(sha256:)?[0-9a-f]{40,}\s*(done|already exists|pulling|download|extract|waiting|verifying|comparing|preparing)?$/i.test(line)) return ""; + if ( + /^(resolve|resolving)\s+(docker|image)/i.test( + line, + ) + ) + return ""; + if ( + /^(sha256:)?[0-9a-f]{40,}\s*(done|already exists|pulling|download|extract|waiting|verifying|comparing|preparing)?$/i.test( + line, + ) + ) + return ""; // Drop generic transfer/extract progress lines (e.g. "extracting sha256:...") - if (/^(extracting|downloading|pushing|waiting|pulling fs layer|verifying checksum|download complete|pull complete|already exists)/i.test(line)) return ""; + if ( + /^(extracting|downloading|pushing|waiting|pulling fs layer|verifying checksum|download complete|pull complete|already exists)/i.test( + line, + ) + ) + return ""; // Drop lines that are purely byte progress like "14.68MB / 48.50MB" - if (/^[\d.]+\s*(B|KB|MB|GB)\s*\/\s*[\d.]+\s*(B|KB|MB|GB)\s*$/i.test(line)) return ""; + if ( + /^[\d.]+\s*(B|KB|MB|GB)\s*\/\s*[\d.]+\s*(B|KB|MB|GB)\s*$/i.test( + line, + ) + ) + return ""; // Strip trailing timestamps like "5.4s done" from DONE lines - const noTime = line.replace(/\s+[\d.]+s?\s*(done|error|failed|canceled|DONE|ERROR|FAILED|CANCELED)?\s*$/i, ""); + const noTime = line.replace( + /\s+[\d.]+s?\s*(done|error|failed|canceled|DONE|ERROR|FAILED|CANCELED)?\s*$/i, + "", + ); if (!noTime) return ""; // Strip all #N prefixes and whitespace - const stripped = noTime.replace(/^#\d+\s*/i, "").trim(); + const stripped = noTime + .replace(/^#\d+\s*/i, "") + .trim(); if (!stripped) return ""; // Strip trailing time from DONE lines that are still there - const clean = stripped.replace(/\s+[\d.]+s?\s*(done|error|failed|canceled|DONE|ERROR|FAILED|CANCELED)?\s*$/i, "").trim(); + const clean = stripped + .replace( + /\s+[\d.]+s?\s*(done|error|failed|canceled|DONE|ERROR|FAILED|CANCELED)?\s*$/i, + "", + ) + .trim(); if (!clean) return ""; // Drop pure step-header lines like "#N" alone if (/^#\d+$/i.test(clean)) return ""; // Drop sha256 lines that survived earlier filters (after #N stripping) - if (/^(sha256:)?[0-9a-f]{40,}/i.test(clean)) return ""; + if (/^(sha256:)?[0-9a-f]{40,}/i.test(clean)) + return ""; return clean; }; +export class CancelledError extends Error { + constructor() { + super("Deployment cancelled"); + this.name = "CancelledError"; + } +} + const spawnAsync = ( cmd: string, args: string[], @@ -57,6 +104,7 @@ const spawnAsync = ( timeoutMs?: number; onTimeout?: () => void; onLine?: (line: string) => void; + signal?: AbortSignal; }, ): Promise<{ stdout: string; @@ -73,6 +121,32 @@ const spawnAsync = ( let stderr = ""; let settled = false; + const onAbort = () => { + if (settled) return; + child.kill("SIGTERM"); + setTimeout(() => { + if (!settled) { + child.kill("SIGKILL"); + } + }, 5000); + finish( + undefined, + new CancelledError(), + ); + }; + + if (opts?.signal) { + if (opts.signal.aborted) { + onAbort(); + return; + } + opts.signal.addEventListener( + "abort", + onAbort, + { once: true }, + ); + } + const timeout = opts?.timeoutMs && opts.timeoutMs > 0 ? setTimeout(() => { @@ -81,7 +155,9 @@ const spawnAsync = ( child.kill("SIGTERM"); setTimeout(() => { if (!settled) { - child.kill("SIGKILL"); + child.kill( + "SIGKILL", + ); } }, 5000); finish( @@ -104,6 +180,12 @@ const spawnAsync = ( if (settled) return; settled = true; if (timeout) clearTimeout(timeout); + if (opts?.signal) { + opts.signal.removeEventListener( + "abort", + onAbort, + ); + } if (error) { reject(error); return; @@ -188,11 +270,459 @@ const ensureBuilder = async (): Promise => { } }; +const rewriteLocalhostBinding = async ( + dir: string, + onLog: (line: string) => Promise, +): Promise => { + const ignoreDirs = new Set([ + "node_modules", + "target", + ".git", + ".cargo", + "dist", + "build", + ".next", + ".svelte-kit", + "vendor", + ]); + const allowedExts = new Set([ + "rs", + "go", + "js", + "ts", + "py", + "java", + "json", + "yaml", + "yml", + "toml", + ]); + + try { + const entries = await readdir(dir, { + withFileTypes: true, + }); + for (const entry of entries) { + const fullPath = join( + dir, + entry.name, + ); + if (entry.isDirectory()) { + if (ignoreDirs.has(entry.name)) { + continue; + } + await rewriteLocalhostBinding( + fullPath, + onLog, + ); + } else if (entry.isFile()) { + const ext = entry.name + .split(".") + .pop(); + if (ext && allowedExts.has(ext)) { + try { + let content = + await Bun.file( + fullPath, + ).text(); + if ( + content.includes( + "127.0.0.1", + ) + ) { + content = + content.replaceAll( + "127.0.0.1", + "0.0.0.0", + ); + await Bun.write( + fullPath, + content, + ); + await onLog( + `Auto-rewrote 127.0.0.1 to 0.0.0.0 in ${entry.name} for container compatibility.`, + ); + } + } catch { + // Ignore read errors + } + } + } + } + } catch { + // Ignore readdir/directory access errors + } +}; + +const generateDynamicRailpackJson = async ( + workspace: string, + sourceDir: string | null, + onLog: (line: string) => Promise, +): Promise => { + const cleanSourceDir = sourceDir + ? sourceDir.replace(/^\//, "") + : ""; + const buildDir = cleanSourceDir + ? join(workspace, cleanSourceDir) + : workspace; + await rewriteLocalhostBinding( + buildDir, + onLog, + ); + const configPath = join( + workspace, + "railpack.json", + ); + + // Default configuration template + const config: Record = { + caches: {}, + steps: {}, + deploy: {}, + }; + + let configured = false; + + const hasPackageJson = await Bun.file( + join(buildDir, "package.json"), + ).exists(); + if (hasPackageJson) { + try { + const packageJson = await Bun.file( + join(buildDir, "package.json"), + ).json(); + const scripts = + packageJson.scripts || {}; + + let pm = "npm"; + if ( + await Bun.file( + join( + workspace, + "pnpm-lock.yaml", + ), + ).exists() + ) { + pm = "pnpm"; + } else if ( + await Bun.file( + join(workspace, "yarn.lock"), + ).exists() + ) { + pm = "yarn"; + } else if ( + await Bun.file( + join(workspace, "bun.lockb"), + ).exists() + ) { + pm = "bun"; + } + + await onLog( + `Configuring Node.js project using ${pm}`, + ); + + if (scripts.build) { + config.steps.build = { + commands: [ + cleanSourceDir + ? `cd ${cleanSourceDir} && ${pm} run build` + : `${pm} run build`, + ], + }; + } + + if (scripts.start) { + config.deploy.startCommand = + cleanSourceDir + ? `cd ${cleanSourceDir} && ${pm} run start` + : `${pm} run start`; + } else { + config.deploy.startCommand = + cleanSourceDir + ? `cd ${cleanSourceDir} && node dist/index.js` + : "node dist/index.js"; + } + + configured = true; + } catch (err) { + await onLog( + `Error parsing package.json: ${err}`, + ); + } + } + + const hasCargoToml = await Bun.file( + join(buildDir, "Cargo.toml"), + ).exists(); + if (hasCargoToml && !configured) { + try { + let cargoContent = await Bun.file( + join(buildDir, "Cargo.toml"), + ).text(); + + let hasWorkspaceRoot = false; + let currentDir = buildDir; + while ( + currentDir.startsWith(workspace) + ) { + const parentCargo = join( + currentDir, + "Cargo.toml", + ); + if ( + currentDir !== buildDir && + (await Bun.file( + parentCargo, + ).exists()) + ) { + const content = + await Bun.file( + parentCargo, + ).text(); + if ( + content.includes( + "[workspace]", + ) + ) { + hasWorkspaceRoot = true; + break; + } + } + const nextDir = join( + currentDir, + "..", + ); + if ( + nextDir === currentDir || + !nextDir.startsWith(workspace) + ) + break; + currentDir = nextDir; + } + + if ( + !hasWorkspaceRoot && + (cargoContent.includes( + ".workspace = true", + ) || + cargoContent.includes( + "workspace = true", + )) + ) { + await onLog( + `Detected workspace inheritance in Cargo.toml without workspace root. Resolving workspace variables...`, + ); + const commonDeps: Record< + string, + string + > = { + "actix-web": '"4"', + "actix-files": '"0.6"', + "actix-rt": '"2.9"', + serde: '{"version": "1", "features": ["derive"]}', + serde_json: '"1"', + tokio: '{"version": "1", "features": ["full"]}', + futures: '"0.3"', + log: '"0.4"', + env_logger: '"0.11"', + uuid: '{"version": "1", "features": ["v4"]}', + chrono: '"0.4"', + reqwest: + '{"version": "0.12", "features": ["json"]}', + anyhow: '"1"', + thiserror: '"1"', + }; + + cargoContent = + cargoContent.replace( + /edition\.workspace\s*=\s*true/g, + 'edition = "2021"', + ); + cargoContent = + cargoContent.replace( + /rust-version\.workspace\s*=\s*true/g, + 'rust-version = "1.89"', + ); + cargoContent = + cargoContent.replace( + /version\.workspace\s*=\s*true/g, + 'version = "0.1.0"', + ); + + const lines = + cargoContent.split("\n"); + for ( + let i = 0; + i < lines.length; + i++ + ) { + const line = lines[i]; + const matchSimple = + line.match( + /^(\s*)([a-zA-Z0-9_-]+)\.workspace\s*=\s*true/, + ); + if (matchSimple) { + const indent = + matchSimple[1]; + const name = + matchSimple[2]; + const ver = + commonDeps[name] || + '"*"'; + lines[i] = + `${indent}${name} = ${ver}`; + continue; + } + const matchComplex = + line.match( + /^(\s*)([a-zA-Z0-9_-]+)\s*=\s*\{\s*workspace\s*=\s*true\s*,?\s*(.*)\}/, + ); + if (matchComplex) { + const indent = + matchComplex[1]; + const name = + matchComplex[2]; + const rest = + matchComplex[3].trim(); + const restComma = rest + ? `, ${rest}` + : ""; + lines[i] = + `${indent}${name} = { version = "*"${restComma} }`; + continue; + } + } + cargoContent = lines.join("\n"); + await Bun.write( + join(buildDir, "Cargo.toml"), + cargoContent, + ); + await onLog( + `Wrote standalone Cargo.toml to resolve workspace inheritance.`, + ); + } + + const match = cargoContent.match( + /\[package\][^]*?name\s*=\s*"([^"]+)"/, + ); + const pkgName = match + ? match[1] + : null; + + if (pkgName) { + await onLog( + `Configuring Rust project: ${pkgName}`, + ); + config.caches = { + cargo_registry: { + directory: + "/root/.cargo/registry", + type: "shared", + }, + cargo_git: { + directory: + "/root/.cargo/git", + type: "shared", + }, + cargo_target: { + directory: "target", + type: "shared", + }, + }; + config.steps.build = { + commands: [ + `cargo build --release -p ${pkgName}`, + "mkdir -p bin", + `cp target/release/${pkgName} bin/`, + ], + caches: [ + "cargo_registry", + "cargo_git", + "cargo_target", + ], + }; + config.deploy.startCommand = `./bin/${pkgName}`; + configured = true; + } + } catch (err) { + await onLog( + `Error parsing Cargo.toml: ${err}`, + ); + } + } + + let hasGoMod = false; + let currentDir = buildDir; + while (currentDir.startsWith(workspace)) { + if ( + await Bun.file( + join(currentDir, "go.mod"), + ).exists() + ) { + hasGoMod = true; + break; + } + const nextDir = join(currentDir, ".."); + if ( + nextDir === currentDir || + !nextDir.startsWith(workspace) + ) + break; + currentDir = nextDir; + } + if (hasGoMod && !configured) { + await onLog(`Configuring Go project`); + config.caches = { + go_build: { + directory: + "/root/.cache/go-build", + type: "shared", + }, + go_mod: { + directory: "/go/pkg/mod", + type: "shared", + }, + }; + config.steps.build = { + commands: [ + cleanSourceDir + ? `cd ${cleanSourceDir} && go build -o bin/app .` + : "go build -o bin/app .", + ], + caches: ["go_build", "go_mod"], + }; + config.deploy.startCommand = + cleanSourceDir + ? `cd ${cleanSourceDir} && ./bin/app` + : "./bin/app"; + configured = true; + } + + if (!configured) { + await onLog( + `Relying on default Railpack language auto-detection`, + ); + } + + await Bun.write( + configPath, + JSON.stringify(config, null, 2), + ); + await onLog( + `Wrote dynamic railpack.json for caching and monorepo resolution`, + ); +}; + export const buildWithRailpack = async ( workspace: string, imageTag: string, onLog: (line: string) => Promise, - opts?: { cacheKey?: string }, + opts?: { + cacheKey?: string; + sourceDir?: string | null; + signal?: AbortSignal; + }, ): Promise => { await onLog( `Starting Railpack CLI build for image: ${imageTag}`, @@ -205,23 +735,45 @@ export const buildWithRailpack = async ( opts?.cacheKey ?? imageTag .split(":")[0] - .replace(/[^a-zA-Z0-9_-]/g, "-") - .split("-") - .slice(0, 2) - .join("-"); // Fallback to 'dequel-default' or similar if imageTag starts with 'dequel-' + .replace(/-[0-9a-f]{8}$/i, "") // Strip unique deployment short ID suffix + .replace(/[^a-zA-Z0-9_-]/g, "-"); + + const cleanSourceDir = opts?.sourceDir + ? opts.sourceDir.replace(/^\//, "") + : null; + await onLog( + `Generating dynamic railpack.json for caching and monorepo resolution...`, + ); + await generateDynamicRailpackJson( + workspace, + cleanSourceDir, + onLog, + ); + + const args = [ + "build", + "--name", + imageTag, + "--progress", + "plain", + "--cache-key", + cacheKey, + "--env", + "CARGO_HTTP_MULTIPLEXING=false", + "--env", + "CARGO_HTTP_TIMEOUT=120", + "--env", + "CARGO_NET_GIT_FETCH_WITH_CLI=true", + "--env", + "RUSTUP_AUTO_SELF_UPDATE=off", + "--env", + "NPM_CONFIG_TIMEOUT=120000", + workspace, + ]; const build = await spawnAsync( "railpack", - [ - "build", - "--name", - imageTag, - "--progress", - "plain", - "--cache-key", - cacheKey, - workspace, - ], + args, { env: { ...process.env, @@ -238,11 +790,13 @@ export const buildWithRailpack = async ( ); }, onLine: (line) => { - const cleaned = cleanBuildkitLine(line); + const cleaned = + cleanBuildkitLine(line); if (cleaned) { void onLog(cleaned); } }, + signal: opts?.signal, }, ); @@ -253,8 +807,12 @@ export const buildWithRailpack = async ( } if ( - build.stdout.includes("No start command detected") || - build.stderr.includes("No start command detected") + build.stdout.includes( + "No start command detected", + ) || + build.stderr.includes( + "No start command detected", + ) ) { throw new Error( "Railpack build failed: No start command detected. Specify a start script in your package.json, a main field, or an index.ts/js file.", From 722c574a8032fcf80ecb99fda274536e5cefe5e3 Mon Sep 17 00:00:00 2001 From: lftobs Date: Mon, 15 Jun 2026 05:48:56 +0100 Subject: [PATCH 12/12] chore: remove PostHog analytics from install script --- .github/workflows/release.yml | 4 --- scripts/install.sh | 49 ----------------------------------- 2 files changed, 53 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6119e2..19c64a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,10 +78,6 @@ jobs: cp infra/monitoring/grafana/datasources/prometheus.yml "$TAR_DIR/infra/monitoring/grafana/datasources/" cd "$TAR_DIR" && tar -czf "../dequel-config-${VERSION}.tar.gz" . - - name: Inject PostHog key into install script - run: | - sed -i "s|__POSTHOG_API_KEY__|${{ secrets.POSTHOG_API_KEY }}|g" scripts/install.sh - - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: diff --git a/scripts/install.sh b/scripts/install.sh index 4eba720..14dde29 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -58,50 +58,6 @@ setup_directories() { info "Installing to: $INSTALL_DIR" } -sample_machine_id() { - if [ -f /etc/machine-id ]; then - cat /etc/machine-id - elif command -v hostname &>/dev/null; then - hostname - else - uuidgen 2>/dev/null || echo "unknown" - fi -} - -posthog_track_install() { - local version="$1" - local install_marker="$INSTALL_DIR/.install-id" - if [ -f "$install_marker" ]; then - return - fi - - local mid - mid="$(sample_machine_id)" - local distinct_id="install-$(printf '%s' "$mid" | sha256sum 2>/dev/null | cut -c1-32)" - if [ -z "$distinct_id" ]; then - distinct_id="install-$(uuidgen 2>/dev/null || date +%s)" - fi - - local key="${POSTHOG_API_KEY:-__POSTHOG_API_KEY__}" - # If the placeholder wasn't replaced at release time, skip silently - if echo "$key" | grep -q "^__"; then - echo "$distinct_id" > "$install_marker" - return - fi - if [ -z "$key" ]; then - echo "$distinct_id" > "$install_marker" - return - fi - - # Fire and forget — one-shot at install time, never again - curl -fsSL -X POST "https://us.i.posthog.com/capture" \ - -H "Content-Type: application/json" \ - -d "{\"api_key\":\"$key\",\"event\":\"dequel_installed\",\"distinct_id\":\"$distinct_id\",\"properties\":{\"version\":\"$version\"}}" \ - >/dev/null 2>&1 || true - - echo "$distinct_id" > "$install_marker" -} - resolve_base_url() { header "Downloading configuration" TAG="" @@ -233,11 +189,6 @@ main() { prompt_config pull_images install_cli - - local version="${TAG#v}" - [ -z "$version" ] && version="latest" - posthog_track_install "$version" - print_summary }