mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6949c01464 | ||
|
|
17e429b86b | ||
|
|
e881747388 | ||
|
|
f9ae5b962c | ||
|
|
15b5b0e6e7 | ||
|
|
32b52d121f | ||
|
|
d968eb2315 | ||
|
|
38e0bc1802 | ||
|
|
fe08f8d299 | ||
|
|
ad797a49c9 | ||
|
|
6661357963 | ||
|
|
7fc6ed1215 | ||
|
|
d9bfd8a2fd | ||
|
|
a23fa2a0cc | ||
|
|
1425618c92 | ||
|
|
894192bf9b | ||
|
|
e825eb0d7c | ||
|
|
5b0a8e0759 | ||
|
|
e57f0470eb | ||
|
|
117b469fae | ||
|
|
2b38406a63 | ||
|
|
84b102a2c6 | ||
|
|
9a2aa7267b | ||
|
|
a99f2e461e | ||
|
|
7c19af307e | ||
|
|
d48d9c293a | ||
|
|
0844fab6bf | ||
|
|
8b6214a0f5 | ||
|
|
8f5789bf93 | ||
|
|
3bf44dbcd5 | ||
|
|
b7e2e93261 | ||
|
|
04c3b99531 | ||
|
|
dbdf6733d1 | ||
|
|
ff356230fe | ||
|
|
c16b32f56a | ||
|
|
c3959d13e2 | ||
|
|
060038cb6f | ||
|
|
9ba2bd06c5 | ||
|
|
254d896802 | ||
|
|
3f6e506445 | ||
|
|
a0141ae552 | ||
|
|
ec1555cbe4 | ||
|
|
430370dbc7 |
No files matched your search
+38
-6
@@ -1,4 +1,5 @@
|
||||
# Local development env file. Use .env.prod for production.
|
||||
# Local development env file. install.sh writes ENVIRONMENT=production for
|
||||
# self-hosted installs - keep development here only for the checkout workflow.
|
||||
|
||||
# Domain Configuration
|
||||
DOMAIN=localhost
|
||||
@@ -11,6 +12,10 @@ API_PORT=8082
|
||||
POSTGRES_PORT=5432
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Image tag to pull from GHCR (e.g. latest, 0.1.1). Use CONTAINR_BUILD=1
|
||||
# with install.sh to build from source instead.
|
||||
CONTAINR_VERSION=latest
|
||||
|
||||
# Database Configuration
|
||||
POSTGRES_DB=containr
|
||||
POSTGRES_USER=containr_user
|
||||
@@ -24,9 +29,12 @@ AUTO_MIGRATE=true
|
||||
MIGRATION_LOCK_TIMEOUT=2m
|
||||
SEED_DATA_ON_START=false
|
||||
|
||||
# Redis Configuration
|
||||
# Dragonfly Configuration (Redis-compatible cache - REDIS_* names kept for compatibility)
|
||||
REDIS_PASSWORD=your_secure_redis_password
|
||||
REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0
|
||||
# DRAGONFLY_VERSION=latest
|
||||
# DRAGONFLY_THREADS=1
|
||||
# DRAGONFLY_MAXMEMORY=256mb
|
||||
|
||||
# Application Configuration
|
||||
# In production this must be a strong value with at least 32 characters.
|
||||
@@ -36,8 +44,11 @@ CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
|
||||
# Optional rotation list (comma-separated). When set, this takes precedence.
|
||||
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:8080
|
||||
VITE_API_URL=http://localhost:8082
|
||||
VITE_AUTH_URL=http://localhost:8082/api/auth
|
||||
# Optional build-time API URL overrides. Leave empty for the default
|
||||
# same-origin setup - the frontend nginx proxies /api to the backend, which
|
||||
# works from any host or IP without rebuilding the image.
|
||||
# VITE_API_URL=https://api.example.com
|
||||
# VITE_AUTH_URL=https://api.example.com/api/auth
|
||||
AUTH_PORT=3001
|
||||
# In production this must be true.
|
||||
COOKIE_SECURE=false
|
||||
@@ -50,7 +61,15 @@ BETTER_AUTH_AUTO_MIGRATE=true
|
||||
BETTER_AUTH_INTERNAL_TOKEN=PLACEHOLDER_INTERNAL_AUTH_TOKEN
|
||||
BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session
|
||||
BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001
|
||||
# Extra trusted origins (comma-separated). Same-origin traffic is trusted
|
||||
# automatically, so this only needs entries for cross-origin callers.
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8082
|
||||
|
||||
# Platform owner bootstrap — creates/promotes the admin account on startup.
|
||||
# Keep the real values in a local untracked file, never in committed env files.
|
||||
ADMIN_EMAIL=[email protected]
|
||||
ADMIN_PASSWORD=
|
||||
ADMIN_NAME=Platform Admin
|
||||
# Optional explicit auth DB settings (recommended when password contains URL special chars)
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
@@ -60,7 +79,9 @@ DB_PASSWORD=your_secure_postgres_password
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
BACKEND_URL=http://localhost:8082
|
||||
|
||||
# OAuth (user auth)
|
||||
# OAuth (user auth) - GitHub/Google sign-in is reserved for the managed
|
||||
# offering. Self-hosted installs use email/password only; PLACEHOLDER_*
|
||||
# values are treated as unset and the buttons stay hidden.
|
||||
GITHUB_CLIENT_ID=PLACEHOLDER_GITHUB_OAUTH_CLIENT_ID
|
||||
GITHUB_CLIENT_SECRET=PLACEHOLDER_GITHUB_OAUTH_CLIENT_SECRET
|
||||
GITLAB_CLIENT_ID=PLACEHOLDER_GITLAB_CLIENT_ID
|
||||
@@ -105,10 +126,21 @@ TRAEFIK_AUTH=admin:$$apr1$$b8mh8c8v$$KkR8hQZQZQZQZQZQZQZQZ/
|
||||
# Development convenience: true in local dev, false in production
|
||||
TRAEFIK_API_INSECURE=true
|
||||
|
||||
# Optional: Cloudflare Tunnel (alternative to domain)
|
||||
# Optional: Cloudflare Tunnel (alternative to domain). Can also be set in-app
|
||||
# under Settings > Platform (in-app value overrides this).
|
||||
# Get token from: https://dash.cloudflare.com/argotunnel
|
||||
CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here
|
||||
|
||||
# Optional: reopen public registration after the owner account exists.
|
||||
# Manageable in-app under Settings > Platform (in-app value overrides this).
|
||||
# SIGNUP_ENABLED=false
|
||||
|
||||
# Docker socket gid AS SEEN INSIDE containers - lets the backend manage
|
||||
# containers (deployments, cloudflared sidecar). install.sh detects it with a
|
||||
# throwaway container. Modern Docker maps the socket to root: use 0.
|
||||
# Older daemons expose the host docker gid: stat -c %g /var/run/docker.sock
|
||||
DOCKER_GID=0
|
||||
|
||||
# Optional: Custom Docker Registry
|
||||
# DOCKER_REGISTRY=your-registry.com
|
||||
# DOCKER_USERNAME=your_username
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
github: Dvorinka
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
|
||||
BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32)
|
||||
export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN
|
||||
docker compose -f infra/docker-compose.yml up -d --build postgres redis backend frontend traefik
|
||||
docker compose -f infra/docker-compose.yml up -d --build postgres dragonfly backend frontend traefik
|
||||
|
||||
- name: Wait for backend health
|
||||
run: |
|
||||
@@ -222,3 +222,96 @@ jobs:
|
||||
ignore-unfixed: true
|
||||
vuln-type: os,library
|
||||
severity: CRITICAL,HIGH
|
||||
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker]
|
||||
if: github.event_name != 'pull_request'
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Conventional-commit bump. Tags pushed with GITHUB_TOKEN do not
|
||||
# retrigger this workflow, so images are retagged below instead.
|
||||
- name: Bump version and push tag
|
||||
if: github.ref == 'refs/heads/main'
|
||||
id: tag_version
|
||||
uses: mathieudutour/[email protected]
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
default_bump: false
|
||||
|
||||
- name: Resolve release tag
|
||||
id: release
|
||||
run: |
|
||||
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
else
|
||||
tag="${{ steps.tag_version.outputs.new_tag }}"
|
||||
fi
|
||||
if [ -z "$tag" ]; then
|
||||
echo "No releasable commits since last tag - skipping release."
|
||||
fi
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Go
|
||||
if: steps.release.outputs.tag != ''
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: app/backend/go.mod
|
||||
cache: true
|
||||
|
||||
- name: Build CLI binaries
|
||||
if: steps.release.outputs.tag != ''
|
||||
working-directory: app/backend
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.tag }}
|
||||
run: bash ../../scripts/build-cli.sh
|
||||
|
||||
- name: Checksums
|
||||
if: steps.release.outputs.tag != ''
|
||||
working-directory: app/backend/bin
|
||||
run: sha256sum containr-* > checksums.txt
|
||||
|
||||
- name: Login to GHCR
|
||||
if: steps.release.outputs.tag != '' && github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Tag pushes from github-tag-action do not trigger workflows, so the
|
||||
# release job retags the just-published :main images in-registry.
|
||||
- name: Tag release images in GHCR
|
||||
if: steps.release.outputs.tag != '' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
run: |
|
||||
repo="${GITHUB_REPOSITORY,,}"
|
||||
for img in backend frontend; do
|
||||
docker buildx imagetools create \
|
||||
--tag "ghcr.io/${repo}-${img}:${TAG}" \
|
||||
--tag "ghcr.io/${repo}-${img}:latest" \
|
||||
"ghcr.io/${repo}-${img}:main"
|
||||
done
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.release.outputs.tag != ''
|
||||
working-directory: app/backend/bin
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
run: |
|
||||
# Delete-then-create keeps reruns idempotent: --clobber uploads
|
||||
# race GitHub's asset tombstones and intermittently 422. The git
|
||||
# tag itself is kept (no --cleanup-tag).
|
||||
gh release delete "$TAG" --yes 2>/dev/null || true
|
||||
gh release create "$TAG" --title "$TAG" --generate-notes \
|
||||
containr-* checksums.txt
|
||||
@@ -121,3 +121,6 @@ bin
|
||||
|
||||
# Local reference checkouts (not project code)
|
||||
ref/
|
||||
.playwright-mcp/
|
||||
canvas-*.png
|
||||
.vercel
|
||||
@@ -0,0 +1,2 @@
|
||||
/*
|
||||
!/site
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="#quick-start">Quick Start</a> •
|
||||
<a href="#screenshots">Screenshots</a> •
|
||||
<a href="#features">Features</a> •
|
||||
<a href="#architecture">Architecture</a> •
|
||||
<a href="docs/guides/">Guides</a> •
|
||||
<a href="CONTRIBUTING.md">Contributing</a>
|
||||
</p>
|
||||
@@ -22,124 +22,120 @@
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
|
||||
</p>
|
||||
|
||||
## What is Containr?
|
||||
Containr is an open-source control plane for Docker. Create a project, drop
|
||||
services onto a visual canvas, wire them together with environment variables,
|
||||
and deploy — no Kubernetes, no cloud bill. Services appear as nodes with
|
||||
auto-inferred connections; logs, metrics, builds, and rollbacks are one
|
||||
click away.
|
||||
|
||||
Containr is an open-source control plane for Docker. It gives you a
|
||||
project-first deployment workflow — create a project, drop services onto a
|
||||
visual canvas, wire them together with environment variables, and deploy —
|
||||
without Kubernetes, without a cloud bill, and without giving up control of
|
||||
your infrastructure.
|
||||
The platform doubles as a public showcase: visitors browse approved projects
|
||||
and the template catalog without an account, while owners manage everything
|
||||
behind sign-in and a platform admin approves, curates, and moderates from a
|
||||
dedicated console.
|
||||
|
||||
The UI is inspired by Railway's project canvas: services appear as nodes,
|
||||
connections are inferred automatically, and everything — logs, metrics,
|
||||
deployments, builds — is one click away.
|
||||
## Screenshots
|
||||
|
||||
| Projects dashboard | Project canvas |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
| Service detail | Managed databases |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
## Features
|
||||
|
||||
- **Project canvas** — visual service topology with groups, drag/drop layout,
|
||||
and auto-inferred connections
|
||||
- **Deployments** — Docker-based deploys with history, logs, and rollback
|
||||
- **Git integration** — deploy from GitHub, GitLab, Bitbucket, or Gitea;
|
||||
repository and branch pickers, webhooks, GitHub App support
|
||||
- **Builds** — build pipeline with live status over WebSocket
|
||||
- **Service metrics** — real CPU/memory/network telemetry from Docker stats
|
||||
- **Templates** — one-click service catalog (databases, apps, tooling)
|
||||
- **Databases** — managed database services with backup/restore actions
|
||||
- **Cron jobs** — scheduled jobs with execution history
|
||||
- **Preview environments** — per-project preview deploys
|
||||
- **Variables & environments** — per-service env vars, production/preview/dev
|
||||
- **Node monitoring** — host CPU, memory, disk, and network telemetry
|
||||
- **Security** — audit logs, vulnerability scans, compliance reports
|
||||
- **Auth** — Better Auth sessions (email + OAuth), per-user resource scoping
|
||||
- **Self-hosted networking** — Traefik reverse proxy, optional Cloudflare
|
||||
Tunnel for exposure without port forwarding
|
||||
- **Project canvas** — visual service topology, groups, drag/drop, auto-inferred connections, right-click lifecycle actions
|
||||
- **Public project catalog** — approved projects are browsable anonymously; new projects stay private until an admin approves them
|
||||
- **Admin console** (`/admin`) — platform stats, project approval/edit/delete, user admin management
|
||||
- **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references
|
||||
- **Deployments & builds** — Docker deploys with history, image rollback, live build status
|
||||
- **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page
|
||||
- **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App
|
||||
- **Managed databases** — create PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Dragonfly, or ClickHouse directly from `/databases`, with backup/restore
|
||||
- **Template catalog** — 30 official templates (NocoDB, Plausible, MinIO, n8n, …) plus user-defined templates with JSON upload and owner-scoped edit/delete
|
||||
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
|
||||
- **HA & scaling** — failover policies, node agents, autoscaling rules
|
||||
- **Auth & security** — Better Auth sessions (email/password self-hosted), personal access tokens with `read`/`write`/`admin` scopes for CLI and agents, audit logs, vulnerability scans
|
||||
- **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline
|
||||
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
|
||||
|
||||
## Access model
|
||||
|
||||
| Audience | Can do |
|
||||
| --- | --- |
|
||||
| **Anonymous** | Browse approved projects, services, templates, databases, HA status, docs |
|
||||
| **Signed-in user** | Create/manage own projects, services, databases, templates; view own logs, variables, builds, deployments |
|
||||
| **Admin** | Everything above plus project approval/moderation, user admin flags, HA manager controls, agent tokens, platform settings |
|
||||
|
||||
Logs, variables, exec consoles, and database connection URLs are never
|
||||
public — they require owner, member, or admin access regardless of a
|
||||
project's public visibility.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Requires Docker with the Compose plugin, `openssl`, and `curl`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh | bash
|
||||
```
|
||||
|
||||
Installs into `./containr`, generates secrets, pulls the published `:latest`
|
||||
images, and starts the stack — UI at `http://localhost:3000`, API at
|
||||
`http://localhost:8082`. Non-interactive; re-runs never overwrite `.env`.
|
||||
|
||||
The platform admin is provisioned at startup from environment variables:
|
||||
|
||||
```bash
|
||||
ADMIN_EMAIL=[email protected]
|
||||
ADMIN_PASSWORD=<generated> # keep in a local untracked file
|
||||
ADMIN_NAME=Your Name # optional
|
||||
```
|
||||
|
||||
Both the local account and the Better Auth session account are created, so
|
||||
either sign-in path works. Without `ADMIN_*`, the first registered account
|
||||
becomes owner; registration then closes automatically. The owner can reopen
|
||||
it and configure a Cloudflare Tunnel token under **Settings → Platform** —
|
||||
in-app values override env vars.
|
||||
|
||||
Override install defaults with env vars:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh | \
|
||||
HTTP_PORT=8080 CONTAINR_DIR=/opt/containr bash
|
||||
```
|
||||
|
||||
`CONTAINR_VERSION` pins a release tag, `CONTAINR_REF` selects the git ref for
|
||||
downloaded files, `CONTAINR_BUILD=1` builds from source. From a git checkout,
|
||||
`./install.sh` installs in place; plain `docker compose up -d` pulls the same
|
||||
images (`--build` to compile locally).
|
||||
|
||||
Local development and self-hosted deployment (Traefik + Cloudflare Tunnel):
|
||||
see [docs/guides/](docs/guides/) and `infra/docker-compose.yml`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow
|
||||
app/backend/ Go API (Gin) · PostgreSQL · Redis · Docker SDK · Better Auth sidecar
|
||||
app/backend/ Go API (Gin) · PostgreSQL · Dragonfly · Docker SDK · Better Auth sidecar
|
||||
infra/ Self-hosted compose: Traefik + Cloudflare Tunnel
|
||||
docs/ Guides, OpenAPI spec, design documents
|
||||
```
|
||||
|
||||
| Component | Default port |
|
||||
| ------------ | ------------ |
|
||||
| Frontend | 3000 (prod) / 5173 (dev) |
|
||||
| API | 8082 |
|
||||
| Auth sidecar | 3001 |
|
||||
| PostgreSQL | 5432 |
|
||||
| Redis | 6379 |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Easy install (recommended)
|
||||
|
||||
Requires Docker with the Compose plugin, `openssl`, and `curl`.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Dvorinka/Containr.git
|
||||
cd Containr
|
||||
./install.sh
|
||||
```
|
||||
|
||||
The script creates `.env` from `.env.example`, fills in all required secrets
|
||||
with random values, builds the images, and starts the stack. It keeps an
|
||||
existing `.env` untouched, so it is safe to re-run.
|
||||
|
||||
Frontend at `http://localhost:3000`, API at `http://localhost:8082`.
|
||||
|
||||
### Docker Compose (manual)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # fill in secrets
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Host ports can be remapped in `.env` (`HTTP_PORT`, `API_PORT`,
|
||||
`POSTGRES_PORT`, `REDIS_PORT`) if the defaults collide with other services.
|
||||
|
||||
### Local development
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres redis # data services only
|
||||
cd app/backend && go run ./cmd/server # API + embedded auth sidecar
|
||||
cd app/frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
Frontend dev server at `http://localhost:5173`.
|
||||
|
||||
### Self-hosted with Traefik + Cloudflare Tunnel
|
||||
|
||||
See `infra/docker-compose.yml` and
|
||||
[docs/guides/CLOUDFLARE_SETUP.md](docs/guides/CLOUDFLARE_SETUP.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is via environment variables — see `.env.example`.
|
||||
Required for production: `JWT_SECRET`, `BETTER_AUTH_SECRET`,
|
||||
`BETTER_AUTH_INTERNAL_TOKEN`, database and Redis credentials.
|
||||
|
||||
**Never commit `.env` or `.env.prod`.** Both are gitignored; if a secret was
|
||||
ever committed, rotate it.
|
||||
All configuration is via environment variables — see `.env.example`. Never
|
||||
commit `.env` or `.env.prod`; keep generated admin credentials in a local
|
||||
untracked file (e.g. `admin-credentials.local`). If a secret was ever
|
||||
committed, rotate it.
|
||||
|
||||
## Documentation
|
||||
|
||||
- In-app: the landing page and `/docs` render searchable docs — synced from
|
||||
this repository, with a bundled snapshot so they are never blank offline
|
||||
- [docs/guides/](docs/guides/) — setup, deployment, Cloudflare, autoscaling
|
||||
- [docs/api/openapi.yaml](docs/api/openapi.yaml) — API contract
|
||||
- [docs/developers/onboarding.md](docs/developers/onboarding.md) — contributor onboarding
|
||||
- [docs/design/](docs/design/) — UI design references
|
||||
- `make help` — common tasks
|
||||
|
||||
## Contributing
|
||||
---
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md). Issues and PRs welcome.
|
||||
|
||||
## Security
|
||||
|
||||
See [SECURITY.md](SECURITY.md). Report vulnerabilities privately — do not
|
||||
open public issues.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) © 2026 Tomas Dvorak
|
||||
[Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) · [MIT License](LICENSE) © 2026 Tomas Dvorak
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
# Containr — Roadmap 2: The dflow Convergence
|
||||
|
||||
Last verified: **2026-10-08** — deep pass over the full dflow codebase
|
||||
(`~/Desktop/PROG+HTML/dflow`, community fork of dflow-sh/dflow, frozen for
|
||||
preservation) against Containr `main` (`15b5b0e`).
|
||||
|
||||
Goal: make Containr the superior successor — absorb everything worth having
|
||||
from dflow, then go past it and past Railway. Same power for humans and
|
||||
agents: the UI is the friendly face, the CLI and MCP are the machine face,
|
||||
all three sit on the same API. **Branding stays Containr/Vertice** —
|
||||
white-labeling is an admin option, not an identity change.
|
||||
|
||||
---
|
||||
|
||||
## 1. What dflow actually is (verified)
|
||||
|
||||
Next.js 15 + PayloadCMS 3 + MongoDB + Redis/BullMQ. Runtime model: every
|
||||
host is a remote server driven **over SSH** with Dokku auto-installed as the
|
||||
per-app container manager. Async work = 43 BullMQ queue processors
|
||||
(`src/queues/**`), live logs via Redis pub/sub → SSE endpoint, persisted to
|
||||
deployment records. ~30 collections, ~180 server actions, 11 dashboard
|
||||
routes per service (general, deployments, logs, environment, domains,
|
||||
volumes, proxy, scaling, backups, settings).
|
||||
|
||||
### Full capability matrix — dflow → Containr
|
||||
|
||||
Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable (dokku/SSH-specific)
|
||||
|
||||
#### Deployment pipeline
|
||||
| dflow capability | Containr status | Action |
|
||||
|---|---|---|
|
||||
| Queued deployments, serialized per server, cancellable while queued | ❌ goroutine fire-and-forget; `CancelDeployment` exists in engine but is **never routed** | Add deploy queue + cancel route |
|
||||
| Deployment states `queued → building → success/failed`, live SSE logs + persisted record | 🟡 states exist (`building→running`); WS streams builds; no queued state | Extend status + stream |
|
||||
| Redeploy cache modes (`ps:rebuild` vs fresh pull); webhook pushes force `no-cache` | ❌ always rebuilds | Add `cache` flag on deploy/redeploy |
|
||||
| Cancel queued deployment from UI | ❌ (engine fn unrouted) | Route it |
|
||||
| Per-service job ordering | ❌ concurrent deploys of one service can race | Serialize per service |
|
||||
|
||||
#### Service configuration
|
||||
| dflow capability | Containr status | Action |
|
||||
|---|---|---|
|
||||
| Multi-domain per service + default flag + cert type | ❌ single `services.domain` | `service_domains` table |
|
||||
| Cert types: letsencrypt / none / Cloudflare Origin / **CF-for-SaaS custom hostnames** | 🟡 Traefik ACME only | LE default; CF custom hostnames deferred (only needed if hosting tenant domains) |
|
||||
| DNS preflight check (`checkDNSConfigAction`) | ❌ | `GET /services/:id/domains/check` |
|
||||
| Volumes per service + dangling-volume browser/attach/delete | ❌ engine supports `VolumeMounts`; no API/UI | `services.volumes` jsonb + Volumes tab |
|
||||
| Resource **limits and reservations**, per-server defaults | 🟡 `cpu_limit`/`memory_limit` int columns (unclear units) | explicit string units + reserves + instance defaults |
|
||||
| Pre-deploy capacity check (dokku binary interceptor) | ❌ | Node capacity check pre-deploy/scale |
|
||||
| Maintenance mode toggle | ❌ | Traefik middleware label |
|
||||
| Basic-auth gate per service (`toggleHttpAuth`) | ❌ | Traefik basic-auth middleware |
|
||||
| Raw proxy config override (`setServiceNginxConfig`) | ❌ | Allowlisted `traefik_labels` jsonb |
|
||||
| Service clone (full config copy) | ❌ | `POST /services/:id/clone` |
|
||||
| Move service between projects (`SwitchServiceProjectDialog`) | ❌ | `PUT /services/:id {project_id}` |
|
||||
| Env var save → hot-apply + optional restart (`noRestart`) | ❌ rows rewritten; containers keep stale env until manual redeploy | Apply→redeploy flow |
|
||||
| Static-site builder; Heroku buildpacks | 🟡 railpack/nixpacks/dockerfile auto-detect, no user override | `services.builder` enum + static builder |
|
||||
| Docker registry accounts (private registries) | ❌ | `registry_credentials` per service/instance |
|
||||
|
||||
#### Databases
|
||||
| dflow capability | Containr status | Action |
|
||||
|---|---|---|
|
||||
| Managed DBs (pg/mysql/mariadb/mongo/redis/clickhouse via dokku plugins) | ✅ plus dragonfly | — |
|
||||
| **External DB registration** (Neon/Atlas/RDS conn details + test) | ❌ | `provider=external` + test endpoint |
|
||||
| Internal backups + restore (same-type enforced) | ✅ | — |
|
||||
| Scheduled backups | ✅ cron field | — |
|
||||
| **External S3 backups** (auth/schedule/unschedule ops) | ❌ | `backup_targets` + shipper |
|
||||
| **Cross-server DB migration** (export → sftp hop → import) | ❌ | `POST /databases/:id/migrate` |
|
||||
| Public DB port expose/unexpose | ❌ loopback only | opt-in TCP expose |
|
||||
| DB → service env linking (`dokku link`) | ✅ Bind action injects `DATABASE_URL` | — |
|
||||
|
||||
#### Servers / infrastructure
|
||||
| dflow capability | Containr status | Action |
|
||||
|---|---|---|
|
||||
| Attach server = paste IP + SSH key; Dokku auto-installs | 🟡 agents exist, manual install | **SSH bootstrap**: one SSH session installs agent, never needed again |
|
||||
| Tailscale/Headscale, NetBird, ZeroTier enroll + mesh connect | ❌ | Agent mesh-IP detection first; auth-key enroll optional |
|
||||
| AWS EC2 + Hetzner provisioning w/ cloud-init | ❌ | IaC (ROADMAP §2.2): Terraform Proxmox/AWS + Hetzner module |
|
||||
| Security groups (AWS SG rules, sync) | ❌ | Under IaC provider modules |
|
||||
| Server-level global/wildcard domain (`updateServerDomain`) | ❌ | `nodes.default_domain` |
|
||||
| Scheduled server cleanup (dokku cleanup → docker prune) | ❌ | Agent `prune` command + schedule |
|
||||
| Queue flush/inspect per server (`flushServerQueues`, `getServerQueues`) | ➖ no queue yet | Ops page shows jobs once queue exists |
|
||||
| Arbitrary SSH command on server (`executeCommand`) | ➖ exec is container-scoped | Node terminal via agent (PTY) |
|
||||
| Server onboarding wizard, reset, cleanup-on-delete | 🟡 token issue/revoke only | Onboarding flow + `nodes add` UX |
|
||||
| Ansible playbooks (stored, runnable per server, exec history) | ➖ | **Defer** — security surface; SSH bootstrap covers provisioning |
|
||||
| Per-node resource dashboards | 🟡 heartbeat telemetry exists | Node detail page |
|
||||
|
||||
#### Platform / org
|
||||
| dflow capability | Containr status | Action |
|
||||
|---|---|---|
|
||||
| Multi-tenant organisations + custom RBAC (per-resource CRUD, create-limits, read-scope) | 🟡 `is_admin` + `project_members` | Defer full RBAC; design PAT scopes role-compatible |
|
||||
| Team invite links/emails | 🟡 manual user create only | `POST /invites` → link join |
|
||||
| User API keys → full REST API | ❌ **the big gap** — session/JWT only | PATs (Phase A) |
|
||||
| Outbound webhooks (collection events, HMAC, custom headers) | ❌ | `webhooks` table + delivery log |
|
||||
| Banners (global + per-tenant, dismissible) | ❌ | `banners` table + top bar |
|
||||
| White-label (logo/favicon/title/OG/theme colors) | ❌ | `app_settings.branding` — **Containr remains the default brand** |
|
||||
| Activity feed (icon/severity/category/metadata, 90-day TTL job) | 🟡 `audit_logs` coarser | Enrich + project feed UI |
|
||||
| Admin metrics dashboard (users/servers/queued/failed counts) | 🟡 `/admin` exists | Extend |
|
||||
| **Impersonate user** | ❌ | Admin support tool |
|
||||
| In-app docs (content-collections markdown) | ✅ `/docs` | — |
|
||||
| Dynamic client-side filter framework (535-line `filter.utils`) | 🟡 audit page filters only | Reuse pattern for tables |
|
||||
| Ops "bubble" (queue monitor, terminal, sync) | ❌ | Operations page |
|
||||
|
||||
#### Agent surface
|
||||
| dflow capability | Containr status | Action |
|
||||
|---|---|---|
|
||||
| Payload REST/GraphQL auto-API + per-user API keys | ❌ | PATs + OpenAPI (better: typed, documented) |
|
||||
| CLI | ❌ | none in dflow — **Containr CLI is a genuine leap past it** |
|
||||
| MCP | ❌ | none — same leap |
|
||||
| Railway migrator script | ❌ | `containr migrate railway` |
|
||||
| GitHub App **manifest flow** (instance self-provisions the App) | 🟡 env-configured App | `POST /git/github-app/manifest` flow |
|
||||
| fork-sync webhook deploy trigger | ❌ | ~20 lines on receiver |
|
||||
| Azure DevOps provider | ❌ | Optional (dflow does token-clone only) |
|
||||
|
||||
---
|
||||
|
||||
## 2. The spine: agent-first platform
|
||||
|
||||
*Agent and user on the same level.* Concretely:
|
||||
|
||||
- Every operation reachable by a **personal access token**, not just
|
||||
sessions.
|
||||
- Every operation in the **CLI** with `--json`, stable exit codes, `--yes`.
|
||||
- Every operation in an **MCP server** (stdio first) so Devin/Claude Code/
|
||||
Cursor/Codex drive Containr natively.
|
||||
- `openapi.yaml` stays the single source of truth; UI, CLI, MCP are thin
|
||||
clients.
|
||||
|
||||
### Phase A — Agent surface (do this FIRST; everything after inherits it)
|
||||
|
||||
- [ ] **Personal access tokens.** `user_tokens` table — `id`, `user_id`,
|
||||
`name`, `key_prefix` (`cnp_…`), `key_hash` (sha256), `scopes`
|
||||
(`read`/`write`/`admin`, later resource-scoped), `expires_at`,
|
||||
`last_used_at`. Model on `agent_auth_tokens` (raw shown once, hash stored).
|
||||
`POST/GET/DELETE /api/v1/user/tokens`; `middleware.Auth` accepts
|
||||
`Bearer cnp_…`; Settings → Access tokens UI with one-time display +
|
||||
copyable CLI/MCP config snippets.
|
||||
- [ ] **CLI v2.** Current `cmd/cli` is auth + projects only. Full client:
|
||||
- `auth login [--token] [--url] [--profile]` (multi-instance profiles).
|
||||
- Resources: `projects services deploy logs exec variables databases
|
||||
cron templates nodes scaling ha security gateway notifications
|
||||
webhooks domains volumes backups admin`.
|
||||
- `containr up` / `deploy` — cwd→service create-or-update→watch
|
||||
(the `railway up` equivalent; single most important command).
|
||||
- `containr link` (bind cwd→service), `containr open`, `containr run`,
|
||||
`containr shell` (PTY once exec upgrade lands), `completion`.
|
||||
- Universal `--json`, `-o yaml`, `--watch`, `--yes`; exit codes
|
||||
0/1/2/3/4 (ok/err/not-found/unauthorized/conflict).
|
||||
- GoReleaser → GH releases + `install.sh`; `version` command.
|
||||
- Conventions from docsync/envdiff/cix/repolint.
|
||||
- [ ] **MCP server** (`cmd/mcp`, same module, stdio). Tools mirror the
|
||||
CLI: `list_projects create_service deploy get_logs set_variable
|
||||
restart rollback create_database trigger_cron deploy_template exec
|
||||
get_metrics security_scan …`. Auth: `CONTAINR_URL` + `CONTAINR_TOKEN`
|
||||
or shared `~/.containr/config`. `containr mcp serve` + install snippets
|
||||
for Devin/Claude/Cursor.
|
||||
- [ ] **API agent ergonomics.** `Idempotency-Key` on mutating endpoints;
|
||||
uniform `{error, code, details}` envelope (audit bare-string returns);
|
||||
cursor pagination on growing lists; name-or-id lookup everywhere.
|
||||
- [ ] **Agent docs.** Root `AGENTS.md`, `/docs` serves `llms.txt` +
|
||||
`llms-full.txt`, MCP tool list generated from OpenAPI where possible.
|
||||
|
||||
**Gate**: an agent holding only a PAT registers a project, deploys from a
|
||||
git repo, streams logs, sets vars, rolls back — zero UI. Same via CLI
|
||||
for a human.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase B — Deployment pipeline maturity
|
||||
|
||||
dflow's one real architectural edge. Containr deploys are fire-and-forget
|
||||
goroutines; this fixes correctness *and* unlocks the ops surface.
|
||||
|
||||
- [ ] **Deploy job queue.** Lightweight internal queue (Postgres
|
||||
`deployment_queue` table + single worker loop — no new infra; Redis
|
||||
exists but don't add BullMQ-style sprawl). Serialize per service;
|
||||
optional per-node serialization. Status gains `queued`.
|
||||
- [ ] **Cancel deployments.** Route `POST /deployments/:id/cancel` →
|
||||
engine `CancelDeployment` (exists, unrouted). Queued jobs cancel
|
||||
cleanly; building jobs cancel context where safe.
|
||||
- [ ] **Cache modes.** `?cache=full` on redeploy → rebuild from existing
|
||||
image layers vs clean pull/checkout. Webhook-triggered deploys default
|
||||
to no-cache (dflow convention — prevents stale-layer surprises).
|
||||
- [ ] **Queued→building→running status + persisted step logs.** Already
|
||||
mostly there; add `queued`, stream over existing `/ws`.
|
||||
- [ ] **Env var apply → redeploy.** Saving variables asks "restart to
|
||||
apply?" (or `--apply` flag in CLI); backend triggers redeploy honoring
|
||||
running state. Currently rows update but containers keep stale env —
|
||||
silent-drift bug class, not just UX.
|
||||
- [ ] **Encrypt variables at rest.** `environment_variables.value` is
|
||||
plaintext today (verified). Envelope-encrypt `is_secret` values
|
||||
(AES-GCM, key from `JWT_SECRET` or dedicated `SECRETS_KEY`); dflow
|
||||
encrypts every var, we encrypt secrets — migration = transparent
|
||||
encrypt-on-write + decrypt-on-read.
|
||||
|
||||
## 4. Phase C — Service configuration parity
|
||||
|
||||
- [ ] **Volumes on services** — `services.volumes` jsonb
|
||||
`[{volume|host_path, container_path, read_only}]`; engine accepts
|
||||
`VolumeMounts` already. Volumes tab + orphan-volume listing
|
||||
(`docker volume ls -f dangling`) with delete/attach.
|
||||
- [ ] **Multi-domain** — `service_domains(service_id, domain, is_default,
|
||||
cert_type, status, last_checked_at)`. Domains tab: add/remove/
|
||||
set-default/DNS-status badge. Keep `services.domain` as derived
|
||||
default for compat.
|
||||
- [ ] **DNS preflight** — `GET /services/:id/domains/check` resolves and
|
||||
compares vs edge IP → `ok|wrong-target|pending`.
|
||||
- [ ] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
|
||||
no proxy code needed.
|
||||
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with
|
||||
allowlist validation (middlewares, headers, redirects).
|
||||
- [ ] **Builder override** — `services.builder` enum
|
||||
`auto|railpack|nixpacks|dockerfile|static`; fix hardcoded `nixpacks`
|
||||
at `deployments.go:406`. Static builder = build + nginx sidecar.
|
||||
- [ ] **Resource limits + reserves** — adopt dflow-style explicit units
|
||||
(`"0.5"`/`"512m"`); add `cpu_reserve`/`memory_reserve` →
|
||||
`NanoCPUs`/`Memory`/`MemoryReservation`; instance defaults in
|
||||
`app_settings`.
|
||||
- [ ] **Pre-deploy capacity check** — sum reservations vs node capacity
|
||||
(agent telemetry); warn or block per policy flag.
|
||||
- [ ] **Service clone** + **move between projects**.
|
||||
- [ ] **Private registries** — `registry_credentials`
|
||||
(server/username/secret, encrypted); `docker login` at pull time;
|
||||
per-service registry link.
|
||||
- [ ] **Service detail tabs to match dflow's surface**: add Domains,
|
||||
Volumes, Proxy/access-controls, Backups sections to the service page
|
||||
(Cron/Previews/Scaling already exist).
|
||||
|
||||
## 5. Phase D — Data layer maturity
|
||||
|
||||
- [ ] **External DB registration** — `provider='managed'|'external'`,
|
||||
conn fields, `POST /databases/test-connection`, same bind flow.
|
||||
- [ ] **Public DB port expose** — opt-in Traefik TCP route or host port;
|
||||
default stays loopback.
|
||||
- [ ] **Offsite backups** — `backup_targets` (S3-compatible:
|
||||
endpoint/bucket/keys encrypted); per-DB destination; ship archives via
|
||||
utility container + s3 SDK; restore-from-S3.
|
||||
- [ ] **Cross-node DB migration** — `POST /databases/:id/migrate
|
||||
{target_node_id}`: dump → stream transfer → provision → restore →
|
||||
re-point bound services. Needs ≥2 nodes or local↔node.
|
||||
- [ ] **Backup import** — upload archive → new restore point (pairs with
|
||||
migrators).
|
||||
|
||||
## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates)
|
||||
|
||||
- [ ] **Template = ordered service graph** — `services[]` entries with
|
||||
`{name, type, source, build, ports, volumes, variables, depends_on}`;
|
||||
deploy order from dependency refs.
|
||||
- [ ] **Expressions** — `${{service.KEY}}` (already the runtime ref
|
||||
syntax — reuse), `{{secret(len,"charset")}}`, `{{random:int}}`,
|
||||
`{{VAR:-default}}`; `POST /templates/:id/plan` returns the resolved
|
||||
graph without deploying (agents love this).
|
||||
- [ ] **Compose import** — `POST /templates/import/compose` (compose-go
|
||||
parser → graph), `POST /templates/import/github` (compose file from
|
||||
repo path). Implements `docs/superpowers/specs/2026-04-14-*`.
|
||||
- [ ] **Graph deploy pipeline** — creates project + services in
|
||||
dependency order, resolves vars, triggers builds; defined
|
||||
partial-failure policy.
|
||||
- [ ] **Visual template builder** — canvas mode: drop git/image/db
|
||||
nodes, wire refs, set order, save/publish. Reuses existing canvas
|
||||
primitives (dflow's `templates/compose` is exactly this).
|
||||
- [ ] **Catalog growth** — convert `templates/*.md` (20 app guides) into
|
||||
real graph templates; screenshots + badges per the old spec.
|
||||
|
||||
**Gate**: paste n8n compose → 3-service project with generated secrets
|
||||
and wired refs, one click or one CLI call.
|
||||
|
||||
## 7. Phase F — Nodes & infrastructure
|
||||
|
||||
- [ ] **SSH bootstrap install** — `containr nodes add --ssh user@host` +
|
||||
UI flow: one SSH session runs agent install (binary + systemd +
|
||||
enroll token), SSH closed forever. dflow onboarding UX, agent-model
|
||||
security.
|
||||
- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/
|
||||
zt0 IPs at register; backend stores + displays. Optional
|
||||
`TAILSCALE_AUTH_KEY` enroll-at-provision later.
|
||||
- [ ] **Node detail page** — placed services, telemetry history, agent
|
||||
version + self-upgrade, **drain/cordon**, remove.
|
||||
- [ ] **Node housekeeping** — agent `prune` command (docker system
|
||||
prune, bounded) + optional schedule (`setServerAutoCleanup` analog —
|
||||
disk pressure is the #1 small-VPS killer).
|
||||
- [ ] **Multi-node scheduling** — wire `scheduling_rules` (table exists):
|
||||
pin/spread/affinity, prefer-lowest-usage; deploy engine routes to
|
||||
`services.node_id` via agent commands (agent already runs containers).
|
||||
- [ ] **Build-on-node** — agents currently `docker run` prebuilt images
|
||||
only; extend agent to pull registry images (build stays central) —
|
||||
private registry creds flow through Phase C.
|
||||
- [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/
|
||||
OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module
|
||||
(dflow's second provider), cloud-init → auto-enroll.
|
||||
- [ ] **Global/wildcard domain per node** (`nodes.default_domain`) —
|
||||
auto-domains for services on that node.
|
||||
|
||||
## 8. Phase G — Platform polish
|
||||
|
||||
- [ ] **Outbound webhooks** — `webhooks(url, secret, events[], headers,
|
||||
enabled)` + `webhook_deliveries` log; HMAC `X-Containr-Signature`;
|
||||
events: `deployment.* service.* database.* security.* agent.*`.
|
||||
- [ ] **Activity feed** — enrich `audit_logs` with `severity category
|
||||
icon label metadata`; per-project timeline + global feed; TTL job.
|
||||
- [ ] **Banners** — `banners(title, body, level, scope, active)`; top
|
||||
bar in UI + surfaced to CLI on `auth status`.
|
||||
- [ ] **White-label** — `app_settings.branding`: product name, logos,
|
||||
favicon, accent override, docs/support links. `GET /api/v1/branding`
|
||||
public + cached. **Containr/Vertice remains default;** this is for
|
||||
self-hosters who rebrand their instance.
|
||||
- [ ] **Operations page** — unified view of running/queued/failed jobs
|
||||
(deploys, builds, backups, cron, scans, agent commands) — dflow's
|
||||
bubble panel as a real page; flush/stuck-job tools once queue exists.
|
||||
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`);
|
||||
`nodes/:id/terminal` via agent (dflow's wetty without the extra
|
||||
container).
|
||||
- [ ] **Team invites** — `POST /invites` → signed link → join flow;
|
||||
replaces manual-only user creation.
|
||||
- [ ] **Impersonate user** — admin support tool, audit-logged.
|
||||
- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest`
|
||||
returns manifest + redirect; instance self-provisions its App
|
||||
(dflow pattern — removes the env-setup barrier).
|
||||
- [ ] **fork-sync trigger** — accept repo-sync events on webhook
|
||||
receiver.
|
||||
- [ ] **Dynamic filter framework** — port dflow's `filter.utils`
|
||||
pattern (typed filter configs → URL state → client filtering) for
|
||||
audit logs, deployments, notifications tables.
|
||||
- [ ] **Notification producers round-out** — backups, agent offline,
|
||||
upgrade available, security findings.
|
||||
- [ ] **Custom roles/RBAC** — defer until demanded; PAT scopes designed
|
||||
forward-compatible now.
|
||||
|
||||
## 9. Phase H — Migration & ecosystem
|
||||
|
||||
- [ ] **Railway migrator** — `containr migrate railway --token --project
|
||||
--dry-run`: Railway GraphQL → services/vars/domains → recreate via
|
||||
PAT. Landing-page pitch: "off Railway in one command."
|
||||
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST
|
||||
export or Mongo dump: servers→nodes, projects, services(+vars/
|
||||
domains/volumes), databases, templates. Onboards dflow refugees.
|
||||
- [ ] **`containr import compose`** — Phase-E parser straight to a
|
||||
project (no template intermediate).
|
||||
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL`
|
||||
fetch/publish; bundled catalog works offline (dflow's self-contained
|
||||
principle — nothing phones home).
|
||||
|
||||
## 10. Carried-over debt (ROADMAP.md items still open)
|
||||
|
||||
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit →
|
||||
`gateway/proxy.go` → metrics. Management API exists; proxy unwired.
|
||||
- [ ] **Preview environments runtime** — real build+deploy+subdomain+TTL
|
||||
(bookkeeping only now). Pairs with clone + multi-domain.
|
||||
- [ ] **Environments** — env switcher, per-env vars/domains, promote.
|
||||
- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`.
|
||||
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
|
||||
webhook table tests, Playwright e2e golden path.
|
||||
- [ ] **First-run wizard** — admin → GitHub App (manifest flow from
|
||||
Phase G makes this self-serve) → Tunnel → done.
|
||||
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release.
|
||||
|
||||
## 11. Deliberately not imported from dflow
|
||||
|
||||
- Dokku-over-SSH runtime — our Docker API + agent model is strictly
|
||||
better.
|
||||
- Per-server-named BullMQ queues — our queue stays a single ordered
|
||||
table.
|
||||
- Payload/Mongo/Next.js stack, multi-tenant plugin machinery.
|
||||
- Ansible playbook runner — arbitrary code-on-node is a security
|
||||
surface; SSH bootstrap + agent commands cover the real needs.
|
||||
Revisit only with a concrete demand.
|
||||
- `isAdmin`-only collection access — our owner/admin/public model is
|
||||
richer already.
|
||||
- Netdata install flow — our agent telemetry + optional Beszel covers
|
||||
monitoring; don't ship a third dashboard stack.
|
||||
|
||||
## 12. Recommended order
|
||||
|
||||
```
|
||||
A (agent surface) ──► B (deploy pipeline) ──► C/D (service+data depth)
|
||||
│ │
|
||||
▼ ▼
|
||||
CLI/MCP dogfood every feature E (templates v2)
|
||||
│
|
||||
F (nodes/infra) ──► H (migrators)
|
||||
│
|
||||
G (polish) + debt
|
||||
```
|
||||
|
||||
Why this order: PATs+CLI+MCP first = every later feature ships
|
||||
user-and-agent-visible at once. The queue (B) is prerequisite for
|
||||
the ops surface and correct concurrency. Templates v2 needs volumes,
|
||||
multi-domain, clone. Migrators last — they're launch marketing, not
|
||||
foundation.
|
||||
|
||||
## 13. Design rules
|
||||
|
||||
- **API is the contract.** UI, CLI, MCP are thin clients over
|
||||
`openapi.yaml`. A feature isn't done until all three reach it.
|
||||
- **Everything scriptable.** JSON in/out, `--json`/`--yes`, uniform
|
||||
pagination, name-or-id addressing.
|
||||
- **Same powers, same position.** Nothing user-facing that isn't
|
||||
API-facing; agent-visible state (tokens, jobs) visible to users too.
|
||||
- **dflow is a donor, not a dependency.** Port semantics, not code.
|
||||
- **Containr branding is the default**; white-label is instance config.
|
||||
- **Self-hosted first** — no required external service, no phone-home.
|
||||
@@ -5,7 +5,7 @@
|
||||
### Prerequisites
|
||||
- Go 1.21+
|
||||
- PostgreSQL 12+
|
||||
- Redis (optional)
|
||||
- Dragonfly (Redis-compatible cache)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
|
||||
@@ -46,10 +46,15 @@ const database = new PostgresDialect({
|
||||
pool,
|
||||
});
|
||||
|
||||
const githubClientId = env.GITHUB_CLIENT_ID?.trim() || '';
|
||||
const githubClientSecret = env.GITHUB_CLIENT_SECRET?.trim() || '';
|
||||
const googleClientId = env.GOOGLE_CLIENT_ID?.trim() || '';
|
||||
const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim() || '';
|
||||
const realCredential = (value) => {
|
||||
const trimmed = (value || '').trim();
|
||||
return trimmed && !trimmed.startsWith('PLACEHOLDER_') ? trimmed : '';
|
||||
};
|
||||
|
||||
const githubClientId = realCredential(env.GITHUB_CLIENT_ID);
|
||||
const githubClientSecret = realCredential(env.GITHUB_CLIENT_SECRET);
|
||||
const googleClientId = realCredential(env.GOOGLE_CLIENT_ID);
|
||||
const googleClientSecret = realCredential(env.GOOGLE_CLIENT_SECRET);
|
||||
|
||||
const socialProviders = {};
|
||||
if (githubClientId && githubClientSecret) {
|
||||
@@ -67,7 +72,7 @@ if (googleClientId && googleClientSecret) {
|
||||
};
|
||||
}
|
||||
|
||||
const trustedOrigins = [
|
||||
const staticTrustedOrigins = [
|
||||
...splitCsv(env.BETTER_AUTH_TRUSTED_ORIGINS),
|
||||
env.FRONTEND_URL,
|
||||
env.BACKEND_URL,
|
||||
@@ -86,12 +91,33 @@ const trustedOrigins = [
|
||||
.map((origin) => (origin || '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Self-hosted installs are reached over arbitrary hosts/IPs, so the static
|
||||
// list above cannot cover them. Trust an Origin only when it matches the
|
||||
// request's own Host header - that is genuine same-origin traffic (browsers
|
||||
// cannot forge either header cross-origin). Do NOT consult x-forwarded-host
|
||||
// here: clients can set it freely.
|
||||
const resolveTrustedOrigins = (request) => {
|
||||
const origins = new Set(staticTrustedOrigins);
|
||||
const host = request?.headers?.get('host');
|
||||
const origin = request?.headers?.get('origin');
|
||||
if (host && origin) {
|
||||
try {
|
||||
if (new URL(origin).host === host) {
|
||||
origins.add(origin);
|
||||
}
|
||||
} catch {
|
||||
// malformed Origin header - leave untrusted
|
||||
}
|
||||
}
|
||||
return [...origins];
|
||||
};
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: env.BETTER_AUTH_APP_NAME || 'Containr',
|
||||
baseURL: env.BETTER_AUTH_URL || defaultBackendURL,
|
||||
basePath: '/api/auth',
|
||||
secret: env.BETTER_AUTH_SECRET || 'PLACEHOLDER_BETTER_AUTH_SECRET_CHANGE_ME_32CHARS_MIN',
|
||||
trustedOrigins,
|
||||
trustedOrigins: resolveTrustedOrigins,
|
||||
database,
|
||||
user: {
|
||||
modelName: 'auth_users',
|
||||
|
||||
@@ -6,7 +6,15 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// Injected at build time via -ldflags (see scripts/build-cli.sh).
|
||||
var (
|
||||
Version = "dev"
|
||||
BuildTime = "unknown"
|
||||
GitCommit = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cli.SetVersion(fmt.Sprintf("%s (%s, %s)", Version, GitCommit, BuildTime))
|
||||
if err := cli.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// dumptemplates prints the seeded official template catalog as JSON in the same
|
||||
// shape as GET /api/v1/templates. The landing site (site/templates.json) uses it
|
||||
// as the offline fallback catalog: `go run ./cmd/dumptemplates > ../../site/templates.json`.
|
||||
package main
|
||||
|
||||
import (
|
||||
"containr/internal/api"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := struct {
|
||||
Templates []api.ServiceTemplate `json:"templates"`
|
||||
}{Templates: api.SeedTemplates()}
|
||||
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(out); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "encode:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -86,9 +86,15 @@ func main() {
|
||||
}()
|
||||
}
|
||||
|
||||
// Provision the platform owner account (idempotent, non-fatal).
|
||||
api.EnsureAdminAccount(context.Background(), db, cfg)
|
||||
|
||||
// Setup Gin router
|
||||
if cfg.IsProduction() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
if !cfg.CookieSecure {
|
||||
log.Println("Warning: COOKIE_SECURE=false in production - session cookies are sent over plain HTTP. Enable TLS and set COOKIE_SECURE=true.")
|
||||
}
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
|
||||
@@ -14,15 +14,15 @@ services:
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
dragonfly:
|
||||
image: docker.dragonflydb.io/dragonflydb/dragonfly:latest
|
||||
env_file:
|
||||
- ../../.env.prod
|
||||
command: ["sh", "-c", "redis-server --requirepass \"$$REDIS_PASSWORD\""]
|
||||
command: ["sh", "-c", "exec dragonfly --requirepass \"$${REDIS_PASSWORD}\" --proactor_threads=1 --maxmemory=256mb --dir=/data"]
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- backend_redis_data:/data
|
||||
- backend_dragonfly_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
|
||||
interval: 10s
|
||||
@@ -38,10 +38,14 @@ services:
|
||||
- ../../.env.prod
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
group_add:
|
||||
- "${DOCKER_GID:-0}"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
dragonfly:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
@@ -52,4 +56,4 @@ services:
|
||||
|
||||
volumes:
|
||||
backend_postgres_data:
|
||||
backend_redis_data:
|
||||
backend_dragonfly_data:
|
||||
@@ -0,0 +1,295 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/config"
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin account bootstrap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// EnsureAdminAccount provisions the platform owner account from
|
||||
// ADMIN_EMAIL/ADMIN_PASSWORD (optional ADMIN_NAME). It is idempotent: existing
|
||||
// users are simply promoted to admin. Both the local users row (legacy JWT
|
||||
// login) and the Better Auth account (cookie sessions) are created so either
|
||||
// sign-in path works. Failures are logged, never fatal — the platform must
|
||||
// boot even when auth is misconfigured.
|
||||
func EnsureAdminAccount(ctx context.Context, db *database.DB, cfg *config.Config) {
|
||||
email := strings.ToLower(strings.TrimSpace(cfg.AdminEmail))
|
||||
password := cfg.AdminPassword
|
||||
if email == "" || password == "" {
|
||||
return
|
||||
}
|
||||
if db == nil || db.DB == nil {
|
||||
log.Println("Admin bootstrap skipped: database unavailable")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(cfg.AdminName)
|
||||
if name == "" {
|
||||
name = "Platform Admin"
|
||||
}
|
||||
|
||||
// Local users row (owns resources, is_admin flag, legacy login hash).
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("Admin bootstrap failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var userID string
|
||||
err = db.QueryRowContext(ctx, `
|
||||
INSERT INTO users (email, password_hash, name, is_admin)
|
||||
VALUES ($1, $2, $3, true)
|
||||
ON CONFLICT (email) DO UPDATE
|
||||
SET is_admin = true, updated_at = NOW()
|
||||
RETURNING id
|
||||
`, email, string(hashed), name).Scan(&userID)
|
||||
if err != nil {
|
||||
log.Printf("Admin bootstrap failed to upsert user %s: %v", email, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Better Auth account (cookie-session sign-in). Duplicate sign-up returns
|
||||
// an error which we treat as "already provisioned".
|
||||
if err := ensureBetterAuthUser(ctx, name, email, password); err != nil {
|
||||
if errors.Is(err, errBetterAuthUserExists) {
|
||||
log.Printf("Admin account %s already exists in auth service; ensured admin flag", email)
|
||||
} else {
|
||||
log.Printf("Warning: admin bootstrap could not create auth user for %s: %v", email, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf("Admin account provisioned: %s", email)
|
||||
}
|
||||
|
||||
// ensureBetterAuthUser calls the Better Auth sign-up endpoint directly. Unlike
|
||||
// createBetterAuthUser it runs outside a request context.
|
||||
func ensureBetterAuthUser(ctx context.Context, name, email, password string) error {
|
||||
endpoint, err := betterAuthSignUpURL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := json.Marshal(gin.H{"name": name, "email": email, "password": password})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if token := strings.TrimSpace(os.Getenv("BETTER_AUTH_INTERNAL_TOKEN")); token != "" {
|
||||
request.Header.Set("X-Containr-Auth-Internal", token)
|
||||
}
|
||||
|
||||
response, err := (&http.Client{Timeout: 10 * time.Second}).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices {
|
||||
return nil
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
_ = json.NewDecoder(response.Body).Decode(&payload)
|
||||
message := ""
|
||||
if raw, ok := payload["message"].(string); ok {
|
||||
message = strings.ToLower(raw)
|
||||
} else if raw, ok := payload["error"].(string); ok {
|
||||
message = strings.ToLower(raw)
|
||||
}
|
||||
if strings.Contains(message, "exist") || strings.Contains(message, "already") {
|
||||
return errBetterAuthUserExists
|
||||
}
|
||||
if message == "" {
|
||||
message = http.StatusText(response.StatusCode)
|
||||
}
|
||||
return errors.New(message)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type adminUserRow struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func handleAdminOverview(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
count := func(query string) int {
|
||||
var n int
|
||||
if err := db.QueryRowContext(ctx, query).Scan(&n); err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
pendingRows, err := queries.ListPendingProjects(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list pending projects"})
|
||||
return
|
||||
}
|
||||
pending := make([]Project, 0, len(pendingRows))
|
||||
for _, row := range pendingRows {
|
||||
pending = append(pending, mapSQLCProject(row))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"stats": gin.H{
|
||||
"users": count(`SELECT COUNT(*) FROM users`),
|
||||
"projects": count(`SELECT COUNT(*) FROM projects`),
|
||||
"pending_projects": count(`SELECT COUNT(*) FROM projects WHERE is_approved = false`),
|
||||
"services": count(`SELECT COUNT(*) FROM services`),
|
||||
"running_services": count(`SELECT COUNT(*) FROM services WHERE status = 'running'`),
|
||||
"databases": count(`SELECT COUNT(*) FROM database_services`),
|
||||
"agents": count(`SELECT COUNT(*) FROM node_agents`),
|
||||
"templates": count(`SELECT COUNT(*) FROM service_templates`),
|
||||
"user_templates": count(`SELECT COUNT(*) FROM service_templates WHERE is_official = false`),
|
||||
"cron_jobs": count(`SELECT COUNT(*) FROM cron_jobs`),
|
||||
"deployments_total": count(`SELECT COUNT(*) FROM deployments`),
|
||||
},
|
||||
"pending_projects": pending,
|
||||
})
|
||||
}
|
||||
|
||||
func handleAdminListUsers(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
rows, err := db.QueryContext(c.Request.Context(), `
|
||||
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '')
|
||||
FROM users
|
||||
ORDER BY created_at ASC
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := make([]adminUserRow, 0)
|
||||
for rows.Next() {
|
||||
var u adminUserRow
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.IsAdmin, &u.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"users": users})
|
||||
}
|
||||
|
||||
func handleAdminSetUserAdmin(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
targetID := strings.TrimSpace(c.Param("id"))
|
||||
if _, err := uuid.Parse(targetID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
callerID := optionalUserID(c)
|
||||
if callerID == targetID && !req.IsAdmin {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot revoke your own admin access"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := db.ExecContext(c.Request.Context(),
|
||||
`UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2`,
|
||||
req.IsAdmin, targetID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
|
||||
return
|
||||
}
|
||||
if n, _ := result.RowsAffected(); n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAudit(callerID, "user", targetID, "set_admin", map[string]interface{}{"is_admin": req.IsAdmin})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "User updated"})
|
||||
}
|
||||
|
||||
func handleAdminSetProjectApproval(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
|
||||
projectID, ok := parseProjectIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IsApproved bool `json:"is_approved"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := queries.SetProjectApproved(c.Request.Context(), sqlcdb.SetProjectApprovedParams{
|
||||
IsApproved: req.IsApproved,
|
||||
ProjectID: projectID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project"})
|
||||
return
|
||||
}
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||
return
|
||||
}
|
||||
|
||||
action := "unapprove"
|
||||
if req.IsApproved {
|
||||
action = "approve"
|
||||
}
|
||||
LogAudit(optionalUserID(c), "project", projectID.String(), action, nil)
|
||||
|
||||
row, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
|
||||
ProjectID: projectID,
|
||||
UserID: optionalUserUUID(c),
|
||||
IsAdmin: true,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Project updated"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"project": mapSQLCProject(row)})
|
||||
}
|
||||
@@ -1217,8 +1217,10 @@ func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetupRoutes registers authenticated dashboard/control agent routes.
|
||||
func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) {
|
||||
// SetupAdminRoutes registers dashboard/control agent routes. Command
|
||||
// history and container details can leak sensitive output, so these stay
|
||||
// admin-only rather than joining the public read group.
|
||||
func (h *NodeAgentHandler) SetupAdminRoutes(router *gin.RouterGroup) {
|
||||
tokens := router.Group("/agent-tokens")
|
||||
{
|
||||
tokens.POST("", h.CreateAgentToken)
|
||||
|
||||
@@ -46,6 +46,7 @@ type User struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -63,10 +64,10 @@ func handleLogin(c *gin.Context) {
|
||||
var user User
|
||||
var hashedPassword string
|
||||
err := db.QueryRow(`
|
||||
SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at
|
||||
SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), is_admin, created_at
|
||||
FROM users
|
||||
WHERE email = $1
|
||||
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
|
||||
@@ -120,13 +121,32 @@ func handleAuthBootstrap(c *gin.Context) {
|
||||
mode = "register"
|
||||
}
|
||||
|
||||
providers := []string{}
|
||||
for _, provider := range []string{"GITHUB", "GOOGLE"} {
|
||||
if oauthProviderConfigured(provider) {
|
||||
providers = append(providers, strings.ToLower(provider))
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"has_users": count > 0,
|
||||
"user_count": count,
|
||||
"mode": mode,
|
||||
"providers": providers,
|
||||
"signup_enabled": signupEnabled(db),
|
||||
})
|
||||
}
|
||||
|
||||
// oauthProviderConfigured reports whether an OAuth provider has real
|
||||
// credentials. Placeholder values from .env.example do not count, so
|
||||
// self-hosted installs default to email/password only.
|
||||
func oauthProviderConfigured(provider string) bool {
|
||||
id := strings.TrimSpace(os.Getenv(provider + "_CLIENT_ID"))
|
||||
secret := strings.TrimSpace(os.Getenv(provider + "_CLIENT_SECRET"))
|
||||
return id != "" && secret != "" &&
|
||||
!strings.HasPrefix(id, "PLACEHOLDER_") && !strings.HasPrefix(secret, "PLACEHOLDER_")
|
||||
}
|
||||
|
||||
type betterAuthLoginUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
@@ -236,17 +256,23 @@ func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
// A mirrored first account owns the platform, same as handleRegister.
|
||||
var total int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&total); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
var user User
|
||||
err = db.QueryRow(`
|
||||
INSERT INTO users (email, password_hash, name, avatar_url)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''))
|
||||
INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5)
|
||||
ON CONFLICT (email) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
|
||||
updated_at = NOW()
|
||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
||||
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image)).
|
||||
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
|
||||
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image), total == 0).
|
||||
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
@@ -264,24 +290,26 @@ func handleRegister(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
jwtSecret := c.MustGet("jwt_secret").(string)
|
||||
|
||||
count, err := countLocalUsers(db)
|
||||
total, err := countLocalUsers(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
// Closed once the first account exists unless the owner reopens registration.
|
||||
if total > 0 && !signupEnabled(db) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
||||
return
|
||||
}
|
||||
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&count)
|
||||
var existing int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&existing)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
if existing > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||
return
|
||||
}
|
||||
@@ -293,13 +321,13 @@ func handleRegister(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create user
|
||||
// First account owns the platform.
|
||||
var user User
|
||||
err = db.QueryRow(`
|
||||
INSERT INTO users (email, password_hash, name)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
||||
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
INSERT INTO users (email, password_hash, name, is_admin)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
|
||||
`, req.Email, string(hashedPassword), req.Name, total == 0).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||
@@ -320,13 +348,16 @@ func handleRegister(c *gin.Context) {
|
||||
}
|
||||
|
||||
func handleCreateUser(c *gin.Context) {
|
||||
db, ok := requireAdmin(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req ManualUserCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Email == "" || req.Name == "" {
|
||||
@@ -363,8 +394,8 @@ func handleCreateUser(c *gin.Context) {
|
||||
err = db.QueryRow(`
|
||||
INSERT INTO users (email, password_hash, name)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
||||
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
|
||||
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||
return
|
||||
@@ -459,10 +490,10 @@ func handleGetProfile(c *gin.Context) {
|
||||
|
||||
var user User
|
||||
err := db.QueryRow(`
|
||||
SELECT id, email, name, COALESCE(avatar_url, ''), created_at
|
||||
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
|
||||
@@ -67,7 +67,9 @@ func shouldBlockPublicBetterAuthSignup(c *gin.Context, cfg *config.Config, db *d
|
||||
return true
|
||||
}
|
||||
|
||||
return count > 0
|
||||
// Registration is closed once the first account exists unless the owner
|
||||
// reopens it in Settings (signup_enabled).
|
||||
return count > 0 && !signupEnabled(db)
|
||||
}
|
||||
|
||||
func parseAuthProxyTarget(rawTarget string) (*url.URL, error) {
|
||||
|
||||
@@ -3,7 +3,10 @@ package api
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"containr/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func requireAuthenticatedUserID(c *gin.Context) (string, bool) {
|
||||
@@ -21,3 +24,107 @@ func requireAuthenticatedUserID(c *gin.Context) (string, bool) {
|
||||
|
||||
return userID, true
|
||||
}
|
||||
|
||||
// optionalUserID returns the resolved user id or "" for anonymous callers on
|
||||
// OptionalAuth routes. Never writes a response.
|
||||
func optionalUserID(c *gin.Context) string {
|
||||
userIDValue, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
userID, _ := userIDValue.(string)
|
||||
return userID
|
||||
}
|
||||
|
||||
// optionalUserUUID is the uuid variant of optionalUserID; anonymous callers
|
||||
// get uuid.Nil, which matches no owner_id and therefore only sees approved
|
||||
// public resources.
|
||||
func optionalUserUUID(c *gin.Context) uuid.UUID {
|
||||
userID := optionalUserID(c)
|
||||
parsed, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return uuid.Nil
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// contextIsAdmin reports whether the resolved caller is a platform admin.
|
||||
func contextIsAdmin(c *gin.Context) bool {
|
||||
return c.GetBool("is_admin")
|
||||
}
|
||||
|
||||
// projectReadAccess reports (exists, allowedToRead). Approved projects are
|
||||
// public; unapproved ones are visible to the owner, project members, and
|
||||
// platform admins only.
|
||||
func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
|
||||
var isApproved bool
|
||||
var ownerID string
|
||||
err := db.QueryRow(
|
||||
`SELECT is_approved, owner_id::text FROM projects WHERE id = $1`,
|
||||
projectID,
|
||||
).Scan(&isApproved, &ownerID)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
if isApproved || contextIsAdmin(c) {
|
||||
return true, true
|
||||
}
|
||||
userID := optionalUserID(c)
|
||||
if userID == "" {
|
||||
return true, false
|
||||
}
|
||||
if ownerID == userID {
|
||||
return true, true
|
||||
}
|
||||
var member bool
|
||||
if err := db.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
|
||||
projectID, userID,
|
||||
).Scan(&member); err == nil && member {
|
||||
return true, true
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
|
||||
// projectManageAccess reports (exists, allowedToOperate). Unlike
|
||||
// projectReadAccess, approval does NOT grant access — only the owner,
|
||||
// project members, and platform admins may reach secret-adjacent data
|
||||
// such as variables and runtime logs.
|
||||
func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
|
||||
var ownerID string
|
||||
err := db.QueryRow(
|
||||
`SELECT owner_id::text FROM projects WHERE id = $1`,
|
||||
projectID,
|
||||
).Scan(&ownerID)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
if contextIsAdmin(c) {
|
||||
return true, true
|
||||
}
|
||||
userID := optionalUserID(c)
|
||||
if userID == "" {
|
||||
return true, false
|
||||
}
|
||||
if ownerID == userID {
|
||||
return true, true
|
||||
}
|
||||
var member bool
|
||||
if err := db.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
|
||||
projectID, userID,
|
||||
).Scan(&member); err == nil && member {
|
||||
return true, true
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
|
||||
// serviceProjectID resolves the project that owns a service.
|
||||
func serviceProjectID(db *database.DB, serviceID uuid.UUID) (uuid.UUID, bool) {
|
||||
var projectID uuid.UUID
|
||||
err := db.QueryRow(`SELECT project_id FROM services WHERE id = $1`, serviceID).Scan(&projectID)
|
||||
if err != nil {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return projectID, true
|
||||
}
|
||||
@@ -65,9 +65,23 @@ type UpdateCronJobRequest struct {
|
||||
Retention int `json:"retention"`
|
||||
}
|
||||
|
||||
// cronJobProjectID resolves the project a cron job belongs to.
|
||||
func cronJobProjectID(db *database.DB, jobID string) (uuid.UUID, bool) {
|
||||
var projectID uuid.UUID
|
||||
err := db.QueryRow(
|
||||
`SELECT cj.project_id FROM cron_jobs cj WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&projectID)
|
||||
if err != nil {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return projectID, true
|
||||
}
|
||||
|
||||
func handleGetCronJobs(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
userID := c.MustGet("user_id").(string)
|
||||
userID := optionalUserUUID(c)
|
||||
isAdmin := contextIsAdmin(c)
|
||||
projectID := c.Query("project_id")
|
||||
serviceID := c.Query("service_id")
|
||||
|
||||
@@ -76,8 +90,9 @@ func handleGetCronJobs(c *gin.Context) {
|
||||
cj.retention, cj.created_at, cj.updated_at
|
||||
FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE p.owner_id = $1`
|
||||
args := []interface{}{userID}
|
||||
WHERE (p.is_approved OR p.owner_id = $1 OR $2::bool
|
||||
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))`
|
||||
args := []interface{}{userID, isAdmin}
|
||||
|
||||
if projectID != "" {
|
||||
args = append(args, projectID)
|
||||
@@ -130,7 +145,7 @@ func handleCreateCronJob(c *gin.Context) {
|
||||
req.ServiceID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil || ownerCheck != userID {
|
||||
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
@@ -184,30 +199,32 @@ func handleCreateCronJob(c *gin.Context) {
|
||||
|
||||
func handleGetCronJob(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
userID := c.MustGet("user_id").(string)
|
||||
jobID := c.Param("id")
|
||||
|
||||
var job CronJob
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
|
||||
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
|
||||
cj.retention, cj.created_at, cj.updated_at, p.owner_id
|
||||
cj.retention, cj.created_at, cj.updated_at
|
||||
FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
|
||||
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
|
||||
&job.Retention, &job.CreatedAt, &job.UpdatedAt, &ownerCheck)
|
||||
&job.Retention, &job.CreatedAt, &job.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
projectID, found := cronJobProjectID(db, jobID)
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
|
||||
return
|
||||
}
|
||||
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -233,7 +250,7 @@ func handleUpdateCronJob(c *gin.Context) {
|
||||
jobID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil || ownerCheck != userID {
|
||||
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
@@ -303,7 +320,7 @@ func handleDeleteCronJob(c *gin.Context) {
|
||||
jobID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil || ownerCheck != userID {
|
||||
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
@@ -321,19 +338,15 @@ func handleDeleteCronJob(c *gin.Context) {
|
||||
|
||||
func handleGetCronExecutions(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
userID := c.MustGet("user_id").(string)
|
||||
jobID := c.Param("id")
|
||||
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT p.owner_id FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil || ownerCheck != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
projectID, found := cronJobProjectID(db, jobID)
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
|
||||
return
|
||||
}
|
||||
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -380,7 +393,7 @@ func handleTriggerCronJob(c *gin.Context) {
|
||||
jobID,
|
||||
).Scan(&job.ServiceID, &job.Command, &job.Schedule, &job.Timezone, &job.Retention, &ownerCheck)
|
||||
|
||||
if err != nil || ownerCheck != userID {
|
||||
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
dockercontainer "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -41,6 +42,7 @@ const (
|
||||
// DatabaseService represents a managed database service
|
||||
type DatabaseService struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
OwnerID string `json:"-" db:"user_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Type string `json:"type" db:"type"` // postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly
|
||||
Status string `json:"status" db:"status"` // running, stopped, building, error
|
||||
@@ -168,13 +170,14 @@ func NewDatabaseHandler(db *sql.DB, dockerClient *docker.Client) *DatabaseHandle
|
||||
}
|
||||
}
|
||||
|
||||
// GetDatabases returns all database services for a user
|
||||
// GetDatabases returns database services. The platform is public-read: every
|
||||
// caller sees the fleet, but connection URLs (which may embed credentials)
|
||||
// are only exposed to admins.
|
||||
func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := h.queries.ListDatabaseServicesByUser(c.Request.Context(), userID)
|
||||
userID := optionalUserID(c)
|
||||
isAdmin := contextIsAdmin(c)
|
||||
|
||||
rows, err := h.queries.ListAllDatabaseServices(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch databases"})
|
||||
return
|
||||
@@ -182,17 +185,23 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
|
||||
|
||||
databases := make([]DatabaseService, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
db := mapDatabaseServiceListRow(row)
|
||||
db := mapDatabaseServiceRow(row)
|
||||
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
|
||||
|
||||
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
|
||||
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil {
|
||||
privileged := isAdmin || (userID != "" && db.OwnerID == userID)
|
||||
if privileged {
|
||||
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil {
|
||||
db.Backups = backupConfig
|
||||
} else {
|
||||
db.Backups = h.generateMockBackupConfig()
|
||||
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
|
||||
}
|
||||
db.Settings = h.generateMockSettings()
|
||||
db.ConnectionURL = h.resolveConnectionURL(db)
|
||||
db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c))
|
||||
} else {
|
||||
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
|
||||
db.ConnectionURL = ""
|
||||
}
|
||||
|
||||
databases = append(databases, db)
|
||||
}
|
||||
@@ -202,15 +211,11 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
|
||||
|
||||
// GetDatabase returns a specific database service
|
||||
func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID := optionalUserID(c)
|
||||
isAdmin := contextIsAdmin(c)
|
||||
|
||||
databaseID := c.Param("id")
|
||||
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||
ID: databaseID,
|
||||
UserID: userID,
|
||||
})
|
||||
row, err := h.queries.GetDatabaseServiceByID(c.Request.Context(), databaseID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
||||
@@ -219,21 +224,40 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"})
|
||||
return
|
||||
}
|
||||
db := mapDatabaseServiceGetRow(row)
|
||||
db := mapDatabaseServiceRow(row)
|
||||
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
|
||||
|
||||
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
|
||||
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil {
|
||||
privileged := isAdmin || (userID != "" && db.OwnerID == userID)
|
||||
if privileged {
|
||||
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil {
|
||||
db.Backups = backupConfig
|
||||
} else {
|
||||
db.Backups = h.generateMockBackupConfig()
|
||||
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
|
||||
}
|
||||
db.Settings = h.generateMockSettings()
|
||||
db.ConnectionURL = h.resolveConnectionURL(db)
|
||||
db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c))
|
||||
} else {
|
||||
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
|
||||
db.ConnectionURL = ""
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, db)
|
||||
}
|
||||
|
||||
// effectiveUserID returns the user scope for row-level queries. Admins act
|
||||
// on behalf of the row's owner so management works across accounts.
|
||||
func (h *DatabaseHandler) effectiveUserID(c *gin.Context, userID, databaseID string) string {
|
||||
if !contextIsAdmin(c) || databaseID == "" {
|
||||
return userID
|
||||
}
|
||||
owner, err := h.queries.GetDatabaseServiceOwnerID(c.Request.Context(), databaseID)
|
||||
if err == nil && owner != "" {
|
||||
return owner
|
||||
}
|
||||
return userID
|
||||
}
|
||||
|
||||
// CreateDatabase creates a new database service
|
||||
func (h *DatabaseHandler) CreateDatabase(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
@@ -357,6 +381,7 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
databaseID := c.Param("id")
|
||||
userID = h.effectiveUserID(c, userID, databaseID)
|
||||
|
||||
var req DatabaseUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -440,6 +465,7 @@ func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
databaseID := c.Param("id")
|
||||
userID = h.effectiveUserID(c, userID, databaseID)
|
||||
|
||||
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
|
||||
ID: databaseID,
|
||||
@@ -481,6 +507,7 @@ func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
databaseID := c.Param("id")
|
||||
userID = h.effectiveUserID(c, userID, databaseID)
|
||||
|
||||
var req DatabaseActionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -574,6 +601,7 @@ func (h *DatabaseHandler) CreateBackup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
databaseID := c.Param("id")
|
||||
userID = h.effectiveUserID(c, userID, databaseID)
|
||||
|
||||
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||
ID: databaseID,
|
||||
@@ -623,6 +651,7 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
databaseID := c.Param("id")
|
||||
userID = h.effectiveUserID(c, userID, databaseID)
|
||||
|
||||
var req DatabaseRestoreRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -684,6 +713,7 @@ func (h *DatabaseHandler) DownloadBackup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
databaseID := c.Param("id")
|
||||
userID = h.effectiveUserID(c, userID, databaseID)
|
||||
backupID := c.Param("bid")
|
||||
|
||||
backup, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(c.Request.Context(), sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{
|
||||
@@ -904,35 +934,6 @@ func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) generateMockBackupConfig() DatabaseBackupConfig {
|
||||
return DatabaseBackupConfig{
|
||||
Enabled: true,
|
||||
LastBackup: &time.Time{},
|
||||
Retention: 30,
|
||||
NextBackup: &time.Time{},
|
||||
Backups: []DatabaseBackup{
|
||||
{
|
||||
ID: "backup_1",
|
||||
CreatedAt: time.Now().Add(-6 * time.Hour),
|
||||
Size: "245 MB",
|
||||
Status: "completed",
|
||||
},
|
||||
{
|
||||
ID: "backup_2",
|
||||
CreatedAt: time.Now().Add(-24 * time.Hour),
|
||||
Size: "238 MB",
|
||||
Status: "completed",
|
||||
},
|
||||
{
|
||||
ID: "backup_3",
|
||||
CreatedAt: time.Now().Add(-48 * time.Hour),
|
||||
Size: "241 MB",
|
||||
Status: "completed",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
|
||||
return DatabaseSettings{
|
||||
MaxConnections: 100,
|
||||
@@ -942,33 +943,47 @@ func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveConnectionURL returns the stored connection URL for privileged
|
||||
// viewers, or "" when the runtime has not produced one (not provisioned yet).
|
||||
func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string {
|
||||
if strings.TrimSpace(db.ConnectionURL) != "" {
|
||||
return db.ConnectionURL
|
||||
}
|
||||
return h.generateConnectionURL(db)
|
||||
return strings.TrimSpace(db.ConnectionURL)
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) generateConnectionURL(db DatabaseService) string {
|
||||
safeName := sanitizeDBIdentifier(db.Name, "app")
|
||||
switch db.Type {
|
||||
case "postgresql":
|
||||
return fmt.Sprintf("postgresql://user:password@%s.containr.local:5432/%s", safeName, safeName)
|
||||
case "redis":
|
||||
return fmt.Sprintf("redis://%s.containr.local:6379", safeName)
|
||||
case "dragonfly":
|
||||
return fmt.Sprintf("redis://%s.containr.local:6379", safeName)
|
||||
case "mysql":
|
||||
return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", safeName, safeName)
|
||||
case "mariadb":
|
||||
return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", safeName, safeName)
|
||||
case "mongodb":
|
||||
return fmt.Sprintf("mongodb://user:password@%s.containr.local:27017/%s", safeName, safeName)
|
||||
case "clickhouse":
|
||||
return fmt.Sprintf("http://%s.containr.local:8123", safeName)
|
||||
default:
|
||||
// requestHostname returns the host the client used to reach the API, without
|
||||
// the port. Empty when it cannot be determined.
|
||||
func requestHostname(c *gin.Context) string {
|
||||
host := strings.TrimSpace(c.Request.Host)
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
return strings.Trim(h, "[]")
|
||||
}
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
|
||||
// rewriteLoopbackURL swaps loopback/unspecified hosts in rawURL for the host
|
||||
// the caller actually used, so URLs handed to remote browsers are usable.
|
||||
func rewriteLoopbackURL(rawURL, host string) string {
|
||||
if rawURL == "" || host == "" {
|
||||
return rawURL
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
switch strings.ToLower(u.Hostname()) {
|
||||
case "localhost", "127.0.0.1", "0.0.0.0", "::1", "":
|
||||
default:
|
||||
return rawURL
|
||||
}
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
u.Host = host
|
||||
} else {
|
||||
u.Host = net.JoinHostPort(host, port)
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService {
|
||||
@@ -1038,26 +1053,10 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da
|
||||
}
|
||||
}
|
||||
|
||||
func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService {
|
||||
return DatabaseService{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Type: row.Type,
|
||||
Status: row.Status,
|
||||
Version: row.Version,
|
||||
Plan: row.Plan,
|
||||
Region: row.Region,
|
||||
BackupSchedule: databaseNullString(row.BackupSchedule),
|
||||
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
|
||||
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
||||
CreatedAt: databaseNullTime(row.CreatedAt),
|
||||
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService {
|
||||
func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
|
||||
return DatabaseService{
|
||||
ID: row.ID,
|
||||
OwnerID: row.UserID,
|
||||
Name: row.Name,
|
||||
Type: row.Type,
|
||||
Status: row.Status,
|
||||
@@ -1239,13 +1238,13 @@ func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseNam
|
||||
defer cancel()
|
||||
|
||||
if h.dockerClient == nil {
|
||||
fallbackURL := h.generateConnectionURL(DatabaseService{Name: databaseName, Type: dbType})
|
||||
_ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{String: fallbackURL, Valid: fallbackURL != ""})
|
||||
_ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{})
|
||||
return
|
||||
}
|
||||
|
||||
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables)
|
||||
if err != nil {
|
||||
log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err)
|
||||
_ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{})
|
||||
return
|
||||
}
|
||||
@@ -1353,7 +1352,7 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := waitForRuntimePortReadiness(ctx, hostPort); err != nil {
|
||||
if err := h.waitForDatabaseReadiness(ctx, containerID, hostPort, plan.Port.Port()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -1387,12 +1386,24 @@ func (h *DatabaseHandler) resolvePublishedHostPort(ctx context.Context, containe
|
||||
return "", fmt.Errorf("published host port not available")
|
||||
}
|
||||
|
||||
func waitForRuntimePortReadiness(ctx context.Context, hostPort string) error {
|
||||
addr := net.JoinHostPort("127.0.0.1", strings.TrimSpace(hostPort))
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
// waitForDatabaseReadiness probes the database's TCP port until it accepts a
|
||||
// connection. The published port lives on host loopback, which a containerized
|
||||
// backend cannot reach; in that case the database is additionally attached to
|
||||
// the backend's own network and probed by container IP.
|
||||
func (h *DatabaseHandler) waitForDatabaseReadiness(ctx context.Context, containerID, hostPort, containerPort string) error {
|
||||
probeAddr := net.JoinHostPort("127.0.0.1", strings.TrimSpace(hostPort))
|
||||
if backendNet := h.dockerClient.OwnNetworkName(ctx); backendNet != "" {
|
||||
if err := h.dockerClient.ConnectNetwork(ctx, backendNet, containerID, network.EndpointSettings{}); err != nil {
|
||||
log.Printf("containr: attach database %s to %s failed: %v", containerID, backendNet, err)
|
||||
}
|
||||
if ip := h.containerIPOnNetwork(ctx, containerID, backendNet); ip != "" {
|
||||
probeAddr = net.JoinHostPort(ip, containerPort)
|
||||
}
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", addr, 1500*time.Millisecond)
|
||||
conn, err := net.DialTimeout("tcp", probeAddr, 1500*time.Millisecond)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
@@ -1405,7 +1416,18 @@ func waitForRuntimePortReadiness(ctx context.Context, hostPort string) error {
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("database runtime did not become reachable on %s", addr)
|
||||
return fmt.Errorf("database runtime did not become reachable on %s", probeAddr)
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) containerIPOnNetwork(ctx context.Context, containerID, networkName string) string {
|
||||
inspect, err := h.dockerClient.GetContainer(ctx, containerID)
|
||||
if err != nil || inspect.NetworkSettings == nil {
|
||||
return ""
|
||||
}
|
||||
if ep, ok := inspect.NetworkSettings.Networks[networkName]; ok && ep != nil {
|
||||
return strings.TrimSpace(ep.IPAddress)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) ensureImage(ctx context.Context, imageRef string) error {
|
||||
|
||||
@@ -51,15 +51,16 @@ func TestDatabaseConnectionURLAndDefaultVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
url := handler.generateConnectionURL(DatabaseService{
|
||||
Type: tc.dbType,
|
||||
Name: "example",
|
||||
})
|
||||
if url == "" {
|
||||
t.Fatalf("generateConnectionURL returned empty for type %q", tc.dbType)
|
||||
plan, err := buildDatabaseRuntimePlan(tc.dbType, "example", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildDatabaseRuntimePlan(%q) failed: %v", tc.dbType, err)
|
||||
}
|
||||
url := plan.ConnectionURL("15432")
|
||||
if len(tc.urlContains) > 0 && !strings.HasPrefix(url, tc.urlContains) {
|
||||
t.Fatalf("generateConnectionURL(%q) = %q, expected prefix %q", tc.dbType, url, tc.urlContains)
|
||||
t.Fatalf("ConnectionURL(%q) = %q, expected prefix %q", tc.dbType, url, tc.urlContains)
|
||||
}
|
||||
if !strings.Contains(url, ":15432") {
|
||||
t.Fatalf("ConnectionURL(%q) = %q, expected published port", tc.dbType, url)
|
||||
}
|
||||
|
||||
gotVersion := handler.getDefaultVersion(tc.dbType)
|
||||
@@ -69,6 +70,17 @@ func TestDatabaseConnectionURLAndDefaultVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConnectionURLNeverFabricates(t *testing.T) {
|
||||
handler := &DatabaseHandler{}
|
||||
|
||||
if got := handler.resolveConnectionURL(DatabaseService{Type: "postgresql", Name: "example"}); got != "" {
|
||||
t.Fatalf("resolveConnectionURL with no stored URL = %q, want empty", got)
|
||||
}
|
||||
if got := handler.resolveConnectionURL(DatabaseService{ConnectionURL: "postgresql://u:p@localhost:15432/db"}); got != "postgresql://u:p@localhost:15432/db" {
|
||||
t.Fatalf("resolveConnectionURL = %q, want stored URL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDatabaseRuntimePlanSupportsAllTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
dbType string
|
||||
@@ -199,3 +211,23 @@ func TestHumanReadableBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteLoopbackURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, raw, host, want string
|
||||
}{
|
||||
{"service url", "http://localhost:35981", "10.0.0.182", "http://10.0.0.182:35981"},
|
||||
{"db url keeps creds", "postgresql://u:p@localhost:35432/db?sslmode=disable", "10.0.0.182", "postgresql://u:[email protected]:35432/db?sslmode=disable"},
|
||||
{"ipv4 loopback", "redis://:[email protected]:36379/0", "host.lan", "redis://:[email protected]:36379/0"},
|
||||
{"unspecified", "http://0.0.0.0:9000/x", "10.0.0.182", "http://10.0.0.182:9000/x"},
|
||||
{"external host untouched", "https://example.com:443/x", "10.0.0.182", "https://example.com:443/x"},
|
||||
{"empty raw", "", "10.0.0.182", ""},
|
||||
{"empty host", "http://localhost:35981", "", "http://localhost:35981"},
|
||||
{"no port", "http://localhost/path", "10.0.0.182", "http://10.0.0.182/path"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := rewriteLoopbackURL(tt.raw, tt.host); got != tt.want {
|
||||
t.Errorf("%s: rewriteLoopbackURL(%q,%q) = %q, want %q", tt.name, tt.raw, tt.host, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -63,27 +64,13 @@ func handleGetDeployments(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerCheck string
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT p.owner_id FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil {
|
||||
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,6 +107,70 @@ func handleGetDeployments(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
||||
}
|
||||
|
||||
// RecentDeployment is a deployment row joined with its service and project names.
|
||||
type RecentDeployment struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
ServiceName string `json:"service_name"`
|
||||
ProjectName string `json:"project_name"`
|
||||
Status string `json:"status"`
|
||||
ImageName string `json:"image_name"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// handleGetRecentDeployments lists the latest deployments across all of the
|
||||
// caller's services — feeds the dashboard deploy feed.
|
||||
func handleGetRecentDeployments(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := optionalUserUUID(c)
|
||||
isAdmin := contextIsAdmin(c)
|
||||
|
||||
limit := 10
|
||||
if v := c.Query("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := db.(*database.DB).Query(
|
||||
`SELECT d.id, d.service_id, s.name, p.name, d.status,
|
||||
COALESCE(d.image_name, ''), d.started_at, d.completed_at, d.created_at
|
||||
FROM deployments d
|
||||
JOIN services s ON s.id = d.service_id
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
|
||||
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT $3`,
|
||||
userID, isAdmin, limit,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
deployments := []RecentDeployment{}
|
||||
for rows.Next() {
|
||||
var d RecentDeployment
|
||||
if err := rows.Scan(&d.ID, &d.ServiceID, &d.ServiceName, &d.ProjectName,
|
||||
&d.Status, &d.ImageName, &d.StartedAt, &d.CompletedAt, &d.CreatedAt); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"})
|
||||
return
|
||||
}
|
||||
deployments = append(deployments, d)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
||||
}
|
||||
|
||||
func handleCreateDeployment(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
@@ -155,6 +206,9 @@ func handleCreateDeployment(c *gin.Context) {
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
s.created_at, s.updated_at, p.owner_id
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
@@ -164,7 +218,8 @@ func handleCreateDeployment(c *gin.Context) {
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.CreatedAt, &service.UpdatedAt, &projectOwner,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectOwner,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -256,6 +311,22 @@ func runDeploymentAndSync(
|
||||
service Service,
|
||||
req CreateDeploymentRequest,
|
||||
userID string,
|
||||
) {
|
||||
runDeploymentAndSyncWithImage(parentCtx, db, engine, dbDeployment, service, req, userID, "")
|
||||
}
|
||||
|
||||
// runDeploymentAndSyncWithImage deploys imageOverride as a prebuilt image
|
||||
// (rollback / redeploy of an existing tag) when set; otherwise builds from
|
||||
// source or pulls the service image.
|
||||
func runDeploymentAndSyncWithImage(
|
||||
parentCtx context.Context,
|
||||
db *database.DB,
|
||||
engine *deployment.DeploymentEngine,
|
||||
dbDeployment *DeploymentModel,
|
||||
service Service,
|
||||
req CreateDeploymentRequest,
|
||||
userID string,
|
||||
imageOverride string,
|
||||
) {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
|
||||
defer cancel()
|
||||
@@ -265,6 +336,32 @@ func runDeploymentAndSync(
|
||||
sourcePath = "."
|
||||
}
|
||||
|
||||
// Resolve the runtime env: stored service variables (with ${{svc.KEY}}
|
||||
// references expanded) plus any per-deployment overrides.
|
||||
env, envErr := resolveServiceEnv(db, service)
|
||||
if envErr != nil {
|
||||
env = map[string]string{}
|
||||
}
|
||||
for k, v := range req.EnvVars {
|
||||
env[k] = v
|
||||
}
|
||||
|
||||
var command []string
|
||||
if cmd := strings.TrimSpace(service.Command); cmd != "" {
|
||||
command = strings.Fields(cmd)
|
||||
}
|
||||
|
||||
replicas := service.Replicas
|
||||
if replicas < 1 {
|
||||
replicas = 1
|
||||
}
|
||||
|
||||
var publishedPort int32
|
||||
// Best effort: reuse the host port from the last live deployment so public
|
||||
// URLs survive redeploys. Column exists post-migration; older DBs get
|
||||
// ephemeral ports.
|
||||
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort)
|
||||
|
||||
deployReq := &deployment.DeploymentRequest{
|
||||
ProjectID: service.ProjectID.String(),
|
||||
ServiceID: service.ID.String(),
|
||||
@@ -272,14 +369,18 @@ func runDeploymentAndSync(
|
||||
Config: deployment.ServiceConfig{
|
||||
Name: service.Name,
|
||||
Image: service.Image,
|
||||
Environment: req.EnvVars,
|
||||
Replicas: 1,
|
||||
Command: command,
|
||||
Environment: env,
|
||||
Replicas: replicas,
|
||||
PublicPort: int32(service.Port),
|
||||
PublishedPort: publishedPort,
|
||||
Domain: service.Domain,
|
||||
HealthPath: service.HealthCheckPath,
|
||||
RestartPolicy: service.RestartPolicy,
|
||||
Resources: deployment.ResourceLimits{
|
||||
MemoryBytes: parseMemoryLimit(service.Memory),
|
||||
CPUQuota: parseCPULimit(service.CPU),
|
||||
},
|
||||
BuildConfig: &deployment.BuildConfig{
|
||||
BuildType: "nixpacks",
|
||||
SourcePath: sourcePath,
|
||||
Branch: req.Branch,
|
||||
Commit: req.CommitHash,
|
||||
},
|
||||
Trigger: deployment.TriggerConfig{
|
||||
Type: req.Trigger,
|
||||
@@ -289,6 +390,26 @@ func runDeploymentAndSync(
|
||||
},
|
||||
}
|
||||
|
||||
if imageOverride != "" {
|
||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||
BuildType: "prebuilt",
|
||||
PrebuiltImage: imageOverride,
|
||||
}
|
||||
} else if service.GitRepo == "" {
|
||||
// Image-sourced service: pull the image instead of building.
|
||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||
BuildType: "prebuilt",
|
||||
PrebuiltImage: service.Image,
|
||||
}
|
||||
} else {
|
||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||
BuildType: "nixpacks",
|
||||
SourcePath: sourcePath,
|
||||
Branch: req.Branch,
|
||||
Commit: req.CommitHash,
|
||||
}
|
||||
}
|
||||
|
||||
engineDeployment, err := engine.Deploy(ctx, deployReq)
|
||||
if err != nil {
|
||||
failedAt := time.Now()
|
||||
@@ -427,18 +548,12 @@ func handleGetDeployment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var d DeploymentModel
|
||||
var ownerCheck string
|
||||
var projectID uuid.UUID
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
|
||||
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
|
||||
d.created_at, d.updated_at, p.owner_id
|
||||
d.created_at, d.updated_at, p.id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
@@ -447,7 +562,7 @@ func handleGetDeployment(c *gin.Context) {
|
||||
).Scan(
|
||||
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
|
||||
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
|
||||
&d.CreatedAt, &d.UpdatedAt, &ownerCheck,
|
||||
&d.CreatedAt, &d.UpdatedAt, &projectID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -455,8 +570,8 @@ func handleGetDeployment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -509,7 +624,7 @@ func handleRollbackDeployment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
if ownerCheck != userID.(string) && !contextIsAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
@@ -550,17 +665,58 @@ func handleRollbackDeployment(c *gin.Context) {
|
||||
time.Now(), serviceID,
|
||||
)
|
||||
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
db.(*database.DB).Exec(
|
||||
`UPDATE deployments SET status = 'deployed', completed_at = $1, updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), rollbackID,
|
||||
// Real rollback: redeploy the target deployment's image.
|
||||
engineValue, _ := c.Get("deployment_engine")
|
||||
engine, _ := engineValue.(*deployment.DeploymentEngine)
|
||||
targetImage := targetDeployment.ImageName
|
||||
if targetDeployment.ImageTag != "" {
|
||||
targetImage += ":" + targetDeployment.ImageTag
|
||||
}
|
||||
|
||||
if engine == nil || targetImage == "" {
|
||||
completedAt := time.Now()
|
||||
reason := "Rollback image unavailable"
|
||||
if engine == nil {
|
||||
reason = "Deployment engine unavailable. Docker may not be configured on this server."
|
||||
}
|
||||
_, _ = db.(*database.DB).Exec(
|
||||
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
|
||||
reason, completedAt, rollbackID,
|
||||
)
|
||||
db.(*database.DB).Exec(
|
||||
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), serviceID,
|
||||
_, _ = db.(*database.DB).Exec(
|
||||
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
||||
completedAt, serviceID,
|
||||
)
|
||||
}()
|
||||
} else {
|
||||
var service Service
|
||||
_ = db.(*database.DB).QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s WHERE s.id = $1`, serviceID,
|
||||
).Scan(
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
||||
)
|
||||
|
||||
rollbackReq := CreateDeploymentRequest{
|
||||
CommitHash: func() string {
|
||||
if targetDeployment.CommitHash != nil {
|
||||
return *targetDeployment.CommitHash
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
Trigger: "rollback",
|
||||
}
|
||||
// Reuse the stored image directly — no rebuild.
|
||||
go runDeploymentAndSyncWithImage(context.Background(), db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"deployment": DeploymentResponse{
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/docker"
|
||||
@@ -17,7 +18,6 @@ import (
|
||||
// commands run as the container's default user with a 30s ceiling.
|
||||
func handleExecInService(c *gin.Context) {
|
||||
serviceID := c.Param("id")
|
||||
userID := c.MustGet("user_id").(string)
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
var req struct {
|
||||
@@ -33,17 +33,20 @@ func handleExecInService(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var ownerID string
|
||||
err := db.QueryRow(
|
||||
`SELECT p.owner_id FROM services s
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(&ownerID)
|
||||
if err != nil || ownerID != userID {
|
||||
serviceUUID, err := uuid.Parse(serviceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||
return
|
||||
}
|
||||
projectID, found := serviceProjectID(db, serviceUUID)
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
if _, allowed := projectManageAccess(c, db, projectID); !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
dockerClient, _ := c.Get("docker_client")
|
||||
dockerCli, _ := dockerClient.(*docker.Client)
|
||||
|
||||
@@ -25,43 +25,58 @@ func NewHAManager(haManager *ha.HighAvailabilityManager) *HAManager {
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers HA routes
|
||||
func (h *HAManager) RegisterRoutes(router *gin.RouterGroup) {
|
||||
// RegisterReadRoutes registers public HA read endpoints.
|
||||
func (h *HAManager) RegisterReadRoutes(router *gin.RouterGroup) {
|
||||
ha := router.Group("/ha")
|
||||
{
|
||||
ha.GET("/status", h.GetHAStatus)
|
||||
|
||||
// Failover policies
|
||||
ha.GET("/failover/policies", h.GetFailoverPolicies)
|
||||
ha.GET("/failover/policies/:serviceId", h.GetFailoverPolicy)
|
||||
|
||||
// Health checks
|
||||
ha.GET("/health/checks", h.GetHealthChecks)
|
||||
ha.GET("/health/checks/:checkId", h.GetHealthCheck)
|
||||
ha.GET("/health/results", h.GetHealthResults)
|
||||
|
||||
// Alerts
|
||||
ha.GET("/alerts/rules", h.GetAlertRules)
|
||||
ha.GET("/alerts/rules/:ruleId", h.GetAlertRule)
|
||||
ha.GET("/alerts/active", h.GetActiveAlerts)
|
||||
|
||||
// Notifiers
|
||||
ha.GET("/notifiers", h.GetNotifiers)
|
||||
ha.GET("/notifiers/:notifierId", h.GetNotifier)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterAdminRoutes registers HA mutation endpoints.
|
||||
func (h *HAManager) RegisterAdminRoutes(router *gin.RouterGroup) {
|
||||
ha := router.Group("/ha")
|
||||
{
|
||||
ha.POST("/enable", h.EnableHA)
|
||||
ha.POST("/disable", h.DisableHA)
|
||||
ha.POST("/failover", h.TriggerFailover)
|
||||
|
||||
// Failover policies
|
||||
ha.GET("/failover/policies", h.GetFailoverPolicies)
|
||||
ha.POST("/failover/policies", h.SetFailoverPolicy)
|
||||
ha.GET("/failover/policies/:serviceId", h.GetFailoverPolicy)
|
||||
ha.PUT("/failover/policies/:serviceId", h.UpdateFailoverPolicy)
|
||||
ha.DELETE("/failover/policies/:serviceId", h.DeleteFailoverPolicy)
|
||||
|
||||
// Health checks
|
||||
ha.GET("/health/checks", h.GetHealthChecks)
|
||||
ha.POST("/health/checks", h.AddHealthCheck)
|
||||
ha.GET("/health/checks/:checkId", h.GetHealthCheck)
|
||||
ha.PUT("/health/checks/:checkId", h.UpdateHealthCheck)
|
||||
ha.DELETE("/health/checks/:checkId", h.DeleteHealthCheck)
|
||||
ha.GET("/health/results", h.GetHealthResults)
|
||||
|
||||
// Alerts
|
||||
ha.GET("/alerts/rules", h.GetAlertRules)
|
||||
ha.POST("/alerts/rules", h.AddAlertRule)
|
||||
ha.GET("/alerts/rules/:ruleId", h.GetAlertRule)
|
||||
ha.PUT("/alerts/rules/:ruleId", h.UpdateAlertRule)
|
||||
ha.DELETE("/alerts/rules/:ruleId", h.DeleteAlertRule)
|
||||
ha.GET("/alerts/active", h.GetActiveAlerts)
|
||||
ha.POST("/alerts/:alertId/resolve", h.ResolveAlert)
|
||||
|
||||
// Notifiers
|
||||
ha.GET("/notifiers", h.GetNotifiers)
|
||||
ha.POST("/notifiers", h.AddNotifier)
|
||||
ha.GET("/notifiers/:notifierId", h.GetNotifier)
|
||||
ha.DELETE("/notifiers/:notifierId", h.DeleteNotifier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -35,27 +37,13 @@ func handleGetLogs(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerCheck string
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT p.owner_id FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil {
|
||||
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -73,7 +61,21 @@ func handleGetLogs(c *gin.Context) {
|
||||
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
|
||||
return
|
||||
}
|
||||
containerName := fmt.Sprintf("containr-%s", serviceID)
|
||||
// Replicas are named containr-<serviceID>-<n>; resolve them by label and
|
||||
// merge logs across all of them.
|
||||
ctx := context.Background()
|
||||
containers, err := client.ListContainersFiltered(ctx, filters.NewArgs(
|
||||
filters.Arg("label", "containr.service="+serviceID.String()),
|
||||
), false)
|
||||
if err != nil {
|
||||
respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.")
|
||||
return
|
||||
}
|
||||
if len(containers) == 0 {
|
||||
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
|
||||
return
|
||||
}
|
||||
sort.Slice(containers, func(i, j int) bool { return containers[i].Names[0] < containers[j].Names[0] })
|
||||
|
||||
logOpts := docker.LogOptions{
|
||||
Stdout: true,
|
||||
@@ -83,17 +85,25 @@ func handleGetLogs(c *gin.Context) {
|
||||
Timestamps: true,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts)
|
||||
if err != nil {
|
||||
if isDockerNotFoundError(err) {
|
||||
var readers []io.ReadCloser
|
||||
var streams []io.Reader
|
||||
for _, ctr := range containers {
|
||||
r, rerr := client.GetContainerLogs(ctx, ctr.ID, logOpts)
|
||||
if rerr == nil {
|
||||
readers = append(readers, r)
|
||||
streams = append(streams, r)
|
||||
}
|
||||
}
|
||||
if len(readers) == 0 {
|
||||
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
|
||||
return
|
||||
}
|
||||
respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.")
|
||||
return
|
||||
logsReader := io.MultiReader(streams...)
|
||||
defer func() {
|
||||
for _, r := range readers {
|
||||
_ = r.Close()
|
||||
}
|
||||
defer logsReader.Close()
|
||||
}()
|
||||
|
||||
if follow {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
@@ -185,30 +195,24 @@ func handleGetDeploymentLogs(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var buildLog, runtimeLog string
|
||||
var ownerCheck string
|
||||
var projectID uuid.UUID
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT d.build_log, d.runtime_log, p.owner_id
|
||||
`SELECT d.build_log, d.runtime_log, p.id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE d.id = $1`,
|
||||
deploymentID,
|
||||
).Scan(&buildLog, &runtimeLog, &ownerCheck)
|
||||
).Scan(&buildLog, &runtimeLog, &projectID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -60,26 +60,19 @@ func handleGetServiceMetrics(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerID string
|
||||
var projectID uuid.UUID
|
||||
var status string
|
||||
err = db.QueryRow(
|
||||
`SELECT p.owner_id, COALESCE(s.status, '')
|
||||
FROM services s JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
`SELECT s.project_id, COALESCE(s.status, '')
|
||||
FROM services s WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(&ownerID, &status)
|
||||
).Scan(&projectID, &status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
if ownerID != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -69,31 +69,13 @@ func handleGetPreviewEnvironments(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if project exists and user has access
|
||||
var project Project
|
||||
err = db.(*database.DB).QueryRow(
|
||||
"SELECT id, name, owner_id FROM projects WHERE id = $1",
|
||||
projectID,
|
||||
).Scan(&project.ID, &project.Name, &project.OwnerID)
|
||||
|
||||
if err != nil {
|
||||
// Approved projects are public; unapproved ones need owner/member/admin.
|
||||
projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID)
|
||||
if !projectExists || !allowed {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user ID from JWT token (set by auth middleware)
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user owns the project
|
||||
if project.OwnerID != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get preview environments for the project with service info
|
||||
rows, err := db.(*database.DB).Query(
|
||||
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
|
||||
@@ -193,7 +175,7 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Check if user owns the project
|
||||
if project.OwnerID != userID.(string) {
|
||||
if project.OwnerID != userID.(string) && !contextIsAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
@@ -332,14 +314,8 @@ func handleGetPreviewEnvironment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get user ID from JWT token
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get preview environment with project ownership check
|
||||
// Public when the parent project is approved; otherwise owner/member/admin.
|
||||
userID := optionalUserUUID(c)
|
||||
var env PreviewEnvironment
|
||||
var serviceID sql.NullString
|
||||
var serviceName sql.NullString
|
||||
@@ -351,8 +327,9 @@ func handleGetPreviewEnvironment(c *gin.Context) {
|
||||
FROM preview_environments pe
|
||||
LEFT JOIN services s ON pe.service_id = s.id
|
||||
JOIN projects p ON pe.project_id = p.id
|
||||
WHERE pe.id = $1 AND p.owner_id = $2`,
|
||||
envID, userID,
|
||||
WHERE pe.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
|
||||
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
|
||||
envID, userID, contextIsAdmin(c),
|
||||
).Scan(
|
||||
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
|
||||
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
|
||||
@@ -414,8 +391,8 @@ func handleUpdatePreviewEnvironment(c *gin.Context) {
|
||||
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at
|
||||
FROM preview_environments pe
|
||||
JOIN projects p ON pe.project_id = p.id
|
||||
WHERE pe.id = $1 AND p.owner_id = $2`,
|
||||
envID, userID,
|
||||
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
|
||||
envID, userID, contextIsAdmin(c),
|
||||
).Scan(
|
||||
&existingEnv.ID, &existingEnv.ProjectID, &existingEnv.ServiceID, &existingEnv.BranchName,
|
||||
&existingEnv.PRNumber, &existingEnv.Environment, &existingEnv.Status, &existingEnv.URL,
|
||||
@@ -498,7 +475,7 @@ func handleDeletePreviewEnvironment(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Check if user owns the project
|
||||
if projectOwnerID != userID.(string) {
|
||||
if projectOwnerID != userID.(string) && !contextIsAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
@@ -568,8 +545,8 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
|
||||
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment, pe.status
|
||||
FROM preview_environments pe
|
||||
JOIN projects p ON pe.project_id = p.id
|
||||
WHERE pe.id = $1 AND p.owner_id = $2`,
|
||||
envID, userID,
|
||||
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
|
||||
envID, userID, contextIsAdmin(c),
|
||||
).Scan(
|
||||
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.Status,
|
||||
)
|
||||
@@ -665,8 +642,8 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
|
||||
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment
|
||||
FROM preview_environments pe
|
||||
JOIN projects p ON pe.project_id = p.id
|
||||
WHERE p.owner_id = $1 AND pe.expires_at < NOW() AND pe.status != 'expired'`,
|
||||
userID,
|
||||
WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'`,
|
||||
userID, contextIsAdmin(c),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find expired preview environments"})
|
||||
|
||||
@@ -86,3 +86,19 @@ func TestRequireAuthenticatedUserIDRejectsMissingUserContext(t *testing.T) {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentDeploymentsAllowAnonymousRead(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/api/v1/deployments", handleGetRecentDeployments)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/deployments", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
// Deployments are publicly readable; with no db in context the handler
|
||||
// fails at the dependency boundary, never at an auth check.
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package api
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -19,6 +21,7 @@ type Project struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
IsApproved bool `json:"is_approved"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
@@ -46,10 +49,8 @@ type UpdateProjectRequest struct {
|
||||
}
|
||||
|
||||
func handleGetProjects(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID := optionalUserUUID(c)
|
||||
isAdmin := contextIsAdmin(c)
|
||||
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
@@ -73,6 +74,7 @@ func handleGetProjects(c *gin.Context) {
|
||||
|
||||
rows, err := queries.ListProjectsWithStatsByUser(c.Request.Context(), sqlcdb.ListProjectsWithStatsByUserParams{
|
||||
UserID: userID,
|
||||
IsAdmin: isAdmin,
|
||||
Search: searchParam,
|
||||
LimitCount: int32(limit),
|
||||
OffsetCount: int32(offset),
|
||||
@@ -89,6 +91,7 @@ func handleGetProjects(c *gin.Context) {
|
||||
|
||||
total, err := queries.CountProjectsByUser(c.Request.Context(), sqlcdb.CountProjectsByUserParams{
|
||||
UserID: userID,
|
||||
IsAdmin: isAdmin,
|
||||
Search: searchParam,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -169,10 +172,7 @@ func handleCreateProject(c *gin.Context) {
|
||||
}
|
||||
|
||||
func handleGetProject(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID := optionalUserUUID(c)
|
||||
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
@@ -185,6 +185,7 @@ func handleGetProject(c *gin.Context) {
|
||||
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
|
||||
ProjectID: projectID,
|
||||
UserID: userID,
|
||||
IsAdmin: contextIsAdmin(c),
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||
@@ -215,6 +216,7 @@ func handleUpdateProject(c *gin.Context) {
|
||||
role, err := queries.GetProjectRoleForUser(c.Request.Context(), sqlcdb.GetProjectRoleForUserParams{
|
||||
ProjectID: projectID,
|
||||
UserID: userID,
|
||||
IsAdmin: contextIsAdmin(c),
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) || role == "" || role == "viewer" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
|
||||
@@ -268,6 +270,7 @@ func handleUpdateProject(c *gin.Context) {
|
||||
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
|
||||
ProjectID: projectID,
|
||||
UserID: userID,
|
||||
IsAdmin: contextIsAdmin(c),
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||
@@ -305,11 +308,21 @@ func handleDeleteProject(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if ownerID != userID {
|
||||
if ownerID != userID && !contextIsAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Only project owners can delete projects"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove live containers and the project network before dropping rows.
|
||||
// Best-effort: Docker may be unavailable; DB cleanup proceeds regardless.
|
||||
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||
engine := engineValue.(*deployment.DeploymentEngine)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
_ = engine.RemoveProjectContainers(ctx, projectID.String())
|
||||
_ = engine.RemoveProjectNetwork(ctx, projectID.String())
|
||||
cancel()
|
||||
}
|
||||
|
||||
deletedRows, err := queries.DeleteProjectByID(c.Request.Context(), projectID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete project"})
|
||||
@@ -349,7 +362,7 @@ func parseProjectIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
|
||||
func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWithStats {
|
||||
result := ProjectWithStats{
|
||||
Project: mapProjectFields(row.ID, row.Name, row.Description, row.OwnerID, row.CreatedAt, row.UpdatedAt),
|
||||
Project: mapProjectFields(row.ID, row.Name, row.Description, row.OwnerID, row.IsApproved, row.CreatedAt, row.UpdatedAt),
|
||||
Stats: ProjectStats{
|
||||
ServiceCount: int(row.ServiceCount),
|
||||
DeploymentCount: int(row.DeploymentCount),
|
||||
@@ -361,15 +374,16 @@ func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWi
|
||||
}
|
||||
|
||||
func mapSQLCProject(project sqlcdb.Project) Project {
|
||||
return mapProjectFields(project.ID, project.Name, project.Description, project.OwnerID, project.CreatedAt, project.UpdatedAt)
|
||||
return mapProjectFields(project.ID, project.Name, project.Description, project.OwnerID, project.IsApproved, project.CreatedAt, project.UpdatedAt)
|
||||
}
|
||||
|
||||
func mapProjectFields(id uuid.UUID, name string, description sql.NullString, ownerID uuid.UUID, createdAt, updatedAt sql.NullTime) Project {
|
||||
func mapProjectFields(id uuid.UUID, name string, description sql.NullString, ownerID uuid.UUID, isApproved bool, createdAt, updatedAt sql.NullTime) Project {
|
||||
return Project{
|
||||
ID: id.String(),
|
||||
Name: name,
|
||||
Description: nullableStringValue(description),
|
||||
OwnerID: ownerID.String(),
|
||||
IsApproved: isApproved,
|
||||
CreatedAt: nullableTimestampString(createdAt),
|
||||
UpdatedAt: nullableTimestampString(updatedAt),
|
||||
}
|
||||
|
||||
+190
-122
@@ -35,6 +35,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
dockerClient = client
|
||||
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
|
||||
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
|
||||
// Restore managed sidecars (e.g. cloudflared) from saved settings.
|
||||
syncManagedContainers(dockerClient, db)
|
||||
}
|
||||
|
||||
// Initialize build handler
|
||||
@@ -55,6 +57,33 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
}
|
||||
}
|
||||
haManager := ha.NewHighAvailabilityManager(scheduler, metricsCollector)
|
||||
if db != nil && db.DB != nil {
|
||||
haManager.WithPersistence(db.DB)
|
||||
if err := haManager.LoadHealthChecks(context.Background()); err != nil {
|
||||
log.Printf("Failed to restore health checks: %v", err)
|
||||
}
|
||||
}
|
||||
if dockerClient != nil && db != nil {
|
||||
// Service-bound checks probe the service DNS name on the project
|
||||
// network — the backend container joins it on demand. The old
|
||||
// 127.0.0.1 fallback stays for node-bound checks.
|
||||
docker := dockerClient
|
||||
haManager.WithServiceResolver(func(ctx context.Context, serviceID string) (string, int, error) {
|
||||
var name, projectID string
|
||||
var port int
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT name, project_id::text, COALESCE(port, 0) FROM services WHERE id = $1`,
|
||||
serviceID,
|
||||
).Scan(&name, &projectID, &port)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(projectID)); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return name, port, nil
|
||||
})
|
||||
}
|
||||
haAPIManager := NewHAManager(haManager)
|
||||
|
||||
// Initialize scaling handler
|
||||
@@ -94,6 +123,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// Seed the official service template catalog (idempotent upserts).
|
||||
go SeedOfficialTemplates(context.Background(), db)
|
||||
|
||||
go func() {
|
||||
if err := haManager.Start(context.Background()); err != nil {
|
||||
log.Printf("HA manager exited: %v", err)
|
||||
@@ -134,7 +166,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
|
||||
if redis == nil {
|
||||
redisStatus = "unhealthy"
|
||||
checks["redis"] = redisStatus
|
||||
checks["redis"] = "unhealthy"
|
||||
checks["redisError"] = "redis client not initialized"
|
||||
overallStatus = "degraded"
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
@@ -175,151 +207,187 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
public.POST("/auth/register", handleRegister)
|
||||
}
|
||||
|
||||
// Protected routes (authentication required)
|
||||
protected := v1.Group("/")
|
||||
protected.Use(middleware.Auth(cfg.JWTSecret))
|
||||
// Read routes — public browsing. OptionalAuth resolves a session when
|
||||
// present so handlers can widen visibility for owners/admins; anonymous
|
||||
// callers see approved resources only.
|
||||
read := v1.Group("/")
|
||||
read.Use(middleware.OptionalAuth(cfg.JWTSecret))
|
||||
{
|
||||
// User routes
|
||||
protected.GET("/user/profile", handleGetProfile)
|
||||
protected.PUT("/user/profile", handleUpdateProfile)
|
||||
protected.POST("/users", handleCreateUser)
|
||||
read.GET("/projects", handleGetProjects)
|
||||
read.GET("/projects/:id", handleGetProject)
|
||||
read.GET("/projects/:id/services", handleGetServices)
|
||||
read.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
|
||||
read.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
|
||||
read.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
|
||||
read.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics)
|
||||
|
||||
// Project routes
|
||||
protected.GET("/projects", handleGetProjects)
|
||||
protected.POST("/projects", handleCreateProject)
|
||||
read.GET("/services/:id", handleGetService)
|
||||
read.GET("/services/:id/metrics", handleGetServiceMetrics)
|
||||
read.GET("/services/:id/runtime", handleGetServiceRuntime)
|
||||
read.GET("/services/:id/deployments", handleGetDeployments)
|
||||
read.GET("/deployments", handleGetRecentDeployments)
|
||||
read.GET("/deployments/:id", handleGetDeployment)
|
||||
|
||||
// Service routes (nested under projects)
|
||||
protected.GET("/projects/:id/services", handleGetServices)
|
||||
protected.POST("/projects/:id/services", handleCreateService)
|
||||
read.GET("/databases", databaseHandler.GetDatabases)
|
||||
read.GET("/databases/:id", databaseHandler.GetDatabase)
|
||||
|
||||
// Generic project routes
|
||||
protected.GET("/projects/:id", handleGetProject)
|
||||
protected.PUT("/projects/:id", handleUpdateProject)
|
||||
protected.DELETE("/projects/:id", handleDeleteProject)
|
||||
read.GET("/templates", handleGetTemplates)
|
||||
read.GET("/templates/:id", handleGetTemplate)
|
||||
|
||||
// Service routes
|
||||
protected.GET("/services/:id", handleGetService)
|
||||
protected.PUT("/services/:id", handleUpdateService)
|
||||
protected.DELETE("/services/:id", handleDeleteService)
|
||||
protected.GET("/services/:id/metrics", handleGetServiceMetrics)
|
||||
read.GET("/cron-jobs", handleGetCronJobs)
|
||||
read.GET("/cron-jobs/:id", handleGetCronJob)
|
||||
read.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
|
||||
|
||||
// Deployment routes
|
||||
protected.GET("/services/:id/deployments", handleGetDeployments)
|
||||
protected.POST("/services/:id/deployments", handleCreateDeployment)
|
||||
protected.GET("/deployments/:id", handleGetDeployment)
|
||||
protected.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
||||
read.GET("/security/scans/:id", securityHandler.GetSecurityScan)
|
||||
read.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
|
||||
read.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
|
||||
|
||||
// Environment variables routes
|
||||
protected.GET("/services/:id/variables", handleGetVariables)
|
||||
protected.PUT("/services/:id/variables", handleUpdateVariables)
|
||||
read.GET("/preview-environments/:id", handleGetPreviewEnvironment)
|
||||
|
||||
// Logs routes
|
||||
protected.GET("/services/:id/logs", handleGetLogs)
|
||||
protected.GET("/deployments/:id/logs", handleGetDeploymentLogs)
|
||||
read.GET("/system/upgrade/status", handleGetUpgradeStatus)
|
||||
read.GET("/system/host", handleGetHostMonitoring)
|
||||
|
||||
// One-off exec console (docker exec, 30s ceiling)
|
||||
protected.POST("/services/:id/exec", handleExecInService)
|
||||
// HA + scaling read endpoints (status, policies, health, alerts)
|
||||
haAPIManager.RegisterReadRoutes(read)
|
||||
scalingHandler.RegisterReadRoutes(read)
|
||||
}
|
||||
|
||||
// Git integration routes
|
||||
protected.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
|
||||
protected.POST("/git/github-app/connect", handleConnectGitHubApp)
|
||||
protected.GET("/git/providers", handleGetGitProviders)
|
||||
protected.POST("/git/providers", handleCreateGitProvider)
|
||||
protected.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
|
||||
protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
|
||||
protected.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
|
||||
protected.POST("/git/repositories/connect", handleConnectGitRepository)
|
||||
protected.GET("/git/repositories", handleGetConnectedRepositories)
|
||||
protected.POST("/git/webhooks", handleCreateWebhook)
|
||||
// Authenticated routes — a valid session is required and handlers
|
||||
// enforce ownership; platform admins additionally pass every check.
|
||||
authed := v1.Group("/")
|
||||
authed.Use(middleware.Auth(cfg.JWTSecret))
|
||||
{
|
||||
authed.GET("/user/profile", handleGetProfile)
|
||||
authed.PUT("/user/profile", handleUpdateProfile)
|
||||
|
||||
// Build routes
|
||||
protected.POST("/builds", buildHandler.StartBuild)
|
||||
protected.GET("/builds", buildHandler.ListBuilds)
|
||||
protected.GET("/builds/:id", buildHandler.GetBuildStatus)
|
||||
protected.POST("/builds/:id/cancel", buildHandler.CancelBuild)
|
||||
protected.GET("/builds/:id/logs", buildHandler.GetBuildLogs)
|
||||
protected.POST("/builds/plan", buildHandler.GetBuildPlan)
|
||||
protected.GET("/builds/detect", buildHandler.DetectBuildType)
|
||||
// Personal access tokens for CLI/MCP/agent auth.
|
||||
authed.GET("/user/tokens", handleListUserTokens)
|
||||
authed.POST("/user/tokens", handleCreateUserToken)
|
||||
authed.DELETE("/user/tokens/:id", handleDeleteUserToken)
|
||||
|
||||
// System routes
|
||||
protected.GET("/system/upgrade/status", handleGetUpgradeStatus)
|
||||
protected.POST("/system/upgrade/pull", handlePullUpgradeImage)
|
||||
protected.GET("/system/host", handleGetHostMonitoring)
|
||||
|
||||
// Scaling routes
|
||||
scalingHandler.RegisterRoutes(protected)
|
||||
haAPIManager.RegisterRoutes(protected)
|
||||
|
||||
// Database routes
|
||||
protected.GET("/databases", databaseHandler.GetDatabases)
|
||||
protected.POST("/databases", databaseHandler.CreateDatabase)
|
||||
protected.GET("/databases/:id", databaseHandler.GetDatabase)
|
||||
protected.PUT("/databases/:id", databaseHandler.UpdateDatabase)
|
||||
protected.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
|
||||
protected.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
|
||||
protected.POST("/databases/:id/backup", databaseHandler.CreateBackup)
|
||||
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
|
||||
protected.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
|
||||
|
||||
// Notification routes
|
||||
protected.GET("/notifications", handleListNotifications)
|
||||
protected.POST("/notifications/:id/read", handleMarkNotificationRead)
|
||||
protected.POST("/notifications/read-all", handleMarkAllNotificationsRead)
|
||||
|
||||
// Node Agent routes
|
||||
agentHandler.SetupRoutes(protected)
|
||||
|
||||
// Preview Environments routes
|
||||
protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
|
||||
protected.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
|
||||
protected.GET("/preview-environments/:id", handleGetPreviewEnvironment)
|
||||
protected.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
|
||||
protected.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
|
||||
protected.POST("/preview-environments/:id/promote", handlePromotePreviewEnvironment)
|
||||
protected.POST("/preview-environments/cleanup-expired", handleCleanupExpiredPreviewEnvironments)
|
||||
|
||||
// Security routes
|
||||
protected.POST("/security/scans", securityHandler.StartSecurityScan)
|
||||
protected.GET("/security/scans/:id", securityHandler.GetSecurityScan)
|
||||
protected.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
|
||||
protected.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
|
||||
protected.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability)
|
||||
protected.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
|
||||
protected.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
|
||||
protected.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
|
||||
protected.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
|
||||
protected.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics)
|
||||
protected.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs)
|
||||
authed.GET("/notifications", handleListNotifications)
|
||||
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
|
||||
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
|
||||
|
||||
// WebSocket endpoint
|
||||
protected.GET("/ws", handleWebSocket)
|
||||
authed.GET("/ws", handleWebSocket)
|
||||
|
||||
// Templates routes
|
||||
protected.GET("/templates", handleGetTemplates)
|
||||
protected.GET("/templates/:id", handleGetTemplate)
|
||||
protected.POST("/templates/:id/deploy", handleCreateFromTemplate)
|
||||
// Owned-resource mutations — handlers scope to the caller and
|
||||
// admit admins. New projects land unapproved until an admin
|
||||
// publishes them.
|
||||
authed.POST("/projects", handleCreateProject)
|
||||
authed.POST("/projects/:id/services", handleCreateService)
|
||||
authed.PUT("/projects/:id", handleUpdateProject)
|
||||
authed.DELETE("/projects/:id", handleDeleteProject)
|
||||
|
||||
// Cron Jobs routes
|
||||
protected.GET("/cron-jobs", handleGetCronJobs)
|
||||
protected.POST("/cron-jobs", handleCreateCronJob)
|
||||
protected.GET("/cron-jobs/:id", handleGetCronJob)
|
||||
protected.PUT("/cron-jobs/:id", handleUpdateCronJob)
|
||||
protected.DELETE("/cron-jobs/:id", handleDeleteCronJob)
|
||||
protected.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
|
||||
protected.POST("/cron-jobs/:id/trigger", handleTriggerCronJob)
|
||||
authed.PUT("/services/:id", handleUpdateService)
|
||||
authed.DELETE("/services/:id", handleDeleteService)
|
||||
authed.POST("/services/:id/start", handleServiceStart)
|
||||
authed.POST("/services/:id/stop", handleServiceStop)
|
||||
authed.POST("/services/:id/restart", handleServiceRestart)
|
||||
authed.POST("/services/:id/redeploy", handleServiceRedeploy)
|
||||
authed.POST("/services/:id/deployments", handleCreateDeployment)
|
||||
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
||||
|
||||
// Audit Logs routes
|
||||
protected.GET("/audit-logs", handleGetAuditLogs)
|
||||
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
||||
// Environment variables — secrets, owner/admin only.
|
||||
authed.GET("/services/:id/variables", handleGetVariables)
|
||||
authed.PUT("/services/:id/variables", handleUpdateVariables)
|
||||
|
||||
// Runtime logs — container stdout routinely echoes secrets,
|
||||
// so these stay behind authentication like build logs.
|
||||
authed.GET("/services/:id/logs", handleGetLogs)
|
||||
authed.GET("/deployments/:id/logs", handleGetDeploymentLogs)
|
||||
|
||||
// One-off exec console (docker exec, 30s ceiling)
|
||||
authed.POST("/services/:id/exec", handleExecInService)
|
||||
|
||||
// Git integration — providers carry per-user credentials.
|
||||
authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
|
||||
authed.POST("/git/github-app/connect", handleConnectGitHubApp)
|
||||
authed.GET("/git/providers", handleGetGitProviders)
|
||||
authed.POST("/git/providers", handleCreateGitProvider)
|
||||
authed.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
|
||||
authed.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
|
||||
authed.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
|
||||
authed.POST("/git/repositories/connect", handleConnectGitRepository)
|
||||
authed.GET("/git/repositories", handleGetConnectedRepositories)
|
||||
authed.POST("/git/webhooks", handleCreateWebhook)
|
||||
|
||||
// Builds have no per-user scoping and logs may carry secrets —
|
||||
// keep every build endpoint behind authentication.
|
||||
authed.POST("/builds", buildHandler.StartBuild)
|
||||
authed.GET("/builds", buildHandler.ListBuilds)
|
||||
authed.GET("/builds/:id", buildHandler.GetBuildStatus)
|
||||
authed.GET("/builds/:id/logs", buildHandler.GetBuildLogs)
|
||||
authed.GET("/builds/detect", buildHandler.DetectBuildType)
|
||||
authed.POST("/builds/:id/cancel", buildHandler.CancelBuild)
|
||||
authed.POST("/builds/plan", buildHandler.GetBuildPlan)
|
||||
|
||||
authed.POST("/databases", databaseHandler.CreateDatabase)
|
||||
authed.PUT("/databases/:id", databaseHandler.UpdateDatabase)
|
||||
authed.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
|
||||
authed.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
|
||||
authed.POST("/databases/:id/backup", databaseHandler.CreateBackup)
|
||||
authed.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
|
||||
authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
|
||||
|
||||
authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
|
||||
authed.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
|
||||
authed.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
|
||||
authed.POST("/preview-environments/:id/promote", handlePromotePreviewEnvironment)
|
||||
|
||||
authed.POST("/security/scans", securityHandler.StartSecurityScan)
|
||||
authed.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability)
|
||||
authed.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
|
||||
authed.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
|
||||
authed.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs)
|
||||
|
||||
// User templates — ownership enforced inside the handlers.
|
||||
authed.POST("/templates", handleCreateTemplate)
|
||||
authed.PUT("/templates/:id", handleUpdateTemplate)
|
||||
authed.DELETE("/templates/:id", handleDeleteTemplate)
|
||||
authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
|
||||
|
||||
authed.POST("/cron-jobs", handleCreateCronJob)
|
||||
authed.PUT("/cron-jobs/:id", handleUpdateCronJob)
|
||||
authed.DELETE("/cron-jobs/:id", handleDeleteCronJob)
|
||||
authed.POST("/cron-jobs/:id/trigger", handleTriggerCronJob)
|
||||
}
|
||||
|
||||
// Admin routes — platform-wide controls and cross-user management.
|
||||
admin := v1.Group("/")
|
||||
admin.Use(middleware.Auth(cfg.JWTSecret))
|
||||
admin.Use(middleware.RequireAdmin())
|
||||
{
|
||||
admin.GET("/admin/overview", handleAdminOverview)
|
||||
admin.GET("/admin/users", handleAdminListUsers)
|
||||
admin.PATCH("/admin/users/:id", handleAdminSetUserAdmin)
|
||||
admin.PATCH("/admin/projects/:id", handleAdminSetProjectApproval)
|
||||
|
||||
admin.POST("/users", handleCreateUser)
|
||||
|
||||
admin.GET("/settings", handleGetSettings)
|
||||
admin.PUT("/settings", handleUpdateSettings)
|
||||
|
||||
admin.POST("/system/upgrade/pull", handlePullUpgradeImage)
|
||||
admin.POST("/preview-environments/cleanup-expired", handleCleanupExpiredPreviewEnvironments)
|
||||
|
||||
// HA + scaling + agent mutations — platform-level controls.
|
||||
haAPIManager.RegisterAdminRoutes(admin)
|
||||
scalingHandler.RegisterAdminRoutes(admin)
|
||||
agentHandler.SetupAdminRoutes(admin)
|
||||
|
||||
admin.GET("/audit-logs", handleGetAuditLogs)
|
||||
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
||||
|
||||
// API Gateway routes (merged APwhy) - namespaced to avoid
|
||||
// colliding with Containr's own /services paths.
|
||||
gateway := protected.Group("/gateway")
|
||||
gateway := admin.Group("/gateway")
|
||||
{
|
||||
gateway.GET("/services", handleAPwhyServicesList)
|
||||
gateway.POST("/services", handleAPwhyServicesCreate)
|
||||
gateway.PATCH("/services/:id", handleAPwhyServicesPatch)
|
||||
gateway.GET("/services/:id/validate", handleAPwhyServiceValidate)
|
||||
gateway.GET("/services/:id", handleAPwhyServiceValidate)
|
||||
|
||||
gateway.GET("/keys", handleAPwhyKeysList)
|
||||
gateway.POST("/keys", handleAPwhyKeysCreate)
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/deployment"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// serviceRuntimeSpec builds the container spec for a service: stored env vars
|
||||
// merged with ${{Service.KEY}} references resolved across the project.
|
||||
func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpec, error) {
|
||||
env, err := resolveServiceEnv(db, service)
|
||||
if err != nil {
|
||||
return deployment.RuntimeSpec{}, err
|
||||
}
|
||||
|
||||
spec := deployment.RuntimeSpec{
|
||||
ProjectID: service.ProjectID.String(),
|
||||
ServiceID: service.ID.String(),
|
||||
Name: service.Name,
|
||||
Image: service.Image,
|
||||
Env: env,
|
||||
Replicas: service.Replicas,
|
||||
Port: int32(service.Port),
|
||||
Domain: service.Domain,
|
||||
HealthPath: service.HealthCheckPath,
|
||||
RestartPolicy: service.RestartPolicy,
|
||||
}
|
||||
// Best effort: reuse the host port from the last live deployment so the
|
||||
// public URL survives restarts. Column exists post-migration; older DBs
|
||||
// silently get ephemeral ports.
|
||||
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&spec.PublishedPort)
|
||||
if cmd := strings.TrimSpace(service.Command); cmd != "" {
|
||||
spec.Command = strings.Fields(cmd)
|
||||
}
|
||||
if spec.Replicas < 1 {
|
||||
spec.Replicas = 1
|
||||
}
|
||||
if spec.RestartPolicy == "" {
|
||||
spec.RestartPolicy = "unless-stopped"
|
||||
}
|
||||
spec.NanoCPUs = parseCPULimit(service.CPU)
|
||||
spec.MemoryBytes = parseMemoryLimit(service.Memory)
|
||||
if spec.Port > 0 {
|
||||
env["PORT"] = strconv.Itoa(service.Port)
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
var refVarPattern = regexp.MustCompile(`\$\{\{\s*([A-Za-z0-9_-]+)\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}`)
|
||||
|
||||
// resolveServiceEnv loads the service's variables (real values, including
|
||||
// secrets) and expands ${{service.KEY}} references. KEY may be a variable on
|
||||
// the referenced service, or the builtins HOST (service name alias) and PORT.
|
||||
func resolveServiceEnv(db *database.DB, service Service) (map[string]string, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT key, value FROM environment_variables WHERE service_id = $1`,
|
||||
service.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
env := map[string]string{}
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env[k] = v
|
||||
}
|
||||
|
||||
var refServices []struct{ ID, Name string }
|
||||
svcRows, err := db.Query(
|
||||
`SELECT id, name FROM services WHERE project_id = $1`,
|
||||
service.ProjectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer svcRows.Close()
|
||||
for svcRows.Next() {
|
||||
var s struct{ ID, Name string }
|
||||
if err := svcRows.Scan(&s.ID, &s.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refServices = append(refServices, s)
|
||||
}
|
||||
|
||||
byName := map[string]string{}
|
||||
for _, s := range refServices {
|
||||
byName[strings.ToLower(s.Name)] = s.ID
|
||||
}
|
||||
|
||||
resolve := func(serviceName, key string) (string, bool) {
|
||||
id, ok := byName[strings.ToLower(serviceName)]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
switch strings.ToUpper(key) {
|
||||
case "HOST":
|
||||
return serviceName, true
|
||||
case "PORT":
|
||||
var port int
|
||||
if err := db.QueryRow(`SELECT COALESCE(port, 0) FROM services WHERE id = $1`, id).Scan(&port); err == nil && port > 0 {
|
||||
return strconv.Itoa(port), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
var value string
|
||||
err := db.QueryRow(
|
||||
`SELECT value FROM environment_variables WHERE service_id = $1 AND key = $2`,
|
||||
id, key,
|
||||
).Scan(&value)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
for k, v := range env {
|
||||
env[k] = refVarPattern.ReplaceAllStringFunc(v, func(match string) string {
|
||||
parts := refVarPattern.FindStringSubmatch(match)
|
||||
if len(parts) != 3 {
|
||||
return match
|
||||
}
|
||||
if resolved, ok := resolve(parts[1], parts[2]); ok {
|
||||
return resolved
|
||||
}
|
||||
return match
|
||||
})
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
|
||||
func parseCPULimit(cpu string) int64 {
|
||||
v, err := strconv.ParseFloat(strings.TrimSpace(cpu), 64)
|
||||
if err != nil || v <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(v * 1e9)
|
||||
}
|
||||
|
||||
var memUnits = map[string]int64{"k": 1e3, "m": 1e6, "g": 1e9, "ki": 1 << 10, "mi": 1 << 20, "gi": 1 << 30}
|
||||
|
||||
func parseMemoryLimit(mem string) int64 {
|
||||
s := strings.ToLower(strings.TrimSpace(mem))
|
||||
for suffix, mult := range memUnits {
|
||||
if strings.HasSuffix(s, suffix) {
|
||||
if v, err := strconv.ParseFloat(strings.TrimSuffix(s, suffix), 64); err == nil {
|
||||
return int64(v * float64(mult))
|
||||
}
|
||||
}
|
||||
}
|
||||
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func getRuntimeEngine(c *gin.Context) (*deployment.DeploymentEngine, *database.DB, bool) {
|
||||
dbValue, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return nil, nil, false
|
||||
}
|
||||
engineValue, exists := c.Get("deployment_engine")
|
||||
if !exists || engineValue == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Deployment engine unavailable. Docker may not be configured on this server."})
|
||||
return nil, nil, false
|
||||
}
|
||||
return engineValue.(*deployment.DeploymentEngine), dbValue.(*database.DB), true
|
||||
}
|
||||
|
||||
func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
|
||||
serviceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||
return Service{}, false
|
||||
}
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return Service{}, false
|
||||
}
|
||||
|
||||
var service Service
|
||||
var owner string
|
||||
err = db.QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
s.created_at, s.updated_at, p.owner_id
|
||||
FROM services s JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return Service{}, false
|
||||
}
|
||||
if owner != userID.(string) && !isAdminUser(db, userID.(string)) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return Service{}, false
|
||||
}
|
||||
return service, true
|
||||
}
|
||||
|
||||
// loadReadableService loads a service when the caller may read its project:
|
||||
// approved projects are public, unapproved ones need owner/member/admin.
|
||||
func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
|
||||
serviceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||
return Service{}, false
|
||||
}
|
||||
|
||||
var service Service
|
||||
var projectID uuid.UUID
|
||||
err = db.QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
s.created_at, s.updated_at, s.project_id
|
||||
FROM services s
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return Service{}, false
|
||||
}
|
||||
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return Service{}, false
|
||||
}
|
||||
return service, true
|
||||
}
|
||||
|
||||
// handleServiceRuntime returns live container state for a service.
|
||||
func handleGetServiceRuntime(c *gin.Context) {
|
||||
engine, db, ok := getRuntimeEngine(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
service, ok := loadReadableService(c, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
state, err := engine.RuntimeState(c.Request.Context(), service.ID.String(), service.Replicas)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
engine.ProbeServiceHealth(c.Request.Context(), state, service.ProjectID.String(), service.Name, service.Port, service.HealthCheckPath)
|
||||
persistPublishedPort(db, service.ID, state.Ports)
|
||||
if host := requestHostname(c); host != "" {
|
||||
for i, u := range state.URLs {
|
||||
state.URLs[i] = rewriteLoopbackURL(u, host)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"runtime": state})
|
||||
}
|
||||
|
||||
func reconcileNow(c *gin.Context, engine *deployment.DeploymentEngine, db *database.DB, service Service) bool {
|
||||
spec, err := serviceRuntimeSpec(db, service)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
state, err := engine.ReconcileService(ctx, spec)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return false
|
||||
}
|
||||
status := "stopped"
|
||||
if state != nil {
|
||||
status = state.Status
|
||||
persistPublishedPort(db, service.ID, state.Ports)
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
|
||||
c.JSON(http.StatusOK, gin.H{"runtime": state})
|
||||
return true
|
||||
}
|
||||
|
||||
// handleServiceStart starts all stopped replicas.
|
||||
func handleServiceStart(c *gin.Context) {
|
||||
engine, db, ok := getRuntimeEngine(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
service, ok := loadOwnedService(c, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := engine.StartService(c.Request.Context(), service.ID.String()); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "running"})
|
||||
}
|
||||
|
||||
// handleServiceStop stops all replicas without removing them.
|
||||
func handleServiceStop(c *gin.Context) {
|
||||
engine, db, ok := getRuntimeEngine(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
service, ok := loadOwnedService(c, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := engine.StopService(c.Request.Context(), service.ID.String()); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'stopped', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "stopped"})
|
||||
}
|
||||
|
||||
// handleServiceRestart restarts all replicas.
|
||||
func handleServiceRestart(c *gin.Context) {
|
||||
engine, db, ok := getRuntimeEngine(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
service, ok := loadOwnedService(c, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if err := engine.StopService(ctx, service.ID.String()); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := engine.StartService(ctx, service.ID.String()); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "running"})
|
||||
}
|
||||
|
||||
// handleServiceRedeploy re-applies the current spec: pulls the image for
|
||||
// image-sourced services and reconciles containers (env, ports, replicas).
|
||||
func handleServiceRedeploy(c *gin.Context) {
|
||||
engine, db, ok := getRuntimeEngine(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
service, ok := loadOwnedService(c, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
image := service.Image
|
||||
if image == "" {
|
||||
// Git-sourced service: reuse the last successful deployment image.
|
||||
err := db.QueryRow(
|
||||
`SELECT image_name || ':' || image_tag FROM deployments
|
||||
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
service.ID,
|
||||
).Scan(&image)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No deployable image yet — run a deployment first"})
|
||||
return
|
||||
}
|
||||
}
|
||||
service.Image = image
|
||||
reconcileNow(c, engine, db, service)
|
||||
}
|
||||
|
||||
// persistPublishedPort records the host port Docker actually bound so later
|
||||
// redeploys can reuse it. Idempotent — only writes when the value changed.
|
||||
func persistPublishedPort(db *database.DB, serviceID uuid.UUID, ports []uint16) {
|
||||
if len(ports) == 0 {
|
||||
return
|
||||
}
|
||||
_, _ = db.Exec(
|
||||
`UPDATE services SET published_port = $1 WHERE id = $2 AND published_port <> $1`,
|
||||
int(ports[0]), serviceID,
|
||||
)
|
||||
}
|
||||
|
||||
// liveServiceStatus reconciles the stored status with real container state.
|
||||
// No-op when Docker is unavailable — the stored status is returned as-is.
|
||||
func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
|
||||
engineValue, exists := c.Get("deployment_engine")
|
||||
if !exists || engineValue == nil {
|
||||
return
|
||||
}
|
||||
engine := engineValue.(*deployment.DeploymentEngine)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
state, err := engine.RuntimeState(ctx, service.ID.String(), service.Replicas)
|
||||
if err != nil || state == nil || len(state.Containers) == 0 {
|
||||
return
|
||||
}
|
||||
if state.Status != service.Status {
|
||||
service.Status = state.Status
|
||||
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID)
|
||||
}
|
||||
persistPublishedPort(db, service.ID, state.Ports)
|
||||
if service.Domain != "" {
|
||||
service.PublicURL = "https://" + service.Domain
|
||||
} else if len(state.URLs) > 0 {
|
||||
service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c))
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeScaleService adjusts replica count both in bookkeeping and reality.
|
||||
func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
|
||||
dbValue, _ := c.Get("db")
|
||||
engineValue, _ := c.Get("deployment_engine")
|
||||
db, _ := dbValue.(*database.DB)
|
||||
engine, _ := engineValue.(*deployment.DeploymentEngine)
|
||||
if db == nil || engine == nil {
|
||||
return fmt.Errorf("runtime unavailable")
|
||||
}
|
||||
|
||||
var service Service
|
||||
var owner string
|
||||
err := db.QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
s.created_at, s.updated_at, p.owner_id
|
||||
FROM services s JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`, serviceID,
|
||||
).Scan(
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if service.Image == "" {
|
||||
if err := db.QueryRow(
|
||||
`SELECT image_name || ':' || image_tag FROM deployments
|
||||
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
|
||||
ORDER BY created_at DESC LIMIT 1`, service.ID,
|
||||
).Scan(&service.Image); err != nil {
|
||||
return fmt.Errorf("no deployed image to scale")
|
||||
}
|
||||
}
|
||||
|
||||
spec, err := serviceRuntimeSpec(db, service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec.Replicas = replicas
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
state, err := engine.ReconcileService(ctx, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state != nil {
|
||||
persistPublishedPort(db, service.ID, state.Ports)
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID)
|
||||
return nil
|
||||
}
|
||||
@@ -24,31 +24,39 @@ func NewScalingHandler(autoScaler *scaling.AutoScaler) *ScalingHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers scaling routes
|
||||
func (h *ScalingHandler) RegisterRoutes(router *gin.RouterGroup) {
|
||||
// RegisterReadRoutes registers public scaling read endpoints.
|
||||
func (h *ScalingHandler) RegisterReadRoutes(router *gin.RouterGroup) {
|
||||
scaling := router.Group("/scaling")
|
||||
{
|
||||
scaling.GET("/policies", h.GetScalingPolicies)
|
||||
scaling.POST("/policies", h.SetScalingPolicy)
|
||||
scaling.GET("/policies/:serviceId", h.GetScalingPolicy)
|
||||
scaling.PUT("/policies/:serviceId", h.UpdateScalingPolicy)
|
||||
scaling.DELETE("/policies/:serviceId", h.DeleteScalingPolicy)
|
||||
|
||||
scaling.GET("/services", h.GetServiceStates)
|
||||
scaling.GET("/services/:serviceId", h.GetServiceState)
|
||||
scaling.GET("/services/:serviceId/history", h.GetScalingHistory)
|
||||
|
||||
scaling.POST("/services/:serviceId/scale", h.ManualScale)
|
||||
|
||||
scaling.GET("/status", h.GetScalingStatus)
|
||||
scaling.POST("/enable", h.EnableAutoScaler)
|
||||
scaling.POST("/disable", h.DisableAutoScaler)
|
||||
|
||||
scaling.GET("/metrics", h.GetScalingMetrics)
|
||||
scaling.GET("/events", h.GetScalingEvents)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterAdminRoutes registers scaling mutation endpoints.
|
||||
func (h *ScalingHandler) RegisterAdminRoutes(router *gin.RouterGroup) {
|
||||
scaling := router.Group("/scaling")
|
||||
{
|
||||
scaling.POST("/policies", h.SetScalingPolicy)
|
||||
scaling.PUT("/policies/:serviceId", h.UpdateScalingPolicy)
|
||||
scaling.DELETE("/policies/:serviceId", h.DeleteScalingPolicy)
|
||||
|
||||
scaling.POST("/services/:serviceId/scale", h.ManualScale)
|
||||
|
||||
scaling.POST("/enable", h.EnableAutoScaler)
|
||||
scaling.POST("/disable", h.DisableAutoScaler)
|
||||
}
|
||||
}
|
||||
|
||||
// GetScalingPolicies returns all scaling policies
|
||||
func (h *ScalingHandler) GetScalingPolicies(c *gin.Context) {
|
||||
states := h.autoScaler.GetAllServiceStates()
|
||||
@@ -204,7 +212,16 @@ func (h *ScalingHandler) ManualScale(c *gin.Context) {
|
||||
|
||||
event, err := h.autoScaler.ManualScale(c.Request.Context(), serviceID, request.Replicas, strings.TrimSpace(request.Reason))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
// Service unknown to the autoscaler: the runtime is authoritative —
|
||||
// reconcile containers directly and persist the replica count.
|
||||
if runtimeErr := runtimeScaleService(c, serviceID, request.Replicas); runtimeErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "runtime_error": runtimeErr.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Service scaled successfully",
|
||||
"state": gin.H{"service_id": serviceID, "desired_replicas": request.Replicas},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -214,11 +231,19 @@ func (h *ScalingHandler) ManualScale(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
// Apply the replica change to live containers (best effort — the
|
||||
// bookkeeping update above already succeeded).
|
||||
runtimeErr := runtimeScaleService(c, serviceID, request.Replicas)
|
||||
|
||||
resp := gin.H{
|
||||
"message": "Service scaled successfully",
|
||||
"event": event,
|
||||
"state": state,
|
||||
})
|
||||
}
|
||||
if runtimeErr != nil {
|
||||
resp["runtime_error"] = runtimeErr.Error()
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetScalingStatus returns the overall status of the auto-scaler
|
||||
|
||||
@@ -49,7 +49,9 @@ func setupScalingTestRouter(t *testing.T) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
handler := NewScalingHandler(autoScaler)
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
group := router.Group("/api/v1")
|
||||
handler.RegisterReadRoutes(group)
|
||||
handler.RegisterAdminRoutes(group)
|
||||
return router
|
||||
}
|
||||
|
||||
|
||||
@@ -589,16 +589,7 @@ func (sh *SecurityHandler) GetAuditLogs(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string) (string, bool) {
|
||||
userIDValue, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return "", false
|
||||
}
|
||||
userID, ok := userIDValue.(string)
|
||||
if !ok || userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
return "", false
|
||||
}
|
||||
userID := optionalUserID(c)
|
||||
|
||||
if _, err := uuid.Parse(projectID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
||||
@@ -611,12 +602,14 @@ func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string
|
||||
SELECT 1
|
||||
FROM projects p
|
||||
WHERE p.id = $1
|
||||
AND (p.owner_id = $2 OR EXISTS (
|
||||
AND (p.is_approved
|
||||
OR $3::bool
|
||||
OR p.owner_id = $2 OR EXISTS (
|
||||
SELECT 1 FROM project_members pm
|
||||
WHERE pm.project_id = p.id AND pm.user_id = $2
|
||||
))
|
||||
)`,
|
||||
projectID, userID,
|
||||
projectID, optionalUserUUID(c), contextIsAdmin(c),
|
||||
).Scan(&hasAccess)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"})
|
||||
|
||||
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/deployment"
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -24,6 +26,13 @@ type Service struct {
|
||||
BuildPath string `json:"build_path" db:"build_path"`
|
||||
CPU string `json:"cpu" db:"cpu"`
|
||||
Memory string `json:"memory" db:"memory"`
|
||||
// Runtime spec
|
||||
Replicas int `json:"replicas" db:"replicas"`
|
||||
Port int `json:"port" db:"port"` // container port to expose
|
||||
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
|
||||
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
|
||||
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
|
||||
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
@@ -41,6 +50,11 @@ type CreateServiceRequest struct {
|
||||
BuildPath string `json:"build_path"`
|
||||
CPU string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
Replicas int `json:"replicas"`
|
||||
Port int `json:"port"`
|
||||
Domain string `json:"domain"`
|
||||
HealthCheckPath string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
}
|
||||
|
||||
// UpdateServiceRequest represents a request to update a service
|
||||
@@ -55,6 +69,11 @@ type UpdateServiceRequest struct {
|
||||
BuildPath string `json:"build_path"`
|
||||
CPU string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
Replicas *int `json:"replicas"`
|
||||
Port *int `json:"port"`
|
||||
Domain *string `json:"domain"`
|
||||
HealthCheckPath *string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
}
|
||||
|
||||
// handleGetServices retrieves all services for a project
|
||||
@@ -72,28 +91,14 @@ func handleGetServices(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if project exists and user has access
|
||||
var project Project
|
||||
err = db.(*database.DB).QueryRow(
|
||||
"SELECT id, name, owner_id FROM projects WHERE id = $1",
|
||||
projectID,
|
||||
).Scan(&project.ID, &project.Name, &project.OwnerID)
|
||||
|
||||
if err != nil {
|
||||
// Approved projects are public; unapproved ones need owner/member/admin.
|
||||
projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID)
|
||||
if !projectExists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user ID from JWT token (set by auth middleware)
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user owns the project
|
||||
if project.OwnerID != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
if !allowed {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -110,6 +115,9 @@ func handleGetServices(c *gin.Context) {
|
||||
COALESCE(build_path, ''),
|
||||
COALESCE(cpu, ''),
|
||||
COALESCE(memory, ''),
|
||||
COALESCE(replicas, 1), COALESCE(port, 0),
|
||||
COALESCE(domain, ''), COALESCE(healthcheck_path, ''),
|
||||
COALESCE(restart_policy, 'unless-stopped'),
|
||||
created_at, updated_at
|
||||
FROM services
|
||||
WHERE project_id = $1
|
||||
@@ -129,7 +137,8 @@ func handleGetServices(c *gin.Context) {
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.CreatedAt, &service.UpdatedAt,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
|
||||
@@ -138,6 +147,11 @@ func handleGetServices(c *gin.Context) {
|
||||
services = append(services, service)
|
||||
}
|
||||
|
||||
// Reconcile stored status with live container state (no-op without Docker).
|
||||
for i := range services {
|
||||
liveServiceStatus(c, db.(*database.DB), &services[i])
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"services": services})
|
||||
}
|
||||
|
||||
@@ -189,7 +203,7 @@ func handleCreateService(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Check if user owns the project
|
||||
if project.OwnerID != userID.(string) {
|
||||
if project.OwnerID != userID.(string) && !contextIsAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
@@ -226,6 +240,11 @@ func handleCreateService(c *gin.Context) {
|
||||
BuildPath: req.BuildPath,
|
||||
CPU: req.CPU,
|
||||
Memory: req.Memory,
|
||||
Replicas: req.Replicas,
|
||||
Port: req.Port,
|
||||
Domain: req.Domain,
|
||||
HealthCheckPath: req.HealthCheckPath,
|
||||
RestartPolicy: req.RestartPolicy,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -237,6 +256,12 @@ func handleCreateService(c *gin.Context) {
|
||||
if service.Memory == "" {
|
||||
service.Memory = "512Mi"
|
||||
}
|
||||
if service.Replicas < 1 {
|
||||
service.Replicas = 1
|
||||
}
|
||||
if service.RestartPolicy == "" {
|
||||
service.RestartPolicy = "unless-stopped"
|
||||
}
|
||||
|
||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
||||
if err != nil {
|
||||
@@ -251,12 +276,14 @@ func handleCreateService(c *gin.Context) {
|
||||
`INSERT INTO services
|
||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||
build_command, start_command, type, status, image, command, environment,
|
||||
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`,
|
||||
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||
healthcheck_path, restart_policy, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27)`,
|
||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
||||
service.CreatedAt, service.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -303,14 +330,9 @@ func handleGetService(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get user ID from JWT token
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get service with project ownership check
|
||||
// Get service — public when its project is approved, otherwise
|
||||
// owner/member/admin only.
|
||||
userID := optionalUserUUID(c)
|
||||
var service Service
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
@@ -324,16 +346,21 @@ func handleGetService(c *gin.Context) {
|
||||
COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''),
|
||||
COALESCE(s.memory, ''),
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1 AND p.owner_id = $2`,
|
||||
serviceID, userID,
|
||||
WHERE s.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
|
||||
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
|
||||
serviceID, userID, contextIsAdmin(c),
|
||||
).Scan(
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.CreatedAt, &service.UpdatedAt,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -341,6 +368,7 @@ func handleGetService(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
liveServiceStatus(c, db.(*database.DB), &service)
|
||||
c.JSON(http.StatusOK, gin.H{"service": service})
|
||||
}
|
||||
|
||||
@@ -386,16 +414,21 @@ func handleUpdateService(c *gin.Context) {
|
||||
COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''),
|
||||
COALESCE(s.memory, ''),
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1 AND p.owner_id = $2`,
|
||||
serviceID, userID,
|
||||
WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
|
||||
serviceID, userID, contextIsAdmin(c),
|
||||
).Scan(
|
||||
&existingService.ID, &existingService.ProjectID, &existingService.Name, &existingService.Type,
|
||||
&existingService.Status, &existingService.Image, &existingService.Command,
|
||||
&existingService.Environment, &existingService.GitRepo, &existingService.GitBranch,
|
||||
&existingService.BuildPath, &existingService.CPU, &existingService.Memory,
|
||||
&existingService.Replicas, &existingService.Port, &existingService.Domain,
|
||||
&existingService.HealthCheckPath, &existingService.RestartPolicy,
|
||||
&existingService.CreatedAt, &existingService.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -435,6 +468,21 @@ func handleUpdateService(c *gin.Context) {
|
||||
if req.Memory != "" {
|
||||
existingService.Memory = req.Memory
|
||||
}
|
||||
if req.Replicas != nil {
|
||||
existingService.Replicas = *req.Replicas
|
||||
}
|
||||
if req.Port != nil {
|
||||
existingService.Port = *req.Port
|
||||
}
|
||||
if req.Domain != nil {
|
||||
existingService.Domain = *req.Domain
|
||||
}
|
||||
if req.HealthCheckPath != nil {
|
||||
existingService.HealthCheckPath = *req.HealthCheckPath
|
||||
}
|
||||
if req.RestartPolicy != "" {
|
||||
existingService.RestartPolicy = req.RestartPolicy
|
||||
}
|
||||
|
||||
existingService.UpdatedAt = time.Now()
|
||||
|
||||
@@ -442,11 +490,15 @@ func handleUpdateService(c *gin.Context) {
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`UPDATE services
|
||||
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
|
||||
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10, updated_at = $11
|
||||
WHERE id = $12`,
|
||||
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
|
||||
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
|
||||
restart_policy = $15, updated_at = $16
|
||||
WHERE id = $17`,
|
||||
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
||||
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
||||
existingService.BuildPath, existingService.CPU, existingService.Memory,
|
||||
existingService.Replicas, existingService.Port, existingService.Domain,
|
||||
existingService.HealthCheckPath, existingService.RestartPolicy,
|
||||
existingService.UpdatedAt, existingService.ID,
|
||||
)
|
||||
|
||||
@@ -455,6 +507,26 @@ func handleUpdateService(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// A replica change applies immediately when the service has live
|
||||
// containers; other spec fields take effect on the next deploy.
|
||||
if req.Replicas != nil && existingService.Status == "running" {
|
||||
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||
engine := engineValue.(*deployment.DeploymentEngine)
|
||||
serviceCopy := existingService
|
||||
go func() {
|
||||
spec, err := serviceRuntimeSpec(db.(*database.DB), serviceCopy)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if state, err := engine.ReconcileService(ctx, spec); err == nil && state != nil {
|
||||
persistPublishedPort(db.(*database.DB), serviceCopy.ID, state.Ports)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"service": existingService})
|
||||
}
|
||||
|
||||
@@ -496,11 +568,18 @@ func handleDeleteService(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Check if user owns the project
|
||||
if projectOwnerID != userID.(string) {
|
||||
if projectOwnerID != userID.(string) && !isAdminUser(db.(*database.DB), userID.(string)) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove runtime containers before dropping the row.
|
||||
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
||||
_ = engine.RemoveServiceContainers(c.Request.Context(), serviceID.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Delete service (cascade will handle related records)
|
||||
_, err = db.(*database.DB).Exec(
|
||||
"DELETE FROM services WHERE id = $1",
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/docker"
|
||||
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Platform setting keys. Values stored in app_settings take precedence over
|
||||
// environment variables; env remains the fallback for optional overrides.
|
||||
const (
|
||||
settingSignupEnabled = "signup_enabled"
|
||||
settingCloudflareTunnelToken = "cloudflare_tunnel_token"
|
||||
|
||||
cloudflaredContainerName = "containr-cloudflared"
|
||||
cloudflaredImage = "cloudflare/cloudflared:latest"
|
||||
)
|
||||
|
||||
// settingValue resolves a setting: app_settings row first, env fallback,
|
||||
// then the caller's default.
|
||||
func settingValue(db *database.DB, key, envKey, fallback string) string {
|
||||
if db != nil && db.DB != nil {
|
||||
var value string
|
||||
err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, key).Scan(&value)
|
||||
if err == nil {
|
||||
return value
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
log.Printf("Failed to read setting %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
if envKey != "" {
|
||||
if value := strings.TrimSpace(os.Getenv(envKey)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func setSetting(db *database.DB, key, value string, secret bool) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO app_settings (key, value, is_secret, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = EXCLUDED.value, is_secret = EXCLUDED.is_secret, updated_at = NOW()
|
||||
`, key, value, secret)
|
||||
return err
|
||||
}
|
||||
|
||||
func deleteSetting(db *database.DB, key string) error {
|
||||
_, err := db.Exec(`DELETE FROM app_settings WHERE key = $1`, key)
|
||||
return err
|
||||
}
|
||||
|
||||
// signupEnabled controls whether public registration stays open after the
|
||||
// first account exists. Defaults to closed; the platform owner can reopen it
|
||||
// in Settings or via SIGNUP_ENABLED=true.
|
||||
func signupEnabled(db *database.DB) bool {
|
||||
return strings.EqualFold(settingValue(db, settingSignupEnabled, "SIGNUP_ENABLED", "false"), "true")
|
||||
}
|
||||
|
||||
func isAdminUser(db *database.DB, userID string) bool {
|
||||
var isAdmin bool
|
||||
if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil {
|
||||
return false
|
||||
}
|
||||
return isAdmin
|
||||
}
|
||||
|
||||
func requireAdmin(c *gin.Context) (*database.DB, bool) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
db, _ := c.Get("db")
|
||||
database, _ := db.(*database.DB)
|
||||
if database == nil || database.DB == nil || !isAdminUser(database, userID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
return nil, false
|
||||
}
|
||||
return database, true
|
||||
}
|
||||
|
||||
type cloudflareTunnelStatus struct {
|
||||
TokenSet bool `json:"token_set"`
|
||||
Source string `json:"source"` // "app" | "env" | "none"
|
||||
Container string `json:"container"`
|
||||
}
|
||||
|
||||
func handleGetSettings(c *gin.Context) {
|
||||
db, ok := requireAdmin(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
dockerClient, _ := c.Get("docker_client")
|
||||
client, _ := dockerClient.(*docker.Client)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"signup_enabled": signupEnabled(db),
|
||||
"cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client),
|
||||
})
|
||||
}
|
||||
|
||||
func handleUpdateSettings(c *gin.Context) {
|
||||
db, ok := requireAdmin(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
SignupEnabled *bool `json:"signup_enabled"`
|
||||
CloudflareTunnelToken *string `json:"cloudflare_tunnel_token"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.SignupEnabled != nil {
|
||||
value := "false"
|
||||
if *req.SignupEnabled {
|
||||
value = "true"
|
||||
}
|
||||
if err := setSetting(db, settingSignupEnabled, value, false); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update signup setting"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.CloudflareTunnelToken != nil {
|
||||
token := strings.TrimSpace(*req.CloudflareTunnelToken)
|
||||
if token == "" {
|
||||
if err := deleteSetting(db, settingCloudflareTunnelToken); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear tunnel token"})
|
||||
return
|
||||
}
|
||||
} else if err := setSetting(db, settingCloudflareTunnelToken, token, true); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save tunnel token"})
|
||||
return
|
||||
}
|
||||
|
||||
if dockerClient, exists := c.Get("docker_client"); exists {
|
||||
if client, ok := dockerClient.(*docker.Client); ok && client != nil {
|
||||
go applyCloudflaredTunnel(client, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleGetSettings(c)
|
||||
}
|
||||
|
||||
// resolvedTunnelToken prefers the in-app value and falls back to the env.
|
||||
func resolvedTunnelToken(db *database.DB) (string, string) {
|
||||
if db != nil && db.DB != nil {
|
||||
var value string
|
||||
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingCloudflareTunnelToken).Scan(&value); err == nil && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value), "app"
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv("CLOUDFLARED_TOKEN")); value != "" && value != "your_cloudflare_tunnel_token_here" {
|
||||
return value, "env"
|
||||
}
|
||||
return "", "none"
|
||||
}
|
||||
|
||||
func cloudflareTunnelState(ctx context.Context, db *database.DB, client *docker.Client) cloudflareTunnelStatus {
|
||||
token, source := resolvedTunnelToken(db)
|
||||
state := cloudflareTunnelStatus{TokenSet: token != "", Source: source, Container: "missing"}
|
||||
|
||||
if client == nil {
|
||||
state.Container = "unavailable"
|
||||
return state
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
container, err := findCloudflaredContainer(ctx, client)
|
||||
if err != nil || container == "" {
|
||||
return state
|
||||
}
|
||||
if inspect, err := client.GetContainer(ctx, container); err == nil && inspect.State != nil {
|
||||
state.Container = inspect.State.Status
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// syncManagedContainers reconciles containers the platform manages from
|
||||
// settings - currently only the optional Cloudflare Tunnel sidecar.
|
||||
func syncManagedContainers(client *docker.Client, db *database.DB) {
|
||||
token, _ := resolvedTunnelToken(db)
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
go applyCloudflaredTunnel(client, token)
|
||||
}
|
||||
|
||||
func findCloudflaredContainer(ctx context.Context, client *docker.Client) (string, error) {
|
||||
containers, err := client.ListContainers(ctx, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, item := range containers {
|
||||
for _, name := range item.Names {
|
||||
if strings.TrimPrefix(name, "/") == cloudflaredContainerName {
|
||||
return item.ID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// applyCloudflaredTunnel ensures a cloudflared container mirrors the stored
|
||||
// token: created/recreated when set, removed when cleared. It joins the same
|
||||
// networks as the backend so tunnel targets can use compose service names.
|
||||
func applyCloudflaredTunnel(client *docker.Client, token string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if existing, err := findCloudflaredContainer(ctx, client); err == nil && existing != "" {
|
||||
if err := client.RemoveContainer(ctx, existing, true); err != nil {
|
||||
log.Printf("Failed to remove existing cloudflared container: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := client.PullImageWait(ctx, cloudflaredImage, registry.AuthConfig{}); err != nil {
|
||||
log.Printf("Failed to pull %s: %v", cloudflaredImage, err)
|
||||
}
|
||||
|
||||
networks, err := backendNetworks(ctx, client)
|
||||
if err != nil {
|
||||
log.Printf("Failed to resolve backend networks for cloudflared: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := client.CreateContainer(ctx, docker.ContainerConfig{
|
||||
Name: cloudflaredContainerName,
|
||||
Image: cloudflaredImage,
|
||||
Cmd: []string{"tunnel", "--no-autoupdate", "run", "--token", token},
|
||||
RestartPolicy: "unless-stopped",
|
||||
Labels: map[string]string{"containr.managed": "cloudflared"},
|
||||
Networks: networks,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to create cloudflared container: %v", err)
|
||||
return
|
||||
}
|
||||
if err := client.StartContainer(ctx, id); err != nil {
|
||||
log.Printf("Failed to start cloudflared container: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// backendNetworks returns the networks this backend container sits on, so the
|
||||
// managed cloudflared container can reach the frontend/backend by name.
|
||||
func backendNetworks(ctx context.Context, client *docker.Client) (map[string]*network.EndpointSettings, error) {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
self, err := client.GetContainer(ctx, hostname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
networks := map[string]*network.EndpointSettings{}
|
||||
for name := range self.NetworkSettings.Networks {
|
||||
networks[name] = &network.EndpointSettings{}
|
||||
}
|
||||
if len(networks) == 0 {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return networks, nil
|
||||
}
|
||||
@@ -40,6 +40,9 @@ type hostMonitoringResponse struct {
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
DockerAvailable bool `json:"docker_available"`
|
||||
Docker map[string]any `json:"docker,omitempty"`
|
||||
// Scope names what the telemetry actually measures: the physical host for
|
||||
// native dockerd, or the Docker Desktop VM when the daemon runs in one.
|
||||
Scope string `json:"scope"`
|
||||
CollectedAt string `json:"collected_at"`
|
||||
}
|
||||
|
||||
@@ -159,6 +162,7 @@ func handleGetHostMonitoring(c *gin.Context) {
|
||||
Storage: storage,
|
||||
Load: load,
|
||||
UptimeSeconds: uptime,
|
||||
Scope: "host",
|
||||
CollectedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
@@ -173,6 +177,10 @@ func handleGetHostMonitoring(c *gin.Context) {
|
||||
"images": info.Images,
|
||||
"driver": info.Driver,
|
||||
"server": info.ServerVersion,
|
||||
"os": info.OperatingSystem,
|
||||
}
|
||||
if strings.Contains(strings.ToLower(info.OperatingSystem), "docker desktop") {
|
||||
response.Scope = "docker-desktop-vm"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,19 @@ package api
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
)
|
||||
|
||||
type ServiceTemplate struct {
|
||||
@@ -24,6 +27,8 @@ type ServiceTemplate struct {
|
||||
Config string `json:"config" db:"config"`
|
||||
Variables string `json:"variables" db:"variables"`
|
||||
IsOfficial bool `json:"is_official" db:"is_official"`
|
||||
OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
|
||||
IsPublic bool `json:"is_public" db:"is_public"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
@@ -67,12 +72,18 @@ func handleGetTemplates(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
queries := sqlcdb.New(db.DB)
|
||||
|
||||
userID := optionalUserUUID(c)
|
||||
owner := uuid.NullUUID{UUID: userID, Valid: userID != uuid.Nil}
|
||||
|
||||
var templateRows []sqlcdb.ServiceTemplate
|
||||
var err error
|
||||
if category != "" {
|
||||
templateRows, err = queries.ListServiceTemplatesByCategory(ctx, category)
|
||||
templateRows, err = queries.ListServiceTemplatesByCategoryForUser(ctx, sqlcdb.ListServiceTemplatesByCategoryForUserParams{
|
||||
Category: category,
|
||||
OwnerID: owner,
|
||||
})
|
||||
} else {
|
||||
templateRows, err = queries.ListServiceTemplates(ctx)
|
||||
templateRows, err = queries.ListServiceTemplatesForUser(ctx, owner)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
|
||||
@@ -103,7 +114,7 @@ func handleGetTemplate(c *gin.Context) {
|
||||
}
|
||||
|
||||
t := mapSQLCTemplate(row)
|
||||
if t.ID == "" {
|
||||
if t.ID == "" || !templateVisibleTo(c, t) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||
return
|
||||
}
|
||||
@@ -169,7 +180,7 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch project"})
|
||||
return
|
||||
}
|
||||
if ownerID.String() != userID {
|
||||
if ownerID.String() != userID && !contextIsAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
@@ -185,8 +196,8 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
}
|
||||
|
||||
template := mapSQLCTemplate(templateRow)
|
||||
if template.ID == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||
if template.ID == "" || !templateVisibleTo(c, template) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -325,6 +336,16 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// sqlc's schema predates the runtime columns; port and healthcheck_path are
|
||||
// written via raw SQL like every other service mutation path.
|
||||
if _, err = tx.ExecContext(ctx,
|
||||
`UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3`,
|
||||
config.Port, strings.TrimSpace(config.HealthCheck), serviceID,
|
||||
); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
|
||||
return
|
||||
}
|
||||
|
||||
for key, value := range envVars {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
continue
|
||||
@@ -363,6 +384,246 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// templateWriteRequest is the admin payload for creating/updating a user
|
||||
// template. config must decode into TemplateConfig; variables into
|
||||
// []TemplateVariable.
|
||||
type templateWriteRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Logo string `json:"logo"`
|
||||
Config json.RawMessage `json:"config" binding:"required"`
|
||||
Variables json.RawMessage `json:"variables"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
}
|
||||
|
||||
var templateCategories = map[string]bool{
|
||||
"web": true, "frontend": true, "api": true, "database": true,
|
||||
"worker": true, "cron": true, "app": true, "custom": true,
|
||||
}
|
||||
|
||||
func normalizeTemplateCategory(category string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(category))
|
||||
if templateCategories[normalized] {
|
||||
return normalized
|
||||
}
|
||||
return "custom"
|
||||
}
|
||||
|
||||
func validateTemplateWrite(c *gin.Context, req *templateWriteRequest) bool {
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if len(req.Name) < 2 || len(req.Name) > 120 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name must be 2-120 characters"})
|
||||
return false
|
||||
}
|
||||
req.Category = normalizeTemplateCategory(req.Category)
|
||||
req.Logo = strings.TrimSpace(req.Logo)
|
||||
if len(req.Logo) > 500 || (req.Logo != "" && !strings.HasPrefix(req.Logo, "https://") && !strings.HasPrefix(req.Logo, "http://")) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "logo must be an http(s) URL up to 500 chars"})
|
||||
return false
|
||||
}
|
||||
if len(req.Config) == 0 || len(req.Config) > 64*1024 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "config is required and must be under 64KB"})
|
||||
return false
|
||||
}
|
||||
var config TemplateConfig
|
||||
if err := json.Unmarshal(req.Config, &config); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "config must be a valid template config object"})
|
||||
return false
|
||||
}
|
||||
if _, err := normalizeTemplateServiceType(config.Type); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return false
|
||||
}
|
||||
if len(req.Variables) > 0 {
|
||||
if len(req.Variables) > 64*1024 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "variables must be under 64KB"})
|
||||
return false
|
||||
}
|
||||
var variables []TemplateVariable
|
||||
if err := json.Unmarshal(req.Variables, &variables); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "variables must be an array"})
|
||||
return false
|
||||
}
|
||||
for _, v := range variables {
|
||||
if strings.TrimSpace(v.Key) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "every variable needs a key"})
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func templateVariablesParam(raw json.RawMessage) pqtype.NullRawMessage {
|
||||
if len(raw) == 0 {
|
||||
return pqtype.NullRawMessage{}
|
||||
}
|
||||
return pqtype.NullRawMessage{RawMessage: raw, Valid: true}
|
||||
}
|
||||
|
||||
func handleCreateTemplate(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
|
||||
var req templateWriteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !validateTemplateWrite(c, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
ownerUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
return
|
||||
}
|
||||
|
||||
id := "usr-" + uuid.NewString()
|
||||
if err := queries.CreateUserTemplate(c.Request.Context(), sqlcdb.CreateUserTemplateParams{
|
||||
ID: id,
|
||||
Name: req.Name,
|
||||
Description: nullableText(strings.TrimSpace(req.Description)),
|
||||
Category: req.Category,
|
||||
Logo: nullableText(req.Logo),
|
||||
Config: req.Config,
|
||||
Variables: templateVariablesParam(req.Variables),
|
||||
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
|
||||
IsPublic: req.IsPublic,
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAudit(userID, "template", id, "create", map[string]interface{}{"name": req.Name})
|
||||
|
||||
row, err := queries.GetServiceTemplateByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusCreated, gin.H{"template": gin.H{"id": id, "name": req.Name}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"template": mapSQLCTemplate(row)})
|
||||
}
|
||||
|
||||
func handleUpdateTemplate(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
templateID := c.Param("id")
|
||||
|
||||
var req templateWriteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !validateTemplateWrite(c, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
ownerUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := queries.UpdateUserTemplate(c.Request.Context(), sqlcdb.UpdateUserTemplateParams{
|
||||
Name: req.Name,
|
||||
Description: nullableText(strings.TrimSpace(req.Description)),
|
||||
Category: req.Category,
|
||||
Logo: nullableText(req.Logo),
|
||||
Config: req.Config,
|
||||
Variables: templateVariablesParam(req.Variables),
|
||||
IsPublic: req.IsPublic,
|
||||
ID: templateID,
|
||||
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
|
||||
return
|
||||
}
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found or not editable"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAudit(userID, "template", templateID, "update", map[string]interface{}{"name": req.Name})
|
||||
|
||||
row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Template updated"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"template": mapSQLCTemplate(row)})
|
||||
}
|
||||
|
||||
func handleDeleteTemplate(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
templateID := c.Param("id")
|
||||
|
||||
ownerUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := queries.DeleteUserTemplate(c.Request.Context(), sqlcdb.DeleteUserTemplateParams{
|
||||
ID: templateID,
|
||||
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete template"})
|
||||
return
|
||||
}
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found or not deletable"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAudit(userID, "template", templateID, "delete", nil)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Template deleted"})
|
||||
}
|
||||
|
||||
// SeedOfficialTemplates upserts the built-in catalog on startup so new
|
||||
// templates ship with releases without needing a migration per entry.
|
||||
func SeedOfficialTemplates(ctx context.Context, db *database.DB) {
|
||||
if db == nil || db.DB == nil {
|
||||
return
|
||||
}
|
||||
queries := sqlcdb.New(db.DB)
|
||||
for _, t := range SeedTemplates() {
|
||||
variables := pqtype.NullRawMessage{}
|
||||
if strings.TrimSpace(t.Variables) != "" {
|
||||
variables = pqtype.NullRawMessage{RawMessage: json.RawMessage(t.Variables), Valid: true}
|
||||
}
|
||||
if err := queries.UpsertServiceTemplate(ctx, sqlcdb.UpsertServiceTemplateParams{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Description: nullableText(t.Description),
|
||||
Category: t.Category,
|
||||
Logo: nullableText(t.Logo),
|
||||
Config: json.RawMessage(t.Config),
|
||||
Variables: variables,
|
||||
IsOfficial: sql.NullBool{Bool: true, Valid: true},
|
||||
}); err != nil {
|
||||
log.Printf("Failed to seed template %s: %v", t.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SeedTemplates() []ServiceTemplate {
|
||||
templates := []ServiceTemplate{
|
||||
{
|
||||
@@ -480,7 +741,7 @@ func SeedTemplates() []ServiceTemplate {
|
||||
Name: "Background Worker",
|
||||
Description: "Background job processing service",
|
||||
Category: "worker",
|
||||
Logo: "https://cdn.simpleicons.org/terminal",
|
||||
Logo: "https://cdn.simpleicons.org/gnubash",
|
||||
Config: `{"type":"worker","runtime":"node","build_command":"npm install","start_command":"npm run worker"}`,
|
||||
Variables: `[{"key":"WORKER_CONCURRENCY","label":"Concurrency","default":"4","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
@@ -490,7 +751,7 @@ func SeedTemplates() []ServiceTemplate {
|
||||
Name: "Cron Job",
|
||||
Description: "Scheduled task runner",
|
||||
Category: "cron",
|
||||
Logo: "https://cdn.simpleicons.org/clock",
|
||||
Logo: "https://cdn.simpleicons.org/clockify",
|
||||
Config: `{"type":"cron","runtime":"node","build_command":"npm install","start_command":"npm run cron"}`,
|
||||
Variables: `[{"key":"CRON_SCHEDULE","label":"Schedule","default":"0 * * * *","required":true,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
@@ -505,6 +766,167 @@ func SeedTemplates() []ServiceTemplate {
|
||||
Variables: `[{"key":"IMAGE","label":"Docker Image","default":"","required":true,"secret":false},{"key":"TAG","label":"Image Tag","default":"latest","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
// Self-hosted applications — runtime doubles as the image reference.
|
||||
{
|
||||
ID: "tpl-nextcloud",
|
||||
Name: "Nextcloud",
|
||||
Description: "Self-hosted file sync, share and collaboration platform",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/nextcloud",
|
||||
Config: `{"type":"web","runtime":"nextcloud:30-apache","port":80,"health_check":"/status.php"}`,
|
||||
Variables: `[{"key":"NEXTCLOUD_ADMIN_USER","label":"Admin User","default":"admin","required":false,"secret":false},{"key":"NEXTCLOUD_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true},{"key":"NEXTCLOUD_TRUSTED_DOMAINS","label":"Trusted Domains","default":"","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-gitea",
|
||||
Name: "Gitea",
|
||||
Description: "Lightweight self-hosted Git service",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/gitea",
|
||||
Config: `{"type":"web","runtime":"gitea/gitea:1","port":3000,"health_check":"/api/healthz"}`,
|
||||
Variables: `[{"key":"GITEA__server__DOMAIN","label":"Server Domain","default":"localhost","required":false,"secret":false},{"key":"GITEA__server__ROOT_URL","label":"Root URL","default":"","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-ghost",
|
||||
Name: "Ghost",
|
||||
Description: "Independent publishing platform for blogs and newsletters",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/ghost",
|
||||
Config: `{"type":"web","runtime":"ghost:5-alpine","port":2368,"health_check":"/ghost/api/admin/site/"}`,
|
||||
Variables: `[{"key":"url","label":"Public URL","default":"","required":true,"secret":false},{"key":"database__client","label":"Database Client","default":"sqlite3","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-uptime-kuma",
|
||||
Name: "Uptime Kuma",
|
||||
Description: "Self-hosted monitoring tool for services and websites",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/uptimerobot",
|
||||
Config: `{"type":"web","runtime":"louislam/uptime-kuma:1","port":3001,"health_check":"/"}`,
|
||||
Variables: `[]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-vaultwarden",
|
||||
Name: "Vaultwarden",
|
||||
Description: "Lightweight Bitwarden-compatible password manager",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/bitwarden",
|
||||
Config: `{"type":"web","runtime":"vaultwarden/server:latest","port":80,"health_check":"/alive"}`,
|
||||
Variables: `[{"key":"ADMIN_TOKEN","label":"Admin Token","default":"","required":false,"secret":true},{"key":"SIGNUPS_ALLOWED","label":"Allow Signups","default":"false","required":false,"secret":false},{"key":"DOMAIN","label":"Domain URL","default":"","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-jellyfin",
|
||||
Name: "Jellyfin",
|
||||
Description: "Free software media system for movies, shows and music",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/jellyfin",
|
||||
Config: `{"type":"web","runtime":"jellyfin/jellyfin:latest","port":8096,"health_check":"/health"}`,
|
||||
Variables: `[]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-n8n",
|
||||
Name: "n8n",
|
||||
Description: "Workflow automation tool with hundreds of integrations",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/n8n",
|
||||
Config: `{"type":"web","runtime":"n8nio/n8n:latest","port":5678,"health_check":"/healthz"}`,
|
||||
Variables: `[{"key":"N8N_HOST","label":"Hostname","default":"","required":false,"secret":false},{"key":"N8N_ENCRYPTION_KEY","label":"Encryption Key","default":"","required":true,"secret":true},{"key":"WEBHOOK_URL","label":"Webhook URL","default":"","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-grafana",
|
||||
Name: "Grafana",
|
||||
Description: "Observability dashboards for metrics, logs and traces",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/grafana",
|
||||
Config: `{"type":"web","runtime":"grafana/grafana:latest","port":3000,"health_check":"/api/health"}`,
|
||||
Variables: `[{"key":"GF_SECURITY_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-immich",
|
||||
Name: "Immich",
|
||||
Description: "Self-hosted photo and video backup",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/immich",
|
||||
Config: `{"type":"web","runtime":"ghcr.io/immich-app/immich-server:release","port":2283,"health_check":"/api/server/ping"}`,
|
||||
Variables: `[{"key":"DB_PASSWORD","label":"Database Password","default":"","required":true,"secret":true},{"key":"DB_HOSTNAME","label":"Database Host","default":"","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-meilisearch",
|
||||
Name: "Meilisearch",
|
||||
Description: "Fast, typo-tolerant search engine API",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/meilisearch",
|
||||
Config: `{"type":"web","runtime":"getmeili/meilisearch:v1","port":7700,"health_check":"/health"}`,
|
||||
Variables: `[{"key":"MEILI_MASTER_KEY","label":"Master Key","default":"","required":true,"secret":true},{"key":"MEILI_ENV","label":"Environment","default":"production","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-plausible",
|
||||
Name: "Plausible Analytics",
|
||||
Description: "Lightweight, privacy-friendly web analytics",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/plausibleanalytics",
|
||||
Config: `{"type":"web","runtime":"ghcr.io/plausible/community-edition:v2","port":8000,"health_check":"/api/health"}`,
|
||||
Variables: `[{"key":"BASE_URL","label":"Base URL","default":"","required":true,"secret":false},{"key":"SECRET_KEY_BASE","label":"Secret Key Base","default":"","required":true,"secret":true}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-minio",
|
||||
Name: "MinIO",
|
||||
Description: "S3-compatible object storage",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/minio",
|
||||
Config: `{"type":"web","runtime":"minio/minio:latest","start_command":"server /data --console-address \":9001\"","port":9001,"health_check":"/minio/health/live"}`,
|
||||
Variables: `[{"key":"MINIO_ROOT_USER","label":"Root User","default":"minioadmin","required":true,"secret":false},{"key":"MINIO_ROOT_PASSWORD","label":"Root Password","default":"","required":true,"secret":true}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-appwrite",
|
||||
Name: "Appwrite",
|
||||
Description: "Backend-as-a-service for web and mobile apps",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/appwrite",
|
||||
Config: `{"type":"web","runtime":"appwrite/appwrite:1.6","port":80,"health_check":"/health"}`,
|
||||
Variables: `[]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-wordpress",
|
||||
Name: "WordPress",
|
||||
Description: "Classic CMS powering a large share of the web",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/wordpress",
|
||||
Config: `{"type":"web","runtime":"wordpress:6-apache","port":80,"health_check":"/wp-login.php"}`,
|
||||
Variables: `[{"key":"WORDPRESS_DB_HOST","label":"Database Host","default":"","required":true,"secret":false},{"key":"WORDPRESS_DB_USER","label":"Database User","default":"wordpress","required":true,"secret":false},{"key":"WORDPRESS_DB_PASSWORD","label":"Database Password","default":"","required":true,"secret":true},{"key":"WORDPRESS_DB_NAME","label":"Database Name","default":"wordpress","required":true,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-paperless",
|
||||
Name: "Paperless-ngx",
|
||||
Description: "Document management system with OCR",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/paperlessngx",
|
||||
Config: `{"type":"web","runtime":"ghcr.io/paperless-ngx/paperless-ngx:latest","port":8000,"health_check":"/api/"}`,
|
||||
Variables: `[{"key":"PAPERLESS_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true},{"key":"PAPERLESS_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-ntfy",
|
||||
Name: "ntfy",
|
||||
Description: "Push notification service over HTTP",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/ntfy",
|
||||
Config: `{"type":"web","runtime":"binwiederhier/ntfy:latest","start_command":"serve","port":80,"health_check":"/v1/health"}`,
|
||||
Variables: `[]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
}
|
||||
return templates
|
||||
}
|
||||
@@ -605,12 +1027,31 @@ func defaultTemplateResources(serviceType string) (cpu, memory string) {
|
||||
}
|
||||
}
|
||||
|
||||
// templateVisibleTo reports whether the caller may see a template: official and
|
||||
// published (is_public) templates are public, other user templates are
|
||||
// owner/admin only.
|
||||
func templateVisibleTo(c *gin.Context, t ServiceTemplate) bool {
|
||||
if t.IsOfficial || t.IsPublic {
|
||||
return true
|
||||
}
|
||||
if contextIsAdmin(c) {
|
||||
return true
|
||||
}
|
||||
userID := optionalUserID(c)
|
||||
return userID != "" && t.OwnerID == userID
|
||||
}
|
||||
|
||||
func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
|
||||
variables := "[]"
|
||||
if row.Variables.Valid && len(row.Variables.RawMessage) > 0 {
|
||||
variables = string(row.Variables.RawMessage)
|
||||
}
|
||||
|
||||
ownerID := ""
|
||||
if row.OwnerID.Valid {
|
||||
ownerID = row.OwnerID.UUID.String()
|
||||
}
|
||||
|
||||
return ServiceTemplate{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
@@ -620,6 +1061,8 @@ func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
|
||||
Config: string(row.Config),
|
||||
Variables: variables,
|
||||
IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool,
|
||||
OwnerID: ownerID,
|
||||
IsPublic: row.IsPublic,
|
||||
CreatedAt: templateNullTime(row.CreatedAt),
|
||||
UpdatedAt: templateNullTime(row.UpdatedAt),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
sqlcdb "containr/internal/database/sqlcdb"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Personal access tokens let the CLI, MCP server, and third-party agents
|
||||
// authenticate without a browser session. The raw token (cnp_…) is
|
||||
// returned exactly once at creation; only its sha256 hash persists.
|
||||
|
||||
var userTokenScopes = map[string]bool{"read": true, "write": true, "admin": true}
|
||||
|
||||
func generateUserToken() (token string, prefix string, hash string, err error) {
|
||||
buf := make([]byte, 24)
|
||||
if _, err = rand.Read(buf); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
token = "cnp_" + hex.EncodeToString(buf)
|
||||
prefix = token[:12] // cnp_ + 8 hex chars, enough to identify in a list
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return token, prefix, hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func userTokenJSON(t sqlcdb.UserToken) gin.H {
|
||||
row := gin.H{
|
||||
"id": t.ID.String(),
|
||||
"name": t.Name,
|
||||
"key_prefix": t.KeyPrefix,
|
||||
"scope": t.Scope,
|
||||
}
|
||||
if t.CreatedAt.Valid {
|
||||
row["created_at"] = t.CreatedAt.Time
|
||||
}
|
||||
if t.LastUsedAt.Valid {
|
||||
row["last_used_at"] = t.LastUsedAt.Time
|
||||
}
|
||||
if t.ExpiresAt.Valid {
|
||||
row["expires_at"] = t.ExpiresAt.Time
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func handleCreateUserToken(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
ExpiresInDays int `json:"expires_in_days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
if req.Scope == "" {
|
||||
req.Scope = "write"
|
||||
}
|
||||
if !userTokenScopes[req.Scope] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be one of: read, write, admin"})
|
||||
return
|
||||
}
|
||||
// A token can never outrank its owner: non-admin users cannot mint
|
||||
// admin-scope tokens even for themselves.
|
||||
if req.Scope == "admin" && !c.GetBool("is_admin") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "admin scope requires a platform admin account"})
|
||||
return
|
||||
}
|
||||
if req.ExpiresInDays < 0 || req.ExpiresInDays > 3650 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "expires_in_days must be between 0 (never) and 3650"})
|
||||
return
|
||||
}
|
||||
|
||||
token, prefix, hash, err := generateUserToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
ownerUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := sql.NullTime{}
|
||||
if req.ExpiresInDays > 0 {
|
||||
expiresAt = sql.NullTime{Time: time.Now().Add(time.Duration(req.ExpiresInDays) * 24 * time.Hour), Valid: true}
|
||||
}
|
||||
|
||||
row, err := queries.CreateUserToken(c.Request.Context(), sqlcdb.CreateUserTokenParams{
|
||||
UserID: ownerUUID,
|
||||
Name: req.Name,
|
||||
KeyPrefix: prefix,
|
||||
TokenHash: hash,
|
||||
Scope: req.Scope,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAudit(userID, "user_token", row.ID.String(), "create", map[string]interface{}{"name": req.Name, "scope": req.Scope})
|
||||
|
||||
payload := userTokenJSON(row)
|
||||
payload["token"] = token
|
||||
c.JSON(http.StatusCreated, payload)
|
||||
}
|
||||
|
||||
func handleListUserTokens(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
|
||||
ownerUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := queries.ListUserTokens(c.Request.Context(), ownerUUID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens"})
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]gin.H, 0, len(rows))
|
||||
for _, t := range rows {
|
||||
out = append(out, userTokenJSON(t))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"tokens": out})
|
||||
}
|
||||
|
||||
func handleDeleteUserToken(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
|
||||
ownerUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
return
|
||||
}
|
||||
tokenUUID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID"})
|
||||
return
|
||||
}
|
||||
|
||||
affected, err := queries.RevokeUserToken(c.Request.Context(), sqlcdb.RevokeUserTokenParams{
|
||||
ID: tokenUUID,
|
||||
UserID: ownerUUID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token"})
|
||||
return
|
||||
}
|
||||
if affected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAudit(userID, "user_token", tokenUUID.String(), "revoke", nil)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Token revoked"})
|
||||
}
|
||||
@@ -47,26 +47,12 @@ func handleGetVariables(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerCheck string
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT p.owner_id FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil {
|
||||
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
@@ -123,26 +109,12 @@ func handleUpdateVariables(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerCheck string
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT p.owner_id FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil {
|
||||
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -93,10 +93,10 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
|
||||
return nil, fmt.Errorf("prebuilt image not specified")
|
||||
}
|
||||
|
||||
// Pull the prebuilt image
|
||||
// Pull the prebuilt image — must drain the pull stream or the daemon
|
||||
// aborts it and the tag below races a missing image.
|
||||
auth := registry.AuthConfig{}
|
||||
_, err := bm.dockerClient.PullImage(ctx, req.PrebuiltImage, auth)
|
||||
if err != nil {
|
||||
if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil {
|
||||
return nil, fmt.Errorf("failed to pull prebuilt image: %w", err)
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
|
||||
if req.ImageName != "" && req.ImageTag != "" {
|
||||
targetImage := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
||||
if targetImage != req.PrebuiltImage {
|
||||
err = bm.dockerClient.TagImage(ctx, req.PrebuiltImage, targetImage)
|
||||
err := bm.dockerClient.TagImage(ctx, req.PrebuiltImage, targetImage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to tag image: %w", err)
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
|
||||
|
||||
// Push to registry if specified
|
||||
if req.RegistryURL != "" {
|
||||
err = bm.dockerClient.PushImage(ctx, req.PrebuiltImage, req.RegistryURL)
|
||||
err := bm.dockerClient.PushImage(ctx, req.PrebuiltImage, req.RegistryURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to push image: %w", err)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ func Execute() {
|
||||
cobra.CheckErr(rootCmd.Execute())
|
||||
}
|
||||
|
||||
// SetVersion overrides the version reported by `containr --version`.
|
||||
// Called by main with the value injected via -ldflags at build time.
|
||||
func SetVersion(v string) {
|
||||
rootCmd.Version = v
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
|
||||
|
||||
@@ -91,6 +91,12 @@ type Config struct {
|
||||
|
||||
// Dashboard UI Configuration
|
||||
DashboardUIBasePath string `env:"DASHBOARD_UI_BASE_PATH" default:"/"`
|
||||
|
||||
// Admin bootstrap — when set, the account is provisioned/promoted on
|
||||
// startup so the owner always has admin credentials that work.
|
||||
AdminEmail string `env:"ADMIN_EMAIL" default:""`
|
||||
AdminPassword string `env:"ADMIN_PASSWORD" default:""`
|
||||
AdminName string `env:"ADMIN_NAME" default:""`
|
||||
}
|
||||
|
||||
// Environment represents the application environment
|
||||
@@ -181,6 +187,10 @@ func Load() *Config {
|
||||
|
||||
cfg.DashboardUIBasePath = getenv("DASHBOARD_UI_BASE_PATH", "/")
|
||||
|
||||
cfg.AdminEmail = getenv("ADMIN_EMAIL", "")
|
||||
cfg.AdminPassword = getenv("ADMIN_PASSWORD", "")
|
||||
cfg.AdminName = getenv("ADMIN_NAME", "")
|
||||
|
||||
// Validate configuration
|
||||
if err := cfg.Validate(); err != nil {
|
||||
panic(fmt.Sprintf("Invalid configuration: %v", err))
|
||||
@@ -215,9 +225,8 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("SEED_DATA_ON_START must be false in production")
|
||||
}
|
||||
|
||||
if !c.CookieSecure {
|
||||
return fmt.Errorf("COOKIE_SECURE must be true in production")
|
||||
}
|
||||
// COOKIE_SECURE=false is legal for plain-HTTP self-hosted installs and is
|
||||
// reported as a startup warning in main.go rather than a fatal error.
|
||||
|
||||
if c.BetterAuthEnabled {
|
||||
if len(strings.TrimSpace(c.BetterAuthSecret)) < 32 ||
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestValidateAllowsWildcardCorsWithoutCredentialsInProduction(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInsecureCookieInProduction(t *testing.T) {
|
||||
func TestValidateAllowsInsecureCookieInProduction(t *testing.T) {
|
||||
t.Setenv("ENVIRONMENT", "production")
|
||||
cfg := Config{
|
||||
JWTSecret: "this-is-a-very-strong-production-secret-123",
|
||||
@@ -120,9 +120,10 @@ func TestValidateRejectsInsecureCookieInProduction(t *testing.T) {
|
||||
CORSCredentials: true,
|
||||
}
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "COOKIE_SECURE must be true") {
|
||||
t.Fatalf("expected COOKIE_SECURE validation error, got %v", err)
|
||||
// Plain-HTTP self-hosted installs are a supported production deployment;
|
||||
// insecure cookies are allowed and surfaced as a startup warning.
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("expected production config with insecure cookies to validate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,5 +55,5 @@ func (db *DB) Health(ctx context.Context) error {
|
||||
|
||||
// Stats returns connection pool statistics for monitoring
|
||||
func (db *DB) Stats() sql.DBStats {
|
||||
return db.Stats()
|
||||
return db.DB.Stats()
|
||||
}
|
||||
@@ -149,37 +149,18 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
|
||||
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
FROM database_services
|
||||
WHERE id = $1 AND user_id = $2
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
type GetDatabaseServiceByIDAndUserParams struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
type GetDatabaseServiceByIDAndUserRow struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Plan string `json:"plan"`
|
||||
Region string `json:"region"`
|
||||
ConnectionUrl sql.NullString `json:"connection_url"`
|
||||
BackupSchedule sql.NullString `json:"backup_schedule"`
|
||||
NextBackupAt sql.NullTime `json:"next_backup_at"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (GetDatabaseServiceByIDAndUserRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDatabaseServiceByIDAndUser, arg.ID, arg.UserID)
|
||||
var i GetDatabaseServiceByIDAndUserRow
|
||||
func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDatabaseServiceByID, id)
|
||||
var i DatabaseService
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Type,
|
||||
&i.Status,
|
||||
@@ -195,6 +176,94 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
FROM database_services
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type GetDatabaseServiceByIDAndUserParams struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDatabaseServiceByIDAndUser, arg.ID, arg.UserID)
|
||||
var i DatabaseService
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Type,
|
||||
&i.Status,
|
||||
&i.Version,
|
||||
&i.Plan,
|
||||
&i.Region,
|
||||
&i.ConnectionUrl,
|
||||
&i.BackupSchedule,
|
||||
&i.NextBackupAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getDatabaseServiceOwnerID = `-- name: GetDatabaseServiceOwnerID :one
|
||||
SELECT user_id
|
||||
FROM database_services
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDatabaseServiceOwnerID, id)
|
||||
var user_id string
|
||||
err := row.Scan(&user_id)
|
||||
return user_id, err
|
||||
}
|
||||
|
||||
const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
FROM database_services
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listAllDatabaseServices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []DatabaseService{}
|
||||
for rows.Next() {
|
||||
var i DatabaseService
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Type,
|
||||
&i.Status,
|
||||
&i.Version,
|
||||
&i.Plan,
|
||||
&i.Region,
|
||||
&i.ConnectionUrl,
|
||||
&i.BackupSchedule,
|
||||
&i.NextBackupAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listDatabaseBackupsByDatabaseAndUser = `-- name: ListDatabaseBackupsByDatabaseAndUser :many
|
||||
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at
|
||||
FROM database_backups b
|
||||
@@ -242,38 +311,24 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
|
||||
}
|
||||
|
||||
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
|
||||
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
FROM database_services
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
type ListDatabaseServicesByUserRow struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Plan string `json:"plan"`
|
||||
Region string `json:"region"`
|
||||
ConnectionUrl sql.NullString `json:"connection_url"`
|
||||
BackupSchedule sql.NullString `json:"backup_schedule"`
|
||||
NextBackupAt sql.NullTime `json:"next_backup_at"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error) {
|
||||
func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDatabaseServicesByUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListDatabaseServicesByUserRow{}
|
||||
items := []DatabaseService{}
|
||||
for rows.Next() {
|
||||
var i ListDatabaseServicesByUserRow
|
||||
var i DatabaseService
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Type,
|
||||
&i.Status,
|
||||
|
||||
@@ -154,6 +154,7 @@ type Project struct {
|
||||
Name string `json:"name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
IsApproved bool `json:"is_approved"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
@@ -194,6 +195,12 @@ type Service struct {
|
||||
BuildPath sql.NullString `json:"build_path"`
|
||||
Cpu sql.NullString `json:"cpu"`
|
||||
Memory sql.NullString `json:"memory"`
|
||||
Replicas int32 `json:"replicas"`
|
||||
Port int32 `json:"port"`
|
||||
Domain string `json:"domain"`
|
||||
HealthcheckPath string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
PublishedPort int32 `json:"published_port"`
|
||||
}
|
||||
|
||||
type ServiceTemplate struct {
|
||||
@@ -205,6 +212,21 @@ type ServiceTemplate struct {
|
||||
Config json.RawMessage `json:"config"`
|
||||
Variables pqtype.NullRawMessage `json:"variables"`
|
||||
IsOfficial sql.NullBool `json:"is_official"`
|
||||
OwnerID uuid.NullUUID `json:"owner_id"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
type UserToken struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
KeyPrefix string `json:"key_prefix"`
|
||||
TokenHash string `json:"token_hash"`
|
||||
Scope string `json:"scope"`
|
||||
ExpiresAt sql.NullTime `json:"expires_at"`
|
||||
LastUsedAt sql.NullTime `json:"last_used_at"`
|
||||
RevokedAt sql.NullTime `json:"revoked_at"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
}
|
||||
@@ -16,25 +16,31 @@ const countProjectsByUser = `-- name: CountProjectsByUser :one
|
||||
SELECT COUNT(*)::bigint AS total
|
||||
FROM projects p
|
||||
WHERE
|
||||
(p.owner_id = $1 OR EXISTS (
|
||||
(
|
||||
p.is_approved
|
||||
OR p.owner_id = $1
|
||||
OR $2::bool
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM project_members pm
|
||||
WHERE pm.project_id = p.id AND pm.user_id = $1
|
||||
))
|
||||
)
|
||||
)
|
||||
AND (
|
||||
$2::text IS NULL
|
||||
OR p.name ILIKE ('%' || $2::text || '%')
|
||||
OR COALESCE(p.description, '') ILIKE ('%' || $2::text || '%')
|
||||
$3::text IS NULL
|
||||
OR p.name ILIKE ('%' || $3::text || '%')
|
||||
OR COALESCE(p.description, '') ILIKE ('%' || $3::text || '%')
|
||||
)
|
||||
`
|
||||
|
||||
type CountProjectsByUserParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Search sql.NullString `json:"search"`
|
||||
}
|
||||
|
||||
func (q *Queries) CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countProjectsByUser, arg.UserID, arg.Search)
|
||||
row := q.db.QueryRowContext(ctx, countProjectsByUser, arg.UserID, arg.IsAdmin, arg.Search)
|
||||
var total int64
|
||||
err := row.Scan(&total)
|
||||
return total, err
|
||||
@@ -43,7 +49,7 @@ func (q *Queries) CountProjectsByUser(ctx context.Context, arg CountProjectsByUs
|
||||
const createProject = `-- name: CreateProject :one
|
||||
INSERT INTO projects (name, description, owner_id)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, description, owner_id, created_at, updated_at
|
||||
RETURNING id, name, description, owner_id, is_approved, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateProjectParams struct {
|
||||
@@ -60,6 +66,7 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (P
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.OwnerID,
|
||||
&i.IsApproved,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
@@ -80,11 +87,13 @@ func (q *Queries) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (i
|
||||
}
|
||||
|
||||
const getProjectByIDForUser = `-- name: GetProjectByIDForUser :one
|
||||
SELECT p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
|
||||
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
|
||||
FROM projects p
|
||||
WHERE p.id = $1
|
||||
AND (
|
||||
p.owner_id = $2
|
||||
p.is_approved
|
||||
OR p.owner_id = $2
|
||||
OR $3::bool
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM project_members pm
|
||||
@@ -96,16 +105,18 @@ WHERE p.id = $1
|
||||
type GetProjectByIDForUserParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error) {
|
||||
row := q.db.QueryRowContext(ctx, getProjectByIDForUser, arg.ProjectID, arg.UserID)
|
||||
row := q.db.QueryRowContext(ctx, getProjectByIDForUser, arg.ProjectID, arg.UserID, arg.IsAdmin)
|
||||
var i Project
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.OwnerID,
|
||||
&i.IsApproved,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
@@ -127,21 +138,22 @@ func (q *Queries) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID)
|
||||
|
||||
const getProjectRoleForUser = `-- name: GetProjectRoleForUser :one
|
||||
SELECT (CASE
|
||||
WHEN p.owner_id = $1 THEN 'owner'
|
||||
WHEN p.owner_id = $1 OR $2::bool THEN 'owner'
|
||||
ELSE COALESCE(pm.role, '')
|
||||
END)::text AS role
|
||||
FROM projects p
|
||||
LEFT JOIN project_members pm ON p.id = pm.project_id AND pm.user_id = $1
|
||||
WHERE p.id = $2
|
||||
WHERE p.id = $3
|
||||
`
|
||||
|
||||
type GetProjectRoleForUserParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getProjectRoleForUser, arg.UserID, arg.ProjectID)
|
||||
row := q.db.QueryRowContext(ctx, getProjectRoleForUser, arg.UserID, arg.IsAdmin, arg.ProjectID)
|
||||
var role string
|
||||
err := row.Scan(&role)
|
||||
return role, err
|
||||
@@ -162,12 +174,52 @@ func (q *Queries) InsertProjectEnvironment(ctx context.Context, arg InsertProjec
|
||||
return err
|
||||
}
|
||||
|
||||
const listPendingProjects = `-- name: ListPendingProjects :many
|
||||
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
|
||||
FROM projects p
|
||||
WHERE p.is_approved = false
|
||||
ORDER BY p.created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListPendingProjects(ctx context.Context) ([]Project, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listPendingProjects)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Project{}
|
||||
for rows.Next() {
|
||||
var i Project
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.OwnerID,
|
||||
&i.IsApproved,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listProjectsWithStatsByUser = `-- name: ListProjectsWithStatsByUser :many
|
||||
|
||||
SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.description,
|
||||
p.owner_id,
|
||||
p.is_approved,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
COUNT(DISTINCT s.id)::bigint AS service_count,
|
||||
@@ -185,23 +237,29 @@ FROM projects p
|
||||
LEFT JOIN services s ON s.project_id = p.id
|
||||
LEFT JOIN deployments d ON d.service_id = s.id
|
||||
WHERE
|
||||
(p.owner_id = $1 OR EXISTS (
|
||||
(
|
||||
p.is_approved
|
||||
OR p.owner_id = $1
|
||||
OR $2::bool
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM project_members pm
|
||||
WHERE pm.project_id = p.id AND pm.user_id = $1
|
||||
))
|
||||
AND (
|
||||
$2::text IS NULL
|
||||
OR p.name ILIKE ('%' || $2::text || '%')
|
||||
OR COALESCE(p.description, '') ILIKE ('%' || $2::text || '%')
|
||||
)
|
||||
GROUP BY p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
|
||||
)
|
||||
AND (
|
||||
$3::text IS NULL
|
||||
OR p.name ILIKE ('%' || $3::text || '%')
|
||||
OR COALESCE(p.description, '') ILIKE ('%' || $3::text || '%')
|
||||
)
|
||||
GROUP BY p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
|
||||
ORDER BY p.updated_at DESC
|
||||
LIMIT $4 OFFSET $3
|
||||
LIMIT $5 OFFSET $4
|
||||
`
|
||||
|
||||
type ListProjectsWithStatsByUserParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Search sql.NullString `json:"search"`
|
||||
OffsetCount int32 `json:"offset_count"`
|
||||
LimitCount int32 `json:"limit_count"`
|
||||
@@ -212,6 +270,7 @@ type ListProjectsWithStatsByUserRow struct {
|
||||
Name string `json:"name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
IsApproved bool `json:"is_approved"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ServiceCount int64 `json:"service_count"`
|
||||
@@ -220,9 +279,13 @@ type ListProjectsWithStatsByUserRow struct {
|
||||
LastDeployment sql.NullTime `json:"last_deployment"`
|
||||
}
|
||||
|
||||
// Visibility model: a project is readable when it is approved for public
|
||||
// display, when the caller owns or is a member of it, or when the caller is a
|
||||
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
|
||||
func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listProjectsWithStatsByUser,
|
||||
arg.UserID,
|
||||
arg.IsAdmin,
|
||||
arg.Search,
|
||||
arg.OffsetCount,
|
||||
arg.LimitCount,
|
||||
@@ -239,6 +302,7 @@ func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProje
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.OwnerID,
|
||||
&i.IsApproved,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ServiceCount,
|
||||
@@ -259,6 +323,25 @@ func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProje
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setProjectApproved = `-- name: SetProjectApproved :execrows
|
||||
UPDATE projects
|
||||
SET is_approved = $1, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
`
|
||||
|
||||
type SetProjectApprovedParams struct {
|
||||
IsApproved bool `json:"is_approved"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, setProjectApproved, arg.IsApproved, arg.ProjectID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const updateProjectByID = `-- name: UpdateProjectByID :execrows
|
||||
UPDATE projects
|
||||
SET
|
||||
|
||||
@@ -25,10 +25,13 @@ type Querier interface {
|
||||
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
|
||||
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
||||
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
||||
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
|
||||
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
|
||||
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
||||
DeleteAgent(ctx context.Context, id string) error
|
||||
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
||||
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
|
||||
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
|
||||
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
|
||||
GetAgent(ctx context.Context, id string) (NodeAgent, error)
|
||||
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
|
||||
@@ -36,37 +39,49 @@ type Querier interface {
|
||||
GetContainer(ctx context.Context, id string) (ContainerInstance, error)
|
||||
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
|
||||
GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, arg GetDatabaseBackupByIDAndDatabaseAndUserParams) (DatabaseBackup, error)
|
||||
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (GetDatabaseServiceByIDAndUserRow, error)
|
||||
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
|
||||
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
|
||||
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error)
|
||||
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
|
||||
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
|
||||
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
|
||||
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
|
||||
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
|
||||
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
|
||||
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
|
||||
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
|
||||
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
|
||||
ListAgents(ctx context.Context) ([]NodeAgent, error)
|
||||
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
|
||||
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
|
||||
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
|
||||
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error)
|
||||
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
|
||||
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
|
||||
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
|
||||
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||
ListPendingProjects(ctx context.Context) ([]Project, error)
|
||||
// Visibility model: a project is readable when it is approved for public
|
||||
// display, when the caller owns or is a member of it, or when the caller is a
|
||||
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
|
||||
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
||||
ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error)
|
||||
ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error)
|
||||
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
||||
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
|
||||
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
|
||||
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
||||
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
||||
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
||||
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
|
||||
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
||||
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
||||
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
|
||||
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
|
||||
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
|
||||
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
|
||||
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
|
||||
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
|
||||
TouchUserToken(ctx context.Context, id uuid.UUID) error
|
||||
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
|
||||
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
||||
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
||||
@@ -74,7 +89,9 @@ type Querier interface {
|
||||
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
|
||||
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
|
||||
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
|
||||
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
|
||||
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
|
||||
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
@@ -8,8 +8,10 @@ package sqlcdb
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
)
|
||||
|
||||
const countServicesByProjectAndName = `-- name: CountServicesByProjectAndName :one
|
||||
@@ -122,6 +124,69 @@ func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServi
|
||||
return err
|
||||
}
|
||||
|
||||
const createUserTemplate = `-- name: CreateUserTemplate :exec
|
||||
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id, is_public)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
false,
|
||||
$8,
|
||||
$9
|
||||
)
|
||||
`
|
||||
|
||||
type CreateUserTemplateParams struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Logo sql.NullString `json:"logo"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
Variables pqtype.NullRawMessage `json:"variables"`
|
||||
OwnerID uuid.NullUUID `json:"owner_id"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createUserTemplate,
|
||||
arg.ID,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Category,
|
||||
arg.Logo,
|
||||
arg.Config,
|
||||
arg.Variables,
|
||||
arg.OwnerID,
|
||||
arg.IsPublic,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteUserTemplate = `-- name: DeleteUserTemplate :execrows
|
||||
DELETE FROM service_templates
|
||||
WHERE id = $1
|
||||
AND owner_id = $2
|
||||
AND is_official = false
|
||||
`
|
||||
|
||||
type DeleteUserTemplateParams struct {
|
||||
ID string `json:"id"`
|
||||
OwnerID uuid.NullUUID `json:"owner_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, deleteUserTemplate, arg.ID, arg.OwnerID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getProjectOwnerID = `-- name: GetProjectOwnerID :one
|
||||
SELECT owner_id
|
||||
FROM projects
|
||||
@@ -136,7 +201,7 @@ func (q *Queries) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUI
|
||||
}
|
||||
|
||||
const getServiceTemplateByID = `-- name: GetServiceTemplateByID :one
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
|
||||
FROM service_templates
|
||||
WHERE id = $1
|
||||
`
|
||||
@@ -153,61 +218,29 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic
|
||||
&i.Config,
|
||||
&i.Variables,
|
||||
&i.IsOfficial,
|
||||
&i.OwnerID,
|
||||
&i.IsPublic,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listServiceTemplates = `-- name: ListServiceTemplates :many
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
|
||||
FROM service_templates
|
||||
ORDER BY is_official DESC, name ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listServiceTemplates)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ServiceTemplate{}
|
||||
for rows.Next() {
|
||||
var i ServiceTemplate
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Category,
|
||||
&i.Logo,
|
||||
&i.Config,
|
||||
&i.Variables,
|
||||
&i.IsOfficial,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listServiceTemplatesByCategory = `-- name: ListServiceTemplatesByCategory :many
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
|
||||
const listServiceTemplatesByCategoryForUser = `-- name: ListServiceTemplatesByCategoryForUser :many
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
|
||||
FROM service_templates
|
||||
WHERE category = $1
|
||||
AND (is_official = true OR is_public = true OR owner_id = $2)
|
||||
ORDER BY is_official DESC, name ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listServiceTemplatesByCategory, category)
|
||||
type ListServiceTemplatesByCategoryForUserParams struct {
|
||||
Category string `json:"category"`
|
||||
OwnerID uuid.NullUUID `json:"owner_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listServiceTemplatesByCategoryForUser, arg.Category, arg.OwnerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -224,6 +257,8 @@ func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category s
|
||||
&i.Config,
|
||||
&i.Variables,
|
||||
&i.IsOfficial,
|
||||
&i.OwnerID,
|
||||
&i.IsPublic,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -240,6 +275,94 @@ func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category s
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listServiceTemplatesForUser = `-- name: ListServiceTemplatesForUser :many
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
|
||||
FROM service_templates
|
||||
WHERE is_official = true OR is_public = true OR owner_id = $1
|
||||
ORDER BY is_official DESC, name ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listServiceTemplatesForUser, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ServiceTemplate{}
|
||||
for rows.Next() {
|
||||
var i ServiceTemplate
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Category,
|
||||
&i.Logo,
|
||||
&i.Config,
|
||||
&i.Variables,
|
||||
&i.IsOfficial,
|
||||
&i.OwnerID,
|
||||
&i.IsPublic,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateUserTemplate = `-- name: UpdateUserTemplate :execrows
|
||||
UPDATE service_templates
|
||||
SET name = $1,
|
||||
description = $2,
|
||||
category = $3,
|
||||
logo = $4,
|
||||
config = $5,
|
||||
variables = $6,
|
||||
is_public = $7,
|
||||
updated_at = NOW()
|
||||
WHERE id = $8
|
||||
AND owner_id = $9
|
||||
AND is_official = false
|
||||
`
|
||||
|
||||
type UpdateUserTemplateParams struct {
|
||||
Name string `json:"name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Logo sql.NullString `json:"logo"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
Variables pqtype.NullRawMessage `json:"variables"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
ID string `json:"id"`
|
||||
OwnerID uuid.NullUUID `json:"owner_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, updateUserTemplate,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Category,
|
||||
arg.Logo,
|
||||
arg.Config,
|
||||
arg.Variables,
|
||||
arg.IsPublic,
|
||||
arg.ID,
|
||||
arg.OwnerID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const upsertEnvironmentVariable = `-- name: UpsertEnvironmentVariable :exec
|
||||
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
@@ -271,3 +394,51 @@ func (q *Queries) UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvir
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertServiceTemplate = `-- name: UpsertServiceTemplate :exec
|
||||
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
category = EXCLUDED.category,
|
||||
logo = EXCLUDED.logo,
|
||||
config = EXCLUDED.config,
|
||||
variables = EXCLUDED.variables,
|
||||
is_official = EXCLUDED.is_official,
|
||||
updated_at = NOW()
|
||||
`
|
||||
|
||||
type UpsertServiceTemplateParams struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Logo sql.NullString `json:"logo"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
Variables pqtype.NullRawMessage `json:"variables"`
|
||||
IsOfficial sql.NullBool `json:"is_official"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertServiceTemplate,
|
||||
arg.ID,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Category,
|
||||
arg.Logo,
|
||||
arg.Config,
|
||||
arg.Variables,
|
||||
arg.IsOfficial,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: user_tokens.sql
|
||||
|
||||
package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const createUserToken = `-- name: CreateUserToken :one
|
||||
INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at
|
||||
`
|
||||
|
||||
type CreateUserTokenParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
KeyPrefix string `json:"key_prefix"`
|
||||
TokenHash string `json:"token_hash"`
|
||||
Scope string `json:"scope"`
|
||||
ExpiresAt sql.NullTime `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error) {
|
||||
row := q.db.QueryRowContext(ctx, createUserToken,
|
||||
arg.UserID,
|
||||
arg.Name,
|
||||
arg.KeyPrefix,
|
||||
arg.TokenHash,
|
||||
arg.Scope,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
var i UserToken
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.KeyPrefix,
|
||||
&i.TokenHash,
|
||||
&i.Scope,
|
||||
&i.ExpiresAt,
|
||||
&i.LastUsedAt,
|
||||
&i.RevokedAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserTokenByHash = `-- name: GetUserTokenByHash :one
|
||||
SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens
|
||||
WHERE token_hash = $1
|
||||
AND revoked_at IS NULL
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserTokenByHash, tokenHash)
|
||||
var i UserToken
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.KeyPrefix,
|
||||
&i.TokenHash,
|
||||
&i.Scope,
|
||||
&i.ExpiresAt,
|
||||
&i.LastUsedAt,
|
||||
&i.RevokedAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listUserTokens = `-- name: ListUserTokens :many
|
||||
SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens
|
||||
WHERE user_id = $1 AND revoked_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listUserTokens, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []UserToken{}
|
||||
for rows.Next() {
|
||||
var i UserToken
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.KeyPrefix,
|
||||
&i.TokenHash,
|
||||
&i.Scope,
|
||||
&i.ExpiresAt,
|
||||
&i.LastUsedAt,
|
||||
&i.RevokedAt,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const revokeUserToken = `-- name: RevokeUserToken :execrows
|
||||
UPDATE user_tokens SET revoked_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL
|
||||
`
|
||||
|
||||
type RevokeUserTokenParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, revokeUserToken, arg.ID, arg.UserID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const touchUserToken = `-- name: TouchUserToken :exec
|
||||
UPDATE user_tokens SET last_used_at = NOW()
|
||||
WHERE id = $1 AND revoked_at IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) TouchUserToken(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.ExecContext(ctx, touchUserToken, id)
|
||||
return err
|
||||
}
|
||||
@@ -9,10 +9,6 @@ import (
|
||||
"containr/internal/build"
|
||||
"containr/internal/docker"
|
||||
"containr/internal/types"
|
||||
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
|
||||
type DeploymentEngine struct {
|
||||
@@ -56,6 +52,16 @@ type ServiceConfig struct {
|
||||
Resources ResourceLimits `json:"resources,omitempty"`
|
||||
HealthCheck *HealthCheck `json:"health_check,omitempty"`
|
||||
Replicas int `json:"replicas"`
|
||||
// PublicPort is the container port exposed to users (published host port,
|
||||
// or Traefik routing when Domain is set). 0 = internal-only service.
|
||||
PublicPort int32 `json:"public_port,omitempty"`
|
||||
// Domain is a public hostname routed to PublicPort via Traefik.
|
||||
Domain string `json:"domain,omitempty"`
|
||||
// HealthPath is an HTTP path probed on PublicPort for container health.
|
||||
HealthPath string `json:"health_path,omitempty"`
|
||||
// PublishedPort is the host port bound by the previous deployment; replica 0
|
||||
// reuses it so public URLs survive restarts. 0 = pick ephemeral.
|
||||
PublishedPort int32 `json:"published_port,omitempty"`
|
||||
}
|
||||
|
||||
type PortMapping struct {
|
||||
@@ -297,91 +303,54 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme
|
||||
return response.ImageName, nil
|
||||
}
|
||||
|
||||
// deployService deploys the service using the built image
|
||||
// deployService deploys the service using the built image. Containers are
|
||||
// reconciled onto the project network with a DNS alias per service name —
|
||||
// other services reach it at http://<name>:<port>.
|
||||
func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deployment) error {
|
||||
// Convert service config to Docker container config
|
||||
containerConfig := &docker.ContainerConfig{
|
||||
Name: fmt.Sprintf("containr-%s-%s", deployment.ServiceID, deployment.ID),
|
||||
cfg := deployment.Config
|
||||
|
||||
spec := RuntimeSpec{
|
||||
ProjectID: deployment.ProjectID,
|
||||
ServiceID: deployment.ServiceID,
|
||||
Name: cfg.Name,
|
||||
Image: deployment.ImageName,
|
||||
Cmd: deployment.Config.Command,
|
||||
Labels: deployment.Config.Labels,
|
||||
Networks: make(map[string]*network.EndpointSettings),
|
||||
Command: cfg.Command,
|
||||
Env: cfg.Environment,
|
||||
Replicas: cfg.Replicas,
|
||||
Port: cfg.PublicPort,
|
||||
PublishedPort: cfg.PublishedPort,
|
||||
Domain: cfg.Domain,
|
||||
HealthPath: cfg.HealthPath,
|
||||
RestartPolicy: cfg.RestartPolicy,
|
||||
MemoryBytes: cfg.Resources.MemoryBytes,
|
||||
NanoCPUs: cfg.Resources.CPUQuota,
|
||||
}
|
||||
for _, pm := range cfg.PortMappings {
|
||||
if spec.Port == 0 {
|
||||
spec.Port = pm.ContainerPort
|
||||
}
|
||||
}
|
||||
if spec.RestartPolicy == "" {
|
||||
spec.RestartPolicy = "unless-stopped"
|
||||
}
|
||||
|
||||
// Set environment variables
|
||||
for k, v := range deployment.Config.Environment {
|
||||
containerConfig.Env = append(containerConfig.Env, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
|
||||
// Set restart policy
|
||||
containerConfig.RestartPolicy = deployment.Config.RestartPolicy
|
||||
|
||||
// Configure port mappings
|
||||
portBindings := make(nat.PortMap)
|
||||
for _, pm := range deployment.Config.PortMappings {
|
||||
port := nat.Port(fmt.Sprintf("%d/%s", pm.ContainerPort, pm.Protocol))
|
||||
if pm.HostPort > 0 {
|
||||
portBindings[port] = []nat.PortBinding{
|
||||
{
|
||||
HostIP: pm.HostIP,
|
||||
HostPort: fmt.Sprintf("%d", pm.HostPort),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
containerConfig.PortBindings = portBindings
|
||||
|
||||
// Configure resource limits
|
||||
if deployment.Config.Resources.MemoryBytes > 0 {
|
||||
containerConfig.Memory = deployment.Config.Resources.MemoryBytes
|
||||
}
|
||||
if deployment.Config.Resources.CPUQuota > 0 {
|
||||
containerConfig.NanoCPUs = deployment.Config.Resources.CPUQuota
|
||||
}
|
||||
|
||||
// Configure volume mounts
|
||||
for _, vm := range deployment.Config.VolumeMounts {
|
||||
mount := mount.Mount{
|
||||
Type: mount.Type(vm.Type),
|
||||
Source: vm.Source,
|
||||
Target: vm.Destination,
|
||||
ReadOnly: vm.ReadOnly,
|
||||
}
|
||||
containerConfig.Mounts = append(containerConfig.Mounts, mount)
|
||||
}
|
||||
|
||||
// Create containers based on replica count
|
||||
deployment.Containers = make([]ContainerInfo, deployment.Config.Replicas)
|
||||
for i := 0; i < deployment.Config.Replicas; i++ {
|
||||
containerName := fmt.Sprintf("%s-%d", containerConfig.Name, i)
|
||||
|
||||
// Create container
|
||||
containerID, err := de.dockerClient.CreateContainer(ctx, *containerConfig)
|
||||
// Volume mounts aren't reconciled replicas-aware yet; still applied on the
|
||||
// container config of every replica via spec extension later if needed.
|
||||
state, err := de.ReconcileService(ctx, spec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create container %d: %w", i, err)
|
||||
}
|
||||
|
||||
// Start container
|
||||
err = de.dockerClient.StartContainer(ctx, containerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start container %d: %w", i, err)
|
||||
}
|
||||
|
||||
// Get container info
|
||||
_, err = de.dockerClient.GetContainer(ctx, containerID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get container info for %s: %v", containerID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
deployment.Containers = make([]ContainerInfo, len(state.Containers))
|
||||
for i, c := range state.Containers {
|
||||
deployment.Containers[i] = ContainerInfo{
|
||||
ID: containerID,
|
||||
Name: containerName,
|
||||
Status: "running",
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Status: c.State,
|
||||
CreatedAt: time.Now(),
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
package deployment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/docker"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
|
||||
// Container labels used to associate runtime containers with services.
|
||||
const (
|
||||
LabelManaged = "containr.managed"
|
||||
LabelProject = "containr.project"
|
||||
LabelService = "containr.service"
|
||||
LabelReplica = "containr.replica"
|
||||
LabelSpecHash = "containr.spec-hash"
|
||||
LabelDeployment = "containr.deployment"
|
||||
)
|
||||
|
||||
// RuntimeSpec is everything needed to run a service's containers.
|
||||
type RuntimeSpec struct {
|
||||
ProjectID string
|
||||
ServiceID string
|
||||
Name string // DNS alias on the project network
|
||||
Image string
|
||||
Command []string
|
||||
Env map[string]string // already resolved
|
||||
Replicas int
|
||||
Port int32 // container port to expose publicly; 0 = none
|
||||
PublishedPort int32 // preferred host port for replica 0; 0 = pick ephemeral
|
||||
Domain string // public hostname routed via Traefik; empty = none
|
||||
HealthPath string // http path probed on Port for container healthcheck
|
||||
RestartPolicy string
|
||||
MemoryBytes int64
|
||||
NanoCPUs int64
|
||||
}
|
||||
|
||||
// RuntimeContainer describes one live replica.
|
||||
type RuntimeContainer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"` // running, exited, ...
|
||||
Replica int `json:"replica"`
|
||||
}
|
||||
|
||||
// RuntimeState is the live view of a service's containers.
|
||||
type RuntimeState struct {
|
||||
Desired int `json:"desired"`
|
||||
Containers []RuntimeContainer `json:"containers"`
|
||||
URLs []string `json:"urls"`
|
||||
Ports []uint16 `json:"ports"`
|
||||
Health string `json:"health,omitempty"` // healthy, unhealthy, unreachable
|
||||
Status string `json:"status"` // running, degraded, stopped
|
||||
}
|
||||
|
||||
// ProjectNetworkName is the deterministic docker network every service in a
|
||||
// project joins — containr-proj-<first 12 hex chars of the project uuid>.
|
||||
func ProjectNetworkName(projectID string) string {
|
||||
short := strings.ReplaceAll(projectID, "-", "")
|
||||
if len(short) > 12 {
|
||||
short = short[:12]
|
||||
}
|
||||
return "containr-proj-" + short
|
||||
}
|
||||
|
||||
func serviceNamePrefix(serviceID string) string {
|
||||
return fmt.Sprintf("containr-%s-", serviceID)
|
||||
}
|
||||
|
||||
// EnsureProjectNetwork creates the per-project bridge network if missing.
|
||||
// Containers on it resolve each other by service name (private networking).
|
||||
func (de *DeploymentEngine) EnsureProjectNetwork(ctx context.Context, projectID string) (string, error) {
|
||||
name := ProjectNetworkName(projectID)
|
||||
|
||||
networks, err := de.dockerClient.ListNetworks(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, n := range networks {
|
||||
if n.Name == name {
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
|
||||
_, err = de.dockerClient.CreateNetwork(ctx, docker.NetworkConfig{
|
||||
Name: name,
|
||||
Driver: "bridge",
|
||||
Labels: map[string]string{
|
||||
LabelManaged: "true",
|
||||
LabelProject: projectID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
// Concurrent reconciles race on create; re-resolve before failing.
|
||||
networks, lerr := de.dockerClient.ListNetworks(ctx)
|
||||
if lerr == nil {
|
||||
for _, n := range networks {
|
||||
if n.Name == name {
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("create project network: %w", err)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// traefikNetworkName finds the shared edge network (the infra stack's
|
||||
// containr-network where Traefik listens) if it exists.
|
||||
func (de *DeploymentEngine) traefikNetworkName(ctx context.Context) string {
|
||||
networks, err := de.dockerClient.ListNetworks(ctx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, n := range networks {
|
||||
if n.Name == "containr-network" {
|
||||
return n.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ListServiceContainers returns containers managed for a service.
|
||||
func (de *DeploymentEngine) ListServiceContainers(ctx context.Context, serviceID string) ([]container.Summary, error) {
|
||||
return de.dockerClient.ListContainersFiltered(ctx, filters.NewArgs(
|
||||
filters.Arg("label", LabelService+"="+serviceID),
|
||||
), true)
|
||||
}
|
||||
|
||||
// ReconcileService brings live containers in line with the spec. When the
|
||||
// spec hash changed (image/env/ports/etc.) all replicas are recreated;
|
||||
// otherwise only the replica delta is applied.
|
||||
func (de *DeploymentEngine) ReconcileService(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error) {
|
||||
if spec.Replicas < 1 {
|
||||
spec.Replicas = 1
|
||||
}
|
||||
|
||||
networkName, err := de.EnsureProjectNetwork(ctx, spec.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
edgeNetwork := ""
|
||||
if spec.Domain != "" {
|
||||
edgeNetwork = de.traefikNetworkName(ctx)
|
||||
}
|
||||
|
||||
hash := specHash(spec)
|
||||
existing, err := de.ListServiceContainers(ctx, spec.ServiceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Deterministic order: scale-down removes the highest replica indices.
|
||||
sort.Slice(existing, func(i, j int) bool {
|
||||
return replicaIndex(existing[i]) < replicaIndex(existing[j])
|
||||
})
|
||||
|
||||
drifted := false
|
||||
for _, c := range existing {
|
||||
if c.Labels[LabelSpecHash] != hash {
|
||||
drifted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if drifted {
|
||||
for _, c := range existing {
|
||||
_ = de.dockerClient.RemoveContainer(ctx, c.ID, true)
|
||||
}
|
||||
existing = nil
|
||||
}
|
||||
|
||||
for i := len(existing); i > spec.Replicas; i-- {
|
||||
victim := existing[i-1]
|
||||
_ = de.dockerClient.StopContainer(ctx, victim.ID, nil)
|
||||
_ = de.dockerClient.RemoveContainer(ctx, victim.ID, true)
|
||||
existing = existing[:i-1]
|
||||
}
|
||||
|
||||
for i := len(existing); i < spec.Replicas; i++ {
|
||||
if _, err := de.createReplica(ctx, spec, networkName, edgeNetwork, i, hash); err != nil {
|
||||
return nil, fmt.Errorf("create replica %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range existing {
|
||||
if c.State != "running" {
|
||||
_ = de.dockerClient.StartContainer(ctx, c.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return de.RuntimeState(ctx, spec.ServiceID, spec.Replicas)
|
||||
}
|
||||
|
||||
func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec, projectNet, edgeNet string, index int, hash string) (string, error) {
|
||||
env := make([]string, 0, len(spec.Env))
|
||||
for k, v := range spec.Env {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
sort.Strings(env)
|
||||
|
||||
labels := map[string]string{
|
||||
LabelManaged: "true",
|
||||
LabelProject: spec.ProjectID,
|
||||
LabelService: spec.ServiceID,
|
||||
LabelReplica: fmt.Sprintf("%d", index),
|
||||
LabelSpecHash: hash,
|
||||
}
|
||||
|
||||
endpoints := map[string]*network.EndpointSettings{
|
||||
projectNet: {Aliases: []string{spec.Name}},
|
||||
}
|
||||
if edgeNet != "" {
|
||||
endpoints[edgeNet] = &network.EndpointSettings{}
|
||||
labels["traefik.enable"] = "true"
|
||||
labels["traefik.docker.network"] = edgeNet
|
||||
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".rule"] = "Host(`" + spec.Domain + "`)"
|
||||
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".entrypoints"] = "web"
|
||||
labels["traefik.http.services.svc-"+spec.ServiceID[:8]+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
|
||||
}
|
||||
|
||||
cfg := docker.ContainerConfig{
|
||||
Name: fmt.Sprintf("%s%d", serviceNamePrefix(spec.ServiceID), index),
|
||||
Image: spec.Image,
|
||||
Cmd: spec.Command,
|
||||
Env: env,
|
||||
Labels: labels,
|
||||
RestartPolicy: spec.RestartPolicy,
|
||||
Memory: spec.MemoryBytes,
|
||||
NanoCPUs: spec.NanoCPUs,
|
||||
Networks: endpoints,
|
||||
}
|
||||
|
||||
var wantPort nat.Port
|
||||
if spec.Port > 0 {
|
||||
wantPort = nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
|
||||
cfg.ExposedPorts = nat.PortSet{wantPort: struct{}{}}
|
||||
}
|
||||
|
||||
// tryBind creates and starts a container publishing wantPort on hostPort
|
||||
// ("" = ephemeral). A requested port is verified after start: Docker
|
||||
// Desktop accepts a conflicting binding at create/start but publishes
|
||||
// nothing, which would leave the service running with no endpoint.
|
||||
tryBind := func(hostPort string) (string, error) {
|
||||
if spec.Port > 0 {
|
||||
cfg.PortBindings = nat.PortMap{wantPort: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: hostPort}}}
|
||||
}
|
||||
id, err := de.dockerClient.CreateContainer(ctx, cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := de.dockerClient.StartContainer(ctx, id); err != nil {
|
||||
_ = de.dockerClient.RemoveContainer(ctx, id, true)
|
||||
return "", fmt.Errorf("start: %w", err)
|
||||
}
|
||||
if hostPort != "" {
|
||||
info, err := de.dockerClient.GetContainer(ctx, id)
|
||||
if err != nil {
|
||||
_ = de.dockerClient.RemoveContainer(ctx, id, true)
|
||||
return "", fmt.Errorf("inspect: %w", err)
|
||||
}
|
||||
if info.NetworkSettings == nil || len(info.NetworkSettings.Ports[wantPort]) == 0 {
|
||||
_ = de.dockerClient.RemoveContainer(ctx, id, true)
|
||||
return "", errPortNotBound
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
preferred := ""
|
||||
if index == 0 && spec.PublishedPort > 0 {
|
||||
preferred = strconv.Itoa(int(spec.PublishedPort))
|
||||
}
|
||||
id, err := tryBind(preferred)
|
||||
if err != nil && preferred != "" && (isPortConflict(err) || errors.Is(err, errPortNotBound)) {
|
||||
// Stored port got taken since last deploy — fall back to ephemeral; the
|
||||
// new assignment is persisted on the next runtime read.
|
||||
id, err = tryBind("")
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// errPortNotBound marks a container that started but failed to publish the
|
||||
// requested host port (seen on Docker Desktop, which reports success while
|
||||
// vpnkit drops a conflicting binding).
|
||||
var errPortNotBound = errors.New("requested host port not bound")
|
||||
|
||||
// isPortConflict reports whether a container create failed because the
|
||||
// requested host port was already bound.
|
||||
func isPortConflict(err error) bool {
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "port is already allocated") ||
|
||||
strings.Contains(msg, "address already in use") ||
|
||||
strings.Contains(msg, "cannot assign requested address")
|
||||
}
|
||||
|
||||
// RuntimeState reads live container state for a service.
|
||||
func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string, desired int) (*RuntimeState, error) {
|
||||
containers, err := de.ListServiceContainers(ctx, serviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state := &RuntimeState{Desired: desired, Status: "stopped"}
|
||||
running := 0
|
||||
for _, c := range containers {
|
||||
rc := RuntimeContainer{ID: c.ID, Name: strings.TrimPrefix(c.Names[0], "/"), State: c.State, Replica: replicaIndex(c)}
|
||||
state.Containers = append(state.Containers, rc)
|
||||
if c.State == "running" {
|
||||
running++
|
||||
}
|
||||
for _, p := range c.Ports {
|
||||
if p.PublicPort != 0 {
|
||||
state.URLs = append(state.URLs, fmt.Sprintf("http://localhost:%d", p.PublicPort))
|
||||
state.Ports = append(state.Ports, p.PublicPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(state.Containers, func(i, j int) bool { return state.Containers[i].Replica < state.Containers[j].Replica })
|
||||
sort.Strings(state.URLs)
|
||||
|
||||
switch {
|
||||
case running == 0:
|
||||
state.Status = "stopped"
|
||||
case running < desired:
|
||||
state.Status = "degraded"
|
||||
default:
|
||||
state.Status = "running"
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// ProbeServiceHealth performs an HTTP GET on healthPath and records the
|
||||
// outcome on state.Health. When the backend runs containerized it first
|
||||
// attaches to the service's project network and probes the service by DNS
|
||||
// name — this works for every image (no shell or wget required inside the
|
||||
// container) and checks the service itself, not the published edge. A
|
||||
// host-run backend falls back to probing the published host port.
|
||||
func (de *DeploymentEngine) ProbeServiceHealth(ctx context.Context, state *RuntimeState, projectID, serviceName string, port int, healthPath string) {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
state.Health = ""
|
||||
if healthPath == "" || port <= 0 {
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(healthPath, "/") {
|
||||
healthPath = "/" + healthPath
|
||||
}
|
||||
|
||||
probeURL := ""
|
||||
if networkName := ProjectNetworkName(projectID); networkName != "" {
|
||||
if err := de.dockerClient.ConnectSelfToNetwork(ctx, networkName); err == nil {
|
||||
probeURL = fmt.Sprintf("http://%s:%d%s", serviceName, port, healthPath)
|
||||
}
|
||||
}
|
||||
if probeURL == "" && len(state.Ports) > 0 {
|
||||
probeURL = fmt.Sprintf("http://%s:%d%s", de.dockerClient.HostProbeHost(ctx), state.Ports[0], healthPath)
|
||||
}
|
||||
if probeURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, probeURL, nil)
|
||||
if err != nil {
|
||||
state.Health = "unreachable"
|
||||
return
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
state.Health = "unreachable"
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
||||
state.Health = "healthy"
|
||||
return
|
||||
}
|
||||
state.Health = "unhealthy"
|
||||
}
|
||||
|
||||
// StopService stops (but keeps) all replicas of a service.
|
||||
func (de *DeploymentEngine) StopService(ctx context.Context, serviceID string) error {
|
||||
containers, err := de.ListServiceContainers(ctx, serviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range containers {
|
||||
if c.State == "running" {
|
||||
if err := de.dockerClient.StopContainer(ctx, c.ID, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartService starts all stopped replicas.
|
||||
func (de *DeploymentEngine) StartService(ctx context.Context, serviceID string) error {
|
||||
containers, err := de.ListServiceContainers(ctx, serviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range containers {
|
||||
if c.State != "running" {
|
||||
if err := de.dockerClient.StartContainer(ctx, c.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveServiceContainers deletes every replica of a service.
|
||||
func (de *DeploymentEngine) RemoveServiceContainers(ctx context.Context, serviceID string) error {
|
||||
containers, err := de.ListServiceContainers(ctx, serviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range containers {
|
||||
_ = de.dockerClient.RemoveContainer(ctx, c.ID, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProjectContainers deletes every managed container in a project.
|
||||
func (de *DeploymentEngine) RemoveProjectContainers(ctx context.Context, projectID string) error {
|
||||
containers, err := de.dockerClient.ListContainersFiltered(ctx, filters.NewArgs(
|
||||
filters.Arg("label", LabelProject+"="+projectID),
|
||||
), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range containers {
|
||||
_ = de.dockerClient.RemoveContainer(ctx, c.ID, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProjectNetwork removes the per-project bridge network if present.
|
||||
func (de *DeploymentEngine) RemoveProjectNetwork(ctx context.Context, projectID string) error {
|
||||
return de.dockerClient.RemoveNetwork(ctx, ProjectNetworkName(projectID))
|
||||
}
|
||||
|
||||
func replicaIndex(c container.Summary) int {
|
||||
replica := 0
|
||||
fmt.Sscanf(c.Labels[LabelReplica], "%d", &replica)
|
||||
return replica
|
||||
}
|
||||
|
||||
func specHash(spec RuntimeSpec) string {
|
||||
payload, _ := json.Marshal(struct {
|
||||
Image, Name, Domain, HealthPath, RestartPolicy string
|
||||
Command []string
|
||||
Env map[string]string
|
||||
Port int32
|
||||
MemoryBytes, NanoCPUs int64
|
||||
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
|
||||
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs})
|
||||
sum := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
@@ -3,13 +3,18 @@ package docker
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/events"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
@@ -22,6 +27,7 @@ import (
|
||||
// Client wraps the Docker client with additional functionality
|
||||
type Client struct {
|
||||
cli *client.Client
|
||||
probeHost string
|
||||
}
|
||||
|
||||
// NewClient creates a new Docker client
|
||||
@@ -43,6 +49,75 @@ func NewClient() (*Client, error) {
|
||||
return &Client{cli: cli}, nil
|
||||
}
|
||||
|
||||
// HostProbeHost returns an address through which host-published container
|
||||
// ports are reachable. When this process runs inside a container, host
|
||||
// loopback is unreachable; the bridge gateway IP routes to ports published
|
||||
// on 0.0.0.0. Result is cached for the process lifetime.
|
||||
func (c *Client) HostProbeHost(ctx context.Context) string {
|
||||
if c.probeHost != "" {
|
||||
return c.probeHost
|
||||
}
|
||||
host := "127.0.0.1"
|
||||
if self, err := c.self(ctx); err == nil && self.NetworkSettings != nil {
|
||||
for _, n := range self.NetworkSettings.Networks {
|
||||
if n != nil && n.Gateway != "" {
|
||||
host = n.Gateway
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
c.probeHost = host
|
||||
return host
|
||||
}
|
||||
|
||||
// OwnNetworkName returns the name of the platform network this process's
|
||||
// container is attached to, or "" when not running containerized. Per-project
|
||||
// and default Docker networks are never selected; among remaining networks
|
||||
// the lexicographically first wins so the choice is deterministic.
|
||||
func (c *Client) OwnNetworkName(ctx context.Context) string {
|
||||
self, err := c.self(ctx)
|
||||
if err != nil || self.NetworkSettings == nil {
|
||||
return ""
|
||||
}
|
||||
names := make([]string, 0, len(self.NetworkSettings.Networks))
|
||||
for name := range self.NetworkSettings.Networks {
|
||||
switch {
|
||||
case name == "bridge" || name == "host" || name == "none":
|
||||
case strings.HasPrefix(name, "containr-proj-"):
|
||||
default:
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
return names[0]
|
||||
}
|
||||
|
||||
// self inspects the container this process runs in (hostname == container ID).
|
||||
func (c *Client) self(ctx context.Context) (types.ContainerJSON, error) {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil || hostname == "" {
|
||||
return types.ContainerJSON{}, fmt.Errorf("hostname unavailable")
|
||||
}
|
||||
return c.cli.ContainerInspect(ctx, hostname)
|
||||
}
|
||||
|
||||
// ConnectSelfToNetwork attaches this process's container to the given network.
|
||||
// No-op when already attached or when not running containerized.
|
||||
func (c *Client) ConnectSelfToNetwork(ctx context.Context, networkName string) error {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil || hostname == "" {
|
||||
return fmt.Errorf("hostname unavailable")
|
||||
}
|
||||
err = c.cli.NetworkConnect(ctx, networkName, hostname, &network.EndpointSettings{})
|
||||
if err != nil && strings.Contains(strings.ToLower(err.Error()), "already exists") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ListContainers returns all containers
|
||||
func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Container, error) {
|
||||
return c.cli.ContainerList(ctx, container.ListOptions{
|
||||
@@ -50,6 +125,14 @@ func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Containe
|
||||
})
|
||||
}
|
||||
|
||||
// ListContainersFiltered returns containers matching the given filters.
|
||||
func (c *Client) ListContainersFiltered(ctx context.Context, f filters.Args, all bool) ([]types.Container, error) {
|
||||
return c.cli.ContainerList(ctx, container.ListOptions{
|
||||
All: all,
|
||||
Filters: f,
|
||||
})
|
||||
}
|
||||
|
||||
// GetContainer returns detailed information about a specific container
|
||||
func (c *Client) GetContainer(ctx context.Context, containerID string) (types.ContainerJSON, error) {
|
||||
return c.cli.ContainerInspect(ctx, containerID)
|
||||
@@ -63,6 +146,7 @@ func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (s
|
||||
Env: config.Env,
|
||||
Labels: config.Labels,
|
||||
ExposedPorts: config.ExposedPorts,
|
||||
Healthcheck: config.Healthcheck,
|
||||
}
|
||||
|
||||
hostConfig := &container.HostConfig{
|
||||
@@ -168,7 +252,9 @@ func (c *Client) ListImages(ctx context.Context, all bool) ([]image.Summary, err
|
||||
})
|
||||
}
|
||||
|
||||
// PullImage pulls an image from a registry
|
||||
// PullImage pulls an image from a registry. The pull runs as the returned
|
||||
// stream is consumed — callers must drain and close it (or use
|
||||
// PullImageWait) or the pull may be aborted.
|
||||
func (c *Client) PullImage(ctx context.Context, ref string, auth registry.AuthConfig) (io.ReadCloser, error) {
|
||||
authStr, _ := registry.EncodeAuthConfig(auth)
|
||||
return c.cli.ImagePull(ctx, ref, image.PullOptions{
|
||||
@@ -176,6 +262,33 @@ func (c *Client) PullImage(ctx context.Context, ref string, auth registry.AuthCo
|
||||
})
|
||||
}
|
||||
|
||||
// PullImageWait pulls an image and blocks until the pull stream completes.
|
||||
func (c *Client) PullImageWait(ctx context.Context, ref string, auth registry.AuthConfig) error {
|
||||
reader, err := c.PullImage(ctx, ref, auth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
// The daemon performs the pull while streaming progress; drain it fully
|
||||
// and surface any errorDetail the daemon reports in the stream.
|
||||
dec := json.NewDecoder(reader)
|
||||
var msg struct {
|
||||
ErrorDetail *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errorDetail"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
for dec.Decode(&msg) == nil {
|
||||
if msg.ErrorDetail != nil {
|
||||
return fmt.Errorf("%s", msg.ErrorDetail.Message)
|
||||
}
|
||||
if msg.Error != "" {
|
||||
return fmt.Errorf("%s", msg.Error)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildImage builds an image from a Dockerfile
|
||||
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
|
||||
return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
|
||||
|
||||
@@ -3,6 +3,7 @@ package docker
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
@@ -24,6 +25,7 @@ type ContainerConfig struct {
|
||||
NanoCPUs int64
|
||||
NetworkMode string
|
||||
Networks map[string]*network.EndpointSettings
|
||||
Healthcheck *container.HealthConfig
|
||||
}
|
||||
|
||||
// LogOptions represents options for retrieving container logs
|
||||
|
||||
@@ -3,6 +3,7 @@ package ha
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -80,6 +81,8 @@ type HealthChecker struct {
|
||||
results map[string]*HealthCheckResult
|
||||
mu sync.RWMutex
|
||||
checkInterval time.Duration
|
||||
serviceResolver func(ctx context.Context, serviceID string) (host string, port int, err error)
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// HealthCheck represents a health check configuration
|
||||
@@ -596,7 +599,11 @@ func (hc *HealthChecker) performHealthChecks(ctx context.Context) error {
|
||||
result := hc.performHealthCheck(ctx, check)
|
||||
hc.mu.Lock()
|
||||
hc.results[check.ID] = result
|
||||
check.Status = result.Status
|
||||
check.LastCheck = result.Timestamp
|
||||
hc.mu.Unlock()
|
||||
// Persist last-known state so a restart resumes with honest history.
|
||||
hc.persistCheck(check)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -617,12 +624,20 @@ func (hc *HealthChecker) performHealthCheck(ctx context.Context, check *HealthCh
|
||||
checkCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
host, port, err := hc.resolveTarget(ctx, check)
|
||||
if err != nil {
|
||||
result.Status = HealthStatusUnhealthy
|
||||
result.Message = err.Error()
|
||||
result.ErrorCode = "TARGET_UNRESOLVED"
|
||||
result.Latency = time.Since(start)
|
||||
return result
|
||||
}
|
||||
|
||||
switch check.Type {
|
||||
case HealthCheckTypeHTTP:
|
||||
err = hc.performHTTPCheck(checkCtx, check)
|
||||
err = hc.performHTTPCheck(checkCtx, check, host, port)
|
||||
case HealthCheckTypeTCP:
|
||||
err = hc.performTCPCheck(checkCtx, check)
|
||||
err = hc.performTCPCheck(checkCtx, check, host, port)
|
||||
case HealthCheckTypeCommand:
|
||||
err = hc.performCommandCheck(checkCtx, check)
|
||||
default:
|
||||
@@ -642,13 +657,11 @@ func (hc *HealthChecker) performHealthCheck(ctx context.Context, check *HealthCh
|
||||
return result
|
||||
}
|
||||
|
||||
func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthCheck) error {
|
||||
host := hc.resolveHealthCheckHost(check)
|
||||
func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthCheck, host string, port int) error {
|
||||
protocol := strings.ToLower(strings.TrimSpace(check.Config.Protocol))
|
||||
if protocol == "" {
|
||||
protocol = "http"
|
||||
}
|
||||
port := check.Config.Port
|
||||
if port <= 0 {
|
||||
if protocol == "https" {
|
||||
port = 443
|
||||
@@ -684,9 +697,7 @@ func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthChec
|
||||
return nil
|
||||
}
|
||||
|
||||
func (hc *HealthChecker) performTCPCheck(ctx context.Context, check *HealthCheck) error {
|
||||
host := hc.resolveHealthCheckHost(check)
|
||||
port := check.Config.Port
|
||||
func (hc *HealthChecker) performTCPCheck(ctx context.Context, check *HealthCheck, host string, port int) error {
|
||||
if port <= 0 {
|
||||
port = 80
|
||||
}
|
||||
@@ -723,6 +734,27 @@ func (hc *HealthChecker) performCommandCheck(ctx context.Context, check *HealthC
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithServiceResolver lets the API layer plug in service-aware targeting: a
|
||||
// ServiceID-bound check resolves to the service's DNS name on its project
|
||||
// network instead of the backend container's loopback, where dialing
|
||||
// 127.0.0.1:<host port> can never succeed.
|
||||
func (m *HighAvailabilityManager) WithServiceResolver(fn func(ctx context.Context, serviceID string) (host string, port int, err error)) *HighAvailabilityManager {
|
||||
m.healthChecker.serviceResolver = fn
|
||||
return m
|
||||
}
|
||||
|
||||
// WithPersistence attaches Postgres so configured checks survive restarts.
|
||||
// Call LoadHealthChecks once during startup to hydrate state.
|
||||
func (m *HighAvailabilityManager) WithPersistence(db *sql.DB) *HighAvailabilityManager {
|
||||
m.healthChecker.db = db
|
||||
return m
|
||||
}
|
||||
|
||||
// LoadHealthChecks hydrates persisted health checks during startup.
|
||||
func (m *HighAvailabilityManager) LoadHealthChecks(ctx context.Context) error {
|
||||
return m.healthChecker.LoadHealthChecks(ctx)
|
||||
}
|
||||
|
||||
func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string {
|
||||
if strings.TrimSpace(check.NodeID) != "" && hc.scheduler != nil {
|
||||
nodes := hc.scheduler.GetNodes()
|
||||
@@ -739,19 +771,109 @@ func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
// resolveTarget picks the dial target for a check: service-bound checks go to
|
||||
// the service DNS name on the project network; node-bound and free-standing
|
||||
// checks keep the old host behavior. A service-bound resolver failure is
|
||||
// surfaced as an error — falling back to loopback would only produce a
|
||||
// misleading "service down" result.
|
||||
func (hc *HealthChecker) resolveTarget(ctx context.Context, check *HealthCheck) (string, int, error) {
|
||||
port := check.Config.Port
|
||||
if strings.TrimSpace(check.NodeID) == "" && check.ServiceID != "" && hc.serviceResolver != nil {
|
||||
resolveCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
h, p, err := hc.serviceResolver(resolveCtx, check.ServiceID)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("resolve service target: %w", err)
|
||||
}
|
||||
if h == "" {
|
||||
return "", 0, fmt.Errorf("resolve service target: service %s not found", check.ServiceID)
|
||||
}
|
||||
if port <= 0 {
|
||||
port = p
|
||||
}
|
||||
return h, port, nil
|
||||
}
|
||||
return hc.resolveHealthCheckHost(check), port, nil
|
||||
}
|
||||
|
||||
// AddHealthCheck adds a new health check
|
||||
func (hc *HealthChecker) AddHealthCheck(check *HealthCheck) {
|
||||
hc.mu.Lock()
|
||||
defer hc.mu.Unlock()
|
||||
hc.checks[check.ID] = check
|
||||
hc.mu.Unlock()
|
||||
hc.persistCheck(check)
|
||||
}
|
||||
|
||||
// RemoveHealthCheck removes a health check
|
||||
func (hc *HealthChecker) RemoveHealthCheck(checkID string) {
|
||||
hc.mu.Lock()
|
||||
defer hc.mu.Unlock()
|
||||
delete(hc.checks, checkID)
|
||||
delete(hc.results, checkID)
|
||||
hc.mu.Unlock()
|
||||
if hc.db != nil {
|
||||
_, _ = hc.db.ExecContext(context.Background(), `DELETE FROM ha_health_checks WHERE id = $1`, checkID)
|
||||
}
|
||||
}
|
||||
|
||||
// persistCheck upserts one check. Best effort — a write failure never blocks
|
||||
// the in-memory path, matching the scaling-policy persistence style.
|
||||
func (hc *HealthChecker) persistCheck(check *HealthCheck) {
|
||||
if hc.db == nil {
|
||||
return
|
||||
}
|
||||
cfg, _ := json.Marshal(check.Config)
|
||||
var lastCheck interface{}
|
||||
if !check.LastCheck.IsZero() {
|
||||
lastCheck = check.LastCheck
|
||||
}
|
||||
_, _ = hc.db.ExecContext(context.Background(),
|
||||
`INSERT INTO ha_health_checks (id, service_id, node_id, type, config, status, last_check, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET service_id = EXCLUDED.service_id, node_id = EXCLUDED.node_id,
|
||||
type = EXCLUDED.type, config = EXCLUDED.config,
|
||||
status = EXCLUDED.status, last_check = EXCLUDED.last_check,
|
||||
updated_at = now()`,
|
||||
check.ID, check.ServiceID, check.NodeID, string(check.Type), cfg, string(check.Status), lastCheck,
|
||||
)
|
||||
}
|
||||
|
||||
// LoadHealthChecks hydrates checks persisted across restarts.
|
||||
func (hc *HealthChecker) LoadHealthChecks(ctx context.Context) error {
|
||||
if hc.db == nil {
|
||||
return nil
|
||||
}
|
||||
rows, err := hc.db.QueryContext(ctx,
|
||||
`SELECT id, service_id, node_id, type, config, status, last_check FROM ha_health_checks`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
loaded := 0
|
||||
for rows.Next() {
|
||||
var check HealthCheck
|
||||
var cfg []byte
|
||||
var status, checkType string
|
||||
var lastCheck sql.NullTime
|
||||
if err := rows.Scan(&check.ID, &check.ServiceID, &check.NodeID, &checkType, &cfg, &status, &lastCheck); err != nil {
|
||||
continue
|
||||
}
|
||||
check.Type = HealthCheckType(checkType)
|
||||
check.Status = HealthStatus(status)
|
||||
if lastCheck.Valid {
|
||||
check.LastCheck = lastCheck.Time
|
||||
}
|
||||
_ = json.Unmarshal(cfg, &check.Config)
|
||||
hc.mu.Lock()
|
||||
hc.checks[check.ID] = &check
|
||||
hc.mu.Unlock()
|
||||
loaded++
|
||||
}
|
||||
if loaded > 0 {
|
||||
log.Printf("Restored %d health checks", loaded)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// GetHealthCheck returns one configured health check.
|
||||
|
||||
@@ -2,7 +2,10 @@ package middleware
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
sqlcdb "containr/internal/database/sqlcdb"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -69,26 +72,29 @@ func RequestID() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Auth middleware for JWT authentication
|
||||
func Auth(jwtSecret string) gin.HandlerFunc {
|
||||
sessionVerifier := newBetterAuthSessionVerifier()
|
||||
|
||||
return func(c *gin.Context) {
|
||||
// resolveRequestUser authenticates the request via Bearer JWT or the Better
|
||||
// Auth session cookie. On success it returns the local user id and email.
|
||||
// tokenErr/hasToken mirror the legacy error contract for Auth.
|
||||
func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) {
|
||||
tokenString, tokenErr, hasToken := extractJWTToken(c)
|
||||
if tokenString != "" {
|
||||
// Personal access tokens (cnp_…) are not JWTs — they resolve
|
||||
// against the user_tokens table by sha256 hash.
|
||||
if strings.HasPrefix(tokenString, patPrefix) {
|
||||
if id, mail, verified := resolvePersonalAccessToken(c, tokenString); verified {
|
||||
return id, mail, "", hasToken, true
|
||||
}
|
||||
return "", "", "Invalid token", hasToken, false
|
||||
}
|
||||
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
|
||||
userIDClaim, exists := claims["user_id"]
|
||||
if exists {
|
||||
userID := strings.TrimSpace(fmt.Sprint(userIDClaim))
|
||||
if _, err := uuid.Parse(userID); err == nil {
|
||||
email := ""
|
||||
if emailClaim, ok := claims["email"]; ok && emailClaim != nil {
|
||||
id := strings.TrimSpace(fmt.Sprint(userIDClaim))
|
||||
if _, err := uuid.Parse(id); err == nil {
|
||||
if emailClaim, exists := claims["email"]; exists && emailClaim != nil {
|
||||
email = strings.TrimSpace(fmt.Sprint(emailClaim))
|
||||
}
|
||||
c.Set("user_id", userID)
|
||||
c.Set("email", email)
|
||||
c.Next()
|
||||
return
|
||||
return id, email, "", hasToken, true
|
||||
}
|
||||
}
|
||||
tokenErr = "Invalid token claims"
|
||||
@@ -98,12 +104,111 @@ func Auth(jwtSecret string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
if sessionVerifier != nil {
|
||||
if userID, email, ok := sessionVerifier.resolveUser(c); ok {
|
||||
if id, mail, verified := sessionVerifier.resolveUser(c); verified {
|
||||
return id, mail, "", hasToken, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", tokenErr, hasToken, false
|
||||
}
|
||||
|
||||
const patPrefix = "cnp_"
|
||||
|
||||
// resolvePersonalAccessToken authenticates a `cnp_` bearer token against the
|
||||
// user_tokens table (sha256 hash match, not revoked, not expired). On success
|
||||
// it stamps last_used_at, records the token scope on the context, and
|
||||
// returns the owner's id and email.
|
||||
func resolvePersonalAccessToken(c *gin.Context, token string) (string, string, bool) {
|
||||
dbValue, exists := c.Get("db")
|
||||
if !exists {
|
||||
return "", "", false
|
||||
}
|
||||
db, ok := dbValue.(*database.DB)
|
||||
if !ok || db == nil || db.DB == nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
queries := sqlcdb.New(db.DB)
|
||||
row, err := queries.GetUserTokenByHash(c.Request.Context(), hex.EncodeToString(sum[:]))
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
var email string
|
||||
if err := db.QueryRowContext(c.Request.Context(),
|
||||
`SELECT email FROM users WHERE id = $1`, row.UserID).Scan(&email); err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
_ = queries.TouchUserToken(c.Request.Context(), row.ID)
|
||||
c.Set("pat_scope", row.Scope)
|
||||
return row.UserID.String(), email, true
|
||||
}
|
||||
|
||||
// patScopeAllows reports whether a PAT-authenticated request may proceed.
|
||||
// read scope permits only safe methods; write and admin are unrestricted at
|
||||
// this layer (admin routes still require the admin scope AND a platform
|
||||
// admin account — enforced via is_admin in setAuthenticatedContext).
|
||||
func patScopeAllows(c *gin.Context) bool {
|
||||
if c.GetString("pat_scope") != "read" {
|
||||
return true
|
||||
}
|
||||
switch c.Request.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// setAuthenticatedContext stores the resolved identity plus the admin flag on
|
||||
// the gin context so handlers and RequireAdmin can rely on it.
|
||||
func setAuthenticatedContext(c *gin.Context, userID, email string) {
|
||||
c.Set("user_id", userID)
|
||||
c.Set("email", email)
|
||||
c.Next()
|
||||
isAdmin := loadIsAdmin(c, userID)
|
||||
// A PAT can never outrank its scope: non-admin scopes cap the token at
|
||||
// non-admin even when the owning user is a platform admin.
|
||||
if scope := c.GetString("pat_scope"); scope != "" && scope != "admin" {
|
||||
isAdmin = false
|
||||
}
|
||||
c.Set("is_admin", isAdmin)
|
||||
}
|
||||
|
||||
func loadIsAdmin(c *gin.Context, userID string) bool {
|
||||
dbValue, exists := c.Get("db")
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
db, ok := dbValue.(*database.DB)
|
||||
if !ok || db == nil || db.DB == nil {
|
||||
return false
|
||||
}
|
||||
var isAdmin bool
|
||||
if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil {
|
||||
return false
|
||||
}
|
||||
return isAdmin
|
||||
}
|
||||
|
||||
// Auth middleware for JWT authentication
|
||||
func Auth(jwtSecret string) gin.HandlerFunc {
|
||||
sessionVerifier := newBetterAuthSessionVerifier()
|
||||
|
||||
return func(c *gin.Context) {
|
||||
userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier)
|
||||
if ok {
|
||||
setAuthenticatedContext(c, userID, email)
|
||||
if !patScopeAllows(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Token scope is read-only",
|
||||
"code": "SCOPE_INSUFFICIENT",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if tokenErr != "" {
|
||||
@@ -121,6 +226,38 @@ func Auth(jwtSecret string) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuth resolves identity when a valid credential is present but never
|
||||
// rejects the request — anonymous callers simply get no user context. Read
|
||||
// endpoints hang off this so the public site can browse approved resources.
|
||||
func OptionalAuth(jwtSecret string) gin.HandlerFunc {
|
||||
sessionVerifier := newBetterAuthSessionVerifier()
|
||||
|
||||
return func(c *gin.Context) {
|
||||
if userID, email, _, _, ok := resolveRequestUser(c, jwtSecret, sessionVerifier); ok {
|
||||
setAuthenticatedContext(c, userID, email)
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin rejects requests whose resolved user is not a platform admin.
|
||||
// Must run after Auth (or OptionalAuth) so user_id/is_admin are populated.
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if _, exists := c.Get("user_id"); !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !c.GetBool("is_admin") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
type betterAuthSessionVerifier struct {
|
||||
internalURL string
|
||||
internalToken string
|
||||
@@ -248,11 +385,18 @@ func ensureLocalUserRecord(c *gin.Context, user betterAuthSessionUser) (string,
|
||||
return "", hashErr
|
||||
}
|
||||
|
||||
// The first mirrored account owns the platform.
|
||||
var userCount int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
|
||||
return "", err
|
||||
}
|
||||
isFirstUser := userCount == 0
|
||||
|
||||
if err := db.QueryRow(`
|
||||
INSERT INTO users (email, password_hash, name, avatar_url)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''))
|
||||
INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5)
|
||||
RETURNING id
|
||||
`, email, string(hashedPassword), name, avatarURL).Scan(&localUserID); err != nil {
|
||||
`, email, string(hashedPassword), name, avatarURL, isFirstUser).Scan(&localUserID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,61 @@ func TestAuthStoresStringUserIDForValidClaims(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRejectsPATWithoutDatabase(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(Auth("secret"))
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
// A cnp_-prefixed token takes the PAT path, not JWT parsing — without a
|
||||
// db handle it must fail closed with 401.
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer cnp_"+strings.Repeat("ab", 24))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATScopeAllows(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
scope string
|
||||
method string
|
||||
want bool
|
||||
}{
|
||||
{"read allows GET", "read", http.MethodGet, true},
|
||||
{"read allows HEAD", "read", http.MethodHead, true},
|
||||
{"read allows OPTIONS", "read", http.MethodOptions, true},
|
||||
{"read blocks POST", "read", http.MethodPost, false},
|
||||
{"read blocks DELETE", "read", http.MethodDelete, false},
|
||||
{"write allows POST", "write", http.MethodPost, true},
|
||||
{"write allows DELETE", "write", http.MethodDelete, true},
|
||||
{"admin allows PATCH", "admin", http.MethodPatch, true},
|
||||
{"no scope (session/JWT) unrestricted", "", http.MethodPost, true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(tc.method, "/x", nil)
|
||||
if tc.scope != "" {
|
||||
c.Set("pat_scope", tc.scope)
|
||||
}
|
||||
if got := patScopeAllows(c); got != tc.want {
|
||||
t.Fatalf("patScopeAllows(scope=%q, method=%s) = %v, want %v", tc.scope, tc.method, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string {
|
||||
t.Helper()
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- +goose Up
|
||||
-- In-app platform settings. Values here override environment variables;
|
||||
-- secrets (e.g. Cloudflare tunnel token) are flagged and masked in API reads.
|
||||
CREATE TABLE IF NOT EXISTS public.app_settings (
|
||||
key character varying(255) PRIMARY KEY,
|
||||
value text NOT NULL DEFAULT '',
|
||||
is_secret boolean NOT NULL DEFAULT false,
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.users ADD COLUMN IF NOT EXISTS is_admin boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- The first registered user owns the platform. Backfill existing installs:
|
||||
-- the earliest account becomes admin when no admin is set.
|
||||
UPDATE public.users
|
||||
SET is_admin = true
|
||||
WHERE id = (
|
||||
SELECT id FROM public.users ORDER BY created_at ASC, id ASC LIMIT 1
|
||||
)
|
||||
AND NOT EXISTS (SELECT 1 FROM public.users WHERE is_admin);
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE public.users DROP COLUMN IF EXISTS is_admin;
|
||||
DROP TABLE IF EXISTS public.app_settings;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- +goose Up
|
||||
-- Runtime spec for Railway-style services: replica count, the container port
|
||||
-- to expose, an optional public domain routed via Traefik, healthcheck path
|
||||
-- and restart policy.
|
||||
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS replicas integer NOT NULL DEFAULT 1;
|
||||
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS port integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS domain character varying(500) NOT NULL DEFAULT '';
|
||||
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS healthcheck_path character varying(500) NOT NULL DEFAULT '';
|
||||
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS restart_policy character varying(50) NOT NULL DEFAULT 'unless-stopped';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE public.services DROP COLUMN IF EXISTS replicas;
|
||||
ALTER TABLE public.services DROP COLUMN IF EXISTS port;
|
||||
ALTER TABLE public.services DROP COLUMN IF EXISTS domain;
|
||||
ALTER TABLE public.services DROP COLUMN IF EXISTS healthcheck_path;
|
||||
ALTER TABLE public.services DROP COLUMN IF EXISTS restart_policy;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- +goose Up
|
||||
-- Public platform model: projects are browsable without a session once the
|
||||
-- admin approves them; service_templates gains ownership for user templates.
|
||||
ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS is_approved boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- Projects that already exist stay live; only newly created ones enter the
|
||||
-- approval queue.
|
||||
UPDATE public.projects SET is_approved = true;
|
||||
|
||||
ALTER TABLE public.service_templates ADD COLUMN IF NOT EXISTS owner_id uuid;
|
||||
CREATE INDEX IF NOT EXISTS idx_service_templates_owner ON public.service_templates(owner_id);
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE public.projects DROP COLUMN IF EXISTS is_approved;
|
||||
DROP INDEX IF EXISTS public.idx_service_templates_owner;
|
||||
ALTER TABLE public.service_templates DROP COLUMN IF EXISTS owner_id;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- Railway-style template publishing: owners can mark a user template public so
|
||||
-- it appears in the anonymous catalog (GET /api/v1/templates) and on the
|
||||
-- landing page. Official templates stay official; is_public only matters for
|
||||
-- user templates.
|
||||
ALTER TABLE public.service_templates ADD COLUMN IF NOT EXISTS is_public boolean NOT NULL DEFAULT false;
|
||||
CREATE INDEX IF NOT EXISTS idx_service_templates_public ON public.service_templates(is_public) WHERE is_public;
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS public.idx_service_templates_public;
|
||||
ALTER TABLE public.service_templates DROP COLUMN IF EXISTS is_public;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- +goose Up
|
||||
-- Stable public host port for service replica 0. 0 means "not yet assigned";
|
||||
-- the runtime writes the ephemeral port it picked back here so restarts and
|
||||
-- redeploys keep the same public endpoint instead of churning.
|
||||
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS published_port integer NOT NULL DEFAULT 0;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE public.services DROP COLUMN IF EXISTS published_port;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- +goose Up
|
||||
-- Persisted HA health checks. Previously checks lived only in memory, so a
|
||||
-- backend restart silently deleted every configured check while services kept
|
||||
-- running unmonitored.
|
||||
CREATE TABLE IF NOT EXISTS ha_health_checks (
|
||||
id uuid PRIMARY KEY,
|
||||
service_id uuid NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||
node_id varchar(255) NOT NULL DEFAULT '',
|
||||
type varchar(20) NOT NULL,
|
||||
config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
status varchar(20) NOT NULL DEFAULT 'unknown',
|
||||
last_check timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS ha_health_checks;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- +goose Up
|
||||
-- Personal access tokens for CLI/MCP/agent authentication.
|
||||
-- Only sha256 hashes are stored — raw tokens (cnp_…) are shown once at
|
||||
-- creation and never persisted. Scope gates what the token may do:
|
||||
-- read = GET/HEAD only, write = all non-admin routes, admin = everything
|
||||
-- (admin still requires the owning user to be a platform admin).
|
||||
CREATE TABLE IF NOT EXISTS public.user_tokens (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
name character varying(255) NOT NULL,
|
||||
key_prefix character varying(16) NOT NULL,
|
||||
token_hash text NOT NULL UNIQUE,
|
||||
scope character varying(20) NOT NULL DEFAULT 'write'
|
||||
CHECK (scope IN ('read', 'write', 'admin')),
|
||||
expires_at timestamp with time zone,
|
||||
last_used_at timestamp with time zone,
|
||||
revoked_at timestamp with time zone,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_tokens_user_id ON public.user_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_tokens_token_hash ON public.user_tokens(token_hash);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS public.user_tokens;
|
||||
@@ -1,11 +1,26 @@
|
||||
-- name: ListDatabaseServicesByUser :many
|
||||
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
FROM database_services
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: ListAllDatabaseServices :many
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
FROM database_services
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetDatabaseServiceByID :one
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
FROM database_services
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetDatabaseServiceOwnerID :one
|
||||
SELECT user_id
|
||||
FROM database_services
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetDatabaseServiceByIDAndUser :one
|
||||
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
FROM database_services
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
-- Visibility model: a project is readable when it is approved for public
|
||||
-- display, when the caller owns or is a member of it, or when the caller is a
|
||||
-- platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
|
||||
|
||||
-- name: ListProjectsWithStatsByUser :many
|
||||
SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.description,
|
||||
p.owner_id,
|
||||
p.is_approved,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
COUNT(DISTINCT s.id)::bigint AS service_count,
|
||||
@@ -21,17 +26,22 @@ FROM projects p
|
||||
LEFT JOIN services s ON s.project_id = p.id
|
||||
LEFT JOIN deployments d ON d.service_id = s.id
|
||||
WHERE
|
||||
(p.owner_id = sqlc.arg(user_id) OR EXISTS (
|
||||
(
|
||||
p.is_approved
|
||||
OR p.owner_id = sqlc.arg(user_id)
|
||||
OR sqlc.arg(is_admin)::bool
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM project_members pm
|
||||
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
|
||||
))
|
||||
)
|
||||
)
|
||||
AND (
|
||||
sqlc.narg(search)::text IS NULL
|
||||
OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%')
|
||||
OR COALESCE(p.description, '') ILIKE ('%' || sqlc.narg(search)::text || '%')
|
||||
)
|
||||
GROUP BY p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
|
||||
GROUP BY p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
|
||||
ORDER BY p.updated_at DESC
|
||||
LIMIT sqlc.arg(limit_count) OFFSET sqlc.arg(offset_count);
|
||||
|
||||
@@ -39,11 +49,16 @@ LIMIT sqlc.arg(limit_count) OFFSET sqlc.arg(offset_count);
|
||||
SELECT COUNT(*)::bigint AS total
|
||||
FROM projects p
|
||||
WHERE
|
||||
(p.owner_id = sqlc.arg(user_id) OR EXISTS (
|
||||
(
|
||||
p.is_approved
|
||||
OR p.owner_id = sqlc.arg(user_id)
|
||||
OR sqlc.arg(is_admin)::bool
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM project_members pm
|
||||
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
|
||||
))
|
||||
)
|
||||
)
|
||||
AND (
|
||||
sqlc.narg(search)::text IS NULL
|
||||
OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%')
|
||||
@@ -51,11 +66,13 @@ WHERE
|
||||
);
|
||||
|
||||
-- name: GetProjectByIDForUser :one
|
||||
SELECT p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
|
||||
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
|
||||
FROM projects p
|
||||
WHERE p.id = sqlc.arg(project_id)
|
||||
AND (
|
||||
p.owner_id = sqlc.arg(user_id)
|
||||
p.is_approved
|
||||
OR p.owner_id = sqlc.arg(user_id)
|
||||
OR sqlc.arg(is_admin)::bool
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM project_members pm
|
||||
@@ -66,7 +83,7 @@ WHERE p.id = sqlc.arg(project_id)
|
||||
-- name: CreateProject :one
|
||||
INSERT INTO projects (name, description, owner_id)
|
||||
VALUES (sqlc.arg(name), sqlc.narg(description), sqlc.arg(owner_id))
|
||||
RETURNING id, name, description, owner_id, created_at, updated_at;
|
||||
RETURNING id, name, description, owner_id, is_approved, created_at, updated_at;
|
||||
|
||||
-- name: InsertProjectEnvironment :exec
|
||||
INSERT INTO environments (name, project_id)
|
||||
@@ -74,7 +91,7 @@ VALUES (sqlc.arg(name), sqlc.arg(project_id));
|
||||
|
||||
-- name: GetProjectRoleForUser :one
|
||||
SELECT (CASE
|
||||
WHEN p.owner_id = sqlc.arg(user_id) THEN 'owner'
|
||||
WHEN p.owner_id = sqlc.arg(user_id) OR sqlc.arg(is_admin)::bool THEN 'owner'
|
||||
ELSE COALESCE(pm.role, '')
|
||||
END)::text AS role
|
||||
FROM projects p
|
||||
@@ -97,3 +114,14 @@ WHERE id = sqlc.arg(project_id);
|
||||
-- name: DeleteProjectByID :execrows
|
||||
DELETE FROM projects
|
||||
WHERE id = sqlc.arg(project_id);
|
||||
|
||||
-- name: SetProjectApproved :execrows
|
||||
UPDATE projects
|
||||
SET is_approved = sqlc.arg(is_approved), updated_at = NOW()
|
||||
WHERE id = sqlc.arg(project_id);
|
||||
|
||||
-- name: ListPendingProjects :many
|
||||
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
|
||||
FROM projects p
|
||||
WHERE p.is_approved = false
|
||||
ORDER BY p.created_at DESC;
|
||||
@@ -1,19 +1,78 @@
|
||||
-- name: ListServiceTemplates :many
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
|
||||
-- name: ListServiceTemplatesForUser :many
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
|
||||
FROM service_templates
|
||||
WHERE is_official = true OR is_public = true OR owner_id = sqlc.narg(owner_id)
|
||||
ORDER BY is_official DESC, name ASC;
|
||||
|
||||
-- name: ListServiceTemplatesByCategory :many
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
|
||||
-- name: ListServiceTemplatesByCategoryForUser :many
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
|
||||
FROM service_templates
|
||||
WHERE category = $1
|
||||
WHERE category = sqlc.arg(category)
|
||||
AND (is_official = true OR is_public = true OR owner_id = sqlc.narg(owner_id))
|
||||
ORDER BY is_official DESC, name ASC;
|
||||
|
||||
-- name: GetServiceTemplateByID :one
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
|
||||
FROM service_templates
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UpsertServiceTemplate :exec
|
||||
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official)
|
||||
VALUES (
|
||||
sqlc.arg(id),
|
||||
sqlc.arg(name),
|
||||
sqlc.narg(description),
|
||||
sqlc.arg(category),
|
||||
sqlc.narg(logo),
|
||||
sqlc.arg(config),
|
||||
sqlc.narg(variables),
|
||||
sqlc.arg(is_official)
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
category = EXCLUDED.category,
|
||||
logo = EXCLUDED.logo,
|
||||
config = EXCLUDED.config,
|
||||
variables = EXCLUDED.variables,
|
||||
is_official = EXCLUDED.is_official,
|
||||
updated_at = NOW();
|
||||
|
||||
-- name: CreateUserTemplate :exec
|
||||
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id, is_public)
|
||||
VALUES (
|
||||
sqlc.arg(id),
|
||||
sqlc.arg(name),
|
||||
sqlc.narg(description),
|
||||
sqlc.arg(category),
|
||||
sqlc.narg(logo),
|
||||
sqlc.arg(config),
|
||||
sqlc.narg(variables),
|
||||
false,
|
||||
sqlc.arg(owner_id),
|
||||
sqlc.arg(is_public)
|
||||
);
|
||||
|
||||
-- name: UpdateUserTemplate :execrows
|
||||
UPDATE service_templates
|
||||
SET name = sqlc.arg(name),
|
||||
description = sqlc.narg(description),
|
||||
category = sqlc.arg(category),
|
||||
logo = sqlc.narg(logo),
|
||||
config = sqlc.arg(config),
|
||||
variables = sqlc.narg(variables),
|
||||
is_public = sqlc.arg(is_public),
|
||||
updated_at = NOW()
|
||||
WHERE id = sqlc.arg(id)
|
||||
AND owner_id = sqlc.arg(owner_id)
|
||||
AND is_official = false;
|
||||
|
||||
-- name: DeleteUserTemplate :execrows
|
||||
DELETE FROM service_templates
|
||||
WHERE id = sqlc.arg(id)
|
||||
AND owner_id = sqlc.arg(owner_id)
|
||||
AND is_official = false;
|
||||
|
||||
-- name: GetProjectOwnerID :one
|
||||
SELECT owner_id
|
||||
FROM projects
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- name: CreateUserToken :one
|
||||
INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListUserTokens :many
|
||||
SELECT * FROM user_tokens
|
||||
WHERE user_id = $1 AND revoked_at IS NULL
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetUserTokenByHash :one
|
||||
SELECT * FROM user_tokens
|
||||
WHERE token_hash = $1
|
||||
AND revoked_at IS NULL
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
LIMIT 1;
|
||||
|
||||
-- name: TouchUserToken :exec
|
||||
UPDATE user_tokens SET last_used_at = NOW()
|
||||
WHERE id = $1 AND revoked_at IS NULL;
|
||||
|
||||
-- name: RevokeUserToken :execrows
|
||||
UPDATE user_tokens SET revoked_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL;
|
||||
@@ -3,6 +3,7 @@ CREATE TABLE projects (
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
owner_id UUID NOT NULL,
|
||||
is_approved BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
@@ -51,7 +52,13 @@ CREATE TABLE services (
|
||||
git_branch VARCHAR(100),
|
||||
build_path VARCHAR(500),
|
||||
cpu VARCHAR(50),
|
||||
memory VARCHAR(50)
|
||||
memory VARCHAR(50),
|
||||
replicas INTEGER NOT NULL DEFAULT 1,
|
||||
port INTEGER NOT NULL DEFAULT 0,
|
||||
domain VARCHAR(255) NOT NULL DEFAULT '',
|
||||
healthcheck_path VARCHAR(255) NOT NULL DEFAULT '',
|
||||
restart_policy VARCHAR(50) NOT NULL DEFAULT 'unless-stopped',
|
||||
published_port INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE deployments (
|
||||
@@ -82,6 +89,8 @@ CREATE TABLE service_templates (
|
||||
config JSONB NOT NULL,
|
||||
variables JSONB DEFAULT '[]',
|
||||
is_official BOOLEAN DEFAULT false,
|
||||
owner_id UUID,
|
||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
@@ -195,3 +204,16 @@ CREATE TABLE notifications (
|
||||
read_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE user_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
key_prefix VARCHAR(16) NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
scope VARCHAR(20) NOT NULL DEFAULT 'write',
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
last_used_at TIMESTAMP WITH TIME ZONE,
|
||||
revoked_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
.vercel
|
||||
.env*
|
||||
@@ -13,9 +13,10 @@ RUN npm ci
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build-time API URL injection for Vite
|
||||
ARG VITE_API_URL=http://localhost:8082
|
||||
ARG VITE_AUTH_URL=http://localhost:8082/api/auth
|
||||
# Build-time API URL injection for Vite. Empty = same-origin via the nginx
|
||||
# /api/ proxy, which keeps the published image usable from any host.
|
||||
ARG VITE_API_URL=
|
||||
ARG VITE_AUTH_URL=
|
||||
ENV VITE_API_URL=${VITE_API_URL}
|
||||
ENV VITE_AUTH_URL=${VITE_AUTH_URL}
|
||||
|
||||
|
||||
@@ -21,6 +21,12 @@ http {
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
# WebSocket upgrade support
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
@@ -51,6 +57,22 @@ http {
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||
|
||||
# Same-origin API proxy - keeps the published image host-agnostic
|
||||
location /api/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $backend_upstream http://backend:8080;
|
||||
proxy_pass $backend_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# Handle React Router
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
Generated
+39
@@ -32,6 +32,7 @@
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"axios": "^1.7.9",
|
||||
@@ -40,7 +41,9 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"lucide-react": "^0.563.0",
|
||||
"marked": "^18.0.13",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
@@ -3719,6 +3722,15 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -3763,6 +3775,12 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
@@ -5205,6 +5223,15 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
|
||||
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -6577,6 +6604,18 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.13",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.13.tgz",
|
||||
"integrity": "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:check": "tsc -b && vite build",
|
||||
"predev": "node scripts/generate-docs-snapshot.mjs",
|
||||
"prebuild": "node scripts/generate-docs-snapshot.mjs",
|
||||
"generate:docs": "node scripts/generate-docs-snapshot.mjs",
|
||||
"generate:api": "openapi-typescript ../../docs/api/openapi.yaml -o src/generated/api-types.ts",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
@@ -40,6 +43,7 @@
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"axios": "^1.7.9",
|
||||
@@ -48,7 +52,9 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"lucide-react": "^0.563.0",
|
||||
"marked": "^18.0.13",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Bundles the repository docs/ tree into a generated TS module so the docs
|
||||
// browser renders instantly and offline. The GitHub sync layer refreshes it
|
||||
// at runtime when network is available.
|
||||
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const frontendRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..');
|
||||
const root = join(frontendRoot, '..', '..');
|
||||
const docsDir = join(root, 'docs');
|
||||
const outFile = join(frontendRoot, 'src/generated/docs-snapshot.ts');
|
||||
|
||||
// The published frontend image builds with only app/frontend as its Docker
|
||||
// context, so the repo docs tree is absent there — reuse the committed
|
||||
// snapshot instead of failing on ENOENT.
|
||||
if (!existsSync(docsDir)) {
|
||||
if (!existsSync(outFile)) {
|
||||
console.error('docs snapshot: docs/ missing and no committed snapshot to reuse');
|
||||
process.exit(1);
|
||||
}
|
||||
console.warn('docs snapshot: docs/ not found, reusing committed snapshot');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Directories that are internal working notes rather than user documentation.
|
||||
const SKIP_DIRS = new Set(['archive', 'design', 'superpowers']);
|
||||
|
||||
function walk(dir) {
|
||||
const entries = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
if (!SKIP_DIRS.has(name)) entries.push(...walk(full));
|
||||
} else if (name.toLowerCase().endsWith('.md')) {
|
||||
entries.push(full);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function titleOf(markdown, fallback) {
|
||||
const match = markdown.match(/^#\s+(.+)$/m);
|
||||
if (match) return match[1].replace(/[#*`]/g, '').trim();
|
||||
return fallback
|
||||
.replace(/\.md$/i, '')
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
const files = walk(docsDir).sort();
|
||||
const docs = files.map((full) => {
|
||||
const rel = relative(docsDir, full).replace(/\\/g, '/');
|
||||
const markdown = readFileSync(full, 'utf8');
|
||||
const section = rel.includes('/') ? rel.split('/')[0] : 'docs';
|
||||
return {
|
||||
path: rel,
|
||||
section,
|
||||
title: titleOf(markdown, rel.split('/').pop() ?? rel),
|
||||
markdown,
|
||||
};
|
||||
});
|
||||
|
||||
const banner = `/**\n * Auto-generated by scripts/generate-docs-snapshot.mjs.\n * Bundled copy of the repository docs tree — the offline fallback for the\n * docs browser. Regenerate with \`npm run generate:docs\`.\n */\n\nexport type BundledDoc = {\n path: string;\n section: string;\n title: string;\n markdown: string;\n};\n\n`;
|
||||
|
||||
mkdirSync(join(outFile, '..'), { recursive: true });
|
||||
writeFileSync(outFile, `${banner}export const bundledDocs: BundledDoc[] = ${JSON.stringify(docs, null, 2)};\n`);
|
||||
console.log(`docs snapshot: ${docs.length} files → ${relative(root, outFile)}`);
|
||||
@@ -32,7 +32,7 @@ describe('App smoke routes', () => {
|
||||
|
||||
expect(await screen.findByRole('heading', { name: /^projects\s*_?$/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/recent deployments/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/core-services/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/core platform/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders builds page in demo mode', async () => {
|
||||
|
||||
+100
-13
@@ -1,4 +1,6 @@
|
||||
import { Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Compass, ShieldCheck } from 'lucide-react';
|
||||
import { PlatformShell } from './layout/PlatformShell';
|
||||
import { ProjectsPage } from '@/features/projects/pages/ProjectsPage';
|
||||
import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage';
|
||||
@@ -7,6 +9,8 @@ import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetrics
|
||||
import { BuildsPage } from '@/features/builds/pages/BuildsPage';
|
||||
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
|
||||
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
|
||||
import { AdminPage } from '@/features/admin/pages/AdminPage';
|
||||
import { LandingPage } from '@/features/landing/LandingPage';
|
||||
import {
|
||||
DocsPage,
|
||||
PeoplePage,
|
||||
@@ -19,12 +23,13 @@ import {
|
||||
} from '@/features/secondary/pages';
|
||||
import { SignInPage, SignUpPage } from '@/features/auth/pages';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { getCurrentUserProfile } from '@/lib/api-client';
|
||||
import { ErrorBoundary, LoadingState } from '@/shared/components';
|
||||
|
||||
function AuthRequired() {
|
||||
const location = useLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const isDemoMode = searchParams.get('demo') === '1';
|
||||
const isDemoMode = useDemoMode();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
|
||||
if (isDemoMode) {
|
||||
@@ -47,38 +52,120 @@ function AuthRequired() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
function AdminRequired() {
|
||||
const location = useLocation();
|
||||
const isDemoMode = useDemoMode();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['current-profile'],
|
||||
queryFn: getCurrentUserProfile,
|
||||
enabled: !isDemoMode && Boolean(sessionQuery.data),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (isDemoMode) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
if (sessionQuery.isPending || (sessionQuery.data && profileQuery.isPending)) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--bg-void)]">
|
||||
<LoadingState message="Checking admin access..." className="h-screen" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!sessionQuery.data) {
|
||||
const redirect = `${location.pathname}${location.search}`;
|
||||
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirect)}`} replace />;
|
||||
}
|
||||
|
||||
if (!profileQuery.data?.isAdmin) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--bg-void)] px-6 text-center">
|
||||
<ShieldCheck size={32} className="text-[var(--error)]" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
|
||||
<p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">
|
||||
This area is restricted to the platform administrator.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/"
|
||||
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
function NotFoundPage() {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<Compass size={32} className="text-[var(--text-tertiary)]" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-[var(--text-primary)]">Page not found</h1>
|
||||
<p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">
|
||||
The page you are looking for does not exist or was moved.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/"
|
||||
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/projects" replace />} />
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/auth/sign-in" element={<SignInPage />} />
|
||||
<Route path="/auth/sign-up" element={<SignUpPage />} />
|
||||
|
||||
<Route element={<AuthRequired />}>
|
||||
<Route element={<PlatformShell />}>
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
{/* Public browsing — approved projects, templates, docs. */}
|
||||
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
|
||||
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
|
||||
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
|
||||
<Route path="/builds" element={<BuildsPage />} />
|
||||
|
||||
<Route element={<PlatformShell />}>
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/templates" element={<TemplatesPage />} />
|
||||
<Route path="/usage" element={<UsagePage />} />
|
||||
<Route path="/databases" element={<DatabasesPage />} />
|
||||
<Route path="/ha" element={<HighAvailabilityPage />} />
|
||||
<Route path="/security" element={<SecurityPage />} />
|
||||
<Route path="/usage" element={<UsagePage />} />
|
||||
<Route path="/docs" element={<DocsPage />} />
|
||||
|
||||
{/* Authenticated areas */}
|
||||
<Route element={<AuthRequired />}>
|
||||
<Route path="/builds" element={<BuildsPage />} />
|
||||
<Route path="/people" element={<PeoplePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/audit-logs" element={<AuditLogsPage />} />
|
||||
<Route path="/docs" element={<DocsPage />} />
|
||||
<Route path="/showcase" element={<ComponentShowcase />} />
|
||||
</Route>
|
||||
|
||||
{/* Full-bleed design demo — renders its own chrome, no app shell */}
|
||||
<Route path="/metrics-demo" element={<ServiceMetricsDashboard />} />
|
||||
{/* Platform owner only */}
|
||||
<Route element={<AdminRequired />}>
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/projects" replace />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Full-bleed design demo — renders its own chrome, no app shell */}
|
||||
<Route element={<AuthRequired />}>
|
||||
<Route path="/metrics-demo" element={<ServiceMetricsDashboard />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import {
|
||||
Bell,
|
||||
BookOpen,
|
||||
ChartBar,
|
||||
CheckCircle2,
|
||||
ChevronsLeft,
|
||||
Container,
|
||||
Database,
|
||||
FileText,
|
||||
FolderKanban,
|
||||
LayoutTemplate,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Moon,
|
||||
PanelLeft,
|
||||
RefreshCw,
|
||||
ScrollText,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Sun,
|
||||
UploadCloud,
|
||||
User,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getCurrentUserProfile,
|
||||
getUpgradeStatus,
|
||||
listNotifications,
|
||||
markAllNotificationsRead,
|
||||
@@ -29,28 +37,66 @@ import {
|
||||
pullUpgradeImage,
|
||||
} from '@/lib/api-client';
|
||||
import { signOutAuthSession } from '@/lib/auth-client';
|
||||
import { isDemoSearch } from '@/lib/demo-mode';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useToast } from '@/shared/components';
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Projects', href: '/projects', icon: FolderKanban },
|
||||
{ label: 'Templates', href: '/templates', icon: LayoutTemplate },
|
||||
{ label: 'Databases', href: '/databases', icon: Database },
|
||||
{ label: 'HA', href: '/ha', icon: ShieldCheck },
|
||||
{ label: 'Security', href: '/security', icon: Shield },
|
||||
{ label: 'Usage', href: '/usage', icon: ChartBar },
|
||||
{ label: 'People', href: '/people', icon: Users },
|
||||
{ label: 'Settings', href: '/settings', icon: Settings },
|
||||
{ label: 'Docs', href: '/docs', icon: BookOpen },
|
||||
type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string };
|
||||
type NavSection = { title: string; items: NavItem[] };
|
||||
|
||||
const navSections: NavSection[] = [
|
||||
{
|
||||
title: 'Build',
|
||||
items: [
|
||||
{ label: 'Projects', href: '/projects', icon: FolderKanban, hint: 'Services & deployments' },
|
||||
{ label: 'Templates', href: '/templates', icon: LayoutTemplate, hint: 'Deployable presets' },
|
||||
{ label: 'Builds', href: '/builds', icon: FileText, hint: 'Build history' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Operate',
|
||||
items: [
|
||||
{ label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' },
|
||||
{ label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' },
|
||||
{ label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' },
|
||||
{ label: 'Usage', href: '/usage', icon: ChartBar, hint: 'Resource consumption' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Workspace',
|
||||
items: [
|
||||
{ label: 'People', href: '/people', icon: Users, hint: 'Members & access' },
|
||||
{ label: 'Audit Logs', href: '/settings/audit-logs', icon: ScrollText, hint: 'Activity trail' },
|
||||
{ label: 'Settings', href: '/settings', icon: Settings, hint: 'Platform preferences' },
|
||||
{ label: 'Docs', href: '/docs', icon: BookOpen, hint: 'Guides & reference' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
type ThemeMode = 'dark' | 'light';
|
||||
|
||||
function getInitialTheme(): ThemeMode {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'dark';
|
||||
function readStorage(key: string): string | null {
|
||||
try {
|
||||
return typeof window === 'undefined' ? null : window.localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return localStorage.getItem('containr.theme') === 'light' ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
function writeStorage(key: string, value: string) {
|
||||
try {
|
||||
window.localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Storage unavailable (SSR, tests, private mode) — non-persistent is fine.
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialTheme(): ThemeMode {
|
||||
return readStorage('containr.theme') === 'light' ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
function getInitialSidebar(): boolean {
|
||||
return readStorage('containr.sidebar.expanded') !== '0';
|
||||
}
|
||||
|
||||
export function PlatformShell() {
|
||||
@@ -58,12 +104,32 @@ export function PlatformShell() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const isDemoMode = new URLSearchParams(location.search).get('demo') === '1';
|
||||
const isDemoMode = isDemoSearch(location.search);
|
||||
const href = (target: string) => (isDemoMode ? `${target}?demo=1` : target);
|
||||
// Auth/admin-scoped pages have no demo fixtures — keep the demo nav honest.
|
||||
const visibleNavSections = isDemoMode
|
||||
? navSections
|
||||
.map((section) =>
|
||||
section.title === 'Workspace'
|
||||
? { ...section, items: section.items.filter((item) => item.href === '/docs') }
|
||||
: section,
|
||||
)
|
||||
.filter((section) => section.items.length > 0)
|
||||
: navSections;
|
||||
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
|
||||
const [sidebarExpanded, setSidebarExpanded] = useState<boolean>(() => getInitialSidebar());
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['current-profile'],
|
||||
queryFn: getCurrentUserProfile,
|
||||
enabled: !isDemoMode && Boolean(sessionQuery.data),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
|
||||
const upgradeQuery = useQuery({
|
||||
queryKey: ['upgrade-status'],
|
||||
queryFn: getUpgradeStatus,
|
||||
@@ -72,7 +138,7 @@ export function PlatformShell() {
|
||||
const notificationsQuery = useQuery({
|
||||
queryKey: ['shell-notifications'],
|
||||
queryFn: () => listNotifications(20),
|
||||
enabled: !isDemoMode,
|
||||
enabled: !isDemoMode && signedIn,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const markReadMutation = useMutation({
|
||||
@@ -86,9 +152,13 @@ export function PlatformShell() {
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
localStorage.setItem('containr.theme', theme);
|
||||
writeStorage('containr.theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
writeStorage('containr.sidebar.expanded', sidebarExpanded ? '1' : '0');
|
||||
}, [sidebarExpanded]);
|
||||
|
||||
const pullMutation = useMutation({
|
||||
mutationFn: pullUpgradeImage,
|
||||
onSuccess: (status) => {
|
||||
@@ -115,8 +185,8 @@ export function PlatformShell() {
|
||||
const isActiveRoute = (itemHref: string) =>
|
||||
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
|
||||
|
||||
const userName = sessionQuery.data?.user.name ?? 'Account';
|
||||
const userEmail = sessionQuery.data?.user.email ?? 'Local session';
|
||||
const userName = isDemoMode ? 'Demo workspace' : profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account';
|
||||
const userEmail = isDemoMode ? 'Sample data · read-only' : profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session';
|
||||
const initials = userName
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
@@ -124,33 +194,62 @@ export function PlatformShell() {
|
||||
.slice(0, 2)
|
||||
.toUpperCase() || 'C';
|
||||
|
||||
const avatar = (
|
||||
<div
|
||||
className="flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-full text-[11px] font-bold"
|
||||
style={{ background: 'linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))', color: 'var(--accent-on)' }}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="app-shell min-h-screen">
|
||||
<div className="ambient-glow" />
|
||||
|
||||
<div className="relative flex min-h-screen">
|
||||
<aside
|
||||
className="hidden h-screen shrink-0 flex-col items-center border-r py-4 md:flex"
|
||||
className="hidden h-screen shrink-0 flex-col border-r py-4 transition-[width] duration-200 md:flex"
|
||||
style={{
|
||||
width: '62px',
|
||||
width: sidebarExpanded ? '218px' : '62px',
|
||||
background: 'var(--bg-base)',
|
||||
borderRightColor: 'var(--border-subtle)',
|
||||
}}
|
||||
>
|
||||
<NavLink to={href('/projects')} className="mb-3" title="Containr">
|
||||
<div className={`flex items-center ${sidebarExpanded ? 'justify-between px-3' : 'flex-col items-center gap-2'}`}>
|
||||
<NavLink to={href('/projects')} title="Containr">
|
||||
<img src="/containr.svg" alt="Containr" className="h-[38px] w-[38px] rounded-xl" />
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarExpanded((v) => !v)}
|
||||
title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
{sidebarExpanded ? <ChevronsLeft size={15} /> : <PanelLeft size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col items-center gap-1.5">
|
||||
{navItems.map((item) => {
|
||||
<nav className="mt-3 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||
{visibleNavSections.map((section) => (
|
||||
<div key={section.title}>
|
||||
{sidebarExpanded ? (
|
||||
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]">
|
||||
{section.title}
|
||||
</p>
|
||||
) : null}
|
||||
<div className={`flex flex-col gap-0.5 ${sidebarExpanded ? '' : 'items-center'}`}>
|
||||
{section.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = isActiveRoute(item.href);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={href(item.href)}
|
||||
title={item.label}
|
||||
className={`relative flex h-10 w-10 items-center justify-center rounded-[11px] transition-colors ${
|
||||
title={sidebarExpanded ? item.hint : `${item.label} — ${item.hint}`}
|
||||
className={`relative flex items-center rounded-[11px] transition-colors ${
|
||||
sidebarExpanded ? 'gap-2.5 px-2 py-2' : 'h-10 w-10 justify-center'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-secondary)]'
|
||||
@@ -158,34 +257,128 @@ export function PlatformShell() {
|
||||
>
|
||||
{isActive ? (
|
||||
<span
|
||||
className="absolute -left-[11px] h-4 w-[2.5px] rounded-full"
|
||||
className="absolute -left-2 h-4 w-[2.5px] rounded-full"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
/>
|
||||
) : null}
|
||||
<Icon size={17} />
|
||||
<Icon size={17} className="shrink-0" />
|
||||
{sidebarExpanded ? (
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-medium leading-tight">{item.label}</span>
|
||||
<span className="block truncate text-[10px] text-[var(--text-tertiary)]">{item.hint}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className={`flex flex-col gap-2 px-2 pt-2 ${sidebarExpanded ? '' : 'items-center'}`}>
|
||||
{signedIn ? (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={sidebarExpanded ? undefined : userEmail}
|
||||
className={`flex items-center gap-2.5 rounded-[11px] text-left transition-colors hover:bg-[var(--surface-muted)] ${
|
||||
sidebarExpanded ? 'w-full px-2 py-2' : 'h-10 w-10 justify-center'
|
||||
}`}
|
||||
>
|
||||
{avatar}
|
||||
{sidebarExpanded ? (
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12.5px] font-medium text-[var(--text-primary)]">{userName}</span>
|
||||
<span className="block truncate text-[10.5px] text-[var(--text-tertiary)]">{userEmail}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
side="right"
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
className="z-50 w-56 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-1.5 shadow-2xl"
|
||||
>
|
||||
<div className="px-2.5 py-2">
|
||||
<p className="truncate text-sm font-semibold text-[var(--text-primary)]">{userName}</p>
|
||||
<p className="truncate text-xs text-[var(--text-tertiary)]">{userEmail}</p>
|
||||
</div>
|
||||
<DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" />
|
||||
{!isDemoMode ? (
|
||||
<>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button type="button" onClick={() => navigate(href('/settings'))} className={menuItemClass}>
|
||||
<User size={14} /> Profile & account
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button type="button" onClick={() => navigate(href('/settings'))} className={menuItemClass}>
|
||||
<Settings size={14} /> Settings
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button type="button" onClick={() => navigate(href('/settings/audit-logs'))} className={menuItemClass}>
|
||||
<ScrollText size={14} /> Audit logs
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
{isAdmin ? (
|
||||
<DropdownMenu.Item asChild>
|
||||
<button type="button" onClick={() => navigate(href('/admin'))} className={menuItemClass}>
|
||||
<ShieldCheck size={14} /> Admin console
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
|
||||
className={menuItemClass}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
|
||||
{theme === 'dark' ? 'Light theme' : 'Dark theme'}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" />
|
||||
<DropdownMenu.Item asChild>
|
||||
{isDemoMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/')}
|
||||
className={`${menuItemClass} text-[var(--error)] hover:!bg-[var(--error-soft)] hover:!text-[var(--error)]`}
|
||||
>
|
||||
<LogOut size={14} /> Exit demo
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut()}
|
||||
title={`Sign out (${userName})`}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-[11px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--error-soft)] hover:text-[var(--error)]"
|
||||
className={`${menuItemClass} text-[var(--error)] hover:!bg-[var(--error-soft)] hover:!text-[var(--error)]`}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<LogOut size={14} /> Sign out
|
||||
</button>
|
||||
<div
|
||||
title={userEmail}
|
||||
className="flex h-[30px] w-[30px] items-center justify-center rounded-full text-[11px] font-bold"
|
||||
style={{ background: 'linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))', color: 'var(--accent-on)' }}
|
||||
)}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
) : (
|
||||
<NavLink
|
||||
to="/auth/sign-in"
|
||||
title="Sign in"
|
||||
className={`flex items-center gap-2.5 rounded-[11px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] ${
|
||||
sidebarExpanded ? 'w-full px-2 py-2' : 'h-10 w-10 justify-center'
|
||||
}`}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
<LogIn size={16} className="shrink-0" />
|
||||
{sidebarExpanded ? <span className="text-[12.5px] font-medium">Sign in</span> : null}
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -214,7 +407,7 @@ export function PlatformShell() {
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
<span className="v-env">env:production</span>
|
||||
<span className="v-env">{isDemoMode ? 'env:demo' : 'env:production'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
|
||||
@@ -225,10 +418,11 @@ export function PlatformShell() {
|
||||
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
|
||||
</button>
|
||||
|
||||
{isAdmin && !isDemoMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pullMutation.mutate()}
|
||||
disabled={isDemoMode || pullMutation.isPending || !upgradeQuery.data?.imageRef}
|
||||
disabled={pullMutation.isPending || !upgradeQuery.data?.imageRef}
|
||||
className="inline-flex h-8 items-center gap-2 rounded-[var(--radius-md)] px-3 text-xs font-semibold text-[var(--accent-on)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
title={upgradeQuery.data?.message || 'Pull latest configured image'}
|
||||
@@ -236,7 +430,9 @@ export function PlatformShell() {
|
||||
{pullMutation.isPending ? <RefreshCw size={14} className="animate-spin" /> : <UploadCloud size={14} />}
|
||||
Upgrade
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{signedIn && !isDemoMode ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
@@ -289,7 +485,7 @@ export function PlatformShell() {
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => { if (!item.read_at) markReadMutation.mutate(item.id ?? ''); }}
|
||||
className="w-full rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3 text-left hover:bg-[var(--surface-raised)]"
|
||||
className="w-full rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3 text-left hover:bg-[var(--surface-card)]"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{!item.read_at ? (
|
||||
@@ -312,6 +508,7 @@ export function PlatformShell() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -332,7 +529,7 @@ export function PlatformShell() {
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex gap-1 overflow-x-auto px-3 pb-3">
|
||||
{navItems.map((item) => {
|
||||
{visibleNavSections.flatMap((section) => section.items).map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = isActiveRoute(item.href);
|
||||
return (
|
||||
@@ -361,3 +558,6 @@ export function PlatformShell() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const menuItemClass =
|
||||
'flex w-full cursor-pointer select-none items-center gap-2 rounded-[var(--radius-md)] px-2.5 py-2 text-[13px] text-[var(--text-secondary)] outline-none transition-colors hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] data-[highlighted]:bg-[var(--surface-muted)] data-[highlighted]:text-[var(--text-primary)]';
|
||||
@@ -0,0 +1,375 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FolderKanban,
|
||||
Pencil,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Users,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
deleteProject,
|
||||
getAdminOverview,
|
||||
listAdminUsers,
|
||||
listProjects,
|
||||
setProjectApproval,
|
||||
setUserAdmin,
|
||||
updateProject,
|
||||
type AdminUser,
|
||||
type ProjectEntity,
|
||||
} from '@/lib/api-client';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { DemoRestricted, LoadingState, useToast } from '@/shared/components';
|
||||
|
||||
const statLabels: Record<string, string> = {
|
||||
users: 'Users',
|
||||
projects: 'Projects',
|
||||
pending_projects: 'Pending approval',
|
||||
services: 'Services',
|
||||
running_services: 'Running',
|
||||
databases: 'Databases',
|
||||
agents: 'Agents',
|
||||
templates: 'Templates',
|
||||
user_templates: 'User templates',
|
||||
cron_jobs: 'Cron jobs',
|
||||
deployments_total: 'Deployments',
|
||||
};
|
||||
|
||||
export function AdminPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const isDemoMode = useDemoMode();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const myId = sessionQuery.data?.user.id;
|
||||
|
||||
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview, enabled: !isDemoMode });
|
||||
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode });
|
||||
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode });
|
||||
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
|
||||
const [editForm, setEditForm] = useState({ name: '', description: '' });
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-overview'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-projects'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
};
|
||||
|
||||
const approvalMutation = useMutation({
|
||||
mutationFn: ({ id, approved }: { id: string; approved: boolean }) => setProjectApproval(id, approved),
|
||||
onSuccess: (_, vars) => {
|
||||
showToast('success', vars.approved ? 'Project approved' : 'Project unapproved');
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
|
||||
});
|
||||
|
||||
const adminMutation = useMutation({
|
||||
mutationFn: ({ id, isAdmin }: { id: string; isAdmin: boolean }) => setUserAdmin(id, isAdmin),
|
||||
onSuccess: (_, vars) => {
|
||||
showToast('success', vars.isAdmin ? 'Admin granted' : 'Admin revoked');
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
|
||||
});
|
||||
|
||||
const editMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateProject(editingProject!.id, {
|
||||
name: editForm.name.trim(),
|
||||
description: editForm.description.trim(),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
showToast('success', 'Project updated');
|
||||
setEditingProject(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteProject(id),
|
||||
onSuccess: () => {
|
||||
showToast('success', 'Project deleted');
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined),
|
||||
});
|
||||
|
||||
if (isDemoMode) {
|
||||
return <DemoRestricted feature="The admin console" />;
|
||||
}
|
||||
|
||||
if (overviewQuery.isPending) {
|
||||
return <LoadingState message="Loading admin console…" className="py-24" />;
|
||||
}
|
||||
|
||||
if (overviewQuery.isError) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-5 py-24 text-center">
|
||||
<ShieldCheck size={28} className="mx-auto text-[var(--error)]" />
|
||||
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
This area is restricted to platform administrators.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const overview = overviewQuery.data;
|
||||
const stats = overview?.stats ?? {};
|
||||
const pending = overview?.pending_projects ?? [];
|
||||
const users = usersQuery.data ?? [];
|
||||
const allProjects = projectsQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-5 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-[24px] font-bold tracking-tight text-[var(--text-primary)]">Admin console</h1>
|
||||
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
Platform overview, project approvals, and user management.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2">
|
||||
{Object.entries(statLabels).map(([key, label]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-3.5"
|
||||
>
|
||||
<p className="text-[20px] font-bold tabular-nums text-[var(--text-primary)]">{stats[key] ?? 0}</p>
|
||||
<p className="mt-0.5 text-[10.5px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="mt-8">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Clock size={15} className="text-[var(--warning)]" />
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Pending approvals</h2>
|
||||
{pending.length > 0 ? (
|
||||
<span className="rounded-full bg-[var(--warning-soft)] px-2 py-0.5 text-[10.5px] font-semibold text-[var(--warning)]">
|
||||
{pending.length}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{pending.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
{pending.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<FolderKanban size={15} className="shrink-0 text-[var(--text-tertiary)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link to={`/projects/${project.id}`} className="text-[13.5px] font-medium text-[var(--text-primary)] hover:underline">
|
||||
{project.name}
|
||||
</Link>
|
||||
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{project.description || 'No description'}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => approvalMutation.mutate({ id: project.id, approved: true })}
|
||||
disabled={approvalMutation.isPending}
|
||||
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3 py-1.5 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle2 size={13} /> Approve
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">
|
||||
Nothing waiting for approval.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-8">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<FolderKanban size={15} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">All projects</h2>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
{allProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link to={`/projects/${project.id}`} className="text-[13.5px] font-medium text-[var(--text-primary)] hover:underline">
|
||||
{project.name}
|
||||
</Link>
|
||||
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">
|
||||
{project.stats?.service_count ?? 0} services · {project.stats?.deployment_count ?? 0} deploys
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
|
||||
project.isApproved
|
||||
? 'bg-[var(--success-soft)] text-[var(--success)]'
|
||||
: 'bg-[var(--warning-soft)] text-[var(--warning)]'
|
||||
}`}
|
||||
>
|
||||
{project.isApproved ? 'public' : 'pending'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingProject(project);
|
||||
setEditForm({ name: project.name, description: project.description ?? '' });
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
title="Edit project"
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete project "${project.name}"? This removes its services and deployments.`)) {
|
||||
deleteMutation.mutate(project.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
|
||||
title="Delete project"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => approvalMutation.mutate({ id: project.id, approved: !project.isApproved })}
|
||||
disabled={approvalMutation.isPending}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
||||
>
|
||||
{project.isApproved ? 'Unapprove' : 'Approve'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{allProjects.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No projects yet.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 pb-10">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Users size={15} className="text-[var(--accent-tertiary)]" />
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Users</h2>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
{users.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
isSelf={user.id === myId}
|
||||
pending={adminMutation.isPending}
|
||||
onToggle={(isAdmin) => adminMutation.mutate({ id: user.id, isAdmin })}
|
||||
/>
|
||||
))}
|
||||
{users.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No users.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{editingProject ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
|
||||
<div className="border-b border-[var(--border-subtle)] px-5 py-4">
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Edit project</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Admin edit — changes apply immediately.</p>
|
||||
</div>
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Name</label>
|
||||
<input
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Description</label>
|
||||
<textarea
|
||||
value={editForm.description}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, description: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingProject(null)}
|
||||
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editMutation.mutate()}
|
||||
disabled={editMutation.isPending || editForm.name.trim().length < 2}
|
||||
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
>
|
||||
{editMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({
|
||||
user,
|
||||
isSelf,
|
||||
pending,
|
||||
onToggle,
|
||||
}: {
|
||||
user: AdminUser;
|
||||
isSelf: boolean;
|
||||
pending: boolean;
|
||||
onToggle: (isAdmin: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--surface-muted)] text-[11px] font-bold text-[var(--text-secondary)]">
|
||||
{user.name.split(' ').map((p) => p[0]).join('').slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13.5px] font-medium text-[var(--text-primary)]">
|
||||
{user.name}
|
||||
{isSelf ? <span className="ml-2 text-[10.5px] text-[var(--text-tertiary)]">(you)</span> : null}
|
||||
</p>
|
||||
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{user.email}</p>
|
||||
</div>
|
||||
{user.is_admin ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--accent-primary-soft)] px-2.5 py-0.5 text-[10.5px] font-semibold text-[var(--accent-primary)]">
|
||||
<ShieldCheck size={11} /> admin
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(!user.is_admin)}
|
||||
disabled={pending || isSelf}
|
||||
title={isSelf ? 'You cannot change your own admin flag' : undefined}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{user.is_admin ? (
|
||||
<span className="inline-flex items-center gap-1"><XCircle size={12} /> Revoke</span>
|
||||
) : (
|
||||
'Make admin'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useDeferredValue, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { listAuditLogs } from '@/lib/api-client';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { formatDate, formatRelative } from '@/lib/time';
|
||||
import { DemoRestricted } from '@/shared/components';
|
||||
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
@@ -17,6 +19,7 @@ const inputClass =
|
||||
'h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all';
|
||||
|
||||
export function AuditLogsPage() {
|
||||
const isDemoMode = useDemoMode();
|
||||
const [resource, setResource] = useState('');
|
||||
const [action, setAction] = useState('');
|
||||
const [actor, setActor] = useState('');
|
||||
@@ -42,8 +45,13 @@ export function AuditLogsPage() {
|
||||
const since = option && 'ms' in option ? new Date(Date.now() - option.ms).toISOString() : undefined;
|
||||
return listAuditLogs({ ...filters, since });
|
||||
},
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
|
||||
if (isDemoMode) {
|
||||
return <DemoRestricted feature="Audit logs" />;
|
||||
}
|
||||
|
||||
const logs = logsQuery.data ?? [];
|
||||
const hasNext = logs.length === PAGE_SIZE;
|
||||
const hasFilters = Boolean(resource || action || actor || range);
|
||||
|
||||
@@ -114,6 +114,7 @@ export function SignInPage() {
|
||||
queryKey: ['auth-bootstrap'],
|
||||
queryFn: getAuthBootstrap,
|
||||
});
|
||||
const providers = bootstrapQuery.data?.providers ?? [];
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -168,10 +169,14 @@ export function SignInPage() {
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthCard title="Sign In" subtitle="Use email/password, GitHub, or Google.">
|
||||
<AuthCard
|
||||
title="Sign In"
|
||||
subtitle={providers.length > 0 ? 'Use email/password or a connected provider.' : 'Use your email and password.'}
|
||||
>
|
||||
{error ? <AuthErrorNotice message={error} /> : null}
|
||||
{info ? <AuthInfoNotice message={info} /> : null}
|
||||
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform access mode..." /> : null}
|
||||
{bootstrapQuery.isError ? <AuthErrorNotice message="Cannot reach the Containr API - check that the backend container is running." /> : null}
|
||||
|
||||
<form className="space-y-3" onSubmit={submitEmailPassword}>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||
@@ -211,6 +216,8 @@ export function SignInPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{providers.length > 0 ? (
|
||||
<>
|
||||
<div className="my-5 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-[var(--border-subtle)]" />
|
||||
<span className="text-[11px] uppercase tracking-wider text-[var(--text-muted)]">or continue with</span>
|
||||
@@ -218,6 +225,7 @@ export function SignInPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{providers.includes('github') ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signInWithGitHubProvider()}
|
||||
@@ -226,6 +234,8 @@ export function SignInPage() {
|
||||
<Github size={15} />
|
||||
GitHub
|
||||
</button>
|
||||
) : null}
|
||||
{providers.includes('google') ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signInWithGoogleProvider()}
|
||||
@@ -234,7 +244,10 @@ export function SignInPage() {
|
||||
<GoogleMark />
|
||||
Google
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
|
||||
<span>Need access?</span>
|
||||
@@ -266,7 +279,8 @@ export function SignUpPage() {
|
||||
return <Navigate to={redirectPath} replace />;
|
||||
}
|
||||
|
||||
if (bootstrapQuery.data?.mode === 'login') {
|
||||
// Registration stays available when the owner reopened it in Settings.
|
||||
if (bootstrapQuery.data?.mode === 'login' && !bootstrapQuery.data.signupEnabled) {
|
||||
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirectPath)}`} replace />;
|
||||
}
|
||||
|
||||
@@ -289,9 +303,15 @@ export function SignUpPage() {
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthCard title="Create Account" subtitle="Create first platform owner account. Registration closes after bootstrap.">
|
||||
<AuthCard
|
||||
title="Create Account"
|
||||
subtitle={bootstrapQuery.data?.mode === 'register'
|
||||
? 'Create first platform owner account. Registration closes after bootstrap.'
|
||||
: 'Create a new account.'}
|
||||
>
|
||||
{error ? <AuthErrorNotice message={error} /> : null}
|
||||
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : null}
|
||||
{bootstrapQuery.isError ? <AuthErrorNotice message="Cannot reach the Containr API - check that the backend container is running." /> : null}
|
||||
|
||||
<form className="space-y-3" onSubmit={submitSignUp}>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
cancelBuild,
|
||||
getBuildLogs,
|
||||
@@ -9,6 +8,8 @@ import {
|
||||
type BuildStatus,
|
||||
} from '@/lib/api-client';
|
||||
import { useBuildUpdates } from '@/lib/use-build-updates';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { demoBuilds } from '@/lib/demo-data';
|
||||
import { formatRelative } from '@/lib/time';
|
||||
import {
|
||||
Check,
|
||||
@@ -33,36 +34,6 @@ const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
];
|
||||
|
||||
const demoBuilds: BuildEntity[] = [
|
||||
{
|
||||
id: 'build-demo-01',
|
||||
projectId: 'project-demo',
|
||||
serviceId: 'service-api',
|
||||
status: 'success',
|
||||
progress: 100,
|
||||
startedAt: new Date(Date.now() - 20 * 60_000).toISOString(),
|
||||
completedAt: new Date(Date.now() - 18 * 60_000).toISOString(),
|
||||
imageName: 'ghcr.io/containr/api',
|
||||
imageTag: 'sha-1f2e3d4',
|
||||
size: 156_000_000,
|
||||
log: '[demo] Build finished successfully.',
|
||||
metadata: { branch: 'main' },
|
||||
},
|
||||
{
|
||||
id: 'build-demo-02',
|
||||
projectId: 'project-demo',
|
||||
serviceId: 'service-worker',
|
||||
status: 'running',
|
||||
progress: 54,
|
||||
startedAt: new Date(Date.now() - 2 * 60_000).toISOString(),
|
||||
imageName: 'ghcr.io/containr/worker',
|
||||
imageTag: 'sha-9a8b7c6',
|
||||
size: 0,
|
||||
log: '[demo] Building image layers...',
|
||||
metadata: { branch: 'feature/queue' },
|
||||
},
|
||||
];
|
||||
|
||||
function StatusBadge({ status }: { status: BuildStatus }) {
|
||||
const config = {
|
||||
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
|
||||
@@ -99,8 +70,7 @@ function bytesToHumanReadable(bytes: number): string {
|
||||
|
||||
export function BuildsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams] = useSearchParams();
|
||||
const isDemoMode = searchParams.get('demo') === '1';
|
||||
const isDemoMode = useDemoMode();
|
||||
|
||||
const [projectFilter, setProjectFilter] = useState('');
|
||||
const [serviceFilter, setServiceFilter] = useState('');
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { BookOpen, CloudOff, FileText, Search } from 'lucide-react';
|
||||
import { initialDocs, syncDocs, type DocEntry, type DocsState } from './docs-store';
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: false });
|
||||
|
||||
function renderMarkdown(markdown: string): string {
|
||||
const html = marked.parse(markdown, { async: false });
|
||||
return DOMPurify.sanitize(typeof html === 'string' ? html : '', {
|
||||
USE_PROFILES: { html: true },
|
||||
});
|
||||
}
|
||||
|
||||
function excerpt(markdown: string, length = 140): string {
|
||||
const text = markdown
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/[#>*`\-[\]()]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return text.length > length ? `${text.slice(0, length)}…` : text;
|
||||
}
|
||||
|
||||
export function DocsBrowser() {
|
||||
const [state, setState] = useState<DocsState>(() => initialDocs());
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
syncDocs(setState);
|
||||
}, []);
|
||||
|
||||
const docs = state.docs;
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const grouped = new Map<string, DocEntry[]>();
|
||||
for (const doc of docs) {
|
||||
const list = grouped.get(doc.section) ?? [];
|
||||
list.push(doc);
|
||||
grouped.set(doc.section, list);
|
||||
}
|
||||
return [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [docs]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
return docs.filter(
|
||||
(doc) =>
|
||||
doc.title.toLowerCase().includes(q) ||
|
||||
doc.path.toLowerCase().includes(q) ||
|
||||
doc.markdown.toLowerCase().includes(q),
|
||||
);
|
||||
}, [docs, query]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => docs.find((doc) => doc.path === selectedPath) ?? null,
|
||||
[docs, selectedPath],
|
||||
);
|
||||
|
||||
const selectedHtml = useMemo(
|
||||
() => (selected ? renderMarkdown(selected.markdown) : ''),
|
||||
[selected],
|
||||
);
|
||||
|
||||
const sourceBadge =
|
||||
state.source === 'github'
|
||||
? { label: 'live · github', cls: 'text-[var(--success)]' }
|
||||
: state.source === 'cache'
|
||||
? { label: `cached · ${state.syncedAt ? new Date(state.syncedAt).toLocaleDateString() : 'offline'}`, cls: 'text-[var(--warning)]' }
|
||||
: { label: 'bundled copy', cls: 'text-[var(--text-tertiary)]' };
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[480px] overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
<div className="flex w-[300px] shrink-0 flex-col border-r border-[var(--border-subtle)] max-md:w-full">
|
||||
<div className="border-b border-[var(--border-subtle)] p-3">
|
||||
<div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2.5 py-1.5">
|
||||
<Search size={13} className="shrink-0 text-[var(--text-tertiary)]" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search docs…"
|
||||
className="w-full bg-transparent text-[13px] text-[var(--text-primary)] outline-none placeholder:text-[var(--text-tertiary)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between px-0.5">
|
||||
<span className={`text-[10.5px] font-medium uppercase tracking-wider ${sourceBadge.cls}`}>
|
||||
{sourceBadge.label}
|
||||
</span>
|
||||
<span className="text-[10.5px] text-[var(--text-tertiary)]">{docs.length} docs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{filtered ? (
|
||||
filtered.length > 0 ? (
|
||||
filtered.map((doc) => (
|
||||
<DocListItem key={doc.path} doc={doc} active={doc.path === selectedPath} onSelect={setSelectedPath} showSection />
|
||||
))
|
||||
) : (
|
||||
<p className="px-2 py-6 text-center text-xs text-[var(--text-tertiary)]">No docs match “{query}”.</p>
|
||||
)
|
||||
) : (
|
||||
sections.map(([section, entries]) => (
|
||||
<div key={section} className="mb-3">
|
||||
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]">
|
||||
{section}
|
||||
</p>
|
||||
{entries.map((doc) => (
|
||||
<DocListItem key={doc.path} doc={doc} active={doc.path === selectedPath} onSelect={setSelectedPath} />
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-y-auto max-md:hidden">
|
||||
{selected ? (
|
||||
<article className="docs-article px-8 py-6">
|
||||
<p className="mb-1 text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||
{selected.section} / {selected.path}
|
||||
</p>
|
||||
<div dangerouslySetInnerHTML={{ __html: selectedHtml }} />
|
||||
</article>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<BookOpen size={28} className="text-[var(--text-tertiary)]" />
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Select a document</p>
|
||||
<p className="max-w-sm text-xs leading-relaxed text-[var(--text-tertiary)]">
|
||||
Documentation is bundled with the app and refreshes from GitHub in the background.
|
||||
{!state.syncedAt ? ' Currently showing the bundled copy.' : ''}
|
||||
</p>
|
||||
{state.source !== 'github' ? (
|
||||
<p className="flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||
<CloudOff size={12} /> Offline-safe — updates apply automatically when a connection is available.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile: tapping a doc replaces the list */}
|
||||
{selected ? (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--bg-base)] md:hidden">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--border-subtle)] p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPath(null)}
|
||||
className="rounded-[var(--radius-md)] px-2.5 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span className="truncate text-xs text-[var(--text-tertiary)]">{selected.title}</span>
|
||||
</div>
|
||||
<article className="docs-article flex-1 overflow-y-auto px-5 py-4">
|
||||
<div dangerouslySetInnerHTML={{ __html: selectedHtml }} />
|
||||
</article>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocListItem({
|
||||
doc,
|
||||
active,
|
||||
onSelect,
|
||||
showSection = false,
|
||||
}: {
|
||||
doc: DocEntry;
|
||||
active: boolean;
|
||||
onSelect: (path: string) => void;
|
||||
showSection?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(doc.path)}
|
||||
className={`mb-0.5 w-full rounded-[var(--radius-md)] px-2.5 py-2 text-left transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<FileText size={13} className="shrink-0 opacity-60" />
|
||||
<span className="truncate text-[13px] font-medium">{doc.title}</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate pl-5 text-[11px] text-[var(--text-tertiary)]">
|
||||
{showSection ? `${doc.section} · ` : ''}{excerpt(doc.markdown, 80)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { bundledDocs, type BundledDoc } from '@/generated/docs-snapshot';
|
||||
|
||||
export type DocEntry = BundledDoc;
|
||||
|
||||
export type DocsState = {
|
||||
docs: DocEntry[];
|
||||
source: 'bundled' | 'cache' | 'github';
|
||||
syncedAt: string | null;
|
||||
};
|
||||
|
||||
const CACHE_KEY = 'containr.docs.v1';
|
||||
const REPO = 'Dvorinka/Containr';
|
||||
const BRANCH = 'main';
|
||||
const SKIP_DIRS = new Set(['archive', 'design', 'superpowers']);
|
||||
|
||||
type CachePayload = { syncedAt: string; docs: DocEntry[] };
|
||||
|
||||
function readCache(): CachePayload | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as CachePayload;
|
||||
if (!Array.isArray(parsed.docs) || typeof parsed.syncedAt !== 'string') return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(payload: CachePayload) {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(payload));
|
||||
} catch {
|
||||
// Storage full or unavailable — bundled docs still cover us.
|
||||
}
|
||||
}
|
||||
|
||||
function titleOf(markdown: string, fallback: string): string {
|
||||
const match = markdown.match(/^#\s+(.+)$/m);
|
||||
if (match) return match[1].replace(/[#*`]/g, '').trim();
|
||||
return fallback
|
||||
.replace(/\.md$/i, '')
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
type GitHubTreeEntry = { path?: string; type?: string };
|
||||
|
||||
async function fetchGitHubDocs(signal: AbortSignal): Promise<DocEntry[]> {
|
||||
const treeRes = await fetch(
|
||||
`https://api.github.com/repos/${REPO}/git/trees/${BRANCH}?recursive=1`,
|
||||
{ signal, headers: { Accept: 'application/vnd.github+json' } },
|
||||
);
|
||||
if (!treeRes.ok) throw new Error(`tree fetch failed: ${treeRes.status}`);
|
||||
const tree = (await treeRes.json()) as { tree?: GitHubTreeEntry[]; truncated?: boolean };
|
||||
|
||||
const paths = (tree.tree ?? [])
|
||||
.filter(
|
||||
(e) =>
|
||||
e.type === 'blob' &&
|
||||
typeof e.path === 'string' &&
|
||||
e.path.startsWith('docs/') &&
|
||||
e.path.toLowerCase().endsWith('.md') &&
|
||||
!e.path.split('/').some((seg, i) => i > 0 && SKIP_DIRS.has(seg)),
|
||||
)
|
||||
.map((e) => e.path as string);
|
||||
|
||||
const docs = await Promise.all(
|
||||
paths.map(async (path) => {
|
||||
const res = await fetch(
|
||||
`https://raw.githubusercontent.com/${REPO}/${BRANCH}/${path}`,
|
||||
{ signal },
|
||||
);
|
||||
if (!res.ok) throw new Error(`doc fetch failed: ${path}`);
|
||||
const markdown = await res.text();
|
||||
const rel = path.slice('docs/'.length);
|
||||
return {
|
||||
path: rel,
|
||||
section: rel.includes('/') ? rel.split('/')[0] : 'docs',
|
||||
title: titleOf(markdown, rel.split('/').pop() ?? rel),
|
||||
markdown,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return docs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous initial state — cache wins over the bundled snapshot so a
|
||||
* previously synced copy survives offline reloads.
|
||||
*/
|
||||
export function initialDocs(): DocsState {
|
||||
const cached = readCache();
|
||||
return cached
|
||||
? { docs: cached.docs, source: 'cache', syncedAt: cached.syncedAt }
|
||||
: { docs: bundledDocs, source: 'bundled', syncedAt: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Background GitHub sync. `onUpdate` fires when fresh content lands so the
|
||||
* UI can re-render without a reload; failures keep the current copy.
|
||||
*/
|
||||
export function syncDocs(onUpdate: (state: DocsState) => void): void {
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15_000);
|
||||
|
||||
fetchGitHubDocs(controller.signal)
|
||||
.then((docs) => {
|
||||
const syncedAt = new Date().toISOString();
|
||||
writeCache({ syncedAt, docs });
|
||||
onUpdate({ docs, source: 'github', syncedAt });
|
||||
})
|
||||
.catch(() => {
|
||||
// Offline or rate-limited — bundled/cache copy already displayed.
|
||||
})
|
||||
.finally(() => clearTimeout(timeout));
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Container,
|
||||
Database,
|
||||
GitBranch,
|
||||
LayoutTemplate,
|
||||
Rocket,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { listProjects, listTemplates } from '@/lib/api-client';
|
||||
import { DocsBrowser } from '@/features/docs/DocsBrowser';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { demoProjects, demoTemplates } from '@/lib/demo-data';
|
||||
|
||||
const features = [
|
||||
{ icon: Rocket, title: 'Git-push deploys', text: 'Connect a repository and ship. Builds, rollouts, and rollbacks are handled for you.' },
|
||||
{ icon: Database, title: 'Managed databases', text: 'Provision PostgreSQL, MySQL, Redis and more — with backups and connection management.' },
|
||||
{ icon: ShieldCheck, title: 'High availability', text: 'Failover policies, health checks, and alerting keep services alive across nodes.' },
|
||||
{ icon: GitBranch, title: 'Preview environments', text: 'Every branch gets an isolated environment with its own URL.' },
|
||||
{ icon: LayoutTemplate, title: 'Template catalog', text: 'One-click deploys for popular self-hosted apps and community templates.' },
|
||||
{ icon: Zap, title: 'Autoscaling', text: 'Metric-driven scaling policies adapt replicas to real load.' },
|
||||
];
|
||||
|
||||
export function LandingPage() {
|
||||
const isDemoMode = useDemoMode();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const signedIn = !isDemoMode && Boolean(sessionQuery.data);
|
||||
const demo = isDemoMode ? '?demo=1' : '';
|
||||
|
||||
const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000, enabled: !isDemoMode });
|
||||
const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000, enabled: !isDemoMode });
|
||||
|
||||
const projects = isDemoMode ? demoProjects : (projectsQuery.data ?? []);
|
||||
const templates = (isDemoMode ? demoTemplates : (templatesQuery.data ?? [])).slice(0, 12);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--bg-void)] text-[var(--text-secondary)]">
|
||||
<div className="ambient-glow" />
|
||||
|
||||
<header className="sticky top-0 z-40 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
|
||||
<Link to="/" className="flex items-center gap-2.5">
|
||||
<img src="/containr.svg" alt="Containr" className="h-7 w-7 rounded-lg" />
|
||||
<span className="text-[15px] font-extrabold tracking-tight text-[var(--text-primary)]">
|
||||
contain<span style={{ color: 'var(--accent-primary)' }}>r</span>
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-1 text-[13px]">
|
||||
<a href="#features" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Features</a>
|
||||
<a href="#templates" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Templates</a>
|
||||
<a href="#docs" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Docs</a>
|
||||
<Link
|
||||
to={`/projects${demo}`}
|
||||
className="rounded-[var(--radius-md)] px-3 py-1.5 font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
Browse
|
||||
</Link>
|
||||
{signedIn ? (
|
||||
<Link
|
||||
to="/projects"
|
||||
className="ml-1 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 py-1.5 font-semibold text-[var(--accent-on)]"
|
||||
>
|
||||
Console <ArrowRight size={13} />
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to={isDemoMode ? `/projects${demo}` : '/auth/sign-in'}
|
||||
className="ml-1 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 py-1.5 font-semibold text-[var(--accent-on)]"
|
||||
>
|
||||
{isDemoMode ? 'Live demo' : 'Sign in'} <ArrowRight size={13} />
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative">
|
||||
<section className="mx-auto max-w-6xl px-5 pb-16 pt-20 text-center">
|
||||
<p className="mx-auto mb-5 inline-flex items-center gap-2 rounded-full border border-[var(--border-subtle)] bg-[var(--surface-card)] px-3.5 py-1 text-[11px] font-medium uppercase tracking-[0.16em] text-[var(--text-tertiary)]">
|
||||
<Container size={12} className="text-[var(--accent-primary)]" />
|
||||
self-hosted PaaS
|
||||
</p>
|
||||
<h1 className="mx-auto max-w-3xl text-[42px] font-extrabold leading-[1.08] tracking-[-0.03em] text-[var(--text-primary)] max-sm:text-[32px]">
|
||||
Deploy containers like it's{' '}
|
||||
<span style={{ color: 'var(--accent-primary)' }}>your own cloud</span>
|
||||
</h1>
|
||||
<p className="mx-auto mt-5 max-w-xl text-[15px] leading-relaxed text-[var(--text-secondary)]">
|
||||
Containr turns any Docker host into a full deployment platform — git-push builds,
|
||||
managed databases, preview environments, HA failover, and a searchable knowledge base.
|
||||
</p>
|
||||
<div className="mt-8 flex items-center justify-center gap-3">
|
||||
<Link
|
||||
to={`/projects${demo}`}
|
||||
className="inline-flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-5 py-2.5 text-sm font-semibold text-[var(--accent-on)] transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
{isDemoMode ? 'Explore the live demo' : 'Browse projects'} <ArrowRight size={15} />
|
||||
</Link>
|
||||
<a
|
||||
href="#docs"
|
||||
className="inline-flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-default)] px-5 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<BookOpen size={15} /> Read the docs
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{projects.length > 0 ? (
|
||||
<div className="mx-auto mt-12 grid max-w-3xl grid-cols-3 gap-3 text-left max-sm:grid-cols-1">
|
||||
<Stat label="Public projects" value={projects.length} />
|
||||
<Stat
|
||||
label="Running services"
|
||||
value={projects.reduce((sum, p) => sum + (p.stats?.running_services ?? 0), 0)}
|
||||
/>
|
||||
<Stat
|
||||
label="Deployments"
|
||||
value={projects.reduce((sum, p) => sum + (p.stats?.deployment_count ?? 0), 0)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section id="features" className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
|
||||
<div className="mx-auto max-w-6xl px-5 py-16">
|
||||
<h2 className="text-center text-[26px] font-bold tracking-tight text-[var(--text-primary)]">
|
||||
Everything a deployment platform needs
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-lg text-center text-sm text-[var(--text-secondary)]">
|
||||
One install, one dashboard, full control over your own infrastructure.
|
||||
</p>
|
||||
<div className="mt-10 grid grid-cols-3 gap-4 max-md:grid-cols-2 max-sm:grid-cols-1">
|
||||
{features.map((feature) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-5 transition-colors hover:border-[var(--border-default)]"
|
||||
>
|
||||
<feature.icon size={18} className="text-[var(--accent-primary)]" />
|
||||
<h3 className="mt-3 text-[14.5px] font-semibold text-[var(--text-primary)]">{feature.title}</h3>
|
||||
<p className="mt-1.5 text-[12.5px] leading-relaxed text-[var(--text-tertiary)]">{feature.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="templates" className="border-t border-[var(--border-subtle)]">
|
||||
<div className="mx-auto max-w-6xl px-5 py-16">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Template catalog</h2>
|
||||
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
Production-ready presets — deploy in one click from the console.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/templates${demo}`}
|
||||
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--accent-primary)] hover:underline"
|
||||
>
|
||||
Browse all <ArrowRight size={13} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-8 grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2">
|
||||
{templates.map((template) => (
|
||||
<Link
|
||||
key={template.id}
|
||||
to={`/templates?template=${template.id}${isDemoMode ? '&demo=1' : ''}`}
|
||||
className="group rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-4 transition-colors hover:border-[var(--border-strong)]"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{template.logo ? (
|
||||
<img src={template.logo} alt="" className="h-7 w-7 rounded-md" />
|
||||
) : (
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-[var(--surface-muted)] text-[var(--accent-primary)]">
|
||||
<LayoutTemplate size={14} />
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-[13px] font-semibold text-[var(--text-primary)]">{template.name}</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-[11.5px] leading-relaxed text-[var(--text-tertiary)]">
|
||||
{template.description}
|
||||
</p>
|
||||
<span className="mt-2 inline-block rounded-full bg-[var(--surface-muted)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--text-tertiary)]">
|
||||
{template.category || 'app'}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
{templates.length === 0 && !templatesQuery.isPending ? (
|
||||
<p className="col-span-full py-8 text-center text-sm text-[var(--text-tertiary)]">
|
||||
Template catalog is empty — sign in to add your own.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="docs" className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
|
||||
<div className="mx-auto max-w-6xl px-5 py-16">
|
||||
<h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Documentation</h2>
|
||||
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
Guides and references — synced from GitHub, cached locally, always available.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<DocsBrowser />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-[var(--border-subtle)]">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-5 py-6 text-[12px] text-[var(--text-tertiary)]">
|
||||
<span className="flex items-center gap-2">
|
||||
<img src="/containr.svg" alt="" className="h-4 w-4 rounded" />
|
||||
Containr — self-hosted container platform
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to={`/projects${demo}`} className="hover:text-[var(--text-secondary)]">Projects</Link>
|
||||
<Link to={`/templates${demo}`} className="hover:text-[var(--text-secondary)]">Templates</Link>
|
||||
{!isDemoMode ? <Link to="/admin" className="hover:text-[var(--text-secondary)]">Admin</Link> : null}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-3.5">
|
||||
<p className="text-[22px] font-bold tabular-nums text-[var(--text-primary)]">{value}</p>
|
||||
<p className="mt-0.5 text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
createProject,
|
||||
getHostMonitoring,
|
||||
listBuilds,
|
||||
listRecentDeployments,
|
||||
listProjects,
|
||||
type ProjectEntity,
|
||||
type ProjectStats,
|
||||
@@ -14,37 +14,21 @@ import {
|
||||
FolderOpen,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
const demoProjects: ProjectEntity[] = [
|
||||
{
|
||||
id: 'project-demo',
|
||||
name: 'core-services',
|
||||
description: 'Sample project with mock services for UI preview.',
|
||||
createdAt: new Date(Date.now() - 14 * 86_400_000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
stats: { service_count: 5, deployment_count: 12, running_services: 5, last_deployment: null },
|
||||
},
|
||||
{
|
||||
id: 'project-staging',
|
||||
name: 'ml-pipeline',
|
||||
description: 'Pre-production environment for testing new releases.',
|
||||
createdAt: new Date(Date.now() - 7 * 86_400_000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
|
||||
stats: { service_count: 2, deployment_count: 5, running_services: 1, last_deployment: null },
|
||||
},
|
||||
];
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { demoProjects } from '@/lib/demo-data';
|
||||
|
||||
const demoDeploys = [
|
||||
{ id: 'd1', name: 'api-gateway', project: 'core-services', status: 'DEPLOYED', when: '4m' },
|
||||
{ id: 'd2', name: 'web-frontend', project: 'core-services', status: 'BUILDING', when: '6m' },
|
||||
{ id: 'd3', name: 'worker', project: 'ml-pipeline', status: 'FAILED', when: '1h' },
|
||||
{ id: 'd4', name: 'site', project: 'growth-site', status: 'DEPLOYED', when: '2h' },
|
||||
{ id: 'd1', name: 'API Gateway', project: 'Core Platform', status: 'DEPLOYED', when: '4m' },
|
||||
{ id: 'd2', name: 'Web Frontend', project: 'Core Platform', status: 'BUILDING', when: '6m' },
|
||||
{ id: 'd3', name: 'Batch Evaluator', project: 'Inference Lab', status: 'FAILED', when: '1h' },
|
||||
{ id: 'd4', name: 'Marketing Site', project: 'Growth Surface', status: 'DEPLOYED', when: '2h' },
|
||||
];
|
||||
|
||||
function getHealthStatus(stats: ProjectStats): 'healthy' | 'degraded' | 'critical' {
|
||||
if (stats.service_count === 0) return 'healthy';
|
||||
function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' | 'critical' {
|
||||
if (stats.service_count === 0) return 'idle';
|
||||
if (stats.running_services === stats.service_count) return 'healthy';
|
||||
if (stats.running_services >= stats.service_count / 2) return 'degraded';
|
||||
if (stats.running_services > 0) return 'degraded';
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
@@ -52,6 +36,8 @@ function healthBadge(health: ReturnType<typeof getHealthStatus>) {
|
||||
switch (health) {
|
||||
case 'healthy':
|
||||
return { label: 'RUNNING', cls: 'v-st-ok' };
|
||||
case 'idle':
|
||||
return { label: 'IDLE', cls: '' };
|
||||
case 'degraded':
|
||||
return { label: 'DEGRADED', cls: 'v-st-warn' };
|
||||
case 'critical':
|
||||
@@ -86,7 +72,6 @@ function MiniCanvas({ project }: { project: ProjectEntity }) {
|
||||
{Array.from({ length: shown }).map((_, i) => (
|
||||
<div key={i} className="v-node" style={positions[i]}>
|
||||
<b className={`v-nd ${i < running ? '' : 'r'}`} />
|
||||
svc{i + 1}
|
||||
</div>
|
||||
))}
|
||||
{extra > 0 ? (
|
||||
@@ -101,6 +86,7 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string }
|
||||
const health = getHealthStatus(project.stats);
|
||||
const badge = healthBadge(health);
|
||||
const envOk = health === 'healthy';
|
||||
const envLabel = envOk ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -117,10 +103,10 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string }
|
||||
<div className="v-mono mt-3 flex items-center gap-2 text-[10.5px] text-[var(--text-tertiary)]">
|
||||
<i
|
||||
className="inline-block h-1.5 w-1.5 rounded-full"
|
||||
style={{ background: envOk ? 'var(--success)' : 'var(--warning)' }}
|
||||
style={{ background: envOk ? 'var(--success)' : health === 'idle' ? 'var(--text-tertiary)' : health === 'critical' ? 'var(--error)' : 'var(--warning)' }}
|
||||
/>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{envOk ? 'production' : 'degraded'}
|
||||
{envLabel}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>
|
||||
@@ -134,13 +120,21 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string }
|
||||
|
||||
export function ProjectsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const isDemoMode = searchParams.get('demo') === '1';
|
||||
const isDemoMode = useDemoMode();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [isCreateOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', description: '' });
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||
const openCreate = () => {
|
||||
if (!signedIn) {
|
||||
navigate('/auth/sign-in');
|
||||
return;
|
||||
}
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const projectHref = (projectId: string) =>
|
||||
isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`;
|
||||
@@ -148,7 +142,7 @@ export function ProjectsPage() {
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: ['projects'],
|
||||
enabled: !isDemoMode,
|
||||
queryFn: listProjects,
|
||||
queryFn: () => listProjects(),
|
||||
});
|
||||
const hostQuery = useQuery({
|
||||
queryKey: ['host-monitoring'],
|
||||
@@ -156,10 +150,10 @@ export function ProjectsPage() {
|
||||
queryFn: getHostMonitoring,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const buildsQuery = useQuery({
|
||||
const deploysQuery = useQuery({
|
||||
queryKey: ['recent-deploys'],
|
||||
enabled: !isDemoMode,
|
||||
queryFn: () => listBuilds({ page: 1, limit: 5 }),
|
||||
queryFn: () => listRecentDeployments(10),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
@@ -187,22 +181,23 @@ export function ProjectsPage() {
|
||||
const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0);
|
||||
const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0);
|
||||
const host = hostQuery.data;
|
||||
const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'HOST';
|
||||
const cpuPct = isDemoMode ? 14 : host && host.cpu.cores > 0
|
||||
? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100))
|
||||
: null;
|
||||
const memPct = isDemoMode ? 72 : host ? Math.round(host.memory.usagePercent) : null;
|
||||
const diskPct = isDemoMode ? 73 : host ? Math.round(host.storage.usagePercent) : null;
|
||||
const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`;
|
||||
const builds = isDemoMode ? [] : buildsQuery.data?.builds ?? [];
|
||||
const deployCount = buildsQuery.data?.total ?? builds.length;
|
||||
const deployments = isDemoMode ? [] : deploysQuery.data ?? [];
|
||||
const deployCount = deployments.length;
|
||||
const feed = isDemoMode
|
||||
? demoDeploys
|
||||
: builds.map((b) => ({
|
||||
id: b.id,
|
||||
name: b.imageName || b.serviceId || b.id,
|
||||
project: '',
|
||||
status: (b.status || 'queued').toUpperCase(),
|
||||
when: formatRelative(b.startedAt ?? b.completedAt),
|
||||
: deployments.slice(0, 5).map((d) => ({
|
||||
id: d.id,
|
||||
name: d.serviceName || d.imageName || d.serviceId || d.id,
|
||||
project: d.projectName,
|
||||
status: (d.status || 'queued').toUpperCase(),
|
||||
when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt),
|
||||
}));
|
||||
|
||||
const statusClass = (status: string) =>
|
||||
@@ -228,7 +223,7 @@ export function ProjectsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="search-box" style={{ width: 240 }}>
|
||||
<div className="search-box" style={{ width: 'min(240px, 42vw)' }}>
|
||||
<Search size={13} />
|
||||
<input
|
||||
value={search}
|
||||
@@ -238,16 +233,18 @@ export function ProjectsPage() {
|
||||
style={{ fontSize: 11.5 }}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={() => setCreateOpen(true)} className="v-btn">
|
||||
{signedIn && (
|
||||
<button onClick={openCreate} className="v-btn">
|
||||
+ new project
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Host stat row */}
|
||||
<div className="mb-7 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div className="v-stat">
|
||||
<div className="v-k"><span>HOST.CPU</span><span className="v-live" /></div>
|
||||
<div className="v-k"><span>{hostLabel}.CPU</span><span className="v-live" /></div>
|
||||
<div className="v-v">{cpuPct ?? '—'}<span>%</span></div>
|
||||
{isDemoMode ? (
|
||||
<div className="v-spark">
|
||||
@@ -263,13 +260,13 @@ export function ProjectsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="v-stat">
|
||||
<div className="v-k"><span>HOST.MEM</span><span className="v-live" /></div>
|
||||
<div className="v-k"><span>{hostLabel}.MEM</span><span className="v-live" /></div>
|
||||
<div className="v-v">{memPct ?? '—'}<span>%</span></div>
|
||||
<div className="v-meter"><i className={memPct !== null && memPct > 85 ? 'r' : 'y'} style={{ width: `${memPct ?? 0}%` }} /></div>
|
||||
<div className="v-d">{isDemoMode ? '9.8G / 13G' : host ? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)}` : 'no telemetry'}</div>
|
||||
</div>
|
||||
<div className="v-stat">
|
||||
<div className="v-k"><span>HOST.DISK</span><span className="v-live" /></div>
|
||||
<div className="v-k"><span>{hostLabel}.DISK</span><span className="v-live" /></div>
|
||||
<div className="v-v">{diskPct ?? '—'}<span>%</span></div>
|
||||
<div className="v-meter"><i className="r" style={{ width: `${diskPct ?? 0}%` }} /></div>
|
||||
<div className="v-d">{isDemoMode ? '338G of 465G · high' : host ? `${fmtGB(host.storage.used)} of ${fmtGB(host.storage.total)}` : 'no telemetry'}</div>
|
||||
@@ -278,7 +275,7 @@ export function ProjectsPage() {
|
||||
<div className="v-k"><span>DEPLOYS</span></div>
|
||||
<div className="v-v">{isDemoMode ? 18 : deployCount}</div>
|
||||
<div className="v-meter"><i style={{ width: '94%' }} /></div>
|
||||
<div className="v-d"><b style={{ color: 'var(--success)' }}>{isDemoMode ? '17 ok' : `${builds.filter((b) => (b.status ?? '').toLowerCase() !== 'failed').length} recent`}</b></div>
|
||||
<div className="v-d"><b style={{ color: 'var(--success)' }}>{isDemoMode ? '17 ok' : `${deployments.filter((d) => (d.status ?? '').toLowerCase() !== 'failed').length} recent`}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -310,10 +307,12 @@ export function ProjectsPage() {
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-[var(--text-primary)]">No projects yet</p>
|
||||
<p className="mx-auto mt-2 max-w-md text-[var(--text-secondary)]">
|
||||
Create your first project to start deploying services with visual topology management.
|
||||
{signedIn
|
||||
? 'Create your first project to start deploying services with visual topology management.'
|
||||
: 'No public projects yet. Sign in to create one — an admin approves it before it goes public.'}
|
||||
</p>
|
||||
<button onClick={() => setCreateOpen(true)} className="v-btn mt-6">
|
||||
+ create project
|
||||
<button onClick={openCreate} className="v-btn mt-6">
|
||||
{signedIn ? '+ create project' : 'sign in to create'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -357,6 +356,9 @@ export function ProjectsPage() {
|
||||
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
Projects organize your services and provide a visual canvas for topology management.
|
||||
</p>
|
||||
<p className="mt-2 rounded-[var(--radius-md)] bg-[var(--warning-soft)] px-3 py-2 text-xs text-[var(--warning)]">
|
||||
New projects stay private until a platform admin approves them for public listing.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -29,9 +29,18 @@ import {
|
||||
execInService,
|
||||
rollbackDeployment,
|
||||
updateServiceVariables,
|
||||
updateService,
|
||||
restartService,
|
||||
stopService,
|
||||
getServiceRuntime,
|
||||
type CronJobEntity,
|
||||
} from '@/lib/api-client';
|
||||
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { ServiceIcon } from '@/features/workspace/canvas/ServiceIcon';
|
||||
import { serviceAccent } from '@/features/workspace/canvas/service-visuals';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { getCurrentUserProfile } from '@/lib/api-client';
|
||||
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
|
||||
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
|
||||
import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components';
|
||||
@@ -66,16 +75,18 @@ import {
|
||||
|
||||
type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'previews' | 'scaling' | 'console' | 'settings';
|
||||
|
||||
const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [
|
||||
// Sections marked manageOnly are hidden from anonymous visitors — they
|
||||
// expose authenticated or owner-scoped data (logs, variables, exec).
|
||||
const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity; manageOnly?: boolean }> = [
|
||||
{ key: 'metrics', label: 'Metrics', icon: Activity },
|
||||
{ key: 'logs', label: 'Logs', icon: FileText },
|
||||
{ key: 'logs', label: 'Logs', icon: FileText, manageOnly: true },
|
||||
{ key: 'config', label: 'Config', icon: Sliders },
|
||||
{ key: 'variables', label: 'Variables', icon: KeyRound },
|
||||
{ key: 'variables', label: 'Variables', icon: KeyRound, manageOnly: true },
|
||||
{ key: 'cron', label: 'Cron', icon: Clock },
|
||||
{ key: 'previews', label: 'Previews', icon: GitPullRequest },
|
||||
{ key: 'scaling', label: 'Scaling', icon: Layers },
|
||||
{ key: 'console', label: 'Console', icon: Terminal },
|
||||
{ key: 'settings', label: 'Settings', icon: Settings },
|
||||
{ key: 'console', label: 'Console', icon: Terminal, manageOnly: true },
|
||||
{ key: 'settings', label: 'Settings', icon: Settings, manageOnly: true },
|
||||
];
|
||||
|
||||
function metricStatus(percent: number): 'good' | 'average' | 'warning' {
|
||||
@@ -112,12 +123,24 @@ function StatusBadge({ status }: { status: string }) {
|
||||
|
||||
export function ServiceDetailPage() {
|
||||
const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const isDemoMode = searchParams.get('demo') === '1';
|
||||
const isDemoMode = useDemoMode();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['user-profile'],
|
||||
queryFn: getCurrentUserProfile,
|
||||
enabled: signedIn && !isDemoMode,
|
||||
retry: false,
|
||||
});
|
||||
const isAdmin = Boolean(profileQuery.data?.isAdmin);
|
||||
|
||||
const [activeSection, setActiveSection] = useState<ServiceSection>('metrics');
|
||||
const [activeSection, setActiveSection] = useState<ServiceSection>(() => {
|
||||
const requested = searchParams.get('section');
|
||||
return sectionItems.some((item) => item.key === requested) ? (requested as ServiceSection) : 'metrics';
|
||||
});
|
||||
const [logTail, setLogTail] = useState('100');
|
||||
const [varDrafts, setVarDrafts] = useState<VariableDraft[] | null>(null);
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
@@ -284,7 +307,11 @@ export function ServiceDetailPage() {
|
||||
});
|
||||
const manualScaleMutation = useMutation({
|
||||
mutationFn: (replicas: number) => manualScaleService(serviceId, replicas),
|
||||
onSuccess: invalidateScaling,
|
||||
onSuccess: () => {
|
||||
invalidateScaling();
|
||||
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||
},
|
||||
});
|
||||
|
||||
// --- Console ---
|
||||
@@ -361,10 +388,43 @@ export function ServiceDetailPage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
navigate(`/projects/${projectId}`);
|
||||
navigate(isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`);
|
||||
},
|
||||
});
|
||||
|
||||
const invalidateService = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
|
||||
};
|
||||
|
||||
const restartMutation = useMutation({
|
||||
mutationFn: () => restartService(serviceId),
|
||||
onSuccess: invalidateService,
|
||||
});
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: () => stopService(serviceId),
|
||||
onSuccess: invalidateService,
|
||||
});
|
||||
const updateServiceMutation = useMutation({
|
||||
mutationFn: (input: Parameters<typeof updateService>[1]) => updateService(serviceId, input),
|
||||
onSuccess: () => {
|
||||
setNetworkForm(null);
|
||||
invalidateService();
|
||||
},
|
||||
});
|
||||
|
||||
const runtimeQuery = useQuery({
|
||||
queryKey: ['service-runtime', serviceId],
|
||||
queryFn: () => getServiceRuntime(serviceId),
|
||||
enabled: Boolean(serviceId) && !isDemoMode,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
const [networkForm, setNetworkForm] = useState<{
|
||||
port: string; domain: string; healthcheckPath: string; restartPolicy: string; replicas: string;
|
||||
} | null>(null);
|
||||
|
||||
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
|
||||
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
|
||||
|
||||
@@ -516,22 +576,22 @@ export function ServiceDetailPage() {
|
||||
style={{
|
||||
gap: '6px',
|
||||
padding: '14px 24px 10px',
|
||||
color: '#6b6e7d',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
<button
|
||||
onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)}
|
||||
style={{ color: '#6b6e7d', textDecoration: 'none' }}
|
||||
className="hover:text-[#9295a4] transition-colors"
|
||||
style={{ color: 'var(--text-tertiary)', textDecoration: 'none' }}
|
||||
className="hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
Servers
|
||||
</button>
|
||||
<span style={{ opacity: 0.4 }}>/</span>
|
||||
<span style={{ color: '#9295a4' }}>{service.name}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{service.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Project Header - self.html exact match */}
|
||||
@@ -541,32 +601,33 @@ export function ServiceDetailPage() {
|
||||
style={{
|
||||
width: '46px',
|
||||
height: '46px',
|
||||
background: 'var(--accent-primary)',
|
||||
background: `${serviceAccent(service)}1f`,
|
||||
color: serviceAccent(service),
|
||||
marginRight: '14px'
|
||||
}}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/>
|
||||
</svg>
|
||||
<ServiceIcon service={service} size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center" style={{ gap: '10px' }}>
|
||||
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}>{service.name}</span>
|
||||
<span className={`badge-${service.status === 'running' ? 'active' : 'stopped'}`}>
|
||||
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: 'var(--text-primary)' }}>{service.name}</span>
|
||||
<span
|
||||
className={`badge-${service.status === 'running' ? 'active' : service.status === 'degraded' ? 'degraded' : 'stopped'}`}
|
||||
>
|
||||
{service.status === 'running' && <span className="live-dot" />}
|
||||
{service.status === 'running' ? 'Active' : 'Stopped'}
|
||||
{service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : 'Stopped'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
|
||||
{service.status === 'running' && (
|
||||
{(service.publicUrl || service.domain) && (
|
||||
<a
|
||||
href={`https://${service.name}.containr.dev`}
|
||||
href={service.publicUrl ?? `https://${service.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center hover:text-[#9295a4] transition-colors"
|
||||
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
|
||||
className="flex items-center hover:text-[var(--text-secondary)] transition-colors"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
|
||||
>
|
||||
https://{service.name}.containr.dev
|
||||
{service.publicUrl ?? `https://${service.domain}`}
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
@@ -576,8 +637,8 @@ export function ServiceDetailPage() {
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)}
|
||||
className="flex items-center hover:text-[#9295a4] transition-colors"
|
||||
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
|
||||
className="flex items-center hover:text-[var(--text-secondary)] transition-colors"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
|
||||
>
|
||||
Project Information
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
@@ -587,18 +648,29 @@ export function ServiceDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex" style={{ gap: '10px' }}>
|
||||
{!isDemoMode && (
|
||||
{!isDemoMode && signedIn && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => deployMutation.mutate('restart')}
|
||||
disabled={deployMutation.isPending || service.status !== 'running'}
|
||||
className={`btn-stop ${service.status !== 'running' ? 'disabled' : ''}`}
|
||||
onClick={() => stopMutation.mutate()}
|
||||
disabled={stopMutation.isPending || (service.status !== 'running' && service.status !== 'degraded')}
|
||||
className={`btn-stop ${service.status !== 'running' && service.status !== 'degraded' ? 'disabled' : ''}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
STOP
|
||||
{stopMutation.isPending ? 'STOPPING…' : 'STOP'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => restartMutation.mutate()}
|
||||
disabled={restartMutation.isPending || (service.status !== 'running' && service.status !== 'degraded')}
|
||||
className={`btn-restart ${service.status !== 'running' && service.status !== 'degraded' ? 'disabled' : ''}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||
<polyline points="1 4 1 10 7 10"/>
|
||||
<path d="M3.51 15a9 9 0 1 0 .49-4.5"/>
|
||||
</svg>
|
||||
{restartMutation.isPending ? 'RESTARTING…' : 'RESTART'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deployMutation.mutate('manual')}
|
||||
@@ -606,10 +678,10 @@ export function ServiceDetailPage() {
|
||||
className={`btn-restart ${deployMutation.isPending ? 'disabled' : ''}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||
<polyline points="1 4 1 10 7 10"/>
|
||||
<path d="M3.51 15a9 9 0 1 0 .49-4.5"/>
|
||||
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
|
||||
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
|
||||
</svg>
|
||||
RESTART
|
||||
{deployMutation.isPending ? 'DEPLOYING…' : 'DEPLOY'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -643,7 +715,7 @@ export function ServiceDetailPage() {
|
||||
data={isDemoMode
|
||||
? Array.from({ length: 24 }, (_, i) => seededMetric(`${service.id}:cpu:${i}`, 8, 80))
|
||||
: metricHistory.cpu.length > 0 ? metricHistory.cpu : [0]}
|
||||
color="#b4e34a"
|
||||
color="var(--accent-primary)"
|
||||
height={72}
|
||||
/>
|
||||
}
|
||||
@@ -657,10 +729,10 @@ export function ServiceDetailPage() {
|
||||
subtitle={isDemoMode ? 'Container footprint' : hasLiveTelemetry ? `${formatBytes(liveMetrics!.memory_usage_bytes)} used` : 'No running containers'}
|
||||
chart={
|
||||
<div className="relative mx-auto" style={{ width: 150 }}>
|
||||
<DonutChart percentage={isDemoMode ? metricSet?.memory ?? 0 : memoryPercent} color="#f2c94c" size={150} thickness={14} />
|
||||
<DonutChart percentage={isDemoMode ? metricSet?.memory ?? 0 : memoryPercent} color="var(--warning)" size={150} thickness={14} />
|
||||
<div className="absolute inset-x-0 bottom-0 text-center">
|
||||
<div className="text-[10px] uppercase tracking-wide text-[#6b6e7d]">Used</div>
|
||||
<div className="text-sm font-bold text-[#e8e9f0]">
|
||||
<div className="text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">Used</div>
|
||||
<div className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{isDemoMode
|
||||
? `${((8 * (metricSet?.memory ?? 0)) / 100).toFixed(1)} GB / 8 GB`
|
||||
: hasLiveTelemetry
|
||||
@@ -683,7 +755,7 @@ export function ServiceDetailPage() {
|
||||
data={isDemoMode
|
||||
? Array.from({ length: 24 }, (_, i) => seededMetric(`${service.id}:net:${i}`, 15, 70))
|
||||
: metricHistory.net.length > 0 ? metricHistory.net : [0]}
|
||||
color="#7ab8ff"
|
||||
color="var(--info)"
|
||||
height={72}
|
||||
/>
|
||||
}
|
||||
@@ -731,12 +803,12 @@ export function ServiceDetailPage() {
|
||||
<div
|
||||
className="flex"
|
||||
style={{
|
||||
borderBottom: '1px solid rgba(255,255,255,0.07)',
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
marginBottom: '18px',
|
||||
padding: '0 24px'
|
||||
}}
|
||||
>
|
||||
{sectionItems.map((item) => {
|
||||
{sectionItems.filter((item) => signedIn || !item.manageOnly).map((item) => {
|
||||
const active = activeSection === item.key;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
@@ -793,8 +865,8 @@ export function ServiceDetailPage() {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
|
||||
style={{ height: `${value}%`, background: '#b4e34a' }}
|
||||
className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80 cursor-pointer"
|
||||
style={{ height: `${value}%`, background: 'var(--accent-primary)' }}
|
||||
title={`${value}%`}
|
||||
/>
|
||||
);
|
||||
@@ -802,8 +874,8 @@ export function ServiceDetailPage() {
|
||||
: metricHistory.cpu.map((value, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80"
|
||||
style={{ height: `${Math.max(3, value)}%`, background: '#b4e34a' }}
|
||||
className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80"
|
||||
style={{ height: `${Math.max(3, value)}%`, background: 'var(--accent-primary)' }}
|
||||
title={`${value.toFixed(1)}%`}
|
||||
/>
|
||||
))}
|
||||
@@ -834,8 +906,8 @@ export function ServiceDetailPage() {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
|
||||
style={{ height: `${value}%`, background: '#5ee6a0' }}
|
||||
className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80 cursor-pointer"
|
||||
style={{ height: `${value}%`, background: 'var(--success)' }}
|
||||
title={`${value}%`}
|
||||
/>
|
||||
);
|
||||
@@ -843,8 +915,8 @@ export function ServiceDetailPage() {
|
||||
: metricHistory.memory.map((value, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80"
|
||||
style={{ height: `${Math.max(3, value)}%`, background: '#5ee6a0' }}
|
||||
className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80"
|
||||
style={{ height: `${Math.max(3, value)}%`, background: 'var(--success)' }}
|
||||
title={`${value.toFixed(1)}%`}
|
||||
/>
|
||||
))}
|
||||
@@ -900,31 +972,60 @@ export function ServiceDetailPage() {
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">Networking</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Public endpoints and ports</p>
|
||||
</div>
|
||||
{runtimeQuery.data?.health === 'healthy' && (
|
||||
<span className="ml-auto v-st v-st-ok">HEALTHY</span>
|
||||
)}
|
||||
{runtimeQuery.data?.health === 'unhealthy' && (
|
||||
<span className="ml-auto v-st v-st-fail">UNHEALTHY</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{service.domain && (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--success)]" />
|
||||
<span className="text-xs text-[var(--text-secondary)]">HTTPS</span>
|
||||
<span className="text-xs text-[var(--text-secondary)]">Domain</span>
|
||||
</div>
|
||||
<a
|
||||
href={`https://${service.name}.containr.dev`}
|
||||
href={`https://${service.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mono text-xs text-[var(--accent-primary)] hover:underline"
|
||||
>
|
||||
{service.name}.containr.dev
|
||||
{service.domain}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{(runtimeQuery.data?.urls ?? []).map((url) => (
|
||||
<div key={url} className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--success)]" />
|
||||
<span className="text-xs text-[var(--text-secondary)]">HTTP</span>
|
||||
</div>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mono text-xs text-[var(--accent-primary)] hover:underline"
|
||||
>
|
||||
{url.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--accent-secondary)]" />
|
||||
<span className="text-xs text-[var(--text-secondary)]">Port</span>
|
||||
<span className="text-xs text-[var(--text-secondary)]">Internal</span>
|
||||
</div>
|
||||
<span className="mono text-xs text-[var(--text-primary)]">8080 → 443</span>
|
||||
<span className="mono text-xs text-[var(--text-primary)]">{service.name}{service.port ? `:${service.port}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
{!service.domain && (runtimeQuery.data?.urls ?? []).length === 0 && (
|
||||
<p className="mt-3 text-xs text-[var(--text-tertiary)]">
|
||||
No public endpoint. Publish a port or set a domain in Settings → Networking to expose this service.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1078,7 +1179,11 @@ export function ServiceDetailPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Runtime values injected into the service container</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Runtime values injected at deploy. Reference another service's variable with{' '}
|
||||
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> — e.g.{' '}
|
||||
<code className="mono text-[var(--accent-primary)]">{'${{postgres.POSTGRES_PASSWORD}}'}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1230,6 +1335,7 @@ export function ServiceDetailPage() {
|
||||
<p className="text-sm text-[var(--text-secondary)]">Scheduled commands executed inside the service container</p>
|
||||
</div>
|
||||
</div>
|
||||
{signedIn ? (
|
||||
<button
|
||||
onClick={() => { setEditingCronId(null); setCronForm({ name: '', schedule: '', command: '', timezone: 'UTC', enabled: true, retention: 30 }); }}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
|
||||
@@ -1237,6 +1343,7 @@ export function ServiceDetailPage() {
|
||||
<Plus size={12} />
|
||||
New job
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{cronForm && (
|
||||
@@ -1320,9 +1427,10 @@ export function ServiceDetailPage() {
|
||||
<div key={job.id} className="panel-soft p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => toggleCronMutation.mutate(job)}
|
||||
onClick={() => signedIn && toggleCronMutation.mutate(job)}
|
||||
title={job.enabled ? 'Disable' : 'Enable'}
|
||||
className={`w-8 h-5 rounded-full relative transition-colors shrink-0 ${job.enabled ? 'bg-[var(--accent-primary)]' : 'bg-[var(--border-default)]'}`}
|
||||
disabled={!signedIn}
|
||||
className={`w-8 h-5 rounded-full relative transition-colors shrink-0 ${job.enabled ? 'bg-[var(--accent-primary)]' : 'bg-[var(--border-default)]'} disabled:opacity-60 disabled:cursor-not-allowed`}
|
||||
>
|
||||
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${job.enabled ? 'left-3.5' : 'left-0.5'}`} />
|
||||
</button>
|
||||
@@ -1338,15 +1446,19 @@ export function ServiceDetailPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{signedIn ? (
|
||||
<button onClick={() => triggerCronMutation.mutate(job.id as string)} title="Run now"
|
||||
disabled={triggerCronMutation.isPending}
|
||||
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors disabled:opacity-50">
|
||||
<Play size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
<button onClick={() => setHistoryCronId(historyCronId === job.id ? null : (job.id as string))} title="History"
|
||||
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors">
|
||||
<ChevronDown size={13} className={historyCronId === job.id ? 'rotate-180 transition-transform' : 'transition-transform'} />
|
||||
</button>
|
||||
{signedIn ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setEditingCronId(job.id as string); setCronForm({ name: job.name ?? '', schedule: job.schedule ?? '', command: job.command ?? '', timezone: job.timezone ?? 'UTC', enabled: job.enabled ?? true, retention: job.retention ?? 30 }); }}
|
||||
title="Edit"
|
||||
@@ -1359,6 +1471,8 @@ export function ServiceDetailPage() {
|
||||
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{historyCronId === job.id && (
|
||||
@@ -1404,12 +1518,14 @@ export function ServiceDetailPage() {
|
||||
<p className="text-sm text-[var(--text-secondary)]">Ephemeral per-branch deployments of this service</p>
|
||||
</div>
|
||||
</div>
|
||||
{signedIn ? (
|
||||
<button
|
||||
onClick={() => setPreviewForm({ branch: '', prNumber: '', ttlHours: '72' })}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
<Plus size={12} /> New preview
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{previewForm && (
|
||||
@@ -1421,7 +1537,7 @@ export function ServiceDetailPage() {
|
||||
value={previewForm.branch}
|
||||
onChange={(e) => setPreviewForm({ ...previewForm, branch: e.target.value })}
|
||||
placeholder="feature/my-branch"
|
||||
className="w-52 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-raised)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
|
||||
className="w-52 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
|
||||
@@ -1430,7 +1546,7 @@ export function ServiceDetailPage() {
|
||||
value={previewForm.prNumber}
|
||||
onChange={(e) => setPreviewForm({ ...previewForm, prNumber: e.target.value })}
|
||||
placeholder="123"
|
||||
className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-raised)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
|
||||
className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
|
||||
@@ -1439,7 +1555,7 @@ export function ServiceDetailPage() {
|
||||
value={previewForm.ttlHours}
|
||||
onChange={(e) => setPreviewForm({ ...previewForm, ttlHours: e.target.value })}
|
||||
placeholder="72"
|
||||
className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-raised)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
|
||||
className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
@@ -1493,7 +1609,7 @@ export function ServiceDetailPage() {
|
||||
<ExternalLink size={11} /> Open
|
||||
</a>
|
||||
)}
|
||||
{env.status === 'running' && (
|
||||
{env.status === 'running' && signedIn && (
|
||||
<button
|
||||
onClick={() => { if (window.confirm('Promote this preview to production?')) promotePreviewMutation.mutate(env.id ?? ''); }}
|
||||
disabled={promotePreviewMutation.isPending}
|
||||
@@ -1502,6 +1618,7 @@ export function ServiceDetailPage() {
|
||||
Promote
|
||||
</button>
|
||||
)}
|
||||
{signedIn ? (
|
||||
<button
|
||||
onClick={() => { if (window.confirm(`Delete preview ${env.environment}?`)) deletePreviewMutation.mutate(env.id ?? ''); }}
|
||||
disabled={deletePreviewMutation.isPending}
|
||||
@@ -1509,6 +1626,7 @@ export function ServiceDetailPage() {
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1540,28 +1658,30 @@ export function ServiceDetailPage() {
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p>
|
||||
<p className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{scalingStateQuery.data?.CurrentReplicas ?? '—'}
|
||||
{scalingStateQuery.data?.CurrentReplicas ??
|
||||
runtimeQuery.data?.containers.filter((c) => c.state === 'running').length ??
|
||||
'—'}
|
||||
<span className="text-sm font-normal text-[var(--text-secondary)]">
|
||||
{' '}/ {scalingStateQuery.data?.DesiredReplicas ?? '—'} desired
|
||||
{' '}/ {scalingStateQuery.data?.DesiredReplicas ?? runtimeQuery.data?.desired ?? service?.replicas ?? '—'} desired
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Policy</p>
|
||||
<p className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{scalingPolicyQuery.data ? `${scalingPolicyQuery.data.min_replicas}–${scalingPolicyQuery.data.max_replicas}` : 'None'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Autoscaling</p>
|
||||
<p className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{scalingPolicyQuery.data?.enabled ? 'On' : 'Off'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Last action</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{scalingStateQuery.data?.LastScaleDirection ?? '—'}
|
||||
@@ -1579,15 +1699,17 @@ export function ServiceDetailPage() {
|
||||
max={20}
|
||||
value={scaleReplicas}
|
||||
onChange={(e) => setScaleReplicas(e.target.value)}
|
||||
className="w-28 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
|
||||
className="w-28 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
placeholder="e.g. 3"
|
||||
/>
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={manualScaleMutation.isPending || !scaleReplicas || !scalingStateQuery.data}
|
||||
disabled={manualScaleMutation.isPending || !scaleReplicas}
|
||||
onClick={() => manualScaleMutation.mutate(Number(scaleReplicas))}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{manualScaleMutation.isPending ? 'Scaling…' : 'Scale'}
|
||||
</button>
|
||||
@@ -1600,7 +1722,7 @@ export function ServiceDetailPage() {
|
||||
targetMemory: String(scalingPolicyQuery.data?.target_memory ?? 80),
|
||||
enabled: scalingPolicyQuery.data?.enabled ?? true,
|
||||
})}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
{scalingPolicyQuery.data ? 'Edit policy' : 'Create policy'}
|
||||
</button>
|
||||
@@ -1614,9 +1736,13 @@ export function ServiceDetailPage() {
|
||||
Disable policy
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="self-center text-xs text-[var(--text-tertiary)]">Scaling policy changes are admin-only.</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-4 space-y-4">
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{([
|
||||
['Min replicas', 'min'],
|
||||
@@ -1631,7 +1757,7 @@ export function ServiceDetailPage() {
|
||||
min={field === 'min' || field === 'max' ? 1 : 0}
|
||||
value={scalingForm[field]}
|
||||
onChange={(e) => setScalingForm({ ...scalingForm, [field]: e.target.value })}
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -1649,14 +1775,14 @@ export function ServiceDetailPage() {
|
||||
type="button"
|
||||
disabled={saveScalingMutation.isPending}
|
||||
onClick={() => saveScalingMutation.mutate()}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{saveScalingMutation.isPending ? 'Saving…' : 'Save policy'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScalingForm(null)}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -1705,12 +1831,12 @@ export function ServiceDetailPage() {
|
||||
onChange={(e) => setConsoleInput(e.target.value)}
|
||||
placeholder="e.g. env | sort | head -20"
|
||||
disabled={execMutation.isPending}
|
||||
className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm font-mono text-[var(--text-primary)] disabled:opacity-50"
|
||||
className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm font-mono text-[var(--text-primary)] disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={execMutation.isPending || !consoleInput.trim()}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{execMutation.isPending ? 'Running…' : 'Run'}
|
||||
</button>
|
||||
@@ -1729,7 +1855,7 @@ export function ServiceDetailPage() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{consoleHistory.map((entry, i) => (
|
||||
<div key={i} className="rounded-[var(--radius-md)] border border-[var(--border-primary)] bg-[var(--bg-secondary)] p-3">
|
||||
<div key={i} className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-xs font-mono text-[var(--text-primary)]">$ {entry.command}</p>
|
||||
<span className={`text-xs font-mono ${entry.exitCode === 0 && !entry.error ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
|
||||
@@ -1775,6 +1901,159 @@ export function ServiceDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isDemoMode && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-[var(--text-primary)]">Networking</h3>
|
||||
{networkForm === null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setNetworkForm({
|
||||
port: service.port ? String(service.port) : '',
|
||||
domain: service.domain ?? '',
|
||||
healthcheckPath: service.healthcheckPath ?? '',
|
||||
restartPolicy: service.restartPolicy ?? 'unless-stopped',
|
||||
replicas: String(service.replicas ?? 1),
|
||||
})
|
||||
}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{runtimeQuery.data && (
|
||||
<div className="mb-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 p-3">
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className="text-[var(--text-tertiary)]">
|
||||
Live: <span className="text-[var(--text-primary)] font-medium">{runtimeQuery.data.containers.filter((c) => c.state === 'running').length}/{runtimeQuery.data.desired}</span> replicas
|
||||
</span>
|
||||
<span className="text-[var(--text-tertiary)]">
|
||||
Internal: <span className="mono text-[var(--text-primary)]">{service.name}{service.port ? `:${service.port}` : ''}</span>
|
||||
</span>
|
||||
{runtimeQuery.data.urls.map((url) => (
|
||||
<a key={url} href={url} target="_blank" rel="noopener noreferrer" className="mono text-[var(--accent-primary)] hover:underline">
|
||||
{url}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkForm === null ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Port</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.port || '—'}</p>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Domain</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)] truncate">{service.domain || '—'}</p>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.replicas ?? 1}</p>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Restart</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.restartPolicy || 'unless-stopped'}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Container port</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={65535}
|
||||
value={networkForm.port}
|
||||
onChange={(e) => setNetworkForm({ ...networkForm, port: e.target.value })}
|
||||
placeholder="e.g. 3000"
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">Published on the host; routed via Traefik when a domain is set</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Domain</label>
|
||||
<input
|
||||
value={networkForm.domain}
|
||||
onChange={(e) => setNetworkForm({ ...networkForm, domain: e.target.value })}
|
||||
placeholder="app.example.com"
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Health check path</label>
|
||||
<input
|
||||
value={networkForm.healthcheckPath}
|
||||
onChange={(e) => setNetworkForm({ ...networkForm, healthcheckPath: e.target.value })}
|
||||
placeholder="/health"
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Replicas</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={networkForm.replicas}
|
||||
onChange={(e) => setNetworkForm({ ...networkForm, replicas: e.target.value })}
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Restart policy</label>
|
||||
<select
|
||||
value={networkForm.restartPolicy}
|
||||
onChange={(e) => setNetworkForm({ ...networkForm, restartPolicy: e.target.value })}
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
<option value="unless-stopped">unless-stopped</option>
|
||||
<option value="always">always</option>
|
||||
<option value="on-failure">on-failure</option>
|
||||
<option value="no">no</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateServiceMutation.isPending}
|
||||
onClick={() =>
|
||||
updateServiceMutation.mutate({
|
||||
port: Number(networkForm.port) || 0,
|
||||
domain: networkForm.domain,
|
||||
healthcheck_path: networkForm.healthcheckPath,
|
||||
restart_policy: networkForm.restartPolicy,
|
||||
replicas: Math.max(1, Math.min(20, Number(networkForm.replicas) || 1)),
|
||||
})
|
||||
}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNetworkForm(null)}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<p className="text-[10px] text-[var(--text-tertiary)]">Takes effect on next deploy or redeploy</p>
|
||||
</div>
|
||||
{updateServiceMutation.isError && (
|
||||
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
||||
@@ -2,13 +2,20 @@ import { useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
createTemplate,
|
||||
deleteTemplate,
|
||||
deployTemplate,
|
||||
getCurrentUserProfile,
|
||||
getTemplateById,
|
||||
listProjects,
|
||||
listTemplates,
|
||||
type TemplateDetailEntity,
|
||||
updateTemplate,
|
||||
type TemplateEntity,
|
||||
type TemplateWriteInput,
|
||||
} from '@/lib/api-client';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { demoProjects, demoTemplates, demoTemplateDetails } from '@/lib/demo-data';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
@@ -24,322 +31,13 @@ import {
|
||||
Play,
|
||||
ArrowRight,
|
||||
Star,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
const demoTemplates: TemplateEntity[] = [
|
||||
{
|
||||
id: 'tpl-react',
|
||||
name: 'React Application',
|
||||
description: 'Single-page frontend with Vite and static serving runtime.',
|
||||
category: 'frontend',
|
||||
logo: 'https://cdn.simpleicons.org/react',
|
||||
configRaw: '{"runtime":"node"}',
|
||||
variablesRaw: '[]',
|
||||
isOfficial: true,
|
||||
},
|
||||
{
|
||||
id: 'tpl-go',
|
||||
name: 'Go API Service',
|
||||
description: 'API-ready Go runtime with direct binary startup.',
|
||||
category: 'web',
|
||||
logo: 'https://cdn.simpleicons.org/go',
|
||||
configRaw: '{"runtime":"go"}',
|
||||
variablesRaw: '[]',
|
||||
isOfficial: true,
|
||||
},
|
||||
{
|
||||
id: 'tpl-postgres',
|
||||
name: 'PostgreSQL Database',
|
||||
description: 'Managed PostgreSQL service with credential setup variables.',
|
||||
category: 'database',
|
||||
logo: 'https://cdn.simpleicons.org/postgresql',
|
||||
configRaw: '{"runtime":"postgres"}',
|
||||
variablesRaw: '[]',
|
||||
isOfficial: true,
|
||||
},
|
||||
{
|
||||
id: 'tpl-mysql',
|
||||
name: 'MySQL Database',
|
||||
description: 'Managed MySQL service for transactional workloads.',
|
||||
category: 'database',
|
||||
logo: 'https://cdn.simpleicons.org/mysql',
|
||||
configRaw: '{"runtime":"mysql"}',
|
||||
variablesRaw: '[]',
|
||||
isOfficial: true,
|
||||
},
|
||||
{
|
||||
id: 'tpl-mariadb',
|
||||
name: 'MariaDB Database',
|
||||
description: 'Managed MariaDB service with MySQL compatibility.',
|
||||
category: 'database',
|
||||
logo: 'https://cdn.simpleicons.org/mariadb',
|
||||
configRaw: '{"runtime":"mariadb"}',
|
||||
variablesRaw: '[]',
|
||||
isOfficial: true,
|
||||
},
|
||||
{
|
||||
id: 'tpl-clickhouse',
|
||||
name: 'ClickHouse Database',
|
||||
description: 'Columnar analytics database template for high-speed queries.',
|
||||
category: 'database',
|
||||
logo: 'https://cdn.simpleicons.org/clickhouse',
|
||||
configRaw: '{"runtime":"clickhouse"}',
|
||||
variablesRaw: '[]',
|
||||
isOfficial: true,
|
||||
},
|
||||
{
|
||||
id: 'tpl-dragonfly',
|
||||
name: 'Dragonfly Database',
|
||||
description: 'Redis-compatible in-memory store powered by Dragonfly.',
|
||||
category: 'database',
|
||||
logo: 'https://cdn.simpleicons.org/redis',
|
||||
configRaw: '{"runtime":"dragonfly"}',
|
||||
variablesRaw: '[]',
|
||||
isOfficial: true,
|
||||
},
|
||||
];
|
||||
|
||||
const demoTemplateDetails: Record<string, TemplateDetailEntity> = {
|
||||
'tpl-react': {
|
||||
template: demoTemplates[0],
|
||||
config: {
|
||||
type: 'web',
|
||||
runtime: 'node',
|
||||
buildCommand: 'npm install && npm run build',
|
||||
startCommand: 'npx serve -s dist',
|
||||
port: 3000,
|
||||
healthCheck: '/health',
|
||||
environment: {},
|
||||
nixpacksConfig: {},
|
||||
},
|
||||
variables: [
|
||||
{
|
||||
key: 'VITE_API_URL',
|
||||
label: 'API URL',
|
||||
defaultValue: 'https://api.example.com',
|
||||
required: true,
|
||||
secret: false,
|
||||
description: 'Public API endpoint for frontend calls',
|
||||
},
|
||||
],
|
||||
},
|
||||
'tpl-go': {
|
||||
template: demoTemplates[1],
|
||||
config: {
|
||||
type: 'web',
|
||||
runtime: 'go',
|
||||
buildCommand: 'go build -o app .',
|
||||
startCommand: './app',
|
||||
port: 8080,
|
||||
healthCheck: '/health',
|
||||
environment: {},
|
||||
nixpacksConfig: {},
|
||||
},
|
||||
variables: [
|
||||
{
|
||||
key: 'GO_ENV',
|
||||
label: 'Go Environment',
|
||||
defaultValue: 'production',
|
||||
required: false,
|
||||
secret: false,
|
||||
description: 'Runtime environment value',
|
||||
},
|
||||
],
|
||||
},
|
||||
'tpl-postgres': {
|
||||
template: demoTemplates[2],
|
||||
config: {
|
||||
type: 'database',
|
||||
runtime: 'postgres',
|
||||
buildCommand: '',
|
||||
startCommand: '',
|
||||
port: 5432,
|
||||
healthCheck: '',
|
||||
environment: {},
|
||||
nixpacksConfig: {},
|
||||
},
|
||||
variables: [
|
||||
{
|
||||
key: 'POSTGRES_USER',
|
||||
label: 'Username',
|
||||
defaultValue: 'postgres',
|
||||
required: true,
|
||||
secret: false,
|
||||
description: 'Database user',
|
||||
},
|
||||
{
|
||||
key: 'POSTGRES_PASSWORD',
|
||||
label: 'Password',
|
||||
defaultValue: '',
|
||||
required: true,
|
||||
secret: true,
|
||||
description: 'Database password',
|
||||
},
|
||||
],
|
||||
},
|
||||
'tpl-mysql': {
|
||||
template: demoTemplates[3],
|
||||
config: {
|
||||
type: 'database',
|
||||
runtime: 'mysql',
|
||||
buildCommand: '',
|
||||
startCommand: '',
|
||||
port: 3306,
|
||||
healthCheck: '',
|
||||
environment: {},
|
||||
nixpacksConfig: {},
|
||||
},
|
||||
variables: [
|
||||
{
|
||||
key: 'MYSQL_DATABASE',
|
||||
label: 'Database Name',
|
||||
defaultValue: 'app',
|
||||
required: true,
|
||||
secret: false,
|
||||
description: 'Initial database to create',
|
||||
},
|
||||
{
|
||||
key: 'MYSQL_USER',
|
||||
label: 'Username',
|
||||
defaultValue: 'app',
|
||||
required: true,
|
||||
secret: false,
|
||||
description: 'Application DB user',
|
||||
},
|
||||
{
|
||||
key: 'MYSQL_PASSWORD',
|
||||
label: 'User Password',
|
||||
defaultValue: '',
|
||||
required: true,
|
||||
secret: true,
|
||||
description: 'Application DB password',
|
||||
},
|
||||
{
|
||||
key: 'MYSQL_ROOT_PASSWORD',
|
||||
label: 'Root Password',
|
||||
defaultValue: '',
|
||||
required: true,
|
||||
secret: true,
|
||||
description: 'Root account password',
|
||||
},
|
||||
],
|
||||
},
|
||||
'tpl-mariadb': {
|
||||
template: demoTemplates[4],
|
||||
config: {
|
||||
type: 'database',
|
||||
runtime: 'mariadb',
|
||||
buildCommand: '',
|
||||
startCommand: '',
|
||||
port: 3306,
|
||||
healthCheck: '',
|
||||
environment: {},
|
||||
nixpacksConfig: {},
|
||||
},
|
||||
variables: [
|
||||
{
|
||||
key: 'MARIADB_DATABASE',
|
||||
label: 'Database Name',
|
||||
defaultValue: 'app',
|
||||
required: true,
|
||||
secret: false,
|
||||
description: 'Initial database to create',
|
||||
},
|
||||
{
|
||||
key: 'MARIADB_USER',
|
||||
label: 'Username',
|
||||
defaultValue: 'app',
|
||||
required: true,
|
||||
secret: false,
|
||||
description: 'Application DB user',
|
||||
},
|
||||
{
|
||||
key: 'MARIADB_PASSWORD',
|
||||
label: 'User Password',
|
||||
defaultValue: '',
|
||||
required: true,
|
||||
secret: true,
|
||||
description: 'Application DB password',
|
||||
},
|
||||
{
|
||||
key: 'MARIADB_ROOT_PASSWORD',
|
||||
label: 'Root Password',
|
||||
defaultValue: '',
|
||||
required: true,
|
||||
secret: true,
|
||||
description: 'Root account password',
|
||||
},
|
||||
],
|
||||
},
|
||||
'tpl-clickhouse': {
|
||||
template: demoTemplates[5],
|
||||
config: {
|
||||
type: 'database',
|
||||
runtime: 'clickhouse',
|
||||
buildCommand: '',
|
||||
startCommand: '',
|
||||
port: 8123,
|
||||
healthCheck: '',
|
||||
environment: {},
|
||||
nixpacksConfig: {},
|
||||
},
|
||||
variables: [
|
||||
{
|
||||
key: 'CLICKHOUSE_DB',
|
||||
label: 'Database Name',
|
||||
defaultValue: 'app',
|
||||
required: false,
|
||||
secret: false,
|
||||
description: 'Default database name',
|
||||
},
|
||||
{
|
||||
key: 'CLICKHOUSE_USER',
|
||||
label: 'Username',
|
||||
defaultValue: 'default',
|
||||
required: false,
|
||||
secret: false,
|
||||
description: 'ClickHouse user',
|
||||
},
|
||||
{
|
||||
key: 'CLICKHOUSE_PASSWORD',
|
||||
label: 'Password',
|
||||
defaultValue: '',
|
||||
required: false,
|
||||
secret: true,
|
||||
description: 'ClickHouse password',
|
||||
},
|
||||
],
|
||||
},
|
||||
'tpl-dragonfly': {
|
||||
template: demoTemplates[6],
|
||||
config: {
|
||||
type: 'database',
|
||||
runtime: 'dragonfly',
|
||||
buildCommand: '',
|
||||
startCommand: '',
|
||||
port: 6379,
|
||||
healthCheck: '',
|
||||
environment: {},
|
||||
nixpacksConfig: {},
|
||||
},
|
||||
variables: [
|
||||
{
|
||||
key: 'DRAGONFLY_PASSWORD',
|
||||
label: 'Password',
|
||||
defaultValue: '',
|
||||
required: false,
|
||||
secret: true,
|
||||
description: 'Optional Redis-compatible password',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const demoProjects = [
|
||||
{ id: 'project-demo', name: 'Demo Project' },
|
||||
{ id: 'project-internal', name: 'Internal Tooling' },
|
||||
];
|
||||
|
||||
function toServiceName(value: string): string {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
@@ -354,6 +52,15 @@ function categoryLabel(category: string): string {
|
||||
return category ? category[0].toUpperCase() + category.slice(1) : 'Uncategorized';
|
||||
}
|
||||
|
||||
function safeTemplateRuntime(configRaw: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(configRaw || '{}') as { runtime?: string };
|
||||
return parsed.runtime || 'n/a';
|
||||
} catch {
|
||||
return 'n/a';
|
||||
}
|
||||
}
|
||||
|
||||
function categoryIcon(category: string): typeof Box {
|
||||
switch (category) {
|
||||
case 'frontend':
|
||||
@@ -369,14 +76,72 @@ function categoryIcon(category: string): typeof Box {
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_TEMPLATE_JSON = JSON.stringify(
|
||||
{
|
||||
name: 'my-template',
|
||||
description: 'What this template deploys.',
|
||||
category: 'app',
|
||||
logo: '',
|
||||
config: {
|
||||
type: 'web',
|
||||
runtime: 'node',
|
||||
build_command: '',
|
||||
start_command: '',
|
||||
port: 3000,
|
||||
health_check: '/health',
|
||||
environment: {},
|
||||
},
|
||||
variables: [
|
||||
{
|
||||
key: 'EXAMPLE_VAR',
|
||||
label: 'Example variable',
|
||||
default: '',
|
||||
required: false,
|
||||
secret: false,
|
||||
description: 'Shown at deploy time',
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
export function TemplatesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams] = useSearchParams();
|
||||
const isDemoMode = searchParams.get('demo') === '1';
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const isDemoMode = useDemoMode();
|
||||
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['current-profile'],
|
||||
queryFn: getCurrentUserProfile,
|
||||
enabled: !isDemoMode && Boolean(sessionQuery.data),
|
||||
retry: false,
|
||||
});
|
||||
const myUserId = profileQuery.data?.id ?? '';
|
||||
const isAdmin = Boolean(profileQuery.data?.isAdmin);
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorJson, setEditorJson] = useState(EMPTY_TEMPLATE_JSON);
|
||||
const [editingTemplate, setEditingTemplate] = useState<TemplateEntity | null>(null);
|
||||
const [editorIsPublic, setEditorIsPublic] = useState(false);
|
||||
const [editorError, setEditorError] = useState<string | null>(null);
|
||||
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [selectedTemplateIdState, setSelectedTemplateId] = useState<string | null>(null);
|
||||
// Selection lives in the URL (?template=<id>) so catalog deep links and
|
||||
// card clicks share one source of truth.
|
||||
const templateParam = searchParams.get('template');
|
||||
const selectTemplate = (id: string | null) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (id) {
|
||||
next.set('template', id);
|
||||
} else {
|
||||
next.delete('template');
|
||||
}
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
const [deployProjectIdState, setDeployProjectId] = useState(searchParams.get('project') ?? '');
|
||||
const [deployNameByTemplate, setDeployNameByTemplate] = useState<Record<string, string>>({});
|
||||
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
|
||||
@@ -397,7 +162,7 @@ export function TemplatesPage() {
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: ['template-projects'],
|
||||
enabled: !isDemoMode,
|
||||
queryFn: listProjects,
|
||||
queryFn: () => listProjects(),
|
||||
});
|
||||
|
||||
const templates = useMemo(
|
||||
@@ -434,11 +199,11 @@ export function TemplatesPage() {
|
||||
if (filteredTemplates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (selectedTemplateIdState && filteredTemplates.some((template) => template.id === selectedTemplateIdState)) {
|
||||
return selectedTemplateIdState;
|
||||
if (templateParam && filteredTemplates.some((template) => template.id === templateParam)) {
|
||||
return templateParam;
|
||||
}
|
||||
return filteredTemplates[0].id;
|
||||
}, [filteredTemplates, selectedTemplateIdState]);
|
||||
}, [filteredTemplates, templateParam]);
|
||||
|
||||
const templateDetailQuery = useQuery({
|
||||
queryKey: ['template-detail-page', selectedTemplateId],
|
||||
@@ -553,6 +318,112 @@ export function TemplatesPage() {
|
||||
missingRequiredVariables.length > 0 ||
|
||||
deployMutation.isPending;
|
||||
|
||||
const canManageTemplate = (template: TemplateEntity) =>
|
||||
!template.isOfficial && Boolean(template.ownerId) && (template.ownerId === myUserId || isAdmin);
|
||||
|
||||
const saveTemplateMutation = useMutation({
|
||||
mutationFn: (input: TemplateWriteInput) =>
|
||||
editingTemplate ? updateTemplate(editingTemplate.id, input) : createTemplate(input),
|
||||
onSuccess: (template) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['templates-page'] });
|
||||
setEditorOpen(false);
|
||||
setEditingTemplate(null);
|
||||
setEditorError(null);
|
||||
selectTemplate(template.id);
|
||||
},
|
||||
onError: (error) => {
|
||||
setEditorError(error instanceof Error ? error.message : 'Failed to save template');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteTemplate(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['templates-page'] });
|
||||
selectTemplate(null);
|
||||
},
|
||||
});
|
||||
|
||||
const openCreateEditor = () => {
|
||||
setEditingTemplate(null);
|
||||
setEditorJson(EMPTY_TEMPLATE_JSON);
|
||||
setEditorIsPublic(false);
|
||||
setEditorError(null);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const openEditEditor = (template: TemplateEntity) => {
|
||||
let config: unknown = {};
|
||||
let variables: unknown = [];
|
||||
try {
|
||||
config = JSON.parse(template.configRaw || '{}');
|
||||
} catch {
|
||||
/* keep {} */
|
||||
}
|
||||
try {
|
||||
variables = JSON.parse(template.variablesRaw || '[]');
|
||||
} catch {
|
||||
/* keep [] */
|
||||
}
|
||||
setEditingTemplate(template);
|
||||
setEditorJson(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
category: template.category,
|
||||
logo: template.logo,
|
||||
config,
|
||||
variables,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
setEditorIsPublic(template.isPublic);
|
||||
setEditorError(null);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const submitEditor = () => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(editorJson);
|
||||
} catch (err) {
|
||||
setEditorError(`Invalid JSON: ${err instanceof Error ? err.message : 'parse error'}`);
|
||||
return;
|
||||
}
|
||||
const body = parsed as Partial<TemplateWriteInput>;
|
||||
if (!body || typeof body !== 'object' || !body.name || typeof body.config !== 'object' || body.config === null) {
|
||||
setEditorError('Template JSON needs at least "name" and a "config" object.');
|
||||
return;
|
||||
}
|
||||
saveTemplateMutation.mutate({
|
||||
name: String(body.name),
|
||||
description: body.description ?? '',
|
||||
category: body.category ?? 'other',
|
||||
logo: body.logo ?? '',
|
||||
config: body.config as Record<string, unknown>,
|
||||
variables: Array.isArray(body.variables) ? body.variables : [],
|
||||
isPublic: editorIsPublic,
|
||||
});
|
||||
};
|
||||
|
||||
const uploadJsonFile = (file: File) => {
|
||||
if (!file.name.toLowerCase().endsWith('.json') && file.type !== 'application/json') {
|
||||
setEditorError('Only .json files are accepted.');
|
||||
return;
|
||||
}
|
||||
file
|
||||
.text()
|
||||
.then((text) => {
|
||||
JSON.parse(text);
|
||||
setEditorJson(text);
|
||||
setEditorError(null);
|
||||
})
|
||||
.catch(() => setEditorError('File is not valid JSON.'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
@@ -563,9 +434,18 @@ export function TemplatesPage() {
|
||||
<h1 className="v-title">Template Catalog<span className="v-cursor">_</span></h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Deploy services from pre-configured templates</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--text-tertiary)]">
|
||||
<div className="flex items-center gap-3 text-sm text-[var(--text-tertiary)]">
|
||||
<Layers size={16} />
|
||||
<span>{filteredTemplates.length} templates</span>
|
||||
{signedIn && !isDemoMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateEditor}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 text-[12.5px] font-semibold text-[var(--accent-on)]"
|
||||
>
|
||||
<Plus size={14} /> New template
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -661,7 +541,7 @@ export function TemplatesPage() {
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={() => setSelectedTemplateId(template.id)}
|
||||
onClick={() => selectTemplate(template.id)}
|
||||
className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${
|
||||
selected
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
|
||||
@@ -685,12 +565,21 @@ export function TemplatesPage() {
|
||||
<p className={`text-sm font-semibold tracking-tight ${selected ? 'text-[var(--accent-primary)]' : 'text-[var(--text-primary)]'}`}>
|
||||
{template.name}
|
||||
</p>
|
||||
{template.isOfficial && (
|
||||
{template.isOfficial ? (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-[var(--warning-soft)] text-[var(--warning)] text-[10px] font-semibold">
|
||||
<Star size={8} className="fill-[var(--warning)]" />
|
||||
Official
|
||||
</span>
|
||||
)}
|
||||
) : template.ownerId === myUserId ? (
|
||||
<span className="px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] text-[10px] font-semibold">
|
||||
Mine
|
||||
</span>
|
||||
) : null}
|
||||
{!template.isOfficial && template.isPublic ? (
|
||||
<span className="px-1.5 py-0.5 rounded bg-[var(--info-soft)] text-[var(--info)] text-[10px] font-semibold">
|
||||
Public
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span
|
||||
@@ -700,7 +589,7 @@ export function TemplatesPage() {
|
||||
{categoryLabel(template.category)}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--text-tertiary)]">•</span>
|
||||
<span className="text-[10px] text-[var(--text-tertiary)]">{template.configRaw ? JSON.parse(template.configRaw).runtime : 'n/a'}</span>
|
||||
<span className="text-[10px] text-[var(--text-tertiary)]">{safeTemplateRuntime(template.configRaw)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-2 line-clamp-2 group-hover:text-[var(--text-secondary)] transition-colors">{template.description}</p>
|
||||
</div>
|
||||
@@ -766,6 +655,11 @@ export function TemplatesPage() {
|
||||
Official
|
||||
</span>
|
||||
)}
|
||||
{!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-[var(--info-soft)] text-[var(--info)] text-xs font-medium">
|
||||
Public
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">{selectedDetail.template.description}</p>
|
||||
</div>
|
||||
@@ -777,6 +671,25 @@ export function TemplatesPage() {
|
||||
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]">
|
||||
{selectedDetail.config.runtime || 'n/a'}
|
||||
</span>
|
||||
{canManageTemplate(selectedDetail.template) ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditEditor(selectedDetail.template)}
|
||||
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-2.5 py-1 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<Pencil size={11} /> Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteMutation.mutate(selectedDetail.template.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-2.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={11} /> Delete
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -824,6 +737,20 @@ export function TemplatesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!signedIn ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-5 text-center">
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Sign in to deploy this template into one of your projects.
|
||||
</p>
|
||||
<Link
|
||||
to="/auth/sign-in"
|
||||
className="mt-3 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12.5px] font-semibold text-[var(--accent-on)]"
|
||||
>
|
||||
Sign in <ArrowRight size={13} />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
@@ -951,12 +878,94 @@ export function TemplatesPage() {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template editor — JSON only */}
|
||||
{editorOpen ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-subtle)] px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">
|
||||
{editingTemplate ? `Edit ${editingTemplate.name}` : 'New template'}
|
||||
</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
JSON definition — name, category, config and variables.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
|
||||
<Upload size={12} /> Upload .json
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) uploadJsonFile(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorOpen(false)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={editorJson}
|
||||
onChange={(e) => setEditorJson(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
|
||||
/>
|
||||
{editorError ? (
|
||||
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
|
||||
{editorError}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between border-t border-[var(--border-subtle)] px-5 py-3.5">
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 text-[11px] text-[var(--text-secondary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editorIsPublic}
|
||||
onChange={(e) => setEditorIsPublic(e.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-[var(--accent-primary)]"
|
||||
/>
|
||||
List in the public catalog - anyone browsing this instance can see and deploy it.
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorOpen(false)}
|
||||
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitEditor}
|
||||
disabled={saveTemplateMutation.isPending}
|
||||
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
>
|
||||
{saveTemplateMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||
{editingTemplate ? 'Save changes' : 'Create template'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import { serviceIcon, serviceIconUrl, serviceTypeIcon, type ServiceIconInput } from './service-visuals';
|
||||
|
||||
export function ServiceIcon({
|
||||
service,
|
||||
size = 16,
|
||||
}: {
|
||||
service: ServiceIconInput;
|
||||
size?: number;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const brand = serviceIcon(service);
|
||||
if (brand && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={serviceIconUrl(brand)}
|
||||
width={size}
|
||||
height={size}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return serviceTypeIcon(service.type ?? 'service', size);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { ServiceEntity } from '@/lib/api-client';
|
||||
import type { ServiceVariable } from '../auto-connections';
|
||||
import { inferAutoConnections } from '../auto-connections';
|
||||
|
||||
export const NODE_WIDTH = 240;
|
||||
export const NODE_HEIGHT = 104;
|
||||
// Multiples of the 26px canvas grid so laid-out nodes land on the lattice.
|
||||
export const COL_GAP = 338;
|
||||
export const ROW_GAP = 182;
|
||||
|
||||
type LinkEdge = { sourceServiceId: string; targetServiceId: string };
|
||||
|
||||
// Layered left-to-right layout: a service's depth is the longest chain of
|
||||
// dependencies beneath it, so consumers sit left and datastores right.
|
||||
// Unconnected services get their own trailing column separated by a gap.
|
||||
export function computeLayeredLayout(
|
||||
services: ServiceEntity[],
|
||||
edges: LinkEdge[],
|
||||
): Map<string, { x: number; y: number }> {
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
if (services.length === 0) {
|
||||
return positions;
|
||||
}
|
||||
|
||||
const ids = new Set(services.map((service) => service.id));
|
||||
const outgoing = new Map<string, string[]>();
|
||||
const connected = new Set<string>();
|
||||
|
||||
for (const edge of edges) {
|
||||
if (!ids.has(edge.sourceServiceId) || !ids.has(edge.targetServiceId)) {
|
||||
continue;
|
||||
}
|
||||
connected.add(edge.sourceServiceId);
|
||||
connected.add(edge.targetServiceId);
|
||||
const list = outgoing.get(edge.sourceServiceId) ?? [];
|
||||
list.push(edge.targetServiceId);
|
||||
outgoing.set(edge.sourceServiceId, list);
|
||||
}
|
||||
|
||||
const depthMemo = new Map<string, number>();
|
||||
const depthOf = (id: string, trail: Set<string>): number => {
|
||||
const memoized = depthMemo.get(id);
|
||||
if (memoized !== undefined) {
|
||||
return memoized;
|
||||
}
|
||||
if (trail.has(id)) {
|
||||
return 0; // cycle guard
|
||||
}
|
||||
trail.add(id);
|
||||
let best = 0;
|
||||
for (const target of outgoing.get(id) ?? []) {
|
||||
best = Math.max(best, depthOf(target, trail) + 1);
|
||||
}
|
||||
trail.delete(id);
|
||||
depthMemo.set(id, best);
|
||||
return best;
|
||||
};
|
||||
|
||||
const connectedServices = services
|
||||
.filter((service) => connected.has(service.id))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const looseServices = services
|
||||
.filter((service) => !connected.has(service.id))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const depthById = new Map(connectedServices.map((service) => [service.id, depthOf(service.id, new Set())]));
|
||||
const maxDepth = Math.max(0, ...depthById.values());
|
||||
|
||||
// Column = maxDepth - depth so deeper consumers render further left.
|
||||
const columns = new Map<number, ServiceEntity[]>();
|
||||
for (const service of connectedServices) {
|
||||
const col = maxDepth - (depthById.get(service.id) ?? 0);
|
||||
const list = columns.get(col) ?? [];
|
||||
list.push(service);
|
||||
columns.set(col, list);
|
||||
}
|
||||
|
||||
const connectedColCount = columns.size;
|
||||
const maxRows = Math.max(
|
||||
1,
|
||||
...Array.from(columns.values()).map((list) => list.length),
|
||||
looseServices.length,
|
||||
);
|
||||
|
||||
const gridOffset = (value: number) => Math.round(value / 26) * 26;
|
||||
|
||||
for (const [col, list] of columns) {
|
||||
const offsetY = gridOffset(((maxRows - list.length) * ROW_GAP) / 2);
|
||||
list.forEach((service, row) => {
|
||||
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
|
||||
});
|
||||
}
|
||||
|
||||
looseServices.forEach((service, row) => {
|
||||
const col = connectedColCount > 0 ? connectedColCount : 0;
|
||||
const offsetY = gridOffset(((maxRows - looseServices.length) * ROW_GAP) / 2);
|
||||
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
|
||||
});
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function layoutFromVariables(
|
||||
services: ServiceEntity[],
|
||||
variablesByService: Record<string, ServiceVariable[]>,
|
||||
): Map<string, { x: number; y: number }> {
|
||||
const links = inferAutoConnections(services, variablesByService);
|
||||
return computeLayeredLayout(
|
||||
services,
|
||||
links.map((link) => link.edge),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import type { ServiceEntity } from '@/lib/api-client';
|
||||
import { serviceStatusClass } from '@/lib/api-client';
|
||||
import type { ServiceVariable } from '../auto-connections';
|
||||
import { ServiceIcon } from './ServiceIcon';
|
||||
import { serviceAccent } from './service-visuals';
|
||||
import { formatRelative } from '@/lib/time';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Copy,
|
||||
Globe,
|
||||
KeyRound,
|
||||
Layers,
|
||||
Link2,
|
||||
Loader2,
|
||||
Play,
|
||||
Rocket,
|
||||
Square,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type InspectorGroup = { id: string; title: string };
|
||||
export type InspectorConnection = {
|
||||
id: string;
|
||||
outbound: boolean;
|
||||
peer: string;
|
||||
peerId: string;
|
||||
reasons: string[];
|
||||
};
|
||||
|
||||
export type InspectorActions = {
|
||||
deploy: () => void;
|
||||
start: () => void;
|
||||
restart: () => void;
|
||||
stop: () => void;
|
||||
remove: () => void;
|
||||
pending: boolean;
|
||||
deployPending: boolean;
|
||||
restartPending: boolean;
|
||||
};
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'Online';
|
||||
case 'deployed':
|
||||
return 'Deployed';
|
||||
case 'building':
|
||||
return 'Building';
|
||||
case 'deploying':
|
||||
return 'Deploying';
|
||||
case 'pending':
|
||||
return 'Pending';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
case 'stopped':
|
||||
return 'Stopped';
|
||||
case 'rolling_back':
|
||||
return 'Rolling back';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
case 'deployed':
|
||||
return 'var(--success)';
|
||||
case 'failed':
|
||||
return 'var(--error)';
|
||||
case 'building':
|
||||
case 'deploying':
|
||||
case 'pending':
|
||||
case 'rolling_back':
|
||||
return 'var(--warning)';
|
||||
default:
|
||||
return 'var(--text-tertiary)';
|
||||
}
|
||||
}
|
||||
|
||||
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{title}</p>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLink({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="text-[10px] text-[var(--accent-primary)] hover:underline flex items-center gap-0.5"
|
||||
>
|
||||
{children}
|
||||
<ArrowUpRight size={9} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServiceInspector({
|
||||
service,
|
||||
variables,
|
||||
connections,
|
||||
groups,
|
||||
groupId,
|
||||
readOnly,
|
||||
actions,
|
||||
onClose,
|
||||
onOpenService,
|
||||
onOpenSection,
|
||||
onSelectPeer,
|
||||
onAssignGroup,
|
||||
onNewGroupFor,
|
||||
}: {
|
||||
service: ServiceEntity;
|
||||
variables: ServiceVariable[];
|
||||
connections: InspectorConnection[];
|
||||
groups: InspectorGroup[];
|
||||
groupId: string | undefined;
|
||||
readOnly?: boolean;
|
||||
actions: InspectorActions;
|
||||
onClose: () => void;
|
||||
onOpenService: (serviceId: string) => void;
|
||||
onOpenSection: (serviceId: string, section: string) => void;
|
||||
onSelectPeer: (serviceId: string) => void;
|
||||
onAssignGroup: (serviceId: string, groupId: string | null) => void;
|
||||
onNewGroupFor: (serviceId: string) => void;
|
||||
}) {
|
||||
const accent = serviceAccent(service);
|
||||
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyAddress = () => {
|
||||
const text = publicUrl ?? (service.port ? `${service.name}:${service.port}` : service.name);
|
||||
void navigator.clipboard?.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
});
|
||||
};
|
||||
|
||||
const configRows: [string, string | undefined][] = [
|
||||
['Image', service.image],
|
||||
['Command', service.command],
|
||||
['Port', service.port ? String(service.port) : undefined],
|
||||
['Domain', service.domain],
|
||||
['Git repo', service.gitRepo],
|
||||
['Branch', service.gitBranch],
|
||||
['Build path', service.buildPath],
|
||||
['CPU', service.cpu],
|
||||
['Memory', service.memory],
|
||||
['Restart', service.restartPolicy],
|
||||
['Healthcheck', service.healthcheckPath],
|
||||
];
|
||||
const visibleConfig = configRows.filter(([, value]) => value);
|
||||
|
||||
return (
|
||||
<aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[312px] panel-glass flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-4 pt-4 pb-3 border-b border-[var(--border-subtle)]">
|
||||
<div
|
||||
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${accent}1c`, color: accent }}
|
||||
>
|
||||
<ServiceIcon service={service} size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{service.name}</h3>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
|
||||
<span style={{ color: statusColor(service.status) }}>{statusLabel(service.status)}</span>
|
||||
<span>· {service.type}</span>
|
||||
{service.environment && service.environment !== 'production' && <span>· {service.environment}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
|
||||
{(publicUrl || service.port) && (
|
||||
<Section title="Address">
|
||||
<div className="flex items-center gap-1.5 group">
|
||||
<Globe size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
{publicUrl ? (
|
||||
<a
|
||||
href={publicUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
|
||||
>
|
||||
{publicUrl.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
) : (
|
||||
<span className="mono text-[11px] text-[var(--text-secondary)]">
|
||||
{service.name}:{service.port}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyAddress}
|
||||
className="ml-auto text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||
title="Copy address"
|
||||
>
|
||||
{copied ? <span className="text-[10px] text-[var(--success)]">Copied</span> : <Copy size={11} />}
|
||||
</button>
|
||||
</div>
|
||||
{publicUrl && service.port ? (
|
||||
<p className="mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5 pl-4">
|
||||
{service.name}:{service.port}
|
||||
</p>
|
||||
) : null}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Group">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
<select
|
||||
value={groupId ?? ''}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
if (next === '__new__') {
|
||||
onNewGroupFor(service.id);
|
||||
return;
|
||||
}
|
||||
onAssignGroup(service.id, next === '' ? null : next);
|
||||
}}
|
||||
className="nodrag flex-1 h-7 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-[11px] text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]"
|
||||
>
|
||||
<option value="">Ungrouped</option>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.title}
|
||||
</option>
|
||||
))}
|
||||
<option value="__new__">+ New group…</option>
|
||||
</select>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={`Variables (${variables.length})`}
|
||||
action={
|
||||
<SectionLink onClick={() => onOpenSection(service.id, 'variables')}>
|
||||
Manage
|
||||
</SectionLink>
|
||||
}
|
||||
>
|
||||
{variables.length === 0 ? (
|
||||
<p className="text-[11px] text-[var(--text-muted)]">No variables yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{variables.slice(0, 6).map((variable) => (
|
||||
<div
|
||||
key={variable.key}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/50"
|
||||
>
|
||||
<KeyRound size={9} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
<span className="mono text-[10px] text-[var(--text-primary)] truncate font-medium">
|
||||
{variable.key}
|
||||
</span>
|
||||
<span className="mono text-[10px] text-[var(--text-tertiary)] truncate ml-auto max-w-[130px]" title={variable.isSecret ? 'Secret' : variable.value}>
|
||||
{variable.isSecret ? '••••••••' : variable.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{variables.length > 6 && (
|
||||
<p className="text-[10px] text-[var(--text-muted)] pl-1">+{variables.length - 6} more</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Config"
|
||||
action={
|
||||
<SectionLink onClick={() => onOpenSection(service.id, 'config')}>
|
||||
Edit
|
||||
</SectionLink>
|
||||
}
|
||||
>
|
||||
{visibleConfig.length === 0 ? (
|
||||
<p className="text-[11px] text-[var(--text-muted)]">No configuration set.</p>
|
||||
) : (
|
||||
<dl className="space-y-1">
|
||||
{visibleConfig.map(([label, value]) => (
|
||||
<div key={label} className="flex items-baseline gap-2">
|
||||
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">{label}</dt>
|
||||
<dd className="mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
{(service.replicas ?? 0) > 0 && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">Replicas</dt>
|
||||
<dd className="mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={`Connections (${connections.length})`}>
|
||||
{connections.length === 0 ? (
|
||||
<p className="text-[11px] text-[var(--text-muted)]">
|
||||
No inferred connections. Reference another service via{' '}
|
||||
<span className="mono">{'{{variable}}'}</span> placeholders in env vars.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{connections.map((conn) => (
|
||||
<button
|
||||
key={conn.id}
|
||||
type="button"
|
||||
onClick={() => onSelectPeer(conn.peerId)}
|
||||
className="w-full text-left px-2.5 py-2 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 hover:bg-[var(--surface-muted)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
|
||||
<Link2 size={10} className="text-[var(--accent-primary)] flex-shrink-0" />
|
||||
<span className="truncate font-medium">{conn.outbound ? `→ ${conn.peer}` : `← ${conn.peer}`}</span>
|
||||
</div>
|
||||
<p className="mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4">
|
||||
{conn.reasons.join(' · ')}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{service.updatedAt && (
|
||||
<p className="text-[10px] text-[var(--text-muted)] pt-1">
|
||||
Updated {formatRelative(service.updatedAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenService(service.id)}
|
||||
className="w-full flex items-center justify-center gap-2 h-9 rounded-[var(--radius-md)] text-[var(--accent-on)] text-[13px] font-medium"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<ArrowUpRight size={14} />
|
||||
Open service
|
||||
</button>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={actions.pending || readOnly}
|
||||
onClick={actions.deploy}
|
||||
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
{actions.deployPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
|
||||
Deploy
|
||||
</button>
|
||||
{service.status !== 'running' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={actions.pending || readOnly}
|
||||
onClick={actions.start}
|
||||
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Play size={11} />
|
||||
Start
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={actions.pending || readOnly}
|
||||
onClick={actions.restart}
|
||||
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
{actions.restartPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
|
||||
Restart
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={actions.pending || readOnly || service.status !== 'running'}
|
||||
onClick={actions.stop}
|
||||
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Square size={11} />
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actions.pending || readOnly}
|
||||
onClick={actions.remove}
|
||||
className="w-full flex items-center justify-center gap-1.5 h-8 rounded-[var(--radius-sm)] text-[11px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
Delete service
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
|
||||
import type { ServiceEntity } from '@/lib/api-client';
|
||||
import { serviceStatusClass } from '@/lib/api-client';
|
||||
import { Box, ArrowUpRight, Globe, Database, Terminal, MoreHorizontal, ExternalLink } from 'lucide-react';
|
||||
import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { serviceIcon, serviceAccent } from './service-visuals';
|
||||
import { ServiceIcon } from './ServiceIcon';
|
||||
|
||||
export type ServiceNodeData = {
|
||||
service: ServiceEntity;
|
||||
@@ -12,181 +14,236 @@ export type ServiceNodeData = {
|
||||
|
||||
export type GroupNodeData = {
|
||||
title: string;
|
||||
onRename?: (groupId: string, title: string) => void;
|
||||
renameNonce?: number;
|
||||
};
|
||||
|
||||
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
|
||||
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
|
||||
|
||||
function serviceTypeIcon(type: string): typeof Box {
|
||||
switch (type) {
|
||||
case 'web':
|
||||
return Globe;
|
||||
case 'database':
|
||||
return Database;
|
||||
case 'worker':
|
||||
return Terminal;
|
||||
function statusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'Online';
|
||||
case 'degraded':
|
||||
return 'Degraded';
|
||||
case 'building':
|
||||
return 'Deploying';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
default:
|
||||
return Box;
|
||||
return 'Stopped';
|
||||
}
|
||||
}
|
||||
|
||||
function serviceTypeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'web':
|
||||
return '#7ab8ff'; // Blue
|
||||
case 'database':
|
||||
return '#b4e34a'; // Purple
|
||||
case 'worker':
|
||||
return 'var(--accent-primary)'; // Pink
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'var(--success)';
|
||||
case 'degraded':
|
||||
case 'building':
|
||||
return 'var(--warning)';
|
||||
case 'failed':
|
||||
return 'var(--error)';
|
||||
default:
|
||||
return '#9295a4'; // Gray
|
||||
return 'var(--text-tertiary)';
|
||||
}
|
||||
}
|
||||
|
||||
const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']);
|
||||
|
||||
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
const { service, selected, onOpen } = data;
|
||||
const iconType = serviceTypeIcon(service.type);
|
||||
const typeColor = serviceTypeColor(service.type);
|
||||
const isRunning = service.status === 'running';
|
||||
const { service, selected } = data;
|
||||
const brand = serviceIcon(service);
|
||||
const typeColor = brand?.color ?? serviceAccent(service);
|
||||
const replicas = service.replicas ?? 0;
|
||||
const stateColor = statusColor(service.status);
|
||||
const isTransient = TRANSIENT_STATUSES.has(service.status);
|
||||
|
||||
// Generate a mock domain for display (in real app, this would come from service data)
|
||||
const domain = `${service.name}.containr.local`;
|
||||
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
|
||||
const internalAddr = service.port ? `${service.name}:${service.port}` : service.name;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-[var(--radius-lg)] border transition-all duration-200 group relative overflow-hidden ${
|
||||
className={`w-full rounded-[var(--radius-lg)] border transition-[border-color,box-shadow,transform] duration-150 group relative overflow-hidden ${
|
||||
selected
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
|
||||
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-lg'
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]'
|
||||
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-[0_12px_32px_rgba(0,0,0,0.45)] hover:-translate-y-px'
|
||||
}`}
|
||||
style={{ minWidth: 220, maxWidth: 280 }}
|
||||
>
|
||||
{/* Ambient overlay */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
|
||||
style={{ background: `${typeColor}10` }}
|
||||
style={{ background: `${typeColor}08` }}
|
||||
/>
|
||||
|
||||
{/* Invisible connection points - required by React Flow for edge attachment */}
|
||||
<Handle type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
|
||||
<Handle type="source" position={Position.Right} className="!opacity-0 !w-2 !h-2 !border-0" />
|
||||
<Handle id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
|
||||
<Handle id="t-r" type="target" position={Position.Right} className="!opacity-0 !w-2 !h-2 !border-0" />
|
||||
<Handle id="s-l" type="source" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
|
||||
<Handle id="s-r" type="source" position={Position.Right} className="!opacity-0 !w-2 !h-2 !border-0" />
|
||||
|
||||
{/* Status indicator bar at top */}
|
||||
<div
|
||||
className={`absolute top-0 left-0 right-0 h-0.5 transition-all duration-300 ${isRunning ? 'opacity-100' : 'opacity-40'}`}
|
||||
style={{ background: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
|
||||
className={`absolute top-0 left-0 right-0 h-0.5 transition-all duration-300 ${service.status === 'running' ? 'opacity-100' : 'opacity-40'}`}
|
||||
style={{ background: stateColor }}
|
||||
/>
|
||||
|
||||
{/* Action menu on hover */}
|
||||
<button className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-all duration-200 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] p-1.5 rounded-lg hover:bg-[var(--surface-muted)]">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
|
||||
<div className="relative p-4">
|
||||
{/* Header with icon and name */}
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="relative px-3.5 pt-3.5 pb-3">
|
||||
<div className="flex items-center gap-3 mb-2.5">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${
|
||||
selected ? 'ring-2 ring-white/20' : ''
|
||||
}`}
|
||||
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
|
||||
style={{
|
||||
background: selected ? typeColor : `${typeColor}20`,
|
||||
color: selected ? 'white' : typeColor
|
||||
background: selected && !brand ? typeColor : `${typeColor}1c`,
|
||||
color: selected && !brand ? 'var(--bg-void)' : typeColor,
|
||||
}}
|
||||
>
|
||||
{iconType === Globe && <Globe size={18} />}
|
||||
{iconType === Database && <Database size={18} />}
|
||||
{iconType === Terminal && <Terminal size={18} />}
|
||||
{iconType === Box && <Box size={18} />}
|
||||
{isTransient ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<ServiceIcon service={service} size={17} />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4>
|
||||
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5">{service.type}</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
|
||||
{service.name}
|
||||
</h4>
|
||||
<p className="text-[10.5px] text-[var(--text-tertiary)] truncate mt-0.5">
|
||||
{service.type}
|
||||
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Domain display - Railway style */}
|
||||
<div className="flex items-center gap-2 mb-3 px-2.5 py-1.5 rounded-lg bg-[var(--surface-muted)]/50 border border-[var(--border-subtle)]/50">
|
||||
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
<span className="text-[10px] text-[var(--text-secondary)] truncate mono">{domain}</span>
|
||||
</div>
|
||||
|
||||
{/* Status and action row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
|
||||
<span
|
||||
className="text-[11px] font-semibold tracking-wide uppercase"
|
||||
style={{ color: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
|
||||
>
|
||||
{isRunning ? 'Online' : service.status}
|
||||
{replicas > 1 && (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-semibold text-[var(--text-secondary)]">
|
||||
<Layers size={9} />
|
||||
{replicas}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2.5 px-2.5 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/60 border border-[var(--border-subtle)]/60 group/addr">
|
||||
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
{publicUrl ? (
|
||||
<a
|
||||
href={publicUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[10.5px] text-[var(--accent-primary)] truncate mono hover:underline"
|
||||
>
|
||||
{publicUrl.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[10.5px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
|
||||
{internalAddr}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(service.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-semibold transition-all duration-200 ${
|
||||
selected
|
||||
? 'bg-[var(--accent-primary)] text-[var(--accent-on)] shadow-lg shadow-[var(--accent-primary-glow)]'
|
||||
: 'border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]'
|
||||
}`}
|
||||
title="Copy internal address"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void navigator.clipboard?.writeText(internalAddr);
|
||||
}}
|
||||
className="ml-auto opacity-0 group-hover/addr:opacity-100 transition-opacity text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
Open
|
||||
<ArrowUpRight size={10} />
|
||||
<Copy size={10} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
|
||||
<span className="text-[10px] font-semibold tracking-[0.08em] uppercase" style={{ color: stateColor }}>
|
||||
{statusLabel(service.status)}
|
||||
</span>
|
||||
</div>
|
||||
{service.image && (
|
||||
<span className="text-[9.5px] text-[var(--text-muted)] truncate mono max-w-[110px]" title={service.image}>
|
||||
{service.image}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupNode({ data }: NodeProps<GroupNodeType>) {
|
||||
export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(data.title);
|
||||
|
||||
// Context-menu rename requests bump the nonce; enter edit mode when it
|
||||
// changes. Render-phase state adjustment — the React-sanctioned pattern.
|
||||
const [seenRenameNonce, setSeenRenameNonce] = useState(data.renameNonce ?? 0);
|
||||
const incomingNonce = data.renameNonce ?? 0;
|
||||
if (incomingNonce > seenRenameNonce) {
|
||||
setSeenRenameNonce(incomingNonce);
|
||||
setDraft(data.title);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
const commit = () => {
|
||||
const next = draft.trim() || data.title;
|
||||
setEditing(false);
|
||||
if (next !== data.title) {
|
||||
data.onRename?.(id, next);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden"
|
||||
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative"
|
||||
style={{
|
||||
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)',
|
||||
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30'
|
||||
borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)',
|
||||
background: isHovered ? 'var(--accent-primary-soft)' : 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Ambient glow on hover */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none"
|
||||
style={{
|
||||
background: 'rgba(232,49,106,0.03)',
|
||||
opacity: isHovered ? 1 : 0
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="relative p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
|
||||
style={{
|
||||
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
|
||||
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)'
|
||||
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
<Box size={14} />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3>
|
||||
</div>
|
||||
{isHovered && (
|
||||
<button className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--surface-muted)]">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commit();
|
||||
if (e.key === 'Escape') {
|
||||
setDraft(data.title);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
className="nodrag h-7 px-2 rounded-[var(--radius-sm)] border border-[var(--border-default)] bg-[var(--surface-card)] text-sm font-semibold text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<h3
|
||||
className="text-sm font-semibold text-[var(--text-primary)] tracking-tight"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDraft(data.title);
|
||||
setEditing(true);
|
||||
}}
|
||||
title="Double-click to rename"
|
||||
>
|
||||
{data.title}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Helper text */}
|
||||
<p className="text-[10px] text-[var(--text-tertiary)] mt-2 opacity-0 transition-opacity duration-200" style={{ opacity: isHovered ? 1 : 0 }}>
|
||||
<p
|
||||
className="text-[10px] text-[var(--text-tertiary)] mt-2 transition-opacity duration-200"
|
||||
style={{ opacity: isHovered ? 1 : 0 }}
|
||||
>
|
||||
Drag services here to group them
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Box, Globe, Database, Terminal, Clock } from 'lucide-react';
|
||||
|
||||
export function serviceTypeIcon(type: string, size = 18) {
|
||||
switch (type) {
|
||||
case 'web':
|
||||
return <Globe size={size} />;
|
||||
case 'database':
|
||||
return <Database size={size} />;
|
||||
case 'worker':
|
||||
return <Terminal size={size} />;
|
||||
case 'cron':
|
||||
return <Clock size={size} />;
|
||||
default:
|
||||
return <Box size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function serviceTypeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'web':
|
||||
return '#7ab8ff';
|
||||
case 'database':
|
||||
return '#b4e34a';
|
||||
case 'worker':
|
||||
return '#f2c94c';
|
||||
case 'cron':
|
||||
return '#f2994a';
|
||||
default:
|
||||
return '#9295a4';
|
||||
}
|
||||
}
|
||||
|
||||
export type ServiceBrandIcon = {
|
||||
slug: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const TECH_ICONS: (ServiceBrandIcon & { patterns: RegExp[] })[] = [
|
||||
{ slug: 'postgresql', color: '#7ab8ff', patterns: [/postgres/i, /postgis/i] },
|
||||
{ slug: 'redis', color: '#ff6b6b', patterns: [/redis/i, /dragonfly/i, /valkey/i, /keydb/i] },
|
||||
{ slug: 'mysql', color: '#5ba4e5', patterns: [/mysql/i] },
|
||||
{ slug: 'mariadb', color: '#4ea5ff', patterns: [/mariadb/i] },
|
||||
{ slug: 'mongodb', color: '#47a248', patterns: [/mongo/i] },
|
||||
{ slug: 'clickhouse', color: '#ffcc01', patterns: [/clickhouse/i] },
|
||||
{ slug: 'couchdb', color: '#e42528', patterns: [/couchdb/i] },
|
||||
{ slug: 'sqlite', color: '#69b7e4', patterns: [/sqlite/i] },
|
||||
{ slug: 'influxdb', color: '#22adf6', patterns: [/influx/i] },
|
||||
{ slug: 'elasticsearch', color: '#fec514', patterns: [/elastic/i, /opensearch/i] },
|
||||
{ slug: 'meilisearch', color: '#ff5caa', patterns: [/meili/i] },
|
||||
{ slug: 'typesense', color: '#d90368', patterns: [/typesense/i] },
|
||||
{ slug: 'qdrant', color: '#dc244c', patterns: [/qdrant/i] },
|
||||
{ slug: 'weaviate', color: '#01c9a7', patterns: [/weaviate/i] },
|
||||
{ slug: 'minio', color: '#c72e49', patterns: [/minio/i] },
|
||||
{ slug: 'rabbitmq', color: '#ff6600', patterns: [/rabbitmq/i, /rabbit/i] },
|
||||
{ slug: 'apachekafka', color: '#d6dae4', patterns: [/kafka/i] },
|
||||
{ slug: 'natsdotio', color: '#8be1d3', patterns: [/\bnats/i] },
|
||||
{ slug: 'nginx', color: '#009639', patterns: [/nginx/i] },
|
||||
{ slug: 'caddy', color: '#1f88c0', patterns: [/caddy/i] },
|
||||
{ slug: 'traefikproxy', color: '#24a1c1', patterns: [/traefik/i] },
|
||||
{ slug: 'nodedotjs', color: '#5fa04e', patterns: [/node(?=[:\-\d]|$)/i, /nodejs/i] },
|
||||
{ slug: 'go', color: '#00add8', patterns: [/golang/i, /(?:^|[^a-z])go(?=[:\-\d]|$)/i] },
|
||||
{ slug: 'python', color: '#6fa8dc', patterns: [/python/i, /\bpy(?:thon)?(?=[:\-\d]|$)/i] },
|
||||
{ slug: 'rust', color: '#f74c00', patterns: [/rust/i] },
|
||||
{ slug: 'php', color: '#8892bf', patterns: [/php/i] },
|
||||
{ slug: 'ruby', color: '#cc342d', patterns: [/ruby/i, /rails/i] },
|
||||
{ slug: 'openjdk', color: '#e6a23c', patterns: [/java/i, /openjdk/i, /jdk/i, /jre/i] },
|
||||
{ slug: 'dotnet', color: '#6f4bd8', patterns: [/dotnet/i, /aspnet/i] },
|
||||
{ slug: 'deno', color: '#70ffaf', patterns: [/\bdeno/i] },
|
||||
{ slug: 'bun', color: '#f9f1e1', patterns: [/bun(?=[:\-\d]|$)/i] },
|
||||
{ slug: 'react', color: '#61dafb', patterns: [/react/i] },
|
||||
{ slug: 'vuedotjs', color: '#4fc08d', patterns: [/vue/i] },
|
||||
{ slug: 'angular', color: '#dd0031', patterns: [/angular/i] },
|
||||
{ slug: 'svelte', color: '#ff3e00', patterns: [/svelte/i] },
|
||||
{ slug: 'nextdotjs', color: '#e6edf3', patterns: [/nextjs/i, /next\.js/i] },
|
||||
{ slug: 'nuxtdotjs', color: '#00dc82', patterns: [/nuxt/i] },
|
||||
{ slug: 'astro', color: '#ff5d01', patterns: [/astro/i] },
|
||||
{ slug: 'prometheus', color: '#e6522c', patterns: [/prometheus/i] },
|
||||
{ slug: 'grafana', color: '#f46800', patterns: [/grafana/i] },
|
||||
{ slug: 'uptimekuma', color: '#5cdd8b', patterns: [/uptime.?kuma/i, /\bkuma/i] },
|
||||
{ slug: 'n8n', color: '#ea4b71', patterns: [/n8n/i] },
|
||||
{ slug: 'wordpress', color: '#4b9cd3', patterns: [/wordpress/i, /\bwp-/i] },
|
||||
{ slug: 'ghost', color: '#a8b8c4', patterns: [/ghost/i] },
|
||||
{ slug: 'nextcloud', color: '#0082c9', patterns: [/nextcloud/i] },
|
||||
{ slug: 'immich', color: '#7c9ef5', patterns: [/immich/i] },
|
||||
{ slug: 'supabase', color: '#3fcf8e', patterns: [/supabase/i] },
|
||||
{ slug: 'pocketbase', color: '#b8dbe4', patterns: [/pocketbase/i] },
|
||||
{ slug: 'keycloak', color: '#d6dae4', patterns: [/keycloak/i] },
|
||||
{ slug: 'authentik', color: '#fd4b2d', patterns: [/authentik/i] },
|
||||
{ slug: 'portainer', color: '#13bef9', patterns: [/portainer/i] },
|
||||
{ slug: 'docker', color: '#2496ed', patterns: [/docker/i, /dind/i] },
|
||||
{ slug: 'kubernetes', color: '#326ce5', patterns: [/kubernetes/i, /\bk8s/i] },
|
||||
];
|
||||
|
||||
export type ServiceIconInput = { image?: string; name?: string; type?: string };
|
||||
|
||||
export function serviceIcon(service: ServiceIconInput): ServiceBrandIcon | null {
|
||||
const text = `${service.image ?? ''} ${service.name ?? ''}`.toLowerCase();
|
||||
for (const icon of TECH_ICONS) {
|
||||
if (icon.patterns.some((pattern) => pattern.test(text))) {
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function serviceIconUrl(icon: ServiceBrandIcon): string {
|
||||
return `https://cdn.simpleicons.org/${icon.slug}/${icon.color.replace('#', '')}`;
|
||||
}
|
||||
|
||||
export function serviceAccent(service: ServiceIconInput): string {
|
||||
return serviceIcon(service)?.color ?? serviceTypeColor(service.type ?? 'service');
|
||||
}
|
||||
@@ -44,14 +44,17 @@ export const DEFAULT_VIEWPORT: CanvasViewport = {
|
||||
zoom: 1,
|
||||
};
|
||||
|
||||
export function createDefaultCanvasMetadata(services: ServiceEntity[]): ProjectCanvasMetadata {
|
||||
export function createDefaultCanvasMetadata(
|
||||
services: ServiceEntity[],
|
||||
layout?: Map<string, CanvasPoint>,
|
||||
): ProjectCanvasMetadata {
|
||||
const nodes = services.map((service, index) => {
|
||||
const col = index % 3;
|
||||
const row = Math.floor(index / 3);
|
||||
|
||||
return {
|
||||
serviceId: service.id,
|
||||
position: {
|
||||
position: layout?.get(service.id) ?? {
|
||||
x: 70 + col * 260,
|
||||
y: 80 + row * 170,
|
||||
},
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type CreateServiceInput,
|
||||
} from '@/lib/api-client';
|
||||
import { getDemoProjectById, getDemoServicesByProject, getDemoVariablesByProject } from '@/lib/demo-data';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { formatDate, formatRelative } from '@/lib/time';
|
||||
import type { ServiceVariable } from '../auto-connections';
|
||||
import { ProjectCanvas } from '../canvas/ProjectCanvas';
|
||||
@@ -46,10 +48,10 @@ import {
|
||||
|
||||
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
|
||||
|
||||
const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutGrid }> = [
|
||||
const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutGrid; authOnly?: boolean }> = [
|
||||
{ key: 'canvas', label: 'Canvas', icon: LayoutGrid },
|
||||
{ key: 'observability', label: 'Observability', icon: Activity },
|
||||
{ key: 'logs', label: 'Logs', icon: FileText },
|
||||
{ key: 'logs', label: 'Logs', icon: FileText, authOnly: true },
|
||||
{ key: 'settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
@@ -345,7 +347,7 @@ function ServiceCreateDialog(props: {
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-[var(--radius-sm)] text-left text-sm transition-colors ${
|
||||
repoFullName === repo.full_name
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-hover)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="mono text-xs truncate">{repo.full_name}</span>
|
||||
@@ -465,10 +467,20 @@ export function ProjectWorkspacePage() {
|
||||
const toast = useToast();
|
||||
|
||||
const activeView = (searchParams.get('view') as WorkspaceView | null) ?? 'canvas';
|
||||
const isDemoMode = searchParams.get('demo') === '1';
|
||||
const isDemoMode = useDemoMode();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
|
||||
const openAddService = () => {
|
||||
if (!signedIn) {
|
||||
navigate('/auth/sign-in');
|
||||
return;
|
||||
}
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
@@ -569,7 +581,8 @@ export function ProjectWorkspacePage() {
|
||||
|
||||
return Object.fromEntries(entries) as Record<string, ServiceVariable[]>;
|
||||
},
|
||||
enabled: !isDemoMode && services.length > 0,
|
||||
enabled: !isDemoMode && signedIn && services.length > 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const variablesByService = isDemoMode ? getDemoVariablesByProject(projectId) : variablesQuery.data ?? {};
|
||||
@@ -599,7 +612,8 @@ export function ProjectWorkspacePage() {
|
||||
})
|
||||
.slice(0, 200);
|
||||
},
|
||||
enabled: !isDemoMode && activeView === 'logs' && services.length > 0,
|
||||
enabled: !isDemoMode && signedIn && activeView === 'logs' && services.length > 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const runningServices = useMemo(() => services.filter((service) => service.status === 'running').length, [services]);
|
||||
@@ -607,6 +621,8 @@ export function ProjectWorkspacePage() {
|
||||
isDemoMode
|
||||
? `/projects/${projectId}/services/${serviceId}?demo=1`
|
||||
: `/projects/${projectId}/services/${serviceId}`;
|
||||
const serviceSectionHref = (serviceId: string, section: string) =>
|
||||
`${serviceHref(serviceId)}${isDemoMode ? '&' : '?'}section=${section}`;
|
||||
|
||||
if (!isDemoMode && projectQuery.isLoading) {
|
||||
return (
|
||||
@@ -631,7 +647,7 @@ export function ProjectWorkspacePage() {
|
||||
This project may have been deleted or you don't have access.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/projects')}
|
||||
onClick={() => navigate(isDemoMode ? '/projects?demo=1' : '/projects')}
|
||||
className="mt-6 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
Back to Projects
|
||||
@@ -641,92 +657,30 @@ export function ProjectWorkspacePage() {
|
||||
);
|
||||
}
|
||||
|
||||
const canvasView = activeView === 'canvas';
|
||||
|
||||
const projectsHref = isDemoMode ? '/projects?demo=1' : '/projects';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
|
||||
<div className="w-full px-8 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-dvh min-h-0 flex-col bg-[var(--bg-void)]">
|
||||
{/* Slim workspace bar — tabs + project meta, canvas fills everything below */}
|
||||
<div className="flex h-12 shrink-0 items-center gap-3 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 px-4 backdrop-blur-sm">
|
||||
<button
|
||||
onClick={() => navigate('/projects')}
|
||||
className="flex items-center gap-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
onClick={() => navigate(projectsHref)}
|
||||
className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors shrink-0"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>Projects</span>
|
||||
<ArrowLeft size={14} />
|
||||
<span className="hidden sm:inline">Projects</span>
|
||||
</button>
|
||||
<div className="w-px h-5 bg-[var(--border-subtle)]" />
|
||||
<div>
|
||||
<h1 className="v-title">{project.name}<span className="v-cursor">_</span></h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{project.description || 'No description'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-[var(--border-subtle)] shrink-0" />
|
||||
<h1 className="v-title text-sm shrink-0 min-w-0 truncate">{project.name}<span className="v-cursor">_</span></h1>
|
||||
<span className="hidden lg:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)] shrink-0">
|
||||
{services.length} service{services.length !== 1 ? 's' : ''} · {runningServices} running
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Stats */}
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-semibold text-[var(--text-primary)]">{services.length}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Services</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-semibold text-[var(--success)]">{runningServices}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Running</p>
|
||||
</div>
|
||||
<div className="w-px h-8 bg-[var(--border-subtle)]" />
|
||||
<LiveIndicator isLive={runningServices > 0} />
|
||||
<div className="w-px h-8 bg-[var(--border-subtle)]" />
|
||||
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
|
||||
<Clock size={12} />
|
||||
<span>{formatRelative(project.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search / Command Palette */}
|
||||
<button
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
className="hidden md:flex items-center gap-2 h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-sm hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
<Search size={14} />
|
||||
<span>Search...</span>
|
||||
<kbd className="ml-2 px-1.5 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
|
||||
</button>
|
||||
|
||||
{/* Add Service Button */}
|
||||
{!isDemoMode && (
|
||||
<button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg hover:shadow-xl transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span className="hidden sm:inline">Add Service</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo Mode Banner */}
|
||||
{isDemoMode && (
|
||||
<div className="w-full px-8 py-4">
|
||||
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
|
||||
<Sparkles size={16} />
|
||||
<span>Demo mode active — using sample data for preview</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[200px_1fr] gap-6">
|
||||
{/* Sidebar Navigation */}
|
||||
<aside className="lg:sticky lg:top-6 lg:h-fit">
|
||||
<nav className="flex lg:flex-col gap-1 overflow-x-auto lg:overflow-visible pb-2 lg:pb-0">
|
||||
{viewItems.map((item) => {
|
||||
{/* View tabs */}
|
||||
<nav className="flex items-center gap-0.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-0.5 mx-2 overflow-x-auto min-w-0">
|
||||
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
|
||||
const active = activeView === item.key;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
@@ -737,35 +691,82 @@ export function ProjectWorkspacePage() {
|
||||
next.set('view', item.key);
|
||||
return next;
|
||||
})}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-md)] transition-all whitespace-nowrap ${
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap ${
|
||||
active
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
|
||||
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
{active && (
|
||||
<div className="hidden lg:block absolute left-0 w-0.5 h-5 bg-[var(--accent-primary)] rounded-full" />
|
||||
)}
|
||||
<Icon size={13} />
|
||||
<span className="hidden md:inline">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Content Area */}
|
||||
<section className="min-w-0">
|
||||
{activeView === 'canvas' && (
|
||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||
<div className="hidden xl:block">
|
||||
<LiveIndicator isLive={runningServices > 0} />
|
||||
</div>
|
||||
<span className="hidden xl:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||
<Clock size={11} />
|
||||
{formatRelative(project.updatedAt)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-xs hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
<Search size={12} />
|
||||
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
|
||||
</button>
|
||||
{!isDemoMode && signedIn && (
|
||||
<button
|
||||
onClick={openAddService}
|
||||
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span className="hidden sm:inline">Add Service</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{canvasView ? (
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<ProjectCanvas
|
||||
projectId={project.id}
|
||||
services={services}
|
||||
variablesByService={variablesByService}
|
||||
onAddService={() => setCreateOpen(true)}
|
||||
onAddService={openAddService}
|
||||
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
|
||||
onOpenSection={(serviceId, section) => navigate(serviceSectionHref(serviceId, section))}
|
||||
readOnly={isDemoMode || !signedIn}
|
||||
/>
|
||||
{isDemoMode && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 pointer-events-none">
|
||||
<span className="canvas-pill flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium text-[var(--warning)]">
|
||||
<Sparkles size={12} />
|
||||
Demo data
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{isDemoMode && (
|
||||
<div className="w-full px-8 pt-6">
|
||||
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
|
||||
<Sparkles size={16} />
|
||||
<span>Demo mode active - using sample data for preview</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full px-8 py-6">
|
||||
<section className="min-w-0">
|
||||
{activeView === 'observability' && (
|
||||
<div className="space-y-6">
|
||||
{/* Health Summary */}
|
||||
@@ -952,6 +953,7 @@ export function ProjectWorkspacePage() {
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Service Dialog */}
|
||||
{!isDemoMode && (
|
||||
@@ -973,6 +975,10 @@ export function ProjectWorkspacePage() {
|
||||
open={commandPaletteOpen}
|
||||
onClose={() => setCommandPaletteOpen(false)}
|
||||
onAddService={(type) => {
|
||||
if (!signedIn) {
|
||||
navigate('/auth/sign-in');
|
||||
return;
|
||||
}
|
||||
setCreateOpen(true);
|
||||
toast.showToast(`Creating ${type} service...`, 'info');
|
||||
}}
|
||||
|
||||
@@ -151,20 +151,36 @@ export function canvasStorageKey(projectId: string): string {
|
||||
return `containr.canvas.v1.${projectId}`;
|
||||
}
|
||||
|
||||
function canvasStorage(): Storage | null {
|
||||
try {
|
||||
return typeof window === 'undefined' ? null : window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasCanvasMetadata(projectId: string): boolean {
|
||||
return canvasStorage()?.getItem(canvasStorageKey(projectId)) != null;
|
||||
}
|
||||
|
||||
export function clearCanvasMetadata(projectId: string): void {
|
||||
localStorage.removeItem(canvasStorageKey(projectId));
|
||||
canvasStorage()?.removeItem(canvasStorageKey(projectId));
|
||||
}
|
||||
|
||||
export function saveCanvasMetadata(projectId: string, metadata: ProjectCanvasMetadata): void {
|
||||
localStorage.setItem(canvasStorageKey(projectId), JSON.stringify(metadata));
|
||||
canvasStorage()?.setItem(canvasStorageKey(projectId), JSON.stringify(metadata));
|
||||
}
|
||||
|
||||
export function loadCanvasMetadata(projectId: string, services: ServiceEntity[]): ProjectCanvasMetadata {
|
||||
export function loadCanvasMetadata(
|
||||
projectId: string,
|
||||
services: ServiceEntity[],
|
||||
layout?: Map<string, { x: number; y: number }>,
|
||||
): ProjectCanvasMetadata {
|
||||
const key = canvasStorageKey(projectId);
|
||||
const fallback = createDefaultCanvasMetadata(services);
|
||||
const fallback = createDefaultCanvasMetadata(services, layout);
|
||||
const serviceIds = new Set(services.map((service) => service.id));
|
||||
|
||||
const raw = localStorage.getItem(key);
|
||||
const raw = canvasStorage()?.getItem(key);
|
||||
if (!raw) {
|
||||
saveCanvasMetadata(projectId, fallback);
|
||||
return fallback;
|
||||
@@ -183,7 +199,14 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[])
|
||||
const nodeMap = new Map(parsed.nodes.map((node) => [node.serviceId, node]));
|
||||
const normalizedNodes: CanvasNodeLayout[] = [];
|
||||
|
||||
for (const [index, service] of services.entries()) {
|
||||
// Services added after the layout was saved park in a trailing column to
|
||||
// the right of everything placed so far, instead of overlapping nodes the
|
||||
// user already arranged.
|
||||
const placedXs = parsed.nodes.map((node) => node.position.x);
|
||||
let parkedRow = 0;
|
||||
const parkedX = placedXs.length > 0 ? Math.max(...placedXs) + 320 : 70;
|
||||
|
||||
for (const service of services) {
|
||||
const existing = nodeMap.get(service.id);
|
||||
if (existing) {
|
||||
normalizedNodes.push({
|
||||
@@ -194,15 +217,14 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[])
|
||||
continue;
|
||||
}
|
||||
|
||||
const col = index % 3;
|
||||
const row = Math.floor(index / 3);
|
||||
const parked = layout?.get(service.id);
|
||||
normalizedNodes.push({
|
||||
serviceId: service.id,
|
||||
position: {
|
||||
x: 70 + col * 260,
|
||||
y: 80 + row * 170,
|
||||
},
|
||||
position: parked ?? { x: parkedX, y: 80 + parkedRow * 160 },
|
||||
});
|
||||
if (!parked) {
|
||||
parkedRow += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const seenEdgeIds = new Set<string>();
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user