mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +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 Configuration
|
||||||
DOMAIN=localhost
|
DOMAIN=localhost
|
||||||
@@ -11,6 +12,10 @@ API_PORT=8082
|
|||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
REDIS_PORT=6379
|
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
|
# Database Configuration
|
||||||
POSTGRES_DB=containr
|
POSTGRES_DB=containr
|
||||||
POSTGRES_USER=containr_user
|
POSTGRES_USER=containr_user
|
||||||
@@ -24,9 +29,12 @@ AUTO_MIGRATE=true
|
|||||||
MIGRATION_LOCK_TIMEOUT=2m
|
MIGRATION_LOCK_TIMEOUT=2m
|
||||||
SEED_DATA_ON_START=false
|
SEED_DATA_ON_START=false
|
||||||
|
|
||||||
# Redis Configuration
|
# Dragonfly Configuration (Redis-compatible cache - REDIS_* names kept for compatibility)
|
||||||
REDIS_PASSWORD=your_secure_redis_password
|
REDIS_PASSWORD=your_secure_redis_password
|
||||||
REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0
|
REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0
|
||||||
|
# DRAGONFLY_VERSION=latest
|
||||||
|
# DRAGONFLY_THREADS=1
|
||||||
|
# DRAGONFLY_MAXMEMORY=256mb
|
||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
# In production this must be a strong value with at least 32 characters.
|
# 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.
|
# Optional rotation list (comma-separated). When set, this takes precedence.
|
||||||
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
|
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
|
||||||
CORS_ORIGINS=http://localhost:3000,http://localhost:8080
|
CORS_ORIGINS=http://localhost:3000,http://localhost:8080
|
||||||
VITE_API_URL=http://localhost:8082
|
# Optional build-time API URL overrides. Leave empty for the default
|
||||||
VITE_AUTH_URL=http://localhost:8082/api/auth
|
# 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
|
AUTH_PORT=3001
|
||||||
# In production this must be true.
|
# In production this must be true.
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=false
|
||||||
@@ -50,7 +61,15 @@ BETTER_AUTH_AUTO_MIGRATE=true
|
|||||||
BETTER_AUTH_INTERNAL_TOKEN=PLACEHOLDER_INTERNAL_AUTH_TOKEN
|
BETTER_AUTH_INTERNAL_TOKEN=PLACEHOLDER_INTERNAL_AUTH_TOKEN
|
||||||
BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session
|
BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session
|
||||||
BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001
|
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
|
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)
|
# Optional explicit auth DB settings (recommended when password contains URL special chars)
|
||||||
DB_HOST=postgres
|
DB_HOST=postgres
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
@@ -60,7 +79,9 @@ DB_PASSWORD=your_secure_postgres_password
|
|||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
BACKEND_URL=http://localhost:8082
|
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_ID=PLACEHOLDER_GITHUB_OAUTH_CLIENT_ID
|
||||||
GITHUB_CLIENT_SECRET=PLACEHOLDER_GITHUB_OAUTH_CLIENT_SECRET
|
GITHUB_CLIENT_SECRET=PLACEHOLDER_GITHUB_OAUTH_CLIENT_SECRET
|
||||||
GITLAB_CLIENT_ID=PLACEHOLDER_GITLAB_CLIENT_ID
|
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
|
# Development convenience: true in local dev, false in production
|
||||||
TRAEFIK_API_INSECURE=true
|
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
|
# Get token from: https://dash.cloudflare.com/argotunnel
|
||||||
CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here
|
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
|
# Optional: Custom Docker Registry
|
||||||
# DOCKER_REGISTRY=your-registry.com
|
# DOCKER_REGISTRY=your-registry.com
|
||||||
# DOCKER_USERNAME=your_username
|
# DOCKER_USERNAME=your_username
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
github: Dvorinka
|
||||||
@@ -109,7 +109,7 @@ jobs:
|
|||||||
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
|
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
|
||||||
BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32)
|
BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32)
|
||||||
export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN
|
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
|
- name: Wait for backend health
|
||||||
run: |
|
run: |
|
||||||
@@ -222,3 +222,96 @@ jobs:
|
|||||||
ignore-unfixed: true
|
ignore-unfixed: true
|
||||||
vuln-type: os,library
|
vuln-type: os,library
|
||||||
severity: CRITICAL,HIGH
|
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)
|
# Local reference checkouts (not project code)
|
||||||
ref/
|
ref/
|
||||||
|
.playwright-mcp/
|
||||||
|
canvas-*.png
|
||||||
|
.vercel
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/*
|
||||||
|
!/site
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#quick-start">Quick Start</a> •
|
<a href="#quick-start">Quick Start</a> •
|
||||||
|
<a href="#screenshots">Screenshots</a> •
|
||||||
<a href="#features">Features</a> •
|
<a href="#features">Features</a> •
|
||||||
<a href="#architecture">Architecture</a> •
|
|
||||||
<a href="docs/guides/">Guides</a> •
|
<a href="docs/guides/">Guides</a> •
|
||||||
<a href="CONTRIBUTING.md">Contributing</a>
|
<a href="CONTRIBUTING.md">Contributing</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -22,124 +22,120 @@
|
|||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
|
||||||
</p>
|
</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
|
The platform doubles as a public showcase: visitors browse approved projects
|
||||||
project-first deployment workflow — create a project, drop services onto a
|
and the template catalog without an account, while owners manage everything
|
||||||
visual canvas, wire them together with environment variables, and deploy —
|
behind sign-in and a platform admin approves, curates, and moderates from a
|
||||||
without Kubernetes, without a cloud bill, and without giving up control of
|
dedicated console.
|
||||||
your infrastructure.
|
|
||||||
|
|
||||||
The UI is inspired by Railway's project canvas: services appear as nodes,
|
## Screenshots
|
||||||
connections are inferred automatically, and everything — logs, metrics,
|
|
||||||
deployments, builds — is one click away.
|
| Projects dashboard | Project canvas |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
| Service detail | Managed databases |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Project canvas** — visual service topology with groups, drag/drop layout,
|
- **Project canvas** — visual service topology, groups, drag/drop, auto-inferred connections, right-click lifecycle actions
|
||||||
and auto-inferred connections
|
- **Public project catalog** — approved projects are browsable anonymously; new projects stay private until an admin approves them
|
||||||
- **Deployments** — Docker-based deploys with history, logs, and rollback
|
- **Admin console** (`/admin`) — platform stats, project approval/edit/delete, user admin management
|
||||||
- **Git integration** — deploy from GitHub, GitLab, Bitbucket, or Gitea;
|
- **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references
|
||||||
repository and branch pickers, webhooks, GitHub App support
|
- **Deployments & builds** — Docker deploys with history, image rollback, live build status
|
||||||
- **Builds** — build pipeline with live status over WebSocket
|
- **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page
|
||||||
- **Service metrics** — real CPU/memory/network telemetry from Docker stats
|
- **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App
|
||||||
- **Templates** — one-click service catalog (databases, apps, tooling)
|
- **Managed databases** — create PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Dragonfly, or ClickHouse directly from `/databases`, with backup/restore
|
||||||
- **Databases** — managed database services with backup/restore actions
|
- **Template catalog** — 30 official templates (NocoDB, Plausible, MinIO, n8n, …) plus user-defined templates with JSON upload and owner-scoped edit/delete
|
||||||
- **Cron jobs** — scheduled jobs with execution history
|
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
|
||||||
- **Preview environments** — per-project preview deploys
|
- **HA & scaling** — failover policies, node agents, autoscaling rules
|
||||||
- **Variables & environments** — per-service env vars, production/preview/dev
|
- **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
|
||||||
- **Node monitoring** — host CPU, memory, disk, and network telemetry
|
- **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline
|
||||||
- **Security** — audit logs, vulnerability scans, compliance reports
|
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
|
||||||
- **Auth** — Better Auth sessions (email + OAuth), per-user resource scoping
|
|
||||||
- **Self-hosted networking** — Traefik reverse proxy, optional Cloudflare
|
## Access model
|
||||||
Tunnel for exposure without port forwarding
|
|
||||||
|
| 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
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow
|
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
|
infra/ Self-hosted compose: Traefik + Cloudflare Tunnel
|
||||||
docs/ Guides, OpenAPI spec, design documents
|
docs/ Guides, OpenAPI spec, design documents
|
||||||
```
|
```
|
||||||
|
|
||||||
| Component | Default port |
|
All configuration is via environment variables — see `.env.example`. Never
|
||||||
| ------------ | ------------ |
|
commit `.env` or `.env.prod`; keep generated admin credentials in a local
|
||||||
| Frontend | 3000 (prod) / 5173 (dev) |
|
untracked file (e.g. `admin-credentials.local`). If a secret was ever
|
||||||
| API | 8082 |
|
committed, rotate it.
|
||||||
| 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.
|
|
||||||
|
|
||||||
## Documentation
|
## 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/guides/](docs/guides/) — setup, deployment, Cloudflare, autoscaling
|
||||||
- [docs/api/openapi.yaml](docs/api/openapi.yaml) — API contract
|
- [docs/api/openapi.yaml](docs/api/openapi.yaml) — API contract
|
||||||
- [docs/developers/onboarding.md](docs/developers/onboarding.md) — contributor onboarding
|
- [docs/developers/onboarding.md](docs/developers/onboarding.md) — contributor onboarding
|
||||||
- [docs/design/](docs/design/) — UI design references
|
|
||||||
- `make help` — common tasks
|
- `make help` — common tasks
|
||||||
|
|
||||||
## Contributing
|
---
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md). Issues and PRs welcome.
|
[Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) · [MIT License](LICENSE) © 2026 Tomas Dvorak
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
See [SECURITY.md](SECURITY.md). Report vulnerabilities privately — do not
|
|
||||||
open public issues.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[MIT](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
|
### Prerequisites
|
||||||
- Go 1.21+
|
- Go 1.21+
|
||||||
- PostgreSQL 12+
|
- PostgreSQL 12+
|
||||||
- Redis (optional)
|
- Dragonfly (Redis-compatible cache)
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,15 @@ const database = new PostgresDialect({
|
|||||||
pool,
|
pool,
|
||||||
});
|
});
|
||||||
|
|
||||||
const githubClientId = env.GITHUB_CLIENT_ID?.trim() || '';
|
const realCredential = (value) => {
|
||||||
const githubClientSecret = env.GITHUB_CLIENT_SECRET?.trim() || '';
|
const trimmed = (value || '').trim();
|
||||||
const googleClientId = env.GOOGLE_CLIENT_ID?.trim() || '';
|
return trimmed && !trimmed.startsWith('PLACEHOLDER_') ? trimmed : '';
|
||||||
const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim() || '';
|
};
|
||||||
|
|
||||||
|
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 = {};
|
const socialProviders = {};
|
||||||
if (githubClientId && githubClientSecret) {
|
if (githubClientId && githubClientSecret) {
|
||||||
@@ -67,7 +72,7 @@ if (googleClientId && googleClientSecret) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const trustedOrigins = [
|
const staticTrustedOrigins = [
|
||||||
...splitCsv(env.BETTER_AUTH_TRUSTED_ORIGINS),
|
...splitCsv(env.BETTER_AUTH_TRUSTED_ORIGINS),
|
||||||
env.FRONTEND_URL,
|
env.FRONTEND_URL,
|
||||||
env.BACKEND_URL,
|
env.BACKEND_URL,
|
||||||
@@ -86,12 +91,33 @@ const trustedOrigins = [
|
|||||||
.map((origin) => (origin || '').trim())
|
.map((origin) => (origin || '').trim())
|
||||||
.filter(Boolean);
|
.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({
|
export const auth = betterAuth({
|
||||||
appName: env.BETTER_AUTH_APP_NAME || 'Containr',
|
appName: env.BETTER_AUTH_APP_NAME || 'Containr',
|
||||||
baseURL: env.BETTER_AUTH_URL || defaultBackendURL,
|
baseURL: env.BETTER_AUTH_URL || defaultBackendURL,
|
||||||
basePath: '/api/auth',
|
basePath: '/api/auth',
|
||||||
secret: env.BETTER_AUTH_SECRET || 'PLACEHOLDER_BETTER_AUTH_SECRET_CHANGE_ME_32CHARS_MIN',
|
secret: env.BETTER_AUTH_SECRET || 'PLACEHOLDER_BETTER_AUTH_SECRET_CHANGE_ME_32CHARS_MIN',
|
||||||
trustedOrigins,
|
trustedOrigins: resolveTrustedOrigins,
|
||||||
database,
|
database,
|
||||||
user: {
|
user: {
|
||||||
modelName: 'auth_users',
|
modelName: 'auth_users',
|
||||||
|
|||||||
@@ -6,7 +6,15 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Injected at build time via -ldflags (see scripts/build-cli.sh).
|
||||||
|
var (
|
||||||
|
Version = "dev"
|
||||||
|
BuildTime = "unknown"
|
||||||
|
GitCommit = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
cli.SetVersion(fmt.Sprintf("%s (%s, %s)", Version, GitCommit, BuildTime))
|
||||||
if err := cli.Run(); err != nil {
|
if err := cli.Run(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
os.Exit(1)
|
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
|
// Setup Gin router
|
||||||
if cfg.IsProduction() {
|
if cfg.IsProduction() {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
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()
|
router := gin.New()
|
||||||
|
|||||||
@@ -14,15 +14,15 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
redis:
|
dragonfly:
|
||||||
image: redis:7-alpine
|
image: docker.dragonflydb.io/dragonflydb/dragonfly:latest
|
||||||
env_file:
|
env_file:
|
||||||
- ../../.env.prod
|
- ../../.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:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- backend_redis_data:/data
|
- backend_dragonfly_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
|
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -38,10 +38,14 @@ services:
|
|||||||
- ../../.env.prod
|
- ../../.env.prod
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
group_add:
|
||||||
|
- "${DOCKER_GID:-0}"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
dragonfly:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -52,4 +56,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
backend_postgres_data:
|
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.
|
// SetupAdminRoutes registers dashboard/control agent routes. Command
|
||||||
func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) {
|
// 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 := router.Group("/agent-tokens")
|
||||||
{
|
{
|
||||||
tokens.POST("", h.CreateAgentToken)
|
tokens.POST("", h.CreateAgentToken)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ type User struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AvatarURL string `json:"avatar_url,omitempty"`
|
AvatarURL string `json:"avatar_url,omitempty"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,10 +64,10 @@ func handleLogin(c *gin.Context) {
|
|||||||
var user User
|
var user User
|
||||||
var hashedPassword string
|
var hashedPassword string
|
||||||
err := db.QueryRow(`
|
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
|
FROM users
|
||||||
WHERE email = $1
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
|
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
|
||||||
@@ -120,13 +121,32 @@ func handleAuthBootstrap(c *gin.Context) {
|
|||||||
mode = "register"
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"has_users": count > 0,
|
"has_users": count > 0,
|
||||||
"user_count": count,
|
"user_count": count,
|
||||||
"mode": mode,
|
"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 {
|
type betterAuthLoginUser struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@@ -236,17 +256,23 @@ func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser
|
|||||||
return User{}, err
|
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
|
var user User
|
||||||
err = db.QueryRow(`
|
err = db.QueryRow(`
|
||||||
INSERT INTO users (email, password_hash, name, avatar_url)
|
INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
|
||||||
VALUES ($1, $2, $3, NULLIF($4, ''))
|
VALUES ($1, $2, $3, NULLIF($4, ''), $5)
|
||||||
ON CONFLICT (email) DO UPDATE
|
ON CONFLICT (email) DO UPDATE
|
||||||
SET name = EXCLUDED.name,
|
SET name = EXCLUDED.name,
|
||||||
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
|
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
|
||||||
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image)).
|
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image), total == 0).
|
||||||
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return User{}, err
|
return User{}, err
|
||||||
}
|
}
|
||||||
@@ -264,24 +290,26 @@ func handleRegister(c *gin.Context) {
|
|||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
jwtSecret := c.MustGet("jwt_secret").(string)
|
jwtSecret := c.MustGet("jwt_secret").(string)
|
||||||
|
|
||||||
count, err := countLocalUsers(db)
|
total, err := countLocalUsers(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||||
return
|
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"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if count > 0 {
|
if existing > 0 {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -293,13 +321,13 @@ func handleRegister(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user
|
// First account owns the platform.
|
||||||
var user User
|
var user User
|
||||||
err = db.QueryRow(`
|
err = db.QueryRow(`
|
||||||
INSERT INTO users (email, password_hash, name)
|
INSERT INTO users (email, password_hash, name, is_admin)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
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.CreatedAt)
|
`, req.Email, string(hashedPassword), req.Name, total == 0).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
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) {
|
func handleCreateUser(c *gin.Context) {
|
||||||
|
db, ok := requireAdmin(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req ManualUserCreateRequest
|
var req ManualUserCreateRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
db := c.MustGet("db").(*database.DB)
|
|
||||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
req.Name = strings.TrimSpace(req.Name)
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
if req.Email == "" || req.Name == "" {
|
if req.Email == "" || req.Name == "" {
|
||||||
@@ -363,8 +394,8 @@ func handleCreateUser(c *gin.Context) {
|
|||||||
err = db.QueryRow(`
|
err = db.QueryRow(`
|
||||||
INSERT INTO users (email, password_hash, name)
|
INSERT INTO users (email, password_hash, name)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
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.CreatedAt)
|
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||||
return
|
return
|
||||||
@@ -459,10 +490,10 @@ func handleGetProfile(c *gin.Context) {
|
|||||||
|
|
||||||
var user User
|
var user User
|
||||||
err := db.QueryRow(`
|
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
|
FROM users
|
||||||
WHERE id = $1
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
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 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) {
|
func parseAuthProxyTarget(rawTarget string) (*url.URL, error) {
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ package api
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func requireAuthenticatedUserID(c *gin.Context) (string, bool) {
|
func requireAuthenticatedUserID(c *gin.Context) (string, bool) {
|
||||||
@@ -21,3 +24,107 @@ func requireAuthenticatedUserID(c *gin.Context) (string, bool) {
|
|||||||
|
|
||||||
return userID, true
|
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,19 +65,34 @@ type UpdateCronJobRequest struct {
|
|||||||
Retention int `json:"retention"`
|
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) {
|
func handleGetCronJobs(c *gin.Context) {
|
||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
userID := c.MustGet("user_id").(string)
|
userID := optionalUserUUID(c)
|
||||||
|
isAdmin := contextIsAdmin(c)
|
||||||
projectID := c.Query("project_id")
|
projectID := c.Query("project_id")
|
||||||
serviceID := c.Query("service_id")
|
serviceID := c.Query("service_id")
|
||||||
|
|
||||||
query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
|
query := `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.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
|
||||||
cj.retention, cj.created_at, cj.updated_at
|
cj.retention, cj.created_at, cj.updated_at
|
||||||
FROM cron_jobs cj
|
FROM cron_jobs cj
|
||||||
JOIN projects p ON cj.project_id = p.id
|
JOIN projects p ON cj.project_id = p.id
|
||||||
WHERE p.owner_id = $1`
|
WHERE (p.is_approved OR p.owner_id = $1 OR $2::bool
|
||||||
args := []interface{}{userID}
|
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 != "" {
|
if projectID != "" {
|
||||||
args = append(args, projectID)
|
args = append(args, projectID)
|
||||||
@@ -130,7 +145,7 @@ func handleCreateCronJob(c *gin.Context) {
|
|||||||
req.ServiceID,
|
req.ServiceID,
|
||||||
).Scan(&ownerCheck)
|
).Scan(&ownerCheck)
|
||||||
|
|
||||||
if err != nil || ownerCheck != userID {
|
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -184,30 +199,32 @@ func handleCreateCronJob(c *gin.Context) {
|
|||||||
|
|
||||||
func handleGetCronJob(c *gin.Context) {
|
func handleGetCronJob(c *gin.Context) {
|
||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
userID := c.MustGet("user_id").(string)
|
|
||||||
jobID := c.Param("id")
|
jobID := c.Param("id")
|
||||||
|
|
||||||
var job CronJob
|
var job CronJob
|
||||||
var ownerCheck string
|
|
||||||
err := db.QueryRow(
|
err := db.QueryRow(
|
||||||
`SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
|
`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.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
|
FROM cron_jobs cj
|
||||||
JOIN projects p ON cj.project_id = p.id
|
|
||||||
WHERE cj.id = $1`,
|
WHERE cj.id = $1`,
|
||||||
jobID,
|
jobID,
|
||||||
).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
|
).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
|
||||||
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
|
&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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ownerCheck != userID {
|
projectID, found := cronJobProjectID(db, jobID)
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +250,7 @@ func handleUpdateCronJob(c *gin.Context) {
|
|||||||
jobID,
|
jobID,
|
||||||
).Scan(&ownerCheck)
|
).Scan(&ownerCheck)
|
||||||
|
|
||||||
if err != nil || ownerCheck != userID {
|
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -303,7 +320,7 @@ func handleDeleteCronJob(c *gin.Context) {
|
|||||||
jobID,
|
jobID,
|
||||||
).Scan(&ownerCheck)
|
).Scan(&ownerCheck)
|
||||||
|
|
||||||
if err != nil || ownerCheck != userID {
|
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -321,19 +338,15 @@ func handleDeleteCronJob(c *gin.Context) {
|
|||||||
|
|
||||||
func handleGetCronExecutions(c *gin.Context) {
|
func handleGetCronExecutions(c *gin.Context) {
|
||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
userID := c.MustGet("user_id").(string)
|
|
||||||
jobID := c.Param("id")
|
jobID := c.Param("id")
|
||||||
|
|
||||||
var ownerCheck string
|
projectID, found := cronJobProjectID(db, jobID)
|
||||||
err := db.QueryRow(
|
if !found {
|
||||||
`SELECT p.owner_id FROM cron_jobs cj
|
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
|
||||||
JOIN projects p ON cj.project_id = p.id
|
return
|
||||||
WHERE cj.id = $1`,
|
}
|
||||||
jobID,
|
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
|
||||||
).Scan(&ownerCheck)
|
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
|
||||||
|
|
||||||
if err != nil || ownerCheck != userID {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,7 +393,7 @@ func handleTriggerCronJob(c *gin.Context) {
|
|||||||
jobID,
|
jobID,
|
||||||
).Scan(&job.ServiceID, &job.Command, &job.Schedule, &job.Timezone, &job.Retention, &ownerCheck)
|
).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"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
|
|
||||||
dockercontainer "github.com/docker/docker/api/types/container"
|
dockercontainer "github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/docker/api/types/registry"
|
"github.com/docker/docker/api/types/registry"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -41,6 +42,7 @@ const (
|
|||||||
// DatabaseService represents a managed database service
|
// DatabaseService represents a managed database service
|
||||||
type DatabaseService struct {
|
type DatabaseService struct {
|
||||||
ID string `json:"id" db:"id"`
|
ID string `json:"id" db:"id"`
|
||||||
|
OwnerID string `json:"-" db:"user_id"`
|
||||||
Name string `json:"name" db:"name"`
|
Name string `json:"name" db:"name"`
|
||||||
Type string `json:"type" db:"type"` // postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly
|
Type string `json:"type" db:"type"` // postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly
|
||||||
Status string `json:"status" db:"status"` // running, stopped, building, error
|
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) {
|
func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
|
||||||
userID, ok := requireAuthenticatedUserID(c)
|
userID := optionalUserID(c)
|
||||||
if !ok {
|
isAdmin := contextIsAdmin(c)
|
||||||
return
|
|
||||||
}
|
rows, err := h.queries.ListAllDatabaseServices(c.Request.Context())
|
||||||
rows, err := h.queries.ListDatabaseServicesByUser(c.Request.Context(), userID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch databases"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch databases"})
|
||||||
return
|
return
|
||||||
@@ -182,17 +185,23 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
|
|||||||
|
|
||||||
databases := make([]DatabaseService, 0, len(rows))
|
databases := make([]DatabaseService, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
db := mapDatabaseServiceListRow(row)
|
db := mapDatabaseServiceRow(row)
|
||||||
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
|
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
|
||||||
|
|
||||||
db.Metrics = h.resolveDatabaseMetrics(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)
|
||||||
db.Backups = backupConfig
|
if privileged {
|
||||||
|
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil {
|
||||||
|
db.Backups = backupConfig
|
||||||
|
} else {
|
||||||
|
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
|
||||||
|
}
|
||||||
|
db.Settings = h.generateMockSettings()
|
||||||
|
db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c))
|
||||||
} else {
|
} else {
|
||||||
db.Backups = h.generateMockBackupConfig()
|
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
|
||||||
|
db.ConnectionURL = ""
|
||||||
}
|
}
|
||||||
db.Settings = h.generateMockSettings()
|
|
||||||
db.ConnectionURL = h.resolveConnectionURL(db)
|
|
||||||
|
|
||||||
databases = append(databases, db)
|
databases = append(databases, db)
|
||||||
}
|
}
|
||||||
@@ -202,15 +211,11 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
|
|||||||
|
|
||||||
// GetDatabase returns a specific database service
|
// GetDatabase returns a specific database service
|
||||||
func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
|
func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
|
||||||
userID, ok := requireAuthenticatedUserID(c)
|
userID := optionalUserID(c)
|
||||||
if !ok {
|
isAdmin := contextIsAdmin(c)
|
||||||
return
|
|
||||||
}
|
|
||||||
databaseID := c.Param("id")
|
databaseID := c.Param("id")
|
||||||
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
row, err := h.queries.GetDatabaseServiceByID(c.Request.Context(), databaseID)
|
||||||
ID: databaseID,
|
|
||||||
UserID: userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
db := mapDatabaseServiceGetRow(row)
|
db := mapDatabaseServiceRow(row)
|
||||||
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
|
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
|
||||||
|
|
||||||
db.Metrics = h.resolveDatabaseMetrics(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)
|
||||||
db.Backups = backupConfig
|
if privileged {
|
||||||
|
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil {
|
||||||
|
db.Backups = backupConfig
|
||||||
|
} else {
|
||||||
|
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
|
||||||
|
}
|
||||||
|
db.Settings = h.generateMockSettings()
|
||||||
|
db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c))
|
||||||
} else {
|
} else {
|
||||||
db.Backups = h.generateMockBackupConfig()
|
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
|
||||||
|
db.ConnectionURL = ""
|
||||||
}
|
}
|
||||||
db.Settings = h.generateMockSettings()
|
|
||||||
db.ConnectionURL = h.resolveConnectionURL(db)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, db)
|
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
|
// CreateDatabase creates a new database service
|
||||||
func (h *DatabaseHandler) CreateDatabase(c *gin.Context) {
|
func (h *DatabaseHandler) CreateDatabase(c *gin.Context) {
|
||||||
userID, ok := requireAuthenticatedUserID(c)
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
@@ -357,6 +381,7 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
databaseID := c.Param("id")
|
databaseID := c.Param("id")
|
||||||
|
userID = h.effectiveUserID(c, userID, databaseID)
|
||||||
|
|
||||||
var req DatabaseUpdateRequest
|
var req DatabaseUpdateRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -440,6 +465,7 @@ func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
databaseID := c.Param("id")
|
databaseID := c.Param("id")
|
||||||
|
userID = h.effectiveUserID(c, userID, databaseID)
|
||||||
|
|
||||||
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
|
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
|
||||||
ID: databaseID,
|
ID: databaseID,
|
||||||
@@ -481,6 +507,7 @@ func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
databaseID := c.Param("id")
|
databaseID := c.Param("id")
|
||||||
|
userID = h.effectiveUserID(c, userID, databaseID)
|
||||||
|
|
||||||
var req DatabaseActionRequest
|
var req DatabaseActionRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -574,6 +601,7 @@ func (h *DatabaseHandler) CreateBackup(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
databaseID := c.Param("id")
|
databaseID := c.Param("id")
|
||||||
|
userID = h.effectiveUserID(c, userID, databaseID)
|
||||||
|
|
||||||
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||||
ID: databaseID,
|
ID: databaseID,
|
||||||
@@ -623,6 +651,7 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
databaseID := c.Param("id")
|
databaseID := c.Param("id")
|
||||||
|
userID = h.effectiveUserID(c, userID, databaseID)
|
||||||
|
|
||||||
var req DatabaseRestoreRequest
|
var req DatabaseRestoreRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -684,6 +713,7 @@ func (h *DatabaseHandler) DownloadBackup(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
databaseID := c.Param("id")
|
databaseID := c.Param("id")
|
||||||
|
userID = h.effectiveUserID(c, userID, databaseID)
|
||||||
backupID := c.Param("bid")
|
backupID := c.Param("bid")
|
||||||
|
|
||||||
backup, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(c.Request.Context(), sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{
|
backup, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(c.Request.Context(), sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{
|
||||||
@@ -904,35 +934,6 @@ func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string
|
|||||||
}, nil
|
}, 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 {
|
func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
|
||||||
return DatabaseSettings{
|
return DatabaseSettings{
|
||||||
MaxConnections: 100,
|
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 {
|
func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string {
|
||||||
if strings.TrimSpace(db.ConnectionURL) != "" {
|
return strings.TrimSpace(db.ConnectionURL)
|
||||||
return db.ConnectionURL
|
|
||||||
}
|
|
||||||
return h.generateConnectionURL(db)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) generateConnectionURL(db DatabaseService) string {
|
// requestHostname returns the host the client used to reach the API, without
|
||||||
safeName := sanitizeDBIdentifier(db.Name, "app")
|
// the port. Empty when it cannot be determined.
|
||||||
switch db.Type {
|
func requestHostname(c *gin.Context) string {
|
||||||
case "postgresql":
|
host := strings.TrimSpace(c.Request.Host)
|
||||||
return fmt.Sprintf("postgresql://user:password@%s.containr.local:5432/%s", safeName, safeName)
|
if host == "" {
|
||||||
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:
|
|
||||||
return ""
|
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 {
|
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 {
|
func mapDatabaseServiceRow(row sqlcdb.DatabaseService) 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 {
|
|
||||||
return DatabaseService{
|
return DatabaseService{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
|
OwnerID: row.UserID,
|
||||||
Name: row.Name,
|
Name: row.Name,
|
||||||
Type: row.Type,
|
Type: row.Type,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
@@ -1239,13 +1238,13 @@ func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseNam
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if h.dockerClient == nil {
|
if h.dockerClient == nil {
|
||||||
fallbackURL := h.generateConnectionURL(DatabaseService{Name: databaseName, Type: dbType})
|
_ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{})
|
||||||
_ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{String: fallbackURL, Valid: fallbackURL != ""})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables)
|
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err)
|
||||||
_ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{})
|
_ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1353,7 +1352,7 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := waitForRuntimePortReadiness(ctx, hostPort); err != nil {
|
if err := h.waitForDatabaseReadiness(ctx, containerID, hostPort, plan.Port.Port()); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1387,12 +1386,24 @@ func (h *DatabaseHandler) resolvePublishedHostPort(ctx context.Context, containe
|
|||||||
return "", fmt.Errorf("published host port not available")
|
return "", fmt.Errorf("published host port not available")
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForRuntimePortReadiness(ctx context.Context, hostPort string) error {
|
// waitForDatabaseReadiness probes the database's TCP port until it accepts a
|
||||||
addr := net.JoinHostPort("127.0.0.1", strings.TrimSpace(hostPort))
|
// connection. The published port lives on host loopback, which a containerized
|
||||||
deadline := time.Now().Add(30 * time.Second)
|
// 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) {
|
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 {
|
if err == nil {
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
return nil
|
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 {
|
func (h *DatabaseHandler) ensureImage(ctx context.Context, imageRef string) error {
|
||||||
|
|||||||
@@ -51,15 +51,16 @@ func TestDatabaseConnectionURLAndDefaultVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
url := handler.generateConnectionURL(DatabaseService{
|
plan, err := buildDatabaseRuntimePlan(tc.dbType, "example", nil)
|
||||||
Type: tc.dbType,
|
if err != nil {
|
||||||
Name: "example",
|
t.Fatalf("buildDatabaseRuntimePlan(%q) failed: %v", tc.dbType, err)
|
||||||
})
|
|
||||||
if url == "" {
|
|
||||||
t.Fatalf("generateConnectionURL returned empty for type %q", tc.dbType)
|
|
||||||
}
|
}
|
||||||
|
url := plan.ConnectionURL("15432")
|
||||||
if len(tc.urlContains) > 0 && !strings.HasPrefix(url, tc.urlContains) {
|
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)
|
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) {
|
func TestBuildDatabaseRuntimePlanSupportsAllTypes(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
dbType string
|
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"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -63,36 +64,22 @@ func handleGetDeployments(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, exists := c.Get("user_id")
|
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
|
||||||
if !exists {
|
if !found {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
|
||||||
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 {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ownerCheck != userID.(string) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := db.(*database.DB).Query(
|
rows, err := db.(*database.DB).Query(
|
||||||
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
|
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
|
||||||
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
|
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
|
||||||
FROM deployments
|
FROM deployments
|
||||||
WHERE service_id = $1
|
WHERE service_id = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 50`,
|
LIMIT 50`,
|
||||||
serviceID,
|
serviceID,
|
||||||
)
|
)
|
||||||
@@ -120,6 +107,70 @@ func handleGetDeployments(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
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) {
|
func handleCreateDeployment(c *gin.Context) {
|
||||||
db, exists := c.Get("db")
|
db, exists := c.Get("db")
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -153,8 +204,11 @@ func handleCreateDeployment(c *gin.Context) {
|
|||||||
var service Service
|
var service Service
|
||||||
var projectOwner string
|
var projectOwner string
|
||||||
err = db.(*database.DB).QueryRow(
|
err = db.(*database.DB).QueryRow(
|
||||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
`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,
|
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
|
s.created_at, s.updated_at, p.owner_id
|
||||||
FROM services s
|
FROM services s
|
||||||
JOIN projects p ON s.project_id = p.id
|
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.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&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 {
|
if err != nil {
|
||||||
@@ -256,6 +311,22 @@ func runDeploymentAndSync(
|
|||||||
service Service,
|
service Service,
|
||||||
req CreateDeploymentRequest,
|
req CreateDeploymentRequest,
|
||||||
userID string,
|
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)
|
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -265,21 +336,51 @@ func runDeploymentAndSync(
|
|||||||
sourcePath = "."
|
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{
|
deployReq := &deployment.DeploymentRequest{
|
||||||
ProjectID: service.ProjectID.String(),
|
ProjectID: service.ProjectID.String(),
|
||||||
ServiceID: service.ID.String(),
|
ServiceID: service.ID.String(),
|
||||||
Environment: service.Environment,
|
Environment: service.Environment,
|
||||||
Config: deployment.ServiceConfig{
|
Config: deployment.ServiceConfig{
|
||||||
Name: service.Name,
|
Name: service.Name,
|
||||||
Image: service.Image,
|
Image: service.Image,
|
||||||
Environment: req.EnvVars,
|
Command: command,
|
||||||
Replicas: 1,
|
Environment: env,
|
||||||
},
|
Replicas: replicas,
|
||||||
BuildConfig: &deployment.BuildConfig{
|
PublicPort: int32(service.Port),
|
||||||
BuildType: "nixpacks",
|
PublishedPort: publishedPort,
|
||||||
SourcePath: sourcePath,
|
Domain: service.Domain,
|
||||||
Branch: req.Branch,
|
HealthPath: service.HealthCheckPath,
|
||||||
Commit: req.CommitHash,
|
RestartPolicy: service.RestartPolicy,
|
||||||
|
Resources: deployment.ResourceLimits{
|
||||||
|
MemoryBytes: parseMemoryLimit(service.Memory),
|
||||||
|
CPUQuota: parseCPULimit(service.CPU),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Trigger: deployment.TriggerConfig{
|
Trigger: deployment.TriggerConfig{
|
||||||
Type: req.Trigger,
|
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)
|
engineDeployment, err := engine.Deploy(ctx, deployReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failedAt := time.Now()
|
failedAt := time.Now()
|
||||||
@@ -427,18 +548,12 @@ func handleGetDeployment(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, exists := c.Get("user_id")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var d DeploymentModel
|
var d DeploymentModel
|
||||||
var ownerCheck string
|
var projectID uuid.UUID
|
||||||
err = db.(*database.DB).QueryRow(
|
err = db.(*database.DB).QueryRow(
|
||||||
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
|
`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.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
|
FROM deployments d
|
||||||
JOIN services s ON d.service_id = s.id
|
JOIN services s ON d.service_id = s.id
|
||||||
JOIN projects p ON s.project_id = p.id
|
JOIN projects p ON s.project_id = p.id
|
||||||
@@ -447,7 +562,7 @@ func handleGetDeployment(c *gin.Context) {
|
|||||||
).Scan(
|
).Scan(
|
||||||
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
|
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
|
||||||
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
|
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
|
||||||
&d.CreatedAt, &d.UpdatedAt, &ownerCheck,
|
&d.CreatedAt, &d.UpdatedAt, &projectID,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -455,8 +570,8 @@ func handleGetDeployment(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ownerCheck != userID.(string) {
|
if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,7 +624,7 @@ func handleRollbackDeployment(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ownerCheck != userID.(string) {
|
if ownerCheck != userID.(string) && !contextIsAdmin(c) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -550,17 +665,58 @@ func handleRollbackDeployment(c *gin.Context) {
|
|||||||
time.Now(), serviceID,
|
time.Now(), serviceID,
|
||||||
)
|
)
|
||||||
|
|
||||||
go func() {
|
// Real rollback: redeploy the target deployment's image.
|
||||||
time.Sleep(2 * time.Second)
|
engineValue, _ := c.Get("deployment_engine")
|
||||||
db.(*database.DB).Exec(
|
engine, _ := engineValue.(*deployment.DeploymentEngine)
|
||||||
`UPDATE deployments SET status = 'deployed', completed_at = $1, updated_at = $1 WHERE id = $2`,
|
targetImage := targetDeployment.ImageName
|
||||||
time.Now(), rollbackID,
|
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(
|
_, _ = db.(*database.DB).Exec(
|
||||||
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
|
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
||||||
time.Now(), serviceID,
|
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{
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
"deployment": DeploymentResponse{
|
"deployment": DeploymentResponse{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/docker"
|
"containr/internal/docker"
|
||||||
@@ -17,7 +18,6 @@ import (
|
|||||||
// commands run as the container's default user with a 30s ceiling.
|
// commands run as the container's default user with a 30s ceiling.
|
||||||
func handleExecInService(c *gin.Context) {
|
func handleExecInService(c *gin.Context) {
|
||||||
serviceID := c.Param("id")
|
serviceID := c.Param("id")
|
||||||
userID := c.MustGet("user_id").(string)
|
|
||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -33,17 +33,20 @@ func handleExecInService(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var ownerID string
|
serviceUUID, err := uuid.Parse(serviceID)
|
||||||
err := db.QueryRow(
|
if err != nil {
|
||||||
`SELECT p.owner_id FROM services s
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||||
JOIN projects p ON p.id = s.project_id
|
return
|
||||||
WHERE s.id = $1`,
|
}
|
||||||
serviceID,
|
projectID, found := serviceProjectID(db, serviceUUID)
|
||||||
).Scan(&ownerID)
|
if !found {
|
||||||
if err != nil || ownerID != userID {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if _, allowed := projectManageAccess(c, db, projectID); !allowed {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
dockerClient, _ := c.Get("docker_client")
|
dockerClient, _ := c.Get("docker_client")
|
||||||
dockerCli, _ := dockerClient.(*docker.Client)
|
dockerCli, _ := dockerClient.(*docker.Client)
|
||||||
|
|||||||
@@ -25,43 +25,58 @@ func NewHAManager(haManager *ha.HighAvailabilityManager) *HAManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterRoutes registers HA routes
|
// RegisterReadRoutes registers public HA read endpoints.
|
||||||
func (h *HAManager) RegisterRoutes(router *gin.RouterGroup) {
|
func (h *HAManager) RegisterReadRoutes(router *gin.RouterGroup) {
|
||||||
ha := router.Group("/ha")
|
ha := router.Group("/ha")
|
||||||
{
|
{
|
||||||
ha.GET("/status", h.GetHAStatus)
|
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("/enable", h.EnableHA)
|
||||||
ha.POST("/disable", h.DisableHA)
|
ha.POST("/disable", h.DisableHA)
|
||||||
ha.POST("/failover", h.TriggerFailover)
|
ha.POST("/failover", h.TriggerFailover)
|
||||||
|
|
||||||
// Failover policies
|
// Failover policies
|
||||||
ha.GET("/failover/policies", h.GetFailoverPolicies)
|
|
||||||
ha.POST("/failover/policies", h.SetFailoverPolicy)
|
ha.POST("/failover/policies", h.SetFailoverPolicy)
|
||||||
ha.GET("/failover/policies/:serviceId", h.GetFailoverPolicy)
|
|
||||||
ha.PUT("/failover/policies/:serviceId", h.UpdateFailoverPolicy)
|
ha.PUT("/failover/policies/:serviceId", h.UpdateFailoverPolicy)
|
||||||
ha.DELETE("/failover/policies/:serviceId", h.DeleteFailoverPolicy)
|
ha.DELETE("/failover/policies/:serviceId", h.DeleteFailoverPolicy)
|
||||||
|
|
||||||
// Health checks
|
// Health checks
|
||||||
ha.GET("/health/checks", h.GetHealthChecks)
|
|
||||||
ha.POST("/health/checks", h.AddHealthCheck)
|
ha.POST("/health/checks", h.AddHealthCheck)
|
||||||
ha.GET("/health/checks/:checkId", h.GetHealthCheck)
|
|
||||||
ha.PUT("/health/checks/:checkId", h.UpdateHealthCheck)
|
ha.PUT("/health/checks/:checkId", h.UpdateHealthCheck)
|
||||||
ha.DELETE("/health/checks/:checkId", h.DeleteHealthCheck)
|
ha.DELETE("/health/checks/:checkId", h.DeleteHealthCheck)
|
||||||
ha.GET("/health/results", h.GetHealthResults)
|
|
||||||
|
|
||||||
// Alerts
|
// Alerts
|
||||||
ha.GET("/alerts/rules", h.GetAlertRules)
|
|
||||||
ha.POST("/alerts/rules", h.AddAlertRule)
|
ha.POST("/alerts/rules", h.AddAlertRule)
|
||||||
ha.GET("/alerts/rules/:ruleId", h.GetAlertRule)
|
|
||||||
ha.PUT("/alerts/rules/:ruleId", h.UpdateAlertRule)
|
ha.PUT("/alerts/rules/:ruleId", h.UpdateAlertRule)
|
||||||
ha.DELETE("/alerts/rules/:ruleId", h.DeleteAlertRule)
|
ha.DELETE("/alerts/rules/:ruleId", h.DeleteAlertRule)
|
||||||
ha.GET("/alerts/active", h.GetActiveAlerts)
|
|
||||||
ha.POST("/alerts/:alertId/resolve", h.ResolveAlert)
|
ha.POST("/alerts/:alertId/resolve", h.ResolveAlert)
|
||||||
|
|
||||||
// Notifiers
|
// Notifiers
|
||||||
ha.GET("/notifiers", h.GetNotifiers)
|
|
||||||
ha.POST("/notifiers", h.AddNotifier)
|
ha.POST("/notifiers", h.AddNotifier)
|
||||||
ha.GET("/notifiers/:notifierId", h.GetNotifier)
|
|
||||||
ha.DELETE("/notifiers/:notifierId", h.DeleteNotifier)
|
ha.DELETE("/notifiers/:notifierId", h.DeleteNotifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -35,27 +37,13 @@ func handleGetLogs(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, exists := c.Get("user_id")
|
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
|
||||||
if !exists {
|
if !found {
|
||||||
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 {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
||||||
if ownerCheck != userID.(string) {
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +61,21 @@ func handleGetLogs(c *gin.Context) {
|
|||||||
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
|
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
|
||||||
return
|
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{
|
logOpts := docker.LogOptions{
|
||||||
Stdout: true,
|
Stdout: true,
|
||||||
@@ -83,17 +85,25 @@ func handleGetLogs(c *gin.Context) {
|
|||||||
Timestamps: true,
|
Timestamps: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
var readers []io.ReadCloser
|
||||||
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts)
|
var streams []io.Reader
|
||||||
if err != nil {
|
for _, ctr := range containers {
|
||||||
if isDockerNotFoundError(err) {
|
r, rerr := client.GetContainerLogs(ctx, ctr.ID, logOpts)
|
||||||
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
|
if rerr == nil {
|
||||||
return
|
readers = append(readers, r)
|
||||||
|
streams = append(streams, r)
|
||||||
}
|
}
|
||||||
respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.")
|
}
|
||||||
|
if len(readers) == 0 {
|
||||||
|
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer logsReader.Close()
|
logsReader := io.MultiReader(streams...)
|
||||||
|
defer func() {
|
||||||
|
for _, r := range readers {
|
||||||
|
_ = r.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if follow {
|
if follow {
|
||||||
c.Header("Content-Type", "text/event-stream")
|
c.Header("Content-Type", "text/event-stream")
|
||||||
@@ -185,30 +195,24 @@ func handleGetDeploymentLogs(c *gin.Context) {
|
|||||||
return
|
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 buildLog, runtimeLog string
|
||||||
var ownerCheck string
|
var projectID uuid.UUID
|
||||||
err = db.(*database.DB).QueryRow(
|
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
|
FROM deployments d
|
||||||
JOIN services s ON d.service_id = s.id
|
JOIN services s ON d.service_id = s.id
|
||||||
JOIN projects p ON s.project_id = p.id
|
JOIN projects p ON s.project_id = p.id
|
||||||
WHERE d.id = $1`,
|
WHERE d.id = $1`,
|
||||||
deploymentID,
|
deploymentID,
|
||||||
).Scan(&buildLog, &runtimeLog, &ownerCheck)
|
).Scan(&buildLog, &runtimeLog, &projectID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ownerCheck != userID.(string) {
|
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,26 +60,19 @@ func handleGetServiceMetrics(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, exists := c.Get("user_id")
|
var projectID uuid.UUID
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var ownerID string
|
|
||||||
var status string
|
var status string
|
||||||
err = db.QueryRow(
|
err = db.QueryRow(
|
||||||
`SELECT p.owner_id, COALESCE(s.status, '')
|
`SELECT s.project_id, COALESCE(s.status, '')
|
||||||
FROM services s JOIN projects p ON s.project_id = p.id
|
FROM services s WHERE s.id = $1`,
|
||||||
WHERE s.id = $1`,
|
|
||||||
serviceID,
|
serviceID,
|
||||||
).Scan(&ownerID, &status)
|
).Scan(&projectID, &status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ownerID != userID.(string) {
|
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,31 +69,13 @@ func handleGetPreviewEnvironments(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if project exists and user has access
|
// Approved projects are public; unapproved ones need owner/member/admin.
|
||||||
var project Project
|
projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID)
|
||||||
err = db.(*database.DB).QueryRow(
|
if !projectExists || !allowed {
|
||||||
"SELECT id, name, owner_id FROM projects WHERE id = $1",
|
|
||||||
projectID,
|
|
||||||
).Scan(&project.ID, &project.Name, &project.OwnerID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||||
return
|
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
|
// Get preview environments for the project with service info
|
||||||
rows, err := db.(*database.DB).Query(
|
rows, err := db.(*database.DB).Query(
|
||||||
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
|
`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
|
// 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"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -332,27 +314,22 @@ func handleGetPreviewEnvironment(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user ID from JWT token
|
// Public when the parent project is approved; otherwise owner/member/admin.
|
||||||
userID, exists := c.Get("user_id")
|
userID := optionalUserUUID(c)
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get preview environment with project ownership check
|
|
||||||
var env PreviewEnvironment
|
var env PreviewEnvironment
|
||||||
var serviceID sql.NullString
|
var serviceID sql.NullString
|
||||||
var serviceName sql.NullString
|
var serviceName sql.NullString
|
||||||
var serviceType sql.NullString
|
var serviceType sql.NullString
|
||||||
err = db.(*database.DB).QueryRow(
|
err = db.(*database.DB).QueryRow(
|
||||||
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
|
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
|
||||||
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
|
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
|
||||||
s.id as service_id, s.name as service_name, s.type as service_type
|
s.id as service_id, s.name as service_name, s.type as service_type
|
||||||
FROM preview_environments pe
|
FROM preview_environments pe
|
||||||
LEFT JOIN services s ON pe.service_id = s.id
|
LEFT JOIN services s ON pe.service_id = s.id
|
||||||
JOIN projects p ON pe.project_id = p.id
|
JOIN projects p ON pe.project_id = p.id
|
||||||
WHERE pe.id = $1 AND p.owner_id = $2`,
|
WHERE pe.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
|
||||||
envID, userID,
|
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
|
||||||
|
envID, userID, contextIsAdmin(c),
|
||||||
).Scan(
|
).Scan(
|
||||||
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
|
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
|
||||||
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
|
&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
|
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at
|
||||||
FROM preview_environments pe
|
FROM preview_environments pe
|
||||||
JOIN projects p ON pe.project_id = p.id
|
JOIN projects p ON pe.project_id = p.id
|
||||||
WHERE pe.id = $1 AND p.owner_id = $2`,
|
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
|
||||||
envID, userID,
|
envID, userID, contextIsAdmin(c),
|
||||||
).Scan(
|
).Scan(
|
||||||
&existingEnv.ID, &existingEnv.ProjectID, &existingEnv.ServiceID, &existingEnv.BranchName,
|
&existingEnv.ID, &existingEnv.ProjectID, &existingEnv.ServiceID, &existingEnv.BranchName,
|
||||||
&existingEnv.PRNumber, &existingEnv.Environment, &existingEnv.Status, &existingEnv.URL,
|
&existingEnv.PRNumber, &existingEnv.Environment, &existingEnv.Status, &existingEnv.URL,
|
||||||
@@ -498,7 +475,7 @@ func handleDeletePreviewEnvironment(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if user owns the project
|
// 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"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
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
|
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment, pe.status
|
||||||
FROM preview_environments pe
|
FROM preview_environments pe
|
||||||
JOIN projects p ON pe.project_id = p.id
|
JOIN projects p ON pe.project_id = p.id
|
||||||
WHERE pe.id = $1 AND p.owner_id = $2`,
|
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
|
||||||
envID, userID,
|
envID, userID, contextIsAdmin(c),
|
||||||
).Scan(
|
).Scan(
|
||||||
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.Status,
|
&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
|
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment
|
||||||
FROM preview_environments pe
|
FROM preview_environments pe
|
||||||
JOIN projects p ON pe.project_id = p.id
|
JOIN projects p ON pe.project_id = p.id
|
||||||
WHERE p.owner_id = $1 AND pe.expires_at < NOW() AND pe.status != 'expired'`,
|
WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'`,
|
||||||
userID,
|
userID, contextIsAdmin(c),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find expired preview environments"})
|
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)
|
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 (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/database/sqlcdb"
|
"containr/internal/database/sqlcdb"
|
||||||
|
"containr/internal/deployment"
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -19,6 +21,7 @@ type Project struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
OwnerID string `json:"owner_id"`
|
OwnerID string `json:"owner_id"`
|
||||||
|
IsApproved bool `json:"is_approved"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -46,10 +49,8 @@ type UpdateProjectRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleGetProjects(c *gin.Context) {
|
func handleGetProjects(c *gin.Context) {
|
||||||
userID, ok := requireAuthenticatedUserUUID(c)
|
userID := optionalUserUUID(c)
|
||||||
if !ok {
|
isAdmin := contextIsAdmin(c)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
queries := sqlcdb.New(db.DB)
|
queries := sqlcdb.New(db.DB)
|
||||||
@@ -73,6 +74,7 @@ func handleGetProjects(c *gin.Context) {
|
|||||||
|
|
||||||
rows, err := queries.ListProjectsWithStatsByUser(c.Request.Context(), sqlcdb.ListProjectsWithStatsByUserParams{
|
rows, err := queries.ListProjectsWithStatsByUser(c.Request.Context(), sqlcdb.ListProjectsWithStatsByUserParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
IsAdmin: isAdmin,
|
||||||
Search: searchParam,
|
Search: searchParam,
|
||||||
LimitCount: int32(limit),
|
LimitCount: int32(limit),
|
||||||
OffsetCount: int32(offset),
|
OffsetCount: int32(offset),
|
||||||
@@ -88,8 +90,9 @@ func handleGetProjects(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
total, err := queries.CountProjectsByUser(c.Request.Context(), sqlcdb.CountProjectsByUserParams{
|
total, err := queries.CountProjectsByUser(c.Request.Context(), sqlcdb.CountProjectsByUserParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Search: searchParam,
|
IsAdmin: isAdmin,
|
||||||
|
Search: searchParam,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database count error"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database count error"})
|
||||||
@@ -169,10 +172,7 @@ func handleCreateProject(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleGetProject(c *gin.Context) {
|
func handleGetProject(c *gin.Context) {
|
||||||
userID, ok := requireAuthenticatedUserUUID(c)
|
userID := optionalUserUUID(c)
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
queries := sqlcdb.New(db.DB)
|
queries := sqlcdb.New(db.DB)
|
||||||
@@ -185,6 +185,7 @@ func handleGetProject(c *gin.Context) {
|
|||||||
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
|
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
IsAdmin: contextIsAdmin(c),
|
||||||
})
|
})
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
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{
|
role, err := queries.GetProjectRoleForUser(c.Request.Context(), sqlcdb.GetProjectRoleForUserParams{
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
IsAdmin: contextIsAdmin(c),
|
||||||
})
|
})
|
||||||
if errors.Is(err, sql.ErrNoRows) || role == "" || role == "viewer" {
|
if errors.Is(err, sql.ErrNoRows) || role == "" || role == "viewer" {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
|
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{
|
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
IsAdmin: contextIsAdmin(c),
|
||||||
})
|
})
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||||
@@ -305,11 +308,21 @@ func handleDeleteProject(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ownerID != userID {
|
if ownerID != userID && !contextIsAdmin(c) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Only project owners can delete projects"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Only project owners can delete projects"})
|
||||||
return
|
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)
|
deletedRows, err := queries.DeleteProjectByID(c.Request.Context(), projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete project"})
|
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 {
|
func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWithStats {
|
||||||
result := 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{
|
Stats: ProjectStats{
|
||||||
ServiceCount: int(row.ServiceCount),
|
ServiceCount: int(row.ServiceCount),
|
||||||
DeploymentCount: int(row.DeploymentCount),
|
DeploymentCount: int(row.DeploymentCount),
|
||||||
@@ -361,15 +374,16 @@ func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mapSQLCProject(project sqlcdb.Project) Project {
|
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{
|
return Project{
|
||||||
ID: id.String(),
|
ID: id.String(),
|
||||||
Name: name,
|
Name: name,
|
||||||
Description: nullableStringValue(description),
|
Description: nullableStringValue(description),
|
||||||
OwnerID: ownerID.String(),
|
OwnerID: ownerID.String(),
|
||||||
|
IsApproved: isApproved,
|
||||||
CreatedAt: nullableTimestampString(createdAt),
|
CreatedAt: nullableTimestampString(createdAt),
|
||||||
UpdatedAt: nullableTimestampString(updatedAt),
|
UpdatedAt: nullableTimestampString(updatedAt),
|
||||||
}
|
}
|
||||||
|
|||||||
+190
-122
@@ -35,6 +35,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
dockerClient = client
|
dockerClient = client
|
||||||
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
|
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
|
||||||
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
|
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
|
||||||
|
// Restore managed sidecars (e.g. cloudflared) from saved settings.
|
||||||
|
syncManagedContainers(dockerClient, db)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize build handler
|
// Initialize build handler
|
||||||
@@ -55,6 +57,33 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
haManager := ha.NewHighAvailabilityManager(scheduler, metricsCollector)
|
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)
|
haAPIManager := NewHAManager(haManager)
|
||||||
|
|
||||||
// Initialize scaling handler
|
// Initialize scaling handler
|
||||||
@@ -94,6 +123,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
c.Next()
|
c.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Seed the official service template catalog (idempotent upserts).
|
||||||
|
go SeedOfficialTemplates(context.Background(), db)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := haManager.Start(context.Background()); err != nil {
|
if err := haManager.Start(context.Background()); err != nil {
|
||||||
log.Printf("HA manager exited: %v", err)
|
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 {
|
if redis == nil {
|
||||||
redisStatus = "unhealthy"
|
redisStatus = "unhealthy"
|
||||||
checks["redis"] = redisStatus
|
checks["redis"] = "unhealthy"
|
||||||
checks["redisError"] = "redis client not initialized"
|
checks["redisError"] = "redis client not initialized"
|
||||||
overallStatus = "degraded"
|
overallStatus = "degraded"
|
||||||
statusCode = http.StatusServiceUnavailable
|
statusCode = http.StatusServiceUnavailable
|
||||||
@@ -175,151 +207,187 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
public.POST("/auth/register", handleRegister)
|
public.POST("/auth/register", handleRegister)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protected routes (authentication required)
|
// Read routes — public browsing. OptionalAuth resolves a session when
|
||||||
protected := v1.Group("/")
|
// present so handlers can widen visibility for owners/admins; anonymous
|
||||||
protected.Use(middleware.Auth(cfg.JWTSecret))
|
// callers see approved resources only.
|
||||||
|
read := v1.Group("/")
|
||||||
|
read.Use(middleware.OptionalAuth(cfg.JWTSecret))
|
||||||
{
|
{
|
||||||
// User routes
|
read.GET("/projects", handleGetProjects)
|
||||||
protected.GET("/user/profile", handleGetProfile)
|
read.GET("/projects/:id", handleGetProject)
|
||||||
protected.PUT("/user/profile", handleUpdateProfile)
|
read.GET("/projects/:id/services", handleGetServices)
|
||||||
protected.POST("/users", handleCreateUser)
|
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
|
read.GET("/services/:id", handleGetService)
|
||||||
protected.GET("/projects", handleGetProjects)
|
read.GET("/services/:id/metrics", handleGetServiceMetrics)
|
||||||
protected.POST("/projects", handleCreateProject)
|
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)
|
read.GET("/databases", databaseHandler.GetDatabases)
|
||||||
protected.GET("/projects/:id/services", handleGetServices)
|
read.GET("/databases/:id", databaseHandler.GetDatabase)
|
||||||
protected.POST("/projects/:id/services", handleCreateService)
|
|
||||||
|
|
||||||
// Generic project routes
|
read.GET("/templates", handleGetTemplates)
|
||||||
protected.GET("/projects/:id", handleGetProject)
|
read.GET("/templates/:id", handleGetTemplate)
|
||||||
protected.PUT("/projects/:id", handleUpdateProject)
|
|
||||||
protected.DELETE("/projects/:id", handleDeleteProject)
|
|
||||||
|
|
||||||
// Service routes
|
read.GET("/cron-jobs", handleGetCronJobs)
|
||||||
protected.GET("/services/:id", handleGetService)
|
read.GET("/cron-jobs/:id", handleGetCronJob)
|
||||||
protected.PUT("/services/:id", handleUpdateService)
|
read.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
|
||||||
protected.DELETE("/services/:id", handleDeleteService)
|
|
||||||
protected.GET("/services/:id/metrics", handleGetServiceMetrics)
|
|
||||||
|
|
||||||
// Deployment routes
|
read.GET("/security/scans/:id", securityHandler.GetSecurityScan)
|
||||||
protected.GET("/services/:id/deployments", handleGetDeployments)
|
read.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
|
||||||
protected.POST("/services/:id/deployments", handleCreateDeployment)
|
read.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
|
||||||
protected.GET("/deployments/:id", handleGetDeployment)
|
|
||||||
protected.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
|
||||||
|
|
||||||
// Environment variables routes
|
read.GET("/preview-environments/:id", handleGetPreviewEnvironment)
|
||||||
protected.GET("/services/:id/variables", handleGetVariables)
|
|
||||||
protected.PUT("/services/:id/variables", handleUpdateVariables)
|
|
||||||
|
|
||||||
// Logs routes
|
read.GET("/system/upgrade/status", handleGetUpgradeStatus)
|
||||||
protected.GET("/services/:id/logs", handleGetLogs)
|
read.GET("/system/host", handleGetHostMonitoring)
|
||||||
protected.GET("/deployments/:id/logs", handleGetDeploymentLogs)
|
|
||||||
|
|
||||||
// One-off exec console (docker exec, 30s ceiling)
|
// HA + scaling read endpoints (status, policies, health, alerts)
|
||||||
protected.POST("/services/:id/exec", handleExecInService)
|
haAPIManager.RegisterReadRoutes(read)
|
||||||
|
scalingHandler.RegisterReadRoutes(read)
|
||||||
|
}
|
||||||
|
|
||||||
// Git integration routes
|
// Authenticated routes — a valid session is required and handlers
|
||||||
protected.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
|
// enforce ownership; platform admins additionally pass every check.
|
||||||
protected.POST("/git/github-app/connect", handleConnectGitHubApp)
|
authed := v1.Group("/")
|
||||||
protected.GET("/git/providers", handleGetGitProviders)
|
authed.Use(middleware.Auth(cfg.JWTSecret))
|
||||||
protected.POST("/git/providers", handleCreateGitProvider)
|
{
|
||||||
protected.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
|
authed.GET("/user/profile", handleGetProfile)
|
||||||
protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
|
authed.PUT("/user/profile", handleUpdateProfile)
|
||||||
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)
|
|
||||||
|
|
||||||
// Build routes
|
// Personal access tokens for CLI/MCP/agent auth.
|
||||||
protected.POST("/builds", buildHandler.StartBuild)
|
authed.GET("/user/tokens", handleListUserTokens)
|
||||||
protected.GET("/builds", buildHandler.ListBuilds)
|
authed.POST("/user/tokens", handleCreateUserToken)
|
||||||
protected.GET("/builds/:id", buildHandler.GetBuildStatus)
|
authed.DELETE("/user/tokens/:id", handleDeleteUserToken)
|
||||||
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)
|
|
||||||
|
|
||||||
// System routes
|
authed.GET("/notifications", handleListNotifications)
|
||||||
protected.GET("/system/upgrade/status", handleGetUpgradeStatus)
|
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
|
||||||
protected.POST("/system/upgrade/pull", handlePullUpgradeImage)
|
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
|
||||||
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)
|
|
||||||
|
|
||||||
// WebSocket endpoint
|
// WebSocket endpoint
|
||||||
protected.GET("/ws", handleWebSocket)
|
authed.GET("/ws", handleWebSocket)
|
||||||
|
|
||||||
// Templates routes
|
// Owned-resource mutations — handlers scope to the caller and
|
||||||
protected.GET("/templates", handleGetTemplates)
|
// admit admins. New projects land unapproved until an admin
|
||||||
protected.GET("/templates/:id", handleGetTemplate)
|
// publishes them.
|
||||||
protected.POST("/templates/:id/deploy", handleCreateFromTemplate)
|
authed.POST("/projects", handleCreateProject)
|
||||||
|
authed.POST("/projects/:id/services", handleCreateService)
|
||||||
|
authed.PUT("/projects/:id", handleUpdateProject)
|
||||||
|
authed.DELETE("/projects/:id", handleDeleteProject)
|
||||||
|
|
||||||
// Cron Jobs routes
|
authed.PUT("/services/:id", handleUpdateService)
|
||||||
protected.GET("/cron-jobs", handleGetCronJobs)
|
authed.DELETE("/services/:id", handleDeleteService)
|
||||||
protected.POST("/cron-jobs", handleCreateCronJob)
|
authed.POST("/services/:id/start", handleServiceStart)
|
||||||
protected.GET("/cron-jobs/:id", handleGetCronJob)
|
authed.POST("/services/:id/stop", handleServiceStop)
|
||||||
protected.PUT("/cron-jobs/:id", handleUpdateCronJob)
|
authed.POST("/services/:id/restart", handleServiceRestart)
|
||||||
protected.DELETE("/cron-jobs/:id", handleDeleteCronJob)
|
authed.POST("/services/:id/redeploy", handleServiceRedeploy)
|
||||||
protected.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
|
authed.POST("/services/:id/deployments", handleCreateDeployment)
|
||||||
protected.POST("/cron-jobs/:id/trigger", handleTriggerCronJob)
|
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
||||||
|
|
||||||
// Audit Logs routes
|
// Environment variables — secrets, owner/admin only.
|
||||||
protected.GET("/audit-logs", handleGetAuditLogs)
|
authed.GET("/services/:id/variables", handleGetVariables)
|
||||||
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
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
|
// API Gateway routes (merged APwhy) - namespaced to avoid
|
||||||
// colliding with Containr's own /services paths.
|
// colliding with Containr's own /services paths.
|
||||||
gateway := protected.Group("/gateway")
|
gateway := admin.Group("/gateway")
|
||||||
{
|
{
|
||||||
gateway.GET("/services", handleAPwhyServicesList)
|
gateway.GET("/services", handleAPwhyServicesList)
|
||||||
gateway.POST("/services", handleAPwhyServicesCreate)
|
gateway.POST("/services", handleAPwhyServicesCreate)
|
||||||
gateway.PATCH("/services/:id", handleAPwhyServicesPatch)
|
gateway.PATCH("/services/:id", handleAPwhyServicesPatch)
|
||||||
gateway.GET("/services/:id/validate", handleAPwhyServiceValidate)
|
gateway.GET("/services/:id", handleAPwhyServiceValidate)
|
||||||
|
|
||||||
gateway.GET("/keys", handleAPwhyKeysList)
|
gateway.GET("/keys", handleAPwhyKeysList)
|
||||||
gateway.POST("/keys", handleAPwhyKeysCreate)
|
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
|
// RegisterReadRoutes registers public scaling read endpoints.
|
||||||
func (h *ScalingHandler) RegisterRoutes(router *gin.RouterGroup) {
|
func (h *ScalingHandler) RegisterReadRoutes(router *gin.RouterGroup) {
|
||||||
scaling := router.Group("/scaling")
|
scaling := router.Group("/scaling")
|
||||||
{
|
{
|
||||||
scaling.GET("/policies", h.GetScalingPolicies)
|
scaling.GET("/policies", h.GetScalingPolicies)
|
||||||
scaling.POST("/policies", h.SetScalingPolicy)
|
|
||||||
scaling.GET("/policies/:serviceId", h.GetScalingPolicy)
|
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", h.GetServiceStates)
|
||||||
scaling.GET("/services/:serviceId", h.GetServiceState)
|
scaling.GET("/services/:serviceId", h.GetServiceState)
|
||||||
scaling.GET("/services/:serviceId/history", h.GetScalingHistory)
|
scaling.GET("/services/:serviceId/history", h.GetScalingHistory)
|
||||||
|
|
||||||
scaling.POST("/services/:serviceId/scale", h.ManualScale)
|
|
||||||
|
|
||||||
scaling.GET("/status", h.GetScalingStatus)
|
scaling.GET("/status", h.GetScalingStatus)
|
||||||
scaling.POST("/enable", h.EnableAutoScaler)
|
|
||||||
scaling.POST("/disable", h.DisableAutoScaler)
|
|
||||||
|
|
||||||
scaling.GET("/metrics", h.GetScalingMetrics)
|
scaling.GET("/metrics", h.GetScalingMetrics)
|
||||||
scaling.GET("/events", h.GetScalingEvents)
|
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
|
// GetScalingPolicies returns all scaling policies
|
||||||
func (h *ScalingHandler) GetScalingPolicies(c *gin.Context) {
|
func (h *ScalingHandler) GetScalingPolicies(c *gin.Context) {
|
||||||
states := h.autoScaler.GetAllServiceStates()
|
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))
|
event, err := h.autoScaler.ManualScale(c.Request.Context(), serviceID, request.Replicas, strings.TrimSpace(request.Reason))
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,11 +231,19 @@ func (h *ScalingHandler) ManualScale(c *gin.Context) {
|
|||||||
return
|
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",
|
"message": "Service scaled successfully",
|
||||||
"event": event,
|
"event": event,
|
||||||
"state": state,
|
"state": state,
|
||||||
})
|
}
|
||||||
|
if runtimeErr != nil {
|
||||||
|
resp["runtime_error"] = runtimeErr.Error()
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetScalingStatus returns the overall status of the auto-scaler
|
// GetScalingStatus returns the overall status of the auto-scaler
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ func setupScalingTestRouter(t *testing.T) *gin.Engine {
|
|||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
handler := NewScalingHandler(autoScaler)
|
handler := NewScalingHandler(autoScaler)
|
||||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
group := router.Group("/api/v1")
|
||||||
|
handler.RegisterReadRoutes(group)
|
||||||
|
handler.RegisterAdminRoutes(group)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -589,16 +589,7 @@ func (sh *SecurityHandler) GetAuditLogs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string) (string, bool) {
|
func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string) (string, bool) {
|
||||||
userIDValue, exists := c.Get("user_id")
|
userID := optionalUserID(c)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := uuid.Parse(projectID); err != nil {
|
if _, err := uuid.Parse(projectID); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
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
|
SELECT 1
|
||||||
FROM projects p
|
FROM projects p
|
||||||
WHERE p.id = $1
|
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
|
SELECT 1 FROM project_members pm
|
||||||
WHERE pm.project_id = p.id AND pm.user_id = $2
|
WHERE pm.project_id = p.id AND pm.user_id = $2
|
||||||
))
|
))
|
||||||
)`,
|
)`,
|
||||||
projectID, userID,
|
projectID, optionalUserUUID(c), contextIsAdmin(c),
|
||||||
).Scan(&hasAccess)
|
).Scan(&hasAccess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"})
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
|
"containr/internal/deployment"
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -24,37 +26,54 @@ type Service struct {
|
|||||||
BuildPath string `json:"build_path" db:"build_path"`
|
BuildPath string `json:"build_path" db:"build_path"`
|
||||||
CPU string `json:"cpu" db:"cpu"`
|
CPU string `json:"cpu" db:"cpu"`
|
||||||
Memory string `json:"memory" db:"memory"`
|
Memory string `json:"memory" db:"memory"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
// Runtime spec
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateServiceRequest represents a request to create a service
|
// CreateServiceRequest represents a request to create a service
|
||||||
type CreateServiceRequest struct {
|
type CreateServiceRequest struct {
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
Name string `json:"name" binding:"required,min=1,max=255"`
|
Name string `json:"name" binding:"required,min=1,max=255"`
|
||||||
Type string `json:"type" binding:"required,oneof=web worker database cron"`
|
Type string `json:"type" binding:"required,oneof=web worker database cron"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Environment string `json:"environment" binding:"required,oneof=production preview development"`
|
Environment string `json:"environment" binding:"required,oneof=production preview development"`
|
||||||
GitRepo string `json:"git_repo"`
|
GitRepo string `json:"git_repo"`
|
||||||
GitBranch string `json:"git_branch"`
|
GitBranch string `json:"git_branch"`
|
||||||
BuildPath string `json:"build_path"`
|
BuildPath string `json:"build_path"`
|
||||||
CPU string `json:"cpu"`
|
CPU string `json:"cpu"`
|
||||||
Memory string `json:"memory"`
|
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
|
// UpdateServiceRequest represents a request to update a service
|
||||||
type UpdateServiceRequest struct {
|
type UpdateServiceRequest struct {
|
||||||
Name string `json:"name" binding:"omitempty,min=1,max=255"`
|
Name string `json:"name" binding:"omitempty,min=1,max=255"`
|
||||||
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
|
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
||||||
GitRepo string `json:"git_repo"`
|
GitRepo string `json:"git_repo"`
|
||||||
GitBranch string `json:"git_branch"`
|
GitBranch string `json:"git_branch"`
|
||||||
BuildPath string `json:"build_path"`
|
BuildPath string `json:"build_path"`
|
||||||
CPU string `json:"cpu"`
|
CPU string `json:"cpu"`
|
||||||
Memory string `json:"memory"`
|
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
|
// handleGetServices retrieves all services for a project
|
||||||
@@ -72,28 +91,14 @@ func handleGetServices(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if project exists and user has access
|
// Approved projects are public; unapproved ones need owner/member/admin.
|
||||||
var project Project
|
projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID)
|
||||||
err = db.(*database.DB).QueryRow(
|
if !projectExists {
|
||||||
"SELECT id, name, owner_id FROM projects WHERE id = $1",
|
|
||||||
projectID,
|
|
||||||
).Scan(&project.ID, &project.Name, &project.OwnerID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !allowed {
|
||||||
// Get user ID from JWT token (set by auth middleware)
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +115,9 @@ func handleGetServices(c *gin.Context) {
|
|||||||
COALESCE(build_path, ''),
|
COALESCE(build_path, ''),
|
||||||
COALESCE(cpu, ''),
|
COALESCE(cpu, ''),
|
||||||
COALESCE(memory, ''),
|
COALESCE(memory, ''),
|
||||||
|
COALESCE(replicas, 1), COALESCE(port, 0),
|
||||||
|
COALESCE(domain, ''), COALESCE(healthcheck_path, ''),
|
||||||
|
COALESCE(restart_policy, 'unless-stopped'),
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM services
|
FROM services
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
@@ -129,7 +137,8 @@ func handleGetServices(c *gin.Context) {
|
|||||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
|
||||||
@@ -138,6 +147,11 @@ func handleGetServices(c *gin.Context) {
|
|||||||
services = append(services, service)
|
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})
|
c.JSON(http.StatusOK, gin.H{"services": services})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +203,7 @@ func handleCreateService(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if user owns the project
|
// 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"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -213,21 +227,26 @@ func handleCreateService(c *gin.Context) {
|
|||||||
|
|
||||||
// Create new service
|
// Create new service
|
||||||
service := Service{
|
service := Service{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
ProjectID: req.ProjectID,
|
ProjectID: req.ProjectID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
Status: "stopped", // Initial status
|
Status: "stopped", // Initial status
|
||||||
Image: req.Image,
|
Image: req.Image,
|
||||||
Command: req.Command,
|
Command: req.Command,
|
||||||
Environment: req.Environment,
|
Environment: req.Environment,
|
||||||
GitRepo: req.GitRepo,
|
GitRepo: req.GitRepo,
|
||||||
GitBranch: req.GitBranch,
|
GitBranch: req.GitBranch,
|
||||||
BuildPath: req.BuildPath,
|
BuildPath: req.BuildPath,
|
||||||
CPU: req.CPU,
|
CPU: req.CPU,
|
||||||
Memory: req.Memory,
|
Memory: req.Memory,
|
||||||
CreatedAt: time.Now(),
|
Replicas: req.Replicas,
|
||||||
UpdatedAt: time.Now(),
|
Port: req.Port,
|
||||||
|
Domain: req.Domain,
|
||||||
|
HealthCheckPath: req.HealthCheckPath,
|
||||||
|
RestartPolicy: req.RestartPolicy,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default values if not provided
|
// Set default values if not provided
|
||||||
@@ -237,6 +256,12 @@ func handleCreateService(c *gin.Context) {
|
|||||||
if service.Memory == "" {
|
if service.Memory == "" {
|
||||||
service.Memory = "512Mi"
|
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)
|
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -248,15 +273,17 @@ func handleCreateService(c *gin.Context) {
|
|||||||
|
|
||||||
// Insert service into database
|
// Insert service into database
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
`INSERT INTO services
|
`INSERT INTO services
|
||||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||||
build_command, start_command, type, status, image, command, environment,
|
build_command, start_command, type, status, image, command, environment,
|
||||||
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at)
|
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`,
|
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,
|
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
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,
|
service.CreatedAt, service.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -303,14 +330,9 @@ func handleGetService(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user ID from JWT token
|
// Get service — public when its project is approved, otherwise
|
||||||
userID, exists := c.Get("user_id")
|
// owner/member/admin only.
|
||||||
if !exists {
|
userID := optionalUserUUID(c)
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get service with project ownership check
|
|
||||||
var service Service
|
var service Service
|
||||||
err = db.(*database.DB).QueryRow(
|
err = db.(*database.DB).QueryRow(
|
||||||
`SELECT s.id, s.project_id, s.name,
|
`SELECT s.id, s.project_id, s.name,
|
||||||
@@ -324,16 +346,21 @@ func handleGetService(c *gin.Context) {
|
|||||||
COALESCE(s.build_path, ''),
|
COALESCE(s.build_path, ''),
|
||||||
COALESCE(s.cpu, ''),
|
COALESCE(s.cpu, ''),
|
||||||
COALESCE(s.memory, ''),
|
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.created_at, s.updated_at
|
||||||
FROM services s
|
FROM services s
|
||||||
JOIN projects p ON s.project_id = p.id
|
JOIN projects p ON s.project_id = p.id
|
||||||
WHERE s.id = $1 AND p.owner_id = $2`,
|
WHERE s.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
|
||||||
serviceID, userID,
|
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
|
||||||
|
serviceID, userID, contextIsAdmin(c),
|
||||||
).Scan(
|
).Scan(
|
||||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&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 {
|
if err != nil {
|
||||||
@@ -341,6 +368,7 @@ func handleGetService(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
liveServiceStatus(c, db.(*database.DB), &service)
|
||||||
c.JSON(http.StatusOK, gin.H{"service": service})
|
c.JSON(http.StatusOK, gin.H{"service": service})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,16 +414,21 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
COALESCE(s.build_path, ''),
|
COALESCE(s.build_path, ''),
|
||||||
COALESCE(s.cpu, ''),
|
COALESCE(s.cpu, ''),
|
||||||
COALESCE(s.memory, ''),
|
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.created_at, s.updated_at
|
||||||
FROM services s
|
FROM services s
|
||||||
JOIN projects p ON s.project_id = p.id
|
JOIN projects p ON s.project_id = p.id
|
||||||
WHERE s.id = $1 AND p.owner_id = $2`,
|
WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
|
||||||
serviceID, userID,
|
serviceID, userID, contextIsAdmin(c),
|
||||||
).Scan(
|
).Scan(
|
||||||
&existingService.ID, &existingService.ProjectID, &existingService.Name, &existingService.Type,
|
&existingService.ID, &existingService.ProjectID, &existingService.Name, &existingService.Type,
|
||||||
&existingService.Status, &existingService.Image, &existingService.Command,
|
&existingService.Status, &existingService.Image, &existingService.Command,
|
||||||
&existingService.Environment, &existingService.GitRepo, &existingService.GitBranch,
|
&existingService.Environment, &existingService.GitRepo, &existingService.GitBranch,
|
||||||
&existingService.BuildPath, &existingService.CPU, &existingService.Memory,
|
&existingService.BuildPath, &existingService.CPU, &existingService.Memory,
|
||||||
|
&existingService.Replicas, &existingService.Port, &existingService.Domain,
|
||||||
|
&existingService.HealthCheckPath, &existingService.RestartPolicy,
|
||||||
&existingService.CreatedAt, &existingService.UpdatedAt,
|
&existingService.CreatedAt, &existingService.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -435,18 +468,37 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
if req.Memory != "" {
|
if req.Memory != "" {
|
||||||
existingService.Memory = 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()
|
existingService.UpdatedAt = time.Now()
|
||||||
|
|
||||||
// Update service in database
|
// Update service in database
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
`UPDATE services
|
`UPDATE services
|
||||||
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
|
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
|
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
|
||||||
WHERE id = $12`,
|
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.Name, existingService.Type, existingService.Image, existingService.Command,
|
||||||
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
||||||
existingService.BuildPath, existingService.CPU, existingService.Memory,
|
existingService.BuildPath, existingService.CPU, existingService.Memory,
|
||||||
|
existingService.Replicas, existingService.Port, existingService.Domain,
|
||||||
|
existingService.HealthCheckPath, existingService.RestartPolicy,
|
||||||
existingService.UpdatedAt, existingService.ID,
|
existingService.UpdatedAt, existingService.ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -455,6 +507,26 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
return
|
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})
|
c.JSON(http.StatusOK, gin.H{"service": existingService})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,11 +568,18 @@ func handleDeleteService(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if user owns the project
|
// 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"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
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)
|
// Delete service (cascade will handle related records)
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
"DELETE FROM services WHERE id = $1",
|
"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"`
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||||
DockerAvailable bool `json:"docker_available"`
|
DockerAvailable bool `json:"docker_available"`
|
||||||
Docker map[string]any `json:"docker,omitempty"`
|
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"`
|
CollectedAt string `json:"collected_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +162,7 @@ func handleGetHostMonitoring(c *gin.Context) {
|
|||||||
Storage: storage,
|
Storage: storage,
|
||||||
Load: load,
|
Load: load,
|
||||||
UptimeSeconds: uptime,
|
UptimeSeconds: uptime,
|
||||||
|
Scope: "host",
|
||||||
CollectedAt: time.Now().UTC().Format(time.RFC3339),
|
CollectedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +177,10 @@ func handleGetHostMonitoring(c *gin.Context) {
|
|||||||
"images": info.Images,
|
"images": info.Images,
|
||||||
"driver": info.Driver,
|
"driver": info.Driver,
|
||||||
"server": info.ServerVersion,
|
"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 (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/database/sqlcdb"
|
"containr/internal/database/sqlcdb"
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/sqlc-dev/pqtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServiceTemplate struct {
|
type ServiceTemplate struct {
|
||||||
@@ -24,6 +27,8 @@ type ServiceTemplate struct {
|
|||||||
Config string `json:"config" db:"config"`
|
Config string `json:"config" db:"config"`
|
||||||
Variables string `json:"variables" db:"variables"`
|
Variables string `json:"variables" db:"variables"`
|
||||||
IsOfficial bool `json:"is_official" db:"is_official"`
|
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"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -67,12 +72,18 @@ func handleGetTemplates(c *gin.Context) {
|
|||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
queries := sqlcdb.New(db.DB)
|
queries := sqlcdb.New(db.DB)
|
||||||
|
|
||||||
|
userID := optionalUserUUID(c)
|
||||||
|
owner := uuid.NullUUID{UUID: userID, Valid: userID != uuid.Nil}
|
||||||
|
|
||||||
var templateRows []sqlcdb.ServiceTemplate
|
var templateRows []sqlcdb.ServiceTemplate
|
||||||
var err error
|
var err error
|
||||||
if category != "" {
|
if category != "" {
|
||||||
templateRows, err = queries.ListServiceTemplatesByCategory(ctx, category)
|
templateRows, err = queries.ListServiceTemplatesByCategoryForUser(ctx, sqlcdb.ListServiceTemplatesByCategoryForUserParams{
|
||||||
|
Category: category,
|
||||||
|
OwnerID: owner,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
templateRows, err = queries.ListServiceTemplates(ctx)
|
templateRows, err = queries.ListServiceTemplatesForUser(ctx, owner)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
|
||||||
@@ -103,7 +114,7 @@ func handleGetTemplate(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
t := mapSQLCTemplate(row)
|
t := mapSQLCTemplate(row)
|
||||||
if t.ID == "" {
|
if t.ID == "" || !templateVisibleTo(c, t) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -169,7 +180,7 @@ func handleCreateFromTemplate(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch project"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch project"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ownerID.String() != userID {
|
if ownerID.String() != userID && !contextIsAdmin(c) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -185,8 +196,8 @@ func handleCreateFromTemplate(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
template := mapSQLCTemplate(templateRow)
|
template := mapSQLCTemplate(templateRow)
|
||||||
if template.ID == "" {
|
if template.ID == "" || !templateVisibleTo(c, template) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,6 +336,16 @@ func handleCreateFromTemplate(c *gin.Context) {
|
|||||||
return
|
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 {
|
for key, value := range envVars {
|
||||||
if strings.TrimSpace(key) == "" {
|
if strings.TrimSpace(key) == "" {
|
||||||
continue
|
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 {
|
func SeedTemplates() []ServiceTemplate {
|
||||||
templates := []ServiceTemplate{
|
templates := []ServiceTemplate{
|
||||||
{
|
{
|
||||||
@@ -480,7 +741,7 @@ func SeedTemplates() []ServiceTemplate {
|
|||||||
Name: "Background Worker",
|
Name: "Background Worker",
|
||||||
Description: "Background job processing service",
|
Description: "Background job processing service",
|
||||||
Category: "worker",
|
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"}`,
|
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}]`,
|
Variables: `[{"key":"WORKER_CONCURRENCY","label":"Concurrency","default":"4","required":false,"secret":false}]`,
|
||||||
IsOfficial: true,
|
IsOfficial: true,
|
||||||
@@ -490,7 +751,7 @@ func SeedTemplates() []ServiceTemplate {
|
|||||||
Name: "Cron Job",
|
Name: "Cron Job",
|
||||||
Description: "Scheduled task runner",
|
Description: "Scheduled task runner",
|
||||||
Category: "cron",
|
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"}`,
|
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}]`,
|
Variables: `[{"key":"CRON_SCHEDULE","label":"Schedule","default":"0 * * * *","required":true,"secret":false}]`,
|
||||||
IsOfficial: true,
|
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}]`,
|
Variables: `[{"key":"IMAGE","label":"Docker Image","default":"","required":true,"secret":false},{"key":"TAG","label":"Image Tag","default":"latest","required":false,"secret":false}]`,
|
||||||
IsOfficial: true,
|
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
|
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 {
|
func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
|
||||||
variables := "[]"
|
variables := "[]"
|
||||||
if row.Variables.Valid && len(row.Variables.RawMessage) > 0 {
|
if row.Variables.Valid && len(row.Variables.RawMessage) > 0 {
|
||||||
variables = string(row.Variables.RawMessage)
|
variables = string(row.Variables.RawMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ownerID := ""
|
||||||
|
if row.OwnerID.Valid {
|
||||||
|
ownerID = row.OwnerID.UUID.String()
|
||||||
|
}
|
||||||
|
|
||||||
return ServiceTemplate{
|
return ServiceTemplate{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
Name: row.Name,
|
Name: row.Name,
|
||||||
@@ -620,6 +1061,8 @@ func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
|
|||||||
Config: string(row.Config),
|
Config: string(row.Config),
|
||||||
Variables: variables,
|
Variables: variables,
|
||||||
IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool,
|
IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool,
|
||||||
|
OwnerID: ownerID,
|
||||||
|
IsPublic: row.IsPublic,
|
||||||
CreatedAt: templateNullTime(row.CreatedAt),
|
CreatedAt: templateNullTime(row.CreatedAt),
|
||||||
UpdatedAt: templateNullTime(row.UpdatedAt),
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, exists := c.Get("user_id")
|
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
|
||||||
if !exists {
|
if !found {
|
||||||
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 {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
||||||
if ownerCheck != userID.(string) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -123,26 +109,12 @@ func handleUpdateVariables(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, exists := c.Get("user_id")
|
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
|
||||||
if !exists {
|
if !found {
|
||||||
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 {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
||||||
if ownerCheck != userID.(string) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,10 +93,10 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
|
|||||||
return nil, fmt.Errorf("prebuilt image not specified")
|
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{}
|
auth := registry.AuthConfig{}
|
||||||
_, err := bm.dockerClient.PullImage(ctx, req.PrebuiltImage, auth)
|
if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to pull prebuilt image: %w", err)
|
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 != "" {
|
if req.ImageName != "" && req.ImageTag != "" {
|
||||||
targetImage := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
targetImage := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
||||||
if targetImage != req.PrebuiltImage {
|
if targetImage != req.PrebuiltImage {
|
||||||
err = bm.dockerClient.TagImage(ctx, req.PrebuiltImage, targetImage)
|
err := bm.dockerClient.TagImage(ctx, req.PrebuiltImage, targetImage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to tag image: %w", err)
|
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
|
// Push to registry if specified
|
||||||
if req.RegistryURL != "" {
|
if req.RegistryURL != "" {
|
||||||
err = bm.dockerClient.PushImage(ctx, req.PrebuiltImage, req.RegistryURL)
|
err := bm.dockerClient.PushImage(ctx, req.PrebuiltImage, req.RegistryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to push image: %w", err)
|
return nil, fmt.Errorf("failed to push image: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ func Execute() {
|
|||||||
cobra.CheckErr(rootCmd.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() {
|
func init() {
|
||||||
cobra.OnInitialize(initConfig)
|
cobra.OnInitialize(initConfig)
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ type Config struct {
|
|||||||
|
|
||||||
// Dashboard UI Configuration
|
// Dashboard UI Configuration
|
||||||
DashboardUIBasePath string `env:"DASHBOARD_UI_BASE_PATH" default:"/"`
|
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
|
// Environment represents the application environment
|
||||||
@@ -181,6 +187,10 @@ func Load() *Config {
|
|||||||
|
|
||||||
cfg.DashboardUIBasePath = getenv("DASHBOARD_UI_BASE_PATH", "/")
|
cfg.DashboardUIBasePath = getenv("DASHBOARD_UI_BASE_PATH", "/")
|
||||||
|
|
||||||
|
cfg.AdminEmail = getenv("ADMIN_EMAIL", "")
|
||||||
|
cfg.AdminPassword = getenv("ADMIN_PASSWORD", "")
|
||||||
|
cfg.AdminName = getenv("ADMIN_NAME", "")
|
||||||
|
|
||||||
// Validate configuration
|
// Validate configuration
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
panic(fmt.Sprintf("Invalid configuration: %v", err))
|
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")
|
return fmt.Errorf("SEED_DATA_ON_START must be false in production")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !c.CookieSecure {
|
// COOKIE_SECURE=false is legal for plain-HTTP self-hosted installs and is
|
||||||
return fmt.Errorf("COOKIE_SECURE must be true in production")
|
// reported as a startup warning in main.go rather than a fatal error.
|
||||||
}
|
|
||||||
|
|
||||||
if c.BetterAuthEnabled {
|
if c.BetterAuthEnabled {
|
||||||
if len(strings.TrimSpace(c.BetterAuthSecret)) < 32 ||
|
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")
|
t.Setenv("ENVIRONMENT", "production")
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
JWTSecret: "this-is-a-very-strong-production-secret-123",
|
JWTSecret: "this-is-a-very-strong-production-secret-123",
|
||||||
@@ -120,9 +120,10 @@ func TestValidateRejectsInsecureCookieInProduction(t *testing.T) {
|
|||||||
CORSCredentials: true,
|
CORSCredentials: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := cfg.Validate()
|
// Plain-HTTP self-hosted installs are a supported production deployment;
|
||||||
if err == nil || !strings.Contains(err.Error(), "COOKIE_SECURE must be true") {
|
// insecure cookies are allowed and surfaced as a startup warning.
|
||||||
t.Fatalf("expected COOKIE_SECURE validation error, got %v", err)
|
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
|
// Stats returns connection pool statistics for monitoring
|
||||||
func (db *DB) Stats() sql.DBStats {
|
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
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
|
const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :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
|
FROM database_services
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetDatabaseServiceByIDAndUserParams struct {
|
func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error) {
|
||||||
ID string `json:"id"`
|
row := q.db.QueryRowContext(ctx, getDatabaseServiceByID, id)
|
||||||
UserID string `json:"user_id"`
|
var i DatabaseService
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Type,
|
&i.Type,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
@@ -195,6 +176,94 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
|
|||||||
return i, err
|
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
|
const listDatabaseBackupsByDatabaseAndUser = `-- name: ListDatabaseBackupsByDatabaseAndUser :many
|
||||||
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at
|
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at
|
||||||
FROM database_backups b
|
FROM database_backups b
|
||||||
@@ -242,38 +311,24 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
|
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
|
FROM database_services
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListDatabaseServicesByUserRow struct {
|
func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) {
|
||||||
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) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, listDatabaseServicesByUser, userID)
|
rows, err := q.db.QueryContext(ctx, listDatabaseServicesByUser, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
items := []ListDatabaseServicesByUserRow{}
|
items := []DatabaseService{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListDatabaseServicesByUserRow
|
var i DatabaseService
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Type,
|
&i.Type,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ type Project struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description sql.NullString `json:"description"`
|
Description sql.NullString `json:"description"`
|
||||||
OwnerID uuid.UUID `json:"owner_id"`
|
OwnerID uuid.UUID `json:"owner_id"`
|
||||||
|
IsApproved bool `json:"is_approved"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -167,33 +168,39 @@ type ProjectMember struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description sql.NullString `json:"description"`
|
Description sql.NullString `json:"description"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
EnvironmentID uuid.UUID `json:"environment_id"`
|
EnvironmentID uuid.UUID `json:"environment_id"`
|
||||||
ServiceType string `json:"service_type"`
|
ServiceType string `json:"service_type"`
|
||||||
SourceType string `json:"source_type"`
|
SourceType string `json:"source_type"`
|
||||||
SourceUrl sql.NullString `json:"source_url"`
|
SourceUrl sql.NullString `json:"source_url"`
|
||||||
ImageName sql.NullString `json:"image_name"`
|
ImageName sql.NullString `json:"image_name"`
|
||||||
BuildCommand sql.NullString `json:"build_command"`
|
BuildCommand sql.NullString `json:"build_command"`
|
||||||
StartCommand sql.NullString `json:"start_command"`
|
StartCommand sql.NullString `json:"start_command"`
|
||||||
CpuLimit sql.NullInt32 `json:"cpu_limit"`
|
CpuLimit sql.NullInt32 `json:"cpu_limit"`
|
||||||
MemoryLimit sql.NullInt32 `json:"memory_limit"`
|
MemoryLimit sql.NullInt32 `json:"memory_limit"`
|
||||||
PublicUrl sql.NullString `json:"public_url"`
|
PublicUrl sql.NullString `json:"public_url"`
|
||||||
HealthCheckUrl sql.NullString `json:"health_check_url"`
|
HealthCheckUrl sql.NullString `json:"health_check_url"`
|
||||||
Status sql.NullString `json:"status"`
|
Status sql.NullString `json:"status"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
Type sql.NullString `json:"type"`
|
Type sql.NullString `json:"type"`
|
||||||
Image sql.NullString `json:"image"`
|
Image sql.NullString `json:"image"`
|
||||||
Command sql.NullString `json:"command"`
|
Command sql.NullString `json:"command"`
|
||||||
Environment sql.NullString `json:"environment"`
|
Environment sql.NullString `json:"environment"`
|
||||||
GitRepo sql.NullString `json:"git_repo"`
|
GitRepo sql.NullString `json:"git_repo"`
|
||||||
GitBranch sql.NullString `json:"git_branch"`
|
GitBranch sql.NullString `json:"git_branch"`
|
||||||
BuildPath sql.NullString `json:"build_path"`
|
BuildPath sql.NullString `json:"build_path"`
|
||||||
Cpu sql.NullString `json:"cpu"`
|
Cpu sql.NullString `json:"cpu"`
|
||||||
Memory sql.NullString `json:"memory"`
|
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 {
|
type ServiceTemplate struct {
|
||||||
@@ -205,6 +212,21 @@ type ServiceTemplate struct {
|
|||||||
Config json.RawMessage `json:"config"`
|
Config json.RawMessage `json:"config"`
|
||||||
Variables pqtype.NullRawMessage `json:"variables"`
|
Variables pqtype.NullRawMessage `json:"variables"`
|
||||||
IsOfficial sql.NullBool `json:"is_official"`
|
IsOfficial sql.NullBool `json:"is_official"`
|
||||||
|
OwnerID uuid.NullUUID `json:"owner_id"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_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
|
SELECT COUNT(*)::bigint AS total
|
||||||
FROM projects p
|
FROM projects p
|
||||||
WHERE
|
WHERE
|
||||||
(p.owner_id = $1 OR EXISTS (
|
(
|
||||||
SELECT 1
|
p.is_approved
|
||||||
FROM project_members pm
|
OR p.owner_id = $1
|
||||||
WHERE pm.project_id = p.id AND pm.user_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 (
|
AND (
|
||||||
$2::text IS NULL
|
$3::text IS NULL
|
||||||
OR p.name ILIKE ('%' || $2::text || '%')
|
OR p.name ILIKE ('%' || $3::text || '%')
|
||||||
OR COALESCE(p.description, '') ILIKE ('%' || $2::text || '%')
|
OR COALESCE(p.description, '') ILIKE ('%' || $3::text || '%')
|
||||||
)
|
)
|
||||||
`
|
`
|
||||||
|
|
||||||
type CountProjectsByUserParams struct {
|
type CountProjectsByUserParams struct {
|
||||||
UserID uuid.UUID `json:"user_id"`
|
UserID uuid.UUID `json:"user_id"`
|
||||||
Search sql.NullString `json:"search"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
Search sql.NullString `json:"search"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) {
|
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
|
var total int64
|
||||||
err := row.Scan(&total)
|
err := row.Scan(&total)
|
||||||
return total, err
|
return total, err
|
||||||
@@ -43,7 +49,7 @@ func (q *Queries) CountProjectsByUser(ctx context.Context, arg CountProjectsByUs
|
|||||||
const createProject = `-- name: CreateProject :one
|
const createProject = `-- name: CreateProject :one
|
||||||
INSERT INTO projects (name, description, owner_id)
|
INSERT INTO projects (name, description, owner_id)
|
||||||
VALUES ($1, $2, $3)
|
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 {
|
type CreateProjectParams struct {
|
||||||
@@ -60,6 +66,7 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (P
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
&i.OwnerID,
|
&i.OwnerID,
|
||||||
|
&i.IsApproved,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -80,11 +87,13 @@ func (q *Queries) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (i
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getProjectByIDForUser = `-- name: GetProjectByIDForUser :one
|
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
|
FROM projects p
|
||||||
WHERE p.id = $1
|
WHERE p.id = $1
|
||||||
AND (
|
AND (
|
||||||
p.owner_id = $2
|
p.is_approved
|
||||||
|
OR p.owner_id = $2
|
||||||
|
OR $3::bool
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM project_members pm
|
FROM project_members pm
|
||||||
@@ -96,16 +105,18 @@ WHERE p.id = $1
|
|||||||
type GetProjectByIDForUserParams struct {
|
type GetProjectByIDForUserParams struct {
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
UserID uuid.UUID `json:"user_id"`
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error) {
|
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
|
var i Project
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
&i.OwnerID,
|
&i.OwnerID,
|
||||||
|
&i.IsApproved,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -127,21 +138,22 @@ func (q *Queries) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID)
|
|||||||
|
|
||||||
const getProjectRoleForUser = `-- name: GetProjectRoleForUser :one
|
const getProjectRoleForUser = `-- name: GetProjectRoleForUser :one
|
||||||
SELECT (CASE
|
SELECT (CASE
|
||||||
WHEN p.owner_id = $1 THEN 'owner'
|
WHEN p.owner_id = $1 OR $2::bool THEN 'owner'
|
||||||
ELSE COALESCE(pm.role, '')
|
ELSE COALESCE(pm.role, '')
|
||||||
END)::text AS role
|
END)::text AS role
|
||||||
FROM projects p
|
FROM projects p
|
||||||
LEFT JOIN project_members pm ON p.id = pm.project_id AND pm.user_id = $1
|
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 {
|
type GetProjectRoleForUserParams struct {
|
||||||
UserID uuid.UUID `json:"user_id"`
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) {
|
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
|
var role string
|
||||||
err := row.Scan(&role)
|
err := row.Scan(&role)
|
||||||
return role, err
|
return role, err
|
||||||
@@ -162,12 +174,52 @@ func (q *Queries) InsertProjectEnvironment(ctx context.Context, arg InsertProjec
|
|||||||
return err
|
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
|
const listProjectsWithStatsByUser = `-- name: ListProjectsWithStatsByUser :many
|
||||||
|
|
||||||
SELECT
|
SELECT
|
||||||
p.id,
|
p.id,
|
||||||
p.name,
|
p.name,
|
||||||
p.description,
|
p.description,
|
||||||
p.owner_id,
|
p.owner_id,
|
||||||
|
p.is_approved,
|
||||||
p.created_at,
|
p.created_at,
|
||||||
p.updated_at,
|
p.updated_at,
|
||||||
COUNT(DISTINCT s.id)::bigint AS service_count,
|
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 services s ON s.project_id = p.id
|
||||||
LEFT JOIN deployments d ON d.service_id = s.id
|
LEFT JOIN deployments d ON d.service_id = s.id
|
||||||
WHERE
|
WHERE
|
||||||
(p.owner_id = $1 OR EXISTS (
|
(
|
||||||
SELECT 1
|
p.is_approved
|
||||||
FROM project_members pm
|
OR p.owner_id = $1
|
||||||
WHERE pm.project_id = p.id AND pm.user_id = $1
|
OR $2::bool
|
||||||
))
|
OR EXISTS (
|
||||||
AND (
|
SELECT 1
|
||||||
$2::text IS NULL
|
FROM project_members pm
|
||||||
OR p.name ILIKE ('%' || $2::text || '%')
|
WHERE pm.project_id = p.id AND pm.user_id = $1
|
||||||
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
|
ORDER BY p.updated_at DESC
|
||||||
LIMIT $4 OFFSET $3
|
LIMIT $5 OFFSET $4
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListProjectsWithStatsByUserParams struct {
|
type ListProjectsWithStatsByUserParams struct {
|
||||||
UserID uuid.UUID `json:"user_id"`
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
Search sql.NullString `json:"search"`
|
Search sql.NullString `json:"search"`
|
||||||
OffsetCount int32 `json:"offset_count"`
|
OffsetCount int32 `json:"offset_count"`
|
||||||
LimitCount int32 `json:"limit_count"`
|
LimitCount int32 `json:"limit_count"`
|
||||||
@@ -212,6 +270,7 @@ type ListProjectsWithStatsByUserRow struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description sql.NullString `json:"description"`
|
Description sql.NullString `json:"description"`
|
||||||
OwnerID uuid.UUID `json:"owner_id"`
|
OwnerID uuid.UUID `json:"owner_id"`
|
||||||
|
IsApproved bool `json:"is_approved"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
ServiceCount int64 `json:"service_count"`
|
ServiceCount int64 `json:"service_count"`
|
||||||
@@ -220,9 +279,13 @@ type ListProjectsWithStatsByUserRow struct {
|
|||||||
LastDeployment sql.NullTime `json:"last_deployment"`
|
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) {
|
func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listProjectsWithStatsByUser,
|
rows, err := q.db.QueryContext(ctx, listProjectsWithStatsByUser,
|
||||||
arg.UserID,
|
arg.UserID,
|
||||||
|
arg.IsAdmin,
|
||||||
arg.Search,
|
arg.Search,
|
||||||
arg.OffsetCount,
|
arg.OffsetCount,
|
||||||
arg.LimitCount,
|
arg.LimitCount,
|
||||||
@@ -239,6 +302,7 @@ func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProje
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
&i.OwnerID,
|
&i.OwnerID,
|
||||||
|
&i.IsApproved,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.ServiceCount,
|
&i.ServiceCount,
|
||||||
@@ -259,6 +323,25 @@ func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProje
|
|||||||
return items, nil
|
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
|
const updateProjectByID = `-- name: UpdateProjectByID :execrows
|
||||||
UPDATE projects
|
UPDATE projects
|
||||||
SET
|
SET
|
||||||
|
|||||||
@@ -25,10 +25,13 @@ type Querier interface {
|
|||||||
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
|
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
|
||||||
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
||||||
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) 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)
|
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
||||||
DeleteAgent(ctx context.Context, id string) error
|
DeleteAgent(ctx context.Context, id string) error
|
||||||
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
||||||
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, 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)
|
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
|
||||||
GetAgent(ctx context.Context, id string) (NodeAgent, error)
|
GetAgent(ctx context.Context, id string) (NodeAgent, error)
|
||||||
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (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)
|
GetContainer(ctx context.Context, id string) (ContainerInstance, error)
|
||||||
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
|
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
|
||||||
GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, arg GetDatabaseBackupByIDAndDatabaseAndUserParams) (DatabaseBackup, 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)
|
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
|
||||||
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
|
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
|
||||||
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||||
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
|
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
|
||||||
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
|
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
|
||||||
|
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
|
||||||
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
|
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
|
||||||
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
|
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
|
||||||
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
|
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
|
||||||
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
|
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
|
||||||
ListAgents(ctx context.Context) ([]NodeAgent, error)
|
ListAgents(ctx context.Context) ([]NodeAgent, error)
|
||||||
|
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
|
||||||
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||||
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
|
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
|
||||||
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, 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)
|
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
|
||||||
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
|
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
|
||||||
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, 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)
|
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
||||||
ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error)
|
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
||||||
ListServiceTemplatesByCategory(ctx context.Context, category string) ([]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
|
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
||||||
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
||||||
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, 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
|
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
||||||
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
||||||
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
|
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
|
||||||
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
|
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
|
||||||
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
|
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
|
||||||
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
|
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
|
||||||
|
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
|
||||||
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
|
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
|
||||||
|
TouchUserToken(ctx context.Context, id uuid.UUID) error
|
||||||
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
|
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
|
||||||
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
||||||
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
||||||
@@ -74,7 +89,9 @@ type Querier interface {
|
|||||||
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
|
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
|
||||||
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
|
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
|
||||||
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
|
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
|
||||||
|
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
|
||||||
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
|
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
|
||||||
|
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ Querier = (*Queries)(nil)
|
var _ Querier = (*Queries)(nil)
|
||||||
@@ -8,8 +8,10 @@ package sqlcdb
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/sqlc-dev/pqtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
const countServicesByProjectAndName = `-- name: CountServicesByProjectAndName :one
|
const countServicesByProjectAndName = `-- name: CountServicesByProjectAndName :one
|
||||||
@@ -122,6 +124,69 @@ func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServi
|
|||||||
return err
|
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
|
const getProjectOwnerID = `-- name: GetProjectOwnerID :one
|
||||||
SELECT owner_id
|
SELECT owner_id
|
||||||
FROM projects
|
FROM projects
|
||||||
@@ -136,7 +201,7 @@ func (q *Queries) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUI
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getServiceTemplateByID = `-- name: GetServiceTemplateByID :one
|
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
|
FROM service_templates
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -153,20 +218,29 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic
|
|||||||
&i.Config,
|
&i.Config,
|
||||||
&i.Variables,
|
&i.Variables,
|
||||||
&i.IsOfficial,
|
&i.IsOfficial,
|
||||||
|
&i.OwnerID,
|
||||||
|
&i.IsPublic,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const listServiceTemplates = `-- name: ListServiceTemplates :many
|
const listServiceTemplatesByCategoryForUser = `-- name: ListServiceTemplatesByCategoryForUser :many
|
||||||
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
|
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
|
ORDER BY is_official DESC, name ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error) {
|
type ListServiceTemplatesByCategoryForUserParams struct {
|
||||||
rows, err := q.db.QueryContext(ctx, listServiceTemplates)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -183,6 +257,8 @@ func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate,
|
|||||||
&i.Config,
|
&i.Config,
|
||||||
&i.Variables,
|
&i.Variables,
|
||||||
&i.IsOfficial,
|
&i.IsOfficial,
|
||||||
|
&i.OwnerID,
|
||||||
|
&i.IsPublic,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -199,15 +275,15 @@ func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate,
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const listServiceTemplatesByCategory = `-- name: ListServiceTemplatesByCategory :many
|
const listServiceTemplatesForUser = `-- name: ListServiceTemplatesForUser :many
|
||||||
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
|
FROM service_templates
|
||||||
WHERE category = $1
|
WHERE is_official = true OR is_public = true OR owner_id = $1
|
||||||
ORDER BY is_official DESC, name ASC
|
ORDER BY is_official DESC, name ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error) {
|
func (q *Queries) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listServiceTemplatesByCategory, category)
|
rows, err := q.db.QueryContext(ctx, listServiceTemplatesForUser, ownerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -224,6 +300,8 @@ func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category s
|
|||||||
&i.Config,
|
&i.Config,
|
||||||
&i.Variables,
|
&i.Variables,
|
||||||
&i.IsOfficial,
|
&i.IsOfficial,
|
||||||
|
&i.OwnerID,
|
||||||
|
&i.IsPublic,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -240,6 +318,51 @@ func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category s
|
|||||||
return items, nil
|
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
|
const upsertEnvironmentVariable = `-- name: UpsertEnvironmentVariable :exec
|
||||||
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
@@ -271,3 +394,51 @@ func (q *Queries) UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvir
|
|||||||
)
|
)
|
||||||
return err
|
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/build"
|
||||||
"containr/internal/docker"
|
"containr/internal/docker"
|
||||||
"containr/internal/types"
|
"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 {
|
type DeploymentEngine struct {
|
||||||
@@ -56,6 +52,16 @@ type ServiceConfig struct {
|
|||||||
Resources ResourceLimits `json:"resources,omitempty"`
|
Resources ResourceLimits `json:"resources,omitempty"`
|
||||||
HealthCheck *HealthCheck `json:"health_check,omitempty"`
|
HealthCheck *HealthCheck `json:"health_check,omitempty"`
|
||||||
Replicas int `json:"replicas"`
|
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 {
|
type PortMapping struct {
|
||||||
@@ -297,91 +303,54 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme
|
|||||||
return response.ImageName, nil
|
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 {
|
func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deployment) error {
|
||||||
// Convert service config to Docker container config
|
cfg := deployment.Config
|
||||||
containerConfig := &docker.ContainerConfig{
|
|
||||||
Name: fmt.Sprintf("containr-%s-%s", deployment.ServiceID, deployment.ID),
|
spec := RuntimeSpec{
|
||||||
Image: deployment.ImageName,
|
ProjectID: deployment.ProjectID,
|
||||||
Cmd: deployment.Config.Command,
|
ServiceID: deployment.ServiceID,
|
||||||
Labels: deployment.Config.Labels,
|
Name: cfg.Name,
|
||||||
Networks: make(map[string]*network.EndpointSettings),
|
Image: deployment.ImageName,
|
||||||
|
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 {
|
||||||
// Set environment variables
|
if spec.Port == 0 {
|
||||||
for k, v := range deployment.Config.Environment {
|
spec.Port = pm.ContainerPort
|
||||||
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
|
if spec.RestartPolicy == "" {
|
||||||
|
spec.RestartPolicy = "unless-stopped"
|
||||||
// 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
|
// Volume mounts aren't reconciled replicas-aware yet; still applied on the
|
||||||
for _, vm := range deployment.Config.VolumeMounts {
|
// container config of every replica via spec extension later if needed.
|
||||||
mount := mount.Mount{
|
state, err := de.ReconcileService(ctx, spec)
|
||||||
Type: mount.Type(vm.Type),
|
if err != nil {
|
||||||
Source: vm.Source,
|
return err
|
||||||
Target: vm.Destination,
|
|
||||||
ReadOnly: vm.ReadOnly,
|
|
||||||
}
|
|
||||||
containerConfig.Mounts = append(containerConfig.Mounts, mount)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create containers based on replica count
|
deployment.Containers = make([]ContainerInfo, len(state.Containers))
|
||||||
deployment.Containers = make([]ContainerInfo, deployment.Config.Replicas)
|
for i, c := range state.Containers {
|
||||||
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)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
deployment.Containers[i] = ContainerInfo{
|
deployment.Containers[i] = ContainerInfo{
|
||||||
ID: containerID,
|
ID: c.ID,
|
||||||
Name: containerName,
|
Name: c.Name,
|
||||||
Status: "running",
|
Status: c.State,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
StartedAt: time.Now(),
|
StartedAt: time.Now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/events"
|
"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/image"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/docker/api/types/registry"
|
"github.com/docker/docker/api/types/registry"
|
||||||
@@ -21,7 +26,8 @@ import (
|
|||||||
|
|
||||||
// Client wraps the Docker client with additional functionality
|
// Client wraps the Docker client with additional functionality
|
||||||
type Client struct {
|
type Client struct {
|
||||||
cli *client.Client
|
cli *client.Client
|
||||||
|
probeHost string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new Docker client
|
// NewClient creates a new Docker client
|
||||||
@@ -43,6 +49,75 @@ func NewClient() (*Client, error) {
|
|||||||
return &Client{cli: cli}, nil
|
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
|
// ListContainers returns all containers
|
||||||
func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Container, error) {
|
func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Container, error) {
|
||||||
return c.cli.ContainerList(ctx, container.ListOptions{
|
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
|
// GetContainer returns detailed information about a specific container
|
||||||
func (c *Client) GetContainer(ctx context.Context, containerID string) (types.ContainerJSON, error) {
|
func (c *Client) GetContainer(ctx context.Context, containerID string) (types.ContainerJSON, error) {
|
||||||
return c.cli.ContainerInspect(ctx, containerID)
|
return c.cli.ContainerInspect(ctx, containerID)
|
||||||
@@ -63,6 +146,7 @@ func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (s
|
|||||||
Env: config.Env,
|
Env: config.Env,
|
||||||
Labels: config.Labels,
|
Labels: config.Labels,
|
||||||
ExposedPorts: config.ExposedPorts,
|
ExposedPorts: config.ExposedPorts,
|
||||||
|
Healthcheck: config.Healthcheck,
|
||||||
}
|
}
|
||||||
|
|
||||||
hostConfig := &container.HostConfig{
|
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) {
|
func (c *Client) PullImage(ctx context.Context, ref string, auth registry.AuthConfig) (io.ReadCloser, error) {
|
||||||
authStr, _ := registry.EncodeAuthConfig(auth)
|
authStr, _ := registry.EncodeAuthConfig(auth)
|
||||||
return c.cli.ImagePull(ctx, ref, image.PullOptions{
|
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
|
// BuildImage builds an image from a Dockerfile
|
||||||
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
|
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
|
||||||
return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
|
return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package docker
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
@@ -24,6 +25,7 @@ type ContainerConfig struct {
|
|||||||
NanoCPUs int64
|
NanoCPUs int64
|
||||||
NetworkMode string
|
NetworkMode string
|
||||||
Networks map[string]*network.EndpointSettings
|
Networks map[string]*network.EndpointSettings
|
||||||
|
Healthcheck *container.HealthConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogOptions represents options for retrieving container logs
|
// LogOptions represents options for retrieving container logs
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ha
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -75,11 +76,13 @@ type HealthCheckConfig struct {
|
|||||||
|
|
||||||
// HealthChecker performs health checks on services and nodes
|
// HealthChecker performs health checks on services and nodes
|
||||||
type HealthChecker struct {
|
type HealthChecker struct {
|
||||||
scheduler *deployment.Scheduler
|
scheduler *deployment.Scheduler
|
||||||
checks map[string]*HealthCheck
|
checks map[string]*HealthCheck
|
||||||
results map[string]*HealthCheckResult
|
results map[string]*HealthCheckResult
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
checkInterval time.Duration
|
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
|
// HealthCheck represents a health check configuration
|
||||||
@@ -596,7 +599,11 @@ func (hc *HealthChecker) performHealthChecks(ctx context.Context) error {
|
|||||||
result := hc.performHealthCheck(ctx, check)
|
result := hc.performHealthCheck(ctx, check)
|
||||||
hc.mu.Lock()
|
hc.mu.Lock()
|
||||||
hc.results[check.ID] = result
|
hc.results[check.ID] = result
|
||||||
|
check.Status = result.Status
|
||||||
|
check.LastCheck = result.Timestamp
|
||||||
hc.mu.Unlock()
|
hc.mu.Unlock()
|
||||||
|
// Persist last-known state so a restart resumes with honest history.
|
||||||
|
hc.persistCheck(check)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -617,12 +624,20 @@ func (hc *HealthChecker) performHealthCheck(ctx context.Context, check *HealthCh
|
|||||||
checkCtx, cancel := context.WithTimeout(ctx, timeout)
|
checkCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
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 {
|
switch check.Type {
|
||||||
case HealthCheckTypeHTTP:
|
case HealthCheckTypeHTTP:
|
||||||
err = hc.performHTTPCheck(checkCtx, check)
|
err = hc.performHTTPCheck(checkCtx, check, host, port)
|
||||||
case HealthCheckTypeTCP:
|
case HealthCheckTypeTCP:
|
||||||
err = hc.performTCPCheck(checkCtx, check)
|
err = hc.performTCPCheck(checkCtx, check, host, port)
|
||||||
case HealthCheckTypeCommand:
|
case HealthCheckTypeCommand:
|
||||||
err = hc.performCommandCheck(checkCtx, check)
|
err = hc.performCommandCheck(checkCtx, check)
|
||||||
default:
|
default:
|
||||||
@@ -642,13 +657,11 @@ func (hc *HealthChecker) performHealthCheck(ctx context.Context, check *HealthCh
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthCheck) error {
|
func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthCheck, host string, port int) error {
|
||||||
host := hc.resolveHealthCheckHost(check)
|
|
||||||
protocol := strings.ToLower(strings.TrimSpace(check.Config.Protocol))
|
protocol := strings.ToLower(strings.TrimSpace(check.Config.Protocol))
|
||||||
if protocol == "" {
|
if protocol == "" {
|
||||||
protocol = "http"
|
protocol = "http"
|
||||||
}
|
}
|
||||||
port := check.Config.Port
|
|
||||||
if port <= 0 {
|
if port <= 0 {
|
||||||
if protocol == "https" {
|
if protocol == "https" {
|
||||||
port = 443
|
port = 443
|
||||||
@@ -684,9 +697,7 @@ func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthChec
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hc *HealthChecker) performTCPCheck(ctx context.Context, check *HealthCheck) error {
|
func (hc *HealthChecker) performTCPCheck(ctx context.Context, check *HealthCheck, host string, port int) error {
|
||||||
host := hc.resolveHealthCheckHost(check)
|
|
||||||
port := check.Config.Port
|
|
||||||
if port <= 0 {
|
if port <= 0 {
|
||||||
port = 80
|
port = 80
|
||||||
}
|
}
|
||||||
@@ -723,6 +734,27 @@ func (hc *HealthChecker) performCommandCheck(ctx context.Context, check *HealthC
|
|||||||
return nil
|
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 {
|
func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string {
|
||||||
if strings.TrimSpace(check.NodeID) != "" && hc.scheduler != nil {
|
if strings.TrimSpace(check.NodeID) != "" && hc.scheduler != nil {
|
||||||
nodes := hc.scheduler.GetNodes()
|
nodes := hc.scheduler.GetNodes()
|
||||||
@@ -739,19 +771,109 @@ func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string {
|
|||||||
return "127.0.0.1"
|
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
|
// AddHealthCheck adds a new health check
|
||||||
func (hc *HealthChecker) AddHealthCheck(check *HealthCheck) {
|
func (hc *HealthChecker) AddHealthCheck(check *HealthCheck) {
|
||||||
hc.mu.Lock()
|
hc.mu.Lock()
|
||||||
defer hc.mu.Unlock()
|
|
||||||
hc.checks[check.ID] = check
|
hc.checks[check.ID] = check
|
||||||
|
hc.mu.Unlock()
|
||||||
|
hc.persistCheck(check)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveHealthCheck removes a health check
|
// RemoveHealthCheck removes a health check
|
||||||
func (hc *HealthChecker) RemoveHealthCheck(checkID string) {
|
func (hc *HealthChecker) RemoveHealthCheck(checkID string) {
|
||||||
hc.mu.Lock()
|
hc.mu.Lock()
|
||||||
defer hc.mu.Unlock()
|
|
||||||
delete(hc.checks, checkID)
|
delete(hc.checks, checkID)
|
||||||
delete(hc.results, 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.
|
// GetHealthCheck returns one configured health check.
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
|
sqlcdb "containr/internal/database/sqlcdb"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -69,41 +72,143 @@ func RequestID() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
return id, email, "", hasToken, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokenErr = "Invalid token claims"
|
||||||
|
} else if tokenErr == "" {
|
||||||
|
tokenErr = "Invalid token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessionVerifier != nil {
|
||||||
|
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)
|
||||||
|
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
|
// Auth middleware for JWT authentication
|
||||||
func Auth(jwtSecret string) gin.HandlerFunc {
|
func Auth(jwtSecret string) gin.HandlerFunc {
|
||||||
sessionVerifier := newBetterAuthSessionVerifier()
|
sessionVerifier := newBetterAuthSessionVerifier()
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
tokenString, tokenErr, hasToken := extractJWTToken(c)
|
userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier)
|
||||||
if tokenString != "" {
|
if ok {
|
||||||
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
|
setAuthenticatedContext(c, userID, email)
|
||||||
userIDClaim, exists := claims["user_id"]
|
if !patScopeAllows(c) {
|
||||||
if exists {
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
userID := strings.TrimSpace(fmt.Sprint(userIDClaim))
|
"error": "Token scope is read-only",
|
||||||
if _, err := uuid.Parse(userID); err == nil {
|
"code": "SCOPE_INSUFFICIENT",
|
||||||
email := ""
|
})
|
||||||
if emailClaim, ok := claims["email"]; ok && emailClaim != nil {
|
c.Abort()
|
||||||
email = strings.TrimSpace(fmt.Sprint(emailClaim))
|
|
||||||
}
|
|
||||||
c.Set("user_id", userID)
|
|
||||||
c.Set("email", email)
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tokenErr = "Invalid token claims"
|
|
||||||
} else if tokenErr == "" {
|
|
||||||
tokenErr = "Invalid token"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if sessionVerifier != nil {
|
|
||||||
if userID, email, ok := sessionVerifier.resolveUser(c); ok {
|
|
||||||
c.Set("user_id", userID)
|
|
||||||
c.Set("email", email)
|
|
||||||
c.Next()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokenErr != "" {
|
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 {
|
type betterAuthSessionVerifier struct {
|
||||||
internalURL string
|
internalURL string
|
||||||
internalToken string
|
internalToken string
|
||||||
@@ -248,11 +385,18 @@ func ensureLocalUserRecord(c *gin.Context, user betterAuthSessionUser) (string,
|
|||||||
return "", hashErr
|
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(`
|
if err := db.QueryRow(`
|
||||||
INSERT INTO users (email, password_hash, name, avatar_url)
|
INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
|
||||||
VALUES ($1, $2, $3, NULLIF($4, ''))
|
VALUES ($1, $2, $3, NULLIF($4, ''), $5)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, email, string(hashedPassword), name, avatarURL).Scan(&localUserID); err != nil {
|
`, email, string(hashedPassword), name, avatarURL, isFirstUser).Scan(&localUserID); err != nil {
|
||||||
return "", err
|
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 {
|
func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
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
|
-- 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
|
FROM database_services
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY created_at DESC;
|
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
|
-- 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
|
FROM database_services
|
||||||
WHERE id = $1 AND user_id = $2;
|
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
|
-- name: ListProjectsWithStatsByUser :many
|
||||||
SELECT
|
SELECT
|
||||||
p.id,
|
p.id,
|
||||||
p.name,
|
p.name,
|
||||||
p.description,
|
p.description,
|
||||||
p.owner_id,
|
p.owner_id,
|
||||||
|
p.is_approved,
|
||||||
p.created_at,
|
p.created_at,
|
||||||
p.updated_at,
|
p.updated_at,
|
||||||
COUNT(DISTINCT s.id)::bigint AS service_count,
|
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 services s ON s.project_id = p.id
|
||||||
LEFT JOIN deployments d ON d.service_id = s.id
|
LEFT JOIN deployments d ON d.service_id = s.id
|
||||||
WHERE
|
WHERE
|
||||||
(p.owner_id = sqlc.arg(user_id) OR EXISTS (
|
(
|
||||||
SELECT 1
|
p.is_approved
|
||||||
FROM project_members pm
|
OR p.owner_id = sqlc.arg(user_id)
|
||||||
WHERE pm.project_id = p.id AND pm.user_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 (
|
AND (
|
||||||
sqlc.narg(search)::text IS NULL
|
sqlc.narg(search)::text IS NULL
|
||||||
OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%')
|
OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%')
|
||||||
OR COALESCE(p.description, '') 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
|
ORDER BY p.updated_at DESC
|
||||||
LIMIT sqlc.arg(limit_count) OFFSET sqlc.arg(offset_count);
|
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
|
SELECT COUNT(*)::bigint AS total
|
||||||
FROM projects p
|
FROM projects p
|
||||||
WHERE
|
WHERE
|
||||||
(p.owner_id = sqlc.arg(user_id) OR EXISTS (
|
(
|
||||||
SELECT 1
|
p.is_approved
|
||||||
FROM project_members pm
|
OR p.owner_id = sqlc.arg(user_id)
|
||||||
WHERE pm.project_id = p.id AND pm.user_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 (
|
AND (
|
||||||
sqlc.narg(search)::text IS NULL
|
sqlc.narg(search)::text IS NULL
|
||||||
OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%')
|
OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%')
|
||||||
@@ -51,11 +66,13 @@ WHERE
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- name: GetProjectByIDForUser :one
|
-- 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
|
FROM projects p
|
||||||
WHERE p.id = sqlc.arg(project_id)
|
WHERE p.id = sqlc.arg(project_id)
|
||||||
AND (
|
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 (
|
OR EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM project_members pm
|
FROM project_members pm
|
||||||
@@ -66,7 +83,7 @@ WHERE p.id = sqlc.arg(project_id)
|
|||||||
-- name: CreateProject :one
|
-- name: CreateProject :one
|
||||||
INSERT INTO projects (name, description, owner_id)
|
INSERT INTO projects (name, description, owner_id)
|
||||||
VALUES (sqlc.arg(name), sqlc.narg(description), sqlc.arg(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
|
-- name: InsertProjectEnvironment :exec
|
||||||
INSERT INTO environments (name, project_id)
|
INSERT INTO environments (name, project_id)
|
||||||
@@ -74,7 +91,7 @@ VALUES (sqlc.arg(name), sqlc.arg(project_id));
|
|||||||
|
|
||||||
-- name: GetProjectRoleForUser :one
|
-- name: GetProjectRoleForUser :one
|
||||||
SELECT (CASE
|
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, '')
|
ELSE COALESCE(pm.role, '')
|
||||||
END)::text AS role
|
END)::text AS role
|
||||||
FROM projects p
|
FROM projects p
|
||||||
@@ -97,3 +114,14 @@ WHERE id = sqlc.arg(project_id);
|
|||||||
-- name: DeleteProjectByID :execrows
|
-- name: DeleteProjectByID :execrows
|
||||||
DELETE FROM projects
|
DELETE FROM projects
|
||||||
WHERE id = sqlc.arg(project_id);
|
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
|
-- name: ListServiceTemplatesForUser :many
|
||||||
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
|
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;
|
ORDER BY is_official DESC, name ASC;
|
||||||
|
|
||||||
-- name: ListServiceTemplatesByCategory :many
|
-- name: ListServiceTemplatesByCategoryForUser :many
|
||||||
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
|
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;
|
ORDER BY is_official DESC, name ASC;
|
||||||
|
|
||||||
-- name: GetServiceTemplateByID :one
|
-- 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
|
FROM service_templates
|
||||||
WHERE id = $1;
|
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
|
-- name: GetProjectOwnerID :one
|
||||||
SELECT owner_id
|
SELECT owner_id
|
||||||
FROM projects
|
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,
|
name VARCHAR(255) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
owner_id UUID NOT NULL,
|
owner_id UUID NOT NULL,
|
||||||
|
is_approved BOOLEAN NOT NULL DEFAULT false,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_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),
|
git_branch VARCHAR(100),
|
||||||
build_path VARCHAR(500),
|
build_path VARCHAR(500),
|
||||||
cpu VARCHAR(50),
|
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 (
|
CREATE TABLE deployments (
|
||||||
@@ -82,6 +89,8 @@ CREATE TABLE service_templates (
|
|||||||
config JSONB NOT NULL,
|
config JSONB NOT NULL,
|
||||||
variables JSONB DEFAULT '[]',
|
variables JSONB DEFAULT '[]',
|
||||||
is_official BOOLEAN DEFAULT false,
|
is_official BOOLEAN DEFAULT false,
|
||||||
|
owner_id UUID,
|
||||||
|
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_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,
|
read_at TIMESTAMP WITH TIME ZONE,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
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 source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build-time API URL injection for Vite
|
# Build-time API URL injection for Vite. Empty = same-origin via the nginx
|
||||||
ARG VITE_API_URL=http://localhost:8082
|
# /api/ proxy, which keeps the published image usable from any host.
|
||||||
ARG VITE_AUTH_URL=http://localhost:8082/api/auth
|
ARG VITE_API_URL=
|
||||||
|
ARG VITE_AUTH_URL=
|
||||||
ENV VITE_API_URL=${VITE_API_URL}
|
ENV VITE_API_URL=${VITE_API_URL}
|
||||||
ENV VITE_AUTH_URL=${VITE_AUTH_URL}
|
ENV VITE_AUTH_URL=${VITE_AUTH_URL}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ http {
|
|||||||
keepalive_timeout 65;
|
keepalive_timeout 65;
|
||||||
types_hash_max_size 2048;
|
types_hash_max_size 2048;
|
||||||
|
|
||||||
|
# WebSocket upgrade support
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
# Gzip compression
|
# Gzip compression
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
@@ -51,6 +57,22 @@ http {
|
|||||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" 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
|
# Handle React Router
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
Generated
+39
@@ -32,6 +32,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
@@ -40,7 +41,9 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dompurify": "^3.2.6",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
|
"marked": "^18.0.13",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -3719,6 +3722,15 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -3763,6 +3775,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
@@ -5205,6 +5223,15 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -6577,6 +6604,18 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:check": "tsc -b && 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",
|
"generate:api": "openapi-typescript ../../docs/api/openapi.yaml -o src/generated/api-types.ts",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@@ -40,6 +43,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
@@ -48,7 +52,9 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dompurify": "^3.2.6",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
|
"marked": "^18.0.13",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^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(await screen.findByRole('heading', { name: /^projects\s*_?$/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText(/recent deployments/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 () => {
|
it('renders builds page in demo mode', async () => {
|
||||||
|
|||||||
+106
-19
@@ -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 { PlatformShell } from './layout/PlatformShell';
|
||||||
import { ProjectsPage } from '@/features/projects/pages/ProjectsPage';
|
import { ProjectsPage } from '@/features/projects/pages/ProjectsPage';
|
||||||
import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage';
|
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 { BuildsPage } from '@/features/builds/pages/BuildsPage';
|
||||||
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
|
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
|
||||||
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
|
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
|
||||||
|
import { AdminPage } from '@/features/admin/pages/AdminPage';
|
||||||
|
import { LandingPage } from '@/features/landing/LandingPage';
|
||||||
import {
|
import {
|
||||||
DocsPage,
|
DocsPage,
|
||||||
PeoplePage,
|
PeoplePage,
|
||||||
@@ -19,12 +23,13 @@ import {
|
|||||||
} from '@/features/secondary/pages';
|
} from '@/features/secondary/pages';
|
||||||
import { SignInPage, SignUpPage } from '@/features/auth/pages';
|
import { SignInPage, SignUpPage } from '@/features/auth/pages';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
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';
|
import { ErrorBoundary, LoadingState } from '@/shared/components';
|
||||||
|
|
||||||
function AuthRequired() {
|
function AuthRequired() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const searchParams = new URLSearchParams(location.search);
|
const isDemoMode = useDemoMode();
|
||||||
const isDemoMode = searchParams.get('demo') === '1';
|
|
||||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||||
|
|
||||||
if (isDemoMode) {
|
if (isDemoMode) {
|
||||||
@@ -47,38 +52,120 @@ function AuthRequired() {
|
|||||||
return <Outlet />;
|
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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/projects" replace />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/auth/sign-in" element={<SignInPage />} />
|
<Route path="/auth/sign-in" element={<SignInPage />} />
|
||||||
<Route path="/auth/sign-up" element={<SignUpPage />} />
|
<Route path="/auth/sign-up" element={<SignUpPage />} />
|
||||||
|
|
||||||
<Route element={<AuthRequired />}>
|
{/* Public browsing — approved projects, templates, docs. */}
|
||||||
<Route element={<PlatformShell />}>
|
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
|
||||||
<Route path="/projects" element={<ProjectsPage />} />
|
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
|
||||||
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
|
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
|
||||||
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
|
|
||||||
<Route path="/builds" element={<BuildsPage />} />
|
|
||||||
|
|
||||||
<Route path="/templates" element={<TemplatesPage />} />
|
<Route element={<PlatformShell />}>
|
||||||
<Route path="/usage" element={<UsagePage />} />
|
<Route path="/projects" element={<ProjectsPage />} />
|
||||||
<Route path="/databases" element={<DatabasesPage />} />
|
<Route path="/templates" element={<TemplatesPage />} />
|
||||||
<Route path="/ha" element={<HighAvailabilityPage />} />
|
<Route path="/databases" element={<DatabasesPage />} />
|
||||||
<Route path="/security" element={<SecurityPage />} />
|
<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="/people" element={<PeoplePage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/settings/audit-logs" element={<AuditLogsPage />} />
|
<Route path="/settings/audit-logs" element={<AuditLogsPage />} />
|
||||||
<Route path="/docs" element={<DocsPage />} />
|
|
||||||
<Route path="/showcase" element={<ComponentShowcase />} />
|
<Route path="/showcase" element={<ComponentShowcase />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Full-bleed design demo — renders its own chrome, no app shell */}
|
{/* Platform owner only */}
|
||||||
<Route path="/metrics-demo" element={<ServiceMetricsDashboard />} />
|
<Route element={<AdminRequired />}>
|
||||||
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/projects" replace />} />
|
{/* Full-bleed design demo — renders its own chrome, no app shell */}
|
||||||
|
<Route element={<AuthRequired />}>
|
||||||
|
<Route path="/metrics-demo" element={<ServiceMetricsDashboard />} />
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,27 +1,35 @@
|
|||||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ChartBar,
|
ChartBar,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ChevronsLeft,
|
||||||
Container,
|
Container,
|
||||||
Database,
|
Database,
|
||||||
|
FileText,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
|
LogIn,
|
||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
|
PanelLeft,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
ScrollText,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Sun,
|
Sun,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
|
User,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
|
getCurrentUserProfile,
|
||||||
getUpgradeStatus,
|
getUpgradeStatus,
|
||||||
listNotifications,
|
listNotifications,
|
||||||
markAllNotificationsRead,
|
markAllNotificationsRead,
|
||||||
@@ -29,28 +37,66 @@ import {
|
|||||||
pullUpgradeImage,
|
pullUpgradeImage,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { signOutAuthSession } from '@/lib/auth-client';
|
import { signOutAuthSession } from '@/lib/auth-client';
|
||||||
|
import { isDemoSearch } from '@/lib/demo-mode';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
import { useToast } from '@/shared/components';
|
import { useToast } from '@/shared/components';
|
||||||
|
|
||||||
const navItems = [
|
type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string };
|
||||||
{ label: 'Projects', href: '/projects', icon: FolderKanban },
|
type NavSection = { title: string; items: NavItem[] };
|
||||||
{ label: 'Templates', href: '/templates', icon: LayoutTemplate },
|
|
||||||
{ label: 'Databases', href: '/databases', icon: Database },
|
const navSections: NavSection[] = [
|
||||||
{ label: 'HA', href: '/ha', icon: ShieldCheck },
|
{
|
||||||
{ label: 'Security', href: '/security', icon: Shield },
|
title: 'Build',
|
||||||
{ label: 'Usage', href: '/usage', icon: ChartBar },
|
items: [
|
||||||
{ label: 'People', href: '/people', icon: Users },
|
{ label: 'Projects', href: '/projects', icon: FolderKanban, hint: 'Services & deployments' },
|
||||||
{ label: 'Settings', href: '/settings', icon: Settings },
|
{ label: 'Templates', href: '/templates', icon: LayoutTemplate, hint: 'Deployable presets' },
|
||||||
{ label: 'Docs', href: '/docs', icon: BookOpen },
|
{ 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';
|
type ThemeMode = 'dark' | 'light';
|
||||||
|
|
||||||
function getInitialTheme(): ThemeMode {
|
function readStorage(key: string): string | null {
|
||||||
if (typeof window === 'undefined') {
|
try {
|
||||||
return 'dark';
|
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() {
|
export function PlatformShell() {
|
||||||
@@ -58,12 +104,32 @@ export function PlatformShell() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { showToast } = useToast();
|
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);
|
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 [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
|
||||||
|
const [sidebarExpanded, setSidebarExpanded] = useState<boolean>(() => getInitialSidebar());
|
||||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||||
|
|
||||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
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({
|
const upgradeQuery = useQuery({
|
||||||
queryKey: ['upgrade-status'],
|
queryKey: ['upgrade-status'],
|
||||||
queryFn: getUpgradeStatus,
|
queryFn: getUpgradeStatus,
|
||||||
@@ -72,7 +138,7 @@ export function PlatformShell() {
|
|||||||
const notificationsQuery = useQuery({
|
const notificationsQuery = useQuery({
|
||||||
queryKey: ['shell-notifications'],
|
queryKey: ['shell-notifications'],
|
||||||
queryFn: () => listNotifications(20),
|
queryFn: () => listNotifications(20),
|
||||||
enabled: !isDemoMode,
|
enabled: !isDemoMode && signedIn,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
const markReadMutation = useMutation({
|
const markReadMutation = useMutation({
|
||||||
@@ -86,9 +152,13 @@ export function PlatformShell() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
localStorage.setItem('containr.theme', theme);
|
writeStorage('containr.theme', theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
writeStorage('containr.sidebar.expanded', sidebarExpanded ? '1' : '0');
|
||||||
|
}, [sidebarExpanded]);
|
||||||
|
|
||||||
const pullMutation = useMutation({
|
const pullMutation = useMutation({
|
||||||
mutationFn: pullUpgradeImage,
|
mutationFn: pullUpgradeImage,
|
||||||
onSuccess: (status) => {
|
onSuccess: (status) => {
|
||||||
@@ -115,8 +185,8 @@ export function PlatformShell() {
|
|||||||
const isActiveRoute = (itemHref: string) =>
|
const isActiveRoute = (itemHref: string) =>
|
||||||
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
|
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
|
||||||
|
|
||||||
const userName = sessionQuery.data?.user.name ?? 'Account';
|
const userName = isDemoMode ? 'Demo workspace' : profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account';
|
||||||
const userEmail = sessionQuery.data?.user.email ?? 'Local session';
|
const userEmail = isDemoMode ? 'Sample data · read-only' : profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session';
|
||||||
const initials = userName
|
const initials = userName
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.map((part) => part[0])
|
.map((part) => part[0])
|
||||||
@@ -124,68 +194,191 @@ export function PlatformShell() {
|
|||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.toUpperCase() || 'C';
|
.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 (
|
return (
|
||||||
<div className="app-shell min-h-screen">
|
<div className="app-shell min-h-screen">
|
||||||
<div className="ambient-glow" />
|
<div className="ambient-glow" />
|
||||||
|
|
||||||
<div className="relative flex min-h-screen">
|
<div className="relative flex min-h-screen">
|
||||||
<aside
|
<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={{
|
style={{
|
||||||
width: '62px',
|
width: sidebarExpanded ? '218px' : '62px',
|
||||||
background: 'var(--bg-base)',
|
background: 'var(--bg-base)',
|
||||||
borderRightColor: 'var(--border-subtle)',
|
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'}`}>
|
||||||
<img src="/containr.svg" alt="Containr" className="h-[38px] w-[38px] rounded-xl" />
|
<NavLink to={href('/projects')} title="Containr">
|
||||||
</NavLink>
|
<img src="/containr.svg" alt="Containr" className="h-[38px] w-[38px] rounded-xl" />
|
||||||
|
</NavLink>
|
||||||
<nav className="flex flex-col items-center gap-1.5">
|
|
||||||
{navItems.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 ${
|
|
||||||
isActive
|
|
||||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
|
||||||
: 'text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-secondary)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isActive ? (
|
|
||||||
<span
|
|
||||||
className="absolute -left-[11px] h-4 w-[2.5px] rounded-full"
|
|
||||||
style={{ background: 'var(--accent-primary)' }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Icon size={17} />
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void signOut()}
|
onClick={() => setSidebarExpanded((v) => !v)}
|
||||||
title={`Sign out (${userName})`}
|
title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||||
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="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)]"
|
||||||
>
|
>
|
||||||
<LogOut size={16} />
|
{sidebarExpanded ? <ChevronsLeft size={15} /> : <PanelLeft size={15} />}
|
||||||
</button>
|
</button>
|
||||||
<div
|
</div>
|
||||||
title={userEmail}
|
|
||||||
className="flex h-[30px] w-[30px] items-center justify-center rounded-full text-[11px] font-bold"
|
<nav className="mt-3 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||||
style={{ background: 'linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))', color: 'var(--accent-on)' }}
|
{visibleNavSections.map((section) => (
|
||||||
>
|
<div key={section.title}>
|
||||||
{initials}
|
{sidebarExpanded ? (
|
||||||
</div>
|
<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={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)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive ? (
|
||||||
|
<span
|
||||||
|
className="absolute -left-2 h-4 w-[2.5px] rounded-full"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<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 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()}
|
||||||
|
className={`${menuItemClass} text-[var(--error)] hover:!bg-[var(--error-soft)] hover:!text-[var(--error)]`}
|
||||||
|
>
|
||||||
|
<LogOut size={14} /> Sign out
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LogIn size={16} className="shrink-0" />
|
||||||
|
{sidebarExpanded ? <span className="text-[12.5px] font-medium">Sign in</span> : null}
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -214,7 +407,7 @@ export function PlatformShell() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-4">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
|
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
|
||||||
@@ -225,93 +418,97 @@ export function PlatformShell() {
|
|||||||
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
|
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
{isAdmin && !isDemoMode ? (
|
||||||
type="button"
|
|
||||||
onClick={() => pullMutation.mutate()}
|
|
||||||
disabled={isDemoMode || 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'}
|
|
||||||
>
|
|
||||||
{pullMutation.isPending ? <RefreshCw size={14} className="animate-spin" /> : <UploadCloud size={14} />}
|
|
||||||
Upgrade
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setNotificationsOpen((open) => !open)}
|
onClick={() => pullMutation.mutate()}
|
||||||
className="relative flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
disabled={pullMutation.isPending || !upgradeQuery.data?.imageRef}
|
||||||
style={{ borderColor: 'var(--border-subtle)' }}
|
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"
|
||||||
aria-label="Notifications"
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
title={upgradeQuery.data?.message || 'Pull latest configured image'}
|
||||||
>
|
>
|
||||||
<Bell size={15} />
|
{pullMutation.isPending ? <RefreshCw size={14} className="animate-spin" /> : <UploadCloud size={14} />}
|
||||||
{unreadCount > 0 ? (
|
Upgrade
|
||||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-primary)] px-1 text-[10px] font-semibold text-[var(--accent-on)]">
|
|
||||||
{unreadCount > 99 ? '99+' : unreadCount}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{notificationsOpen ? (
|
{signedIn && !isDemoMode ? (
|
||||||
<div className="absolute right-0 top-10 z-50 w-80 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-3 shadow-2xl">
|
<div className="relative">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<button
|
||||||
<div>
|
type="button"
|
||||||
<p className="text-sm font-semibold text-[var(--text-primary)]">Notifications</p>
|
onClick={() => setNotificationsOpen((open) => !open)}
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">
|
className="relative flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
{unreadCount > 0 ? `${unreadCount} unread` : 'All caught up'}
|
style={{ borderColor: 'var(--border-subtle)' }}
|
||||||
</p>
|
aria-label="Notifications"
|
||||||
</div>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<Bell size={15} />
|
||||||
{unreadCount > 0 ? (
|
{unreadCount > 0 ? (
|
||||||
<button
|
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-primary)] px-1 text-[10px] font-semibold text-[var(--accent-on)]">
|
||||||
type="button"
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
onClick={() => markAllReadMutation.mutate()}
|
</span>
|
||||||
className="rounded-[var(--radius-sm)] px-2 py-1 text-xs text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
) : null}
|
||||||
>
|
</button>
|
||||||
Mark all read
|
|
||||||
</button>
|
{notificationsOpen ? (
|
||||||
) : null}
|
<div className="absolute right-0 top-10 z-50 w-80 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-3 shadow-2xl">
|
||||||
<button
|
<div className="mb-2 flex items-center justify-between">
|
||||||
type="button"
|
<div>
|
||||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] })}
|
<p className="text-sm font-semibold text-[var(--text-primary)]">Notifications</p>
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
<p className="text-xs text-[var(--text-tertiary)]">
|
||||||
aria-label="Refresh notifications"
|
{unreadCount > 0 ? `${unreadCount} unread` : 'All caught up'}
|
||||||
>
|
</p>
|
||||||
<RefreshCw size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-80 space-y-2 overflow-y-auto">
|
|
||||||
{notifications.length > 0 ? (
|
|
||||||
notifications.map((item) => (
|
|
||||||
<button
|
|
||||||
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)]"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
{!item.read_at ? (
|
|
||||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--accent-primary)]" />
|
|
||||||
) : null}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className={`text-sm ${item.read_at ? 'text-[var(--text-secondary)]' : 'font-medium text-[var(--text-primary)]'}`}>{item.title}</p>
|
|
||||||
<p className="mt-1 truncate text-xs text-[var(--text-tertiary)]">{item.body}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3 text-sm text-[var(--text-secondary)]">
|
|
||||||
<CheckCircle2 size={15} className="text-[var(--success)]" />
|
|
||||||
No notifications yet
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex items-center gap-1">
|
||||||
|
{unreadCount > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => markAllReadMutation.mutate()}
|
||||||
|
className="rounded-[var(--radius-sm)] px-2 py-1 text-xs text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] })}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||||
|
aria-label="Refresh notifications"
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-80 space-y-2 overflow-y-auto">
|
||||||
|
{notifications.length > 0 ? (
|
||||||
|
notifications.map((item) => (
|
||||||
|
<button
|
||||||
|
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-card)]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{!item.read_at ? (
|
||||||
|
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--accent-primary)]" />
|
||||||
|
) : null}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className={`text-sm ${item.read_at ? 'text-[var(--text-secondary)]' : 'font-medium text-[var(--text-primary)]'}`}>{item.title}</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-[var(--text-tertiary)]">{item.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3 text-sm text-[var(--text-secondary)]">
|
||||||
|
<CheckCircle2 size={15} className="text-[var(--success)]" />
|
||||||
|
No notifications yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -332,7 +529,7 @@ export function PlatformShell() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex gap-1 overflow-x-auto px-3 pb-3">
|
<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 Icon = item.icon;
|
||||||
const isActive = isActiveRoute(item.href);
|
const isActive = isActiveRoute(item.href);
|
||||||
return (
|
return (
|
||||||
@@ -361,3 +558,6 @@ export function PlatformShell() {
|
|||||||
</div>
|
</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 { useDeferredValue, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { listAuditLogs } from '@/lib/api-client';
|
import { listAuditLogs } from '@/lib/api-client';
|
||||||
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
import { formatDate, formatRelative } from '@/lib/time';
|
import { formatDate, formatRelative } from '@/lib/time';
|
||||||
|
import { DemoRestricted } from '@/shared/components';
|
||||||
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
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';
|
'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() {
|
export function AuditLogsPage() {
|
||||||
|
const isDemoMode = useDemoMode();
|
||||||
const [resource, setResource] = useState('');
|
const [resource, setResource] = useState('');
|
||||||
const [action, setAction] = useState('');
|
const [action, setAction] = useState('');
|
||||||
const [actor, setActor] = 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;
|
const since = option && 'ms' in option ? new Date(Date.now() - option.ms).toISOString() : undefined;
|
||||||
return listAuditLogs({ ...filters, since });
|
return listAuditLogs({ ...filters, since });
|
||||||
},
|
},
|
||||||
|
enabled: !isDemoMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isDemoMode) {
|
||||||
|
return <DemoRestricted feature="Audit logs" />;
|
||||||
|
}
|
||||||
|
|
||||||
const logs = logsQuery.data ?? [];
|
const logs = logsQuery.data ?? [];
|
||||||
const hasNext = logs.length === PAGE_SIZE;
|
const hasNext = logs.length === PAGE_SIZE;
|
||||||
const hasFilters = Boolean(resource || action || actor || range);
|
const hasFilters = Boolean(resource || action || actor || range);
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export function SignInPage() {
|
|||||||
queryKey: ['auth-bootstrap'],
|
queryKey: ['auth-bootstrap'],
|
||||||
queryFn: getAuthBootstrap,
|
queryFn: getAuthBootstrap,
|
||||||
});
|
});
|
||||||
|
const providers = bootstrapQuery.data?.providers ?? [];
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -168,10 +169,14 @@ export function SignInPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout>
|
<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}
|
{error ? <AuthErrorNotice message={error} /> : null}
|
||||||
{info ? <AuthInfoNotice message={info} /> : null}
|
{info ? <AuthInfoNotice message={info} /> : null}
|
||||||
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform access mode..." /> : 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}>
|
<form className="space-y-3" onSubmit={submitEmailPassword}>
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||||
@@ -211,30 +216,38 @@ export function SignInPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="my-5 flex items-center gap-3">
|
{providers.length > 0 ? (
|
||||||
<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>
|
<div className="my-5 flex items-center gap-3">
|
||||||
<div className="h-px flex-1 bg-[var(--border-subtle)]" />
|
<div className="h-px flex-1 bg-[var(--border-subtle)]" />
|
||||||
</div>
|
<span className="text-[11px] uppercase tracking-wider text-[var(--text-muted)]">or continue with</span>
|
||||||
|
<div className="h-px flex-1 bg-[var(--border-subtle)]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<button
|
{providers.includes('github') ? (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => void signInWithGitHubProvider()}
|
type="button"
|
||||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
onClick={() => void signInWithGitHubProvider()}
|
||||||
>
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
||||||
<Github size={15} />
|
>
|
||||||
GitHub
|
<Github size={15} />
|
||||||
</button>
|
GitHub
|
||||||
<button
|
</button>
|
||||||
type="button"
|
) : null}
|
||||||
onClick={() => void signInWithGoogleProvider()}
|
{providers.includes('google') ? (
|
||||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
<button
|
||||||
>
|
type="button"
|
||||||
<GoogleMark />
|
onClick={() => void signInWithGoogleProvider()}
|
||||||
Google
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
||||||
</button>
|
>
|
||||||
</div>
|
<GoogleMark />
|
||||||
|
Google
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
|
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
|
||||||
<span>Need access?</span>
|
<span>Need access?</span>
|
||||||
@@ -266,7 +279,8 @@ export function SignUpPage() {
|
|||||||
return <Navigate to={redirectPath} replace />;
|
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 />;
|
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirectPath)}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,9 +303,15 @@ export function SignUpPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout>
|
<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}
|
{error ? <AuthErrorNotice message={error} /> : null}
|
||||||
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : 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}>
|
<form className="space-y-3" onSubmit={submitSignUp}>
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import {
|
import {
|
||||||
cancelBuild,
|
cancelBuild,
|
||||||
getBuildLogs,
|
getBuildLogs,
|
||||||
@@ -9,6 +8,8 @@ import {
|
|||||||
type BuildStatus,
|
type BuildStatus,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { useBuildUpdates } from '@/lib/use-build-updates';
|
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 { formatRelative } from '@/lib/time';
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
@@ -33,36 +34,6 @@ const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
|
|||||||
{ value: 'cancelled', label: 'Cancelled' },
|
{ 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 }) {
|
function StatusBadge({ status }: { status: BuildStatus }) {
|
||||||
const config = {
|
const config = {
|
||||||
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
|
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
|
||||||
@@ -99,8 +70,7 @@ function bytesToHumanReadable(bytes: number): string {
|
|||||||
|
|
||||||
export function BuildsPage() {
|
export function BuildsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchParams] = useSearchParams();
|
const isDemoMode = useDemoMode();
|
||||||
const isDemoMode = searchParams.get('demo') === '1';
|
|
||||||
|
|
||||||
const [projectFilter, setProjectFilter] = useState('');
|
const [projectFilter, setProjectFilter] = useState('');
|
||||||
const [serviceFilter, setServiceFilter] = 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 { 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
createProject,
|
createProject,
|
||||||
getHostMonitoring,
|
getHostMonitoring,
|
||||||
listBuilds,
|
listRecentDeployments,
|
||||||
listProjects,
|
listProjects,
|
||||||
type ProjectEntity,
|
type ProjectEntity,
|
||||||
type ProjectStats,
|
type ProjectStats,
|
||||||
@@ -14,37 +14,21 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
const demoProjects: ProjectEntity[] = [
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
{
|
import { demoProjects } from '@/lib/demo-data';
|
||||||
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 },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const demoDeploys = [
|
const demoDeploys = [
|
||||||
{ id: 'd1', name: 'api-gateway', project: 'core-services', status: 'DEPLOYED', when: '4m' },
|
{ id: 'd1', name: 'API Gateway', project: 'Core Platform', status: 'DEPLOYED', when: '4m' },
|
||||||
{ id: 'd2', name: 'web-frontend', project: 'core-services', status: 'BUILDING', when: '6m' },
|
{ id: 'd2', name: 'Web Frontend', project: 'Core Platform', status: 'BUILDING', when: '6m' },
|
||||||
{ id: 'd3', name: 'worker', project: 'ml-pipeline', status: 'FAILED', when: '1h' },
|
{ id: 'd3', name: 'Batch Evaluator', project: 'Inference Lab', status: 'FAILED', when: '1h' },
|
||||||
{ id: 'd4', name: 'site', project: 'growth-site', status: 'DEPLOYED', when: '2h' },
|
{ id: 'd4', name: 'Marketing Site', project: 'Growth Surface', status: 'DEPLOYED', when: '2h' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getHealthStatus(stats: ProjectStats): 'healthy' | 'degraded' | 'critical' {
|
function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' | 'critical' {
|
||||||
if (stats.service_count === 0) return 'healthy';
|
if (stats.service_count === 0) return 'idle';
|
||||||
if (stats.running_services === stats.service_count) return 'healthy';
|
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';
|
return 'critical';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +36,8 @@ function healthBadge(health: ReturnType<typeof getHealthStatus>) {
|
|||||||
switch (health) {
|
switch (health) {
|
||||||
case 'healthy':
|
case 'healthy':
|
||||||
return { label: 'RUNNING', cls: 'v-st-ok' };
|
return { label: 'RUNNING', cls: 'v-st-ok' };
|
||||||
|
case 'idle':
|
||||||
|
return { label: 'IDLE', cls: '' };
|
||||||
case 'degraded':
|
case 'degraded':
|
||||||
return { label: 'DEGRADED', cls: 'v-st-warn' };
|
return { label: 'DEGRADED', cls: 'v-st-warn' };
|
||||||
case 'critical':
|
case 'critical':
|
||||||
@@ -86,7 +72,6 @@ function MiniCanvas({ project }: { project: ProjectEntity }) {
|
|||||||
{Array.from({ length: shown }).map((_, i) => (
|
{Array.from({ length: shown }).map((_, i) => (
|
||||||
<div key={i} className="v-node" style={positions[i]}>
|
<div key={i} className="v-node" style={positions[i]}>
|
||||||
<b className={`v-nd ${i < running ? '' : 'r'}`} />
|
<b className={`v-nd ${i < running ? '' : 'r'}`} />
|
||||||
svc{i + 1}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{extra > 0 ? (
|
{extra > 0 ? (
|
||||||
@@ -101,6 +86,7 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string }
|
|||||||
const health = getHealthStatus(project.stats);
|
const health = getHealthStatus(project.stats);
|
||||||
const badge = healthBadge(health);
|
const badge = healthBadge(health);
|
||||||
const envOk = health === 'healthy';
|
const envOk = health === 'healthy';
|
||||||
|
const envLabel = envOk ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<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)]">
|
<div className="v-mono mt-3 flex items-center gap-2 text-[10.5px] text-[var(--text-tertiary)]">
|
||||||
<i
|
<i
|
||||||
className="inline-block h-1.5 w-1.5 rounded-full"
|
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)]">
|
<span className="text-[var(--text-secondary)]">
|
||||||
{envOk ? 'production' : 'degraded'}
|
{envLabel}
|
||||||
</span>
|
</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>
|
<span>
|
||||||
@@ -134,13 +120,21 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string }
|
|||||||
|
|
||||||
export function ProjectsPage() {
|
export function ProjectsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isDemoMode = searchParams.get('demo') === '1';
|
const isDemoMode = useDemoMode();
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [isCreateOpen, setCreateOpen] = useState(false);
|
const [isCreateOpen, setCreateOpen] = useState(false);
|
||||||
const [form, setForm] = useState({ name: '', description: '' });
|
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) =>
|
const projectHref = (projectId: string) =>
|
||||||
isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`;
|
isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`;
|
||||||
@@ -148,7 +142,7 @@ export function ProjectsPage() {
|
|||||||
const projectsQuery = useQuery({
|
const projectsQuery = useQuery({
|
||||||
queryKey: ['projects'],
|
queryKey: ['projects'],
|
||||||
enabled: !isDemoMode,
|
enabled: !isDemoMode,
|
||||||
queryFn: listProjects,
|
queryFn: () => listProjects(),
|
||||||
});
|
});
|
||||||
const hostQuery = useQuery({
|
const hostQuery = useQuery({
|
||||||
queryKey: ['host-monitoring'],
|
queryKey: ['host-monitoring'],
|
||||||
@@ -156,10 +150,10 @@ export function ProjectsPage() {
|
|||||||
queryFn: getHostMonitoring,
|
queryFn: getHostMonitoring,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
const buildsQuery = useQuery({
|
const deploysQuery = useQuery({
|
||||||
queryKey: ['recent-deploys'],
|
queryKey: ['recent-deploys'],
|
||||||
enabled: !isDemoMode,
|
enabled: !isDemoMode,
|
||||||
queryFn: () => listBuilds({ page: 1, limit: 5 }),
|
queryFn: () => listRecentDeployments(10),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,22 +181,23 @@ export function ProjectsPage() {
|
|||||||
const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0);
|
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 runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0);
|
||||||
const host = hostQuery.data;
|
const host = hostQuery.data;
|
||||||
|
const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'HOST';
|
||||||
const cpuPct = isDemoMode ? 14 : host && host.cpu.cores > 0
|
const cpuPct = isDemoMode ? 14 : host && host.cpu.cores > 0
|
||||||
? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100))
|
? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100))
|
||||||
: null;
|
: null;
|
||||||
const memPct = isDemoMode ? 72 : host ? Math.round(host.memory.usagePercent) : null;
|
const memPct = isDemoMode ? 72 : host ? Math.round(host.memory.usagePercent) : null;
|
||||||
const diskPct = isDemoMode ? 73 : host ? Math.round(host.storage.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 fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`;
|
||||||
const builds = isDemoMode ? [] : buildsQuery.data?.builds ?? [];
|
const deployments = isDemoMode ? [] : deploysQuery.data ?? [];
|
||||||
const deployCount = buildsQuery.data?.total ?? builds.length;
|
const deployCount = deployments.length;
|
||||||
const feed = isDemoMode
|
const feed = isDemoMode
|
||||||
? demoDeploys
|
? demoDeploys
|
||||||
: builds.map((b) => ({
|
: deployments.slice(0, 5).map((d) => ({
|
||||||
id: b.id,
|
id: d.id,
|
||||||
name: b.imageName || b.serviceId || b.id,
|
name: d.serviceName || d.imageName || d.serviceId || d.id,
|
||||||
project: '',
|
project: d.projectName,
|
||||||
status: (b.status || 'queued').toUpperCase(),
|
status: (d.status || 'queued').toUpperCase(),
|
||||||
when: formatRelative(b.startedAt ?? b.completedAt),
|
when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const statusClass = (status: string) =>
|
const statusClass = (status: string) =>
|
||||||
@@ -228,7 +223,7 @@ export function ProjectsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5">
|
<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} />
|
<Search size={13} />
|
||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
@@ -238,16 +233,18 @@ export function ProjectsPage() {
|
|||||||
style={{ fontSize: 11.5 }}
|
style={{ fontSize: 11.5 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setCreateOpen(true)} className="v-btn">
|
{signedIn && (
|
||||||
+ new project
|
<button onClick={openCreate} className="v-btn">
|
||||||
</button>
|
+ new project
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Host stat row */}
|
{/* Host stat row */}
|
||||||
<div className="mb-7 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
<div className="mb-7 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
<div className="v-stat">
|
<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>
|
<div className="v-v">{cpuPct ?? '—'}<span>%</span></div>
|
||||||
{isDemoMode ? (
|
{isDemoMode ? (
|
||||||
<div className="v-spark">
|
<div className="v-spark">
|
||||||
@@ -263,13 +260,13 @@ export function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="v-stat">
|
<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-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-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 className="v-d">{isDemoMode ? '9.8G / 13G' : host ? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)}` : 'no telemetry'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="v-stat">
|
<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-v">{diskPct ?? '—'}<span>%</span></div>
|
||||||
<div className="v-meter"><i className="r" style={{ width: `${diskPct ?? 0}%` }} /></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>
|
<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-k"><span>DEPLOYS</span></div>
|
||||||
<div className="v-v">{isDemoMode ? 18 : deployCount}</div>
|
<div className="v-v">{isDemoMode ? 18 : deployCount}</div>
|
||||||
<div className="v-meter"><i style={{ width: '94%' }} /></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -310,10 +307,12 @@ export function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xl font-semibold text-[var(--text-primary)]">No projects yet</p>
|
<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)]">
|
<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>
|
</p>
|
||||||
<button onClick={() => setCreateOpen(true)} className="v-btn mt-6">
|
<button onClick={openCreate} className="v-btn mt-6">
|
||||||
+ create project
|
{signedIn ? '+ create project' : 'sign in to create'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -357,6 +356,9 @@ export function ProjectsPage() {
|
|||||||
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
Projects organize your services and provide a visual canvas for topology management.
|
Projects organize your services and provide a visual canvas for topology management.
|
||||||
</p>
|
</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 className="mt-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -29,9 +29,18 @@ import {
|
|||||||
execInService,
|
execInService,
|
||||||
rollbackDeployment,
|
rollbackDeployment,
|
||||||
updateServiceVariables,
|
updateServiceVariables,
|
||||||
|
updateService,
|
||||||
|
restartService,
|
||||||
|
stopService,
|
||||||
|
getServiceRuntime,
|
||||||
type CronJobEntity,
|
type CronJobEntity,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
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 { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
|
||||||
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
|
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
|
||||||
import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components';
|
import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components';
|
||||||
@@ -66,16 +75,18 @@ import {
|
|||||||
|
|
||||||
type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'previews' | 'scaling' | 'console' | 'settings';
|
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: '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: '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: 'cron', label: 'Cron', icon: Clock },
|
||||||
{ key: 'previews', label: 'Previews', icon: GitPullRequest },
|
{ key: 'previews', label: 'Previews', icon: GitPullRequest },
|
||||||
{ key: 'scaling', label: 'Scaling', icon: Layers },
|
{ key: 'scaling', label: 'Scaling', icon: Layers },
|
||||||
{ key: 'console', label: 'Console', icon: Terminal },
|
{ key: 'console', label: 'Console', icon: Terminal, manageOnly: true },
|
||||||
{ key: 'settings', label: 'Settings', icon: Settings },
|
{ key: 'settings', label: 'Settings', icon: Settings, manageOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
function metricStatus(percent: number): 'good' | 'average' | 'warning' {
|
function metricStatus(percent: number): 'good' | 'average' | 'warning' {
|
||||||
@@ -112,12 +123,24 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
|
|
||||||
export function ServiceDetailPage() {
|
export function ServiceDetailPage() {
|
||||||
const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>();
|
const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
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 [logTail, setLogTail] = useState('100');
|
||||||
const [varDrafts, setVarDrafts] = useState<VariableDraft[] | null>(null);
|
const [varDrafts, setVarDrafts] = useState<VariableDraft[] | null>(null);
|
||||||
const [bulkOpen, setBulkOpen] = useState(false);
|
const [bulkOpen, setBulkOpen] = useState(false);
|
||||||
@@ -284,7 +307,11 @@ export function ServiceDetailPage() {
|
|||||||
});
|
});
|
||||||
const manualScaleMutation = useMutation({
|
const manualScaleMutation = useMutation({
|
||||||
mutationFn: (replicas: number) => manualScaleService(serviceId, replicas),
|
mutationFn: (replicas: number) => manualScaleService(serviceId, replicas),
|
||||||
onSuccess: invalidateScaling,
|
onSuccess: () => {
|
||||||
|
invalidateScaling();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Console ---
|
// --- Console ---
|
||||||
@@ -361,10 +388,43 @@ export function ServiceDetailPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
|
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
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 project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
|
||||||
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
|
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
|
||||||
|
|
||||||
@@ -516,57 +576,58 @@ export function ServiceDetailPage() {
|
|||||||
style={{
|
style={{
|
||||||
gap: '6px',
|
gap: '6px',
|
||||||
padding: '14px 24px 10px',
|
padding: '14px 24px 10px',
|
||||||
color: '#6b6e7d',
|
color: 'var(--text-tertiary)',
|
||||||
fontSize: '13px'
|
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"/>
|
<polyline points="15 18 9 12 15 6"/>
|
||||||
</svg>
|
</svg>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)}
|
onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)}
|
||||||
style={{ color: '#6b6e7d', textDecoration: 'none' }}
|
style={{ color: 'var(--text-tertiary)', textDecoration: 'none' }}
|
||||||
className="hover:text-[#9295a4] transition-colors"
|
className="hover:text-[var(--text-secondary)] transition-colors"
|
||||||
>
|
>
|
||||||
Servers
|
Servers
|
||||||
</button>
|
</button>
|
||||||
<span style={{ opacity: 0.4 }}>/</span>
|
<span style={{ opacity: 0.4 }}>/</span>
|
||||||
<span style={{ color: '#9295a4' }}>{service.name}</span>
|
<span style={{ color: 'var(--text-secondary)' }}>{service.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Project Header - self.html exact match */}
|
{/* Project Header - self.html exact match */}
|
||||||
<div className="flex items-center" style={{ padding: '0 24px 18px' }}>
|
<div className="flex items-center" style={{ padding: '0 24px 18px' }}>
|
||||||
<div
|
<div
|
||||||
className="rounded-[13px] flex items-center justify-center flex-shrink-0"
|
className="rounded-[13px] flex items-center justify-center flex-shrink-0"
|
||||||
style={{
|
style={{
|
||||||
width: '46px',
|
width: '46px',
|
||||||
height: '46px',
|
height: '46px',
|
||||||
background: 'var(--accent-primary)',
|
background: `${serviceAccent(service)}1f`,
|
||||||
|
color: serviceAccent(service),
|
||||||
marginRight: '14px'
|
marginRight: '14px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
|
<ServiceIcon service={service} size={22} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center" style={{ gap: '10px' }}>
|
<div className="flex items-center" style={{ gap: '10px' }}>
|
||||||
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}>{service.name}</span>
|
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: 'var(--text-primary)' }}>{service.name}</span>
|
||||||
<span className={`badge-${service.status === 'running' ? 'active' : 'stopped'}`}>
|
<span
|
||||||
|
className={`badge-${service.status === 'running' ? 'active' : service.status === 'degraded' ? 'degraded' : 'stopped'}`}
|
||||||
|
>
|
||||||
{service.status === 'running' && <span className="live-dot" />}
|
{service.status === 'running' && <span className="live-dot" />}
|
||||||
{service.status === 'running' ? 'Active' : 'Stopped'}
|
{service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : 'Stopped'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
|
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
|
||||||
{service.status === 'running' && (
|
{(service.publicUrl || service.domain) && (
|
||||||
<a
|
<a
|
||||||
href={`https://${service.name}.containr.dev`}
|
href={service.publicUrl ?? `https://${service.domain}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center hover:text-[#9295a4] transition-colors"
|
className="flex items-center hover:text-[var(--text-secondary)] transition-colors"
|
||||||
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
|
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">
|
<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"/>
|
<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"/>
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
@@ -576,8 +637,8 @@ export function ServiceDetailPage() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)}
|
onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)}
|
||||||
className="flex items-center hover:text-[#9295a4] transition-colors"
|
className="flex items-center hover:text-[var(--text-secondary)] transition-colors"
|
||||||
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
|
style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
|
||||||
>
|
>
|
||||||
Project Information
|
Project Information
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
<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>
|
</div>
|
||||||
<div className="ml-auto flex" style={{ gap: '10px' }}>
|
<div className="ml-auto flex" style={{ gap: '10px' }}>
|
||||||
{!isDemoMode && (
|
{!isDemoMode && signedIn && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => deployMutation.mutate('restart')}
|
onClick={() => stopMutation.mutate()}
|
||||||
disabled={deployMutation.isPending || service.status !== 'running'}
|
disabled={stopMutation.isPending || (service.status !== 'running' && service.status !== 'degraded')}
|
||||||
className={`btn-stop ${service.status !== 'running' ? 'disabled' : ''}`}
|
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">
|
<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="10"/>
|
||||||
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
|
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
|
||||||
</svg>
|
</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>
|
||||||
<button
|
<button
|
||||||
onClick={() => deployMutation.mutate('manual')}
|
onClick={() => deployMutation.mutate('manual')}
|
||||||
@@ -606,10 +678,10 @@ export function ServiceDetailPage() {
|
|||||||
className={`btn-restart ${deployMutation.isPending ? 'disabled' : ''}`}
|
className={`btn-restart ${deployMutation.isPending ? 'disabled' : ''}`}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
<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="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="M3.51 15a9 9 0 1 0 .49-4.5"/>
|
<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>
|
</svg>
|
||||||
RESTART
|
{deployMutation.isPending ? 'DEPLOYING…' : 'DEPLOY'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -643,7 +715,7 @@ export function ServiceDetailPage() {
|
|||||||
data={isDemoMode
|
data={isDemoMode
|
||||||
? Array.from({ length: 24 }, (_, i) => seededMetric(`${service.id}:cpu:${i}`, 8, 80))
|
? Array.from({ length: 24 }, (_, i) => seededMetric(`${service.id}:cpu:${i}`, 8, 80))
|
||||||
: metricHistory.cpu.length > 0 ? metricHistory.cpu : [0]}
|
: metricHistory.cpu.length > 0 ? metricHistory.cpu : [0]}
|
||||||
color="#b4e34a"
|
color="var(--accent-primary)"
|
||||||
height={72}
|
height={72}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -657,10 +729,10 @@ export function ServiceDetailPage() {
|
|||||||
subtitle={isDemoMode ? 'Container footprint' : hasLiveTelemetry ? `${formatBytes(liveMetrics!.memory_usage_bytes)} used` : 'No running containers'}
|
subtitle={isDemoMode ? 'Container footprint' : hasLiveTelemetry ? `${formatBytes(liveMetrics!.memory_usage_bytes)} used` : 'No running containers'}
|
||||||
chart={
|
chart={
|
||||||
<div className="relative mx-auto" style={{ width: 150 }}>
|
<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="absolute inset-x-0 bottom-0 text-center">
|
||||||
<div className="text-[10px] uppercase tracking-wide text-[#6b6e7d]">Used</div>
|
<div className="text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">Used</div>
|
||||||
<div className="text-sm font-bold text-[#e8e9f0]">
|
<div className="text-sm font-bold text-[var(--text-primary)]">
|
||||||
{isDemoMode
|
{isDemoMode
|
||||||
? `${((8 * (metricSet?.memory ?? 0)) / 100).toFixed(1)} GB / 8 GB`
|
? `${((8 * (metricSet?.memory ?? 0)) / 100).toFixed(1)} GB / 8 GB`
|
||||||
: hasLiveTelemetry
|
: hasLiveTelemetry
|
||||||
@@ -683,7 +755,7 @@ export function ServiceDetailPage() {
|
|||||||
data={isDemoMode
|
data={isDemoMode
|
||||||
? Array.from({ length: 24 }, (_, i) => seededMetric(`${service.id}:net:${i}`, 15, 70))
|
? Array.from({ length: 24 }, (_, i) => seededMetric(`${service.id}:net:${i}`, 15, 70))
|
||||||
: metricHistory.net.length > 0 ? metricHistory.net : [0]}
|
: metricHistory.net.length > 0 ? metricHistory.net : [0]}
|
||||||
color="#7ab8ff"
|
color="var(--info)"
|
||||||
height={72}
|
height={72}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -731,12 +803,12 @@ export function ServiceDetailPage() {
|
|||||||
<div
|
<div
|
||||||
className="flex"
|
className="flex"
|
||||||
style={{
|
style={{
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.07)',
|
borderBottom: '1px solid var(--border-subtle)',
|
||||||
marginBottom: '18px',
|
marginBottom: '18px',
|
||||||
padding: '0 24px'
|
padding: '0 24px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sectionItems.map((item) => {
|
{sectionItems.filter((item) => signedIn || !item.manageOnly).map((item) => {
|
||||||
const active = activeSection === item.key;
|
const active = activeSection === item.key;
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
@@ -793,8 +865,8 @@ export function ServiceDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
|
className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80 cursor-pointer"
|
||||||
style={{ height: `${value}%`, background: '#b4e34a' }}
|
style={{ height: `${value}%`, background: 'var(--accent-primary)' }}
|
||||||
title={`${value}%`}
|
title={`${value}%`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -802,8 +874,8 @@ export function ServiceDetailPage() {
|
|||||||
: metricHistory.cpu.map((value, i) => (
|
: metricHistory.cpu.map((value, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80"
|
className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80"
|
||||||
style={{ height: `${Math.max(3, value)}%`, background: '#b4e34a' }}
|
style={{ height: `${Math.max(3, value)}%`, background: 'var(--accent-primary)' }}
|
||||||
title={`${value.toFixed(1)}%`}
|
title={`${value.toFixed(1)}%`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -834,8 +906,8 @@ export function ServiceDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
|
className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80 cursor-pointer"
|
||||||
style={{ height: `${value}%`, background: '#5ee6a0' }}
|
style={{ height: `${value}%`, background: 'var(--success)' }}
|
||||||
title={`${value}%`}
|
title={`${value}%`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -843,8 +915,8 @@ export function ServiceDetailPage() {
|
|||||||
: metricHistory.memory.map((value, i) => (
|
: metricHistory.memory.map((value, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80"
|
className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80"
|
||||||
style={{ height: `${Math.max(3, value)}%`, background: '#5ee6a0' }}
|
style={{ height: `${Math.max(3, value)}%`, background: 'var(--success)' }}
|
||||||
title={`${value.toFixed(1)}%`}
|
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-sm font-medium text-[var(--text-primary)]">Networking</p>
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">Public endpoints and ports</p>
|
<p className="text-xs text-[var(--text-tertiary)]">Public endpoints and ports</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
|
{service.domain && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
|
||||||
<div className="w-2 h-2 rounded-full bg-[var(--success)]" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-[var(--text-secondary)]">HTTPS</span>
|
<div className="w-2 h-2 rounded-full bg-[var(--success)]" />
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">Domain</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`https://${service.domain}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mono text-xs text-[var(--accent-primary)] hover:underline"
|
||||||
|
>
|
||||||
|
{service.domain}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<a
|
)}
|
||||||
href={`https://${service.name}.containr.dev`}
|
{(runtimeQuery.data?.urls ?? []).map((url) => (
|
||||||
target="_blank"
|
<div key={url} className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
|
||||||
rel="noopener noreferrer"
|
<div className="flex items-center gap-2">
|
||||||
className="mono text-xs text-[var(--accent-primary)] hover:underline"
|
<div className="w-2 h-2 rounded-full bg-[var(--success)]" />
|
||||||
>
|
<span className="text-xs text-[var(--text-secondary)]">HTTP</span>
|
||||||
{service.name}.containr.dev
|
</div>
|
||||||
</a>
|
<a
|
||||||
</div>
|
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 justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-[var(--accent-secondary)]" />
|
<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>
|
</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>
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1078,7 +1179,11 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
|
<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>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<p className="text-sm text-[var(--text-secondary)]">Scheduled commands executed inside the service container</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{signedIn ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditingCronId(null); setCronForm({ name: '', schedule: '', command: '', timezone: 'UTC', enabled: true, retention: 30 }); }}
|
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"
|
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} />
|
<Plus size={12} />
|
||||||
New job
|
New job
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cronForm && (
|
{cronForm && (
|
||||||
@@ -1320,9 +1427,10 @@ export function ServiceDetailPage() {
|
|||||||
<div key={job.id} className="panel-soft p-3">
|
<div key={job.id} className="panel-soft p-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleCronMutation.mutate(job)}
|
onClick={() => signedIn && toggleCronMutation.mutate(job)}
|
||||||
title={job.enabled ? 'Disable' : 'Enable'}
|
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'}`} />
|
<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>
|
</button>
|
||||||
@@ -1338,15 +1446,19 @@ export function ServiceDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<button onClick={() => triggerCronMutation.mutate(job.id as string)} title="Run now"
|
{signedIn ? (
|
||||||
disabled={triggerCronMutation.isPending}
|
<button onClick={() => triggerCronMutation.mutate(job.id as string)} title="Run now"
|
||||||
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">
|
disabled={triggerCronMutation.isPending}
|
||||||
<Play size={13} />
|
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">
|
||||||
</button>
|
<Play size={13} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button onClick={() => setHistoryCronId(historyCronId === job.id ? null : (job.id as string))} title="History"
|
<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">
|
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'} />
|
<ChevronDown size={13} className={historyCronId === job.id ? 'rotate-180 transition-transform' : 'transition-transform'} />
|
||||||
</button>
|
</button>
|
||||||
|
{signedIn ? (
|
||||||
|
<>
|
||||||
<button
|
<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 }); }}
|
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"
|
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">
|
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} />
|
<Trash2 size={13} />
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{historyCronId === job.id && (
|
{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>
|
<p className="text-sm text-[var(--text-secondary)]">Ephemeral per-branch deployments of this service</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{signedIn ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setPreviewForm({ branch: '', prNumber: '', ttlHours: '72' })}
|
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"
|
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
|
<Plus size={12} /> New preview
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{previewForm && (
|
{previewForm && (
|
||||||
@@ -1421,7 +1537,7 @@ export function ServiceDetailPage() {
|
|||||||
value={previewForm.branch}
|
value={previewForm.branch}
|
||||||
onChange={(e) => setPreviewForm({ ...previewForm, branch: e.target.value })}
|
onChange={(e) => setPreviewForm({ ...previewForm, branch: e.target.value })}
|
||||||
placeholder="feature/my-branch"
|
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>
|
||||||
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
|
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
|
||||||
@@ -1430,7 +1546,7 @@ export function ServiceDetailPage() {
|
|||||||
value={previewForm.prNumber}
|
value={previewForm.prNumber}
|
||||||
onChange={(e) => setPreviewForm({ ...previewForm, prNumber: e.target.value })}
|
onChange={(e) => setPreviewForm({ ...previewForm, prNumber: e.target.value })}
|
||||||
placeholder="123"
|
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>
|
||||||
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
|
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
|
||||||
@@ -1439,7 +1555,7 @@ export function ServiceDetailPage() {
|
|||||||
value={previewForm.ttlHours}
|
value={previewForm.ttlHours}
|
||||||
onChange={(e) => setPreviewForm({ ...previewForm, ttlHours: e.target.value })}
|
onChange={(e) => setPreviewForm({ ...previewForm, ttlHours: e.target.value })}
|
||||||
placeholder="72"
|
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>
|
</label>
|
||||||
<button
|
<button
|
||||||
@@ -1493,7 +1609,7 @@ export function ServiceDetailPage() {
|
|||||||
<ExternalLink size={11} /> Open
|
<ExternalLink size={11} /> Open
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{env.status === 'running' && (
|
{env.status === 'running' && signedIn && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (window.confirm('Promote this preview to production?')) promotePreviewMutation.mutate(env.id ?? ''); }}
|
onClick={() => { if (window.confirm('Promote this preview to production?')) promotePreviewMutation.mutate(env.id ?? ''); }}
|
||||||
disabled={promotePreviewMutation.isPending}
|
disabled={promotePreviewMutation.isPending}
|
||||||
@@ -1502,6 +1618,7 @@ export function ServiceDetailPage() {
|
|||||||
Promote
|
Promote
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{signedIn ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (window.confirm(`Delete preview ${env.environment}?`)) deletePreviewMutation.mutate(env.id ?? ''); }}
|
onClick={() => { if (window.confirm(`Delete preview ${env.environment}?`)) deletePreviewMutation.mutate(env.id ?? ''); }}
|
||||||
disabled={deletePreviewMutation.isPending}
|
disabled={deletePreviewMutation.isPending}
|
||||||
@@ -1509,6 +1626,7 @@ export function ServiceDetailPage() {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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="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-xs text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p>
|
||||||
<p className="text-xl font-semibold text-[var(--text-primary)]">
|
<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)]">
|
<span className="text-sm font-normal text-[var(--text-secondary)]">
|
||||||
{' '}/ {scalingStateQuery.data?.DesiredReplicas ?? '—'} desired
|
{' '}/ {scalingStateQuery.data?.DesiredReplicas ?? runtimeQuery.data?.desired ?? service?.replicas ?? '—'} desired
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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-xs text-[var(--text-tertiary)] uppercase tracking-wide">Policy</p>
|
||||||
<p className="text-xl font-semibold text-[var(--text-primary)]">
|
<p className="text-xl font-semibold text-[var(--text-primary)]">
|
||||||
{scalingPolicyQuery.data ? `${scalingPolicyQuery.data.min_replicas}–${scalingPolicyQuery.data.max_replicas}` : 'None'}
|
{scalingPolicyQuery.data ? `${scalingPolicyQuery.data.min_replicas}–${scalingPolicyQuery.data.max_replicas}` : 'None'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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-xs text-[var(--text-tertiary)] uppercase tracking-wide">Autoscaling</p>
|
||||||
<p className="text-xl font-semibold text-[var(--text-primary)]">
|
<p className="text-xl font-semibold text-[var(--text-primary)]">
|
||||||
{scalingPolicyQuery.data?.enabled ? 'On' : 'Off'}
|
{scalingPolicyQuery.data?.enabled ? 'On' : 'Off'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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-xs text-[var(--text-tertiary)] uppercase tracking-wide">Last action</p>
|
||||||
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
|
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||||
{scalingStateQuery.data?.LastScaleDirection ?? '—'}
|
{scalingStateQuery.data?.LastScaleDirection ?? '—'}
|
||||||
@@ -1579,15 +1699,17 @@ export function ServiceDetailPage() {
|
|||||||
max={20}
|
max={20}
|
||||||
value={scaleReplicas}
|
value={scaleReplicas}
|
||||||
onChange={(e) => setScaleReplicas(e.target.value)}
|
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"
|
placeholder="e.g. 3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{isAdmin ? (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={manualScaleMutation.isPending || !scaleReplicas || !scalingStateQuery.data}
|
disabled={manualScaleMutation.isPending || !scaleReplicas}
|
||||||
onClick={() => manualScaleMutation.mutate(Number(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'}
|
{manualScaleMutation.isPending ? 'Scaling…' : 'Scale'}
|
||||||
</button>
|
</button>
|
||||||
@@ -1600,7 +1722,7 @@ export function ServiceDetailPage() {
|
|||||||
targetMemory: String(scalingPolicyQuery.data?.target_memory ?? 80),
|
targetMemory: String(scalingPolicyQuery.data?.target_memory ?? 80),
|
||||||
enabled: scalingPolicyQuery.data?.enabled ?? true,
|
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'}
|
{scalingPolicyQuery.data ? 'Edit policy' : 'Create policy'}
|
||||||
</button>
|
</button>
|
||||||
@@ -1614,9 +1736,13 @@ export function ServiceDetailPage() {
|
|||||||
Disable policy
|
Disable policy
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="self-center text-xs text-[var(--text-tertiary)]">Scaling policy changes are admin-only.</span>
|
||||||
|
)}
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
{([
|
{([
|
||||||
['Min replicas', 'min'],
|
['Min replicas', 'min'],
|
||||||
@@ -1631,7 +1757,7 @@ export function ServiceDetailPage() {
|
|||||||
min={field === 'min' || field === 'max' ? 1 : 0}
|
min={field === 'min' || field === 'max' ? 1 : 0}
|
||||||
value={scalingForm[field]}
|
value={scalingForm[field]}
|
||||||
onChange={(e) => setScalingForm({ ...scalingForm, [field]: e.target.value })}
|
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>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -1649,14 +1775,14 @@ export function ServiceDetailPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={saveScalingMutation.isPending}
|
disabled={saveScalingMutation.isPending}
|
||||||
onClick={() => saveScalingMutation.mutate()}
|
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'}
|
{saveScalingMutation.isPending ? 'Saving…' : 'Save policy'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setScalingForm(null)}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -1705,12 +1831,12 @@ export function ServiceDetailPage() {
|
|||||||
onChange={(e) => setConsoleInput(e.target.value)}
|
onChange={(e) => setConsoleInput(e.target.value)}
|
||||||
placeholder="e.g. env | sort | head -20"
|
placeholder="e.g. env | sort | head -20"
|
||||||
disabled={execMutation.isPending}
|
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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={execMutation.isPending || !consoleInput.trim()}
|
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'}
|
{execMutation.isPending ? 'Running…' : 'Run'}
|
||||||
</button>
|
</button>
|
||||||
@@ -1729,7 +1855,7 @@ export function ServiceDetailPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{consoleHistory.map((entry, i) => (
|
{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">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<p className="text-xs font-mono text-[var(--text-primary)]">$ {entry.command}</p>
|
<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)]'}`}>
|
<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>
|
||||||
</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 && (
|
{!isDemoMode && (
|
||||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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 { Link, useSearchParams } from 'react-router-dom';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
|
createTemplate,
|
||||||
|
deleteTemplate,
|
||||||
deployTemplate,
|
deployTemplate,
|
||||||
|
getCurrentUserProfile,
|
||||||
getTemplateById,
|
getTemplateById,
|
||||||
listProjects,
|
listProjects,
|
||||||
listTemplates,
|
listTemplates,
|
||||||
type TemplateDetailEntity,
|
updateTemplate,
|
||||||
type TemplateEntity,
|
type TemplateEntity,
|
||||||
|
type TemplateWriteInput,
|
||||||
} from '@/lib/api-client';
|
} 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 {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
Filter,
|
||||||
@@ -24,322 +31,13 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Star,
|
Star,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} 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 {
|
function toServiceName(value: string): string {
|
||||||
const normalized = value
|
const normalized = value
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -354,6 +52,15 @@ function categoryLabel(category: string): string {
|
|||||||
return category ? category[0].toUpperCase() + category.slice(1) : 'Uncategorized';
|
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 {
|
function categoryIcon(category: string): typeof Box {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case 'frontend':
|
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() {
|
export function TemplatesPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const isDemoMode = searchParams.get('demo') === '1';
|
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 [categoryFilter, setCategoryFilter] = useState('');
|
||||||
const [searchFilter, setSearchFilter] = 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 [deployProjectIdState, setDeployProjectId] = useState(searchParams.get('project') ?? '');
|
||||||
const [deployNameByTemplate, setDeployNameByTemplate] = useState<Record<string, string>>({});
|
const [deployNameByTemplate, setDeployNameByTemplate] = useState<Record<string, string>>({});
|
||||||
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
|
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
|
||||||
@@ -397,7 +162,7 @@ export function TemplatesPage() {
|
|||||||
const projectsQuery = useQuery({
|
const projectsQuery = useQuery({
|
||||||
queryKey: ['template-projects'],
|
queryKey: ['template-projects'],
|
||||||
enabled: !isDemoMode,
|
enabled: !isDemoMode,
|
||||||
queryFn: listProjects,
|
queryFn: () => listProjects(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const templates = useMemo(
|
const templates = useMemo(
|
||||||
@@ -434,11 +199,11 @@ export function TemplatesPage() {
|
|||||||
if (filteredTemplates.length === 0) {
|
if (filteredTemplates.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (selectedTemplateIdState && filteredTemplates.some((template) => template.id === selectedTemplateIdState)) {
|
if (templateParam && filteredTemplates.some((template) => template.id === templateParam)) {
|
||||||
return selectedTemplateIdState;
|
return templateParam;
|
||||||
}
|
}
|
||||||
return filteredTemplates[0].id;
|
return filteredTemplates[0].id;
|
||||||
}, [filteredTemplates, selectedTemplateIdState]);
|
}, [filteredTemplates, templateParam]);
|
||||||
|
|
||||||
const templateDetailQuery = useQuery({
|
const templateDetailQuery = useQuery({
|
||||||
queryKey: ['template-detail-page', selectedTemplateId],
|
queryKey: ['template-detail-page', selectedTemplateId],
|
||||||
@@ -553,6 +318,112 @@ export function TemplatesPage() {
|
|||||||
missingRequiredVariables.length > 0 ||
|
missingRequiredVariables.length > 0 ||
|
||||||
deployMutation.isPending;
|
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 (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -563,9 +434,18 @@ export function TemplatesPage() {
|
|||||||
<h1 className="v-title">Template Catalog<span className="v-cursor">_</span></h1>
|
<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>
|
<p className="text-sm text-[var(--text-secondary)]">Deploy services from pre-configured templates</p>
|
||||||
</div>
|
</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} />
|
<Layers size={16} />
|
||||||
<span>{filteredTemplates.length} templates</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -661,7 +541,7 @@ export function TemplatesPage() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={template.id}
|
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 ${
|
className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${
|
||||||
selected
|
selected
|
||||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
|
? '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)]'}`}>
|
<p className={`text-sm font-semibold tracking-tight ${selected ? 'text-[var(--accent-primary)]' : 'text-[var(--text-primary)]'}`}>
|
||||||
{template.name}
|
{template.name}
|
||||||
</p>
|
</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">
|
<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)]" />
|
<Star size={8} className="fill-[var(--warning)]" />
|
||||||
Official
|
Official
|
||||||
</span>
|
</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>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span
|
<span
|
||||||
@@ -700,7 +589,7 @@ export function TemplatesPage() {
|
|||||||
{categoryLabel(template.category)}
|
{categoryLabel(template.category)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-[var(--text-tertiary)]">•</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>
|
</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>
|
<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>
|
</div>
|
||||||
@@ -766,6 +655,11 @@ export function TemplatesPage() {
|
|||||||
Official
|
Official
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<p className="text-sm text-[var(--text-secondary)] mt-1">{selectedDetail.template.description}</p>
|
<p className="text-sm text-[var(--text-secondary)] mt-1">{selectedDetail.template.description}</p>
|
||||||
</div>
|
</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)]">
|
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]">
|
||||||
{selectedDetail.config.runtime || 'n/a'}
|
{selectedDetail.config.runtime || 'n/a'}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -824,6 +737,20 @@ export function TemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||||
@@ -951,12 +878,94 @@ export function TemplatesPage() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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 { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
|
||||||
import type { ServiceEntity } from '@/lib/api-client';
|
import type { ServiceEntity } from '@/lib/api-client';
|
||||||
import { serviceStatusClass } 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 { useState } from 'react';
|
||||||
|
import { serviceIcon, serviceAccent } from './service-visuals';
|
||||||
|
import { ServiceIcon } from './ServiceIcon';
|
||||||
|
|
||||||
export type ServiceNodeData = {
|
export type ServiceNodeData = {
|
||||||
service: ServiceEntity;
|
service: ServiceEntity;
|
||||||
@@ -12,181 +14,236 @@ export type ServiceNodeData = {
|
|||||||
|
|
||||||
export type GroupNodeData = {
|
export type GroupNodeData = {
|
||||||
title: string;
|
title: string;
|
||||||
|
onRename?: (groupId: string, title: string) => void;
|
||||||
|
renameNonce?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
|
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
|
||||||
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
|
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
|
||||||
|
|
||||||
function serviceTypeIcon(type: string): typeof Box {
|
function statusLabel(status: string): string {
|
||||||
switch (type) {
|
switch (status) {
|
||||||
case 'web':
|
case 'running':
|
||||||
return Globe;
|
return 'Online';
|
||||||
case 'database':
|
case 'degraded':
|
||||||
return Database;
|
return 'Degraded';
|
||||||
case 'worker':
|
case 'building':
|
||||||
return Terminal;
|
return 'Deploying';
|
||||||
|
case 'failed':
|
||||||
|
return 'Failed';
|
||||||
default:
|
default:
|
||||||
return Box;
|
return 'Stopped';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function serviceTypeColor(type: string): string {
|
function statusColor(status: string): string {
|
||||||
switch (type) {
|
switch (status) {
|
||||||
case 'web':
|
case 'running':
|
||||||
return '#7ab8ff'; // Blue
|
return 'var(--success)';
|
||||||
case 'database':
|
case 'degraded':
|
||||||
return '#b4e34a'; // Purple
|
case 'building':
|
||||||
case 'worker':
|
return 'var(--warning)';
|
||||||
return 'var(--accent-primary)'; // Pink
|
case 'failed':
|
||||||
|
return 'var(--error)';
|
||||||
default:
|
default:
|
||||||
return '#9295a4'; // Gray
|
return 'var(--text-tertiary)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']);
|
||||||
|
|
||||||
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||||
const { service, selected, onOpen } = data;
|
const { service, selected } = data;
|
||||||
const iconType = serviceTypeIcon(service.type);
|
const brand = serviceIcon(service);
|
||||||
const typeColor = serviceTypeColor(service.type);
|
const typeColor = brand?.color ?? serviceAccent(service);
|
||||||
const isRunning = service.status === 'running';
|
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 publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
|
||||||
const domain = `${service.name}.containr.local`;
|
const internalAddr = service.port ? `${service.name}:${service.port}` : service.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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
|
selected
|
||||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
|
? '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-lg'
|
: '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
|
<div
|
||||||
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
|
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 id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
|
||||||
<Handle 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 type="source" 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
|
||||||
<div
|
className={`absolute top-0 left-0 right-0 h-0.5 transition-all duration-300 ${service.status === 'running' ? 'opacity-100' : 'opacity-40'}`}
|
||||||
className={`absolute top-0 left-0 right-0 h-0.5 transition-all duration-300 ${isRunning ? 'opacity-100' : 'opacity-40'}`}
|
style={{ background: stateColor }}
|
||||||
style={{ background: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Action menu on hover */}
|
<div className="relative px-3.5 pt-3.5 pb-3">
|
||||||
<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)]">
|
<div className="flex items-center gap-3 mb-2.5">
|
||||||
<MoreHorizontal size={14} />
|
<div
|
||||||
</button>
|
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
|
||||||
|
style={{
|
||||||
<div className="relative p-4">
|
background: selected && !brand ? typeColor : `${typeColor}1c`,
|
||||||
{/* Header with icon and name */}
|
color: selected && !brand ? 'var(--bg-void)' : typeColor,
|
||||||
<div className="flex items-start gap-3 mb-3">
|
|
||||||
<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' : ''
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
background: selected ? typeColor : `${typeColor}20`,
|
|
||||||
color: selected ? 'white' : typeColor
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{iconType === Globe && <Globe size={18} />}
|
{isTransient ? (
|
||||||
{iconType === Database && <Database size={18} />}
|
<Loader2 size={16} className="animate-spin" />
|
||||||
{iconType === Terminal && <Terminal size={18} />}
|
) : (
|
||||||
{iconType === Box && <Box size={18} />}
|
<ServiceIcon service={service} size={17} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 pt-0.5">
|
<div className="min-w-0 flex-1">
|
||||||
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4>
|
<h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
|
||||||
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5">{service.type}</p>
|
{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>
|
||||||
</div>
|
{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)]">
|
||||||
{/* Domain display - Railway style */}
|
<Layers size={9} />
|
||||||
<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">
|
{replicas}
|
||||||
<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}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
)}
|
||||||
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpen(service.id)}
|
title="Copy internal address"
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-semibold transition-all duration-200 ${
|
onClick={(e) => {
|
||||||
selected
|
e.stopPropagation();
|
||||||
? 'bg-[var(--accent-primary)] text-[var(--accent-on)] shadow-lg shadow-[var(--accent-primary-glow)]'
|
void navigator.clipboard?.writeText(internalAddr);
|
||||||
: 'border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]'
|
}}
|
||||||
}`}
|
className="ml-auto opacity-0 group-hover/addr:opacity-100 transition-opacity text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||||
>
|
>
|
||||||
Open
|
<Copy size={10} />
|
||||||
<ArrowUpRight size={10} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GroupNode({ data }: NodeProps<GroupNodeType>) {
|
export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
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 (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)',
|
borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)',
|
||||||
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30'
|
background: isHovered ? 'var(--accent-primary-soft)' : 'rgba(255,255,255,0.02)',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
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="relative p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="flex items-center gap-2.5">
|
<div
|
||||||
<div
|
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
|
||||||
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
|
style={{
|
||||||
style={{
|
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
|
||||||
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} />
|
||||||
<Box size={14} />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3>
|
|
||||||
</div>
|
</div>
|
||||||
{isHovered && (
|
{editing ? (
|
||||||
<button className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--surface-muted)]">
|
<input
|
||||||
<MoreHorizontal size={14} />
|
autoFocus
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Helper text */}
|
<p
|
||||||
<p className="text-[10px] text-[var(--text-tertiary)] mt-2 opacity-0 transition-opacity duration-200" style={{ opacity: isHovered ? 1 : 0 }}>
|
className="text-[10px] text-[var(--text-tertiary)] mt-2 transition-opacity duration-200"
|
||||||
|
style={{ opacity: isHovered ? 1 : 0 }}
|
||||||
|
>
|
||||||
Drag services here to group them
|
Drag services here to group them
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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,
|
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 nodes = services.map((service, index) => {
|
||||||
const col = index % 3;
|
const col = index % 3;
|
||||||
const row = Math.floor(index / 3);
|
const row = Math.floor(index / 3);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serviceId: service.id,
|
serviceId: service.id,
|
||||||
position: {
|
position: layout?.get(service.id) ?? {
|
||||||
x: 70 + col * 260,
|
x: 70 + col * 260,
|
||||||
y: 80 + row * 170,
|
y: 80 + row * 170,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
type CreateServiceInput,
|
type CreateServiceInput,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { getDemoProjectById, getDemoServicesByProject, getDemoVariablesByProject } from '@/lib/demo-data';
|
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 { formatDate, formatRelative } from '@/lib/time';
|
||||||
import type { ServiceVariable } from '../auto-connections';
|
import type { ServiceVariable } from '../auto-connections';
|
||||||
import { ProjectCanvas } from '../canvas/ProjectCanvas';
|
import { ProjectCanvas } from '../canvas/ProjectCanvas';
|
||||||
@@ -46,10 +48,10 @@ import {
|
|||||||
|
|
||||||
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
|
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: 'canvas', label: 'Canvas', icon: LayoutGrid },
|
||||||
{ key: 'observability', label: 'Observability', icon: Activity },
|
{ 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 },
|
{ 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 ${
|
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
|
repoFullName === repo.full_name
|
||||||
? 'bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
|
? '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>
|
<span className="mono text-xs truncate">{repo.full_name}</span>
|
||||||
@@ -465,10 +467,20 @@ export function ProjectWorkspacePage() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const activeView = (searchParams.get('view') as WorkspaceView | null) ?? 'canvas';
|
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 [createOpen, setCreateOpen] = useState(false);
|
||||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||||
|
|
||||||
|
const openAddService = () => {
|
||||||
|
if (!signedIn) {
|
||||||
|
navigate('/auth/sign-in');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreateOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const down = (e: KeyboardEvent) => {
|
const down = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||||
@@ -569,7 +581,8 @@ export function ProjectWorkspacePage() {
|
|||||||
|
|
||||||
return Object.fromEntries(entries) as Record<string, ServiceVariable[]>;
|
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 ?? {};
|
const variablesByService = isDemoMode ? getDemoVariablesByProject(projectId) : variablesQuery.data ?? {};
|
||||||
@@ -599,7 +612,8 @@ export function ProjectWorkspacePage() {
|
|||||||
})
|
})
|
||||||
.slice(0, 200);
|
.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]);
|
const runningServices = useMemo(() => services.filter((service) => service.status === 'running').length, [services]);
|
||||||
@@ -607,6 +621,8 @@ export function ProjectWorkspacePage() {
|
|||||||
isDemoMode
|
isDemoMode
|
||||||
? `/projects/${projectId}/services/${serviceId}?demo=1`
|
? `/projects/${projectId}/services/${serviceId}?demo=1`
|
||||||
: `/projects/${projectId}/services/${serviceId}`;
|
: `/projects/${projectId}/services/${serviceId}`;
|
||||||
|
const serviceSectionHref = (serviceId: string, section: string) =>
|
||||||
|
`${serviceHref(serviceId)}${isDemoMode ? '&' : '?'}section=${section}`;
|
||||||
|
|
||||||
if (!isDemoMode && projectQuery.isLoading) {
|
if (!isDemoMode && projectQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -631,7 +647,7 @@ export function ProjectWorkspacePage() {
|
|||||||
This project may have been deleted or you don't have access.
|
This project may have been deleted or you don't have access.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<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"
|
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
|
Back to Projects
|
||||||
@@ -641,131 +657,116 @@ export function ProjectWorkspacePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canvasView = activeView === 'canvas';
|
||||||
|
|
||||||
|
const projectsHref = isDemoMode ? '/projects?demo=1' : '/projects';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="flex h-dvh min-h-0 flex-col bg-[var(--bg-void)]">
|
||||||
{/* Header */}
|
{/* Slim workspace bar — tabs + project meta, canvas fills everything below */}
|
||||||
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
|
<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">
|
||||||
<div className="w-full px-8 py-4">
|
<button
|
||||||
<div className="flex items-center justify-between gap-4">
|
onClick={() => navigate(projectsHref)}
|
||||||
<div className="flex items-center gap-4">
|
className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
<span className="hidden sm:inline">Projects</span>
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/projects')}
|
key={item.key}
|
||||||
className="flex items-center gap-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
|
onClick={() => setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
next.set('view', item.key);
|
||||||
|
return next;
|
||||||
|
})}
|
||||||
|
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(--surface-card)] text-[var(--text-primary)] shadow-sm'
|
||||||
|
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<Icon size={13} />
|
||||||
<span>Projects</span>
|
<span className="hidden md:inline">{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="w-px h-5 bg-[var(--border-subtle)]" />
|
);
|
||||||
<div>
|
})}
|
||||||
<h1 className="v-title">{project.name}<span className="v-cursor">_</span></h1>
|
</nav>
|
||||||
<p className="text-sm text-[var(--text-secondary)]">{project.description || 'No description'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-6">
|
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||||
{/* Stats */}
|
<div className="hidden xl:block">
|
||||||
<div className="hidden md:flex items-center gap-6">
|
<LiveIndicator isLive={runningServices > 0} />
|
||||||
<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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Demo Mode Banner */}
|
{/* Content */}
|
||||||
{isDemoMode && (
|
{canvasView ? (
|
||||||
<div className="w-full px-8 py-4">
|
<div className="relative flex-1 min-h-0">
|
||||||
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
|
<ProjectCanvas
|
||||||
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
|
projectId={project.id}
|
||||||
<Sparkles size={16} />
|
services={services}
|
||||||
<span>Demo mode active — using sample data for preview</span>
|
variablesByService={variablesByService}
|
||||||
|
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>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
<div className="w-full px-8 py-6">
|
||||||
|
|
||||||
{/* 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) => {
|
|
||||||
const active = activeView === item.key;
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.key}
|
|
||||||
onClick={() => setSearchParams((prev) => {
|
|
||||||
const next = new URLSearchParams(prev);
|
|
||||||
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 ${
|
|
||||||
active
|
|
||||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
|
||||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<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" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Content Area */}
|
|
||||||
<section className="min-w-0">
|
<section className="min-w-0">
|
||||||
{activeView === 'canvas' && (
|
|
||||||
<ProjectCanvas
|
|
||||||
projectId={project.id}
|
|
||||||
services={services}
|
|
||||||
variablesByService={variablesByService}
|
|
||||||
onAddService={() => setCreateOpen(true)}
|
|
||||||
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeView === 'observability' && (
|
{activeView === 'observability' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Health Summary */}
|
{/* Health Summary */}
|
||||||
@@ -952,6 +953,7 @@ export function ProjectWorkspacePage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Create Service Dialog */}
|
{/* Create Service Dialog */}
|
||||||
{!isDemoMode && (
|
{!isDemoMode && (
|
||||||
@@ -973,6 +975,10 @@ export function ProjectWorkspacePage() {
|
|||||||
open={commandPaletteOpen}
|
open={commandPaletteOpen}
|
||||||
onClose={() => setCommandPaletteOpen(false)}
|
onClose={() => setCommandPaletteOpen(false)}
|
||||||
onAddService={(type) => {
|
onAddService={(type) => {
|
||||||
|
if (!signedIn) {
|
||||||
|
navigate('/auth/sign-in');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
toast.showToast(`Creating ${type} service...`, 'info');
|
toast.showToast(`Creating ${type} service...`, 'info');
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -151,20 +151,36 @@ export function canvasStorageKey(projectId: string): string {
|
|||||||
return `containr.canvas.v1.${projectId}`;
|
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 {
|
export function clearCanvasMetadata(projectId: string): void {
|
||||||
localStorage.removeItem(canvasStorageKey(projectId));
|
canvasStorage()?.removeItem(canvasStorageKey(projectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveCanvasMetadata(projectId: string, metadata: ProjectCanvasMetadata): void {
|
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 key = canvasStorageKey(projectId);
|
||||||
const fallback = createDefaultCanvasMetadata(services);
|
const fallback = createDefaultCanvasMetadata(services, layout);
|
||||||
const serviceIds = new Set(services.map((service) => service.id));
|
const serviceIds = new Set(services.map((service) => service.id));
|
||||||
|
|
||||||
const raw = localStorage.getItem(key);
|
const raw = canvasStorage()?.getItem(key);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
saveCanvasMetadata(projectId, fallback);
|
saveCanvasMetadata(projectId, fallback);
|
||||||
return 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 nodeMap = new Map(parsed.nodes.map((node) => [node.serviceId, node]));
|
||||||
const normalizedNodes: CanvasNodeLayout[] = [];
|
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);
|
const existing = nodeMap.get(service.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
normalizedNodes.push({
|
normalizedNodes.push({
|
||||||
@@ -194,15 +217,14 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[])
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const col = index % 3;
|
const parked = layout?.get(service.id);
|
||||||
const row = Math.floor(index / 3);
|
|
||||||
normalizedNodes.push({
|
normalizedNodes.push({
|
||||||
serviceId: service.id,
|
serviceId: service.id,
|
||||||
position: {
|
position: parked ?? { x: parkedX, y: 80 + parkedRow * 160 },
|
||||||
x: 70 + col * 260,
|
|
||||||
y: 80 + row * 170,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (!parked) {
|
||||||
|
parkedRow += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seenEdgeIds = new Set<string>();
|
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