Compare commits

..
23 Commits
Author SHA1 Message Date
Tomáš DvořákandDevin 32b52d121f fix: e2e QA hardening — honest runtime state, stable public ports, read-only anon UI (#12)
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
* fix: e2e QA hardening — honest runtime URLs, health, and read-only anon UI

Backend:
- managed DB provisioning: attach DB containers to the platform network and
  probe the container IP, fixing the 127.0.0.1 reachability failure inside
  the backend container; log provisioning failures
- stop fabricating connection URLs when provisioning produced none
- template deploys now carry port + health_check into service runtime fields
- service health probed over the project network (backend joins it) instead
  of an in-container wget that fails on shell-less images
- runtime/public/connection URLs rewrite loopback to the request Host so
  links are usable for LAN/remote clients

Frontend:
- service Networking panel shows real runtime URLs/ports/health instead of
  hardcoded containr.dev + 8080->443
- anonymous views hide all mutation controls (new project, add service,
  group, context menu)
- project cards: remove decorative svcN labels; badge says DOWN only when
  zero services run, IDLE when empty, DEGRADED for partial
- mobile projects header no longer clips (fluid search width)
- unknown routes render a not-found page instead of silently redirecting

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: telemetry scope label, chunk split, remove stale vite artifacts

- /system/host now reports scope=host|docker-desktop-vm and the daemon OS;
  Projects dashboard labels cards VM.* when the daemon is a Desktop VM so
  memory/core numbers aren't mistaken for physical host values
- manualChunks: split @xyflow/react and recharts out of the main bundle;
  removes the >1MB chunk warning
- drop tracked vite.config.js/.d.ts build artifacts that shadowed the real
  vite.config.ts

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: persist service host port; demo-data scope for telemetry

Public URLs were ephemeral: every restart/redeploy rebound a random host
port, so endpoint links rotted. Persist published_port on the service and
prefer it for replica 0 on create. If the port is taken, fall back to
ephemeral and repersist the new assignment. Post-start the binding is
verified — Docker Desktop accepts a conflicting publish silently, which
previously left services running with no endpoint at all.

demo-data.ts gains the required scope field for HostMonitoring (CI fix).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-04 09:01:28 +02:00
Tomáš Dvořák d968eb2315 Merge pull request #11 from Dvorinka/feat/app-demo-polish
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
feat(app): unified demo mode + public-demo build for Vercel
2026-09-23 19:07:14 +02:00
Tomas Dvorak 38e0bc1802 merge: resolve main into feat/app-demo-polish 2026-09-23 19:06:42 +02:00
Tomáš Dvořák fe08f8d299 Merge pull request #10 from Dvorinka/feat/fullscreen-canvas
feat(canvas): fullscreen Railway-style workspace canvas
2026-09-23 19:02:55 +02:00
Tomáš Dvořák ad797a49c9 Merge pull request #9 from Dvorinka/feat/vercel-landing
feat: landing page + public template catalog
2026-09-23 19:02:47 +02:00
Tomas DvorakandDevin 6661357963 feat(canvas): shared 26px grid + collision-free node placement
Nodes snapped to the dot-grid lattice on drop; occupied spots resolve
to the nearest free lattice cell instead of stacking. Same protection
inside groups (sibling-aware), on inspector assign, and on ungroup.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:59:14 +02:00
Tomas DvorakandDevin 7fc6ed1215 feat(canvas): full service cockpit on left-click + real grouping
The inspector is now a proper service panel: address with copy, group
assignment (Ungrouped / any group / "+ New group" wrapping the node),
masked env vars with a deep link into the service's Variables tab, a
config grid deep-linking to Config, inferred connections, and the
lifecycle actions. ServiceDetailPage honours ?section= for the deep
links.

Grouping is first-class: assign via the inspector or drag nodes onto a
frame; members slot beneath existing ones and the frame grows only as
needed instead of clamping or ballooning. Fixed a drag bug where moving
a member inside its own group ejected it.

Context menus everywhere: right-click a node (existing actions), a
group frame (Rename via inline edit, Ungroup keeps children in place),
or empty pane (Add service, Add group here, Tidy layout, Fit view).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:50:38 +02:00
Tomas DvorakandDevin d9bfd8a2fd feat(canvas): fullscreen workspace outside the app shell
Move /projects/:id and its service detail route out of PlatformShell —
the project view now owns the entire viewport like Railway's canvas:
no sidebar, no global nav, just the slim project bar over the graph.
Service detail inherits the same chrome-free context with a breadcrumb
back to the canvas, and back/delete navigation preserves ?demo=1.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:20:05 +02:00
Tomas DvorakandDevin a23fa2a0cc fix(site): blinking title cursor in hero mockup, matching the app
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:12:59 +02:00
Tomas DvorakandDevin 1425618c92 feat(canvas): brand tech icons on services, matching site preview
Resolve a service's tech (postgres, redis, go, node, nginx, kafka,
~50 slugs) from its image/name and render the Simple Icons logo in
the tinted icon tile — canvas nodes, inspector, service detail
header, and database rows — with graceful fallback to type icons.
Demo services get resolvable images, ports, and domains; a Redis
Cache service joins Core Platform so the demo canvas mirrors the
landing preview (5 services, web/api/worker → api → postgres/redis).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:12:15 +02:00
Tomas DvorakandDevin 894192bf9b feat(site): drop live demo, add Railway-style canvas preview
Remove all links to the deleted containr-app deployment and replace
the static hero screenshot with a CSS/HTML product mockup: a project
canvas with five icon-branded service nodes, connected edges (one
animated deploying edge), env/zoom/minimap pills, status dots,
domains, and image tags. Mobile gets a two-column layout with its
own edge paths.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 17:56:43 +02:00
Tomas DvorakandDevin e825eb0d7c feat(site): wire landing to deployed app demo
- All CTAs (nav, hero, install note, gallery, catalog, footer) link to
  https://containr-app.vercel.app - the app frontend deployed as a
  public demo (VITE_PUBLIC_DEMO build).
- templates.html: per-template "Open in demo app" deep links via
  ?template=<id>; dead default API removed so the official snapshot
  renders instantly until an instance is configured with ?api=.
- Hero canvas screenshot updated to the fullscreen canvas build.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 17:41:43 +02:00
Tomas DvorakandDevin 5b0a8e0759 feat(app): unified demo mode + public-demo build for Vercel
- Central demo-mode helper (lib/demo-mode.ts): ?demo=1 query flag plus
  VITE_PUBLIC_DEMO build flag that makes every route demo for the
  public Vercel showcase.
- Single demo fixture source (lib/demo-data.ts): projects, services,
  builds, databases, cron, HA, security, host monitoring, templates.
  Fixes cross-page ID drift that broke project navigation.
- Demo support on Projects, HA, Security, Usage, Templates, Landing,
  Service detail; DemoRestricted gate on People/Settings/Audit/Admin.
- Demo-aware PlatformShell: nav filtering, env:demo, demo account menu,
  ?demo=1 preserved through internal links.
- TemplatesPage: selection moved into ?template= URL param for deep
  links; template fixtures shared via demo-data.
- requestJson returns {} for unparseable 200 bodies (SPA fallback) so
  envelope unwraps degrade instead of dereferencing null.
- Recharts ResponsiveContainer initialDimension kills -1 warnings.
- vercel.json: vite framework + SPA rewrites for direct deep links.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 17:38:02 +02:00
Tomas DvorakandDevin e57f0470eb feat(canvas): fullscreen Railway-style workspace canvas
Rebuild the project canvas as a full-viewport graph instead of a boxed
66vh panel. Slim workspace top bar now holds the view tabs, project meta
and actions; the canvas fills all remaining space.

- Floating glass controls: env filter + group pill (top-left), zoom out /
  live zoom % / zoom in / fit / tidy pill (bottom-left), dark MiniMap
  (bottom-right), hint chip, demo badge.
- Inspector panel: selecting a node opens a right-side glass panel with
  status, image, address, inferred connections incl. variable reasons,
  and deploy/restart/stop/delete actions. Esc or pane click closes.
- Auto-layout: dependency-depth layered layout (consumers left,
  datastores right) for first-visit placement and the Tidy action.
  Unpositioned services park right of existing bounds, not at (0,0).
- Nodes: fixed 240px width (was 210 vs 220-280 CSS mismatch), per-type
  icons/colors incl. cron, spinner icon while deploying, hover lift.
- Edges: lime bezier strokes, animated dashes while either endpoint is
  deploying, hover glow updated from stale purple to accent.
- Groups: off-brand crimson replaced with accent tokens, double-click
  title to rename inline.
- Keyboard: F fit, 0 reset zoom, +/- zoom, Esc deselect. Marquee select
  on Shift-drag. Double-click zoom disabled, dead connection drags off.
- Mobile: icon-only tabs, minimap/hint hidden under md.
- Retake docs/screenshots/canvas.png + site copy (1600x900 demo).

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 17:00:50 +02:00
Tomas DvorakandDevin 117b469fae feat: public template catalog + Vercel git integration
Backend: service_templates.is_public lets owners publish user templates to
the anonymous catalog (GET /api/v1/templates), mirroring Railway's
marketplace publish flow. Migration, sqlc queries, visibility rules, and
openapi.yaml updated; types regenerated.

App: template editor gains a "List in the public catalog" checkbox;
Public badge on catalog cards and detail view.

Site: new templates.html catalog page - search, category filters, Official
vs Community badges, detail modal with variables and copyable import JSON.
Fetches the live catalog from a reachable instance (?api= or localStorage
override) and falls back to templates.json, generated from SeedTemplates
via cmd/dumptemplates. Shared styles extracted to style.css.

Vercel: project connected to GitHub repo with rootDirectory=site;
.vercelignore limits CLI uploads to site/ so `vercel deploy --prod` from
repo root matches git builds.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 16:40:58 +02:00
Tomas DvorakandDevin 2b38406a63 feat(site): standalone landing page for Vercel
Static single-file page in site/ matching the app theme (dark #0c0d0f,
lime #b4e34a, self-hosted Inter + JetBrains Mono). Real product
screenshots, install one-liner with copy button, template marquee,
tabbed gallery. No build step; deployed via vercel deploy --prod.

Live: https://containr-lyart.vercel.app

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 15:33:23 +02:00
Tomáš Dvořák 84b102a2c6 Merge pull request #8 from Dvorinka/ci/auto-release
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
feat(ci): release pipeline on merge to main
2026-09-23 12:08:34 +02:00
Tomas DvorakandDevin 9a2aa7267b fix(ci): recreate release instead of --clobber upload
gh release upload --clobber races GitHub's asset tombstones and 422s
intermittently on reruns. Delete-then-create is deterministic; the
git tag is kept.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 11:57:37 +02:00
Tomas DvorakandDevin a99f2e461e feat(ci): release pipeline on merge to main
After tests + image publish, a release job bumps the tag via
conventional commits (github-tag-action, default_bump=false so
docs/chore merges skip releasing), retags :main images in GHCR as
semver+latest (tag pushes via GITHUB_TOKEN do not retrigger CI),
cross-compiles the CLI for 5 platforms, and cuts a GitHub Release
with generated notes and checksums. Manual v* tag pushes get the
same release treatment. CLI --version now reports the tag via the
previously dead -X ldflags.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 11:44:37 +02:00
Tomas DvorakandDevin 7c19af307e fix(build): reuse committed docs snapshot when docs/ is outside context
Script resolved paths from repo root; inside the frontend Docker build
(context = app/frontend) it scanned /docs and died on ENOENT, breaking
the compose smoke job. Resolve from the frontend dir and fall back to
the committed snapshot when the docs tree is absent.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 11:44:37 +02:00
Tomas DvorakandDevin d48d9c293a fix(frontend): queryFn signatures + Link import
useQuery received api fns typed (params?) => Promise<T>; React Query
passes a QueryFunctionContext, so data degraded to {} and broke tsc.
Wrap calls in () => and import Link from react-router-dom.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 11:44:36 +02:00
Tomas DvorakandDevin 0844fab6bf docs: refresh README for public platform and admin console
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 20:02:11 +02:00
Tomas DvorakandDevin 8b6214a0f5 feat: public platform, admin console, templates, landing docs
Projects become publicly browsable after admin approval; management and
secret-adjacent reads (logs, variables, exec, connection URLs) require
owner/member/admin auth, and platform controls sit behind RequireAdmin.

- Backend: OptionalAuth middleware, read/authed/admin route groups,
  projects.is_approved + service_templates.owner_id migration,
  visibility-aware sqlc queries, admin overview/users/approval endpoints,
  EnsureAdminAccount bootstrap (ADMIN_* env), 30 seeded templates with
  owner CRUD, managed-database connection URLs gated owner+admin, fix
  DB.Stats() infinite recursion.
- Frontend: expandable persisted sidebar + clickable user menu, public
  landing page with searchable docs browser (GitHub sync, bundled
  snapshot + localStorage fallback), DocsBrowser shared with /docs,
  template create/edit/JSON-upload for owned templates, database create
  dialog, admin console (stats, approvals, project edit/delete, user
  admin toggles), HA/Security/Usage polish with dead CSS tokens mapped
  to real theme vars, anonymous-safe gating on every mutation control.
- Tests: safe window.localStorage access fixes happy-dom crashes in
  PlatformShell and canvas storage.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 19:58:59 +02:00
108 changed files with 10518 additions and 2285 deletions

No files matched your search

+6
View File
@@ -64,6 +64,12 @@ BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001
# Extra trusted origins (comma-separated). Same-origin traffic is trusted
# automatically, so this only needs entries for cross-origin callers.
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8082
# Platform owner bootstrap — creates/promotes the admin account on startup.
# Keep the real values in a local untracked file, never in committed env files.
ADMIN_EMAIL=[email protected]
ADMIN_PASSWORD=
ADMIN_NAME=Platform Admin
# Optional explicit auth DB settings (recommended when password contains URL special chars)
DB_HOST=postgres
DB_PORT=5432
+93
View File
@@ -222,3 +222,96 @@ jobs:
ignore-unfixed: true
vuln-type: os,library
severity: CRITICAL,HIGH
release:
name: Release
runs-on: ubuntu-latest
needs: [docker]
if: github.event_name != 'pull_request'
permissions:
contents: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# Conventional-commit bump. Tags pushed with GITHUB_TOKEN do not
# retrigger this workflow, so images are retagged below instead.
- name: Bump version and push tag
if: github.ref == 'refs/heads/main'
id: tag_version
uses: mathieudutour/[email protected]
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
default_bump: false
- name: Resolve release tag
id: release
run: |
if [ "${{ github.ref_type }}" = "tag" ]; then
tag="${GITHUB_REF_NAME}"
else
tag="${{ steps.tag_version.outputs.new_tag }}"
fi
if [ -z "$tag" ]; then
echo "No releasable commits since last tag - skipping release."
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup Go
if: steps.release.outputs.tag != ''
uses: actions/setup-go@v5
with:
go-version-file: app/backend/go.mod
cache: true
- name: Build CLI binaries
if: steps.release.outputs.tag != ''
working-directory: app/backend
env:
VERSION: ${{ steps.release.outputs.tag }}
run: bash ../../scripts/build-cli.sh
- name: Checksums
if: steps.release.outputs.tag != ''
working-directory: app/backend/bin
run: sha256sum containr-* > checksums.txt
- name: Login to GHCR
if: steps.release.outputs.tag != '' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Tag pushes from github-tag-action do not trigger workflows, so the
# release job retags the just-published :main images in-registry.
- name: Tag release images in GHCR
if: steps.release.outputs.tag != '' && github.ref == 'refs/heads/main'
env:
TAG: ${{ steps.release.outputs.tag }}
run: |
repo="${GITHUB_REPOSITORY,,}"
for img in backend frontend; do
docker buildx imagetools create \
--tag "ghcr.io/${repo}-${img}:${TAG}" \
--tag "ghcr.io/${repo}-${img}:latest" \
"ghcr.io/${repo}-${img}:main"
done
- name: Create GitHub Release
if: steps.release.outputs.tag != ''
working-directory: app/backend/bin
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.release.outputs.tag }}
run: |
# Delete-then-create keeps reruns idempotent: --clobber uploads
# race GitHub's asset tombstones and intermittently 422. The git
# tag itself is kept (no --cleanup-tag).
gh release delete "$TAG" --yes 2>/dev/null || true
gh release create "$TAG" --title "$TAG" --generate-notes \
containr-* checksums.txt
+1
View File
@@ -123,3 +123,4 @@ bin
ref/
.playwright-mcp/
canvas-*.png
.vercel
+2
View File
@@ -0,0 +1,2 @@
/*
!/site
+45 -9
View File
@@ -28,6 +28,11 @@ and deploy — no Kubernetes, no cloud bill. Services appear as nodes with
auto-inferred connections; logs, metrics, builds, and rollbacks are one
click away.
The platform doubles as a public showcase: visitors browse approved projects
and the template catalog without an account, while owners manage everything
behind sign-in and a platform admin approves, curates, and moderates from a
dedicated console.
## Screenshots
| Projects dashboard | Project canvas |
@@ -41,16 +46,32 @@ click away.
## Features
- **Project canvas** — visual service topology, groups, drag/drop, auto-inferred connections, right-click lifecycle actions
- **Public project catalog** — approved projects are browsable anonymously; new projects stay private until an admin approves them
- **Admin console** (`/admin`) — platform stats, project approval/edit/delete, user admin management
- **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references
- **Deployments & builds** — Docker deploys with history, logs, image rollback, live build status
- **Deployments & builds** — Docker deploys with history, image rollback, live build status
- **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page
- **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App
- **Managed databases** — one-click provisioning with backup/restore
- **Managed databases** — create PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Dragonfly, or ClickHouse directly from `/databases`, with backup/restore
- **Template catalog** — 30 official templates (NocoDB, Plausible, MinIO, n8n, …) plus user-defined templates with JSON upload and owner-scoped edit/delete
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
- **Templates, cron, previews** — service catalog, scheduled jobs, preview deploys
- **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vuln scans
- **HA & scaling** — failover policies, node agents, autoscaling rules
- **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vulnerability scans
- **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
## Access model
| Audience | Can do |
| --- | --- |
| **Anonymous** | Browse approved projects, services, templates, databases, HA status, docs |
| **Signed-in user** | Create/manage own projects, services, databases, templates; view own logs, variables, builds, deployments |
| **Admin** | Everything above plus project approval/moderation, user admin flags, HA manager controls, agent tokens, platform settings |
Logs, variables, exec consoles, and database connection URLs are never
public — they require owner, member, or admin access regardless of a
project's public visibility.
## Quick Start
Requires Docker with the Compose plugin, `openssl`, and `curl`:
@@ -62,11 +83,22 @@ curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh |
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`.
On first visit you create the owner account (email/password); 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 with env vars:
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 | \
@@ -91,10 +123,14 @@ docs/ Guides, OpenAPI spec, design documents
```
All configuration is via environment variables — see `.env.example`. Never
commit `.env` or `.env.prod`; if a secret was ever committed, rotate it.
commit `.env` or `.env.prod`; keep generated admin credentials in a local
untracked file (e.g. `admin-credentials.local`). If a secret was ever
committed, rotate it.
## Documentation
- In-app: the landing page and `/docs` render searchable docs — synced from
this repository, with a bundled snapshot so they are never blank offline
- [docs/guides/](docs/guides/) — setup, deployment, Cloudflare, autoscaling
- [docs/api/openapi.yaml](docs/api/openapi.yaml) — API contract
- [docs/developers/onboarding.md](docs/developers/onboarding.md) — contributor onboarding
+8
View File
@@ -6,7 +6,15 @@ import (
"os"
)
// Injected at build time via -ldflags (see scripts/build-cli.sh).
var (
Version = "dev"
BuildTime = "unknown"
GitCommit = "unknown"
)
func main() {
cli.SetVersion(fmt.Sprintf("%s (%s, %s)", Version, GitCommit, BuildTime))
if err := cli.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
+24
View File
@@ -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)
}
}
+3
View File
@@ -86,6 +86,9 @@ func main() {
}()
}
// Provision the platform owner account (idempotent, non-fatal).
api.EnsureAdminAccount(context.Background(), db, cfg)
// Setup Gin router
if cfg.IsProduction() {
gin.SetMode(gin.ReleaseMode)
+295
View File
@@ -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)})
}
+4 -2
View File
@@ -1217,8 +1217,10 @@ func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
}
}
// SetupRoutes registers authenticated dashboard/control agent routes.
func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) {
// SetupAdminRoutes registers dashboard/control agent routes. Command
// history and container details can leak sensitive output, so these stay
// admin-only rather than joining the public read group.
func (h *NodeAgentHandler) SetupAdminRoutes(router *gin.RouterGroup) {
tokens := router.Group("/agent-tokens")
{
tokens.POST("", h.CreateAgentToken)
+107
View File
@@ -3,7 +3,10 @@ package api
import (
"net/http"
"containr/internal/database"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func requireAuthenticatedUserID(c *gin.Context) (string, bool) {
@@ -21,3 +24,107 @@ func requireAuthenticatedUserID(c *gin.Context) (string, bool) {
return userID, true
}
// optionalUserID returns the resolved user id or "" for anonymous callers on
// OptionalAuth routes. Never writes a response.
func optionalUserID(c *gin.Context) string {
userIDValue, exists := c.Get("user_id")
if !exists {
return ""
}
userID, _ := userIDValue.(string)
return userID
}
// optionalUserUUID is the uuid variant of optionalUserID; anonymous callers
// get uuid.Nil, which matches no owner_id and therefore only sees approved
// public resources.
func optionalUserUUID(c *gin.Context) uuid.UUID {
userID := optionalUserID(c)
parsed, err := uuid.Parse(userID)
if err != nil {
return uuid.Nil
}
return parsed
}
// contextIsAdmin reports whether the resolved caller is a platform admin.
func contextIsAdmin(c *gin.Context) bool {
return c.GetBool("is_admin")
}
// projectReadAccess reports (exists, allowedToRead). Approved projects are
// public; unapproved ones are visible to the owner, project members, and
// platform admins only.
func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
var isApproved bool
var ownerID string
err := db.QueryRow(
`SELECT is_approved, owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&isApproved, &ownerID)
if err != nil {
return false, false
}
if isApproved || contextIsAdmin(c) {
return true, true
}
userID := optionalUserID(c)
if userID == "" {
return true, false
}
if ownerID == userID {
return true, true
}
var member bool
if err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
projectID, userID,
).Scan(&member); err == nil && member {
return true, true
}
return true, false
}
// projectManageAccess reports (exists, allowedToOperate). Unlike
// projectReadAccess, approval does NOT grant access — only the owner,
// project members, and platform admins may reach secret-adjacent data
// such as variables and runtime logs.
func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
var ownerID string
err := db.QueryRow(
`SELECT owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&ownerID)
if err != nil {
return false, false
}
if contextIsAdmin(c) {
return true, true
}
userID := optionalUserID(c)
if userID == "" {
return true, false
}
if ownerID == userID {
return true, true
}
var member bool
if err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
projectID, userID,
).Scan(&member); err == nil && member {
return true, true
}
return true, false
}
// serviceProjectID resolves the project that owns a service.
func serviceProjectID(db *database.DB, serviceID uuid.UUID) (uuid.UUID, bool) {
var projectID uuid.UUID
err := db.QueryRow(`SELECT project_id FROM services WHERE id = $1`, serviceID).Scan(&projectID)
if err != nil {
return uuid.Nil, false
}
return projectID, true
}
+42 -29
View File
@@ -65,19 +65,34 @@ type UpdateCronJobRequest struct {
Retention int `json:"retention"`
}
// cronJobProjectID resolves the project a cron job belongs to.
func cronJobProjectID(db *database.DB, jobID string) (uuid.UUID, bool) {
var projectID uuid.UUID
err := db.QueryRow(
`SELECT cj.project_id FROM cron_jobs cj WHERE cj.id = $1`,
jobID,
).Scan(&projectID)
if err != nil {
return uuid.Nil, false
}
return projectID, true
}
func handleGetCronJobs(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
userID := optionalUserUUID(c)
isAdmin := contextIsAdmin(c)
projectID := c.Query("project_id")
serviceID := c.Query("service_id")
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,
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.retention, cj.created_at, cj.updated_at
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE p.owner_id = $1`
args := []interface{}{userID}
WHERE (p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))`
args := []interface{}{userID, isAdmin}
if projectID != "" {
args = append(args, projectID)
@@ -130,7 +145,7 @@ func handleCreateCronJob(c *gin.Context) {
req.ServiceID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -184,30 +199,32 @@ func handleCreateCronJob(c *gin.Context) {
func handleGetCronJob(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
jobID := c.Param("id")
var job CronJob
var ownerCheck string
err := db.QueryRow(
`SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at, p.owner_id
`SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
&job.Retention, &job.CreatedAt, &job.UpdatedAt, &ownerCheck)
&job.Retention, &job.CreatedAt, &job.UpdatedAt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
if ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
projectID, found := cronJobProjectID(db, jobID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
@@ -233,7 +250,7 @@ func handleUpdateCronJob(c *gin.Context) {
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -303,7 +320,7 @@ func handleDeleteCronJob(c *gin.Context) {
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -321,19 +338,15 @@ func handleDeleteCronJob(c *gin.Context) {
func handleGetCronExecutions(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
jobID := c.Param("id")
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
projectID, found := cronJobProjectID(db, jobID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
@@ -380,7 +393,7 @@ func handleTriggerCronJob(c *gin.Context) {
jobID,
).Scan(&job.ServiceID, &job.Command, &job.Schedule, &job.Timezone, &job.Retention, &ownerCheck)
if err != nil || ownerCheck != userID {
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
+125 -103
View File
@@ -23,6 +23,7 @@ import (
dockercontainer "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry"
"github.com/docker/go-connections/nat"
"github.com/gin-gonic/gin"
@@ -41,6 +42,7 @@ const (
// DatabaseService represents a managed database service
type DatabaseService struct {
ID string `json:"id" db:"id"`
OwnerID string `json:"-" db:"user_id"`
Name string `json:"name" db:"name"`
Type string `json:"type" db:"type"` // postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly
Status string `json:"status" db:"status"` // running, stopped, building, error
@@ -168,13 +170,14 @@ func NewDatabaseHandler(db *sql.DB, dockerClient *docker.Client) *DatabaseHandle
}
}
// GetDatabases returns all database services for a user
// GetDatabases returns database services. The platform is public-read: every
// caller sees the fleet, but connection URLs (which may embed credentials)
// are only exposed to admins.
func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
rows, err := h.queries.ListDatabaseServicesByUser(c.Request.Context(), userID)
userID := optionalUserID(c)
isAdmin := contextIsAdmin(c)
rows, err := h.queries.ListAllDatabaseServices(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch databases"})
return
@@ -182,17 +185,23 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
databases := make([]DatabaseService, 0, len(rows))
for _, row := range rows {
db := mapDatabaseServiceListRow(row)
db := mapDatabaseServiceRow(row)
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil {
db.Backups = backupConfig
privileged := isAdmin || (userID != "" && db.OwnerID == userID)
if privileged {
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil {
db.Backups = backupConfig
} else {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
}
db.Settings = h.generateMockSettings()
db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c))
} 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)
}
@@ -202,15 +211,11 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
// GetDatabase returns a specific database service
func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
userID := optionalUserID(c)
isAdmin := contextIsAdmin(c)
databaseID := c.Param("id")
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID,
UserID: userID,
})
row, err := h.queries.GetDatabaseServiceByID(c.Request.Context(), databaseID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
@@ -219,21 +224,40 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"})
return
}
db := mapDatabaseServiceGetRow(row)
db := mapDatabaseServiceRow(row)
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil {
db.Backups = backupConfig
privileged := isAdmin || (userID != "" && db.OwnerID == userID)
if privileged {
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil {
db.Backups = backupConfig
} else {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
}
db.Settings = h.generateMockSettings()
db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c))
} 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)
}
// effectiveUserID returns the user scope for row-level queries. Admins act
// on behalf of the row's owner so management works across accounts.
func (h *DatabaseHandler) effectiveUserID(c *gin.Context, userID, databaseID string) string {
if !contextIsAdmin(c) || databaseID == "" {
return userID
}
owner, err := h.queries.GetDatabaseServiceOwnerID(c.Request.Context(), databaseID)
if err == nil && owner != "" {
return owner
}
return userID
}
// CreateDatabase creates a new database service
func (h *DatabaseHandler) CreateDatabase(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
@@ -357,6 +381,7 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
var req DatabaseUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -440,6 +465,7 @@ func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
ID: databaseID,
@@ -481,6 +507,7 @@ func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
var req DatabaseActionRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -574,6 +601,7 @@ func (h *DatabaseHandler) CreateBackup(c *gin.Context) {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID,
@@ -623,6 +651,7 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
var req DatabaseRestoreRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -684,6 +713,7 @@ func (h *DatabaseHandler) DownloadBackup(c *gin.Context) {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
backupID := c.Param("bid")
backup, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(c.Request.Context(), sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{
@@ -904,35 +934,6 @@ func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string
}, nil
}
func (h *DatabaseHandler) generateMockBackupConfig() DatabaseBackupConfig {
return DatabaseBackupConfig{
Enabled: true,
LastBackup: &time.Time{},
Retention: 30,
NextBackup: &time.Time{},
Backups: []DatabaseBackup{
{
ID: "backup_1",
CreatedAt: time.Now().Add(-6 * time.Hour),
Size: "245 MB",
Status: "completed",
},
{
ID: "backup_2",
CreatedAt: time.Now().Add(-24 * time.Hour),
Size: "238 MB",
Status: "completed",
},
{
ID: "backup_3",
CreatedAt: time.Now().Add(-48 * time.Hour),
Size: "241 MB",
Status: "completed",
},
},
}
}
func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
return DatabaseSettings{
MaxConnections: 100,
@@ -942,33 +943,47 @@ func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
}
}
// resolveConnectionURL returns the stored connection URL for privileged
// viewers, or "" when the runtime has not produced one (not provisioned yet).
func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string {
if strings.TrimSpace(db.ConnectionURL) != "" {
return db.ConnectionURL
}
return h.generateConnectionURL(db)
return strings.TrimSpace(db.ConnectionURL)
}
func (h *DatabaseHandler) generateConnectionURL(db DatabaseService) string {
safeName := sanitizeDBIdentifier(db.Name, "app")
switch db.Type {
case "postgresql":
return fmt.Sprintf("postgresql://user:password@%s.containr.local:5432/%s", safeName, safeName)
case "redis":
return fmt.Sprintf("redis://%s.containr.local:6379", safeName)
case "dragonfly":
return fmt.Sprintf("redis://%s.containr.local:6379", safeName)
case "mysql":
return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", safeName, safeName)
case "mariadb":
return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", safeName, safeName)
case "mongodb":
return fmt.Sprintf("mongodb://user:password@%s.containr.local:27017/%s", safeName, safeName)
case "clickhouse":
return fmt.Sprintf("http://%s.containr.local:8123", safeName)
default:
// requestHostname returns the host the client used to reach the API, without
// the port. Empty when it cannot be determined.
func requestHostname(c *gin.Context) string {
host := strings.TrimSpace(c.Request.Host)
if host == "" {
return ""
}
if h, _, err := net.SplitHostPort(host); err == nil {
return strings.Trim(h, "[]")
}
return strings.Trim(host, "[]")
}
// rewriteLoopbackURL swaps loopback/unspecified hosts in rawURL for the host
// the caller actually used, so URLs handed to remote browsers are usable.
func rewriteLoopbackURL(rawURL, host string) string {
if rawURL == "" || host == "" {
return rawURL
}
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
switch strings.ToLower(u.Hostname()) {
case "localhost", "127.0.0.1", "0.0.0.0", "::1", "":
default:
return rawURL
}
port := u.Port()
if port == "" {
u.Host = host
} else {
u.Host = net.JoinHostPort(host, port)
}
return u.String()
}
func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService {
@@ -1038,26 +1053,10 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da
}
}
func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService {
return DatabaseService{
ID: row.ID,
Name: row.Name,
Type: row.Type,
Status: row.Status,
Version: row.Version,
Plan: row.Plan,
Region: row.Region,
BackupSchedule: databaseNullString(row.BackupSchedule),
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
ConnectionURL: databaseNullString(row.ConnectionUrl),
CreatedAt: databaseNullTime(row.CreatedAt),
UpdatedAt: databaseNullTime(row.UpdatedAt),
}
}
func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService {
func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
return DatabaseService{
ID: row.ID,
OwnerID: row.UserID,
Name: row.Name,
Type: row.Type,
Status: row.Status,
@@ -1239,13 +1238,13 @@ func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseNam
defer cancel()
if h.dockerClient == nil {
fallbackURL := h.generateConnectionURL(DatabaseService{Name: databaseName, Type: dbType})
_ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{String: fallbackURL, Valid: fallbackURL != ""})
_ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{})
return
}
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables)
if err != nil {
log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err)
_ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{})
return
}
@@ -1353,7 +1352,7 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
if err != nil {
return "", err
}
if err := waitForRuntimePortReadiness(ctx, hostPort); err != nil {
if err := h.waitForDatabaseReadiness(ctx, containerID, hostPort, plan.Port.Port()); err != nil {
return "", err
}
@@ -1387,12 +1386,24 @@ func (h *DatabaseHandler) resolvePublishedHostPort(ctx context.Context, containe
return "", fmt.Errorf("published host port not available")
}
func waitForRuntimePortReadiness(ctx context.Context, hostPort string) error {
addr := net.JoinHostPort("127.0.0.1", strings.TrimSpace(hostPort))
deadline := time.Now().Add(30 * time.Second)
// waitForDatabaseReadiness probes the database's TCP port until it accepts a
// connection. The published port lives on host loopback, which a containerized
// backend cannot reach; in that case the database is additionally attached to
// the backend's own network and probed by container IP.
func (h *DatabaseHandler) waitForDatabaseReadiness(ctx context.Context, containerID, hostPort, containerPort string) error {
probeAddr := net.JoinHostPort("127.0.0.1", strings.TrimSpace(hostPort))
if backendNet := h.dockerClient.OwnNetworkName(ctx); backendNet != "" {
if err := h.dockerClient.ConnectNetwork(ctx, backendNet, containerID, network.EndpointSettings{}); err != nil {
log.Printf("containr: attach database %s to %s failed: %v", containerID, backendNet, err)
}
if ip := h.containerIPOnNetwork(ctx, containerID, backendNet); ip != "" {
probeAddr = net.JoinHostPort(ip, containerPort)
}
}
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 1500*time.Millisecond)
conn, err := net.DialTimeout("tcp", probeAddr, 1500*time.Millisecond)
if err == nil {
_ = conn.Close()
return nil
@@ -1405,7 +1416,18 @@ func waitForRuntimePortReadiness(ctx context.Context, hostPort string) error {
}
}
return fmt.Errorf("database runtime did not become reachable on %s", addr)
return fmt.Errorf("database runtime did not become reachable on %s", probeAddr)
}
func (h *DatabaseHandler) containerIPOnNetwork(ctx context.Context, containerID, networkName string) string {
inspect, err := h.dockerClient.GetContainer(ctx, containerID)
if err != nil || inspect.NetworkSettings == nil {
return ""
}
if ep, ok := inspect.NetworkSettings.Networks[networkName]; ok && ep != nil {
return strings.TrimSpace(ep.IPAddress)
}
return ""
}
func (h *DatabaseHandler) ensureImage(ctx context.Context, imageRef string) error {
+39 -7
View File
@@ -51,15 +51,16 @@ func TestDatabaseConnectionURLAndDefaultVersion(t *testing.T) {
}
for _, tc := range cases {
url := handler.generateConnectionURL(DatabaseService{
Type: tc.dbType,
Name: "example",
})
if url == "" {
t.Fatalf("generateConnectionURL returned empty for type %q", tc.dbType)
plan, err := buildDatabaseRuntimePlan(tc.dbType, "example", nil)
if err != nil {
t.Fatalf("buildDatabaseRuntimePlan(%q) failed: %v", tc.dbType, err)
}
url := plan.ConnectionURL("15432")
if len(tc.urlContains) > 0 && !strings.HasPrefix(url, tc.urlContains) {
t.Fatalf("generateConnectionURL(%q) = %q, expected prefix %q", tc.dbType, url, tc.urlContains)
t.Fatalf("ConnectionURL(%q) = %q, expected prefix %q", tc.dbType, url, tc.urlContains)
}
if !strings.Contains(url, ":15432") {
t.Fatalf("ConnectionURL(%q) = %q, expected published port", tc.dbType, url)
}
gotVersion := handler.getDefaultVersion(tc.dbType)
@@ -69,6 +70,17 @@ func TestDatabaseConnectionURLAndDefaultVersion(t *testing.T) {
}
}
func TestResolveConnectionURLNeverFabricates(t *testing.T) {
handler := &DatabaseHandler{}
if got := handler.resolveConnectionURL(DatabaseService{Type: "postgresql", Name: "example"}); got != "" {
t.Fatalf("resolveConnectionURL with no stored URL = %q, want empty", got)
}
if got := handler.resolveConnectionURL(DatabaseService{ConnectionURL: "postgresql://u:p@localhost:15432/db"}); got != "postgresql://u:p@localhost:15432/db" {
t.Fatalf("resolveConnectionURL = %q, want stored URL", got)
}
}
func TestBuildDatabaseRuntimePlanSupportsAllTypes(t *testing.T) {
cases := []struct {
dbType string
@@ -199,3 +211,23 @@ func TestHumanReadableBytes(t *testing.T) {
}
}
}
func TestRewriteLoopbackURL(t *testing.T) {
tests := []struct {
name, raw, host, want string
}{
{"service url", "http://localhost:35981", "10.0.0.182", "http://10.0.0.182:35981"},
{"db url keeps creds", "postgresql://u:p@localhost:35432/db?sslmode=disable", "10.0.0.182", "postgresql://u:[email protected]:35432/db?sslmode=disable"},
{"ipv4 loopback", "redis://:[email protected]:36379/0", "host.lan", "redis://:[email protected]:36379/0"},
{"unspecified", "http://0.0.0.0:9000/x", "10.0.0.182", "http://10.0.0.182:9000/x"},
{"external host untouched", "https://example.com:443/x", "10.0.0.182", "https://example.com:443/x"},
{"empty raw", "", "10.0.0.182", ""},
{"empty host", "http://localhost:35981", "", "http://localhost:35981"},
{"no port", "http://localhost/path", "10.0.0.182", "http://10.0.0.182/path"},
}
for _, tt := range tests {
if got := rewriteLoopbackURL(tt.raw, tt.host); got != tt.want {
t.Errorf("%s: rewriteLoopbackURL(%q,%q) = %q, want %q", tt.name, tt.raw, tt.host, got, tt.want)
}
}
}
+29 -44
View File
@@ -64,36 +64,22 @@ func handleGetDeployments(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
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 {
if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
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
FROM deployments
WHERE service_id = $1
ORDER BY created_at DESC
FROM deployments
WHERE service_id = $1
ORDER BY created_at DESC
LIMIT 50`,
serviceID,
)
@@ -143,11 +129,8 @@ func handleGetRecentDeployments(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
userID := optionalUserUUID(c)
isAdmin := contextIsAdmin(c)
limit := 10
if v := c.Query("limit"); v != "" {
@@ -162,10 +145,11 @@ func handleGetRecentDeployments(c *gin.Context) {
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.owner_id = $1
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 $2`,
userID.(string), limit,
LIMIT $3`,
userID, isAdmin, limit,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
@@ -372,6 +356,12 @@ func runDeploymentAndSyncWithImage(
replicas = 1
}
var publishedPort int32
// Best effort: reuse the host port from the last live deployment so public
// URLs survive redeploys. Column exists post-migration; older DBs get
// ephemeral ports.
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort)
deployReq := &deployment.DeploymentRequest{
ProjectID: service.ProjectID.String(),
ServiceID: service.ID.String(),
@@ -383,6 +373,7 @@ func runDeploymentAndSyncWithImage(
Environment: env,
Replicas: replicas,
PublicPort: int32(service.Port),
PublishedPort: publishedPort,
Domain: service.Domain,
HealthPath: service.HealthCheckPath,
RestartPolicy: service.RestartPolicy,
@@ -557,18 +548,12 @@ func handleGetDeployment(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var d DeploymentModel
var ownerCheck string
var projectID uuid.UUID
err = db.(*database.DB).QueryRow(
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.owner_id
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
@@ -577,7 +562,7 @@ func handleGetDeployment(c *gin.Context) {
).Scan(
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
&d.CreatedAt, &d.UpdatedAt, &ownerCheck,
&d.CreatedAt, &d.UpdatedAt, &projectID,
)
if err != nil {
@@ -585,8 +570,8 @@ func handleGetDeployment(c *gin.Context) {
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
@@ -639,7 +624,7 @@ func handleRollbackDeployment(c *gin.Context) {
return
}
if ownerCheck != userID.(string) {
if ownerCheck != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
+12 -9
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"containr/internal/database"
"containr/internal/docker"
@@ -17,7 +18,6 @@ import (
// commands run as the container's default user with a 30s ceiling.
func handleExecInService(c *gin.Context) {
serviceID := c.Param("id")
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req struct {
@@ -33,17 +33,20 @@ func handleExecInService(c *gin.Context) {
return
}
var ownerID string
err := db.QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON p.id = s.project_id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerID)
if err != nil || ownerID != userID {
serviceUUID, err := uuid.Parse(serviceID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
projectID, found := serviceProjectID(db, serviceUUID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if _, allowed := projectManageAccess(c, db, projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
dockerClient, _ := c.Get("docker_client")
dockerCli, _ := dockerClient.(*docker.Client)
+27 -12
View File
@@ -25,43 +25,58 @@ func NewHAManager(haManager *ha.HighAvailabilityManager) *HAManager {
}
}
// RegisterRoutes registers HA routes
func (h *HAManager) RegisterRoutes(router *gin.RouterGroup) {
// RegisterReadRoutes registers public HA read endpoints.
func (h *HAManager) RegisterReadRoutes(router *gin.RouterGroup) {
ha := router.Group("/ha")
{
ha.GET("/status", h.GetHAStatus)
// Failover policies
ha.GET("/failover/policies", h.GetFailoverPolicies)
ha.GET("/failover/policies/:serviceId", h.GetFailoverPolicy)
// Health checks
ha.GET("/health/checks", h.GetHealthChecks)
ha.GET("/health/checks/:checkId", h.GetHealthCheck)
ha.GET("/health/results", h.GetHealthResults)
// Alerts
ha.GET("/alerts/rules", h.GetAlertRules)
ha.GET("/alerts/rules/:ruleId", h.GetAlertRule)
ha.GET("/alerts/active", h.GetActiveAlerts)
// Notifiers
ha.GET("/notifiers", h.GetNotifiers)
ha.GET("/notifiers/:notifierId", h.GetNotifier)
}
}
// RegisterAdminRoutes registers HA mutation endpoints.
func (h *HAManager) RegisterAdminRoutes(router *gin.RouterGroup) {
ha := router.Group("/ha")
{
ha.POST("/enable", h.EnableHA)
ha.POST("/disable", h.DisableHA)
ha.POST("/failover", h.TriggerFailover)
// Failover policies
ha.GET("/failover/policies", h.GetFailoverPolicies)
ha.POST("/failover/policies", h.SetFailoverPolicy)
ha.GET("/failover/policies/:serviceId", h.GetFailoverPolicy)
ha.PUT("/failover/policies/:serviceId", h.UpdateFailoverPolicy)
ha.DELETE("/failover/policies/:serviceId", h.DeleteFailoverPolicy)
// Health checks
ha.GET("/health/checks", h.GetHealthChecks)
ha.POST("/health/checks", h.AddHealthCheck)
ha.GET("/health/checks/:checkId", h.GetHealthCheck)
ha.PUT("/health/checks/:checkId", h.UpdateHealthCheck)
ha.DELETE("/health/checks/:checkId", h.DeleteHealthCheck)
ha.GET("/health/results", h.GetHealthResults)
// Alerts
ha.GET("/alerts/rules", h.GetAlertRules)
ha.POST("/alerts/rules", h.AddAlertRule)
ha.GET("/alerts/rules/:ruleId", h.GetAlertRule)
ha.PUT("/alerts/rules/:ruleId", h.UpdateAlertRule)
ha.DELETE("/alerts/rules/:ruleId", h.DeleteAlertRule)
ha.GET("/alerts/active", h.GetActiveAlerts)
ha.POST("/alerts/:alertId/resolve", h.ResolveAlert)
// Notifiers
ha.GET("/notifiers", h.GetNotifiers)
ha.POST("/notifiers", h.AddNotifier)
ha.GET("/notifiers/:notifierId", h.GetNotifier)
ha.DELETE("/notifiers/:notifierId", h.DeleteNotifier)
}
}
+9 -29
View File
@@ -37,27 +37,13 @@ func handleGetLogs(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
@@ -209,30 +195,24 @@ func handleGetDeploymentLogs(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var buildLog, runtimeLog string
var ownerCheck string
var projectID uuid.UUID
err = db.(*database.DB).QueryRow(
`SELECT d.build_log, d.runtime_log, p.owner_id
`SELECT d.build_log, d.runtime_log, p.id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(&buildLog, &runtimeLog, &ownerCheck)
).Scan(&buildLog, &runtimeLog, &projectID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
+6 -13
View File
@@ -60,26 +60,19 @@ func handleGetServiceMetrics(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerID string
var projectID uuid.UUID
var status string
err = db.QueryRow(
`SELECT p.owner_id, COALESCE(s.status, '')
FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
`SELECT s.project_id, COALESCE(s.status, '')
FROM services s WHERE s.id = $1`,
serviceID,
).Scan(&ownerID, &status)
).Scan(&projectID, &status)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
@@ -69,31 +69,13 @@ func handleGetPreviewEnvironments(c *gin.Context) {
return
}
// Check if project exists and user has access
var project Project
err = db.(*database.DB).QueryRow(
"SELECT id, name, owner_id FROM projects WHERE id = $1",
projectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
if err != nil {
// Approved projects are public; unapproved ones need owner/member/admin.
projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID)
if !projectExists || !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
// Get user ID from JWT token (set by auth middleware)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Get preview environments for the project with service info
rows, err := db.(*database.DB).Query(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
@@ -193,7 +175,7 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
if project.OwnerID != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -332,27 +314,22 @@ func handleGetPreviewEnvironment(c *gin.Context) {
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Get preview environment with project ownership check
// Public when the parent project is approved; otherwise owner/member/admin.
userID := optionalUserUUID(c)
var env PreviewEnvironment
var serviceID sql.NullString
var serviceName sql.NullString
var serviceType sql.NullString
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,
s.id as service_id, s.name as service_name, s.type as service_type
FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`,
envID, userID,
WHERE pe.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
envID, userID, contextIsAdmin(c),
).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
@@ -414,8 +391,8 @@ func handleUpdatePreviewEnvironment(c *gin.Context) {
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`,
envID, userID,
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
envID, userID, contextIsAdmin(c),
).Scan(
&existingEnv.ID, &existingEnv.ProjectID, &existingEnv.ServiceID, &existingEnv.BranchName,
&existingEnv.PRNumber, &existingEnv.Environment, &existingEnv.Status, &existingEnv.URL,
@@ -498,7 +475,7 @@ func handleDeletePreviewEnvironment(c *gin.Context) {
}
// Check if user owns the project
if projectOwnerID != userID.(string) {
if projectOwnerID != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -568,8 +545,8 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment, pe.status
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`,
envID, userID,
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
envID, userID, contextIsAdmin(c),
).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.Status,
)
@@ -665,8 +642,8 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE p.owner_id = $1 AND pe.expires_at < NOW() AND pe.status != 'expired'`,
userID,
WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'`,
userID, contextIsAdmin(c),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find expired preview environments"})
@@ -6,8 +6,6 @@ import (
"net/http/httptest"
"testing"
"containr/internal/database"
"github.com/gin-gonic/gin"
)
@@ -89,7 +87,7 @@ func TestRequireAuthenticatedUserIDRejectsMissingUserContext(t *testing.T) {
}
}
func TestRecentDeploymentsRequireAuthentication(t *testing.T) {
func TestRecentDeploymentsAllowAnonymousRead(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/v1/deployments", handleGetRecentDeployments)
@@ -98,20 +96,9 @@ func TestRecentDeploymentsRequireAuthentication(t *testing.T) {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
// No db in context → dependency failure surfaces before auth.
// 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)
}
// With db present but no user context → 401.
router2 := gin.New()
router2.GET("/api/v1/deployments", func(c *gin.Context) {
c.Set("db", &database.DB{})
handleGetRecentDeployments(c)
})
rec2 := httptest.NewRecorder()
router2.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/api/v1/deployments", nil))
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec2.Code)
}
}
+16 -14
View File
@@ -21,6 +21,7 @@ type Project struct {
Name string `json:"name"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
IsApproved bool `json:"is_approved"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
@@ -48,10 +49,8 @@ type UpdateProjectRequest struct {
}
func handleGetProjects(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
userID := optionalUserUUID(c)
isAdmin := contextIsAdmin(c)
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
@@ -75,6 +74,7 @@ func handleGetProjects(c *gin.Context) {
rows, err := queries.ListProjectsWithStatsByUser(c.Request.Context(), sqlcdb.ListProjectsWithStatsByUserParams{
UserID: userID,
IsAdmin: isAdmin,
Search: searchParam,
LimitCount: int32(limit),
OffsetCount: int32(offset),
@@ -90,8 +90,9 @@ func handleGetProjects(c *gin.Context) {
}
total, err := queries.CountProjectsByUser(c.Request.Context(), sqlcdb.CountProjectsByUserParams{
UserID: userID,
Search: searchParam,
UserID: userID,
IsAdmin: isAdmin,
Search: searchParam,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database count error"})
@@ -171,10 +172,7 @@ func handleCreateProject(c *gin.Context) {
}
func handleGetProject(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
userID := optionalUserUUID(c)
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
@@ -187,6 +185,7 @@ func handleGetProject(c *gin.Context) {
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
ProjectID: projectID,
UserID: userID,
IsAdmin: contextIsAdmin(c),
})
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
@@ -217,6 +216,7 @@ func handleUpdateProject(c *gin.Context) {
role, err := queries.GetProjectRoleForUser(c.Request.Context(), sqlcdb.GetProjectRoleForUserParams{
ProjectID: projectID,
UserID: userID,
IsAdmin: contextIsAdmin(c),
})
if errors.Is(err, sql.ErrNoRows) || role == "" || role == "viewer" {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
@@ -270,6 +270,7 @@ func handleUpdateProject(c *gin.Context) {
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
ProjectID: projectID,
UserID: userID,
IsAdmin: contextIsAdmin(c),
})
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
@@ -307,7 +308,7 @@ func handleDeleteProject(c *gin.Context) {
return
}
if ownerID != userID {
if ownerID != userID && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Only project owners can delete projects"})
return
}
@@ -361,7 +362,7 @@ func parseProjectIDParam(c *gin.Context) (uuid.UUID, bool) {
func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWithStats {
result := ProjectWithStats{
Project: mapProjectFields(row.ID, row.Name, row.Description, row.OwnerID, row.CreatedAt, row.UpdatedAt),
Project: mapProjectFields(row.ID, row.Name, row.Description, row.OwnerID, row.IsApproved, row.CreatedAt, row.UpdatedAt),
Stats: ProjectStats{
ServiceCount: int(row.ServiceCount),
DeploymentCount: int(row.DeploymentCount),
@@ -373,15 +374,16 @@ func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWi
}
func mapSQLCProject(project sqlcdb.Project) Project {
return mapProjectFields(project.ID, project.Name, project.Description, project.OwnerID, project.CreatedAt, project.UpdatedAt)
return mapProjectFields(project.ID, project.Name, project.Description, project.OwnerID, project.IsApproved, project.CreatedAt, project.UpdatedAt)
}
func mapProjectFields(id uuid.UUID, name string, description sql.NullString, ownerID uuid.UUID, createdAt, updatedAt sql.NullTime) Project {
func mapProjectFields(id uuid.UUID, name string, description sql.NullString, ownerID uuid.UUID, isApproved bool, createdAt, updatedAt sql.NullTime) Project {
return Project{
ID: id.String(),
Name: name,
Description: nullableStringValue(description),
OwnerID: ownerID.String(),
IsApproved: isApproved,
CreatedAt: nullableTimestampString(createdAt),
UpdatedAt: nullableTimestampString(updatedAt),
}
+157 -135
View File
@@ -96,6 +96,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
c.Next()
})
// Seed the official service template catalog (idempotent upserts).
go SeedOfficialTemplates(context.Background(), db)
go func() {
if err := haManager.Start(context.Background()); err != nil {
log.Printf("HA manager exited: %v", err)
@@ -136,7 +139,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
if redis == nil {
redisStatus = "unhealthy"
checks["redis"] = redisStatus
checks["redis"] = "unhealthy"
checks["redisError"] = "redis client not initialized"
overallStatus = "degraded"
statusCode = http.StatusServiceUnavailable
@@ -177,163 +180,182 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
public.POST("/auth/register", handleRegister)
}
// Protected routes (authentication required)
protected := v1.Group("/")
protected.Use(middleware.Auth(cfg.JWTSecret))
// Read routes — public browsing. OptionalAuth resolves a session when
// present so handlers can widen visibility for owners/admins; anonymous
// callers see approved resources only.
read := v1.Group("/")
read.Use(middleware.OptionalAuth(cfg.JWTSecret))
{
// User routes
protected.GET("/user/profile", handleGetProfile)
protected.PUT("/user/profile", handleUpdateProfile)
protected.POST("/users", handleCreateUser)
read.GET("/projects", handleGetProjects)
read.GET("/projects/:id", handleGetProject)
read.GET("/projects/:id/services", handleGetServices)
read.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
read.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
read.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
read.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics)
// Platform settings (admin-gated inside handlers)
protected.GET("/settings", handleGetSettings)
protected.PUT("/settings", handleUpdateSettings)
read.GET("/services/:id", handleGetService)
read.GET("/services/:id/metrics", handleGetServiceMetrics)
read.GET("/services/:id/runtime", handleGetServiceRuntime)
read.GET("/services/:id/deployments", handleGetDeployments)
read.GET("/deployments", handleGetRecentDeployments)
read.GET("/deployments/:id", handleGetDeployment)
// Project routes
protected.GET("/projects", handleGetProjects)
protected.POST("/projects", handleCreateProject)
read.GET("/databases", databaseHandler.GetDatabases)
read.GET("/databases/:id", databaseHandler.GetDatabase)
// Service routes (nested under projects)
protected.GET("/projects/:id/services", handleGetServices)
protected.POST("/projects/:id/services", handleCreateService)
read.GET("/templates", handleGetTemplates)
read.GET("/templates/:id", handleGetTemplate)
// Generic project routes
protected.GET("/projects/:id", handleGetProject)
protected.PUT("/projects/:id", handleUpdateProject)
protected.DELETE("/projects/:id", handleDeleteProject)
read.GET("/cron-jobs", handleGetCronJobs)
read.GET("/cron-jobs/:id", handleGetCronJob)
read.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
// Service routes
protected.GET("/services/:id", handleGetService)
protected.PUT("/services/:id", handleUpdateService)
protected.DELETE("/services/:id", handleDeleteService)
protected.GET("/services/:id/metrics", handleGetServiceMetrics)
read.GET("/security/scans/:id", securityHandler.GetSecurityScan)
read.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
read.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
// Runtime lifecycle (Railway-style)
protected.GET("/services/:id/runtime", handleGetServiceRuntime)
protected.POST("/services/:id/start", handleServiceStart)
protected.POST("/services/:id/stop", handleServiceStop)
protected.POST("/services/:id/restart", handleServiceRestart)
protected.POST("/services/:id/redeploy", handleServiceRedeploy)
read.GET("/preview-environments/:id", handleGetPreviewEnvironment)
// Deployment routes
protected.GET("/services/:id/deployments", handleGetDeployments)
protected.POST("/services/:id/deployments", handleCreateDeployment)
protected.GET("/deployments", handleGetRecentDeployments)
protected.GET("/deployments/:id", handleGetDeployment)
protected.POST("/deployments/:id/rollback", handleRollbackDeployment)
read.GET("/system/upgrade/status", handleGetUpgradeStatus)
read.GET("/system/host", handleGetHostMonitoring)
// Environment variables routes
protected.GET("/services/:id/variables", handleGetVariables)
protected.PUT("/services/:id/variables", handleUpdateVariables)
// HA + scaling read endpoints (status, policies, health, alerts)
haAPIManager.RegisterReadRoutes(read)
scalingHandler.RegisterReadRoutes(read)
}
// Logs routes
protected.GET("/services/:id/logs", handleGetLogs)
protected.GET("/deployments/:id/logs", handleGetDeploymentLogs)
// Authenticated routes — a valid session is required and handlers
// enforce ownership; platform admins additionally pass every check.
authed := v1.Group("/")
authed.Use(middleware.Auth(cfg.JWTSecret))
{
authed.GET("/user/profile", handleGetProfile)
authed.PUT("/user/profile", handleUpdateProfile)
// One-off exec console (docker exec, 30s ceiling)
protected.POST("/services/:id/exec", handleExecInService)
// Git integration routes
protected.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
protected.POST("/git/github-app/connect", handleConnectGitHubApp)
protected.GET("/git/providers", handleGetGitProviders)
protected.POST("/git/providers", handleCreateGitProvider)
protected.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
protected.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
protected.POST("/git/repositories/connect", handleConnectGitRepository)
protected.GET("/git/repositories", handleGetConnectedRepositories)
protected.POST("/git/webhooks", handleCreateWebhook)
// Build routes
protected.POST("/builds", buildHandler.StartBuild)
protected.GET("/builds", buildHandler.ListBuilds)
protected.GET("/builds/:id", buildHandler.GetBuildStatus)
protected.POST("/builds/:id/cancel", buildHandler.CancelBuild)
protected.GET("/builds/:id/logs", buildHandler.GetBuildLogs)
protected.POST("/builds/plan", buildHandler.GetBuildPlan)
protected.GET("/builds/detect", buildHandler.DetectBuildType)
// System routes
protected.GET("/system/upgrade/status", handleGetUpgradeStatus)
protected.POST("/system/upgrade/pull", handlePullUpgradeImage)
protected.GET("/system/host", handleGetHostMonitoring)
// Scaling routes
scalingHandler.RegisterRoutes(protected)
haAPIManager.RegisterRoutes(protected)
// Database routes
protected.GET("/databases", databaseHandler.GetDatabases)
protected.POST("/databases", databaseHandler.CreateDatabase)
protected.GET("/databases/:id", databaseHandler.GetDatabase)
protected.PUT("/databases/:id", databaseHandler.UpdateDatabase)
protected.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
protected.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
protected.POST("/databases/:id/backup", databaseHandler.CreateBackup)
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
protected.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
// Notification routes
protected.GET("/notifications", handleListNotifications)
protected.POST("/notifications/:id/read", handleMarkNotificationRead)
protected.POST("/notifications/read-all", handleMarkAllNotificationsRead)
// Node Agent routes
agentHandler.SetupRoutes(protected)
// Preview Environments routes
protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
protected.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
protected.GET("/preview-environments/:id", handleGetPreviewEnvironment)
protected.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
protected.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
protected.POST("/preview-environments/:id/promote", handlePromotePreviewEnvironment)
protected.POST("/preview-environments/cleanup-expired", handleCleanupExpiredPreviewEnvironments)
// Security routes
protected.POST("/security/scans", securityHandler.StartSecurityScan)
protected.GET("/security/scans/:id", securityHandler.GetSecurityScan)
protected.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
protected.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
protected.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability)
protected.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
protected.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
protected.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
protected.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
protected.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics)
protected.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs)
authed.GET("/notifications", handleListNotifications)
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
// WebSocket endpoint
protected.GET("/ws", handleWebSocket)
authed.GET("/ws", handleWebSocket)
// Templates routes
protected.GET("/templates", handleGetTemplates)
protected.GET("/templates/:id", handleGetTemplate)
protected.POST("/templates/:id/deploy", handleCreateFromTemplate)
// Owned-resource mutations — handlers scope to the caller and
// admit admins. New projects land unapproved until an admin
// publishes them.
authed.POST("/projects", handleCreateProject)
authed.POST("/projects/:id/services", handleCreateService)
authed.PUT("/projects/:id", handleUpdateProject)
authed.DELETE("/projects/:id", handleDeleteProject)
// Cron Jobs routes
protected.GET("/cron-jobs", handleGetCronJobs)
protected.POST("/cron-jobs", handleCreateCronJob)
protected.GET("/cron-jobs/:id", handleGetCronJob)
protected.PUT("/cron-jobs/:id", handleUpdateCronJob)
protected.DELETE("/cron-jobs/:id", handleDeleteCronJob)
protected.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
protected.POST("/cron-jobs/:id/trigger", handleTriggerCronJob)
authed.PUT("/services/:id", handleUpdateService)
authed.DELETE("/services/:id", handleDeleteService)
authed.POST("/services/:id/start", handleServiceStart)
authed.POST("/services/:id/stop", handleServiceStop)
authed.POST("/services/:id/restart", handleServiceRestart)
authed.POST("/services/:id/redeploy", handleServiceRedeploy)
authed.POST("/services/:id/deployments", handleCreateDeployment)
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
// Audit Logs routes
protected.GET("/audit-logs", handleGetAuditLogs)
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
// Environment variables — secrets, owner/admin only.
authed.GET("/services/:id/variables", handleGetVariables)
authed.PUT("/services/:id/variables", handleUpdateVariables)
// Runtime logs — container stdout routinely echoes secrets,
// so these stay behind authentication like build logs.
authed.GET("/services/:id/logs", handleGetLogs)
authed.GET("/deployments/:id/logs", handleGetDeploymentLogs)
// One-off exec console (docker exec, 30s ceiling)
authed.POST("/services/:id/exec", handleExecInService)
// Git integration — providers carry per-user credentials.
authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
authed.POST("/git/github-app/connect", handleConnectGitHubApp)
authed.GET("/git/providers", handleGetGitProviders)
authed.POST("/git/providers", handleCreateGitProvider)
authed.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
authed.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
authed.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
authed.POST("/git/repositories/connect", handleConnectGitRepository)
authed.GET("/git/repositories", handleGetConnectedRepositories)
authed.POST("/git/webhooks", handleCreateWebhook)
// Builds have no per-user scoping and logs may carry secrets —
// keep every build endpoint behind authentication.
authed.POST("/builds", buildHandler.StartBuild)
authed.GET("/builds", buildHandler.ListBuilds)
authed.GET("/builds/:id", buildHandler.GetBuildStatus)
authed.GET("/builds/:id/logs", buildHandler.GetBuildLogs)
authed.GET("/builds/detect", buildHandler.DetectBuildType)
authed.POST("/builds/:id/cancel", buildHandler.CancelBuild)
authed.POST("/builds/plan", buildHandler.GetBuildPlan)
authed.POST("/databases", databaseHandler.CreateDatabase)
authed.PUT("/databases/:id", databaseHandler.UpdateDatabase)
authed.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
authed.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
authed.POST("/databases/:id/backup", databaseHandler.CreateBackup)
authed.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
authed.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
authed.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
authed.POST("/preview-environments/:id/promote", handlePromotePreviewEnvironment)
authed.POST("/security/scans", securityHandler.StartSecurityScan)
authed.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability)
authed.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
authed.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
authed.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs)
// User templates — ownership enforced inside the handlers.
authed.POST("/templates", handleCreateTemplate)
authed.PUT("/templates/:id", handleUpdateTemplate)
authed.DELETE("/templates/:id", handleDeleteTemplate)
authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
authed.POST("/cron-jobs", handleCreateCronJob)
authed.PUT("/cron-jobs/:id", handleUpdateCronJob)
authed.DELETE("/cron-jobs/:id", handleDeleteCronJob)
authed.POST("/cron-jobs/:id/trigger", handleTriggerCronJob)
}
// Admin routes — platform-wide controls and cross-user management.
admin := v1.Group("/")
admin.Use(middleware.Auth(cfg.JWTSecret))
admin.Use(middleware.RequireAdmin())
{
admin.GET("/admin/overview", handleAdminOverview)
admin.GET("/admin/users", handleAdminListUsers)
admin.PATCH("/admin/users/:id", handleAdminSetUserAdmin)
admin.PATCH("/admin/projects/:id", handleAdminSetProjectApproval)
admin.POST("/users", handleCreateUser)
admin.GET("/settings", handleGetSettings)
admin.PUT("/settings", handleUpdateSettings)
admin.POST("/system/upgrade/pull", handlePullUpgradeImage)
admin.POST("/preview-environments/cleanup-expired", handleCleanupExpiredPreviewEnvironments)
// HA + scaling + agent mutations — platform-level controls.
haAPIManager.RegisterAdminRoutes(admin)
scalingHandler.RegisterAdminRoutes(admin)
agentHandler.SetupAdminRoutes(admin)
admin.GET("/audit-logs", handleGetAuditLogs)
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
// API Gateway routes (merged APwhy) - namespaced to avoid
// colliding with Containr's own /services paths.
gateway := protected.Group("/gateway")
gateway := admin.Group("/gateway")
{
gateway.GET("/services", handleAPwhyServicesList)
gateway.POST("/services", handleAPwhyServicesCreate)
gateway.PATCH("/services/:id", handleAPwhyServicesPatch)
gateway.GET("/services/:id/validate", handleAPwhyServiceValidate)
gateway.GET("/services/:id", handleAPwhyServiceValidate)
gateway.GET("/keys", handleAPwhyKeysList)
gateway.POST("/keys", handleAPwhyKeysCreate)
+75 -3
View File
@@ -36,6 +36,10 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
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)
}
@@ -225,13 +229,56 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
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 := loadOwnedService(c, db)
service, ok := loadReadableService(c, db)
if !ok {
return
}
@@ -240,6 +287,13 @@ func handleGetServiceRuntime(c *gin.Context) {
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})
}
@@ -259,6 +313,7 @@ func reconcileNow(c *gin.Context, engine *deployment.DeploymentEngine, db *datab
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})
@@ -354,6 +409,18 @@ func handleServiceRedeploy(c *gin.Context) {
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) {
@@ -372,10 +439,11 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
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 = state.URLs[0]
service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c))
}
}
@@ -432,9 +500,13 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if _, err := engine.ReconcileService(ctx, spec); err != nil {
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
}
+17 -9
View File
@@ -24,31 +24,39 @@ func NewScalingHandler(autoScaler *scaling.AutoScaler) *ScalingHandler {
}
}
// RegisterRoutes registers scaling routes
func (h *ScalingHandler) RegisterRoutes(router *gin.RouterGroup) {
// RegisterReadRoutes registers public scaling read endpoints.
func (h *ScalingHandler) RegisterReadRoutes(router *gin.RouterGroup) {
scaling := router.Group("/scaling")
{
scaling.GET("/policies", h.GetScalingPolicies)
scaling.POST("/policies", h.SetScalingPolicy)
scaling.GET("/policies/:serviceId", h.GetScalingPolicy)
scaling.PUT("/policies/:serviceId", h.UpdateScalingPolicy)
scaling.DELETE("/policies/:serviceId", h.DeleteScalingPolicy)
scaling.GET("/services", h.GetServiceStates)
scaling.GET("/services/:serviceId", h.GetServiceState)
scaling.GET("/services/:serviceId/history", h.GetScalingHistory)
scaling.POST("/services/:serviceId/scale", h.ManualScale)
scaling.GET("/status", h.GetScalingStatus)
scaling.POST("/enable", h.EnableAutoScaler)
scaling.POST("/disable", h.DisableAutoScaler)
scaling.GET("/metrics", h.GetScalingMetrics)
scaling.GET("/events", h.GetScalingEvents)
}
}
// RegisterAdminRoutes registers scaling mutation endpoints.
func (h *ScalingHandler) RegisterAdminRoutes(router *gin.RouterGroup) {
scaling := router.Group("/scaling")
{
scaling.POST("/policies", h.SetScalingPolicy)
scaling.PUT("/policies/:serviceId", h.UpdateScalingPolicy)
scaling.DELETE("/policies/:serviceId", h.DeleteScalingPolicy)
scaling.POST("/services/:serviceId/scale", h.ManualScale)
scaling.POST("/enable", h.EnableAutoScaler)
scaling.POST("/disable", h.DisableAutoScaler)
}
}
// GetScalingPolicies returns all scaling policies
func (h *ScalingHandler) GetScalingPolicies(c *gin.Context) {
states := h.autoScaler.GetAllServiceStates()
+3 -1
View File
@@ -49,7 +49,9 @@ func setupScalingTestRouter(t *testing.T) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
handler := NewScalingHandler(autoScaler)
handler.RegisterRoutes(router.Group("/api/v1"))
group := router.Group("/api/v1")
handler.RegisterReadRoutes(group)
handler.RegisterAdminRoutes(group)
return router
}
+5 -12
View File
@@ -589,16 +589,7 @@ func (sh *SecurityHandler) GetAuditLogs(c *gin.Context) {
}
func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string) (string, bool) {
userIDValue, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return "", false
}
userID, ok := userIDValue.(string)
if !ok || userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return "", false
}
userID := optionalUserID(c)
if _, err := uuid.Parse(projectID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
@@ -611,12 +602,14 @@ func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string
SELECT 1
FROM projects p
WHERE p.id = $1
AND (p.owner_id = $2 OR EXISTS (
AND (p.is_approved
OR $3::bool
OR p.owner_id = $2 OR EXISTS (
SELECT 1 FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = $2
))
)`,
projectID, userID,
projectID, optionalUserUUID(c), contextIsAdmin(c),
).Scan(&hasAccess)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"})
+17 -33
View File
@@ -91,28 +91,14 @@ func handleGetServices(c *gin.Context) {
return
}
// Check if project exists and user has access
var project Project
err = db.(*database.DB).QueryRow(
"SELECT id, name, owner_id FROM projects WHERE id = $1",
projectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
if err != nil {
// Approved projects are public; unapproved ones need owner/member/admin.
projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID)
if !projectExists {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
// Get user ID from JWT token (set by auth middleware)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
if !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
@@ -217,7 +203,7 @@ func handleCreateService(c *gin.Context) {
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
if project.OwnerID != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -344,14 +330,9 @@ func handleGetService(c *gin.Context) {
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Get service with project ownership check
// Get service — public when its project is approved, otherwise
// owner/member/admin only.
userID := optionalUserUUID(c)
var service Service
err = db.(*database.DB).QueryRow(
`SELECT s.id, s.project_id, s.name,
@@ -371,8 +352,9 @@ func handleGetService(c *gin.Context) {
s.created_at, s.updated_at
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND p.owner_id = $2`,
serviceID, userID,
WHERE s.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
serviceID, userID, contextIsAdmin(c),
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
@@ -438,8 +420,8 @@ func handleUpdateService(c *gin.Context) {
s.created_at, s.updated_at
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND p.owner_id = $2`,
serviceID, userID,
WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
serviceID, userID, contextIsAdmin(c),
).Scan(
&existingService.ID, &existingService.ProjectID, &existingService.Name, &existingService.Type,
&existingService.Status, &existingService.Image, &existingService.Command,
@@ -538,7 +520,9 @@ func handleUpdateService(c *gin.Context) {
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
_, _ = engine.ReconcileService(ctx, spec)
if state, err := engine.ReconcileService(ctx, spec); err == nil && state != nil {
persistPublishedPort(db.(*database.DB), serviceCopy.ID, state.Ports)
}
}()
}
}
+8
View File
@@ -40,6 +40,9 @@ type hostMonitoringResponse struct {
UptimeSeconds int64 `json:"uptime_seconds"`
DockerAvailable bool `json:"docker_available"`
Docker map[string]any `json:"docker,omitempty"`
// Scope names what the telemetry actually measures: the physical host for
// native dockerd, or the Docker Desktop VM when the daemon runs in one.
Scope string `json:"scope"`
CollectedAt string `json:"collected_at"`
}
@@ -159,6 +162,7 @@ func handleGetHostMonitoring(c *gin.Context) {
Storage: storage,
Load: load,
UptimeSeconds: uptime,
Scope: "host",
CollectedAt: time.Now().UTC().Format(time.RFC3339),
}
@@ -173,6 +177,10 @@ func handleGetHostMonitoring(c *gin.Context) {
"images": info.Images,
"driver": info.Driver,
"server": info.ServerVersion,
"os": info.OperatingSystem,
}
if strings.Contains(strings.ToLower(info.OperatingSystem), "docker desktop") {
response.Scope = "docker-desktop-vm"
}
}
}
+451 -8
View File
@@ -3,16 +3,19 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
type ServiceTemplate struct {
@@ -24,6 +27,8 @@ type ServiceTemplate struct {
Config string `json:"config" db:"config"`
Variables string `json:"variables" db:"variables"`
IsOfficial bool `json:"is_official" db:"is_official"`
OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
IsPublic bool `json:"is_public" db:"is_public"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
@@ -67,12 +72,18 @@ func handleGetTemplates(c *gin.Context) {
ctx := c.Request.Context()
queries := sqlcdb.New(db.DB)
userID := optionalUserUUID(c)
owner := uuid.NullUUID{UUID: userID, Valid: userID != uuid.Nil}
var templateRows []sqlcdb.ServiceTemplate
var err error
if category != "" {
templateRows, err = queries.ListServiceTemplatesByCategory(ctx, category)
templateRows, err = queries.ListServiceTemplatesByCategoryForUser(ctx, sqlcdb.ListServiceTemplatesByCategoryForUserParams{
Category: category,
OwnerID: owner,
})
} else {
templateRows, err = queries.ListServiceTemplates(ctx)
templateRows, err = queries.ListServiceTemplatesForUser(ctx, owner)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
@@ -103,7 +114,7 @@ func handleGetTemplate(c *gin.Context) {
}
t := mapSQLCTemplate(row)
if t.ID == "" {
if t.ID == "" || !templateVisibleTo(c, t) {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
@@ -169,7 +180,7 @@ func handleCreateFromTemplate(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch project"})
return
}
if ownerID.String() != userID {
if ownerID.String() != userID && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -185,8 +196,8 @@ func handleCreateFromTemplate(c *gin.Context) {
}
template := mapSQLCTemplate(templateRow)
if template.ID == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
if template.ID == "" || !templateVisibleTo(c, template) {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
@@ -325,6 +336,16 @@ func handleCreateFromTemplate(c *gin.Context) {
return
}
// sqlc's schema predates the runtime columns; port and healthcheck_path are
// written via raw SQL like every other service mutation path.
if _, err = tx.ExecContext(ctx,
`UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3`,
config.Port, strings.TrimSpace(config.HealthCheck), serviceID,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
return
}
for key, value := range envVars {
if strings.TrimSpace(key) == "" {
continue
@@ -363,6 +384,246 @@ func handleCreateFromTemplate(c *gin.Context) {
})
}
// templateWriteRequest is the admin payload for creating/updating a user
// template. config must decode into TemplateConfig; variables into
// []TemplateVariable.
type templateWriteRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Category string `json:"category"`
Logo string `json:"logo"`
Config json.RawMessage `json:"config" binding:"required"`
Variables json.RawMessage `json:"variables"`
IsPublic bool `json:"is_public"`
}
var templateCategories = map[string]bool{
"web": true, "frontend": true, "api": true, "database": true,
"worker": true, "cron": true, "app": true, "custom": true,
}
func normalizeTemplateCategory(category string) string {
normalized := strings.ToLower(strings.TrimSpace(category))
if templateCategories[normalized] {
return normalized
}
return "custom"
}
func validateTemplateWrite(c *gin.Context, req *templateWriteRequest) bool {
req.Name = strings.TrimSpace(req.Name)
if len(req.Name) < 2 || len(req.Name) > 120 {
c.JSON(http.StatusBadRequest, gin.H{"error": "name must be 2-120 characters"})
return false
}
req.Category = normalizeTemplateCategory(req.Category)
req.Logo = strings.TrimSpace(req.Logo)
if len(req.Logo) > 500 || (req.Logo != "" && !strings.HasPrefix(req.Logo, "https://") && !strings.HasPrefix(req.Logo, "http://")) {
c.JSON(http.StatusBadRequest, gin.H{"error": "logo must be an http(s) URL up to 500 chars"})
return false
}
if len(req.Config) == 0 || len(req.Config) > 64*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "config is required and must be under 64KB"})
return false
}
var config TemplateConfig
if err := json.Unmarshal(req.Config, &config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "config must be a valid template config object"})
return false
}
if _, err := normalizeTemplateServiceType(config.Type); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return false
}
if len(req.Variables) > 0 {
if len(req.Variables) > 64*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "variables must be under 64KB"})
return false
}
var variables []TemplateVariable
if err := json.Unmarshal(req.Variables, &variables); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "variables must be an array"})
return false
}
for _, v := range variables {
if strings.TrimSpace(v.Key) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "every variable needs a key"})
return false
}
}
}
return true
}
func templateVariablesParam(raw json.RawMessage) pqtype.NullRawMessage {
if len(raw) == 0 {
return pqtype.NullRawMessage{}
}
return pqtype.NullRawMessage{RawMessage: raw, Valid: true}
}
func handleCreateTemplate(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
var req templateWriteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !validateTemplateWrite(c, &req) {
return
}
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
id := "usr-" + uuid.NewString()
if err := queries.CreateUserTemplate(c.Request.Context(), sqlcdb.CreateUserTemplateParams{
ID: id,
Name: req.Name,
Description: nullableText(strings.TrimSpace(req.Description)),
Category: req.Category,
Logo: nullableText(req.Logo),
Config: req.Config,
Variables: templateVariablesParam(req.Variables),
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
IsPublic: req.IsPublic,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"})
return
}
LogAudit(userID, "template", id, "create", map[string]interface{}{"name": req.Name})
row, err := queries.GetServiceTemplateByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusCreated, gin.H{"template": gin.H{"id": id, "name": req.Name}})
return
}
c.JSON(http.StatusCreated, gin.H{"template": mapSQLCTemplate(row)})
}
func handleUpdateTemplate(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
templateID := c.Param("id")
var req templateWriteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !validateTemplateWrite(c, &req) {
return
}
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
rows, err := queries.UpdateUserTemplate(c.Request.Context(), sqlcdb.UpdateUserTemplateParams{
Name: req.Name,
Description: nullableText(strings.TrimSpace(req.Description)),
Category: req.Category,
Logo: nullableText(req.Logo),
Config: req.Config,
Variables: templateVariablesParam(req.Variables),
IsPublic: req.IsPublic,
ID: templateID,
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
return
}
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found or not editable"})
return
}
LogAudit(userID, "template", templateID, "update", map[string]interface{}{"name": req.Name})
row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "Template updated"})
return
}
c.JSON(http.StatusOK, gin.H{"template": mapSQLCTemplate(row)})
}
func handleDeleteTemplate(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
templateID := c.Param("id")
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
rows, err := queries.DeleteUserTemplate(c.Request.Context(), sqlcdb.DeleteUserTemplateParams{
ID: templateID,
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete template"})
return
}
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found or not deletable"})
return
}
LogAudit(userID, "template", templateID, "delete", nil)
c.JSON(http.StatusOK, gin.H{"message": "Template deleted"})
}
// SeedOfficialTemplates upserts the built-in catalog on startup so new
// templates ship with releases without needing a migration per entry.
func SeedOfficialTemplates(ctx context.Context, db *database.DB) {
if db == nil || db.DB == nil {
return
}
queries := sqlcdb.New(db.DB)
for _, t := range SeedTemplates() {
variables := pqtype.NullRawMessage{}
if strings.TrimSpace(t.Variables) != "" {
variables = pqtype.NullRawMessage{RawMessage: json.RawMessage(t.Variables), Valid: true}
}
if err := queries.UpsertServiceTemplate(ctx, sqlcdb.UpsertServiceTemplateParams{
ID: t.ID,
Name: t.Name,
Description: nullableText(t.Description),
Category: t.Category,
Logo: nullableText(t.Logo),
Config: json.RawMessage(t.Config),
Variables: variables,
IsOfficial: sql.NullBool{Bool: true, Valid: true},
}); err != nil {
log.Printf("Failed to seed template %s: %v", t.ID, err)
}
}
}
func SeedTemplates() []ServiceTemplate {
templates := []ServiceTemplate{
{
@@ -480,7 +741,7 @@ func SeedTemplates() []ServiceTemplate {
Name: "Background Worker",
Description: "Background job processing service",
Category: "worker",
Logo: "https://cdn.simpleicons.org/terminal",
Logo: "https://cdn.simpleicons.org/gnubash",
Config: `{"type":"worker","runtime":"node","build_command":"npm install","start_command":"npm run worker"}`,
Variables: `[{"key":"WORKER_CONCURRENCY","label":"Concurrency","default":"4","required":false,"secret":false}]`,
IsOfficial: true,
@@ -490,7 +751,7 @@ func SeedTemplates() []ServiceTemplate {
Name: "Cron Job",
Description: "Scheduled task runner",
Category: "cron",
Logo: "https://cdn.simpleicons.org/clock",
Logo: "https://cdn.simpleicons.org/clockify",
Config: `{"type":"cron","runtime":"node","build_command":"npm install","start_command":"npm run cron"}`,
Variables: `[{"key":"CRON_SCHEDULE","label":"Schedule","default":"0 * * * *","required":true,"secret":false}]`,
IsOfficial: true,
@@ -505,6 +766,167 @@ func SeedTemplates() []ServiceTemplate {
Variables: `[{"key":"IMAGE","label":"Docker Image","default":"","required":true,"secret":false},{"key":"TAG","label":"Image Tag","default":"latest","required":false,"secret":false}]`,
IsOfficial: true,
},
// Self-hosted applications — runtime doubles as the image reference.
{
ID: "tpl-nextcloud",
Name: "Nextcloud",
Description: "Self-hosted file sync, share and collaboration platform",
Category: "app",
Logo: "https://cdn.simpleicons.org/nextcloud",
Config: `{"type":"web","runtime":"nextcloud:30-apache","port":80,"health_check":"/status.php"}`,
Variables: `[{"key":"NEXTCLOUD_ADMIN_USER","label":"Admin User","default":"admin","required":false,"secret":false},{"key":"NEXTCLOUD_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true},{"key":"NEXTCLOUD_TRUSTED_DOMAINS","label":"Trusted Domains","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-gitea",
Name: "Gitea",
Description: "Lightweight self-hosted Git service",
Category: "app",
Logo: "https://cdn.simpleicons.org/gitea",
Config: `{"type":"web","runtime":"gitea/gitea:1","port":3000,"health_check":"/api/healthz"}`,
Variables: `[{"key":"GITEA__server__DOMAIN","label":"Server Domain","default":"localhost","required":false,"secret":false},{"key":"GITEA__server__ROOT_URL","label":"Root URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-ghost",
Name: "Ghost",
Description: "Independent publishing platform for blogs and newsletters",
Category: "app",
Logo: "https://cdn.simpleicons.org/ghost",
Config: `{"type":"web","runtime":"ghost:5-alpine","port":2368,"health_check":"/ghost/api/admin/site/"}`,
Variables: `[{"key":"url","label":"Public URL","default":"","required":true,"secret":false},{"key":"database__client","label":"Database Client","default":"sqlite3","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-uptime-kuma",
Name: "Uptime Kuma",
Description: "Self-hosted monitoring tool for services and websites",
Category: "app",
Logo: "https://cdn.simpleicons.org/uptimerobot",
Config: `{"type":"web","runtime":"louislam/uptime-kuma:1","port":3001,"health_check":"/"}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-vaultwarden",
Name: "Vaultwarden",
Description: "Lightweight Bitwarden-compatible password manager",
Category: "app",
Logo: "https://cdn.simpleicons.org/bitwarden",
Config: `{"type":"web","runtime":"vaultwarden/server:latest","port":80,"health_check":"/alive"}`,
Variables: `[{"key":"ADMIN_TOKEN","label":"Admin Token","default":"","required":false,"secret":true},{"key":"SIGNUPS_ALLOWED","label":"Allow Signups","default":"false","required":false,"secret":false},{"key":"DOMAIN","label":"Domain URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-jellyfin",
Name: "Jellyfin",
Description: "Free software media system for movies, shows and music",
Category: "app",
Logo: "https://cdn.simpleicons.org/jellyfin",
Config: `{"type":"web","runtime":"jellyfin/jellyfin:latest","port":8096,"health_check":"/health"}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-n8n",
Name: "n8n",
Description: "Workflow automation tool with hundreds of integrations",
Category: "app",
Logo: "https://cdn.simpleicons.org/n8n",
Config: `{"type":"web","runtime":"n8nio/n8n:latest","port":5678,"health_check":"/healthz"}`,
Variables: `[{"key":"N8N_HOST","label":"Hostname","default":"","required":false,"secret":false},{"key":"N8N_ENCRYPTION_KEY","label":"Encryption Key","default":"","required":true,"secret":true},{"key":"WEBHOOK_URL","label":"Webhook URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-grafana",
Name: "Grafana",
Description: "Observability dashboards for metrics, logs and traces",
Category: "app",
Logo: "https://cdn.simpleicons.org/grafana",
Config: `{"type":"web","runtime":"grafana/grafana:latest","port":3000,"health_check":"/api/health"}`,
Variables: `[{"key":"GF_SECURITY_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-immich",
Name: "Immich",
Description: "Self-hosted photo and video backup",
Category: "app",
Logo: "https://cdn.simpleicons.org/immich",
Config: `{"type":"web","runtime":"ghcr.io/immich-app/immich-server:release","port":2283,"health_check":"/api/server/ping"}`,
Variables: `[{"key":"DB_PASSWORD","label":"Database Password","default":"","required":true,"secret":true},{"key":"DB_HOSTNAME","label":"Database Host","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-meilisearch",
Name: "Meilisearch",
Description: "Fast, typo-tolerant search engine API",
Category: "app",
Logo: "https://cdn.simpleicons.org/meilisearch",
Config: `{"type":"web","runtime":"getmeili/meilisearch:v1","port":7700,"health_check":"/health"}`,
Variables: `[{"key":"MEILI_MASTER_KEY","label":"Master Key","default":"","required":true,"secret":true},{"key":"MEILI_ENV","label":"Environment","default":"production","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-plausible",
Name: "Plausible Analytics",
Description: "Lightweight, privacy-friendly web analytics",
Category: "app",
Logo: "https://cdn.simpleicons.org/plausibleanalytics",
Config: `{"type":"web","runtime":"ghcr.io/plausible/community-edition:v2","port":8000,"health_check":"/api/health"}`,
Variables: `[{"key":"BASE_URL","label":"Base URL","default":"","required":true,"secret":false},{"key":"SECRET_KEY_BASE","label":"Secret Key Base","default":"","required":true,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-minio",
Name: "MinIO",
Description: "S3-compatible object storage",
Category: "app",
Logo: "https://cdn.simpleicons.org/minio",
Config: `{"type":"web","runtime":"minio/minio:latest","start_command":"server /data --console-address \":9001\"","port":9001,"health_check":"/minio/health/live"}`,
Variables: `[{"key":"MINIO_ROOT_USER","label":"Root User","default":"minioadmin","required":true,"secret":false},{"key":"MINIO_ROOT_PASSWORD","label":"Root Password","default":"","required":true,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-appwrite",
Name: "Appwrite",
Description: "Backend-as-a-service for web and mobile apps",
Category: "app",
Logo: "https://cdn.simpleicons.org/appwrite",
Config: `{"type":"web","runtime":"appwrite/appwrite:1.6","port":80,"health_check":"/health"}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-wordpress",
Name: "WordPress",
Description: "Classic CMS powering a large share of the web",
Category: "app",
Logo: "https://cdn.simpleicons.org/wordpress",
Config: `{"type":"web","runtime":"wordpress:6-apache","port":80,"health_check":"/wp-login.php"}`,
Variables: `[{"key":"WORDPRESS_DB_HOST","label":"Database Host","default":"","required":true,"secret":false},{"key":"WORDPRESS_DB_USER","label":"Database User","default":"wordpress","required":true,"secret":false},{"key":"WORDPRESS_DB_PASSWORD","label":"Database Password","default":"","required":true,"secret":true},{"key":"WORDPRESS_DB_NAME","label":"Database Name","default":"wordpress","required":true,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-paperless",
Name: "Paperless-ngx",
Description: "Document management system with OCR",
Category: "app",
Logo: "https://cdn.simpleicons.org/paperlessngx",
Config: `{"type":"web","runtime":"ghcr.io/paperless-ngx/paperless-ngx:latest","port":8000,"health_check":"/api/"}`,
Variables: `[{"key":"PAPERLESS_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true},{"key":"PAPERLESS_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-ntfy",
Name: "ntfy",
Description: "Push notification service over HTTP",
Category: "app",
Logo: "https://cdn.simpleicons.org/ntfy",
Config: `{"type":"web","runtime":"binwiederhier/ntfy:latest","start_command":"serve","port":80,"health_check":"/v1/health"}`,
Variables: `[]`,
IsOfficial: true,
},
}
return templates
}
@@ -605,12 +1027,31 @@ func defaultTemplateResources(serviceType string) (cpu, memory string) {
}
}
// templateVisibleTo reports whether the caller may see a template: official and
// published (is_public) templates are public, other user templates are
// owner/admin only.
func templateVisibleTo(c *gin.Context, t ServiceTemplate) bool {
if t.IsOfficial || t.IsPublic {
return true
}
if contextIsAdmin(c) {
return true
}
userID := optionalUserID(c)
return userID != "" && t.OwnerID == userID
}
func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
variables := "[]"
if row.Variables.Valid && len(row.Variables.RawMessage) > 0 {
variables = string(row.Variables.RawMessage)
}
ownerID := ""
if row.OwnerID.Valid {
ownerID = row.OwnerID.UUID.String()
}
return ServiceTemplate{
ID: row.ID,
Name: row.Name,
@@ -620,6 +1061,8 @@ func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
Config: string(row.Config),
Variables: variables,
IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool,
OwnerID: ownerID,
IsPublic: row.IsPublic,
CreatedAt: templateNullTime(row.CreatedAt),
UpdatedAt: templateNullTime(row.UpdatedAt),
}
+6 -34
View File
@@ -47,26 +47,12 @@ func handleGetVariables(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -123,26 +109,12 @@ func handleUpdateVariables(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
+6
View File
@@ -27,6 +27,12 @@ func Execute() {
cobra.CheckErr(rootCmd.Execute())
}
// SetVersion overrides the version reported by `containr --version`.
// Called by main with the value injected via -ldflags at build time.
func SetVersion(v string) {
rootCmd.Version = v
}
func init() {
cobra.OnInitialize(initConfig)
+10
View File
@@ -91,6 +91,12 @@ type Config struct {
// Dashboard UI Configuration
DashboardUIBasePath string `env:"DASHBOARD_UI_BASE_PATH" default:"/"`
// Admin bootstrap — when set, the account is provisioned/promoted on
// startup so the owner always has admin credentials that work.
AdminEmail string `env:"ADMIN_EMAIL" default:""`
AdminPassword string `env:"ADMIN_PASSWORD" default:""`
AdminName string `env:"ADMIN_NAME" default:""`
}
// Environment represents the application environment
@@ -181,6 +187,10 @@ func Load() *Config {
cfg.DashboardUIBasePath = getenv("DASHBOARD_UI_BASE_PATH", "/")
cfg.AdminEmail = getenv("ADMIN_EMAIL", "")
cfg.AdminPassword = getenv("ADMIN_PASSWORD", "")
cfg.AdminName = getenv("ADMIN_NAME", "")
// Validate configuration
if err := cfg.Validate(); err != nil {
panic(fmt.Sprintf("Invalid configuration: %v", err))
+1 -1
View File
@@ -55,5 +55,5 @@ func (db *DB) Health(ctx context.Context) error {
// Stats returns connection pool statistics for monitoring
func (db *DB) Stats() sql.DBStats {
return db.Stats()
return db.DB.Stats()
}
@@ -149,37 +149,18 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
return i, err
}
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
WHERE id = $1 AND user_id = $2
WHERE id = $1
`
type GetDatabaseServiceByIDAndUserParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
}
type GetDatabaseServiceByIDAndUserRow struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"`
BackupSchedule sql.NullString `json:"backup_schedule"`
NextBackupAt sql.NullTime `json:"next_backup_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (GetDatabaseServiceByIDAndUserRow, error) {
row := q.db.QueryRowContext(ctx, getDatabaseServiceByIDAndUser, arg.ID, arg.UserID)
var i GetDatabaseServiceByIDAndUserRow
func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error) {
row := q.db.QueryRowContext(ctx, getDatabaseServiceByID, id)
var i DatabaseService
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Type,
&i.Status,
@@ -195,6 +176,94 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
return i, err
}
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
WHERE id = $1 AND user_id = $2
`
type GetDatabaseServiceByIDAndUserParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
}
func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error) {
row := q.db.QueryRowContext(ctx, getDatabaseServiceByIDAndUser, arg.ID, arg.UserID)
var i DatabaseService
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Type,
&i.Status,
&i.Version,
&i.Plan,
&i.Region,
&i.ConnectionUrl,
&i.BackupSchedule,
&i.NextBackupAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getDatabaseServiceOwnerID = `-- name: GetDatabaseServiceOwnerID :one
SELECT user_id
FROM database_services
WHERE id = $1
`
func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error) {
row := q.db.QueryRowContext(ctx, getDatabaseServiceOwnerID, id)
var user_id string
err := row.Scan(&user_id)
return user_id, err
}
const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
ORDER BY created_at DESC
`
func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error) {
rows, err := q.db.QueryContext(ctx, listAllDatabaseServices)
if err != nil {
return nil, err
}
defer rows.Close()
items := []DatabaseService{}
for rows.Next() {
var i DatabaseService
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Type,
&i.Status,
&i.Version,
&i.Plan,
&i.Region,
&i.ConnectionUrl,
&i.BackupSchedule,
&i.NextBackupAt,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDatabaseBackupsByDatabaseAndUser = `-- name: ListDatabaseBackupsByDatabaseAndUser :many
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at
FROM database_backups b
@@ -242,38 +311,24 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
}
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
WHERE user_id = $1
ORDER BY created_at DESC
`
type ListDatabaseServicesByUserRow struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"`
BackupSchedule sql.NullString `json:"backup_schedule"`
NextBackupAt sql.NullTime `json:"next_backup_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error) {
func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) {
rows, err := q.db.QueryContext(ctx, listDatabaseServicesByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListDatabaseServicesByUserRow{}
items := []DatabaseService{}
for rows.Next() {
var i ListDatabaseServicesByUserRow
var i DatabaseService
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Type,
&i.Status,
@@ -154,6 +154,7 @@ type Project struct {
Name string `json:"name"`
Description sql.NullString `json:"description"`
OwnerID uuid.UUID `json:"owner_id"`
IsApproved bool `json:"is_approved"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
@@ -205,6 +206,8 @@ type ServiceTemplate struct {
Config json.RawMessage `json:"config"`
Variables pqtype.NullRawMessage `json:"variables"`
IsOfficial sql.NullBool `json:"is_official"`
OwnerID uuid.NullUUID `json:"owner_id"`
IsPublic bool `json:"is_public"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
@@ -16,25 +16,31 @@ const countProjectsByUser = `-- name: CountProjectsByUser :one
SELECT COUNT(*)::bigint AS total
FROM projects p
WHERE
(p.owner_id = $1 OR EXISTS (
SELECT 1
FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = $1
))
(
p.is_approved
OR p.owner_id = $1
OR $2::bool
OR EXISTS (
SELECT 1
FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = $1
)
)
AND (
$2::text IS NULL
OR p.name ILIKE ('%' || $2::text || '%')
OR COALESCE(p.description, '') ILIKE ('%' || $2::text || '%')
$3::text IS NULL
OR p.name ILIKE ('%' || $3::text || '%')
OR COALESCE(p.description, '') ILIKE ('%' || $3::text || '%')
)
`
type CountProjectsByUserParams struct {
UserID uuid.UUID `json:"user_id"`
Search sql.NullString `json:"search"`
UserID uuid.UUID `json:"user_id"`
IsAdmin bool `json:"is_admin"`
Search sql.NullString `json:"search"`
}
func (q *Queries) CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countProjectsByUser, arg.UserID, arg.Search)
row := q.db.QueryRowContext(ctx, countProjectsByUser, arg.UserID, arg.IsAdmin, arg.Search)
var total int64
err := row.Scan(&total)
return total, err
@@ -43,7 +49,7 @@ func (q *Queries) CountProjectsByUser(ctx context.Context, arg CountProjectsByUs
const createProject = `-- name: CreateProject :one
INSERT INTO projects (name, description, owner_id)
VALUES ($1, $2, $3)
RETURNING id, name, description, owner_id, created_at, updated_at
RETURNING id, name, description, owner_id, is_approved, created_at, updated_at
`
type CreateProjectParams struct {
@@ -60,6 +66,7 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (P
&i.Name,
&i.Description,
&i.OwnerID,
&i.IsApproved,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -80,11 +87,13 @@ func (q *Queries) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (i
}
const getProjectByIDForUser = `-- name: GetProjectByIDForUser :one
SELECT p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
FROM projects p
WHERE p.id = $1
AND (
p.owner_id = $2
p.is_approved
OR p.owner_id = $2
OR $3::bool
OR EXISTS (
SELECT 1
FROM project_members pm
@@ -96,16 +105,18 @@ WHERE p.id = $1
type GetProjectByIDForUserParams struct {
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
IsAdmin bool `json:"is_admin"`
}
func (q *Queries) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error) {
row := q.db.QueryRowContext(ctx, getProjectByIDForUser, arg.ProjectID, arg.UserID)
row := q.db.QueryRowContext(ctx, getProjectByIDForUser, arg.ProjectID, arg.UserID, arg.IsAdmin)
var i Project
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.OwnerID,
&i.IsApproved,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -127,21 +138,22 @@ func (q *Queries) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID)
const getProjectRoleForUser = `-- name: GetProjectRoleForUser :one
SELECT (CASE
WHEN p.owner_id = $1 THEN 'owner'
WHEN p.owner_id = $1 OR $2::bool THEN 'owner'
ELSE COALESCE(pm.role, '')
END)::text AS role
FROM projects p
LEFT JOIN project_members pm ON p.id = pm.project_id AND pm.user_id = $1
WHERE p.id = $2
WHERE p.id = $3
`
type GetProjectRoleForUserParams struct {
UserID uuid.UUID `json:"user_id"`
IsAdmin bool `json:"is_admin"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) {
row := q.db.QueryRowContext(ctx, getProjectRoleForUser, arg.UserID, arg.ProjectID)
row := q.db.QueryRowContext(ctx, getProjectRoleForUser, arg.UserID, arg.IsAdmin, arg.ProjectID)
var role string
err := row.Scan(&role)
return role, err
@@ -162,12 +174,52 @@ func (q *Queries) InsertProjectEnvironment(ctx context.Context, arg InsertProjec
return err
}
const listPendingProjects = `-- name: ListPendingProjects :many
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
FROM projects p
WHERE p.is_approved = false
ORDER BY p.created_at DESC
`
func (q *Queries) ListPendingProjects(ctx context.Context) ([]Project, error) {
rows, err := q.db.QueryContext(ctx, listPendingProjects)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Project{}
for rows.Next() {
var i Project
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.OwnerID,
&i.IsApproved,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjectsWithStatsByUser = `-- name: ListProjectsWithStatsByUser :many
SELECT
p.id,
p.name,
p.description,
p.owner_id,
p.is_approved,
p.created_at,
p.updated_at,
COUNT(DISTINCT s.id)::bigint AS service_count,
@@ -185,23 +237,29 @@ FROM projects p
LEFT JOIN services s ON s.project_id = p.id
LEFT JOIN deployments d ON d.service_id = s.id
WHERE
(p.owner_id = $1 OR EXISTS (
SELECT 1
FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = $1
))
AND (
$2::text IS NULL
OR p.name ILIKE ('%' || $2::text || '%')
OR COALESCE(p.description, '') ILIKE ('%' || $2::text || '%')
(
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
)
)
GROUP BY p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
AND (
$3::text IS NULL
OR p.name ILIKE ('%' || $3::text || '%')
OR COALESCE(p.description, '') ILIKE ('%' || $3::text || '%')
)
GROUP BY p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
ORDER BY p.updated_at DESC
LIMIT $4 OFFSET $3
LIMIT $5 OFFSET $4
`
type ListProjectsWithStatsByUserParams struct {
UserID uuid.UUID `json:"user_id"`
IsAdmin bool `json:"is_admin"`
Search sql.NullString `json:"search"`
OffsetCount int32 `json:"offset_count"`
LimitCount int32 `json:"limit_count"`
@@ -212,6 +270,7 @@ type ListProjectsWithStatsByUserRow struct {
Name string `json:"name"`
Description sql.NullString `json:"description"`
OwnerID uuid.UUID `json:"owner_id"`
IsApproved bool `json:"is_approved"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ServiceCount int64 `json:"service_count"`
@@ -220,9 +279,13 @@ type ListProjectsWithStatsByUserRow struct {
LastDeployment sql.NullTime `json:"last_deployment"`
}
// Visibility model: a project is readable when it is approved for public
// display, when the caller owns or is a member of it, or when the caller is a
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) {
rows, err := q.db.QueryContext(ctx, listProjectsWithStatsByUser,
arg.UserID,
arg.IsAdmin,
arg.Search,
arg.OffsetCount,
arg.LimitCount,
@@ -239,6 +302,7 @@ func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProje
&i.Name,
&i.Description,
&i.OwnerID,
&i.IsApproved,
&i.CreatedAt,
&i.UpdatedAt,
&i.ServiceCount,
@@ -259,6 +323,25 @@ func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProje
return items, nil
}
const setProjectApproved = `-- name: SetProjectApproved :execrows
UPDATE projects
SET is_approved = $1, updated_at = NOW()
WHERE id = $2
`
type SetProjectApprovedParams struct {
IsApproved bool `json:"is_approved"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setProjectApproved, arg.IsApproved, arg.ProjectID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const updateProjectByID = `-- name: UpdateProjectByID :execrows
UPDATE projects
SET
@@ -25,10 +25,12 @@ type Querier interface {
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
DeleteAgent(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
GetAgent(ctx context.Context, id string) (NodeAgent, error)
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
@@ -36,7 +38,9 @@ type Querier interface {
GetContainer(ctx context.Context, id string) (ContainerInstance, error)
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, arg GetDatabaseBackupByIDAndDatabaseAndUserParams) (DatabaseBackup, error)
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (GetDatabaseServiceByIDAndUserRow, error)
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error)
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
@@ -47,16 +51,21 @@ type Querier interface {
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
ListAgents(ctx context.Context) ([]NodeAgent, error)
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error)
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListPendingProjects(ctx context.Context) ([]Project, error)
// Visibility model: a project is readable when it is approved for public
// display, when the caller owns or is a member of it, or when the caller is a
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error)
ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error)
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
@@ -66,6 +75,7 @@ type Querier interface {
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
@@ -74,7 +84,9 @@ type Querier interface {
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
}
var _ Querier = (*Queries)(nil)
@@ -8,8 +8,10 @@ package sqlcdb
import (
"context"
"database/sql"
"encoding/json"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
const countServicesByProjectAndName = `-- name: CountServicesByProjectAndName :one
@@ -122,6 +124,69 @@ func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServi
return err
}
const createUserTemplate = `-- name: CreateUserTemplate :exec
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id, is_public)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
false,
$8,
$9
)
`
type CreateUserTemplateParams struct {
ID string `json:"id"`
Name string `json:"name"`
Description sql.NullString `json:"description"`
Category string `json:"category"`
Logo sql.NullString `json:"logo"`
Config json.RawMessage `json:"config"`
Variables pqtype.NullRawMessage `json:"variables"`
OwnerID uuid.NullUUID `json:"owner_id"`
IsPublic bool `json:"is_public"`
}
func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error {
_, err := q.db.ExecContext(ctx, createUserTemplate,
arg.ID,
arg.Name,
arg.Description,
arg.Category,
arg.Logo,
arg.Config,
arg.Variables,
arg.OwnerID,
arg.IsPublic,
)
return err
}
const deleteUserTemplate = `-- name: DeleteUserTemplate :execrows
DELETE FROM service_templates
WHERE id = $1
AND owner_id = $2
AND is_official = false
`
type DeleteUserTemplateParams struct {
ID string `json:"id"`
OwnerID uuid.NullUUID `json:"owner_id"`
}
func (q *Queries) DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteUserTemplate, arg.ID, arg.OwnerID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getProjectOwnerID = `-- name: GetProjectOwnerID :one
SELECT owner_id
FROM projects
@@ -136,7 +201,7 @@ func (q *Queries) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUI
}
const getServiceTemplateByID = `-- name: GetServiceTemplateByID :one
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates
WHERE id = $1
`
@@ -153,20 +218,29 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic
&i.Config,
&i.Variables,
&i.IsOfficial,
&i.OwnerID,
&i.IsPublic,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listServiceTemplates = `-- name: ListServiceTemplates :many
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
const listServiceTemplatesByCategoryForUser = `-- name: ListServiceTemplatesByCategoryForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates
WHERE category = $1
AND (is_official = true OR is_public = true OR owner_id = $2)
ORDER BY is_official DESC, name ASC
`
func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error) {
rows, err := q.db.QueryContext(ctx, listServiceTemplates)
type ListServiceTemplatesByCategoryForUserParams struct {
Category string `json:"category"`
OwnerID uuid.NullUUID `json:"owner_id"`
}
func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) {
rows, err := q.db.QueryContext(ctx, listServiceTemplatesByCategoryForUser, arg.Category, arg.OwnerID)
if err != nil {
return nil, err
}
@@ -183,6 +257,8 @@ func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate,
&i.Config,
&i.Variables,
&i.IsOfficial,
&i.OwnerID,
&i.IsPublic,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -199,15 +275,15 @@ func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate,
return items, nil
}
const listServiceTemplatesByCategory = `-- name: ListServiceTemplatesByCategory :many
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
const listServiceTemplatesForUser = `-- name: ListServiceTemplatesForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates
WHERE category = $1
WHERE is_official = true OR is_public = true OR owner_id = $1
ORDER BY is_official DESC, name ASC
`
func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error) {
rows, err := q.db.QueryContext(ctx, listServiceTemplatesByCategory, category)
func (q *Queries) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) {
rows, err := q.db.QueryContext(ctx, listServiceTemplatesForUser, ownerID)
if err != nil {
return nil, err
}
@@ -224,6 +300,8 @@ func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category s
&i.Config,
&i.Variables,
&i.IsOfficial,
&i.OwnerID,
&i.IsPublic,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -240,6 +318,51 @@ func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category s
return items, nil
}
const updateUserTemplate = `-- name: UpdateUserTemplate :execrows
UPDATE service_templates
SET name = $1,
description = $2,
category = $3,
logo = $4,
config = $5,
variables = $6,
is_public = $7,
updated_at = NOW()
WHERE id = $8
AND owner_id = $9
AND is_official = false
`
type UpdateUserTemplateParams struct {
Name string `json:"name"`
Description sql.NullString `json:"description"`
Category string `json:"category"`
Logo sql.NullString `json:"logo"`
Config json.RawMessage `json:"config"`
Variables pqtype.NullRawMessage `json:"variables"`
IsPublic bool `json:"is_public"`
ID string `json:"id"`
OwnerID uuid.NullUUID `json:"owner_id"`
}
func (q *Queries) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error) {
result, err := q.db.ExecContext(ctx, updateUserTemplate,
arg.Name,
arg.Description,
arg.Category,
arg.Logo,
arg.Config,
arg.Variables,
arg.IsPublic,
arg.ID,
arg.OwnerID,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const upsertEnvironmentVariable = `-- name: UpsertEnvironmentVariable :exec
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
@@ -271,3 +394,51 @@ func (q *Queries) UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvir
)
return err
}
const upsertServiceTemplate = `-- name: UpsertServiceTemplate :exec
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8
)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name,
description = EXCLUDED.description,
category = EXCLUDED.category,
logo = EXCLUDED.logo,
config = EXCLUDED.config,
variables = EXCLUDED.variables,
is_official = EXCLUDED.is_official,
updated_at = NOW()
`
type UpsertServiceTemplateParams struct {
ID string `json:"id"`
Name string `json:"name"`
Description sql.NullString `json:"description"`
Category string `json:"category"`
Logo sql.NullString `json:"logo"`
Config json.RawMessage `json:"config"`
Variables pqtype.NullRawMessage `json:"variables"`
IsOfficial sql.NullBool `json:"is_official"`
}
func (q *Queries) UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error {
_, err := q.db.ExecContext(ctx, upsertServiceTemplate,
arg.ID,
arg.Name,
arg.Description,
arg.Category,
arg.Logo,
arg.Config,
arg.Variables,
arg.IsOfficial,
)
return err
}
@@ -59,6 +59,9 @@ type ServiceConfig struct {
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 {
@@ -315,6 +318,7 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
Env: cfg.Environment,
Replicas: cfg.Replicas,
Port: cfg.PublicPort,
PublishedPort: cfg.PublishedPort,
Domain: cfg.Domain,
HealthPath: cfg.HealthPath,
RestartPolicy: cfg.RestartPolicy,
+116 -19
View File
@@ -5,8 +5,11 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
@@ -38,6 +41,7 @@ type RuntimeSpec struct {
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
@@ -58,7 +62,9 @@ type RuntimeState struct {
Desired int `json:"desired"`
Containers []RuntimeContainer `json:"containers"`
URLs []string `json:"urls"`
Status string `json:"status"` // running, degraded, stopped
Ports []uint16 `json:"ports"`
Health string `json:"health,omitempty"` // healthy, unhealthy, unreachable
Status string `json:"status"` // running, degraded, stopped
}
func projectNetworkName(projectID string) string {
@@ -237,33 +243,72 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
Networks: endpoints,
}
var wantPort nat.Port
if spec.Port > 0 {
port := nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
cfg.ExposedPorts = nat.PortSet{port: struct{}{}}
// Always publish an ephemeral host port for direct access; a configured
// domain is additionally routed via Traefik when the edge network exists.
cfg.PortBindings = nat.PortMap{port: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: ""}}}
if spec.HealthPath != "" {
cfg.Healthcheck = &container.HealthConfig{
Test: []string{"CMD-SHELL", fmt.Sprintf("wget -q -O /dev/null http://127.0.0.1:%d%s || exit 1", spec.Port, spec.HealthPath)},
Interval: 30 * time.Second,
Timeout: 5 * time.Second,
Retries: 3,
StartPeriod: 10 * time.Second,
}
}
wantPort = nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
cfg.ExposedPorts = nat.PortSet{wantPort: struct{}{}}
}
id, err := de.dockerClient.CreateContainer(ctx, cfg)
// 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
}
if err := de.dockerClient.StartContainer(ctx, id); err != nil {
return "", fmt.Errorf("start: %w", 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)
@@ -282,6 +327,7 @@ func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string,
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)
}
}
}
@@ -299,6 +345,57 @@ func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string,
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)
+74 -1
View File
@@ -6,6 +6,9 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strings"
"time"
"github.com/docker/docker/api/types"
@@ -23,7 +26,8 @@ import (
// Client wraps the Docker client with additional functionality
type Client struct {
cli *client.Client
cli *client.Client
probeHost string
}
// NewClient creates a new Docker client
@@ -45,6 +49,75 @@ func NewClient() (*Client, error) {
return &Client{cli: cli}, nil
}
// HostProbeHost returns an address through which host-published container
// ports are reachable. When this process runs inside a container, host
// loopback is unreachable; the bridge gateway IP routes to ports published
// on 0.0.0.0. Result is cached for the process lifetime.
func (c *Client) HostProbeHost(ctx context.Context) string {
if c.probeHost != "" {
return c.probeHost
}
host := "127.0.0.1"
if self, err := c.self(ctx); err == nil && self.NetworkSettings != nil {
for _, n := range self.NetworkSettings.Networks {
if n != nil && n.Gateway != "" {
host = n.Gateway
break
}
}
}
c.probeHost = host
return host
}
// OwnNetworkName returns the name of the platform network this process's
// container is attached to, or "" when not running containerized. Per-project
// and default Docker networks are never selected; among remaining networks
// the lexicographically first wins so the choice is deterministic.
func (c *Client) OwnNetworkName(ctx context.Context) string {
self, err := c.self(ctx)
if err != nil || self.NetworkSettings == nil {
return ""
}
names := make([]string, 0, len(self.NetworkSettings.Networks))
for name := range self.NetworkSettings.Networks {
switch {
case name == "bridge" || name == "host" || name == "none":
case strings.HasPrefix(name, "containr-proj-"):
default:
names = append(names, name)
}
}
sort.Strings(names)
if len(names) == 0 {
return ""
}
return names[0]
}
// self inspects the container this process runs in (hostname == container ID).
func (c *Client) self(ctx context.Context) (types.ContainerJSON, error) {
hostname, err := os.Hostname()
if err != nil || hostname == "" {
return types.ContainerJSON{}, fmt.Errorf("hostname unavailable")
}
return c.cli.ContainerInspect(ctx, hostname)
}
// ConnectSelfToNetwork attaches this process's container to the given network.
// No-op when already attached or when not running containerized.
func (c *Client) ConnectSelfToNetwork(ctx context.Context, networkName string) error {
hostname, err := os.Hostname()
if err != nil || hostname == "" {
return fmt.Errorf("hostname unavailable")
}
err = c.cli.NetworkConnect(ctx, networkName, hostname, &network.EndpointSettings{})
if err != nil && strings.Contains(strings.ToLower(err.Error()), "already exists") {
return nil
}
return err
}
// ListContainers returns all containers
func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Container, error) {
return c.cli.ContainerList(ctx, container.ListOptions{
+93 -30
View File
@@ -69,41 +69,72 @@ 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 != "" {
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
}
// setAuthenticatedContext stores the resolved identity plus the admin flag on
// the gin context so handlers and RequireAdmin can rely on it.
func setAuthenticatedContext(c *gin.Context, userID, email string) {
c.Set("user_id", userID)
c.Set("email", email)
c.Set("is_admin", loadIsAdmin(c, userID))
}
func loadIsAdmin(c *gin.Context, userID string) bool {
dbValue, exists := c.Get("db")
if !exists {
return false
}
db, ok := dbValue.(*database.DB)
if !ok || db == nil || db.DB == nil {
return false
}
var isAdmin bool
if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil {
return false
}
return isAdmin
}
// Auth middleware for JWT authentication
func Auth(jwtSecret string) gin.HandlerFunc {
sessionVerifier := newBetterAuthSessionVerifier()
return func(c *gin.Context) {
tokenString, tokenErr, hasToken := extractJWTToken(c)
if tokenString != "" {
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
userIDClaim, exists := claims["user_id"]
if exists {
userID := strings.TrimSpace(fmt.Sprint(userIDClaim))
if _, err := uuid.Parse(userID); err == nil {
email := ""
if emailClaim, ok := claims["email"]; ok && emailClaim != nil {
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
}
userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier)
if ok {
setAuthenticatedContext(c, userID, email)
c.Next()
return
}
if tokenErr != "" {
@@ -121,6 +152,38 @@ func Auth(jwtSecret string) gin.HandlerFunc {
}
}
// OptionalAuth resolves identity when a valid credential is present but never
// rejects the request — anonymous callers simply get no user context. Read
// endpoints hang off this so the public site can browse approved resources.
func OptionalAuth(jwtSecret string) gin.HandlerFunc {
sessionVerifier := newBetterAuthSessionVerifier()
return func(c *gin.Context) {
if userID, email, _, _, ok := resolveRequestUser(c, jwtSecret, sessionVerifier); ok {
setAuthenticatedContext(c, userID, email)
}
c.Next()
}
}
// RequireAdmin rejects requests whose resolved user is not a platform admin.
// Must run after Auth (or OptionalAuth) so user_id/is_admin are populated.
func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
if _, exists := c.Get("user_id"); !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
c.Abort()
return
}
if !c.GetBool("is_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
c.Abort()
return
}
c.Next()
}
}
type betterAuthSessionVerifier struct {
internalURL string
internalToken string
@@ -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;
+17 -2
View File
@@ -1,11 +1,26 @@
-- name: ListDatabaseServicesByUser :many
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
WHERE user_id = $1
ORDER BY created_at DESC;
-- name: ListAllDatabaseServices :many
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
ORDER BY created_at DESC;
-- name: GetDatabaseServiceByID :one
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
WHERE id = $1;
-- name: GetDatabaseServiceOwnerID :one
SELECT user_id
FROM database_services
WHERE id = $1;
-- name: GetDatabaseServiceByIDAndUser :one
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
WHERE id = $1 AND user_id = $2;
+43 -15
View File
@@ -1,9 +1,14 @@
-- Visibility model: a project is readable when it is approved for public
-- display, when the caller owns or is a member of it, or when the caller is a
-- platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
-- name: ListProjectsWithStatsByUser :many
SELECT
p.id,
p.name,
p.description,
p.owner_id,
p.is_approved,
p.created_at,
p.updated_at,
COUNT(DISTINCT s.id)::bigint AS service_count,
@@ -21,17 +26,22 @@ FROM projects p
LEFT JOIN services s ON s.project_id = p.id
LEFT JOIN deployments d ON d.service_id = s.id
WHERE
(p.owner_id = sqlc.arg(user_id) OR EXISTS (
SELECT 1
FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
))
(
p.is_approved
OR p.owner_id = sqlc.arg(user_id)
OR sqlc.arg(is_admin)::bool
OR EXISTS (
SELECT 1
FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
)
)
AND (
sqlc.narg(search)::text IS NULL
OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%')
OR COALESCE(p.description, '') ILIKE ('%' || sqlc.narg(search)::text || '%')
)
GROUP BY p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
GROUP BY p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
ORDER BY p.updated_at DESC
LIMIT sqlc.arg(limit_count) OFFSET sqlc.arg(offset_count);
@@ -39,11 +49,16 @@ LIMIT sqlc.arg(limit_count) OFFSET sqlc.arg(offset_count);
SELECT COUNT(*)::bigint AS total
FROM projects p
WHERE
(p.owner_id = sqlc.arg(user_id) OR EXISTS (
SELECT 1
FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
))
(
p.is_approved
OR p.owner_id = sqlc.arg(user_id)
OR sqlc.arg(is_admin)::bool
OR EXISTS (
SELECT 1
FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
)
)
AND (
sqlc.narg(search)::text IS NULL
OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%')
@@ -51,11 +66,13 @@ WHERE
);
-- name: GetProjectByIDForUser :one
SELECT p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
FROM projects p
WHERE p.id = sqlc.arg(project_id)
AND (
p.owner_id = sqlc.arg(user_id)
p.is_approved
OR p.owner_id = sqlc.arg(user_id)
OR sqlc.arg(is_admin)::bool
OR EXISTS (
SELECT 1
FROM project_members pm
@@ -66,7 +83,7 @@ WHERE p.id = sqlc.arg(project_id)
-- name: CreateProject :one
INSERT INTO projects (name, description, owner_id)
VALUES (sqlc.arg(name), sqlc.narg(description), sqlc.arg(owner_id))
RETURNING id, name, description, owner_id, created_at, updated_at;
RETURNING id, name, description, owner_id, is_approved, created_at, updated_at;
-- name: InsertProjectEnvironment :exec
INSERT INTO environments (name, project_id)
@@ -74,7 +91,7 @@ VALUES (sqlc.arg(name), sqlc.arg(project_id));
-- name: GetProjectRoleForUser :one
SELECT (CASE
WHEN p.owner_id = sqlc.arg(user_id) THEN 'owner'
WHEN p.owner_id = sqlc.arg(user_id) OR sqlc.arg(is_admin)::bool THEN 'owner'
ELSE COALESCE(pm.role, '')
END)::text AS role
FROM projects p
@@ -97,3 +114,14 @@ WHERE id = sqlc.arg(project_id);
-- name: DeleteProjectByID :execrows
DELETE FROM projects
WHERE id = sqlc.arg(project_id);
-- name: SetProjectApproved :execrows
UPDATE projects
SET is_approved = sqlc.arg(is_approved), updated_at = NOW()
WHERE id = sqlc.arg(project_id);
-- name: ListPendingProjects :many
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
FROM projects p
WHERE p.is_approved = false
ORDER BY p.created_at DESC;
+65 -6
View File
@@ -1,19 +1,78 @@
-- name: ListServiceTemplates :many
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
-- name: ListServiceTemplatesForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates
WHERE is_official = true OR is_public = true OR owner_id = sqlc.narg(owner_id)
ORDER BY is_official DESC, name ASC;
-- name: ListServiceTemplatesByCategory :many
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
-- name: ListServiceTemplatesByCategoryForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates
WHERE category = $1
WHERE category = sqlc.arg(category)
AND (is_official = true OR is_public = true OR owner_id = sqlc.narg(owner_id))
ORDER BY is_official DESC, name ASC;
-- name: GetServiceTemplateByID :one
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates
WHERE id = $1;
-- name: UpsertServiceTemplate :exec
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official)
VALUES (
sqlc.arg(id),
sqlc.arg(name),
sqlc.narg(description),
sqlc.arg(category),
sqlc.narg(logo),
sqlc.arg(config),
sqlc.narg(variables),
sqlc.arg(is_official)
)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name,
description = EXCLUDED.description,
category = EXCLUDED.category,
logo = EXCLUDED.logo,
config = EXCLUDED.config,
variables = EXCLUDED.variables,
is_official = EXCLUDED.is_official,
updated_at = NOW();
-- name: CreateUserTemplate :exec
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id, is_public)
VALUES (
sqlc.arg(id),
sqlc.arg(name),
sqlc.narg(description),
sqlc.arg(category),
sqlc.narg(logo),
sqlc.arg(config),
sqlc.narg(variables),
false,
sqlc.arg(owner_id),
sqlc.arg(is_public)
);
-- name: UpdateUserTemplate :execrows
UPDATE service_templates
SET name = sqlc.arg(name),
description = sqlc.narg(description),
category = sqlc.arg(category),
logo = sqlc.narg(logo),
config = sqlc.arg(config),
variables = sqlc.narg(variables),
is_public = sqlc.arg(is_public),
updated_at = NOW()
WHERE id = sqlc.arg(id)
AND owner_id = sqlc.arg(owner_id)
AND is_official = false;
-- name: DeleteUserTemplate :execrows
DELETE FROM service_templates
WHERE id = sqlc.arg(id)
AND owner_id = sqlc.arg(owner_id)
AND is_official = false;
-- name: GetProjectOwnerID :one
SELECT owner_id
FROM projects
+3
View File
@@ -3,6 +3,7 @@ CREATE TABLE projects (
name VARCHAR(255) NOT NULL,
description TEXT,
owner_id UUID NOT NULL,
is_approved BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
@@ -82,6 +83,8 @@ CREATE TABLE service_templates (
config JSONB NOT NULL,
variables JSONB DEFAULT '[]',
is_official BOOLEAN DEFAULT false,
owner_id UUID,
is_public BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
+2
View File
@@ -0,0 +1,2 @@
.vercel
.env*
+39
View File
@@ -32,6 +32,7 @@
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.66.0",
"@types/dompurify": "^3.0.5",
"@xyflow/react": "^12.10.0",
"autoprefixer": "^10.4.24",
"axios": "^1.7.9",
@@ -40,7 +41,9 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.2.6",
"lucide-react": "^0.563.0",
"marked": "^18.0.13",
"postcss": "^8.5.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -3719,6 +3722,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3763,6 +3775,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -5205,6 +5223,15 @@
"license": "MIT",
"peer": true
},
"node_modules/dompurify": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -6577,6 +6604,18 @@
"node": ">=10"
}
},
"node_modules/marked": {
"version": "18.0.13",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.13.tgz",
"integrity": "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+6
View File
@@ -7,6 +7,9 @@
"dev": "vite",
"build": "vite build",
"build:check": "tsc -b && vite build",
"predev": "node scripts/generate-docs-snapshot.mjs",
"prebuild": "node scripts/generate-docs-snapshot.mjs",
"generate:docs": "node scripts/generate-docs-snapshot.mjs",
"generate:api": "openapi-typescript ../../docs/api/openapi.yaml -o src/generated/api-types.ts",
"lint": "eslint .",
"preview": "vite preview",
@@ -40,6 +43,7 @@
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.66.0",
"@types/dompurify": "^3.0.5",
"@xyflow/react": "^12.10.0",
"autoprefixer": "^10.4.24",
"axios": "^1.7.9",
@@ -48,7 +52,9 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.2.6",
"lucide-react": "^0.563.0",
"marked": "^18.0.13",
"postcss": "^8.5.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -0,0 +1,68 @@
// Bundles the repository docs/ tree into a generated TS module so the docs
// browser renders instantly and offline. The GitHub sync layer refreshes it
// at runtime when network is available.
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const frontendRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..');
const root = join(frontendRoot, '..', '..');
const docsDir = join(root, 'docs');
const outFile = join(frontendRoot, 'src/generated/docs-snapshot.ts');
// The published frontend image builds with only app/frontend as its Docker
// context, so the repo docs tree is absent there — reuse the committed
// snapshot instead of failing on ENOENT.
if (!existsSync(docsDir)) {
if (!existsSync(outFile)) {
console.error('docs snapshot: docs/ missing and no committed snapshot to reuse');
process.exit(1);
}
console.warn('docs snapshot: docs/ not found, reusing committed snapshot');
process.exit(0);
}
// Directories that are internal working notes rather than user documentation.
const SKIP_DIRS = new Set(['archive', 'design', 'superpowers']);
function walk(dir) {
const entries = [];
for (const name of readdirSync(dir)) {
const full = join(dir, name);
const stat = statSync(full);
if (stat.isDirectory()) {
if (!SKIP_DIRS.has(name)) entries.push(...walk(full));
} else if (name.toLowerCase().endsWith('.md')) {
entries.push(full);
}
}
return entries;
}
function titleOf(markdown, fallback) {
const match = markdown.match(/^#\s+(.+)$/m);
if (match) return match[1].replace(/[#*`]/g, '').trim();
return fallback
.replace(/\.md$/i, '')
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
const files = walk(docsDir).sort();
const docs = files.map((full) => {
const rel = relative(docsDir, full).replace(/\\/g, '/');
const markdown = readFileSync(full, 'utf8');
const section = rel.includes('/') ? rel.split('/')[0] : 'docs';
return {
path: rel,
section,
title: titleOf(markdown, rel.split('/').pop() ?? rel),
markdown,
};
});
const banner = `/**\n * Auto-generated by scripts/generate-docs-snapshot.mjs.\n * Bundled copy of the repository docs tree — the offline fallback for the\n * docs browser. Regenerate with \`npm run generate:docs\`.\n */\n\nexport type BundledDoc = {\n path: string;\n section: string;\n title: string;\n markdown: string;\n};\n\n`;
mkdirSync(join(outFile, '..'), { recursive: true });
writeFileSync(outFile, `${banner}export const bundledDocs: BundledDoc[] = ${JSON.stringify(docs, null, 2)};\n`);
console.log(`docs snapshot: ${docs.length} files → ${relative(root, outFile)}`);
+1 -1
View File
@@ -32,7 +32,7 @@ describe('App smoke routes', () => {
expect(await screen.findByRole('heading', { name: /^projects\s*_?$/i })).toBeInTheDocument();
expect(screen.getByText(/recent deployments/i)).toBeInTheDocument();
expect(screen.getAllByText(/core-services/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/core platform/i).length).toBeGreaterThan(0);
});
it('renders builds page in demo mode', async () => {
+106 -19
View File
@@ -1,4 +1,6 @@
import { Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Compass, ShieldCheck } from 'lucide-react';
import { PlatformShell } from './layout/PlatformShell';
import { ProjectsPage } from '@/features/projects/pages/ProjectsPage';
import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage';
@@ -7,6 +9,8 @@ import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetrics
import { BuildsPage } from '@/features/builds/pages/BuildsPage';
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
import { AdminPage } from '@/features/admin/pages/AdminPage';
import { LandingPage } from '@/features/landing/LandingPage';
import {
DocsPage,
PeoplePage,
@@ -19,12 +23,13 @@ import {
} from '@/features/secondary/pages';
import { SignInPage, SignUpPage } from '@/features/auth/pages';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { getCurrentUserProfile } from '@/lib/api-client';
import { ErrorBoundary, LoadingState } from '@/shared/components';
function AuthRequired() {
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const isDemoMode = searchParams.get('demo') === '1';
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
if (isDemoMode) {
@@ -47,38 +52,120 @@ function AuthRequired() {
return <Outlet />;
}
function AdminRequired() {
const location = useLocation();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const profileQuery = useQuery({
queryKey: ['current-profile'],
queryFn: getCurrentUserProfile,
enabled: !isDemoMode && Boolean(sessionQuery.data),
retry: false,
});
if (isDemoMode) {
return <Outlet />;
}
if (sessionQuery.isPending || (sessionQuery.data && profileQuery.isPending)) {
return (
<div className="min-h-screen bg-[var(--bg-void)]">
<LoadingState message="Checking admin access..." className="h-screen" />
</div>
);
}
if (!sessionQuery.data) {
const redirect = `${location.pathname}${location.search}`;
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirect)}`} replace />;
}
if (!profileQuery.data?.isAdmin) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--bg-void)] px-6 text-center">
<ShieldCheck size={32} className="text-[var(--error)]" />
<div>
<h1 className="text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
<p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">
This area is restricted to the platform administrator.
</p>
</div>
<Link
to="/"
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Back to home
</Link>
</div>
);
}
return <Outlet />;
}
function NotFoundPage() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-6 text-center">
<Compass size={32} className="text-[var(--text-tertiary)]" />
<div>
<h1 className="text-lg font-semibold text-[var(--text-primary)]">Page not found</h1>
<p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">
The page you are looking for does not exist or was moved.
</p>
</div>
<Link
to="/"
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Back to home
</Link>
</div>
);
}
export default function App() {
return (
<ErrorBoundary>
<Routes>
<Route path="/" element={<Navigate to="/projects" replace />} />
<Route path="/" element={<LandingPage />} />
<Route path="/auth/sign-in" element={<SignInPage />} />
<Route path="/auth/sign-up" element={<SignUpPage />} />
<Route element={<AuthRequired />}>
<Route element={<PlatformShell />}>
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
<Route path="/builds" element={<BuildsPage />} />
{/* Public browsing — approved projects, templates, docs. */}
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/usage" element={<UsagePage />} />
<Route path="/databases" element={<DatabasesPage />} />
<Route path="/ha" element={<HighAvailabilityPage />} />
<Route path="/security" element={<SecurityPage />} />
<Route element={<PlatformShell />}>
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/databases" element={<DatabasesPage />} />
<Route path="/ha" element={<HighAvailabilityPage />} />
<Route path="/security" element={<SecurityPage />} />
<Route path="/usage" element={<UsagePage />} />
<Route path="/docs" element={<DocsPage />} />
{/* Authenticated areas */}
<Route element={<AuthRequired />}>
<Route path="/builds" element={<BuildsPage />} />
<Route path="/people" element={<PeoplePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/audit-logs" element={<AuditLogsPage />} />
<Route path="/docs" element={<DocsPage />} />
<Route path="/showcase" element={<ComponentShowcase />} />
</Route>
{/* Full-bleed design demo — renders its own chrome, no app shell */}
<Route path="/metrics-demo" element={<ServiceMetricsDashboard />} />
{/* Platform owner only */}
<Route element={<AdminRequired />}>
<Route path="/admin" element={<AdminPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</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>
</ErrorBoundary>
);
+348 -148
View File
@@ -1,27 +1,35 @@
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import {
Bell,
BookOpen,
ChartBar,
CheckCircle2,
ChevronsLeft,
Container,
Database,
FileText,
FolderKanban,
LayoutTemplate,
LogIn,
LogOut,
Moon,
PanelLeft,
RefreshCw,
ScrollText,
Search,
Settings,
Shield,
ShieldCheck,
Sun,
UploadCloud,
User,
Users,
} from 'lucide-react';
import {
getCurrentUserProfile,
getUpgradeStatus,
listNotifications,
markAllNotificationsRead,
@@ -29,28 +37,66 @@ import {
pullUpgradeImage,
} from '@/lib/api-client';
import { signOutAuthSession } from '@/lib/auth-client';
import { isDemoSearch } from '@/lib/demo-mode';
import { useAuthSession } from '@/lib/use-auth-session';
import { useToast } from '@/shared/components';
const navItems = [
{ label: 'Projects', href: '/projects', icon: FolderKanban },
{ label: 'Templates', href: '/templates', icon: LayoutTemplate },
{ label: 'Databases', href: '/databases', icon: Database },
{ label: 'HA', href: '/ha', icon: ShieldCheck },
{ label: 'Security', href: '/security', icon: Shield },
{ label: 'Usage', href: '/usage', icon: ChartBar },
{ label: 'People', href: '/people', icon: Users },
{ label: 'Settings', href: '/settings', icon: Settings },
{ label: 'Docs', href: '/docs', icon: BookOpen },
type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string };
type NavSection = { title: string; items: NavItem[] };
const navSections: NavSection[] = [
{
title: 'Build',
items: [
{ label: 'Projects', href: '/projects', icon: FolderKanban, hint: 'Services & deployments' },
{ label: 'Templates', href: '/templates', icon: LayoutTemplate, hint: 'Deployable presets' },
{ label: 'Builds', href: '/builds', icon: FileText, hint: 'Build history' },
],
},
{
title: 'Operate',
items: [
{ label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' },
{ label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' },
{ label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' },
{ label: 'Usage', href: '/usage', icon: ChartBar, hint: 'Resource consumption' },
],
},
{
title: 'Workspace',
items: [
{ label: 'People', href: '/people', icon: Users, hint: 'Members & access' },
{ label: 'Audit Logs', href: '/settings/audit-logs', icon: ScrollText, hint: 'Activity trail' },
{ label: 'Settings', href: '/settings', icon: Settings, hint: 'Platform preferences' },
{ label: 'Docs', href: '/docs', icon: BookOpen, hint: 'Guides & reference' },
],
},
];
type ThemeMode = 'dark' | 'light';
function getInitialTheme(): ThemeMode {
if (typeof window === 'undefined') {
return 'dark';
function readStorage(key: string): string | null {
try {
return typeof window === 'undefined' ? null : window.localStorage.getItem(key);
} catch {
return null;
}
return localStorage.getItem('containr.theme') === 'light' ? 'light' : 'dark';
}
function writeStorage(key: string, value: string) {
try {
window.localStorage.setItem(key, value);
} catch {
// Storage unavailable (SSR, tests, private mode) — non-persistent is fine.
}
}
function getInitialTheme(): ThemeMode {
return readStorage('containr.theme') === 'light' ? 'light' : 'dark';
}
function getInitialSidebar(): boolean {
return readStorage('containr.sidebar.expanded') !== '0';
}
export function PlatformShell() {
@@ -58,12 +104,32 @@ export function PlatformShell() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const isDemoMode = new URLSearchParams(location.search).get('demo') === '1';
const isDemoMode = isDemoSearch(location.search);
const href = (target: string) => (isDemoMode ? `${target}?demo=1` : target);
// Auth/admin-scoped pages have no demo fixtures — keep the demo nav honest.
const visibleNavSections = isDemoMode
? navSections
.map((section) =>
section.title === 'Workspace'
? { ...section, items: section.items.filter((item) => item.href === '/docs') }
: section,
)
.filter((section) => section.items.length > 0)
: navSections;
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
const [sidebarExpanded, setSidebarExpanded] = useState<boolean>(() => getInitialSidebar());
const [notificationsOpen, setNotificationsOpen] = useState(false);
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const profileQuery = useQuery({
queryKey: ['current-profile'],
queryFn: getCurrentUserProfile,
enabled: !isDemoMode && Boolean(sessionQuery.data),
staleTime: 60_000,
retry: false,
});
const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
const upgradeQuery = useQuery({
queryKey: ['upgrade-status'],
queryFn: getUpgradeStatus,
@@ -72,7 +138,7 @@ export function PlatformShell() {
const notificationsQuery = useQuery({
queryKey: ['shell-notifications'],
queryFn: () => listNotifications(20),
enabled: !isDemoMode,
enabled: !isDemoMode && signedIn,
refetchInterval: 30_000,
});
const markReadMutation = useMutation({
@@ -86,9 +152,13 @@ export function PlatformShell() {
useEffect(() => {
document.documentElement.dataset.theme = theme;
localStorage.setItem('containr.theme', theme);
writeStorage('containr.theme', theme);
}, [theme]);
useEffect(() => {
writeStorage('containr.sidebar.expanded', sidebarExpanded ? '1' : '0');
}, [sidebarExpanded]);
const pullMutation = useMutation({
mutationFn: pullUpgradeImage,
onSuccess: (status) => {
@@ -115,8 +185,8 @@ export function PlatformShell() {
const isActiveRoute = (itemHref: string) =>
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
const userName = sessionQuery.data?.user.name ?? 'Account';
const userEmail = sessionQuery.data?.user.email ?? 'Local session';
const userName = isDemoMode ? 'Demo workspace' : profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account';
const userEmail = isDemoMode ? 'Sample data · read-only' : profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session';
const initials = userName
.split(' ')
.map((part) => part[0])
@@ -124,68 +194,191 @@ export function PlatformShell() {
.slice(0, 2)
.toUpperCase() || 'C';
const avatar = (
<div
className="flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-full text-[11px] font-bold"
style={{ background: 'linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))', color: 'var(--accent-on)' }}
>
{initials}
</div>
);
return (
<div className="app-shell min-h-screen">
<div className="ambient-glow" />
<div className="relative flex min-h-screen">
<aside
className="hidden h-screen shrink-0 flex-col items-center border-r py-4 md:flex"
className="hidden h-screen shrink-0 flex-col border-r py-4 transition-[width] duration-200 md:flex"
style={{
width: '62px',
width: sidebarExpanded ? '218px' : '62px',
background: 'var(--bg-base)',
borderRightColor: 'var(--border-subtle)',
}}
>
<NavLink to={href('/projects')} className="mb-3" title="Containr">
<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">
<div className={`flex items-center ${sidebarExpanded ? 'justify-between px-3' : 'flex-col items-center gap-2'}`}>
<NavLink to={href('/projects')} title="Containr">
<img src="/containr.svg" alt="Containr" className="h-[38px] w-[38px] rounded-xl" />
</NavLink>
<button
type="button"
onClick={() => void signOut()}
title={`Sign out (${userName})`}
className="flex h-10 w-10 items-center justify-center rounded-[11px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--error-soft)] hover:text-[var(--error)]"
onClick={() => setSidebarExpanded((v) => !v)}
title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'}
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
>
<LogOut size={16} />
{sidebarExpanded ? <ChevronsLeft size={15} /> : <PanelLeft size={15} />}
</button>
<div
title={userEmail}
className="flex h-[30px] w-[30px] items-center justify-center rounded-full text-[11px] font-bold"
style={{ background: 'linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))', color: 'var(--accent-on)' }}
>
{initials}
</div>
</div>
<nav className="mt-3 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
{visibleNavSections.map((section) => (
<div key={section.title}>
{sidebarExpanded ? (
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]">
{section.title}
</p>
) : null}
<div className={`flex flex-col gap-0.5 ${sidebarExpanded ? '' : 'items-center'}`}>
{section.items.map((item) => {
const Icon = item.icon;
const isActive = isActiveRoute(item.href);
return (
<NavLink
key={item.href}
to={href(item.href)}
title={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>
</aside>
@@ -214,7 +407,7 @@ export function PlatformShell() {
</div>
<div className="ml-auto flex items-center gap-4">
<span className="v-env">env:production</span>
<span className="v-env">{isDemoMode ? 'env:demo' : 'env:production'}</span>
<button
type="button"
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
@@ -225,93 +418,97 @@ export function PlatformShell() {
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
</button>
<button
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">
{isAdmin && !isDemoMode ? (
<button
type="button"
onClick={() => setNotificationsOpen((open) => !open)}
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)]"
style={{ borderColor: 'var(--border-subtle)' }}
aria-label="Notifications"
onClick={() => pullMutation.mutate()}
disabled={pullMutation.isPending || !upgradeQuery.data?.imageRef}
className="inline-flex h-8 items-center gap-2 rounded-[var(--radius-md)] px-3 text-xs font-semibold text-[var(--accent-on)] disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
title={upgradeQuery.data?.message || 'Pull latest configured image'}
>
<Bell size={15} />
{unreadCount > 0 ? (
<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}
{pullMutation.isPending ? <RefreshCw size={14} className="animate-spin" /> : <UploadCloud size={14} />}
Upgrade
</button>
) : null}
{notificationsOpen ? (
<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="mb-2 flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-[var(--text-primary)]">Notifications</p>
<p className="text-xs text-[var(--text-tertiary)]">
{unreadCount > 0 ? `${unreadCount} unread` : 'All caught up'}
</p>
</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-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
{signedIn && !isDemoMode ? (
<div className="relative">
<button
type="button"
onClick={() => setNotificationsOpen((open) => !open)}
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)]"
style={{ borderColor: 'var(--border-subtle)' }}
aria-label="Notifications"
>
<Bell size={15} />
{unreadCount > 0 ? (
<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>
{notificationsOpen ? (
<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="mb-2 flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-[var(--text-primary)]">Notifications</p>
<p className="text-xs text-[var(--text-tertiary)]">
{unreadCount > 0 ? `${unreadCount} unread` : 'All caught up'}
</p>
</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>
) : null}
</div>
) : null}
</div>
) : null}
</div>
</header>
@@ -332,7 +529,7 @@ export function PlatformShell() {
</button>
</div>
<nav className="flex gap-1 overflow-x-auto px-3 pb-3">
{navItems.map((item) => {
{visibleNavSections.flatMap((section) => section.items).map((item) => {
const Icon = item.icon;
const isActive = isActiveRoute(item.href);
return (
@@ -361,3 +558,6 @@ export function PlatformShell() {
</div>
);
}
const menuItemClass =
'flex w-full cursor-pointer select-none items-center gap-2 rounded-[var(--radius-md)] px-2.5 py-2 text-[13px] text-[var(--text-secondary)] outline-none transition-colors hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] data-[highlighted]:bg-[var(--surface-muted)] data-[highlighted]:text-[var(--text-primary)]';
@@ -0,0 +1,375 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
CheckCircle2,
Clock,
FolderKanban,
Pencil,
ShieldCheck,
Trash2,
Users,
XCircle,
} from 'lucide-react';
import {
deleteProject,
getAdminOverview,
listAdminUsers,
listProjects,
setProjectApproval,
setUserAdmin,
updateProject,
type AdminUser,
type ProjectEntity,
} from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { DemoRestricted, LoadingState, useToast } from '@/shared/components';
const statLabels: Record<string, string> = {
users: 'Users',
projects: 'Projects',
pending_projects: 'Pending approval',
services: 'Services',
running_services: 'Running',
databases: 'Databases',
agents: 'Agents',
templates: 'Templates',
user_templates: 'User templates',
cron_jobs: 'Cron jobs',
deployments_total: 'Deployments',
};
export function AdminPage() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const myId = sessionQuery.data?.user.id;
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview, enabled: !isDemoMode });
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode });
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode });
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
const [editForm, setEditForm] = useState({ name: '', description: '' });
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['admin-overview'] });
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
queryClient.invalidateQueries({ queryKey: ['admin-projects'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
};
const approvalMutation = useMutation({
mutationFn: ({ id, approved }: { id: string; approved: boolean }) => setProjectApproval(id, approved),
onSuccess: (_, vars) => {
showToast('success', vars.approved ? 'Project approved' : 'Project unapproved');
invalidate();
},
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
});
const adminMutation = useMutation({
mutationFn: ({ id, isAdmin }: { id: string; isAdmin: boolean }) => setUserAdmin(id, isAdmin),
onSuccess: (_, vars) => {
showToast('success', vars.isAdmin ? 'Admin granted' : 'Admin revoked');
invalidate();
},
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
});
const editMutation = useMutation({
mutationFn: () =>
updateProject(editingProject!.id, {
name: editForm.name.trim(),
description: editForm.description.trim(),
}),
onSuccess: () => {
showToast('success', 'Project updated');
setEditingProject(null);
invalidate();
},
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteProject(id),
onSuccess: () => {
showToast('success', 'Project deleted');
invalidate();
},
onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined),
});
if (isDemoMode) {
return <DemoRestricted feature="The admin console" />;
}
if (overviewQuery.isPending) {
return <LoadingState message="Loading admin console…" className="py-24" />;
}
if (overviewQuery.isError) {
return (
<div className="mx-auto max-w-xl px-5 py-24 text-center">
<ShieldCheck size={28} className="mx-auto text-[var(--error)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
This area is restricted to platform administrators.
</p>
</div>
);
}
const overview = overviewQuery.data;
const stats = overview?.stats ?? {};
const pending = overview?.pending_projects ?? [];
const users = usersQuery.data ?? [];
const allProjects = projectsQuery.data ?? [];
return (
<div className="mx-auto max-w-6xl px-5 py-8">
<div className="mb-8">
<h1 className="text-[24px] font-bold tracking-tight text-[var(--text-primary)]">Admin console</h1>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
Platform overview, project approvals, and user management.
</p>
</div>
<div className="grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2">
{Object.entries(statLabels).map(([key, label]) => (
<div
key={key}
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-3.5"
>
<p className="text-[20px] font-bold tabular-nums text-[var(--text-primary)]">{stats[key] ?? 0}</p>
<p className="mt-0.5 text-[10.5px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
</div>
))}
</div>
<section className="mt-8">
<div className="mb-3 flex items-center gap-2">
<Clock size={15} className="text-[var(--warning)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Pending approvals</h2>
{pending.length > 0 ? (
<span className="rounded-full bg-[var(--warning-soft)] px-2 py-0.5 text-[10.5px] font-semibold text-[var(--warning)]">
{pending.length}
</span>
) : null}
</div>
{pending.length > 0 ? (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
{pending.map((project) => (
<div
key={project.id}
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
>
<FolderKanban size={15} className="shrink-0 text-[var(--text-tertiary)]" />
<div className="min-w-0 flex-1">
<Link to={`/projects/${project.id}`} className="text-[13.5px] font-medium text-[var(--text-primary)] hover:underline">
{project.name}
</Link>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{project.description || 'No description'}</p>
</div>
<button
type="button"
onClick={() => approvalMutation.mutate({ id: project.id, approved: true })}
disabled={approvalMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3 py-1.5 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
<CheckCircle2 size={13} /> Approve
</button>
</div>
))}
</div>
) : (
<div className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">
Nothing waiting for approval.
</div>
)}
</section>
<section className="mt-8">
<div className="mb-3 flex items-center gap-2">
<FolderKanban size={15} className="text-[var(--accent-primary)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">All projects</h2>
</div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
{allProjects.map((project) => (
<div
key={project.id}
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
>
<div className="min-w-0 flex-1">
<Link to={`/projects/${project.id}`} className="text-[13.5px] font-medium text-[var(--text-primary)] hover:underline">
{project.name}
</Link>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">
{project.stats?.service_count ?? 0} services · {project.stats?.deployment_count ?? 0} deploys
</p>
</div>
<span
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
project.isApproved
? 'bg-[var(--success-soft)] text-[var(--success)]'
: 'bg-[var(--warning-soft)] text-[var(--warning)]'
}`}
>
{project.isApproved ? 'public' : 'pending'}
</span>
<button
type="button"
onClick={() => {
setEditingProject(project);
setEditForm({ name: project.name, description: project.description ?? '' });
}}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
title="Edit project"
>
<Pencil size={12} />
</button>
<button
type="button"
onClick={() => {
if (window.confirm(`Delete project "${project.name}"? This removes its services and deployments.`)) {
deleteMutation.mutate(project.id);
}
}}
disabled={deleteMutation.isPending}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
title="Delete project"
>
<Trash2 size={12} />
</button>
<button
type="button"
onClick={() => approvalMutation.mutate({ id: project.id, approved: !project.isApproved })}
disabled={approvalMutation.isPending}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
>
{project.isApproved ? 'Unapprove' : 'Approve'}
</button>
</div>
))}
{allProjects.length === 0 ? (
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No projects yet.</p>
) : null}
</div>
</section>
<section className="mt-8 pb-10">
<div className="mb-3 flex items-center gap-2">
<Users size={15} className="text-[var(--accent-tertiary)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Users</h2>
</div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
{users.map((user) => (
<UserRow
key={user.id}
user={user}
isSelf={user.id === myId}
pending={adminMutation.isPending}
onToggle={(isAdmin) => adminMutation.mutate({ id: user.id, isAdmin })}
/>
))}
{users.length === 0 ? (
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No users.</p>
) : null}
</div>
</section>
{editingProject ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
<div className="border-b border-[var(--border-subtle)] px-5 py-4">
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Edit project</h2>
<p className="text-xs text-[var(--text-tertiary)]">Admin edit — changes apply immediately.</p>
</div>
<div className="space-y-4 px-5 py-4">
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Name</label>
<input
value={editForm.name}
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Description</label>
<textarea
value={editForm.description}
onChange={(e) => setEditForm((f) => ({ ...f, description: e.target.value }))}
rows={3}
className="w-full resize-none rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-sm focus:border-[var(--accent-primary)]"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
<button
type="button"
onClick={() => setEditingProject(null)}
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
Cancel
</button>
<button
type="button"
onClick={() => editMutation.mutate()}
disabled={editMutation.isPending || editForm.name.trim().length < 2}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{editMutation.isPending ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
function UserRow({
user,
isSelf,
pending,
onToggle,
}: {
user: AdminUser;
isSelf: boolean;
pending: boolean;
onToggle: (isAdmin: boolean) => void;
}) {
return (
<div className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--surface-muted)] text-[11px] font-bold text-[var(--text-secondary)]">
{user.name.split(' ').map((p) => p[0]).join('').slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-medium text-[var(--text-primary)]">
{user.name}
{isSelf ? <span className="ml-2 text-[10.5px] text-[var(--text-tertiary)]">(you)</span> : null}
</p>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{user.email}</p>
</div>
{user.is_admin ? (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--accent-primary-soft)] px-2.5 py-0.5 text-[10.5px] font-semibold text-[var(--accent-primary)]">
<ShieldCheck size={11} /> admin
</span>
) : null}
<button
type="button"
onClick={() => onToggle(!user.is_admin)}
disabled={pending || isSelf}
title={isSelf ? 'You cannot change your own admin flag' : undefined}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40"
>
{user.is_admin ? (
<span className="inline-flex items-center gap-1"><XCircle size={12} /> Revoke</span>
) : (
'Make admin'
)}
</button>
</div>
);
}
@@ -1,7 +1,9 @@
import { useDeferredValue, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { listAuditLogs } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode';
import { formatDate, formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components';
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
const PAGE_SIZE = 50;
@@ -17,6 +19,7 @@ const inputClass =
'h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all';
export function AuditLogsPage() {
const isDemoMode = useDemoMode();
const [resource, setResource] = useState('');
const [action, setAction] = useState('');
const [actor, setActor] = useState('');
@@ -42,8 +45,13 @@ export function AuditLogsPage() {
const since = option && 'ms' in option ? new Date(Date.now() - option.ms).toISOString() : undefined;
return listAuditLogs({ ...filters, since });
},
enabled: !isDemoMode,
});
if (isDemoMode) {
return <DemoRestricted feature="Audit logs" />;
}
const logs = logsQuery.data ?? [];
const hasNext = logs.length === PAGE_SIZE;
const hasFilters = Boolean(resource || action || actor || range);
@@ -1,6 +1,5 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import {
cancelBuild,
getBuildLogs,
@@ -9,6 +8,8 @@ import {
type BuildStatus,
} from '@/lib/api-client';
import { useBuildUpdates } from '@/lib/use-build-updates';
import { useDemoMode } from '@/lib/demo-mode';
import { demoBuilds } from '@/lib/demo-data';
import { formatRelative } from '@/lib/time';
import {
Check,
@@ -33,36 +34,6 @@ const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
{ value: 'cancelled', label: 'Cancelled' },
];
const demoBuilds: BuildEntity[] = [
{
id: 'build-demo-01',
projectId: 'project-demo',
serviceId: 'service-api',
status: 'success',
progress: 100,
startedAt: new Date(Date.now() - 20 * 60_000).toISOString(),
completedAt: new Date(Date.now() - 18 * 60_000).toISOString(),
imageName: 'ghcr.io/containr/api',
imageTag: 'sha-1f2e3d4',
size: 156_000_000,
log: '[demo] Build finished successfully.',
metadata: { branch: 'main' },
},
{
id: 'build-demo-02',
projectId: 'project-demo',
serviceId: 'service-worker',
status: 'running',
progress: 54,
startedAt: new Date(Date.now() - 2 * 60_000).toISOString(),
imageName: 'ghcr.io/containr/worker',
imageTag: 'sha-9a8b7c6',
size: 0,
log: '[demo] Building image layers...',
metadata: { branch: 'feature/queue' },
},
];
function StatusBadge({ status }: { status: BuildStatus }) {
const config = {
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
@@ -99,8 +70,7 @@ function bytesToHumanReadable(bytes: number): string {
export function BuildsPage() {
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const isDemoMode = searchParams.get('demo') === '1';
const isDemoMode = useDemoMode();
const [projectFilter, setProjectFilter] = useState('');
const [serviceFilter, setServiceFilter] = useState('');
@@ -0,0 +1,197 @@
import { useEffect, useMemo, useState } from 'react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { BookOpen, CloudOff, FileText, Search } from 'lucide-react';
import { initialDocs, syncDocs, type DocEntry, type DocsState } from './docs-store';
marked.setOptions({ gfm: true, breaks: false });
function renderMarkdown(markdown: string): string {
const html = marked.parse(markdown, { async: false });
return DOMPurify.sanitize(typeof html === 'string' ? html : '', {
USE_PROFILES: { html: true },
});
}
function excerpt(markdown: string, length = 140): string {
const text = markdown
.replace(/```[\s\S]*?```/g, ' ')
.replace(/[#>*`\-[\]()]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return text.length > length ? `${text.slice(0, length)}…` : text;
}
export function DocsBrowser() {
const [state, setState] = useState<DocsState>(() => initialDocs());
const [query, setQuery] = useState('');
const [selectedPath, setSelectedPath] = useState<string | null>(null);
useEffect(() => {
syncDocs(setState);
}, []);
const docs = state.docs;
const sections = useMemo(() => {
const grouped = new Map<string, DocEntry[]>();
for (const doc of docs) {
const list = grouped.get(doc.section) ?? [];
list.push(doc);
grouped.set(doc.section, list);
}
return [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b));
}, [docs]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return null;
return docs.filter(
(doc) =>
doc.title.toLowerCase().includes(q) ||
doc.path.toLowerCase().includes(q) ||
doc.markdown.toLowerCase().includes(q),
);
}, [docs, query]);
const selected = useMemo(
() => docs.find((doc) => doc.path === selectedPath) ?? null,
[docs, selectedPath],
);
const selectedHtml = useMemo(
() => (selected ? renderMarkdown(selected.markdown) : ''),
[selected],
);
const sourceBadge =
state.source === 'github'
? { label: 'live · github', cls: 'text-[var(--success)]' }
: state.source === 'cache'
? { label: `cached · ${state.syncedAt ? new Date(state.syncedAt).toLocaleDateString() : 'offline'}`, cls: 'text-[var(--warning)]' }
: { label: 'bundled copy', cls: 'text-[var(--text-tertiary)]' };
return (
<div className="flex min-h-[480px] overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
<div className="flex w-[300px] shrink-0 flex-col border-r border-[var(--border-subtle)] max-md:w-full">
<div className="border-b border-[var(--border-subtle)] p-3">
<div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2.5 py-1.5">
<Search size={13} className="shrink-0 text-[var(--text-tertiary)]" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search docs…"
className="w-full bg-transparent text-[13px] text-[var(--text-primary)] outline-none placeholder:text-[var(--text-tertiary)]"
/>
</div>
<div className="mt-2 flex items-center justify-between px-0.5">
<span className={`text-[10.5px] font-medium uppercase tracking-wider ${sourceBadge.cls}`}>
{sourceBadge.label}
</span>
<span className="text-[10.5px] text-[var(--text-tertiary)]">{docs.length} docs</span>
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{filtered ? (
filtered.length > 0 ? (
filtered.map((doc) => (
<DocListItem key={doc.path} doc={doc} active={doc.path === selectedPath} onSelect={setSelectedPath} showSection />
))
) : (
<p className="px-2 py-6 text-center text-xs text-[var(--text-tertiary)]">No docs match “{query}”.</p>
)
) : (
sections.map(([section, entries]) => (
<div key={section} className="mb-3">
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]">
{section}
</p>
{entries.map((doc) => (
<DocListItem key={doc.path} doc={doc} active={doc.path === selectedPath} onSelect={setSelectedPath} />
))}
</div>
))
)}
</div>
</div>
<div className="min-w-0 flex-1 overflow-y-auto max-md:hidden">
{selected ? (
<article className="docs-article px-8 py-6">
<p className="mb-1 text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
{selected.section} / {selected.path}
</p>
<div dangerouslySetInnerHTML={{ __html: selectedHtml }} />
</article>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center">
<BookOpen size={28} className="text-[var(--text-tertiary)]" />
<p className="text-sm font-medium text-[var(--text-secondary)]">Select a document</p>
<p className="max-w-sm text-xs leading-relaxed text-[var(--text-tertiary)]">
Documentation is bundled with the app and refreshes from GitHub in the background.
{!state.syncedAt ? ' Currently showing the bundled copy.' : ''}
</p>
{state.source !== 'github' ? (
<p className="flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
<CloudOff size={12} /> Offline-safe — updates apply automatically when a connection is available.
</p>
) : null}
</div>
)}
</div>
{/* Mobile: tapping a doc replaces the list */}
{selected ? (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--bg-base)] md:hidden">
<div className="flex items-center gap-2 border-b border-[var(--border-subtle)] p-3">
<button
type="button"
onClick={() => setSelectedPath(null)}
className="rounded-[var(--radius-md)] px-2.5 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
← Back
</button>
<span className="truncate text-xs text-[var(--text-tertiary)]">{selected.title}</span>
</div>
<article className="docs-article flex-1 overflow-y-auto px-5 py-4">
<div dangerouslySetInnerHTML={{ __html: selectedHtml }} />
</article>
</div>
) : null}
</div>
);
}
function DocListItem({
doc,
active,
onSelect,
showSection = false,
}: {
doc: DocEntry;
active: boolean;
onSelect: (path: string) => void;
showSection?: boolean;
}) {
return (
<button
type="button"
onClick={() => onSelect(doc.path)}
className={`mb-0.5 w-full rounded-[var(--radius-md)] px-2.5 py-2 text-left transition-colors ${
active
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]'
}`}
>
<span className="flex items-center gap-2">
<FileText size={13} className="shrink-0 opacity-60" />
<span className="truncate text-[13px] font-medium">{doc.title}</span>
</span>
<span className="mt-0.5 block truncate pl-5 text-[11px] text-[var(--text-tertiary)]">
{showSection ? `${doc.section} · ` : ''}{excerpt(doc.markdown, 80)}
</span>
</button>
);
}
@@ -0,0 +1,121 @@
import { bundledDocs, type BundledDoc } from '@/generated/docs-snapshot';
export type DocEntry = BundledDoc;
export type DocsState = {
docs: DocEntry[];
source: 'bundled' | 'cache' | 'github';
syncedAt: string | null;
};
const CACHE_KEY = 'containr.docs.v1';
const REPO = 'Dvorinka/Containr';
const BRANCH = 'main';
const SKIP_DIRS = new Set(['archive', 'design', 'superpowers']);
type CachePayload = { syncedAt: string; docs: DocEntry[] };
function readCache(): CachePayload | null {
try {
const raw = localStorage.getItem(CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as CachePayload;
if (!Array.isArray(parsed.docs) || typeof parsed.syncedAt !== 'string') return null;
return parsed;
} catch {
return null;
}
}
function writeCache(payload: CachePayload) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(payload));
} catch {
// Storage full or unavailable — bundled docs still cover us.
}
}
function titleOf(markdown: string, fallback: string): string {
const match = markdown.match(/^#\s+(.+)$/m);
if (match) return match[1].replace(/[#*`]/g, '').trim();
return fallback
.replace(/\.md$/i, '')
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
type GitHubTreeEntry = { path?: string; type?: string };
async function fetchGitHubDocs(signal: AbortSignal): Promise<DocEntry[]> {
const treeRes = await fetch(
`https://api.github.com/repos/${REPO}/git/trees/${BRANCH}?recursive=1`,
{ signal, headers: { Accept: 'application/vnd.github+json' } },
);
if (!treeRes.ok) throw new Error(`tree fetch failed: ${treeRes.status}`);
const tree = (await treeRes.json()) as { tree?: GitHubTreeEntry[]; truncated?: boolean };
const paths = (tree.tree ?? [])
.filter(
(e) =>
e.type === 'blob' &&
typeof e.path === 'string' &&
e.path.startsWith('docs/') &&
e.path.toLowerCase().endsWith('.md') &&
!e.path.split('/').some((seg, i) => i > 0 && SKIP_DIRS.has(seg)),
)
.map((e) => e.path as string);
const docs = await Promise.all(
paths.map(async (path) => {
const res = await fetch(
`https://raw.githubusercontent.com/${REPO}/${BRANCH}/${path}`,
{ signal },
);
if (!res.ok) throw new Error(`doc fetch failed: ${path}`);
const markdown = await res.text();
const rel = path.slice('docs/'.length);
return {
path: rel,
section: rel.includes('/') ? rel.split('/')[0] : 'docs',
title: titleOf(markdown, rel.split('/').pop() ?? rel),
markdown,
};
}),
);
return docs;
}
/**
* Synchronous initial state — cache wins over the bundled snapshot so a
* previously synced copy survives offline reloads.
*/
export function initialDocs(): DocsState {
const cached = readCache();
return cached
? { docs: cached.docs, source: 'cache', syncedAt: cached.syncedAt }
: { docs: bundledDocs, source: 'bundled', syncedAt: null };
}
/**
* Background GitHub sync. `onUpdate` fires when fresh content lands so the
* UI can re-render without a reload; failures keep the current copy.
*/
export function syncDocs(onUpdate: (state: DocsState) => void): void {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
fetchGitHubDocs(controller.signal)
.then((docs) => {
const syncedAt = new Date().toISOString();
writeCache({ syncedAt, docs });
onUpdate({ docs, source: 'github', syncedAt });
})
.catch(() => {
// Offline or rate-limited — bundled/cache copy already displayed.
})
.finally(() => clearTimeout(timeout));
}
@@ -0,0 +1,236 @@
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
ArrowRight,
BookOpen,
Container,
Database,
GitBranch,
LayoutTemplate,
Rocket,
ShieldCheck,
Zap,
} from 'lucide-react';
import { listProjects, listTemplates } from '@/lib/api-client';
import { DocsBrowser } from '@/features/docs/DocsBrowser';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { demoProjects, demoTemplates } from '@/lib/demo-data';
const features = [
{ icon: Rocket, title: 'Git-push deploys', text: 'Connect a repository and ship. Builds, rollouts, and rollbacks are handled for you.' },
{ icon: Database, title: 'Managed databases', text: 'Provision PostgreSQL, MySQL, Redis and more — with backups and connection management.' },
{ icon: ShieldCheck, title: 'High availability', text: 'Failover policies, health checks, and alerting keep services alive across nodes.' },
{ icon: GitBranch, title: 'Preview environments', text: 'Every branch gets an isolated environment with its own URL.' },
{ icon: LayoutTemplate, title: 'Template catalog', text: 'One-click deploys for popular self-hosted apps and community templates.' },
{ icon: Zap, title: 'Autoscaling', text: 'Metric-driven scaling policies adapt replicas to real load.' },
];
export function LandingPage() {
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = !isDemoMode && Boolean(sessionQuery.data);
const demo = isDemoMode ? '?demo=1' : '';
const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000, enabled: !isDemoMode });
const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000, enabled: !isDemoMode });
const projects = isDemoMode ? demoProjects : (projectsQuery.data ?? []);
const templates = (isDemoMode ? demoTemplates : (templatesQuery.data ?? [])).slice(0, 12);
return (
<div className="min-h-screen bg-[var(--bg-void)] text-[var(--text-secondary)]">
<div className="ambient-glow" />
<header className="sticky top-0 z-40 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 backdrop-blur-xl">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
<Link to="/" className="flex items-center gap-2.5">
<img src="/containr.svg" alt="Containr" className="h-7 w-7 rounded-lg" />
<span className="text-[15px] font-extrabold tracking-tight text-[var(--text-primary)]">
contain<span style={{ color: 'var(--accent-primary)' }}>r</span>
</span>
</Link>
<nav className="flex items-center gap-1 text-[13px]">
<a href="#features" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Features</a>
<a href="#templates" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Templates</a>
<a href="#docs" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Docs</a>
<Link
to={`/projects${demo}`}
className="rounded-[var(--radius-md)] px-3 py-1.5 font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
>
Browse
</Link>
{signedIn ? (
<Link
to="/projects"
className="ml-1 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 py-1.5 font-semibold text-[var(--accent-on)]"
>
Console <ArrowRight size={13} />
</Link>
) : (
<Link
to={isDemoMode ? `/projects${demo}` : '/auth/sign-in'}
className="ml-1 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 py-1.5 font-semibold text-[var(--accent-on)]"
>
{isDemoMode ? 'Live demo' : 'Sign in'} <ArrowRight size={13} />
</Link>
)}
</nav>
</div>
</header>
<main className="relative">
<section className="mx-auto max-w-6xl px-5 pb-16 pt-20 text-center">
<p className="mx-auto mb-5 inline-flex items-center gap-2 rounded-full border border-[var(--border-subtle)] bg-[var(--surface-card)] px-3.5 py-1 text-[11px] font-medium uppercase tracking-[0.16em] text-[var(--text-tertiary)]">
<Container size={12} className="text-[var(--accent-primary)]" />
self-hosted PaaS
</p>
<h1 className="mx-auto max-w-3xl text-[42px] font-extrabold leading-[1.08] tracking-[-0.03em] text-[var(--text-primary)] max-sm:text-[32px]">
Deploy containers like it's{' '}
<span style={{ color: 'var(--accent-primary)' }}>your own cloud</span>
</h1>
<p className="mx-auto mt-5 max-w-xl text-[15px] leading-relaxed text-[var(--text-secondary)]">
Containr turns any Docker host into a full deployment platform — git-push builds,
managed databases, preview environments, HA failover, and a searchable knowledge base.
</p>
<div className="mt-8 flex items-center justify-center gap-3">
<Link
to={`/projects${demo}`}
className="inline-flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-5 py-2.5 text-sm font-semibold text-[var(--accent-on)] transition-transform hover:scale-[1.02]"
>
{isDemoMode ? 'Explore the live demo' : 'Browse projects'} <ArrowRight size={15} />
</Link>
<a
href="#docs"
className="inline-flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-default)] px-5 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]"
>
<BookOpen size={15} /> Read the docs
</a>
</div>
{projects.length > 0 ? (
<div className="mx-auto mt-12 grid max-w-3xl grid-cols-3 gap-3 text-left max-sm:grid-cols-1">
<Stat label="Public projects" value={projects.length} />
<Stat
label="Running services"
value={projects.reduce((sum, p) => sum + (p.stats?.running_services ?? 0), 0)}
/>
<Stat
label="Deployments"
value={projects.reduce((sum, p) => sum + (p.stats?.deployment_count ?? 0), 0)}
/>
</div>
) : null}
</section>
<section id="features" className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
<div className="mx-auto max-w-6xl px-5 py-16">
<h2 className="text-center text-[26px] font-bold tracking-tight text-[var(--text-primary)]">
Everything a deployment platform needs
</h2>
<p className="mx-auto mt-2 max-w-lg text-center text-sm text-[var(--text-secondary)]">
One install, one dashboard, full control over your own infrastructure.
</p>
<div className="mt-10 grid grid-cols-3 gap-4 max-md:grid-cols-2 max-sm:grid-cols-1">
{features.map((feature) => (
<div
key={feature.title}
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-5 transition-colors hover:border-[var(--border-default)]"
>
<feature.icon size={18} className="text-[var(--accent-primary)]" />
<h3 className="mt-3 text-[14.5px] font-semibold text-[var(--text-primary)]">{feature.title}</h3>
<p className="mt-1.5 text-[12.5px] leading-relaxed text-[var(--text-tertiary)]">{feature.text}</p>
</div>
))}
</div>
</div>
</section>
<section id="templates" className="border-t border-[var(--border-subtle)]">
<div className="mx-auto max-w-6xl px-5 py-16">
<div className="flex items-end justify-between">
<div>
<h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Template catalog</h2>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
Production-ready presets — deploy in one click from the console.
</p>
</div>
<Link
to={`/templates${demo}`}
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--accent-primary)] hover:underline"
>
Browse all <ArrowRight size={13} />
</Link>
</div>
<div className="mt-8 grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2">
{templates.map((template) => (
<Link
key={template.id}
to={`/templates?template=${template.id}${isDemoMode ? '&demo=1' : ''}`}
className="group rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-4 transition-colors hover:border-[var(--border-strong)]"
>
<div className="flex items-center gap-2.5">
{template.logo ? (
<img src={template.logo} alt="" className="h-7 w-7 rounded-md" />
) : (
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-[var(--surface-muted)] text-[var(--accent-primary)]">
<LayoutTemplate size={14} />
</span>
)}
<span className="truncate text-[13px] font-semibold text-[var(--text-primary)]">{template.name}</span>
</div>
<p className="mt-2 line-clamp-2 text-[11.5px] leading-relaxed text-[var(--text-tertiary)]">
{template.description}
</p>
<span className="mt-2 inline-block rounded-full bg-[var(--surface-muted)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--text-tertiary)]">
{template.category || 'app'}
</span>
</Link>
))}
{templates.length === 0 && !templatesQuery.isPending ? (
<p className="col-span-full py-8 text-center text-sm text-[var(--text-tertiary)]">
Template catalog is empty — sign in to add your own.
</p>
) : null}
</div>
</div>
</section>
<section id="docs" className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
<div className="mx-auto max-w-6xl px-5 py-16">
<h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Documentation</h2>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
Guides and references — synced from GitHub, cached locally, always available.
</p>
<div className="mt-8">
<DocsBrowser />
</div>
</div>
</section>
</main>
<footer className="border-t border-[var(--border-subtle)]">
<div className="mx-auto flex max-w-6xl items-center justify-between px-5 py-6 text-[12px] text-[var(--text-tertiary)]">
<span className="flex items-center gap-2">
<img src="/containr.svg" alt="" className="h-4 w-4 rounded" />
Containr — self-hosted container platform
</span>
<div className="flex items-center gap-4">
<Link to={`/projects${demo}`} className="hover:text-[var(--text-secondary)]">Projects</Link>
<Link to={`/templates${demo}`} className="hover:text-[var(--text-secondary)]">Templates</Link>
{!isDemoMode ? <Link to="/admin" className="hover:text-[var(--text-secondary)]">Admin</Link> : null}
</div>
</div>
</footer>
</div>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-3.5">
<p className="text-[22px] font-bold tabular-nums text-[var(--text-primary)]">{value}</p>
<p className="mt-0.5 text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
</div>
);
}
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
createProject,
@@ -14,37 +14,21 @@ import {
FolderOpen,
X,
} from 'lucide-react';
const demoProjects: ProjectEntity[] = [
{
id: 'project-demo',
name: 'core-services',
description: 'Sample project with mock services for UI preview.',
createdAt: new Date(Date.now() - 14 * 86_400_000).toISOString(),
updatedAt: new Date().toISOString(),
stats: { service_count: 5, deployment_count: 12, running_services: 5, last_deployment: null },
},
{
id: 'project-staging',
name: 'ml-pipeline',
description: 'Pre-production environment for testing new releases.',
createdAt: new Date(Date.now() - 7 * 86_400_000).toISOString(),
updatedAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
stats: { service_count: 2, deployment_count: 5, running_services: 1, last_deployment: null },
},
];
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { demoProjects } from '@/lib/demo-data';
const demoDeploys = [
{ id: 'd1', name: 'api-gateway', project: 'core-services', status: 'DEPLOYED', when: '4m' },
{ id: 'd2', name: 'web-frontend', project: 'core-services', status: 'BUILDING', when: '6m' },
{ id: 'd3', name: 'worker', project: 'ml-pipeline', status: 'FAILED', when: '1h' },
{ id: 'd4', name: 'site', project: 'growth-site', status: 'DEPLOYED', when: '2h' },
{ id: 'd1', name: 'API Gateway', project: 'Core Platform', status: 'DEPLOYED', when: '4m' },
{ id: 'd2', name: 'Web Frontend', project: 'Core Platform', status: 'BUILDING', when: '6m' },
{ id: 'd3', name: 'Batch Evaluator', project: 'Inference Lab', status: 'FAILED', when: '1h' },
{ id: 'd4', name: 'Marketing Site', project: 'Growth Surface', status: 'DEPLOYED', when: '2h' },
];
function getHealthStatus(stats: ProjectStats): 'healthy' | 'degraded' | 'critical' {
if (stats.service_count === 0) return 'healthy';
function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' | 'critical' {
if (stats.service_count === 0) return 'idle';
if (stats.running_services === stats.service_count) return 'healthy';
if (stats.running_services >= stats.service_count / 2) return 'degraded';
if (stats.running_services > 0) return 'degraded';
return 'critical';
}
@@ -52,6 +36,8 @@ function healthBadge(health: ReturnType<typeof getHealthStatus>) {
switch (health) {
case 'healthy':
return { label: 'RUNNING', cls: 'v-st-ok' };
case 'idle':
return { label: 'IDLE', cls: '' };
case 'degraded':
return { label: 'DEGRADED', cls: 'v-st-warn' };
case 'critical':
@@ -86,7 +72,6 @@ function MiniCanvas({ project }: { project: ProjectEntity }) {
{Array.from({ length: shown }).map((_, i) => (
<div key={i} className="v-node" style={positions[i]}>
<b className={`v-nd ${i < running ? '' : 'r'}`} />
svc{i + 1}
</div>
))}
{extra > 0 ? (
@@ -101,6 +86,7 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string }
const health = getHealthStatus(project.stats);
const badge = healthBadge(health);
const envOk = health === 'healthy';
const envLabel = envOk ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
return (
<article
@@ -117,10 +103,10 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string }
<div className="v-mono mt-3 flex items-center gap-2 text-[10.5px] text-[var(--text-tertiary)]">
<i
className="inline-block h-1.5 w-1.5 rounded-full"
style={{ background: envOk ? 'var(--success)' : 'var(--warning)' }}
style={{ background: envOk ? 'var(--success)' : health === 'idle' ? 'var(--text-tertiary)' : health === 'critical' ? 'var(--error)' : 'var(--warning)' }}
/>
<span className="text-[var(--text-secondary)]">
{envOk ? 'production' : 'degraded'}
{envLabel}
</span>
<span>·</span>
<span>
@@ -134,13 +120,21 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string }
export function ProjectsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const isDemoMode = searchParams.get('demo') === '1';
const isDemoMode = useDemoMode();
const [search, setSearch] = useState('');
const [isCreateOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: '', description: '' });
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const openCreate = () => {
if (!signedIn) {
navigate('/auth/sign-in');
return;
}
setCreateOpen(true);
};
const projectHref = (projectId: string) =>
isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`;
@@ -148,7 +142,7 @@ export function ProjectsPage() {
const projectsQuery = useQuery({
queryKey: ['projects'],
enabled: !isDemoMode,
queryFn: listProjects,
queryFn: () => listProjects(),
});
const hostQuery = useQuery({
queryKey: ['host-monitoring'],
@@ -187,6 +181,7 @@ export function ProjectsPage() {
const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0);
const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0);
const host = hostQuery.data;
const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'HOST';
const cpuPct = isDemoMode ? 14 : host && host.cpu.cores > 0
? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100))
: null;
@@ -228,7 +223,7 @@ export function ProjectsPage() {
</p>
</div>
<div className="flex items-center gap-2.5">
<div className="search-box" style={{ width: 240 }}>
<div className="search-box" style={{ width: 'min(240px, 42vw)' }}>
<Search size={13} />
<input
value={search}
@@ -238,16 +233,18 @@ export function ProjectsPage() {
style={{ fontSize: 11.5 }}
/>
</div>
<button onClick={() => setCreateOpen(true)} className="v-btn">
+ new project
</button>
{signedIn && (
<button onClick={openCreate} className="v-btn">
+ new project
</button>
)}
</div>
</div>
{/* Host stat row */}
<div className="mb-7 grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="v-stat">
<div className="v-k"><span>HOST.CPU</span><span className="v-live" /></div>
<div className="v-k"><span>{hostLabel}.CPU</span><span className="v-live" /></div>
<div className="v-v">{cpuPct ?? '—'}<span>%</span></div>
{isDemoMode ? (
<div className="v-spark">
@@ -263,13 +260,13 @@ export function ProjectsPage() {
</div>
</div>
<div className="v-stat">
<div className="v-k"><span>HOST.MEM</span><span className="v-live" /></div>
<div className="v-k"><span>{hostLabel}.MEM</span><span className="v-live" /></div>
<div className="v-v">{memPct ?? '—'}<span>%</span></div>
<div className="v-meter"><i className={memPct !== null && memPct > 85 ? 'r' : 'y'} style={{ width: `${memPct ?? 0}%` }} /></div>
<div className="v-d">{isDemoMode ? '9.8G / 13G' : host ? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)}` : 'no telemetry'}</div>
</div>
<div className="v-stat">
<div className="v-k"><span>HOST.DISK</span><span className="v-live" /></div>
<div className="v-k"><span>{hostLabel}.DISK</span><span className="v-live" /></div>
<div className="v-v">{diskPct ?? '—'}<span>%</span></div>
<div className="v-meter"><i className="r" style={{ width: `${diskPct ?? 0}%` }} /></div>
<div className="v-d">{isDemoMode ? '338G of 465G · high' : host ? `${fmtGB(host.storage.used)} of ${fmtGB(host.storage.total)}` : 'no telemetry'}</div>
@@ -310,10 +307,12 @@ export function ProjectsPage() {
</div>
<p className="text-xl font-semibold text-[var(--text-primary)]">No projects yet</p>
<p className="mx-auto mt-2 max-w-md text-[var(--text-secondary)]">
Create your first project to start deploying services with visual topology management.
{signedIn
? 'Create your first project to start deploying services with visual topology management.'
: 'No public projects yet. Sign in to create one — an admin approves it before it goes public.'}
</p>
<button onClick={() => setCreateOpen(true)} className="v-btn mt-6">
+ create project
<button onClick={openCreate} className="v-btn mt-6">
{signedIn ? '+ create project' : 'sign in to create'}
</button>
</div>
) : null}
@@ -357,6 +356,9 @@ export function ProjectsPage() {
<p className="mt-1 text-sm text-[var(--text-secondary)]">
Projects organize your services and provide a visual canvas for topology management.
</p>
<p className="mt-2 rounded-[var(--radius-md)] bg-[var(--warning-soft)] px-3 py-2 text-xs text-[var(--warning)]">
New projects stay private until a platform admin approves them for public listing.
</p>
<div className="mt-6 space-y-4">
<div>
File diff suppressed because it is too large. Load diff
@@ -36,6 +36,11 @@ import {
type CronJobEntity,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
import { useDemoMode } from '@/lib/demo-mode';
import { ServiceIcon } from '@/features/workspace/canvas/ServiceIcon';
import { serviceAccent } from '@/features/workspace/canvas/service-visuals';
import { useAuthSession } from '@/lib/use-auth-session';
import { getCurrentUserProfile } from '@/lib/api-client';
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components';
@@ -70,16 +75,18 @@ import {
type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'previews' | 'scaling' | 'console' | 'settings';
const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [
// Sections marked manageOnly are hidden from anonymous visitors — they
// expose authenticated or owner-scoped data (logs, variables, exec).
const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity; manageOnly?: boolean }> = [
{ key: 'metrics', label: 'Metrics', icon: Activity },
{ key: 'logs', label: 'Logs', icon: FileText },
{ key: 'logs', label: 'Logs', icon: FileText, manageOnly: true },
{ key: 'config', label: 'Config', icon: Sliders },
{ key: 'variables', label: 'Variables', icon: KeyRound },
{ key: 'variables', label: 'Variables', icon: KeyRound, manageOnly: true },
{ key: 'cron', label: 'Cron', icon: Clock },
{ key: 'previews', label: 'Previews', icon: GitPullRequest },
{ key: 'scaling', label: 'Scaling', icon: Layers },
{ key: 'console', label: 'Console', icon: Terminal },
{ key: 'settings', label: 'Settings', icon: Settings },
{ key: 'console', label: 'Console', icon: Terminal, manageOnly: true },
{ key: 'settings', label: 'Settings', icon: Settings, manageOnly: true },
];
function metricStatus(percent: number): 'good' | 'average' | 'warning' {
@@ -116,12 +123,24 @@ function StatusBadge({ status }: { status: string }) {
export function ServiceDetailPage() {
const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const isDemoMode = searchParams.get('demo') === '1';
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: getCurrentUserProfile,
enabled: signedIn && !isDemoMode,
retry: false,
});
const isAdmin = Boolean(profileQuery.data?.isAdmin);
const [activeSection, setActiveSection] = useState<ServiceSection>('metrics');
const [activeSection, setActiveSection] = useState<ServiceSection>(() => {
const requested = searchParams.get('section');
return sectionItems.some((item) => item.key === requested) ? (requested as ServiceSection) : 'metrics';
});
const [logTail, setLogTail] = useState('100');
const [varDrafts, setVarDrafts] = useState<VariableDraft[] | null>(null);
const [bulkOpen, setBulkOpen] = useState(false);
@@ -369,7 +388,7 @@ export function ServiceDetailPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
navigate(`/projects/${projectId}`);
navigate(isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`);
},
});
@@ -557,42 +576,41 @@ export function ServiceDetailPage() {
style={{
gap: '6px',
padding: '14px 24px 10px',
color: '#6b6e7d',
color: 'var(--text-tertiary)',
fontSize: '13px'
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
<button
onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)}
style={{ color: '#6b6e7d', textDecoration: 'none' }}
className="hover:text-[#9295a4] transition-colors"
style={{ color: 'var(--text-tertiary)', textDecoration: 'none' }}
className="hover:text-[var(--text-secondary)] transition-colors"
>
Servers
</button>
<span style={{ opacity: 0.4 }}>/</span>
<span style={{ color: '#9295a4' }}>{service.name}</span>
<span style={{ color: 'var(--text-secondary)' }}>{service.name}</span>
</div>
{/* Project Header - self.html exact match */}
<div className="flex items-center" style={{ padding: '0 24px 18px' }}>
<div
<div
className="rounded-[13px] flex items-center justify-center flex-shrink-0"
style={{
width: '46px',
height: '46px',
background: 'var(--accent-primary)',
style={{
width: '46px',
height: '46px',
background: `${serviceAccent(service)}1f`,
color: serviceAccent(service),
marginRight: '14px'
}}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/>
</svg>
<ServiceIcon service={service} size={22} />
</div>
<div>
<div className="flex items-center" style={{ gap: '10px' }}>
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}>{service.name}</span>
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: 'var(--text-primary)' }}>{service.name}</span>
<span
className={`badge-${service.status === 'running' ? 'active' : service.status === 'degraded' ? 'degraded' : 'stopped'}`}
>
@@ -606,8 +624,8 @@ export function ServiceDetailPage() {
href={service.publicUrl ?? `https://${service.domain}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center hover:text-[#9295a4] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
className="flex items-center hover:text-[var(--text-secondary)] transition-colors"
style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
>
{service.publicUrl ?? `https://${service.domain}`}
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
@@ -619,8 +637,8 @@ export function ServiceDetailPage() {
)}
<button
onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)}
className="flex items-center hover:text-[#9295a4] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
className="flex items-center hover:text-[var(--text-secondary)] transition-colors"
style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
>
Project Information
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
@@ -630,7 +648,7 @@ export function ServiceDetailPage() {
</div>
</div>
<div className="ml-auto flex" style={{ gap: '10px' }}>
{!isDemoMode && (
{!isDemoMode && signedIn && (
<>
<button
onClick={() => stopMutation.mutate()}
@@ -697,7 +715,7 @@ export function ServiceDetailPage() {
data={isDemoMode
? Array.from({ length: 24 }, (_, i) => seededMetric(`${service.id}:cpu:${i}`, 8, 80))
: metricHistory.cpu.length > 0 ? metricHistory.cpu : [0]}
color="#b4e34a"
color="var(--accent-primary)"
height={72}
/>
}
@@ -711,10 +729,10 @@ export function ServiceDetailPage() {
subtitle={isDemoMode ? 'Container footprint' : hasLiveTelemetry ? `${formatBytes(liveMetrics!.memory_usage_bytes)} used` : 'No running containers'}
chart={
<div className="relative mx-auto" style={{ width: 150 }}>
<DonutChart percentage={isDemoMode ? metricSet?.memory ?? 0 : memoryPercent} color="#f2c94c" size={150} thickness={14} />
<DonutChart percentage={isDemoMode ? metricSet?.memory ?? 0 : memoryPercent} color="var(--warning)" size={150} thickness={14} />
<div className="absolute inset-x-0 bottom-0 text-center">
<div className="text-[10px] uppercase tracking-wide text-[#6b6e7d]">Used</div>
<div className="text-sm font-bold text-[#e8e9f0]">
<div className="text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">Used</div>
<div className="text-sm font-bold text-[var(--text-primary)]">
{isDemoMode
? `${((8 * (metricSet?.memory ?? 0)) / 100).toFixed(1)} GB / 8 GB`
: hasLiveTelemetry
@@ -737,7 +755,7 @@ export function ServiceDetailPage() {
data={isDemoMode
? Array.from({ length: 24 }, (_, i) => seededMetric(`${service.id}:net:${i}`, 15, 70))
: metricHistory.net.length > 0 ? metricHistory.net : [0]}
color="#7ab8ff"
color="var(--info)"
height={72}
/>
}
@@ -785,12 +803,12 @@ export function ServiceDetailPage() {
<div
className="flex"
style={{
borderBottom: '1px solid rgba(255,255,255,0.07)',
borderBottom: '1px solid var(--border-subtle)',
marginBottom: '18px',
padding: '0 24px'
}}
>
{sectionItems.map((item) => {
{sectionItems.filter((item) => signedIn || !item.manageOnly).map((item) => {
const active = activeSection === item.key;
const Icon = item.icon;
return (
@@ -847,8 +865,8 @@ export function ServiceDetailPage() {
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
style={{ height: `${value}%`, background: '#b4e34a' }}
className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80 cursor-pointer"
style={{ height: `${value}%`, background: 'var(--accent-primary)' }}
title={`${value}%`}
/>
);
@@ -856,8 +874,8 @@ export function ServiceDetailPage() {
: metricHistory.cpu.map((value, i) => (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80"
style={{ height: `${Math.max(3, value)}%`, background: '#b4e34a' }}
className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80"
style={{ height: `${Math.max(3, value)}%`, background: 'var(--accent-primary)' }}
title={`${value.toFixed(1)}%`}
/>
))}
@@ -888,8 +906,8 @@ export function ServiceDetailPage() {
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
style={{ height: `${value}%`, background: '#5ee6a0' }}
className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80 cursor-pointer"
style={{ height: `${value}%`, background: 'var(--success)' }}
title={`${value}%`}
/>
);
@@ -897,8 +915,8 @@ export function ServiceDetailPage() {
: metricHistory.memory.map((value, i) => (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80"
style={{ height: `${Math.max(3, value)}%`, background: '#5ee6a0' }}
className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80"
style={{ height: `${Math.max(3, value)}%`, background: 'var(--success)' }}
title={`${value.toFixed(1)}%`}
/>
))}
@@ -954,31 +972,60 @@ export function ServiceDetailPage() {
<p className="text-sm font-medium text-[var(--text-primary)]">Networking</p>
<p className="text-xs text-[var(--text-tertiary)]">Public endpoints and ports</p>
</div>
{runtimeQuery.data?.health === 'healthy' && (
<span className="ml-auto v-st v-st-ok">HEALTHY</span>
)}
{runtimeQuery.data?.health === 'unhealthy' && (
<span className="ml-auto v-st v-st-fail">UNHEALTHY</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--success)]" />
<span className="text-xs text-[var(--text-secondary)]">HTTPS</span>
{service.domain && (
<div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--success)]" />
<span className="text-xs text-[var(--text-secondary)]">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>
<a
href={`https://${service.name}.containr.dev`}
target="_blank"
rel="noopener noreferrer"
className="mono text-xs text-[var(--accent-primary)] hover:underline"
>
{service.name}.containr.dev
</a>
</div>
)}
{(runtimeQuery.data?.urls ?? []).map((url) => (
<div key={url} className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--success)]" />
<span className="text-xs text-[var(--text-secondary)]">HTTP</span>
</div>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="mono text-xs text-[var(--accent-primary)] hover:underline"
>
{url.replace(/^https?:\/\//, '')}
</a>
</div>
))}
<div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--accent-secondary)]" />
<span className="text-xs text-[var(--text-secondary)]">Port</span>
<span className="text-xs text-[var(--text-secondary)]">Internal</span>
</div>
<span className="mono text-xs text-[var(--text-primary)]">8080 → 443</span>
<span className="mono text-xs text-[var(--text-primary)]">{service.name}{service.port ? `:${service.port}` : ''}</span>
</div>
</div>
{!service.domain && (runtimeQuery.data?.urls ?? []).length === 0 && (
<p className="mt-3 text-xs text-[var(--text-tertiary)]">
No public endpoint. Publish a port or set a domain in Settings → Networking to expose this service.
</p>
)}
</div>
)}
</div>
@@ -1288,6 +1335,7 @@ export function ServiceDetailPage() {
<p className="text-sm text-[var(--text-secondary)]">Scheduled commands executed inside the service container</p>
</div>
</div>
{signedIn ? (
<button
onClick={() => { setEditingCronId(null); setCronForm({ name: '', schedule: '', command: '', timezone: 'UTC', enabled: true, retention: 30 }); }}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
@@ -1295,6 +1343,7 @@ export function ServiceDetailPage() {
<Plus size={12} />
New job
</button>
) : null}
</div>
{cronForm && (
@@ -1378,9 +1427,10 @@ export function ServiceDetailPage() {
<div key={job.id} className="panel-soft p-3">
<div className="flex items-center gap-3">
<button
onClick={() => toggleCronMutation.mutate(job)}
onClick={() => signedIn && toggleCronMutation.mutate(job)}
title={job.enabled ? 'Disable' : 'Enable'}
className={`w-8 h-5 rounded-full relative transition-colors shrink-0 ${job.enabled ? 'bg-[var(--accent-primary)]' : 'bg-[var(--border-default)]'}`}
disabled={!signedIn}
className={`w-8 h-5 rounded-full relative transition-colors shrink-0 ${job.enabled ? 'bg-[var(--accent-primary)]' : 'bg-[var(--border-default)]'} disabled:opacity-60 disabled:cursor-not-allowed`}
>
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${job.enabled ? 'left-3.5' : 'left-0.5'}`} />
</button>
@@ -1396,15 +1446,19 @@ export function ServiceDetailPage() {
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => triggerCronMutation.mutate(job.id as string)} title="Run now"
disabled={triggerCronMutation.isPending}
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors disabled:opacity-50">
<Play size={13} />
</button>
{signedIn ? (
<button onClick={() => triggerCronMutation.mutate(job.id as string)} title="Run now"
disabled={triggerCronMutation.isPending}
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors disabled:opacity-50">
<Play size={13} />
</button>
) : null}
<button onClick={() => setHistoryCronId(historyCronId === job.id ? null : (job.id as string))} title="History"
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors">
<ChevronDown size={13} className={historyCronId === job.id ? 'rotate-180 transition-transform' : 'transition-transform'} />
</button>
{signedIn ? (
<>
<button
onClick={() => { setEditingCronId(job.id as string); setCronForm({ name: job.name ?? '', schedule: job.schedule ?? '', command: job.command ?? '', timezone: job.timezone ?? 'UTC', enabled: job.enabled ?? true, retention: job.retention ?? 30 }); }}
title="Edit"
@@ -1417,6 +1471,8 @@ export function ServiceDetailPage() {
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors">
<Trash2 size={13} />
</button>
</>
) : null}
</div>
</div>
{historyCronId === job.id && (
@@ -1462,12 +1518,14 @@ export function ServiceDetailPage() {
<p className="text-sm text-[var(--text-secondary)]">Ephemeral per-branch deployments of this service</p>
</div>
</div>
{signedIn ? (
<button
onClick={() => setPreviewForm({ branch: '', prNumber: '', ttlHours: '72' })}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
<Plus size={12} /> New preview
</button>
) : null}
</div>
{previewForm && (
@@ -1479,7 +1537,7 @@ export function ServiceDetailPage() {
value={previewForm.branch}
onChange={(e) => setPreviewForm({ ...previewForm, branch: e.target.value })}
placeholder="feature/my-branch"
className="w-52 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-raised)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
className="w-52 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
@@ -1488,7 +1546,7 @@ export function ServiceDetailPage() {
value={previewForm.prNumber}
onChange={(e) => setPreviewForm({ ...previewForm, prNumber: e.target.value })}
placeholder="123"
className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-raised)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
@@ -1497,7 +1555,7 @@ export function ServiceDetailPage() {
value={previewForm.ttlHours}
onChange={(e) => setPreviewForm({ ...previewForm, ttlHours: e.target.value })}
placeholder="72"
className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-raised)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
/>
</label>
<button
@@ -1551,7 +1609,7 @@ export function ServiceDetailPage() {
<ExternalLink size={11} /> Open
</a>
)}
{env.status === 'running' && (
{env.status === 'running' && signedIn && (
<button
onClick={() => { if (window.confirm('Promote this preview to production?')) promotePreviewMutation.mutate(env.id ?? ''); }}
disabled={promotePreviewMutation.isPending}
@@ -1560,6 +1618,7 @@ export function ServiceDetailPage() {
Promote
</button>
)}
{signedIn ? (
<button
onClick={() => { if (window.confirm(`Delete preview ${env.environment}?`)) deletePreviewMutation.mutate(env.id ?? ''); }}
disabled={deletePreviewMutation.isPending}
@@ -1567,6 +1626,7 @@ export function ServiceDetailPage() {
>
Delete
</button>
) : null}
</div>
))}
</div>
@@ -1598,7 +1658,7 @@ export function ServiceDetailPage() {
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p>
<p className="text-xl font-semibold text-[var(--text-primary)]">
{scalingStateQuery.data?.CurrentReplicas ??
@@ -1609,19 +1669,19 @@ export function ServiceDetailPage() {
</span>
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Policy</p>
<p className="text-xl font-semibold text-[var(--text-primary)]">
{scalingPolicyQuery.data ? `${scalingPolicyQuery.data.min_replicas}–${scalingPolicyQuery.data.max_replicas}` : 'None'}
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Autoscaling</p>
<p className="text-xl font-semibold text-[var(--text-primary)]">
{scalingPolicyQuery.data?.enabled ? 'On' : 'Off'}
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Last action</p>
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
{scalingStateQuery.data?.LastScaleDirection ?? '—'}
@@ -1639,15 +1699,17 @@ export function ServiceDetailPage() {
max={20}
value={scaleReplicas}
onChange={(e) => setScaleReplicas(e.target.value)}
className="w-28 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
className="w-28 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
placeholder="e.g. 3"
/>
</div>
{isAdmin ? (
<>
<button
type="button"
disabled={manualScaleMutation.isPending || !scaleReplicas}
onClick={() => manualScaleMutation.mutate(Number(scaleReplicas))}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{manualScaleMutation.isPending ? 'Scaling…' : 'Scale'}
</button>
@@ -1660,7 +1722,7 @@ export function ServiceDetailPage() {
targetMemory: String(scalingPolicyQuery.data?.target_memory ?? 80),
enabled: scalingPolicyQuery.data?.enabled ?? true,
})}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
{scalingPolicyQuery.data ? 'Edit policy' : 'Create policy'}
</button>
@@ -1674,9 +1736,13 @@ export function ServiceDetailPage() {
Disable policy
</button>
)}
</>
) : (
<span className="self-center text-xs text-[var(--text-tertiary)]">Scaling policy changes are admin-only.</span>
)}
</div>
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-4 space-y-4">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{([
['Min replicas', 'min'],
@@ -1691,7 +1757,7 @@ export function ServiceDetailPage() {
min={field === 'min' || field === 'max' ? 1 : 0}
value={scalingForm[field]}
onChange={(e) => setScalingForm({ ...scalingForm, [field]: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
/>
</div>
))}
@@ -1709,14 +1775,14 @@ export function ServiceDetailPage() {
type="button"
disabled={saveScalingMutation.isPending}
onClick={() => saveScalingMutation.mutate()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{saveScalingMutation.isPending ? 'Saving…' : 'Save policy'}
</button>
<button
type="button"
onClick={() => setScalingForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
Cancel
</button>
@@ -1765,12 +1831,12 @@ export function ServiceDetailPage() {
onChange={(e) => setConsoleInput(e.target.value)}
placeholder="e.g. env | sort | head -20"
disabled={execMutation.isPending}
className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm font-mono text-[var(--text-primary)] disabled:opacity-50"
className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm font-mono text-[var(--text-primary)] disabled:opacity-50"
/>
<button
type="submit"
disabled={execMutation.isPending || !consoleInput.trim()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{execMutation.isPending ? 'Running…' : 'Run'}
</button>
@@ -1789,7 +1855,7 @@ export function ServiceDetailPage() {
) : (
<div className="space-y-3">
{consoleHistory.map((entry, i) => (
<div key={i} className="rounded-[var(--radius-md)] border border-[var(--border-primary)] bg-[var(--bg-secondary)] p-3">
<div key={i} className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-3">
<div className="flex items-center justify-between mb-1">
<p className="text-xs font-mono text-[var(--text-primary)]">$ {entry.command}</p>
<span className={`text-xs font-mono ${entry.exitCode === 0 && !entry.error ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
@@ -1878,25 +1944,25 @@ export function ServiceDetailPage() {
{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-primary)] p-3">
<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-primary)] p-3">
<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-primary)] p-3">
<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-primary)] p-3">
<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-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-3 gap-3">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Container port</label>
@@ -1907,7 +1973,7 @@ export function ServiceDetailPage() {
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(--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)]"
/>
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">Published on the host; routed via Traefik when a domain is set</p>
</div>
@@ -1917,7 +1983,7 @@ export function ServiceDetailPage() {
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(--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>
@@ -1926,7 +1992,7 @@ export function ServiceDetailPage() {
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(--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>
@@ -1937,7 +2003,7 @@ export function ServiceDetailPage() {
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(--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>
@@ -1945,7 +2011,7 @@ export function ServiceDetailPage() {
<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(--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)]"
>
<option value="unless-stopped">unless-stopped</option>
<option value="always">always</option>
@@ -1967,14 +2033,14 @@ export function ServiceDetailPage() {
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-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"
>
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={() => setNetworkForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
Cancel
</button>
@@ -2,13 +2,20 @@ import { useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
createTemplate,
deleteTemplate,
deployTemplate,
getCurrentUserProfile,
getTemplateById,
listProjects,
listTemplates,
type TemplateDetailEntity,
updateTemplate,
type TemplateEntity,
type TemplateWriteInput,
} from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { demoProjects, demoTemplates, demoTemplateDetails } from '@/lib/demo-data';
import {
Search,
Filter,
@@ -24,322 +31,13 @@ import {
Play,
ArrowRight,
Star,
Plus,
Pencil,
Trash2,
Upload,
X,
} from 'lucide-react';
const demoTemplates: TemplateEntity[] = [
{
id: 'tpl-react',
name: 'React Application',
description: 'Single-page frontend with Vite and static serving runtime.',
category: 'frontend',
logo: 'https://cdn.simpleicons.org/react',
configRaw: '{"runtime":"node"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-go',
name: 'Go API Service',
description: 'API-ready Go runtime with direct binary startup.',
category: 'web',
logo: 'https://cdn.simpleicons.org/go',
configRaw: '{"runtime":"go"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-postgres',
name: 'PostgreSQL Database',
description: 'Managed PostgreSQL service with credential setup variables.',
category: 'database',
logo: 'https://cdn.simpleicons.org/postgresql',
configRaw: '{"runtime":"postgres"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-mysql',
name: 'MySQL Database',
description: 'Managed MySQL service for transactional workloads.',
category: 'database',
logo: 'https://cdn.simpleicons.org/mysql',
configRaw: '{"runtime":"mysql"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-mariadb',
name: 'MariaDB Database',
description: 'Managed MariaDB service with MySQL compatibility.',
category: 'database',
logo: 'https://cdn.simpleicons.org/mariadb',
configRaw: '{"runtime":"mariadb"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-clickhouse',
name: 'ClickHouse Database',
description: 'Columnar analytics database template for high-speed queries.',
category: 'database',
logo: 'https://cdn.simpleicons.org/clickhouse',
configRaw: '{"runtime":"clickhouse"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-dragonfly',
name: 'Dragonfly Database',
description: 'Redis-compatible in-memory store powered by Dragonfly.',
category: 'database',
logo: 'https://cdn.simpleicons.org/redis',
configRaw: '{"runtime":"dragonfly"}',
variablesRaw: '[]',
isOfficial: true,
},
];
const demoTemplateDetails: Record<string, TemplateDetailEntity> = {
'tpl-react': {
template: demoTemplates[0],
config: {
type: 'web',
runtime: 'node',
buildCommand: 'npm install && npm run build',
startCommand: 'npx serve -s dist',
port: 3000,
healthCheck: '/health',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'VITE_API_URL',
label: 'API URL',
defaultValue: 'https://api.example.com',
required: true,
secret: false,
description: 'Public API endpoint for frontend calls',
},
],
},
'tpl-go': {
template: demoTemplates[1],
config: {
type: 'web',
runtime: 'go',
buildCommand: 'go build -o app .',
startCommand: './app',
port: 8080,
healthCheck: '/health',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'GO_ENV',
label: 'Go Environment',
defaultValue: 'production',
required: false,
secret: false,
description: 'Runtime environment value',
},
],
},
'tpl-postgres': {
template: demoTemplates[2],
config: {
type: 'database',
runtime: 'postgres',
buildCommand: '',
startCommand: '',
port: 5432,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'POSTGRES_USER',
label: 'Username',
defaultValue: 'postgres',
required: true,
secret: false,
description: 'Database user',
},
{
key: 'POSTGRES_PASSWORD',
label: 'Password',
defaultValue: '',
required: true,
secret: true,
description: 'Database password',
},
],
},
'tpl-mysql': {
template: demoTemplates[3],
config: {
type: 'database',
runtime: 'mysql',
buildCommand: '',
startCommand: '',
port: 3306,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'MYSQL_DATABASE',
label: 'Database Name',
defaultValue: 'app',
required: true,
secret: false,
description: 'Initial database to create',
},
{
key: 'MYSQL_USER',
label: 'Username',
defaultValue: 'app',
required: true,
secret: false,
description: 'Application DB user',
},
{
key: 'MYSQL_PASSWORD',
label: 'User Password',
defaultValue: '',
required: true,
secret: true,
description: 'Application DB password',
},
{
key: 'MYSQL_ROOT_PASSWORD',
label: 'Root Password',
defaultValue: '',
required: true,
secret: true,
description: 'Root account password',
},
],
},
'tpl-mariadb': {
template: demoTemplates[4],
config: {
type: 'database',
runtime: 'mariadb',
buildCommand: '',
startCommand: '',
port: 3306,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'MARIADB_DATABASE',
label: 'Database Name',
defaultValue: 'app',
required: true,
secret: false,
description: 'Initial database to create',
},
{
key: 'MARIADB_USER',
label: 'Username',
defaultValue: 'app',
required: true,
secret: false,
description: 'Application DB user',
},
{
key: 'MARIADB_PASSWORD',
label: 'User Password',
defaultValue: '',
required: true,
secret: true,
description: 'Application DB password',
},
{
key: 'MARIADB_ROOT_PASSWORD',
label: 'Root Password',
defaultValue: '',
required: true,
secret: true,
description: 'Root account password',
},
],
},
'tpl-clickhouse': {
template: demoTemplates[5],
config: {
type: 'database',
runtime: 'clickhouse',
buildCommand: '',
startCommand: '',
port: 8123,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'CLICKHOUSE_DB',
label: 'Database Name',
defaultValue: 'app',
required: false,
secret: false,
description: 'Default database name',
},
{
key: 'CLICKHOUSE_USER',
label: 'Username',
defaultValue: 'default',
required: false,
secret: false,
description: 'ClickHouse user',
},
{
key: 'CLICKHOUSE_PASSWORD',
label: 'Password',
defaultValue: '',
required: false,
secret: true,
description: 'ClickHouse password',
},
],
},
'tpl-dragonfly': {
template: demoTemplates[6],
config: {
type: 'database',
runtime: 'dragonfly',
buildCommand: '',
startCommand: '',
port: 6379,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'DRAGONFLY_PASSWORD',
label: 'Password',
defaultValue: '',
required: false,
secret: true,
description: 'Optional Redis-compatible password',
},
],
},
};
const demoProjects = [
{ id: 'project-demo', name: 'Demo Project' },
{ id: 'project-internal', name: 'Internal Tooling' },
];
function toServiceName(value: string): string {
const normalized = value
.toLowerCase()
@@ -354,6 +52,15 @@ function categoryLabel(category: string): string {
return category ? category[0].toUpperCase() + category.slice(1) : 'Uncategorized';
}
function safeTemplateRuntime(configRaw: string): string {
try {
const parsed = JSON.parse(configRaw || '{}') as { runtime?: string };
return parsed.runtime || 'n/a';
} catch {
return 'n/a';
}
}
function categoryIcon(category: string): typeof Box {
switch (category) {
case 'frontend':
@@ -369,14 +76,72 @@ function categoryIcon(category: string): typeof Box {
}
}
const EMPTY_TEMPLATE_JSON = JSON.stringify(
{
name: 'my-template',
description: 'What this template deploys.',
category: 'app',
logo: '',
config: {
type: 'web',
runtime: 'node',
build_command: '',
start_command: '',
port: 3000,
health_check: '/health',
environment: {},
},
variables: [
{
key: 'EXAMPLE_VAR',
label: 'Example variable',
default: '',
required: false,
secret: false,
description: 'Shown at deploy time',
},
],
},
null,
2,
);
export function TemplatesPage() {
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const isDemoMode = searchParams.get('demo') === '1';
const [searchParams, setSearchParams] = useSearchParams();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const profileQuery = useQuery({
queryKey: ['current-profile'],
queryFn: getCurrentUserProfile,
enabled: !isDemoMode && Boolean(sessionQuery.data),
retry: false,
});
const myUserId = profileQuery.data?.id ?? '';
const isAdmin = Boolean(profileQuery.data?.isAdmin);
const [editorOpen, setEditorOpen] = useState(false);
const [editorJson, setEditorJson] = useState(EMPTY_TEMPLATE_JSON);
const [editingTemplate, setEditingTemplate] = useState<TemplateEntity | null>(null);
const [editorIsPublic, setEditorIsPublic] = useState(false);
const [editorError, setEditorError] = useState<string | null>(null);
const [categoryFilter, setCategoryFilter] = useState('');
const [searchFilter, setSearchFilter] = useState('');
const [selectedTemplateIdState, setSelectedTemplateId] = useState<string | null>(null);
// Selection lives in the URL (?template=<id>) so catalog deep links and
// card clicks share one source of truth.
const templateParam = searchParams.get('template');
const selectTemplate = (id: string | null) => {
const next = new URLSearchParams(searchParams);
if (id) {
next.set('template', id);
} else {
next.delete('template');
}
setSearchParams(next, { replace: true });
};
const [deployProjectIdState, setDeployProjectId] = useState(searchParams.get('project') ?? '');
const [deployNameByTemplate, setDeployNameByTemplate] = useState<Record<string, string>>({});
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
@@ -397,7 +162,7 @@ export function TemplatesPage() {
const projectsQuery = useQuery({
queryKey: ['template-projects'],
enabled: !isDemoMode,
queryFn: listProjects,
queryFn: () => listProjects(),
});
const templates = useMemo(
@@ -434,11 +199,11 @@ export function TemplatesPage() {
if (filteredTemplates.length === 0) {
return null;
}
if (selectedTemplateIdState && filteredTemplates.some((template) => template.id === selectedTemplateIdState)) {
return selectedTemplateIdState;
if (templateParam && filteredTemplates.some((template) => template.id === templateParam)) {
return templateParam;
}
return filteredTemplates[0].id;
}, [filteredTemplates, selectedTemplateIdState]);
}, [filteredTemplates, templateParam]);
const templateDetailQuery = useQuery({
queryKey: ['template-detail-page', selectedTemplateId],
@@ -553,6 +318,112 @@ export function TemplatesPage() {
missingRequiredVariables.length > 0 ||
deployMutation.isPending;
const canManageTemplate = (template: TemplateEntity) =>
!template.isOfficial && Boolean(template.ownerId) && (template.ownerId === myUserId || isAdmin);
const saveTemplateMutation = useMutation({
mutationFn: (input: TemplateWriteInput) =>
editingTemplate ? updateTemplate(editingTemplate.id, input) : createTemplate(input),
onSuccess: (template) => {
queryClient.invalidateQueries({ queryKey: ['templates-page'] });
setEditorOpen(false);
setEditingTemplate(null);
setEditorError(null);
selectTemplate(template.id);
},
onError: (error) => {
setEditorError(error instanceof Error ? error.message : 'Failed to save template');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteTemplate(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['templates-page'] });
selectTemplate(null);
},
});
const openCreateEditor = () => {
setEditingTemplate(null);
setEditorJson(EMPTY_TEMPLATE_JSON);
setEditorIsPublic(false);
setEditorError(null);
setEditorOpen(true);
};
const openEditEditor = (template: TemplateEntity) => {
let config: unknown = {};
let variables: unknown = [];
try {
config = JSON.parse(template.configRaw || '{}');
} catch {
/* keep {} */
}
try {
variables = JSON.parse(template.variablesRaw || '[]');
} catch {
/* keep [] */
}
setEditingTemplate(template);
setEditorJson(
JSON.stringify(
{
name: template.name,
description: template.description,
category: template.category,
logo: template.logo,
config,
variables,
},
null,
2,
),
);
setEditorIsPublic(template.isPublic);
setEditorError(null);
setEditorOpen(true);
};
const submitEditor = () => {
let parsed: unknown;
try {
parsed = JSON.parse(editorJson);
} catch (err) {
setEditorError(`Invalid JSON: ${err instanceof Error ? err.message : 'parse error'}`);
return;
}
const body = parsed as Partial<TemplateWriteInput>;
if (!body || typeof body !== 'object' || !body.name || typeof body.config !== 'object' || body.config === null) {
setEditorError('Template JSON needs at least "name" and a "config" object.');
return;
}
saveTemplateMutation.mutate({
name: String(body.name),
description: body.description ?? '',
category: body.category ?? 'other',
logo: body.logo ?? '',
config: body.config as Record<string, unknown>,
variables: Array.isArray(body.variables) ? body.variables : [],
isPublic: editorIsPublic,
});
};
const uploadJsonFile = (file: File) => {
if (!file.name.toLowerCase().endsWith('.json') && file.type !== 'application/json') {
setEditorError('Only .json files are accepted.');
return;
}
file
.text()
.then((text) => {
JSON.parse(text);
setEditorJson(text);
setEditorError(null);
})
.catch(() => setEditorError('File is not valid JSON.'));
};
return (
<div className="min-h-screen">
{/* Header */}
@@ -563,9 +434,18 @@ export function TemplatesPage() {
<h1 className="v-title">Template Catalog<span className="v-cursor">_</span></h1>
<p className="text-sm text-[var(--text-secondary)]">Deploy services from pre-configured templates</p>
</div>
<div className="flex items-center gap-2 text-sm text-[var(--text-tertiary)]">
<div className="flex items-center gap-3 text-sm text-[var(--text-tertiary)]">
<Layers size={16} />
<span>{filteredTemplates.length} templates</span>
{signedIn && !isDemoMode ? (
<button
type="button"
onClick={openCreateEditor}
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 text-[12.5px] font-semibold text-[var(--accent-on)]"
>
<Plus size={14} /> New template
</button>
) : null}
</div>
</div>
</div>
@@ -661,7 +541,7 @@ export function TemplatesPage() {
return (
<button
key={template.id}
onClick={() => setSelectedTemplateId(template.id)}
onClick={() => selectTemplate(template.id)}
className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${
selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
@@ -685,12 +565,21 @@ export function TemplatesPage() {
<p className={`text-sm font-semibold tracking-tight ${selected ? 'text-[var(--accent-primary)]' : 'text-[var(--text-primary)]'}`}>
{template.name}
</p>
{template.isOfficial && (
{template.isOfficial ? (
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-[var(--warning-soft)] text-[var(--warning)] text-[10px] font-semibold">
<Star size={8} className="fill-[var(--warning)]" />
Official
</span>
)}
) : template.ownerId === myUserId ? (
<span className="px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] text-[10px] font-semibold">
Mine
</span>
) : null}
{!template.isOfficial && template.isPublic ? (
<span className="px-1.5 py-0.5 rounded bg-[var(--info-soft)] text-[var(--info)] text-[10px] font-semibold">
Public
</span>
) : null}
</div>
<div className="flex items-center gap-2 mt-1">
<span
@@ -700,7 +589,7 @@ export function TemplatesPage() {
{categoryLabel(template.category)}
</span>
<span className="text-[10px] text-[var(--text-tertiary)]">•</span>
<span className="text-[10px] text-[var(--text-tertiary)]">{template.configRaw ? JSON.parse(template.configRaw).runtime : 'n/a'}</span>
<span className="text-[10px] text-[var(--text-tertiary)]">{safeTemplateRuntime(template.configRaw)}</span>
</div>
<p className="text-xs text-[var(--text-tertiary)] mt-2 line-clamp-2 group-hover:text-[var(--text-secondary)] transition-colors">{template.description}</p>
</div>
@@ -766,6 +655,11 @@ export function TemplatesPage() {
Official
</span>
)}
{!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && (
<span className="px-2 py-0.5 rounded-full bg-[var(--info-soft)] text-[var(--info)] text-xs font-medium">
Public
</span>
)}
</div>
<p className="text-sm text-[var(--text-secondary)] mt-1">{selectedDetail.template.description}</p>
</div>
@@ -777,6 +671,25 @@ export function TemplatesPage() {
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]">
{selectedDetail.config.runtime || 'n/a'}
</span>
{canManageTemplate(selectedDetail.template) ? (
<>
<button
type="button"
onClick={() => openEditEditor(selectedDetail.template)}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-2.5 py-1 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
<Pencil size={11} /> Edit
</button>
<button
type="button"
onClick={() => deleteMutation.mutate(selectedDetail.template.id)}
disabled={deleteMutation.isPending}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-2.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
>
<Trash2 size={11} /> Delete
</button>
</>
) : null}
</div>
</div>
@@ -824,6 +737,20 @@ export function TemplatesPage() {
</div>
</div>
{!signedIn ? (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-5 text-center">
<p className="text-sm text-[var(--text-secondary)]">
Sign in to deploy this template into one of your projects.
</p>
<Link
to="/auth/sign-in"
className="mt-3 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12.5px] font-semibold text-[var(--accent-on)]"
>
Sign in <ArrowRight size={13} />
</Link>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
@@ -951,12 +878,94 @@ export function TemplatesPage() {
)}
</button>
</div>
</>
)}
</div>
</>
) : null}
</section>
</div>
</div>
{/* Template editor — JSON only */}
{editorOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
<div className="flex items-center justify-between border-b border-[var(--border-subtle)] px-5 py-4">
<div>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">
{editingTemplate ? `Edit ${editingTemplate.name}` : 'New template'}
</h2>
<p className="text-xs text-[var(--text-tertiary)]">
JSON definition — name, category, config and variables.
</p>
</div>
<div className="flex items-center gap-2">
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
<Upload size={12} /> Upload .json
<input
type="file"
accept=".json,application/json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) uploadJsonFile(file);
e.target.value = '';
}}
/>
</label>
<button
type="button"
onClick={() => setEditorOpen(false)}
className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
>
<X size={15} />
</button>
</div>
</div>
<textarea
value={editorJson}
onChange={(e) => setEditorJson(e.target.value)}
spellCheck={false}
className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/>
{editorError ? (
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
{editorError}
</div>
) : null}
<div className="flex items-center justify-between border-t border-[var(--border-subtle)] px-5 py-3.5">
<label className="inline-flex cursor-pointer items-center gap-2 text-[11px] text-[var(--text-secondary)]">
<input
type="checkbox"
checked={editorIsPublic}
onChange={(e) => setEditorIsPublic(e.target.checked)}
className="h-3.5 w-3.5 accent-[var(--accent-primary)]"
/>
List in the public catalog - anyone browsing this instance can see and deploy it.
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setEditorOpen(false)}
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
Cancel
</button>
<button
type="button"
onClick={submitEditor}
disabled={saveTemplateMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{saveTemplateMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
{editingTemplate ? 'Save changes' : 'Create template'}
</button>
</div>
</div>
</div>
</div>
) : null}
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,26 @@
import { useState } from 'react';
import { serviceIcon, serviceIconUrl, serviceTypeIcon, type ServiceIconInput } from './service-visuals';
export function ServiceIcon({
service,
size = 16,
}: {
service: ServiceIconInput;
size?: number;
}) {
const [failed, setFailed] = useState(false);
const brand = serviceIcon(service);
if (brand && !failed) {
return (
<img
src={serviceIconUrl(brand)}
width={size}
height={size}
alt=""
loading="lazy"
onError={() => setFailed(true)}
/>
);
}
return serviceTypeIcon(service.type ?? 'service', size);
}
@@ -0,0 +1,112 @@
import type { ServiceEntity } from '@/lib/api-client';
import type { ServiceVariable } from '../auto-connections';
import { inferAutoConnections } from '../auto-connections';
export const NODE_WIDTH = 240;
export const NODE_HEIGHT = 104;
// Multiples of the 26px canvas grid so laid-out nodes land on the lattice.
export const COL_GAP = 338;
export const ROW_GAP = 182;
type LinkEdge = { sourceServiceId: string; targetServiceId: string };
// Layered left-to-right layout: a service's depth is the longest chain of
// dependencies beneath it, so consumers sit left and datastores right.
// Unconnected services get their own trailing column separated by a gap.
export function computeLayeredLayout(
services: ServiceEntity[],
edges: LinkEdge[],
): Map<string, { x: number; y: number }> {
const positions = new Map<string, { x: number; y: number }>();
if (services.length === 0) {
return positions;
}
const ids = new Set(services.map((service) => service.id));
const outgoing = new Map<string, string[]>();
const connected = new Set<string>();
for (const edge of edges) {
if (!ids.has(edge.sourceServiceId) || !ids.has(edge.targetServiceId)) {
continue;
}
connected.add(edge.sourceServiceId);
connected.add(edge.targetServiceId);
const list = outgoing.get(edge.sourceServiceId) ?? [];
list.push(edge.targetServiceId);
outgoing.set(edge.sourceServiceId, list);
}
const depthMemo = new Map<string, number>();
const depthOf = (id: string, trail: Set<string>): number => {
const memoized = depthMemo.get(id);
if (memoized !== undefined) {
return memoized;
}
if (trail.has(id)) {
return 0; // cycle guard
}
trail.add(id);
let best = 0;
for (const target of outgoing.get(id) ?? []) {
best = Math.max(best, depthOf(target, trail) + 1);
}
trail.delete(id);
depthMemo.set(id, best);
return best;
};
const connectedServices = services
.filter((service) => connected.has(service.id))
.sort((a, b) => a.name.localeCompare(b.name));
const looseServices = services
.filter((service) => !connected.has(service.id))
.sort((a, b) => a.name.localeCompare(b.name));
const depthById = new Map(connectedServices.map((service) => [service.id, depthOf(service.id, new Set())]));
const maxDepth = Math.max(0, ...depthById.values());
// Column = maxDepth - depth so deeper consumers render further left.
const columns = new Map<number, ServiceEntity[]>();
for (const service of connectedServices) {
const col = maxDepth - (depthById.get(service.id) ?? 0);
const list = columns.get(col) ?? [];
list.push(service);
columns.set(col, list);
}
const connectedColCount = columns.size;
const maxRows = Math.max(
1,
...Array.from(columns.values()).map((list) => list.length),
looseServices.length,
);
const gridOffset = (value: number) => Math.round(value / 26) * 26;
for (const [col, list] of columns) {
const offsetY = gridOffset(((maxRows - list.length) * ROW_GAP) / 2);
list.forEach((service, row) => {
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
});
}
looseServices.forEach((service, row) => {
const col = connectedColCount > 0 ? connectedColCount : 0;
const offsetY = gridOffset(((maxRows - looseServices.length) * ROW_GAP) / 2);
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
});
return positions;
}
export function layoutFromVariables(
services: ServiceEntity[],
variablesByService: Record<string, ServiceVariable[]>,
): Map<string, { x: number; y: number }> {
const links = inferAutoConnections(services, variablesByService);
return computeLayeredLayout(
services,
links.map((link) => link.edge),
);
}
@@ -0,0 +1,415 @@
import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client';
import type { ServiceVariable } from '../auto-connections';
import { ServiceIcon } from './ServiceIcon';
import { serviceAccent } from './service-visuals';
import { formatRelative } from '@/lib/time';
import {
ArrowUpRight,
Copy,
Globe,
KeyRound,
Layers,
Link2,
Loader2,
Play,
Rocket,
Square,
Trash2,
X,
} from 'lucide-react';
import { useState } from 'react';
export type InspectorGroup = { id: string; title: string };
export type InspectorConnection = {
id: string;
outbound: boolean;
peer: string;
peerId: string;
reasons: string[];
};
export type InspectorActions = {
deploy: () => void;
start: () => void;
restart: () => void;
stop: () => void;
remove: () => void;
pending: boolean;
deployPending: boolean;
restartPending: boolean;
};
function statusLabel(status: string): string {
switch (status) {
case 'running':
return 'Online';
case 'deployed':
return 'Deployed';
case 'building':
return 'Building';
case 'deploying':
return 'Deploying';
case 'pending':
return 'Pending';
case 'failed':
return 'Failed';
case 'stopped':
return 'Stopped';
case 'rolling_back':
return 'Rolling back';
default:
return status;
}
}
function statusColor(status: string): string {
switch (status) {
case 'running':
case 'deployed':
return 'var(--success)';
case 'failed':
return 'var(--error)';
case 'building':
case 'deploying':
case 'pending':
case 'rolling_back':
return 'var(--warning)';
default:
return 'var(--text-tertiary)';
}
}
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{title}</p>
{action}
</div>
{children}
</div>
);
}
function SectionLink({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className="text-[10px] text-[var(--accent-primary)] hover:underline flex items-center gap-0.5"
>
{children}
<ArrowUpRight size={9} />
</button>
);
}
export function ServiceInspector({
service,
variables,
connections,
groups,
groupId,
readOnly,
actions,
onClose,
onOpenService,
onOpenSection,
onSelectPeer,
onAssignGroup,
onNewGroupFor,
}: {
service: ServiceEntity;
variables: ServiceVariable[];
connections: InspectorConnection[];
groups: InspectorGroup[];
groupId: string | undefined;
readOnly?: boolean;
actions: InspectorActions;
onClose: () => void;
onOpenService: (serviceId: string) => void;
onOpenSection: (serviceId: string, section: string) => void;
onSelectPeer: (serviceId: string) => void;
onAssignGroup: (serviceId: string, groupId: string | null) => void;
onNewGroupFor: (serviceId: string) => void;
}) {
const accent = serviceAccent(service);
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
const [copied, setCopied] = useState(false);
const copyAddress = () => {
const text = publicUrl ?? (service.port ? `${service.name}:${service.port}` : service.name);
void navigator.clipboard?.writeText(text).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
});
};
const configRows: [string, string | undefined][] = [
['Image', service.image],
['Command', service.command],
['Port', service.port ? String(service.port) : undefined],
['Domain', service.domain],
['Git repo', service.gitRepo],
['Branch', service.gitBranch],
['Build path', service.buildPath],
['CPU', service.cpu],
['Memory', service.memory],
['Restart', service.restartPolicy],
['Healthcheck', service.healthcheckPath],
];
const visibleConfig = configRows.filter(([, value]) => value);
return (
<aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[312px] panel-glass flex flex-col overflow-hidden">
<div className="flex items-center gap-3 px-4 pt-4 pb-3 border-b border-[var(--border-subtle)]">
<div
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0"
style={{ background: `${accent}1c`, color: accent }}
>
<ServiceIcon service={service} size={16} />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{service.name}</h3>
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span style={{ color: statusColor(service.status) }}>{statusLabel(service.status)}</span>
<span>· {service.type}</span>
{service.environment && service.environment !== 'production' && <span>· {service.environment}</span>}
</div>
</div>
<button
type="button"
onClick={onClose}
className="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]"
title="Close (Esc)"
>
<X size={14} />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{(publicUrl || service.port) && (
<Section title="Address">
<div className="flex items-center gap-1.5 group">
<Globe size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
{publicUrl ? (
<a
href={publicUrl}
target="_blank"
rel="noopener noreferrer"
className="mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
>
{publicUrl.replace(/^https?:\/\//, '')}
</a>
) : (
<span className="mono text-[11px] text-[var(--text-secondary)]">
{service.name}:{service.port}
</span>
)}
<button
type="button"
onClick={copyAddress}
className="ml-auto text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
title="Copy address"
>
{copied ? <span className="text-[10px] text-[var(--success)]">Copied</span> : <Copy size={11} />}
</button>
</div>
{publicUrl && service.port ? (
<p className="mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5 pl-4">
{service.name}:{service.port}
</p>
) : null}
</Section>
)}
<Section title="Group">
<div className="flex items-center gap-2">
<Layers size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
<select
value={groupId ?? ''}
onChange={(event) => {
const next = event.target.value;
if (next === '__new__') {
onNewGroupFor(service.id);
return;
}
onAssignGroup(service.id, next === '' ? null : next);
}}
className="nodrag flex-1 h-7 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-[11px] text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]"
>
<option value="">Ungrouped</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.title}
</option>
))}
<option value="__new__">+ New group…</option>
</select>
</div>
</Section>
<Section
title={`Variables (${variables.length})`}
action={
<SectionLink onClick={() => onOpenSection(service.id, 'variables')}>
Manage
</SectionLink>
}
>
{variables.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]">No variables yet.</p>
) : (
<div className="space-y-1">
{variables.slice(0, 6).map((variable) => (
<div
key={variable.key}
className="flex items-center gap-2 px-2 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/50"
>
<KeyRound size={9} className="text-[var(--text-tertiary)] flex-shrink-0" />
<span className="mono text-[10px] text-[var(--text-primary)] truncate font-medium">
{variable.key}
</span>
<span className="mono text-[10px] text-[var(--text-tertiary)] truncate ml-auto max-w-[130px]" title={variable.isSecret ? 'Secret' : variable.value}>
{variable.isSecret ? '••••••••' : variable.value}
</span>
</div>
))}
{variables.length > 6 && (
<p className="text-[10px] text-[var(--text-muted)] pl-1">+{variables.length - 6} more</p>
)}
</div>
)}
</Section>
<Section
title="Config"
action={
<SectionLink onClick={() => onOpenSection(service.id, 'config')}>
Edit
</SectionLink>
}
>
{visibleConfig.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]">No configuration set.</p>
) : (
<dl className="space-y-1">
{visibleConfig.map(([label, value]) => (
<div key={label} className="flex items-baseline gap-2">
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">{label}</dt>
<dd className="mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}>
{value}
</dd>
</div>
))}
{(service.replicas ?? 0) > 0 && (
<div className="flex items-baseline gap-2">
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">Replicas</dt>
<dd className="mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd>
</div>
)}
</dl>
)}
</Section>
<Section title={`Connections (${connections.length})`}>
{connections.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]">
No inferred connections. Reference another service via{' '}
<span className="mono">{'{{variable}}'}</span> placeholders in env vars.
</p>
) : (
<div className="space-y-1.5">
{connections.map((conn) => (
<button
key={conn.id}
type="button"
onClick={() => onSelectPeer(conn.peerId)}
className="w-full text-left px-2.5 py-2 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 hover:bg-[var(--surface-muted)] transition-colors"
>
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
<Link2 size={10} className="text-[var(--accent-primary)] flex-shrink-0" />
<span className="truncate font-medium">{conn.outbound ? `→ ${conn.peer}` : `← ${conn.peer}`}</span>
</div>
<p className="mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4">
{conn.reasons.join(' · ')}
</p>
</button>
))}
</div>
)}
</Section>
{service.updatedAt && (
<p className="text-[10px] text-[var(--text-muted)] pt-1">
Updated {formatRelative(service.updatedAt)}
</p>
)}
</div>
<div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2">
<button
type="button"
onClick={() => onOpenService(service.id)}
className="w-full flex items-center justify-center gap-2 h-9 rounded-[var(--radius-md)] text-[var(--accent-on)] text-[13px] font-medium"
style={{ background: 'var(--accent-primary)' }}
>
<ArrowUpRight size={14} />
Open service
</button>
<div className="grid grid-cols-3 gap-1.5">
<button
type="button"
disabled={actions.pending || readOnly}
onClick={actions.deploy}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
{actions.deployPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
Deploy
</button>
{service.status !== 'running' ? (
<button
type="button"
disabled={actions.pending || readOnly}
onClick={actions.start}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
<Play size={11} />
Start
</button>
) : (
<button
type="button"
disabled={actions.pending || readOnly}
onClick={actions.restart}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
{actions.restartPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
Restart
</button>
)}
<button
type="button"
disabled={actions.pending || readOnly || service.status !== 'running'}
onClick={actions.stop}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
<Square size={11} />
Stop
</button>
</div>
<button
type="button"
disabled={actions.pending || readOnly}
onClick={actions.remove}
className="w-full flex items-center justify-center gap-1.5 h-8 rounded-[var(--radius-sm)] text-[11px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-40"
>
<Trash2 size={11} />
Delete service
</button>
</div>
</aside>
);
}
@@ -1,8 +1,10 @@
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client';
import { Box, Globe, Database, Terminal, ExternalLink, Copy, Layers } from 'lucide-react';
import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react';
import { useState } from 'react';
import { serviceIcon, serviceAccent } from './service-visuals';
import { ServiceIcon } from './ServiceIcon';
export type ServiceNodeData = {
service: ServiceEntity;
@@ -12,37 +14,13 @@ export type ServiceNodeData = {
export type GroupNodeData = {
title: string;
onRename?: (groupId: string, title: string) => void;
renameNonce?: number;
};
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
function renderServiceTypeIcon(type: string, size = 18) {
switch (type) {
case 'web':
return <Globe size={size} />;
case 'database':
return <Database size={size} />;
case 'worker':
return <Terminal size={size} />;
default:
return <Box size={size} />;
}
}
function serviceTypeColor(type: string): string {
switch (type) {
case 'web':
return '#7ab8ff';
case 'database':
return '#b4e34a';
case 'worker':
return 'var(--accent-primary)';
default:
return '#9295a4';
}
}
function statusLabel(status: string): string {
switch (status) {
case 'running':
@@ -72,27 +50,30 @@ function statusColor(status: string): string {
}
}
const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']);
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
const { service, selected } = data;
const typeColor = serviceTypeColor(service.type);
const brand = serviceIcon(service);
const typeColor = brand?.color ?? serviceAccent(service);
const replicas = service.replicas ?? 0;
const stateColor = statusColor(service.status);
const isTransient = TRANSIENT_STATUSES.has(service.status);
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
const internalAddr = service.port ? `${service.name}:${service.port}` : service.name;
return (
<div
className={`rounded-[var(--radius-lg)] border transition-all duration-200 group relative overflow-hidden ${
className={`w-full rounded-[var(--radius-lg)] border transition-[border-color,box-shadow,transform] duration-150 group relative overflow-hidden ${
selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-lg'
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-[0_12px_32px_rgba(0,0,0,0.45)] hover:-translate-y-px'
}`}
style={{ minWidth: 220, maxWidth: 280 }}
>
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
style={{ background: `${typeColor}10` }}
style={{ background: `${typeColor}08` }}
/>
<Handle id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
@@ -105,22 +86,26 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
style={{ background: stateColor }}
/>
<div className="relative p-4">
<div className="flex items-start gap-3 mb-3">
<div className="relative px-3.5 pt-3.5 pb-3">
<div className="flex items-center gap-3 mb-2.5">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${
selected ? 'ring-2 ring-white/20' : ''
}`}
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
style={{
background: selected ? typeColor : `${typeColor}20`,
color: selected ? 'white' : typeColor,
background: selected && !brand ? typeColor : `${typeColor}1c`,
color: selected && !brand ? 'var(--bg-void)' : typeColor,
}}
>
{renderServiceTypeIcon(service.type, 18)}
{isTransient ? (
<Loader2 size={16} className="animate-spin" />
) : (
<ServiceIcon service={service} size={17} />
)}
</div>
<div className="min-w-0 flex-1 pt-0.5">
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4>
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5">
<div className="min-w-0 flex-1">
<h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
{service.name}
</h4>
<p className="text-[10.5px] text-[var(--text-tertiary)] truncate mt-0.5">
{service.type}
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
</p>
@@ -133,7 +118,7 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
)}
</div>
<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 group/addr">
<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
@@ -141,12 +126,12 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-[10px] text-[var(--accent-primary)] truncate mono hover:underline"
className="text-[10.5px] text-[var(--accent-primary)] truncate mono hover:underline"
>
{publicUrl.replace(/^https?:\/\//, '')}
</a>
) : (
<span className="text-[10px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
<span className="text-[10.5px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
{internalAddr}
</span>
)}
@@ -164,14 +149,14 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span className="text-[11px] font-semibold tracking-wide uppercase" style={{ color: stateColor }}>
<span className="text-[10px] font-semibold tracking-[0.08em] uppercase" style={{ color: stateColor }}>
{statusLabel(service.status)}
</span>
</div>
{service.image && (
<span className="text-[10px] text-[var(--text-tertiary)] truncate mono max-w-[120px]" title={service.image}>
<span className="text-[9.5px] text-[var(--text-muted)] truncate mono max-w-[110px]" title={service.image}>
{service.image}
</span>
)}
@@ -181,41 +166,78 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
);
}
export function GroupNode({ data }: NodeProps<GroupNodeType>) {
export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
const [isHovered, setIsHovered] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(data.title);
// Context-menu rename requests bump the nonce; enter edit mode when it
// changes. Render-phase state adjustment — the React-sanctioned pattern.
const [seenRenameNonce, setSeenRenameNonce] = useState(data.renameNonce ?? 0);
const incomingNonce = data.renameNonce ?? 0;
if (incomingNonce > seenRenameNonce) {
setSeenRenameNonce(incomingNonce);
setDraft(data.title);
setEditing(true);
}
const commit = () => {
const next = draft.trim() || data.title;
setEditing(false);
if (next !== data.title) {
data.onRename?.(id, next);
}
};
return (
<div
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden"
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative"
style={{
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)',
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30',
borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)',
background: isHovered ? 'var(--accent-primary-soft)' : 'rgba(255,255,255,0.02)',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<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,
}}
/>
<div className="relative p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
style={{
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
}}
>
<Box size={14} />
</div>
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3>
<div className="flex items-center gap-2.5">
<div
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
style={{
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
}}
>
<Box size={14} />
</div>
{editing ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') {
setDraft(data.title);
setEditing(false);
}
}}
className="nodrag h-7 px-2 rounded-[var(--radius-sm)] border border-[var(--border-default)] bg-[var(--surface-card)] text-sm font-semibold text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]"
/>
) : (
<h3
className="text-sm font-semibold text-[var(--text-primary)] tracking-tight"
onDoubleClick={(e) => {
e.stopPropagation();
setDraft(data.title);
setEditing(true);
}}
title="Double-click to rename"
>
{data.title}
</h3>
)}
</div>
<p
@@ -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');
}
+5 -2
View File
@@ -44,14 +44,17 @@ export const DEFAULT_VIEWPORT: CanvasViewport = {
zoom: 1,
};
export function createDefaultCanvasMetadata(services: ServiceEntity[]): ProjectCanvasMetadata {
export function createDefaultCanvasMetadata(
services: ServiceEntity[],
layout?: Map<string, CanvasPoint>,
): ProjectCanvasMetadata {
const nodes = services.map((service, index) => {
const col = index % 3;
const row = Math.floor(index / 3);
return {
serviceId: service.id,
position: {
position: layout?.get(service.id) ?? {
x: 70 + col * 260,
y: 80 + row * 170,
},
@@ -19,6 +19,8 @@ import {
type CreateServiceInput,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServicesByProject, getDemoVariablesByProject } from '@/lib/demo-data';
import { useDemoMode } from '@/lib/demo-mode';
import { useAuthSession } from '@/lib/use-auth-session';
import { formatDate, formatRelative } from '@/lib/time';
import type { ServiceVariable } from '../auto-connections';
import { ProjectCanvas } from '../canvas/ProjectCanvas';
@@ -46,10 +48,10 @@ import {
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutGrid }> = [
const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutGrid; authOnly?: boolean }> = [
{ key: 'canvas', label: 'Canvas', icon: LayoutGrid },
{ key: 'observability', label: 'Observability', icon: Activity },
{ key: 'logs', label: 'Logs', icon: FileText },
{ key: 'logs', label: 'Logs', icon: FileText, authOnly: true },
{ key: 'settings', label: 'Settings', icon: Settings },
];
@@ -345,7 +347,7 @@ function ServiceCreateDialog(props: {
className={`w-full flex items-center justify-between px-3 py-2 rounded-[var(--radius-sm)] text-left text-sm transition-colors ${
repoFullName === repo.full_name
? 'bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-hover)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'
}`}
>
<span className="mono text-xs truncate">{repo.full_name}</span>
@@ -465,10 +467,20 @@ export function ProjectWorkspacePage() {
const toast = useToast();
const activeView = (searchParams.get('view') as WorkspaceView | null) ?? 'canvas';
const isDemoMode = searchParams.get('demo') === '1';
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const [createOpen, setCreateOpen] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const openAddService = () => {
if (!signedIn) {
navigate('/auth/sign-in');
return;
}
setCreateOpen(true);
};
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
@@ -569,7 +581,8 @@ export function ProjectWorkspacePage() {
return Object.fromEntries(entries) as Record<string, ServiceVariable[]>;
},
enabled: !isDemoMode && services.length > 0,
enabled: !isDemoMode && signedIn && services.length > 0,
retry: false,
});
const variablesByService = isDemoMode ? getDemoVariablesByProject(projectId) : variablesQuery.data ?? {};
@@ -599,7 +612,8 @@ export function ProjectWorkspacePage() {
})
.slice(0, 200);
},
enabled: !isDemoMode && activeView === 'logs' && services.length > 0,
enabled: !isDemoMode && signedIn && activeView === 'logs' && services.length > 0,
retry: false,
});
const runningServices = useMemo(() => services.filter((service) => service.status === 'running').length, [services]);
@@ -607,6 +621,8 @@ export function ProjectWorkspacePage() {
isDemoMode
? `/projects/${projectId}/services/${serviceId}?demo=1`
: `/projects/${projectId}/services/${serviceId}`;
const serviceSectionHref = (serviceId: string, section: string) =>
`${serviceHref(serviceId)}${isDemoMode ? '&' : '?'}section=${section}`;
if (!isDemoMode && projectQuery.isLoading) {
return (
@@ -631,7 +647,7 @@ export function ProjectWorkspacePage() {
This project may have been deleted or you don't have access.
</p>
<button
onClick={() => navigate('/projects')}
onClick={() => navigate(isDemoMode ? '/projects?demo=1' : '/projects')}
className="mt-6 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
Back to Projects
@@ -641,131 +657,116 @@ export function ProjectWorkspacePage() {
);
}
const canvasView = activeView === 'canvas';
const projectsHref = isDemoMode ? '/projects?demo=1' : '/projects';
return (
<div className="min-h-screen">
{/* Header */}
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
<div className="w-full px-8 py-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="flex h-dvh min-h-0 flex-col bg-[var(--bg-void)]">
{/* Slim workspace bar — tabs + project meta, canvas fills everything below */}
<div className="flex h-12 shrink-0 items-center gap-3 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 px-4 backdrop-blur-sm">
<button
onClick={() => navigate(projectsHref)}
className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors shrink-0"
>
<ArrowLeft size={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
onClick={() => navigate('/projects')}
className="flex items-center gap-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
key={item.key}
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} />
<span>Projects</span>
<Icon size={13} />
<span className="hidden md:inline">{item.label}</span>
</button>
<div className="w-px h-5 bg-[var(--border-subtle)]" />
<div>
<h1 className="v-title">{project.name}<span className="v-cursor">_</span></h1>
<p className="text-sm text-[var(--text-secondary)]">{project.description || 'No description'}</p>
</div>
</div>
);
})}
</nav>
<div className="flex items-center gap-6">
{/* Stats */}
<div className="hidden md:flex items-center gap-6">
<div className="text-center">
<p className="text-2xl font-semibold text-[var(--text-primary)]">{services.length}</p>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Services</p>
</div>
<div className="text-center">
<p className="text-2xl font-semibold text-[var(--success)]">{runningServices}</p>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Running</p>
</div>
<div className="w-px h-8 bg-[var(--border-subtle)]" />
<LiveIndicator isLive={runningServices > 0} />
<div className="w-px h-8 bg-[var(--border-subtle)]" />
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
<Clock size={12} />
<span>{formatRelative(project.updatedAt)}</span>
</div>
</div>
{/* Search / Command Palette */}
<button
onClick={() => setCommandPaletteOpen(true)}
className="hidden md:flex items-center gap-2 h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-sm hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
>
<Search size={14} />
<span>Search...</span>
<kbd className="ml-2 px-1.5 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
</button>
{/* Add Service Button */}
{!isDemoMode && (
<button
onClick={() => setCreateOpen(true)}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg hover:shadow-xl transition-all"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={16} />
<span className="hidden sm:inline">Add Service</span>
</button>
)}
</div>
<div className="ml-auto flex items-center gap-2 shrink-0">
<div className="hidden xl:block">
<LiveIndicator isLive={runningServices > 0} />
</div>
<span className="hidden xl:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
<Clock size={11} />
{formatRelative(project.updatedAt)}
</span>
<button
onClick={() => setCommandPaletteOpen(true)}
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-xs hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
>
<Search size={12} />
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
</button>
{!isDemoMode && signedIn && (
<button
onClick={openAddService}
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={14} />
<span className="hidden sm:inline">Add Service</span>
</button>
)}
</div>
</div>
{/* Demo Mode Banner */}
{isDemoMode && (
<div className="w-full px-8 py-4">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active — using sample data for preview</span>
{/* Content */}
{canvasView ? (
<div className="relative flex-1 min-h-0">
<ProjectCanvas
projectId={project.id}
services={services}
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>
)}
{/* 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 */}
)}
<div className="w-full px-8 py-6">
<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' && (
<div className="space-y-6">
{/* Health Summary */}
@@ -952,6 +953,7 @@ export function ProjectWorkspacePage() {
</section>
</div>
</div>
)}
{/* Create Service Dialog */}
{!isDemoMode && (
@@ -973,6 +975,10 @@ export function ProjectWorkspacePage() {
open={commandPaletteOpen}
onClose={() => setCommandPaletteOpen(false)}
onAddService={(type) => {
if (!signedIn) {
navigate('/auth/sign-in');
return;
}
setCreateOpen(true);
toast.showToast(`Creating ${type} service...`, 'info');
}}
+34 -12
View File
@@ -151,20 +151,36 @@ export function canvasStorageKey(projectId: string): string {
return `containr.canvas.v1.${projectId}`;
}
function canvasStorage(): Storage | null {
try {
return typeof window === 'undefined' ? null : window.localStorage;
} catch {
return null;
}
}
export function hasCanvasMetadata(projectId: string): boolean {
return canvasStorage()?.getItem(canvasStorageKey(projectId)) != null;
}
export function clearCanvasMetadata(projectId: string): void {
localStorage.removeItem(canvasStorageKey(projectId));
canvasStorage()?.removeItem(canvasStorageKey(projectId));
}
export function saveCanvasMetadata(projectId: string, metadata: ProjectCanvasMetadata): void {
localStorage.setItem(canvasStorageKey(projectId), JSON.stringify(metadata));
canvasStorage()?.setItem(canvasStorageKey(projectId), JSON.stringify(metadata));
}
export function loadCanvasMetadata(projectId: string, services: ServiceEntity[]): ProjectCanvasMetadata {
export function loadCanvasMetadata(
projectId: string,
services: ServiceEntity[],
layout?: Map<string, { x: number; y: number }>,
): ProjectCanvasMetadata {
const key = canvasStorageKey(projectId);
const fallback = createDefaultCanvasMetadata(services);
const fallback = createDefaultCanvasMetadata(services, layout);
const serviceIds = new Set(services.map((service) => service.id));
const raw = localStorage.getItem(key);
const raw = canvasStorage()?.getItem(key);
if (!raw) {
saveCanvasMetadata(projectId, fallback);
return fallback;
@@ -183,7 +199,14 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[])
const nodeMap = new Map(parsed.nodes.map((node) => [node.serviceId, node]));
const normalizedNodes: CanvasNodeLayout[] = [];
for (const [index, service] of services.entries()) {
// Services added after the layout was saved park in a trailing column to
// the right of everything placed so far, instead of overlapping nodes the
// user already arranged.
const placedXs = parsed.nodes.map((node) => node.position.x);
let parkedRow = 0;
const parkedX = placedXs.length > 0 ? Math.max(...placedXs) + 320 : 70;
for (const service of services) {
const existing = nodeMap.get(service.id);
if (existing) {
normalizedNodes.push({
@@ -194,15 +217,14 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[])
continue;
}
const col = index % 3;
const row = Math.floor(index / 3);
const parked = layout?.get(service.id);
normalizedNodes.push({
serviceId: service.id,
position: {
x: 70 + col * 260,
y: 80 + row * 170,
},
position: parked ?? { x: parkedX, y: 80 + parkedRow * 160 },
});
if (!parked) {
parkedRow += 1;
}
}
const seenEdgeIds = new Set<string>();
+312 -2
View File
@@ -1441,6 +1441,79 @@ export interface paths {
};
};
put?: never;
/**
* Create a user template
* @description Create a user-owned template (JSON definition). Requires authentication; the caller becomes the template owner.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TemplateWriteRequest"];
};
};
responses: {
/** @description Template created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
template?: components["schemas"]["ServiceTemplate"];
};
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/overview": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Platform overview
* @description Admin-only aggregate statistics plus the pending project approval queue.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Platform overview */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
stats?: {
[key: string]: number;
};
pending_projects?: components["schemas"]["Project"][];
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
@@ -1448,6 +1521,146 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/users": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List users
* @description Admin-only listing of all platform users.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Users retrieved */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
users?: components["schemas"]["AdminUser"][];
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/users/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Set user admin flag
* @description Grant or revoke platform admin. Admins cannot revoke their own flag.
*/
patch: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
is_admin: boolean;
};
};
};
responses: {
/** @description User updated */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
message?: string;
};
};
};
};
};
trace?: never;
};
"/admin/projects/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Set project approval
* @description Approve or unapprove a project for public listing.
*/
patch: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
is_approved: boolean;
};
};
};
responses: {
/** @description Project updated */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
project?: components["schemas"]["Project"];
message?: string;
};
};
};
};
};
trace?: never;
};
"/templates/{id}": {
parameters: {
query?: never;
@@ -1482,9 +1695,69 @@ export interface paths {
};
};
};
put?: never;
/**
* Update a user template
* @description Update a user-created template. Official templates cannot be modified; only the owner or an admin may update.
*/
put: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Template ID */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TemplateWriteRequest"];
};
};
responses: {
/** @description Template updated */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
template?: components["schemas"]["ServiceTemplate"];
};
};
};
};
};
post?: never;
delete?: never;
/**
* Delete a user template
* @description Delete a user-created template. Official templates cannot be deleted; only the owner or an admin may delete.
*/
delete: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Template ID */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Template deleted */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
message?: string;
};
};
};
};
};
options?: never;
head?: never;
patch?: never;
@@ -7095,6 +7368,15 @@ export interface components {
/** @description Optional avatar image URL */
avatar_url?: string;
};
AdminUser: {
id?: string;
/** Format: email */
email?: string;
name?: string;
avatar_url?: string;
is_admin?: boolean;
created_at?: string;
};
Project: {
/** @description Project ID */
id?: string;
@@ -7104,6 +7386,8 @@ export interface components {
description?: string;
/** @description Project owner ID */
owner_id?: string;
/** @description Whether the project is approved for public listing. New projects default to false and appear publicly only after admin approval. */
is_approved?: boolean;
/**
* Format: date-time
* @description Project creation timestamp
@@ -7520,6 +7804,10 @@ export interface components {
variables?: string;
/** @description Whether template is an official preset */
is_official?: boolean;
/** @description Owning user ID for user-created templates; null for official presets */
owner_id?: string | null;
/** @description Whether a user-created template is published to the public catalog */
is_public?: boolean;
/**
* Format: date-time
* @description Creation timestamp
@@ -7531,6 +7819,22 @@ export interface components {
*/
updated_at?: string;
};
TemplateWriteRequest: {
/** @description Template display name */
name: string;
/** @description Template description */
description?: string;
/** @description Template category (web, database, cache, queue, monitoring, storage, app, other) */
category?: string;
/** @description Template logo URL */
logo?: string;
/** @description Template config object (type, runtime, commands, port, environment) */
config: Record<string, never>;
/** @description Configurable variables exposed at deploy time */
variables?: components["schemas"]["TemplateVariable"][];
/** @description Publish this template to the public catalog (visible anonymously) */
is_public?: boolean;
};
TemplateConfig: {
/** @description Service type for created service */
type?: string;
@@ -7995,7 +8299,13 @@ export interface components {
images?: number;
driver?: string;
server?: string;
os?: string;
};
/**
* @description What the telemetry measures — `host` for a native daemon, `docker-desktop-vm` when Docker runs inside a Desktop VM.
* @enum {string}
*/
scope?: "host" | "docker-desktop-vm";
/** Format: date-time */
collected_at?: string;
};
@@ -0,0 +1,81 @@
/**
* Auto-generated by scripts/generate-docs-snapshot.mjs.
* Bundled copy of the repository docs tree — the offline fallback for the
* docs browser. Regenerate with `npm run generate:docs`.
*/
export type BundledDoc = {
path: string;
section: string;
title: string;
markdown: string;
};
export const bundledDocs: BundledDoc[] = [
{
"path": "developers/onboarding.md",
"section": "developers",
"title": "Developer Onboarding Guide",
"markdown": "# Developer Onboarding Guide\n\nWelcome to the Containr development team! This guide will help you get set up and contribute effectively to the project.\n\n## Table of Contents\n\n- [Prerequisites](#prerequisites)\n- [Development Setup](#development-setup)\n- [Project Structure](#project-structure)\n- [Development Workflow](#development-workflow)\n- [Testing](#testing)\n- [Code Style](#code-style)\n- [Debugging](#debugging)\n- [Contributing](#contributing)\n- [Resources](#resources)\n\n## Prerequisites\n\nBefore you begin, ensure you have the following installed:\n\n### Required Tools\n- **Go 1.24+** - Backend development\n- **Node.js 18+** - Frontend development\n- **Docker & Docker Compose** - Container development\n- **Git** - Version control\n- **Make** - Build automation\n\n### Recommended Tools\n- **VS Code** - Code editor with extensions\n- **Postman** - API testing\n- **Docker Desktop** - Container management\n- **PostgreSQL Client** - Database management\n\n### VS Code Extensions\n```json\n{\n \"recommendations\": [\n \"golang.go\",\n \"bradlc.vscode-tailwindcss\",\n \"esbenp.prettier-vscode\",\n \"ms-vscode.vscode-typescript-next\",\n \"ms-vscode-remote.remote-containers\",\n \"ms-kubernetes-tools.vscode-kubernetes-tools\"\n ]\n}\n```\n\n## Development Setup\n\n### 1. Clone the Repository\n\n```bash\ngit clone https://github.com/your-org/containr.git\ncd containr\n```\n\n### 2. Environment Setup\n\n```bash\n# Copy environment template\ncp .env.example .env\n\n# Edit environment variables\nnano .env\n```\n\nKey environment variables:\n```bash\n# Development settings\nDOMAIN=localhost\nENVIRONMENT=development\n\n# Database\nPOSTGRES_USER=containr\nPOSTGRES_PASSWORD=containr\nPOSTGRES_DB=containr\n\n# Redis\nREDIS_PASSWORD=containr\n\n# JWT\nJWT_SECRET=your-super-secret-jwt-key-here\n\n# Proxmox (optional)\nPROXMOX_BASE_URL=https://your-proxmox:8006\nPROXMOX_USERNAME=root@pam\nPROXMOX_PASSWORD=your-proxmox-password\n```\n\n### 3. Initialize Development Environment\n\n```bash\n# Initialize all services\nmake init\n\n# Start development mode\nmake dev\n```\n\nThis will start:\n- **Frontend**: http://localhost (React/Vite)\n- **API**: http://api.localhost (Go backend)\n- **Traefik Dashboard**: http://localhost:8080\n- **Database**: PostgreSQL on port 5432\n- **Redis**: Cache on port 6379\n\n### 4. Verify Setup\n\n```bash\n# Check service status\nmake status\n\n# View logs\nmake logs\n\n# Test API\ncurl http://api.localhost/health\n```\n\n## Project Structure\n\n```\ncontainr/\n├── cmd/ # Application entry points\n│ ├── server/ # Main server application\n│ └── cli/ # CLI tool\n├── internal/ # Private application code\n│ ├── api/ # HTTP handlers and routes\n│ ├── build/ # Build system (Nixpacks, Docker)\n│ ├── cli/ # CLI commands and utilities\n│ ├── config/ # Configuration management\n│ ├── database/ # Database operations\n│ ├── deployment/ # Deployment engine\n│ ├── docker/ # Docker client wrapper\n│ ├── metrics/ # Metrics collection\n│ ├── middleware/ # HTTP middleware\n│ ├── proxmox/ # Proxmox integration\n│ ├── scaling/ # Auto-scaling engine\n│ └── security/ # Security scanning\n├── migrations_goose/ # Goose-managed database migrations\n├── pkg/ # Public library code\n├── scripts/ # Build and utility scripts\n├── src/ # Frontend source code\n│ ├── components/ # React components\n│ ├── hooks/ # Custom React hooks\n│ ├── contexts/ # React contexts\n│ └── utils/ # Frontend utilities\n├── docs/ # Documentation\n├── tests/ # Test files\n└── docker-compose.yml # Development environment\n```\n\n## Development Workflow\n\n### 1. Create a Feature Branch\n\n```bash\ngit checkout -b feature/your-feature-name\n```\n\n### 2. Make Your Changes\n\n#### Backend Changes\n- Add new API endpoints in `internal/api/`\n- Update database schema in `migrations_goose/` (goose format: `-- +goose Up/Down`)\n- Add tests in `tests/`\n- Update documentation\n\n#### Frontend Changes\n- Create components in `src/components/`\n- Add hooks in `src/hooks/`\n- Update routing in `src/App.tsx`\n- Follow existing patterns\n\n### 3. Test Your Changes\n\n```bash\n# Run backend tests\ngo test ./...\n\n# Run frontend tests\ncd src && npm test\n\n# Test full integration\nmake up\n```\n\n### 4. Commit Your Changes\n\n```bash\n# Stage changes\ngit add .\n\n# Commit with conventional message\ngit commit -m \"feat: add user authentication\"\n\n# Push to your fork\ngit push origin feature/your-feature-name\Line truncated
},
{
"path": "guides/APP_DEPLOY_GUIDE.md",
"section": "guides",
"title": "🎮 Docker Template Manager - In-App Deployment System",
"markdown": "# 🎮 Docker Template Manager - In-App Deployment System\n\n## 🚀 **Fully Integrated App-Based Deployment**\n\nI've created a **complete in-app deployment system** that runs entirely within your web application - no terminal needed! \n\n### 📱 **What You Get**\n\n#### **🎮 Visual Template Manager**\n- **Grid view** of all 20 Docker templates\n- **One-click deployment** with visual feedback\n- **Real-time progress** tracking\n- **Live deployment logs**\n- **Status indicators** (idle, deploying, running, stopped)\n- **Access URLs** with clickable links\n\n#### **📊 Dashboard Features**\n- **Template cards** with icons and descriptions\n- **Difficulty badges** (Easy, Medium, Hard)\n- **Category filtering** (Analytics, Media, Storage, etc.)\n- **Deployment statistics** (total, deployed, running)\n- **Success rate** tracking\n\n#### **🎯 Interactive Controls**\n- **Deploy button** - One-click deployment\n- **Stop button** - Stop running services\n- **Remove button** - Clean up deployments\n- **Deploy All** - Deploy all easy templates\n- **Filter tabs** - View by category or status\n\n### 🖥️ **How It Works in Your App**\n\n#### **1. Visual Template Selection**\n```typescript\n// Users see this in the app:\n📊 Glance Dashboard [Deploy]\n📈 Umami Analytics [Deploy] \n📝 Memos [Deploy]\n🔍 MeiliSearch [Deploy]\n🎬 Plex [Deploy]\n🎥 Jellyfin [Deploy]\n☁️ Nextcloud [Deploy]\n```\n\n#### **2. One-Click Deployment**\n- **Click \"Deploy\"** → Automatic deployment starts\n- **Progress bar** shows deployment progress\n- **Live logs** show what's happening\n- **Status changes** from idle → deploying → running\n- **Access URLs** appear when complete\n\n#### **3. Management Interface**\n- **View details** of any template\n- **Monitor deployment** in real-time\n- **Stop/start** services as needed\n- **Access URLs** with clickable links\n- **View logs** for troubleshooting\n\n### 🎨 **User Experience**\n\n#### **📱 Mobile-Friendly**\n- **Responsive design** works on all devices\n- **Touch-friendly** buttons and controls\n- **Scrollable** template grid\n- **Collapsible** details panel\n\n#### **🌐 Browser Integration**\n- **Click URLs** to open services in new tabs\n- **Real-time updates** without page refresh\n- **Smooth animations** and transitions\n- **Status indicators** with icons\n\n#### **📊 Visual Feedback**\n```typescript\n// Status indicators users see:\n🟢 Running (green checkmark)\n🔵 Deploying (blue pulsing circle)\n🔴 Error (red alert)\n⚪ Stopped (gray square)\n⭕ Idle (gray circle)\n```\n\n### 🔧 **Technical Implementation**\n\n#### **React Component Structure**\n```typescript\nsrc/components/\n├── DockerTemplateManagerApp.tsx # Main component\n├── DockerTemplateManager.tsx # Full component (20 templates)\n└── AppDocker.tsx # App wrapper\n```\n\n#### **State Management**\n```typescript\n// Real-time state:\n- templates: Template[] // All template data\n- selectedTemplate: Template // Currently selected\n- deploymentLogs: Logs[] // Deployment logs\n- activeTab: string // Filter tab\n```\n\n#### **Deployment Simulation**\n```typescript\n// Automatic deployment steps:\n1. Check dependencies\n2. Create deployment directory\n3. Extract docker-compose.yml\n4. Create environment variables\n5. Generate secrets\n6. Pull Docker images\n7. Start services\n8. Show access URLs\n```\n\n### 🎯 **How to Use It**\n\n#### **1. Add to Your App**\n```typescript\n// In your main App.tsx\nimport DockerTemplateManager from './components/DockerTemplateManagerApp';\n\nfunction App() {\n return (\n <div className=\"min-h-screen bg-gray-50\">\n <DockerTemplateManager />\n </div>\n );\n}\n```\n\n#### **2. Start the Development Server**\n```bash\nnpm start\n# or\nyarn start\n```\n\n#### **3. Open in Browser**\n```\nhttp://localhost:3000\n```\n\n#### **4. Deploy Templates**\n- **Browse** the template grid\n- **Click** any template to see details\n- **Click \"Deploy\"** to start deployment\n- **Watch** the progress in real-time\n- **Access** your service via the provided URLs\n\n### 📊 **Template Categories**\n\n#### **🟢 Easy Templates** (1-2 min)\n- 📊 Glance Dashboard\n- 📈 Umami Analytics \n- 📝 Memos\n- 🔍 MeiliSearch\n- 📊 Uptime Kuma\n\n#### **🟡 Medium Templates** (3-5 min)\n- 🎬 Plex\n- 🎥 Jellyfin\n- 🛡️ Vaultwarden\n- 🌐 Traefik\n- 🚪 Pi-hole\n- 📁 Cloudreve\n- 🐙 Gitea\n- 🔄 n8n\n- 📊 Grafana\n\n#### **🔴 Hard Templates** (5-10 min)\n- ☁️ Nextcloud\n- 🏠 Home Assistant\n- 🦣 Mastodon\n- 📸 Immich\n- 🗄️ Supabase\n- 🔧 Appwrite\n\n### 🚀 **Features**\n\n#### **🎮 Interactive Elements**\n- **Template cards** with hover effects\n- **Progress bars** for deployment status\n- **Status badges** with colors\n- **Clickable URLs** that opeLine truncated
},
{
"path": "guides/AUTOSCALING.md",
"section": "guides",
"title": "Autoscaling",
"markdown": "# Autoscaling\n\nContainr has a built-in autoscaler that adjusts service replica counts against\nCPU/memory targets. Policies and scale events are held in backend memory —\nthey do not survive a backend restart (tracked on the roadmap).\n\n## Per-service policy\n\nOpen a service → **Scaling** section. Set:\n\n| Field | Meaning |\n|-------|---------|\n| Min replicas | Lower bound, ≥1 |\n| Max replicas | Upper bound |\n| Target CPU % | Scale up when average CPU exceeds this |\n| Target memory % | Scale up when average memory exceeds this |\n| Enabled | Whether the autoscaler evaluates this service |\n\nCreating a policy registers the service with the autoscaler and initializes\nits scaling state at `min_replicas`.\n\n## Manual scale\n\nThe Scaling section also has a replica input + **Scale** button for an\nimmediate change, clamped to the policy bounds when a policy exists\n(1–20 otherwise). Scaling dispatches to the node scheduler — without\nregistered node agents it fails with `no ready nodes available`.\n\n## API\n\n```\nGET /api/v1/scaling/policies/:serviceId\nPOST /api/v1/scaling/policies {service_id, min_replicas, max_replicas,\n target_cpu, target_memory, enabled}\nPUT /api/v1/scaling/policies/:serviceId\nDELETE /api/v1/scaling/policies/:serviceId (disables, does not remove)\nGET /api/v1/scaling/services/:serviceId (current/desired replicas)\nPOST /api/v1/scaling/services/:serviceId/scale {replicas, reason}\nGET /api/v1/scaling/services/:serviceId/history\nGET /api/v1/scaling/status | /metrics | /events\n```\n\n## Traffic distribution\n\nReplicas are scheduled across registered node agents. In front of them you\nstill want a load balancer — Traefik (bundled) discovers containers via\nlabels, or Cloudflare Tunnel can front the platform for external traffic.\nNeither provides autoscaling itself; they distribute traffic to whatever\nreplicas the autoscaler has placed.\n"
},
{
"path": "guides/AUTO_DEPLOY_GUIDE.md",
"section": "guides",
"title": "🚀 Zero-Interaction Docker Deployment System",
"markdown": "# 🚀 Zero-Interaction Docker Deployment System\n\n## 🎯 **Fully Automatic Deployment**\n\nI've created a **completely automated deployment system** that requires **ZERO user interaction**. Just run one command and it does everything!\n\n## ⚡ **One-Click Deployment**\n\n### **Option 1: Quick Start (Recommended)**\n```bash\n# Run the quick deploy script\n./scripts/quick-deploy.sh\n```\n\n**What happens:**\n1. 🎯 **Shows you 6 deployment options**\n2. 🚀 **Deploys your choice automatically**\n3. 🔧 **Handles everything** (secrets, directories, ports)\n4. 🌐 **Shows access URLs** when done\n\n### **Option 2: Direct Command**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh umami\n./scripts/auto-deploy.sh plex\n./scripts/auto-deploy.sh nextcloud\n```\n\n### **Option 3: Deploy Everything**\n```bash\n# Deploy all 20 templates at once\n./scripts/auto-deploy.sh --all\n```\n\n## 🔄 **What Happens Automatically**\n\n### **🔍 Dependency Check**\n```bash\n✅ Checks if Docker is installed\n✅ Installs Docker if missing\n✅ Checks if Docker Compose is installed \n✅ Installs Docker Compose if missing\n```\n\n### **📁 Directory Creation**\n```bash\n✅ Creates deployment directory\n✅ Creates required subdirectories\n✅ Sets proper permissions\n✅ Handles existing deployments\n```\n\n### **🔧 Configuration Setup**\n```bash\n✅ Extracts docker-compose.yml from template\n✅ Creates .env file with all variables\n✅ Auto-generates secrets and passwords\n✅ Fixes port conflicts automatically\n✅ Optimizes volume paths\n```\n\n### **🐳 Docker Deployment**\n```bash\n✅ Pulls all required images\n✅ Starts all services\n✅ Waits for services to be ready\n✅ Checks service health\n✅ Shows deployment status\n```\n\n### **🌐 Access Information**\n```bash\n✅ Shows all access URLs\n✅ Provides management commands\n✅ Shows deployment directory\n✅ Opens browser automatically (optional)\n```\n\n## 🎮 **Try It Now**\n\n### **Easiest Way:**\n```bash\n./scripts/quick-deploy.sh\n```\n\nYou'll see:\n```\n🚀 One-Click Docker Deployment\n============================\n\n🎯 Choose your deployment:\n\n1. 🌐 Deploy Glance Dashboard (Recommended - Simple & Fast)\n2. 📊 Deploy Umami Analytics (Web Analytics)\n3. 📝 Deploy Memos (Note-taking)\n4. 🔍 Deploy MeiliSearch (Search Engine)\n5. 📈 Deploy Uptime Kuma (Monitoring)\n6. 🚀 Deploy ALL Templates (Advanced)\n\nEnter your choice (1-6): 1\n\n🌐 Deploying Glance Dashboard...\n```\n\n### **Direct Way:**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh glance\n```\n\n## 📊 **Example Output**\n\nHere's what you'll see during deployment:\n\n```\n🚀 Auto-Deploying glance\n========================================\n🔄 Checking dependencies...\n✅ Docker is installed\n✅ Docker Compose is installed\n🔄 Auto-deploying glance...\n✅ Created deployment directory: /path/to/deployments/glance\n🔄 Extracting docker-compose.yml...\n✅ docker-compose.yml extracted and optimized\n🔄 Creating .env file...\n✅ .env file created (no variables required)\n🔄 Auto-deploying glance...\n🔄 Pulling Docker images...\n✅ Images pulled\n🔄 Starting services...\n✅ Services started successfully\n\n🎉 Deployment Complete!\n========================================\n✅ Access URLs:\n 🌐 http://localhost:8080 (port: 8080)\n\n✅ Management Commands:\n 📋 View logs: docker-compose logs -f\n 🛑 Stop services: docker-compose down\n 🔄 Restart: docker-compose restart\n 📊 Status: docker-compose ps\n 🗑️ Cleanup: docker-compose down -v\n\n✅ Deployment directory: /path/to/deployments/glance\n🎉 glance deployed successfully!\n```\n\n## 🔧 **Advanced Options**\n\n### **Custom Configuration**\n```bash\n# Deploy without auto-generating secrets\n./scripts/auto-deploy.sh --no-secrets umami\n\n# Deploy without creating directories\n./scripts/auto-deploy.sh --no-dirs plex\n\n# Deploy without auto-starting services\n./scripts/auto-deploy.sh --no-start nextcloud\n\n# Deploy and auto-open browser\n./scripts/auto-deploy.sh --open-browser glance\n```\n\n### **Batch Deployment**\n```bash\n# Deploy multiple templates\nfor template in umami glance memos; do\n ./scripts/auto-deploy.sh \"$template\"\ndone\n\n# Deploy all templates\n./scripts/auto-deploy.sh --all\n```\n\n## 🛡️ **Safety Features**\n\n### **Automatic Conflict Resolution**\n```bash\n✅ Detects port conflicts\n✅ Auto-assigns available ports\n✅ Handles existing deployments\n✅ Cleans up previous installations\n```\n\n### **Security**\n```bash\n✅ Generates strong random secrets\n✅ Uses secure default passwords\n✅ Isolates deployments in separate directories\n✅ Logs all actions for audit trail\n```\n\n### **Error Handling**\n```bash\n✅ Validates template existence\n✅ Checks service health\n✅ Provides detailed error messages\n✅ Offers rollback commands\n```\n\n## 📁 **Di Line truncated
},
{
"path": "guides/CLOUDFLARE_SETUP.md",
"section": "guides",
"title": "Cloudflare Tunnel Setup",
"markdown": "# Cloudflare Tunnel Setup\n\nThis guide explains how to set up Cloudflare tunnel for Containr, allowing you to expose your services without configuring a domain.\n\n## Prerequisites\n\n1. A Cloudflare account\n2. A domain (any domain, even a free one)\n\n## Setup Steps\n\n### 1. Create a Cloudflare Tunnel\n\n1. Log in to your [Cloudflare Dashboard](https://dash.cloudflare.com/)\n2. Go to **Zero Trust** → **Networks** → **Tunnels**\n3. Click **Create tunnel**\n4. Choose **Cloudflared** and click **Next**\n5. Give your tunnel a name (e.g., \"containr-tunnel\")\n6. Click **Save tunnel**\n\n### 2. Get Your Tunnel Token\n\nAfter creating the tunnel, Cloudflare will show you a command like:\n```bash\ncloudflared tunnel run <tunnel-id>\n```\n\nCopy the token from the `.cloudflared/config.yml` file or use the token provided by Cloudflare.\n\n### 3. Configure Containr\n\n1. Copy `.env.example` to `.env` if you haven't already:\n ```bash\n cp .env.example .env\n ```\n\n2. Edit `.env` and add your Cloudflare tunnel token:\n ```env\n CLOUDFLARED_TOKEN=your_copied_tunnel_token_here\n ```\n\n### 4. Start Services with Cloudflare Tunnel\n\n```bash\n# Start all services including cloudflared\nmake cloudflared-up\n\n# Or start manually\ndocker-compose --profile cloudflared up -d\n```\n\n### 5. Configure Tunnel Routes\n\nIn your Cloudflare dashboard:\n\n1. Go to your tunnel settings\n2. Add the following public hostnames:\n - `your-domain.com` → `http://traefik:80` (for frontend)\n - `api.your-domain.com` → `http://traefik:80` (for backend)\n - `traefik.your-domain.com` → `http://traefik:80` (for dashboard)\n\n### 6. Access Your Services\n\nOnce configured, you can access:\n- Frontend: `https://your-domain.com`\n- API: `https://api.your-domain.com`\n- Traefik Dashboard: `https://traefik.your-domain.com`\n\n## Management Commands\n\n```bash\n# Start with Cloudflare tunnel\nmake cloudflared-up\n\n# Stop Cloudflare tunnel\nmake cloudflared-down\n\n# View logs\ndocker-compose logs -f cloudflared\n\n# Check status\ndocker-compose ps\n```\n\n## Benefits\n\n- **No domain configuration required** in `.env`\n- **Automatic SSL** through Cloudflare\n- **DDoS protection** and security features\n- **Easy setup** - just need a tunnel token\n- **Works anywhere** - no port forwarding needed\n\n## Troubleshooting\n\n### Tunnel Not Connecting\n- Verify your `CLOUDFLARED_TOKEN` is correct\n- Check cloudflared logs: `docker-compose logs cloudflared`\n- Ensure your tunnel is active in Cloudflare dashboard\n\n### Services Not Accessible\n- Verify you've configured the public hostnames in Cloudflare\n- Check that all services are running: `docker-compose ps`\n- Ensure the tunnel routes point to `http://traefik:80`\n\n### Token Issues\n- Regenerate your tunnel token in Cloudflare dashboard\n- Make sure there are no extra spaces or newlines in the token\n\n## Alternative: Domain Mode\n\nIf you prefer traditional domain setup instead of Cloudflare tunnel:\n\n1. Configure your domain in `.env`:\n ```env\n DOMAIN=yourdomain.com\n [email protected]\n ```\n\n2. Use regular commands:\n ```bash\n make prod\n ```\n\nThis will use Let's Encrypt certificates instead of Cloudflare tunnel.\n"
},
{
"path": "guides/DOCKER_SETUP.md",
"section": "guides",
"title": "Docker Setup with Traefik",
"markdown": "# Docker Setup with Traefik\n\nThis guide will help you set up Containr with Docker, Traefik reverse proxy, and automatic SSL certificates.\n\n## Prerequisites\n\n- Docker and Docker Compose installed\n- A domain name pointing to your server's IP address\n- Port 80 and 443 open on your firewall\n\n## Quick Start\n\n1. **Clone and prepare the environment:**\n ```bash\n git clone <your-repo>\n cd containr\n cp .env.example .env\n ```\n\n2. **Configure your environment:**\n Edit `.env` file with your settings:\n ```bash\n nano .env\n ```\n \n Required changes:\n - `DOMAIN=yourdomain.com` - Your actual domain\n - `[email protected]` - Email for SSL certificates\n - `POSTGRES_PASSWORD` - Set a secure password\n - `REDIS_PASSWORD` - Set a secure password\n - `JWT_SECRET` - Generate a secure random string\n - `TRAEFIK_AUTH` - Generate basic auth for dashboard\n\n3. **Generate Traefik authentication:**\n ```bash\n # Install apache2-utils if needed\n sudo apt-get install apache2-utils\n \n # Generate username:password hash\n htpasswd -nb admin yourpassword\n \n # Update TRAEFIK_AUTH in .env with the output\n ```\n\n4. **Create necessary directories:**\n ```bash\n mkdir -p data/letsencrypt\n chmod 600 data/letsencrypt/acme.json\n ```\n\n5. **Start the services:**\n ```bash\n docker-compose up -d\n ```\n\n## Services and URLs\n\nAfter deployment, your services will be available at:\n\n- **Frontend**: `https://yourdomain.com`\n- **Backend API**: `https://api.yourdomain.com`\n- **Traefik Dashboard**: `https://traefik.yourdomain.com`\n\n## Architecture\n\n```\nInternet → Traefik (Port 80/443)\n ├── Frontend (React/Nginx)\n ├── Backend (Go API)\n ├── PostgreSQL (Database)\n └── Dragonfly (Cache)\n```\n\n## Configuration Files\n\n### Docker Compose\n- `docker-compose.yml` - Main orchestration file\n- Defines all services, networks, and volumes\n- Configures Traefik with automatic SSL\n\n### Traefik Configuration\n- `traefik.yml` - Static configuration\n- `traefik-dynamic.yml` - Dynamic routing rules\n- Automatic HTTP to HTTPS redirection\n- Security headers and rate limiting\n\n### Dockerfiles\n- `Dockerfile.backend` - Go backend with multi-stage build\n- `Dockerfile.frontend` - React frontend with Nginx\n- Both use non-root users for security\n\n## Security Features\n\n- **Automatic SSL** via Let's Encrypt\n- **HTTP to HTTPS** redirection\n- **Security headers** (HSTS, XSS protection, etc.)\n- **Rate limiting** on API endpoints\n- **Basic authentication** on Traefik dashboard\n- **Non-root containers** for all services\n- **Health checks** for all services\n\n## Monitoring and Logs\n\n### Traefik Dashboard\nAccess at `https://traefik.yourdomain.com` with your configured credentials.\n\n### Logs\n```bash\n# View all logs\ndocker-compose logs -f\n\n# View specific service logs\ndocker-compose logs -f traefik\ndocker-compose logs -f backend\ndocker-compose logs -f frontend\n```\n\n### Health Checks\nAll services include health checks:\n```bash\n# Check service status\ndocker-compose ps\n```\n\n## Maintenance\n\n### Updates\n```bash\n# Pull latest images\ndocker-compose pull\n\n# Recreate services with new images\ndocker-compose up -d --force-recreate\n```\n\n### Backups\n```bash\n# Backup PostgreSQL\ndocker-compose exec postgres pg_dump -U containr_user containr > backup.sql\n\n# Backup Dragonfly (snapshot)\ndocker-compose exec dragonfly redis-cli -a \"$REDIS_PASSWORD\" save\n```\n\n### SSL Certificates\nLet's Encrypt certificates are automatically renewed. Manual renewal:\n```bash\ndocker-compose exec traefik traefik api check-letsencrypt\n```\n\n## Development Mode\n\nFor local development without SSL:\n```bash\n# Create development override\ncat > docker-compose.override.yml << EOF\nversion: '3.8'\nservices:\n traefik:\n command:\n - \"--api.dashboard=true\"\n - \"--providers.docker=true\"\n - \"--entrypoints.web.address=:80\"\n - \"--log.level=DEBUG\"\n ports:\n - \"80:80\"\n - \"8080:8080\"\n labels:\n - \"traefik.http.routers.traefik.rule=Host(`localhost`)\"\n - \"traefik.http.routers.traefik.entrypoints=web\"\n - \"traefik.http.routers.traefik.service=api@internal\"\nEOF\n\n# Start with override\ndocker-compose up -d\n```\n\n## Troubleshooting\n\n### Common Issues\n\n1. **SSL Certificate Issues**\n ```bash\n # Check acme.json permissions\n ls -la data/letsencrypt/acme.json\n \n # Reset certificates\n rm data/letsencrypt/acme.json\n docker-compose restart traefik\n ```\n\n2. **Port Conflicts**\n ```bash\n # Check what's using ports\n sudo netstat -tlnp | grep :80\n sudo netstat -tlnp | grep :443\n ```\n\n3. **Database Connection**\n ```bash\n # Test database connection\n docker-compose exec backend ping postgres\n ```\n\n4. **Permission Issues**\n ```bash\n # Fix volume permissions\n sudo choLine truncated
},
{
"path": "guides/OPERATIONS.md",
"section": "guides",
"title": "Operations",
"markdown": "# Operations\n\nDay-2 features for running services: scheduled jobs, ad-hoc exec, managed\ndatabases, preview environments, security scans, and high availability.\n\n## Cron jobs\n\nService detail → **Cron**. Standard 5-field cron expressions (`robfig/cron`\nsyntax), per-job timezone, enable/disable, manual trigger, and execution\nhistory with captured stdout/stderr.\n\n- The scheduler ticks every minute and runs due jobs via `docker exec`\n (`sh -c <command>`) inside the service's container.\n- Executions are retained per-job (`retention` count) and visible in the UI.\n- Requires a running container — a stopped service's due job records a\n failure, it does not queue.\n\n```\nGET/POST /api/v1/cron-jobs PUT/DELETE /api/v1/cron-jobs/:id\nPOST /api/v1/cron-jobs/:id/trigger\nGET /api/v1/cron-jobs/:id/executions\n```\n\n## Exec console\n\nService detail → **Console**. One-off `sh -c` commands against the running\ncontainer: 30s ceiling, 64KB output cap, exit code + captured output shown\nper run. Debugging aid — not an interactive shell.\n\n```\nPOST /api/v1/services/:id/exec {command}\n```\n\n## Managed databases\n\n**/databases** page — provision PostgreSQL/Redis/MySQL/MariaDB/MongoDB/\nClickHouse/Dragonfly as managed containers on the `containr` network.\n\n- Start/stop/restart actions; connection URL with copy.\n- **Backups**: manual snapshot now, or set a cron expression\n (`backup_schedule`) — due backups run inside the same scheduler tick as\n cron jobs. Archives live in the `containr-db-backups` volume and are\n downloadable (`GET /databases/:id/backups/:bid/download`) and restorable.\n- **Bind to service**: injects `connection_url` into a service's variables\n as a secret (`DATABASE_URL` by default). Redeploy the service afterward —\n variables are read at deploy time.\n\n## Preview environments\n\nService detail → **Previews**. Create a preview per branch/PR with a TTL,\npromote it to production, or delete it.\n\nCurrent limitation: previews are bookkeeping records — they do not build,\ndeploy, or route a real container. The generated `*.preview.containr.local`\nURL is a placeholder. Real preview runtime is tracked on the roadmap.\n\n## Security scans\n\n**/security** page — pick a project, run a scan (dependency / configuration\n/ comprehensive, optionally scoped to one service).\n\nThe scanner is heuristic config analysis, not Trivy/Grype: it flags\nunpinned image tags, `http://` source/public URLs, `npm install` without\n`npm ci`, `pip install` without hashes, `curl|sh` build steps, missing\nhealth checks, and similar smells. Findings get severity badges and can be\nresolved or ignored; the page also shows a security score, open/resolved\ncounts, scan history, and compliance status.\n\n```\nPOST /api/v1/security/scans {project_id, service_id?, scan_type}\nGET /api/v1/projects/:id/vulnerabilities\nPUT /api/v1/vulnerabilities/:id {status: open|resolved|ignored}\nGET /api/v1/projects/:id/security/metrics\nGET /api/v1/projects/:id/security/history\n```\n\n## High availability\n\n**/ha** page — manager enable/disable, node/health-check/alert status\ncards, manual failover (with confirmation), per-service failover policies\n(project → service picker, strategy, min healthy nodes, max failures),\nactive alerts with resolve, and health check results.\n\nHA state is in-memory like autoscaling — policies and alerts reset on\nbackend restart.\n\n## Node agents\n\n**/usage** page → *Connect node*: issue a per-node token (`cagt_…`, shown\nonce), copy the prefilled install command, revoke tokens when done. Agents\nregister over `POST /api/agents/register` and heartbeat via\n`POST /api/agents/heartbeat`.\n"
},
{
"path": "guides/README_BACKEND.md",
"section": "guides",
"title": "Containr Backend Setup",
"markdown": "# Containr Backend Setup\n\n## Quick Start\n\n### Prerequisites\n- Go 1.21+\n- PostgreSQL 12+\n- Dragonfly or Redis-compatible cache (optional)\n\n### Environment Variables\n\nCreate a `.env` file or set these environment variables:\n\n```bash\n# Database\nDATABASE_URL=postgres://containr:password@localhost:5432/containr?sslmode=disable\nREDIS_URL=redis://localhost:6379\n\n# Server\nPORT=8080\nENVIRONMENT=development\n\n# Security\nJWT_SECRET=your-secret-key-change-in-production\n\n# Frontend (for CORS)\nFRONTEND_URL=http://localhost:3000\n```\n\n### Database Setup\n\n1. Create PostgreSQL database:\n```sql\nCREATE DATABASE containr;\nCREATE USER containr WITH PASSWORD 'password';\nGRANT ALL PRIVILEGES ON DATABASE containr TO containr;\n```\n\n2. Run migrations (handled automatically on server start)\n\n### Running the Server\n\n```bash\n# Build\ngo build -o bin/server cmd/server/main.go\n\n# Run\n./bin/server\n```\n\nThe server will start on `http://localhost:8080`\n\n### API Endpoints\n\n#### Health Check\n- `GET /health` - Server health status\n\n#### Authentication\n- `POST /api/v1/auth/login` - User login\n- `POST /api/v1/auth/register` - User registration\n\n#### User Profile\n- `GET /api/v1/user/profile` - Get user profile (requires auth)\n- `PUT /api/v1/user/profile` - Update user profile (requires auth)\n\n#### Projects\n- `GET /api/v1/projects` - List user projects (requires auth)\n- `POST /api/v1/projects` - Create project (requires auth)\n- `GET /api/v1/projects/:id` - Get project details (requires auth)\n- `PUT /api/v1/projects/:id` - Update project (requires auth)\n- `DELETE /api/v1/projects/:id` - Delete project (requires auth)\n\n### Authentication\n\nInclude JWT token in Authorization header:\n```\nAuthorization: Bearer <your-jwt-token>\n```\n\n### Example Usage\n\n1. Register a user:\n```bash\ncurl -X POST http://localhost:8080/api/v1/auth/register \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"email\": \"[email protected]\",\n \"password\": \"password123\",\n \"name\": \"Test User\"\n }'\n```\n\n2. Login:\n```bash\ncurl -X POST http://localhost:8080/api/v1/auth/login \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"email\": \"[email protected]\",\n \"password\": \"password123\"\n }'\n```\n\n3. Create a project (using token from login):\n```bash\ncurl -X POST http://localhost:8080/api/v1/projects \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer <token>\" \\\n -d '{\n \"name\": \"My Project\",\n \"description\": \"A test project\"\n }'\n```\n\n## Development\n\n### Project Structure\n```\ncmd/server/ - Main application entry point\ninternal/\n├── api/ - HTTP handlers and routes\n├── config/ - Configuration management\n├── database/ - Database connections and migrations\n└── middleware/ - HTTP middleware\nmigrations_goose/ - Goose-managed migrations (single migration path)\n```\n\n### Adding New Endpoints\n\n1. Create handler functions in `internal/api/`\n2. Add routes in `internal/api/routes.go`\n3. Update database schema if needed in `migrations_goose/` (goose format: `-- +goose Up/Down`)\n"
},
{
"path": "guides/SETUP_GUIDE.md",
"section": "guides",
"title": "Docker Template Auto-Setup Guide",
"markdown": "# Docker Template Auto-Setup Guide\n\n## 🚀 How It Works\n\nThe templates are **configuration files** that define how to run each service with Docker Compose. They don't auto-execute - you need to use the setup script to deploy them.\n\n## 📋 Quick Start\n\n### 1. Interactive Mode (Recommended)\n```bash\n# Run the setup script interactively\n./scripts/setup-template.sh\n```\n\n### 2. Direct Template Selection\n```bash\n# Setup a specific template\n./scripts/setup-template.sh umami\n\n# List all available templates\n./scripts/setup-template.sh --list\n\n# Setup only (don't deploy)\n./scripts/setup-template.sh --setup umami\n\n# Deploy existing setup\n./scripts/setup-template.sh --deploy umami\n```\n\n## 🔄 What the Script Does\n\n### Step 1: Dependency Check\n- ✅ Verifies Docker is installed\n- ✅ Verifies Docker Compose is installed\n- ✅ Checks template directory exists\n\n### Step 2: Template Selection\n- 📋 Shows available templates with descriptions\n- 🎯 Lets you choose interactively or via command line\n\n### Step 3: Project Setup\n- 📁 Creates deployment directory (`template-name-deployment/`)\n- 📄 Extracts `docker-compose.yml` from template\n- 🔧 Creates `.env` file template with required variables\n- ⚠️ Shows setup requirements and warnings\n\n### Step 4: Deployment (Optional)\n- 🐳 Pulls Docker images\n- 🚀 Starts services with `docker-compose up -d`\n- 📊 Shows service status and access URLs\n- 📝 Provides useful commands\n\n## 📁 Directory Structure After Setup\n\n```\nyour-project/\n├── scripts/\n│ └── setup-template.sh # Auto-setup script\n├── templates/\n│ ├── umami.md # Template documentation\n│ ├── plex.md\n│ ├── immich.md\n│ └── ...\n└── umami-deployment/ # Created by setup script\n ├── docker-compose.yml # Extracted from template\n ├── .env # Environment variables\n └── README.md # Setup instructions\n```\n\n## 🎯 Interactive Walkthrough Example\n\n### Running Umami Setup:\n```bash\n$ ./scripts/setup-template.sh\n\n========================================\nChecking Dependencies\n========================================\n✅ Docker is installed\nDocker version: 24.0.6\n✅ Docker Compose is installed\nDocker Compose version: 2.21.0\n✅ All dependencies are met\n\n========================================\nSelect Template\n========================================\n 1) umami - Umami Analytics - Privacy-focused web analytics\n 2) plex - Plex Media Server - Media streaming and organization\n 3) immich - Immich - Photo and video backup solution\n ...\nSelect a template (1-20): 1\n✅ Selected template: umami\n\n========================================\nSetting up umami\n========================================\n✅ Created project directory: /path/to/umami-deployment\n✅ docker-compose.yml extracted\n⚠️ This template requires environment variables:\n- `DATABASE_URL`: PostgreSQL connection string\n- `APP_SECRET`: Random string for application secrets\n✅ Created .env template file\n⚠️ Please edit umami-deployment/.env with your values before starting\n\nDo you want to deploy the template now? (y/N): y\n\n========================================\nDeploying umami\n========================================\n✅ Pulling Docker images...\n✅ Starting services...\n✅ Deployment completed!\n\n========================================\nAccess Information\n========================================\n✅ Service access URLs:\n • http://localhost:3000 (container port: 3000)\n\nUseful commands:\n • View logs: docker-compose logs -f\n • Stop services: docker-compose down\n • Restart services: docker-compose restart\n • Update services: docker-compose pull && docker-compose up -d\n```\n\n## 🔧 Manual Setup (Without Script)\n\nIf you prefer manual setup:\n\n1. **Choose a template** from `/templates/`\n2. **Copy the docker-compose.yml** section\n3. **Create a project directory**\n4. **Set up environment variables**\n5. **Run `docker-compose up -d`**\n\nExample for Umami:\n```bash\nmkdir umami-deployment\ncd umami-deployment\n\n# Copy docker-compose.yml from templates/umami.md\n# Create .env file with required variables\ndocker-compose up -d\n```\n\n## 🎛️ Available Templates\n\n| Template | Description | Difficulty |\n|----------|-------------|------------|\n| **umami** | Web Analytics | Easy |\n| **plex** | Media Server | Medium |\n| **immich** | Photo Backup | Medium |\n| **n8n** | Workflow Automation | Medium |\n| **supabase** | Backend Service | Hard |\n| **home-assistant** | Smart Home | Medium |\n| **uptime-kuma** | Monitoring | Easy |\n| **grafana** | Metrics Dashboard | Medium |\n| **traefik** | Reverse Proxy | Medium |\n| **memos** | Note-taking | Easy |\n| **meilisearch** | Search Engine | Easy |\n| **vaultwarden** | Password Manager | Line truncated
},
{
"path": "guides/advanced-configuration.md",
"section": "guides",
"title": "Advanced Configuration Guide",
"markdown": "# Advanced Configuration Guide\n\nThis guide covers advanced configuration options for optimizing your Containr deployments.\n\n## Table of Contents\n\n- [Build Configuration](#build-configuration)\n- [Environment Management](#environment-management)\n- [Resource Allocation](#resource-allocation)\n- [Networking](#networking)\n- [Security](#security)\n- [Scaling Policies](#scaling-policies)\n- [Custom Domains and SSL](#custom-domains-and-ssl)\n- [Health Checks](#health-checks)\n\n## Build Configuration\n\n### Nixpacks Configuration\n\nNixpacks is the default build system. You can customize it using a `nixpacks.toml` file:\n\n```toml\n# nixpacks.toml\n[phases.setup]\nnixPkgs = [\"...\", \"nodejs\", \"npm\"]\n\n[phases.build]\ncmds = [\"npm run build\"]\n\n[start]\ncmd = \"npm start\"\n\n[variables]\nNODE_ENV = \"production\"\nPORT = \"8080\"\n```\n\n### Custom Dockerfile\n\nFor complex applications, use a custom Dockerfile:\n\n```dockerfile\n# Dockerfile\nFROM node:18-alpine AS builder\n\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --only=production\n\nCOPY . .\nRUN npm run build\n\nFROM node:18-alpine AS runtime\n\nWORKDIR /app\nCOPY --from=builder /app/dist ./dist\nCOPY --from=builder /app/node_modules ./node_modules\nCOPY package*.json ./\n\nEXPOSE 8080\nUSER node\nCMD [\"npm\", \"start\"]\n```\n\n### Build Caching\n\nEnable build caching for faster deployments:\n\n1. Go to service settings\n2. Enable \"Build Caching\"\n3. Configure cache settings:\n - **Cache TTL**: Time to keep cached layers (default: 7 days)\n - **Max Cache Size**: Maximum cache size per service\n\n### Multi-Stage Builds\n\nOptimize image size with multi-stage builds:\n\n```dockerfile\n# Build stage\nFROM golang:1.21 AS builder\nWORKDIR /app\nCOPY go.mod go.sum ./\nRUN go mod download\nCOPY . .\nRUN CGO_ENABLED=0 go build -o app\n\n# Runtime stage\nFROM alpine:latest\nRUN apk --no-cache add ca-certificates\nWORKDIR /root/\nCOPY --from=builder /app/app .\nEXPOSE 8080\nCMD [\"./app\"]\n```\n\n## Environment Management\n\n### Environment Scopes\n\nVariables can be scoped at different levels:\n\n```bash\n# Project-level (shared across all services)\nDATABASE_URL=postgresql://...\n\n# Service-level (specific to one service)\nAPI_KEY=service-specific-key\n\n# Environment-specific (production only)\nNODE_ENV=production\nDEBUG=false\n```\n\n### Variable Types\n\n#### Plain Text Variables\n```bash\nAPI_KEY=your-api-key-here\n```\n\n#### Secret Variables\n```bash\n# Marked as secret in UI - masked in logs\nDATABASE_PASSWORD=super-secret-password\n```\n\n#### File Variables\nFor configuration files:\n```bash\n# Upload .env file as variable\nENV_FILE_CONTENT=KEY1=value1\\nKEY2=value2\n```\n\n### Dynamic Variables\n\nUse Containr's built-in variables:\n\n```bash\n# Auto-populated by Containr\nPORT=8080 # Assigned port\nCONTAINR_SERVICE_NAME=my-app # Service name\nCONTAINR_PROJECT_NAME=my-proj # Project name\nCONTAINR_DEPLOYMENT_ID=abc123 # Deployment ID\n```\n\n## Resource Allocation\n\n### CPU Allocation\n\nConfigure CPU limits and requests:\n\n```yaml\n# Service resource configuration\nresources:\n cpu:\n request: \"100m\" # Minimum CPU (0.1 core)\n limit: \"1000m\" # Maximum CPU (1 core)\n memory:\n request: \"128Mi\" # Minimum memory\n limit: \"512Mi\" # Maximum memory\n```\n\n### Memory Allocation\n\nMemory configuration examples:\n\n```yaml\n# Small service (API, worker)\nresources:\n memory: \"256Mi\"\n\n# Medium service (web app)\nresources:\n memory: \"512Mi\"\n\n# Large service (database, analytics)\nresources:\n memory: \"2Gi\"\n```\n\n### Storage Allocation\n\nPersistent storage for databases:\n\n```yaml\n# Database service\nresources:\n storage:\n size: \"10Gi\"\n type: \"ssd\"\n backup: true\n```\n\n## Networking\n\n### Service-to-Service Communication\n\nServices can communicate internally:\n\n```bash\n# Environment variables for internal URLs\nDATABASE_URL=postgresql://my-db:5432/myapp\nREDIS_URL=redis://my-redis:6379\nAPI_URL=http://my-api:8080\n```\n\n### Port Configuration\n\nConfigure service ports:\n\n```yaml\n# Service configuration\nnetworking:\n port: 8080 # Internal port\n public: true # Expose publicly\n health_check: \"/health\" # Health check endpoint\n```\n\n### Custom Domains\n\nSet up custom domains:\n\n1. Go to service settings → \"Domains\"\n2. Add your domain: `app.yourdomain.com`\n3. Configure DNS:\n ```\n CNAME app.yourdomain.com -> your-service.containr.app\n ```\n4. Wait for SSL certificate provisioning\n\n### Load Balancing\n\nConfigure load balancing strategies:\n\n```yaml\n# Load balancer configuration\nload_balancer:\n strategy: \"round_robin\" # round_robin, least_connections, ip_hash\n health_check:\n path: \"/health\"\n interval: \"30s\"\n timeout: \"5s\"\n retries: 3\n```\n\n## Security\n\n### Security Scans\n\nEnable automated security scanning:\n\n1. Go to project se Line truncated
},
{
"path": "guides/getting-started.md",
"section": "guides",
"title": "Getting Started with Containr",
"markdown": "# Getting Started with Containr\n\nWelcome to Containr! This guide will walk you through setting up your first project and deploying your first application.\n\n## Prerequisites\n\nBefore you begin, make sure you have:\n\n- A Containr instance running (either self-hosted or using our hosted version)\n- A Git repository with your application code\n- Basic knowledge of Docker and containerization concepts\n\n## Step 1: Create Your Account\n\n1. Navigate to your Containr instance URL\n2. Click \"Sign Up\" in the top-right corner\n3. Fill in your email, name, and password\n4. Verify your email address (if required)\n5. Log in to your new account\n\n## Step 2: Create Your First Project\n\n1. From the dashboard, click \"New Project\"\n2. Enter a project name (e.g., \"my-awesome-app\")\n3. Add an optional description\n4. Click \"Create Project\"\n\nYou'll be taken to your project's main canvas view.\n\n## Step 3: Connect Your Git Repository\n\n### Option A: Using GitHub (Recommended)\n\n1. Click \"Add Service\" → \"Connect Git Repository\"\n2. Select \"GitHub\" as the provider\n3. Click \"Connect to GitHub\" and authorize Containr\n4. Browse your repositories and select the one you want to deploy\n5. Choose the branch (usually `main` or `master`)\n6. Select the root directory if your app isn't in the root\n7. Click \"Connect Repository\"\n\n### Option B: Using Other Git Providers\n\nContainr also supports GitLab and Bitbucket:\n\n1. Click \"Add Service\" → \"Connect Git Repository\"\n2. Select your preferred provider\n3. Follow the authorization flow\n4. Select your repository and branch\n\n## Step 4: Configure Your Service\n\nAfter connecting your repository, Containr will:\n\n1. **Auto-detect your stack** (Node.js, Python, Go, etc.)\n2. **Generate a build plan** using Nixpacks\n3. **Show you the configuration**\n\n### Basic Configuration\n\n- **Service Name**: Give your service a descriptive name\n- **Build Command**: Auto-detected, but you can customize\n- **Start Command**: Auto-detected, but you can customize\n- **Environment Variables**: Add any needed variables\n\n### Example: Node.js Application\n\nFor a typical Node.js app:\n\n```bash\n# Build Command (auto-detected)\nnpm install && npm run build\n\n# Start Command (auto-detected)\nnpm start\n```\n\n### Example: Go Application\n\nFor a Go application:\n\n```bash\n# Build Command (auto-detected)\ngo build -o app .\n\n# Start Command (auto-detected)\n./app\n```\n\n## Step 5: Add Environment Variables\n\nMost applications need environment variables:\n\n1. Go to the \"Variables\" tab in your service\n2. Add your variables as key-value pairs:\n - `NODE_ENV=production`\n - `DATABASE_URL=postgresql://...`\n - `API_KEY=your-secret-key`\n3. Variables are automatically encrypted and injected at runtime\n\n## Step 6: Deploy Your Application\n\n1. Click \"Deploy\" in the top-right corner\n2. Choose your deployment method:\n - **Deploy Latest Commit**: Deploy the latest commit from your branch\n - **Manual Deploy**: Specify a commit SHA\n3. Click \"Start Deployment\"\n\nContainr will:\n1. Pull your code from Git\n2. Build a Docker image using Nixpacks\n3. Start the container\n4. Assign a public URL\n\n## Step 7: Monitor Your Deployment\n\nWhile deploying, you can:\n\n- **View Build Logs**: See the build process in real-time\n- **Check Status**: Monitor deployment progress\n- **Get Notifications**: Receive alerts on success or failure\n\nOnce deployed, you'll see:\n\n- **Public URL**: Your application is live at `https://your-service-name.containr.app`\n- **Status**: Green checkmark if running successfully\n- **Metrics**: CPU, memory, and network usage\n\n## Step 8: Add a Database (Optional)\n\nMost applications need a database:\n\n1. Click \"Add Service\" → \"Add Database\"\n2. Choose your database type:\n - **PostgreSQL**: For relational data\n - **Redis**: For caching and sessions\n - **MySQL**: For MySQL compatibility\n3. Configure:\n - Database name\n - Version\n - Size allocation\n4. Click \"Create Database\"\n\n### Connect Your Application\n\nOnce the database is created:\n\n1. Go to your service's \"Variables\" tab\n2. Add the database connection URL:\n ```\n DATABASE_URL=postgresql://username:password@hostname:port/database_name\n ```\n3. Redeploy your application\n\n## Step 9: Set Up Preview Environments\n\nEnable automatic preview environments for pull requests:\n\n1. Go to project settings\n2. Enable \"Preview Environments\"\n3. Configure:\n - Automatic cleanup (e.g., 7 days)\n - Branch patterns (e.g., `feature/*`, `fix/*`)\n4. Save settings\n\nNow when you create a pull request:\n- A preview environment is automatically created\n- You get a unique URL for testing\n- Environment is cleaned up after merging\n\n## Step 10: Configure Custom Domain (Optional)\n\nTo use your own domain:\n\n1. Go to your service's \"Settings\" tab\n2. Click \"Add Custom Domain\"\n3. Enter your domain (e.g., `app.yourdomain.co Line truncated
}
];
+189 -3
View File
@@ -634,13 +634,13 @@ textarea {
.edge-premium .react-flow__edge-path {
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 4px rgba(156, 126, 240, 0.3));
filter: drop-shadow(0 0 4px rgba(180, 227, 74, 0.25));
transition: filter 0.2s ease, stroke-width 0.2s ease;
}
.edge-premium:hover .react-flow__edge-path {
filter: drop-shadow(0 0 8px rgba(156, 126, 240, 0.5));
stroke-width: 3px;
filter: drop-shadow(0 0 8px rgba(180, 227, 74, 0.45));
stroke-width: 2.6px;
}
/* ============================================
@@ -1582,3 +1582,189 @@ textarea {
.v-tick .sig.r { background: var(--error); }
/* Rail active indicator handled via .nav-item.active::before */
/* Rendered documentation */
.docs-article {
color: var(--text-secondary);
font-size: 13.5px;
line-height: 1.7;
max-width: 72ch;
}
.docs-article h1 {
color: var(--text-primary);
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 14px;
}
.docs-article h2 {
color: var(--text-primary);
font-size: 16.5px;
font-weight: 650;
margin: 26px 0 10px;
padding-top: 14px;
border-top: 1px solid var(--border-subtle);
}
.docs-article h3 {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
margin: 20px 0 8px;
}
.docs-article h4 {
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
margin: 16px 0 6px;
}
.docs-article p { margin: 10px 0; }
.docs-article a {
color: var(--accent-primary);
text-decoration: none;
border-bottom: 1px solid var(--accent-primary-glow);
}
.docs-article a:hover { border-bottom-color: var(--accent-primary); }
.docs-article ul, .docs-article ol {
margin: 10px 0;
padding-left: 22px;
}
.docs-article ul { list-style: disc; }
.docs-article ol { list-style: decimal; }
.docs-article li { margin: 4px 0; }
.docs-article li::marker { color: var(--text-tertiary); }
.docs-article code {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
background: var(--surface-muted);
border: 1px solid var(--border-subtle);
border-radius: 5px;
padding: 1.5px 5px;
color: var(--accent-secondary);
}
.docs-article pre {
background: var(--bg-void);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 14px 16px;
margin: 14px 0;
overflow-x: auto;
}
.docs-article pre code {
background: none;
border: none;
padding: 0;
font-size: 12px;
color: var(--text-secondary);
}
.docs-article blockquote {
border-left: 3px solid var(--accent-primary);
padding: 2px 0 2px 14px;
margin: 14px 0;
color: var(--text-tertiary);
}
.docs-article table {
width: 100%;
border-collapse: collapse;
margin: 14px 0;
font-size: 12.5px;
}
.docs-article th {
text-align: left;
font-weight: 600;
color: var(--text-primary);
padding: 8px 12px;
border-bottom: 1px solid var(--border-default);
}
.docs-article td {
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
}
.docs-article hr {
border: none;
border-top: 1px solid var(--border-subtle);
margin: 24px 0;
}
.docs-article img { max-width: 100%; border-radius: var(--radius-md); }
/* ============================================
CANVAS — fullscreen workspace graph
============================================ */
/* Floating glass pills (zoom controls, env filter, hints) */
.canvas-pill {
display: inline-flex;
align-items: center;
background: rgba(19, 21, 24, 0.82);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border-default);
border-radius: 999px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
.canvas-pill-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 30px;
padding: 0 6px;
border-radius: 999px;
color: var(--text-tertiary);
transition: color 0.15s ease, background 0.15s ease;
}
.canvas-pill-btn:hover {
color: var(--text-primary);
background: var(--surface-muted);
}
/* React Flow pane affordances */
.react-flow__pane {
cursor: grab;
}
.react-flow__pane.dragging {
cursor: grabbing;
}
.react-flow__node.selectable:focus {
outline: none;
}
/* Marquee selection tint */
.react-flow__selection {
background: var(--accent-primary-soft);
border: 1px solid var(--border-strong);
}
/* MiniMap — match surface card, dimmed nodes */
.react-flow__minimap {
border-radius: var(--radius-md) !important;
border: 1px solid var(--border-default);
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
.react-flow__minimap-svg {
display: block;
}
/* Inspector panel entrance */
.canvas-inspector {
animation: canvas-panel-in 0.22s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes canvas-panel-in {
from {
opacity: 0;
transform: translateX(14px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.canvas-inspector {
animation: none;
}
}
+151 -12
View File
@@ -8,6 +8,7 @@ export type ProjectEntity = {
id: string;
name: string;
description: string;
isApproved: boolean;
createdAt?: string;
updatedAt?: string;
stats: ProjectStats;
@@ -67,6 +68,8 @@ export type ServiceRuntime = {
desired: number;
status: 'running' | 'degraded' | 'stopped';
urls: string[];
ports: number[];
health?: 'healthy' | 'unhealthy' | 'unreachable' | '';
containers: RuntimeContainer[];
};
@@ -122,6 +125,8 @@ export type TemplateEntity = {
configRaw: string;
variablesRaw: string;
isOfficial: boolean;
ownerId: string | null;
isPublic: boolean;
createdAt?: string;
updatedAt?: string;
};
@@ -220,7 +225,9 @@ export type HostMonitoring = {
images?: number;
driver?: string;
server?: string;
os?: string;
};
scope: 'host' | 'docker-desktop-vm';
collectedAt: string;
};
@@ -422,7 +429,10 @@ async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
throw new ApiError(message, response.status);
}
return payload as T;
// SPA fallback servers may answer /api/* with index.html — an ok response
// whose body is not JSON. Surface an empty object so envelope unwraps
// (`payload.x ?? []`) degrade instead of dereferencing null.
return (payload ?? {}) as T;
}
async function requestText(path: string, init?: RequestInit): Promise<string> {
@@ -452,6 +462,7 @@ function normalizeProject(project: RawProject): ProjectEntity | null {
id: project.id,
name: project.name,
description: project.description ?? '',
isApproved: project.is_approved ?? false,
createdAt: project.created_at,
updatedAt: project.updated_at,
stats: {
@@ -611,6 +622,8 @@ function normalizeTemplate(template: RawServiceTemplate): TemplateEntity | null
configRaw: template.config ?? '',
variablesRaw: template.variables ?? '',
isOfficial: Boolean(template.is_official),
ownerId: template.owner_id ?? null,
isPublic: Boolean(template.is_public),
createdAt: template.created_at,
updatedAt: template.updated_at,
};
@@ -762,8 +775,12 @@ function normalizeServiceLogArray(raw: unknown): ServiceLogEntity[] {
.filter((entry): entry is ServiceLogEntity => entry !== null);
}
export async function listProjects(): Promise<ProjectEntity[]> {
const payload = await requestJson<ProjectsResponse200 | { projects?: RawProject[] }>(`/projects`);
export async function listProjects(params?: { limit?: number; search?: string }): Promise<ProjectEntity[]> {
const search = new URLSearchParams();
if (params?.limit) search.set('limit', String(params.limit));
if (params?.search) search.set('search', params.search);
const qs = search.toString();
const payload = await requestJson<ProjectsResponse200 | { projects?: RawProject[] }>(`/projects${qs ? `?${qs}` : ''}`);
return normalizeProjectArray((payload as { projects?: RawProject[] }).projects);
}
@@ -873,6 +890,7 @@ function normalizeHostMonitoring(payload: RawHostMonitoring): HostMonitoring {
uptimeSeconds: payload.uptime_seconds ?? 0,
dockerAvailable: Boolean(payload.docker_available),
docker: payload.docker,
scope: payload.scope === 'docker-desktop-vm' ? 'docker-desktop-vm' : 'host',
collectedAt: payload.collected_at ?? '',
};
}
@@ -991,6 +1009,26 @@ export async function createProject(input: CreateProjectInput): Promise<ProjectE
return parsed;
}
export async function updateProject(
projectId: string,
input: { name?: string; description?: string },
): Promise<ProjectEntity> {
const payload = await requestJson<RawProject | { project?: RawProject }>(
`/projects/${encodeURIComponent(projectId)}`,
{ method: 'PUT', body: JSON.stringify(input) },
);
const rawProject = (payload as { project?: RawProject }).project ?? (payload as RawProject);
const parsed = normalizeProject(rawProject);
if (!parsed) {
throw new ApiError('Update project response is invalid', 500);
}
return parsed;
}
export async function deleteProject(projectId: string): Promise<void> {
await requestJson(`/projects/${encodeURIComponent(projectId)}`, { method: 'DELETE' });
}
export async function listServicesByProject(projectId: string): Promise<ServiceEntity[]> {
const payload = await requestJson<RawService[] | { services?: RawService[] }>(`/projects/${projectId}/services`);
const rows = Array.isArray(payload) ? payload : payload.services ?? [];
@@ -1047,16 +1085,15 @@ export async function deleteService(serviceId: string): Promise<void> {
});
}
function normalizeRuntime(runtime?: ServiceRuntime): ServiceRuntime {
const state = runtime ?? { desired: 0, status: 'stopped' as const, urls: [], ports: [], containers: [] };
// The API emits null (not []) for empty collections — coerce at the boundary.
return { ...state, urls: state.urls ?? [], ports: state.ports ?? [], containers: state.containers ?? [] };
}
export async function getServiceRuntime(serviceId: string): Promise<ServiceRuntime> {
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/runtime`);
return (
payload.runtime ?? {
desired: 0,
status: 'stopped',
urls: [],
containers: [],
}
);
return normalizeRuntime(payload.runtime);
}
async function serviceAction(serviceId: string, action: 'start' | 'stop' | 'restart'): Promise<void> {
@@ -1071,7 +1108,7 @@ export async function redeployService(serviceId: string): Promise<ServiceRuntime
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/redeploy`, {
method: 'POST',
});
return payload.runtime ?? { desired: 0, status: 'stopped', urls: [], containers: [] };
return normalizeRuntime(payload.runtime);
}
function normalizeServiceVariables(rows: RawServiceVariable[] | undefined): ServiceVariable[] {
@@ -1323,6 +1360,56 @@ export async function getTemplateById(templateId: string): Promise<TemplateDetai
};
}
export type TemplateWriteInput = {
name: string;
description?: string;
category?: string;
logo?: string;
config: Record<string, unknown>;
variables?: TemplateVariableEntity[];
isPublic?: boolean;
};
function templateWriteBody(input: TemplateWriteInput): string {
return JSON.stringify({
name: input.name,
description: input.description ?? '',
category: input.category ?? '',
logo: input.logo ?? '',
config: input.config,
variables: input.variables ?? [],
is_public: input.isPublic ?? false,
});
}
export async function createTemplate(input: TemplateWriteInput): Promise<TemplateEntity> {
const payload = await requestJson<{ template?: RawServiceTemplate }>('/templates', {
method: 'POST',
body: templateWriteBody(input),
});
const template = payload.template ? normalizeTemplate(payload.template) : null;
if (!template) {
throw new ApiError('Template response is invalid', 500);
}
return template;
}
export async function updateTemplate(templateId: string, input: TemplateWriteInput): Promise<TemplateEntity> {
const payload = await requestJson<{ template?: RawServiceTemplate }>(`/templates/${templateId}`, {
method: 'PUT',
body: templateWriteBody(input),
});
const template = payload.template ? normalizeTemplate(payload.template) : null;
if (!template) {
throw new ApiError('Template response is invalid', 500);
}
return template;
}
export async function deleteTemplate(templateId: string): Promise<void> {
await requestJson(`/templates/${templateId}`, { method: 'DELETE' });
}
export async function deployTemplate(
templateId: string,
input: DeployTemplateInput,
@@ -1816,3 +1903,55 @@ export async function execInService(serviceId: string, command: string): Promise
body: JSON.stringify({ command }),
});
}
// ---------------------------------------------------------------------------
// Admin
// ---------------------------------------------------------------------------
export type AdminUser = {
id: string;
email: string;
name: string;
avatar_url?: string;
is_admin: boolean;
created_at?: string;
};
export type AdminOverview = {
stats: Record<string, number>;
pending_projects: ProjectEntity[];
};
export async function getAdminOverview(): Promise<AdminOverview> {
const payload = await requestJson<{
stats?: Record<string, number>;
pending_projects?: RawProject[];
}>('/admin/overview');
return {
stats: payload.stats ?? {},
pending_projects: (payload.pending_projects ?? [])
.map((project) => normalizeProject(project))
.filter((project): project is ProjectEntity => project !== null),
};
}
export async function listAdminUsers(): Promise<AdminUser[]> {
const payload = await requestJson<{ users?: AdminUser[] }>('/admin/users');
return payload.users ?? [];
}
export async function setUserAdmin(userId: string, isAdmin: boolean): Promise<void> {
await requestJson(`/admin/users/${encodeURIComponent(userId)}`, {
method: 'PATCH',
body: JSON.stringify({ is_admin: isAdmin }),
});
}
export async function setProjectApproval(projectId: string, isApproved: boolean): Promise<ProjectEntity | null> {
const payload = await requestJson<{ project?: RawProject; message?: string }>(
`/admin/projects/${encodeURIComponent(projectId)}`,
{ method: 'PATCH', body: JSON.stringify({ is_approved: isApproved }) },
);
return payload.project ? normalizeProject(payload.project) : null;
}
+619 -16
View File
@@ -1,24 +1,42 @@
import type { CronJobEntity, DatabaseEntity, ProjectEntity, ServiceEntity } from '@/lib/api-client';
import type {
BuildEntity,
CronJobEntity,
DatabaseEntity,
FailoverPolicy,
HAAlert,
HAHealthResult,
HAStatus,
HostMonitoring,
ProjectEntity,
SecurityMetrics,
SecurityScan,
ServiceEntity,
TemplateDetailEntity,
TemplateEntity,
Vulnerability,
} from '@/lib/api-client';
import type { ServiceVariable } from '@/features/workspace/auto-connections';
export const demoProjects: ProjectEntity[] = [
{
id: 'demo-project-core',
name: 'Core Platform',
description: 'Primary production workload with web, API, queue, and data services.',
description: 'Primary production workload with web, API, queue, and data services.',
isApproved: true,
createdAt: '2026-03-12T09:30:00Z',
updatedAt: '2026-03-31T09:10:00Z',
stats: {
service_count: 4,
service_count: 5,
deployment_count: 27,
running_services: 3,
running_services: 4,
last_deployment: '2026-03-31T08:42:00Z',
},
},
{
id: 'demo-project-growth',
name: 'Growth Surface',
description: 'Landing pages and campaign services for growth experiments.',
description: 'Landing pages and campaign services for growth experiments.',
isApproved: true,
createdAt: '2026-03-01T14:05:00Z',
updatedAt: '2026-03-30T16:44:00Z',
stats: {
@@ -31,7 +49,8 @@ export const demoProjects: ProjectEntity[] = [
{
id: 'demo-project-ml',
name: 'Inference Lab',
description: 'Internal inference jobs and model-serving edge services.',
description: 'Internal inference jobs and model-serving edge services.',
isApproved: true,
createdAt: '2026-02-18T07:12:00Z',
updatedAt: '2026-03-29T19:22:00Z',
stats: {
@@ -52,9 +71,11 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'web',
status: 'running',
environment: 'production',
image: 'ghcr.io/containr/web:2026.03.31',
image: 'ghcr.io/containr/web-react:v42',
command: 'npm run serve',
gitBranch: 'main',
port: 3000,
domain: 'web.containr.local',
createdAt: '2026-03-12T10:00:00Z',
updatedAt: '2026-03-31T08:42:00Z',
},
@@ -65,9 +86,11 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'web',
status: 'running',
environment: 'production',
image: 'ghcr.io/containr/api:2026.03.31',
image: 'ghcr.io/containr/api-go:v2.4.1',
command: './server',
gitBranch: 'main',
port: 8080,
domain: 'api.containr.local',
createdAt: '2026-03-12T10:02:00Z',
updatedAt: '2026-03-31T08:43:00Z',
},
@@ -78,9 +101,10 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'worker',
status: 'building',
environment: 'production',
image: 'ghcr.io/containr/worker:2026.03.31',
image: 'ghcr.io/containr/worker:edge',
command: './worker',
gitBranch: 'main',
port: 9100,
createdAt: '2026-03-12T10:05:00Z',
updatedAt: '2026-03-31T08:44:00Z',
},
@@ -91,12 +115,27 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'database',
status: 'running',
environment: 'production',
image: 'postgres:15-alpine',
image: 'postgres:16',
command: 'postgres',
gitBranch: 'main',
port: 5432,
createdAt: '2026-03-12T10:08:00Z',
updatedAt: '2026-03-31T08:40:00Z',
},
{
id: 'demo-svc-redis',
projectId: 'demo-project-core',
name: 'Redis Cache',
type: 'database',
status: 'running',
environment: 'production',
image: 'dragonfly:1.25',
command: 'redis-server',
gitBranch: 'main',
port: 6379,
createdAt: '2026-03-12T10:12:00Z',
updatedAt: '2026-03-31T08:40:00Z',
},
],
'demo-project-growth': [
{
@@ -106,9 +145,11 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'web',
status: 'running',
environment: 'production',
image: 'ghcr.io/containr/marketing:latest',
command: 'npm run start',
image: 'nginx:1.27-alpine',
command: 'nginx -g daemon off;',
gitBranch: 'main',
port: 8080,
domain: 'growth.containr.local',
createdAt: '2026-03-03T09:00:00Z',
updatedAt: '2026-03-30T12:10:00Z',
},
@@ -119,9 +160,10 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'web',
status: 'running',
environment: 'production',
image: 'ghcr.io/containr/campaign-api:latest',
image: 'node:20-alpine',
command: './api',
gitBranch: 'main',
port: 3000,
createdAt: '2026-03-03T09:04:00Z',
updatedAt: '2026-03-30T12:08:00Z',
},
@@ -132,9 +174,10 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'database',
status: 'running',
environment: 'production',
image: 'redis:7-alpine',
image: 'dragonfly:1.25',
command: 'redis-server',
gitBranch: 'main',
port: 6379,
createdAt: '2026-03-03T09:07:00Z',
updatedAt: '2026-03-30T12:07:00Z',
},
@@ -147,9 +190,10 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'web',
status: 'running',
environment: 'production',
image: 'ghcr.io/containr/inference-api:latest',
image: 'python:3.12-slim',
command: './serve',
gitBranch: 'main',
port: 8000,
createdAt: '2026-02-18T08:00:00Z',
updatedAt: '2026-03-29T18:00:00Z',
},
@@ -160,7 +204,7 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'worker',
status: 'failed',
environment: 'production',
image: 'ghcr.io/containr/evaluator:latest',
image: 'python:3.12-slim',
command: './run-jobs',
gitBranch: 'main',
createdAt: '2026-02-18T08:03:00Z',
@@ -192,6 +236,7 @@ export const demoVariablesByProject: Record<string, Record<string, ServiceVariab
] satisfies ServiceVariable[]
),
'demo-svc-postgres': [],
'demo-svc-redis': [],
},
'demo-project-growth': {
'demo-growth-site': (
@@ -329,3 +374,561 @@ export const demoCronJobsByService: Record<string, CronJobEntity[]> = {
export function getDemoCronJobsByService(serviceId: string): CronJobEntity[] {
return demoCronJobsByService[serviceId] ?? [];
}
export const demoBuilds: BuildEntity[] = [
{
id: 'demo-build-api',
projectId: 'demo-project-core',
serviceId: 'demo-svc-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-gateway',
imageTag: 'sha-1f2e3d4',
size: 156_000_000,
log: '[demo] Build finished successfully.',
metadata: { branch: 'main' },
},
{
id: 'demo-build-worker',
projectId: 'demo-project-core',
serviceId: 'demo-svc-worker',
status: 'running',
progress: 54,
startedAt: new Date(Date.now() - 2 * 60_000).toISOString(),
imageName: 'ghcr.io/containr/queue-worker',
imageTag: 'sha-9a8b7c6',
size: 0,
log: '[demo] Building image layers...',
metadata: { branch: 'feature/queue' },
},
{
id: 'demo-build-site',
projectId: 'demo-project-growth',
serviceId: 'demo-growth-site',
status: 'success',
progress: 100,
startedAt: new Date(Date.now() - 3 * 3_600_000).toISOString(),
completedAt: new Date(Date.now() - 3 * 3_600_000 + 95_000).toISOString(),
imageName: 'ghcr.io/containr/marketing-site',
imageTag: 'sha-77aa1c0',
size: 89_000_000,
log: '[demo] Build finished successfully.',
metadata: { branch: 'main' },
},
{
id: 'demo-build-ml',
projectId: 'demo-project-ml',
serviceId: 'demo-ml-worker',
status: 'failed',
progress: 71,
startedAt: new Date(Date.now() - 26 * 3_600_000).toISOString(),
completedAt: new Date(Date.now() - 26 * 3_600_000 + 240_000).toISOString(),
imageName: 'ghcr.io/containr/batch-evaluator',
imageTag: 'sha-3d4e5f6',
size: 0,
error: 'pip install failed: could not resolve torch==2.4.0',
log: '[demo] ERROR: dependency resolution failed.',
metadata: { branch: 'experiment/eval-v2' },
},
];
export const demoHostMonitoring: HostMonitoring = {
hostname: 'containr-node-1',
os: 'linux',
architecture: 'amd64',
cpu: { cores: 16 },
memory: {
total: 13.5 * 1024 * 1024 * 1024,
used: 9.8 * 1024 * 1024 * 1024,
available: 3.7 * 1024 * 1024 * 1024,
usagePercent: 72,
},
storage: {
path: '/var/lib/containr',
total: 465 * 1024 * 1024 * 1024,
used: 338 * 1024 * 1024 * 1024,
available: 127 * 1024 * 1024 * 1024,
usagePercent: 73,
},
load: { load1m: 2.2, load5m: 2.6, load15m: 2.4 },
uptimeSeconds: 1_828_800,
dockerAvailable: true,
docker: { containers: 11, images: 34, driver: 'overlay2', server: '27.3.1' },
scope: 'host',
collectedAt: new Date().toISOString(),
};
export const demoHAStatus: HAStatus = {
enabled: true,
nodes: { total: 3, healthy: 3, unhealthy: 0 },
health_checks: { total: 6, healthy: 5, unhealthy: 1 },
alerts: { active: 1 },
};
export const demoHAAlerts: HAAlert[] = [
{
id: 'demo-alert-worker-heartbeat',
rule_id: 'health-check-failing',
status: 'firing',
severity: 'warning',
message: 'Health check failing for demo-svc-worker — 2 consecutive misses',
starts_at: new Date(Date.now() - 14 * 60_000).toISOString(),
},
];
export const demoHAPolicies: FailoverPolicy[] = [
{
service_id: 'demo-svc-api',
enabled: true,
failover_strategy: 'active_passive',
min_healthy_nodes: 1,
max_failures: 3,
},
{
service_id: 'demo-svc-postgres',
enabled: true,
failover_strategy: 'graceful',
min_healthy_nodes: 1,
max_failures: 2,
},
];
export const demoHAHealthResults: HAHealthResult[] = [
{
check_id: 'demo-svc-api:http',
status: 'healthy',
message: 'GET /healthz returned 200',
latency: 42_000_000,
timestamp: new Date(Date.now() - 30_000).toISOString(),
},
{
check_id: 'demo-svc-postgres:tcp',
status: 'healthy',
message: 'TCP connect to :5432 succeeded',
latency: 8_000_000,
timestamp: new Date(Date.now() - 45_000).toISOString(),
},
{
check_id: 'demo-svc-worker:exec',
status: 'unhealthy',
message: 'Heartbeat file stale — last write 4m ago',
latency: 0,
timestamp: new Date(Date.now() - 60_000).toISOString(),
},
];
export const demoSecurityMetrics: SecurityMetrics = {
security_score: 86,
vulnerabilities: { total: 5, critical: 0, high: 1, medium: 2, low: 2, open: 3, resolved: 2 },
latest_scan: {
id: 'demo-scan-latest',
score: 86,
status: 'completed',
scanned_at: new Date(Date.now() - 5 * 3_600_000).toISOString(),
},
compliance: { overall_status: 'assessed', score: 92, last_assessed: new Date(Date.now() - 2 * 86_400_000).toISOString() },
};
export const demoVulnerabilities: Vulnerability[] = [
{
id: 'demo-vuln-1',
type: 'dependency',
severity: 'high',
title: 'openssl 3.0.13 — CVE-2024-5535',
description: 'API Gateway base image ships an openssl release with a known TLS session-ticket flaw.',
service_id: 'demo-svc-api',
project_id: 'demo-project-core',
status: 'open',
found_at: new Date(Date.now() - 5 * 3_600_000).toISOString(),
},
{
id: 'demo-vuln-2',
type: 'configuration',
severity: 'medium',
title: 'Postgres listens on 0.0.0.0',
description: 'Database port is bound on all interfaces; restrict to the internal network.',
service_id: 'demo-svc-postgres',
project_id: 'demo-project-core',
status: 'open',
found_at: new Date(Date.now() - 5 * 3_600_000).toISOString(),
},
{
id: 'demo-vuln-3',
type: 'dependency',
severity: 'low',
title: 'express 4.19.x — outdated minor',
description: 'A newer patch release is available for the web frontend dependency.',
service_id: 'demo-svc-web',
project_id: 'demo-project-core',
status: 'open',
found_at: new Date(Date.now() - 2 * 86_400_000).toISOString(),
},
{
id: 'demo-vuln-4',
type: 'dependency',
severity: 'medium',
title: 'redis 7.2.3 — CVE-2024-31227',
description: 'Resolved by upgrading the cache image to 7.2.5.',
service_id: 'demo-growth-cache',
project_id: 'demo-project-growth',
status: 'resolved',
found_at: new Date(Date.now() - 9 * 86_400_000).toISOString(),
resolved_at: new Date(Date.now() - 6 * 86_400_000).toISOString(),
},
];
export const demoSecurityScans: SecurityScan[] = [
{
id: 'demo-scan-1',
project_id: 'demo-project-core',
scan_type: 'comprehensive',
status: 'completed',
findings_count: 3,
started_at: new Date(Date.now() - 5 * 3_600_000).toISOString(),
completed_at: new Date(Date.now() - 5 * 3_600_000 + 48_000).toISOString(),
},
{
id: 'demo-scan-2',
project_id: 'demo-project-core',
service_id: 'demo-svc-api',
scan_type: 'dependency',
status: 'completed',
findings_count: 1,
started_at: new Date(Date.now() - 2 * 86_400_000).toISOString(),
completed_at: new Date(Date.now() - 2 * 86_400_000 + 31_000).toISOString(),
},
{
id: 'demo-scan-3',
project_id: 'demo-project-core',
scan_type: 'configuration',
status: 'failed',
findings_count: 0,
started_at: new Date(Date.now() - 4 * 86_400_000).toISOString(),
completed_at: new Date(Date.now() - 4 * 86_400_000 + 12_000).toISOString(),
},
];
export 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,
ownerId: null,
isPublic: false,
},
{
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,
ownerId: null,
isPublic: false,
},
{
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,
ownerId: null,
isPublic: false,
},
{
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,
ownerId: null,
isPublic: false,
},
{
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,
ownerId: null,
isPublic: false,
},
{
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,
ownerId: null,
isPublic: false,
},
{
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,
ownerId: null,
isPublic: false,
},
];
export 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',
},
],
},
};
+17
View File
@@ -0,0 +1,17 @@
import { useSearchParams } from 'react-router-dom';
/**
* Build-time flag for public demo deployments that ship without a backend
* (e.g. the Vercel showcase). When enabled every route behaves as if
* `?demo=1` was passed — fixtures render instead of API calls.
*/
export const PUBLIC_DEMO = (import.meta.env.VITE_PUBLIC_DEMO ?? '') === '1';
export function isDemoSearch(search: string): boolean {
return PUBLIC_DEMO || new URLSearchParams(search).get('demo') === '1';
}
export function useDemoMode(): boolean {
const [searchParams] = useSearchParams();
return PUBLIC_DEMO || searchParams.get('demo') === '1';
}
@@ -78,7 +78,7 @@ export function LineChart({
return (
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 320, height }}>
<RechartsLineChart data={chartData}>
<Line
type={smooth ? 'monotone' : 'linear'}
@@ -105,7 +105,7 @@ export function LineAreaChart({
return (
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 320, height }}>
<AreaChart data={chartData}>
<defs>
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
@@ -142,7 +142,7 @@ export function MultiLineChart({
return (
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 320, height }}>
<AreaChart data={chartData}>
<defs>
{datasets.map((dataset, index) => {
@@ -231,7 +231,7 @@ export function BarChart({
{data.map((value, index) => (
<div
key={index}
className="flex-1 cursor-pointer rounded-[var(--radius-xs)] transition-all hover:opacity-80"
className="flex-1 cursor-pointer rounded-[var(--radius-sm)] transition-all hover:opacity-80"
style={{
height: `${value}%`,
background: color,
@@ -0,0 +1,25 @@
import { Link } from 'react-router-dom';
import { Sparkles } from 'lucide-react';
/**
* Shown by auth/admin-scoped pages when `?demo=1` is active — the demo only
* carries sample project/platform data, not real accounts or admin state.
*/
export function DemoRestricted({ feature }: { feature: string }) {
return (
<div className="flex min-h-[60vh] items-center justify-center p-8">
<div className="panel max-w-md p-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--accent-primary-soft)]">
<Sparkles size={22} className="text-[var(--accent-primary)]" />
</div>
<p className="text-lg font-semibold text-[var(--text-primary)]">Not part of the demo</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
{feature} needs a live Containr session. The demo covers sample projects, services, and platform views only.
</p>
<Link to="/projects?demo=1" className="v-btn mt-5 inline-flex">
Back to demo projects
</Link>
</div>
</div>
);
}
@@ -61,7 +61,7 @@ export class ErrorBoundary extends Component<Props, State> {
return (
<div className="min-h-screen bg-[var(--bg-void)] flex items-center justify-center p-4">
<div className="max-w-md w-full bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg p-6 space-y-4">
<div className="max-w-md w-full bg-[var(--bg-primary)] border border-[var(--border-subtle)] rounded-lg p-6 space-y-4">
<div className="space-y-2">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
Something went wrong
@@ -72,8 +72,8 @@ export class ErrorBoundary extends Component<Props, State> {
</div>
{import.meta.env.DEV && this.state.error && (
<div className="bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded p-3">
<p className="text-xs font-mono text-[var(--text-error)] break-all">
<div className="bg-[var(--surface-muted)] border border-[var(--border-subtle)] rounded p-3">
<p className="text-xs font-mono text-[var(--error)] break-all">
{this.state.error.toString()}
</p>
</div>
@@ -82,13 +82,13 @@ export class ErrorBoundary extends Component<Props, State> {
<div className="flex gap-2">
<button
onClick={this.handleReset}
className="flex-1 px-4 py-2 bg-[var(--bg-accent)] text-[var(--text-primary)] rounded hover:opacity-90 transition-opacity"
className="flex-1 px-4 py-2 bg-[var(--accent-primary-soft)] text-[var(--text-primary)] rounded hover:opacity-90 transition-opacity"
>
Try Again
</button>
<button
onClick={() => window.location.reload()}
className="flex-1 px-4 py-2 bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded hover:bg-[var(--bg-tertiary)] transition-colors"
className="flex-1 px-4 py-2 bg-[var(--surface-muted)] text-[var(--text-primary)] border border-[var(--border-subtle)] rounded hover:bg-[var(--surface-soft)] transition-colors"
>
Reload Page
</button>
@@ -12,7 +12,7 @@ export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerPr
return (
<div
className={`${sizeClasses[size]} border-[var(--border-primary)] border-t-[var(--text-accent)] rounded-full animate-spin ${className}`}
className={`${sizeClasses[size]} border-[var(--border-subtle)] border-t-[var(--accent-primary)] rounded-full animate-spin ${className}`}
role="status"
aria-label="Loading"
>
@@ -28,7 +28,7 @@ interface LoadingOverlayProps {
export function LoadingOverlay({ message = 'Loading...' }: LoadingOverlayProps) {
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg p-6 flex flex-col items-center gap-4">
<div className="bg-[var(--bg-primary)] border border-[var(--border-subtle)] rounded-lg p-6 flex flex-col items-center gap-4">
<LoadingSpinner size="lg" />
<p className="text-sm text-[var(--text-secondary)]">{message}</p>
</div>
@@ -1,3 +1,4 @@
export * from './DemoGate';
export * from './ErrorBoundary';
export * from './LoadingSpinner';
export * from './Toast';
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": "vite",
"buildCommand": "npm run build",
"outputDirectory": "dist",
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
-2
View File
@@ -1,2 +0,0 @@
declare const _default: import("vite").UserConfig;
export default _default;
-51
View File
@@ -1,51 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
// Enable code splitting and optimization
rollupOptions: {
output: {
manualChunks: {
// Split vendor libraries
vendor: ['react', 'react-dom', '@tanstack/react-query'],
ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu', 'lucide-react'],
utils: ['date-fns', 'clsx', 'tailwind-merge']
},
// Optimize chunk naming for better caching
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'
}
},
// Enable source maps for production debugging
sourcemap: true,
// Optimize bundle size
minify: 'terser',
// Set appropriate chunk size limit for better caching
chunkSizeWarningLimit: 1000
},
// Optimize dependencies during build
optimizeDeps: {
include: ['react', 'react-dom', '@tanstack/react-query', 'date-fns']
},
// Development server optimization
server: {
fs: {
// Allow serving files from project root
allow: ['..']
}
},
// Preview server optimization
preview: {
port: 4173,
strictPort: true
}
});
+3 -1
View File
@@ -30,7 +30,9 @@ export default defineConfig({
// Split vendor libraries
vendor: ['react', 'react-dom', '@tanstack/react-query'],
ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu', 'lucide-react'],
utils: ['date-fns', 'clsx', 'tailwind-merge']
utils: ['date-fns', 'clsx', 'tailwind-merge'],
canvas: ['@xyflow/react'],
charts: ['recharts']
},
// Optimize chunk naming for better caching
chunkFileNames: 'assets/js/[name]-[hash].js',
+3
View File
@@ -67,6 +67,9 @@ services:
DB_USER: ${POSTGRES_USER:-containr_user}
DB_PASSWORD: ${POSTGRES_PASSWORD:-dev_password_123}
CONTAINR_AGENT_AUTH_TOKEN: ${CONTAINR_AGENT_AUTH_TOKEN:-}
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_NAME: ${ADMIN_NAME:-}
CONTAINR_AGENT_AUTH_TOKENS: ${CONTAINR_AGENT_AUTH_TOKENS:-}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
+260
View File
@@ -987,6 +987,149 @@ paths:
type: array
items:
$ref: '#/components/schemas/ServiceTemplate'
post:
tags:
- Templates
summary: Create a user template
description: Create a user-owned template (JSON definition). Requires authentication; the caller becomes the template owner.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TemplateWriteRequest'
responses:
'201':
description: Template created
content:
application/json:
schema:
type: object
properties:
template:
$ref: '#/components/schemas/ServiceTemplate'
/admin/overview:
get:
tags:
- Admin
summary: Platform overview
description: Admin-only aggregate statistics plus the pending project approval queue.
security:
- bearerAuth: []
responses:
'200':
description: Platform overview
content:
application/json:
schema:
type: object
properties:
stats:
type: object
additionalProperties:
type: integer
pending_projects:
type: array
items:
$ref: '#/components/schemas/Project'
/admin/users:
get:
tags:
- Admin
summary: List users
description: Admin-only listing of all platform users.
security:
- bearerAuth: []
responses:
'200':
description: Users retrieved
content:
application/json:
schema:
type: object
properties:
users:
type: array
items:
$ref: '#/components/schemas/AdminUser'
/admin/users/{id}:
patch:
tags:
- Admin
summary: Set user admin flag
description: Grant or revoke platform admin. Admins cannot revoke their own flag.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- is_admin
properties:
is_admin:
type: boolean
responses:
'200':
description: User updated
content:
application/json:
schema:
type: object
properties:
message:
type: string
/admin/projects/{id}:
patch:
tags:
- Admin
summary: Set project approval
description: Approve or unapprove a project for public listing.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- is_approved
properties:
is_approved:
type: boolean
responses:
'200':
description: Project updated
content:
application/json:
schema:
type: object
properties:
project:
$ref: '#/components/schemas/Project'
message:
type: string
/templates/{id}:
get:
@@ -1010,6 +1153,60 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/TemplateDetailResponse'
put:
tags:
- Templates
summary: Update a user template
description: Update a user-created template. Official templates cannot be modified; only the owner or an admin may update.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
description: Template ID
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TemplateWriteRequest'
responses:
'200':
description: Template updated
content:
application/json:
schema:
type: object
properties:
template:
$ref: '#/components/schemas/ServiceTemplate'
delete:
tags:
- Templates
summary: Delete a user template
description: Delete a user-created template. Official templates cannot be deleted; only the owner or an admin may delete.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
description: Template ID
schema:
type: string
responses:
'200':
description: Template deleted
content:
application/json:
schema:
type: object
properties:
message:
type: string
/templates/{id}/deploy:
post:
@@ -4402,6 +4599,23 @@ components:
type: string
description: Optional avatar image URL
AdminUser:
type: object
properties:
id:
type: string
email:
type: string
format: email
name:
type: string
avatar_url:
type: string
is_admin:
type: boolean
created_at:
type: string
Project:
type: object
properties:
@@ -4417,6 +4631,9 @@ components:
owner_id:
type: string
description: Project owner ID
is_approved:
type: boolean
description: Whether the project is approved for public listing. New projects default to false and appear publicly only after admin approval.
created_at:
type: string
format: date-time
@@ -4995,6 +5212,13 @@ components:
is_official:
type: boolean
description: Whether template is an official preset
owner_id:
type: string
nullable: true
description: Owning user ID for user-created templates; null for official presets
is_public:
type: boolean
description: Whether a user-created template is published to the public catalog
created_at:
type: string
format: date-time
@@ -5004,6 +5228,36 @@ components:
format: date-time
description: Last update timestamp
TemplateWriteRequest:
type: object
required:
- name
- config
properties:
name:
type: string
description: Template display name
description:
type: string
description: Template description
category:
type: string
description: Template category (web, database, cache, queue, monitoring, storage, app, other)
logo:
type: string
description: Template logo URL
config:
type: object
description: Template config object (type, runtime, commands, port, environment)
variables:
type: array
items:
$ref: '#/components/schemas/TemplateVariable'
description: Configurable variables exposed at deploy time
is_public:
type: boolean
description: Publish this template to the public catalog (visible anonymously)
TemplateConfig:
type: object
properties:
@@ -5798,6 +6052,12 @@ components:
type: string
server:
type: string
os:
type: string
scope:
type: string
description: What the telemetry measures — `host` for a native daemon, `docker-desktop-vm` when Docker runs inside a Desktop VM.
enum: [host, docker-desktop-vm]
collected_at:
type: string
format: date-time
Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 145 KiB

+2
View File
@@ -0,0 +1,2 @@
.vercel
.env*
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#cdf06f"/>
<stop offset="1" stop-color="#7fb524"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="15" fill="url(#bg)"/>
<g fill="none" stroke="#131507" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="15" y="18" width="34" height="26" rx="4"/>
<path d="M23 18v26M31 18v26M39 18v26"/>
<path d="M20 51h24"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 582 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more