Compare commits

...
22 Commits
Author SHA1 Message Date
Tomáš Dvořák 6949c01464 Merge pull request #14 from Dvorinka/feat/personal-access-tokens
feat: personal access tokens with scoped bearer auth
2026-10-09 00:41:09 +02:00
Tomas Dvorak 17e429b86b docs: ROADMAP2 — dflow successor plan and phased build
Deep analysis of dflow (~43 queue processors, ~30 collections, ~180
server actions) mapped against Containr, sectioned into phases A–G.
Phase A (agent spine) is in progress — this commit adds PATs.

Authored By: TDvorak <[email protected]>
2026-10-08 14:31:39 +02:00
Tomas Dvorak e881747388 feat: personal access tokens with scoped bearer auth
Add cnp_ personal access tokens so the CLI, MCP server, and external
agents authenticate without a browser session — the agent-first spine
from ROADMAP2 phase A. Modeled on agent_auth_tokens: raw token shown
once, only SHA-256 hash stored.

- user_tokens table (goose migration + sqlc), name/prefix/scope/expiry,
  owner-scoped revoke, last_used_at stamping
- middleware: cnp_ bearer resolution, read scope gates mutations with
  403 SCOPE_INSUFFICIENT, non-admin scopes cap is_admin even for admin
  owners — a token never outranks its scope or its owner
- GET/POST/DELETE /api/v1/user/tokens + OpenAPI + generated types
- Settings UI: create with scope/expiry pickers, one-time token display
  with copy, list with last-used/expiry, confirm-revoke
- docs: OPERATIONS.md section, README feature line

Verified end-to-end against live Postgres: create/list/revoke, read
scope blocks PUT (403), write PAT on admin owner blocked from admin
routes (403), admin scope works, revoked token returns 401.

Authored By: TDvorak <[email protected]>
2026-10-08 14:31:29 +02:00
Tomáš Dvořák f9ae5b962c chore: add FUNDING.yml — GitHub Sponsors button
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
2026-10-04 19:50:34 +02:00
Tomáš DvořákandDevin 15b5b0e6e7 fix: HA checks resolve service targets over project network; persist checks (#13)
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
- Health checks for local services dialed 127.0.0.1 from inside the backend
  container — the host's published port is unreachable there. Service-bound
  checks now resolve the service's DNS name on its project network (backend
  joins on demand), falling back to the service's stored port. A resolver
  failure surfaces as TARGET_UNRESOLVED instead of a fake loopback probe.
- Checks lived only in memory: a backend restart silently deleted every
  configured health check. ha_health_checks table + write-through on
  add/update/tick and hydrate-on-boot, matching the scaling-policy pattern.
- sqlc schema.sql gained the runtime columns added by later migrations —
  the stale snapshot already caused one silently-dropped-column bug.
- OpenAPI: runtime response now documents ports and health fields; types
  regenerated.
- Buttons no longer wrap ("+ new project" rendered on two lines).

Verified live: service check probes whoami-web:80 via DNS → healthy;
check survives a backend restart and rehydrates.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-04 09:37:47 +02:00
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
83 changed files with 7228 additions and 1301 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
github: Dvorinka
+1
View File
@@ -123,3 +123,4 @@ bin
ref/ ref/
.playwright-mcp/ .playwright-mcp/
canvas-*.png canvas-*.png
.vercel
+2
View File
@@ -0,0 +1,2 @@
/*
!/site
+1 -1
View File
@@ -56,7 +56,7 @@ dedicated console.
- **Template catalog** — 30 official templates (NocoDB, Plausible, MinIO, n8n, …) plus user-defined templates with JSON upload and owner-scoped edit/delete - **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 - **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
- **HA & scaling** — failover policies, node agents, autoscaling rules - **HA & scaling** — failover policies, node agents, autoscaling rules
- **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vulnerability scans - **Auth & security** — Better Auth sessions (email/password self-hosted), personal access tokens with `read`/`write`/`admin` scopes for CLI and agents, audit logs, vulnerability scans
- **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline - **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 - **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
+396
View File
@@ -0,0 +1,396 @@
# Containr — Roadmap 2: The dflow Convergence
Last verified: **2026-10-08** — deep pass over the full dflow codebase
(`~/Desktop/PROG+HTML/dflow`, community fork of dflow-sh/dflow, frozen for
preservation) against Containr `main` (`15b5b0e`).
Goal: make Containr the superior successor — absorb everything worth having
from dflow, then go past it and past Railway. Same power for humans and
agents: the UI is the friendly face, the CLI and MCP are the machine face,
all three sit on the same API. **Branding stays Containr/Vertice** —
white-labeling is an admin option, not an identity change.
---
## 1. What dflow actually is (verified)
Next.js 15 + PayloadCMS 3 + MongoDB + Redis/BullMQ. Runtime model: every
host is a remote server driven **over SSH** with Dokku auto-installed as the
per-app container manager. Async work = 43 BullMQ queue processors
(`src/queues/**`), live logs via Redis pub/sub → SSE endpoint, persisted to
deployment records. ~30 collections, ~180 server actions, 11 dashboard
routes per service (general, deployments, logs, environment, domains,
volumes, proxy, scaling, backups, settings).
### Full capability matrix — dflow → Containr
Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable (dokku/SSH-specific)
#### Deployment pipeline
| dflow capability | Containr status | Action |
|---|---|---|
| Queued deployments, serialized per server, cancellable while queued | ❌ goroutine fire-and-forget; `CancelDeployment` exists in engine but is **never routed** | Add deploy queue + cancel route |
| Deployment states `queued → building → success/failed`, live SSE logs + persisted record | 🟡 states exist (`building→running`); WS streams builds; no queued state | Extend status + stream |
| Redeploy cache modes (`ps:rebuild` vs fresh pull); webhook pushes force `no-cache` | ❌ always rebuilds | Add `cache` flag on deploy/redeploy |
| Cancel queued deployment from UI | ❌ (engine fn unrouted) | Route it |
| Per-service job ordering | ❌ concurrent deploys of one service can race | Serialize per service |
#### Service configuration
| dflow capability | Containr status | Action |
|---|---|---|
| Multi-domain per service + default flag + cert type | ❌ single `services.domain` | `service_domains` table |
| Cert types: letsencrypt / none / Cloudflare Origin / **CF-for-SaaS custom hostnames** | 🟡 Traefik ACME only | LE default; CF custom hostnames deferred (only needed if hosting tenant domains) |
| DNS preflight check (`checkDNSConfigAction`) | ❌ | `GET /services/:id/domains/check` |
| Volumes per service + dangling-volume browser/attach/delete | ❌ engine supports `VolumeMounts`; no API/UI | `services.volumes` jsonb + Volumes tab |
| Resource **limits and reservations**, per-server defaults | 🟡 `cpu_limit`/`memory_limit` int columns (unclear units) | explicit string units + reserves + instance defaults |
| Pre-deploy capacity check (dokku binary interceptor) | ❌ | Node capacity check pre-deploy/scale |
| Maintenance mode toggle | ❌ | Traefik middleware label |
| Basic-auth gate per service (`toggleHttpAuth`) | ❌ | Traefik basic-auth middleware |
| Raw proxy config override (`setServiceNginxConfig`) | ❌ | Allowlisted `traefik_labels` jsonb |
| Service clone (full config copy) | ❌ | `POST /services/:id/clone` |
| Move service between projects (`SwitchServiceProjectDialog`) | ❌ | `PUT /services/:id {project_id}` |
| Env var save → hot-apply + optional restart (`noRestart`) | ❌ rows rewritten; containers keep stale env until manual redeploy | Apply→redeploy flow |
| Static-site builder; Heroku buildpacks | 🟡 railpack/nixpacks/dockerfile auto-detect, no user override | `services.builder` enum + static builder |
| Docker registry accounts (private registries) | ❌ | `registry_credentials` per service/instance |
#### Databases
| dflow capability | Containr status | Action |
|---|---|---|
| Managed DBs (pg/mysql/mariadb/mongo/redis/clickhouse via dokku plugins) | ✅ plus dragonfly | — |
| **External DB registration** (Neon/Atlas/RDS conn details + test) | ❌ | `provider=external` + test endpoint |
| Internal backups + restore (same-type enforced) | ✅ | — |
| Scheduled backups | ✅ cron field | — |
| **External S3 backups** (auth/schedule/unschedule ops) | ❌ | `backup_targets` + shipper |
| **Cross-server DB migration** (export → sftp hop → import) | ❌ | `POST /databases/:id/migrate` |
| Public DB port expose/unexpose | ❌ loopback only | opt-in TCP expose |
| DB → service env linking (`dokku link`) | ✅ Bind action injects `DATABASE_URL` | — |
#### Servers / infrastructure
| dflow capability | Containr status | Action |
|---|---|---|
| Attach server = paste IP + SSH key; Dokku auto-installs | 🟡 agents exist, manual install | **SSH bootstrap**: one SSH session installs agent, never needed again |
| Tailscale/Headscale, NetBird, ZeroTier enroll + mesh connect | ❌ | Agent mesh-IP detection first; auth-key enroll optional |
| AWS EC2 + Hetzner provisioning w/ cloud-init | ❌ | IaC (ROADMAP §2.2): Terraform Proxmox/AWS + Hetzner module |
| Security groups (AWS SG rules, sync) | ❌ | Under IaC provider modules |
| Server-level global/wildcard domain (`updateServerDomain`) | ❌ | `nodes.default_domain` |
| Scheduled server cleanup (dokku cleanup → docker prune) | ❌ | Agent `prune` command + schedule |
| Queue flush/inspect per server (`flushServerQueues`, `getServerQueues`) | ➖ no queue yet | Ops page shows jobs once queue exists |
| Arbitrary SSH command on server (`executeCommand`) | ➖ exec is container-scoped | Node terminal via agent (PTY) |
| Server onboarding wizard, reset, cleanup-on-delete | 🟡 token issue/revoke only | Onboarding flow + `nodes add` UX |
| Ansible playbooks (stored, runnable per server, exec history) | ➖ | **Defer** — security surface; SSH bootstrap covers provisioning |
| Per-node resource dashboards | 🟡 heartbeat telemetry exists | Node detail page |
#### Platform / org
| dflow capability | Containr status | Action |
|---|---|---|
| Multi-tenant organisations + custom RBAC (per-resource CRUD, create-limits, read-scope) | 🟡 `is_admin` + `project_members` | Defer full RBAC; design PAT scopes role-compatible |
| Team invite links/emails | 🟡 manual user create only | `POST /invites` → link join |
| User API keys → full REST API | ❌ **the big gap** — session/JWT only | PATs (Phase A) |
| Outbound webhooks (collection events, HMAC, custom headers) | ❌ | `webhooks` table + delivery log |
| Banners (global + per-tenant, dismissible) | ❌ | `banners` table + top bar |
| White-label (logo/favicon/title/OG/theme colors) | ❌ | `app_settings.branding` — **Containr remains the default brand** |
| Activity feed (icon/severity/category/metadata, 90-day TTL job) | 🟡 `audit_logs` coarser | Enrich + project feed UI |
| Admin metrics dashboard (users/servers/queued/failed counts) | 🟡 `/admin` exists | Extend |
| **Impersonate user** | ❌ | Admin support tool |
| In-app docs (content-collections markdown) | ✅ `/docs` | — |
| Dynamic client-side filter framework (535-line `filter.utils`) | 🟡 audit page filters only | Reuse pattern for tables |
| Ops "bubble" (queue monitor, terminal, sync) | ❌ | Operations page |
#### Agent surface
| dflow capability | Containr status | Action |
|---|---|---|
| Payload REST/GraphQL auto-API + per-user API keys | ❌ | PATs + OpenAPI (better: typed, documented) |
| CLI | ❌ | none in dflow — **Containr CLI is a genuine leap past it** |
| MCP | ❌ | none — same leap |
| Railway migrator script | ❌ | `containr migrate railway` |
| GitHub App **manifest flow** (instance self-provisions the App) | 🟡 env-configured App | `POST /git/github-app/manifest` flow |
| fork-sync webhook deploy trigger | ❌ | ~20 lines on receiver |
| Azure DevOps provider | ❌ | Optional (dflow does token-clone only) |
---
## 2. The spine: agent-first platform
*Agent and user on the same level.* Concretely:
- Every operation reachable by a **personal access token**, not just
sessions.
- Every operation in the **CLI** with `--json`, stable exit codes, `--yes`.
- Every operation in an **MCP server** (stdio first) so Devin/Claude Code/
Cursor/Codex drive Containr natively.
- `openapi.yaml` stays the single source of truth; UI, CLI, MCP are thin
clients.
### Phase A — Agent surface (do this FIRST; everything after inherits it)
- [ ] **Personal access tokens.** `user_tokens` table — `id`, `user_id`,
`name`, `key_prefix` (`cnp_…`), `key_hash` (sha256), `scopes`
(`read`/`write`/`admin`, later resource-scoped), `expires_at`,
`last_used_at`. Model on `agent_auth_tokens` (raw shown once, hash stored).
`POST/GET/DELETE /api/v1/user/tokens`; `middleware.Auth` accepts
`Bearer cnp_…`; Settings → Access tokens UI with one-time display +
copyable CLI/MCP config snippets.
- [ ] **CLI v2.** Current `cmd/cli` is auth + projects only. Full client:
- `auth login [--token] [--url] [--profile]` (multi-instance profiles).
- Resources: `projects services deploy logs exec variables databases
cron templates nodes scaling ha security gateway notifications
webhooks domains volumes backups admin`.
- `containr up` / `deploy` — cwd→service create-or-update→watch
(the `railway up` equivalent; single most important command).
- `containr link` (bind cwd→service), `containr open`, `containr run`,
`containr shell` (PTY once exec upgrade lands), `completion`.
- Universal `--json`, `-o yaml`, `--watch`, `--yes`; exit codes
0/1/2/3/4 (ok/err/not-found/unauthorized/conflict).
- GoReleaser → GH releases + `install.sh`; `version` command.
- Conventions from docsync/envdiff/cix/repolint.
- [ ] **MCP server** (`cmd/mcp`, same module, stdio). Tools mirror the
CLI: `list_projects create_service deploy get_logs set_variable
restart rollback create_database trigger_cron deploy_template exec
get_metrics security_scan …`. Auth: `CONTAINR_URL` + `CONTAINR_TOKEN`
or shared `~/.containr/config`. `containr mcp serve` + install snippets
for Devin/Claude/Cursor.
- [ ] **API agent ergonomics.** `Idempotency-Key` on mutating endpoints;
uniform `{error, code, details}` envelope (audit bare-string returns);
cursor pagination on growing lists; name-or-id lookup everywhere.
- [ ] **Agent docs.** Root `AGENTS.md`, `/docs` serves `llms.txt` +
`llms-full.txt`, MCP tool list generated from OpenAPI where possible.
**Gate**: an agent holding only a PAT registers a project, deploys from a
git repo, streams logs, sets vars, rolls back — zero UI. Same via CLI
for a human.
---
## 3. Phase B — Deployment pipeline maturity
dflow's one real architectural edge. Containr deploys are fire-and-forget
goroutines; this fixes correctness *and* unlocks the ops surface.
- [ ] **Deploy job queue.** Lightweight internal queue (Postgres
`deployment_queue` table + single worker loop — no new infra; Redis
exists but don't add BullMQ-style sprawl). Serialize per service;
optional per-node serialization. Status gains `queued`.
- [ ] **Cancel deployments.** Route `POST /deployments/:id/cancel` →
engine `CancelDeployment` (exists, unrouted). Queued jobs cancel
cleanly; building jobs cancel context where safe.
- [ ] **Cache modes.** `?cache=full` on redeploy → rebuild from existing
image layers vs clean pull/checkout. Webhook-triggered deploys default
to no-cache (dflow convention — prevents stale-layer surprises).
- [ ] **Queued→building→running status + persisted step logs.** Already
mostly there; add `queued`, stream over existing `/ws`.
- [ ] **Env var apply → redeploy.** Saving variables asks "restart to
apply?" (or `--apply` flag in CLI); backend triggers redeploy honoring
running state. Currently rows update but containers keep stale env —
silent-drift bug class, not just UX.
- [ ] **Encrypt variables at rest.** `environment_variables.value` is
plaintext today (verified). Envelope-encrypt `is_secret` values
(AES-GCM, key from `JWT_SECRET` or dedicated `SECRETS_KEY`); dflow
encrypts every var, we encrypt secrets — migration = transparent
encrypt-on-write + decrypt-on-read.
## 4. Phase C — Service configuration parity
- [ ] **Volumes on services** — `services.volumes` jsonb
`[{volume|host_path, container_path, read_only}]`; engine accepts
`VolumeMounts` already. Volumes tab + orphan-volume listing
(`docker volume ls -f dangling`) with delete/attach.
- [ ] **Multi-domain** — `service_domains(service_id, domain, is_default,
cert_type, status, last_checked_at)`. Domains tab: add/remove/
set-default/DNS-status badge. Keep `services.domain` as derived
default for compat.
- [ ] **DNS preflight** — `GET /services/:id/domains/check` resolves and
compares vs edge IP → `ok|wrong-target|pending`.
- [ ] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
no proxy code needed.
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with
allowlist validation (middlewares, headers, redirects).
- [ ] **Builder override** — `services.builder` enum
`auto|railpack|nixpacks|dockerfile|static`; fix hardcoded `nixpacks`
at `deployments.go:406`. Static builder = build + nginx sidecar.
- [ ] **Resource limits + reserves** — adopt dflow-style explicit units
(`"0.5"`/`"512m"`); add `cpu_reserve`/`memory_reserve` →
`NanoCPUs`/`Memory`/`MemoryReservation`; instance defaults in
`app_settings`.
- [ ] **Pre-deploy capacity check** — sum reservations vs node capacity
(agent telemetry); warn or block per policy flag.
- [ ] **Service clone** + **move between projects**.
- [ ] **Private registries** — `registry_credentials`
(server/username/secret, encrypted); `docker login` at pull time;
per-service registry link.
- [ ] **Service detail tabs to match dflow's surface**: add Domains,
Volumes, Proxy/access-controls, Backups sections to the service page
(Cron/Previews/Scaling already exist).
## 5. Phase D — Data layer maturity
- [ ] **External DB registration** — `provider='managed'|'external'`,
conn fields, `POST /databases/test-connection`, same bind flow.
- [ ] **Public DB port expose** — opt-in Traefik TCP route or host port;
default stays loopback.
- [ ] **Offsite backups** — `backup_targets` (S3-compatible:
endpoint/bucket/keys encrypted); per-DB destination; ship archives via
utility container + s3 SDK; restore-from-S3.
- [ ] **Cross-node DB migration** — `POST /databases/:id/migrate
{target_node_id}`: dump → stream transfer → provision → restore →
re-point bound services. Needs ≥2 nodes or local↔node.
- [ ] **Backup import** — upload archive → new restore point (pairs with
migrators).
## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates)
- [ ] **Template = ordered service graph** — `services[]` entries with
`{name, type, source, build, ports, volumes, variables, depends_on}`;
deploy order from dependency refs.
- [ ] **Expressions** — `${{service.KEY}}` (already the runtime ref
syntax — reuse), `{{secret(len,"charset")}}`, `{{random:int}}`,
`{{VAR:-default}}`; `POST /templates/:id/plan` returns the resolved
graph without deploying (agents love this).
- [ ] **Compose import** — `POST /templates/import/compose` (compose-go
parser → graph), `POST /templates/import/github` (compose file from
repo path). Implements `docs/superpowers/specs/2026-04-14-*`.
- [ ] **Graph deploy pipeline** — creates project + services in
dependency order, resolves vars, triggers builds; defined
partial-failure policy.
- [ ] **Visual template builder** — canvas mode: drop git/image/db
nodes, wire refs, set order, save/publish. Reuses existing canvas
primitives (dflow's `templates/compose` is exactly this).
- [ ] **Catalog growth** — convert `templates/*.md` (20 app guides) into
real graph templates; screenshots + badges per the old spec.
**Gate**: paste n8n compose → 3-service project with generated secrets
and wired refs, one click or one CLI call.
## 7. Phase F — Nodes & infrastructure
- [ ] **SSH bootstrap install** — `containr nodes add --ssh user@host` +
UI flow: one SSH session runs agent install (binary + systemd +
enroll token), SSH closed forever. dflow onboarding UX, agent-model
security.
- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/
zt0 IPs at register; backend stores + displays. Optional
`TAILSCALE_AUTH_KEY` enroll-at-provision later.
- [ ] **Node detail page** — placed services, telemetry history, agent
version + self-upgrade, **drain/cordon**, remove.
- [ ] **Node housekeeping** — agent `prune` command (docker system
prune, bounded) + optional schedule (`setServerAutoCleanup` analog —
disk pressure is the #1 small-VPS killer).
- [ ] **Multi-node scheduling** — wire `scheduling_rules` (table exists):
pin/spread/affinity, prefer-lowest-usage; deploy engine routes to
`services.node_id` via agent commands (agent already runs containers).
- [ ] **Build-on-node** — agents currently `docker run` prebuilt images
only; extend agent to pull registry images (build stays central) —
private registry creds flow through Phase C.
- [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/
OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module
(dflow's second provider), cloud-init → auto-enroll.
- [ ] **Global/wildcard domain per node** (`nodes.default_domain`) —
auto-domains for services on that node.
## 8. Phase G — Platform polish
- [ ] **Outbound webhooks** — `webhooks(url, secret, events[], headers,
enabled)` + `webhook_deliveries` log; HMAC `X-Containr-Signature`;
events: `deployment.* service.* database.* security.* agent.*`.
- [ ] **Activity feed** — enrich `audit_logs` with `severity category
icon label metadata`; per-project timeline + global feed; TTL job.
- [ ] **Banners** — `banners(title, body, level, scope, active)`; top
bar in UI + surfaced to CLI on `auth status`.
- [ ] **White-label** — `app_settings.branding`: product name, logos,
favicon, accent override, docs/support links. `GET /api/v1/branding`
public + cached. **Containr/Vertice remains default;** this is for
self-hosters who rebrand their instance.
- [ ] **Operations page** — unified view of running/queued/failed jobs
(deploys, builds, backups, cron, scans, agent commands) — dflow's
bubble panel as a real page; flush/stuck-job tools once queue exists.
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`);
`nodes/:id/terminal` via agent (dflow's wetty without the extra
container).
- [ ] **Team invites** — `POST /invites` → signed link → join flow;
replaces manual-only user creation.
- [ ] **Impersonate user** — admin support tool, audit-logged.
- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest`
returns manifest + redirect; instance self-provisions its App
(dflow pattern — removes the env-setup barrier).
- [ ] **fork-sync trigger** — accept repo-sync events on webhook
receiver.
- [ ] **Dynamic filter framework** — port dflow's `filter.utils`
pattern (typed filter configs → URL state → client filtering) for
audit logs, deployments, notifications tables.
- [ ] **Notification producers round-out** — backups, agent offline,
upgrade available, security findings.
- [ ] **Custom roles/RBAC** — defer until demanded; PAT scopes designed
forward-compatible now.
## 9. Phase H — Migration & ecosystem
- [ ] **Railway migrator** — `containr migrate railway --token --project
--dry-run`: Railway GraphQL → services/vars/domains → recreate via
PAT. Landing-page pitch: "off Railway in one command."
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST
export or Mongo dump: servers→nodes, projects, services(+vars/
domains/volumes), databases, templates. Onboards dflow refugees.
- [ ] **`containr import compose`** — Phase-E parser straight to a
project (no template intermediate).
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL`
fetch/publish; bundled catalog works offline (dflow's self-contained
principle — nothing phones home).
## 10. Carried-over debt (ROADMAP.md items still open)
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit →
`gateway/proxy.go` → metrics. Management API exists; proxy unwired.
- [ ] **Preview environments runtime** — real build+deploy+subdomain+TTL
(bookkeeping only now). Pairs with clone + multi-domain.
- [ ] **Environments** — env switcher, per-env vars/domains, promote.
- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`.
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
webhook table tests, Playwright e2e golden path.
- [ ] **First-run wizard** — admin → GitHub App (manifest flow from
Phase G makes this self-serve) → Tunnel → done.
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release.
## 11. Deliberately not imported from dflow
- Dokku-over-SSH runtime — our Docker API + agent model is strictly
better.
- Per-server-named BullMQ queues — our queue stays a single ordered
table.
- Payload/Mongo/Next.js stack, multi-tenant plugin machinery.
- Ansible playbook runner — arbitrary code-on-node is a security
surface; SSH bootstrap + agent commands cover the real needs.
Revisit only with a concrete demand.
- `isAdmin`-only collection access — our owner/admin/public model is
richer already.
- Netdata install flow — our agent telemetry + optional Beszel covers
monitoring; don't ship a third dashboard stack.
## 12. Recommended order
```
A (agent surface) ──► B (deploy pipeline) ──► C/D (service+data depth)
│ │
▼ ▼
CLI/MCP dogfood every feature E (templates v2)
│
F (nodes/infra) ──► H (migrators)
│
G (polish) + debt
```
Why this order: PATs+CLI+MCP first = every later feature ships
user-and-agent-visible at once. The queue (B) is prerequisite for
the ops surface and correct concurrency. Templates v2 needs volumes,
multi-domain, clone. Migrators last — they're launch marketing, not
foundation.
## 13. Design rules
- **API is the contract.** UI, CLI, MCP are thin clients over
`openapi.yaml`. A feature isn't done until all three reach it.
- **Everything scriptable.** JSON in/out, `--json`/`--yes`, uniform
pagination, name-or-id addressing.
- **Same powers, same position.** Nothing user-facing that isn't
API-facing; agent-visible state (tokens, jobs) visible to users too.
- **dflow is a donor, not a dependency.** Port semantics, not code.
- **Containr branding is the default**; white-label is instance config.
- **Self-hosted first** — no required external service, no phone-home.
+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)
}
}
+70 -32
View File
@@ -23,6 +23,7 @@ import (
dockercontainer "github.com/docker/docker/api/types/container" dockercontainer "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry" "github.com/docker/docker/api/types/registry"
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -196,7 +197,7 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
} }
db.Settings = h.generateMockSettings() db.Settings = h.generateMockSettings()
db.ConnectionURL = h.resolveConnectionURL(db) db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c))
} else { } else {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
db.ConnectionURL = "" db.ConnectionURL = ""
@@ -235,7 +236,7 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
} }
db.Settings = h.generateMockSettings() db.Settings = h.generateMockSettings()
db.ConnectionURL = h.resolveConnectionURL(db) db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c))
} else { } else {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
db.ConnectionURL = "" db.ConnectionURL = ""
@@ -942,33 +943,47 @@ func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
} }
} }
// resolveConnectionURL returns the stored connection URL for privileged
// viewers, or "" when the runtime has not produced one (not provisioned yet).
func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string { func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string {
if strings.TrimSpace(db.ConnectionURL) != "" { return strings.TrimSpace(db.ConnectionURL)
return db.ConnectionURL
}
return h.generateConnectionURL(db)
} }
func (h *DatabaseHandler) generateConnectionURL(db DatabaseService) string { // requestHostname returns the host the client used to reach the API, without
safeName := sanitizeDBIdentifier(db.Name, "app") // the port. Empty when it cannot be determined.
switch db.Type { func requestHostname(c *gin.Context) string {
case "postgresql": host := strings.TrimSpace(c.Request.Host)
return fmt.Sprintf("postgresql://user:password@%s.containr.local:5432/%s", safeName, safeName) if host == "" {
case "redis":
return fmt.Sprintf("redis://%s.containr.local:6379", safeName)
case "dragonfly":
return fmt.Sprintf("redis://%s.containr.local:6379", safeName)
case "mysql":
return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", safeName, safeName)
case "mariadb":
return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", safeName, safeName)
case "mongodb":
return fmt.Sprintf("mongodb://user:password@%s.containr.local:27017/%s", safeName, safeName)
case "clickhouse":
return fmt.Sprintf("http://%s.containr.local:8123", safeName)
default:
return "" return ""
} }
if h, _, err := net.SplitHostPort(host); err == nil {
return strings.Trim(h, "[]")
}
return strings.Trim(host, "[]")
}
// rewriteLoopbackURL swaps loopback/unspecified hosts in rawURL for the host
// the caller actually used, so URLs handed to remote browsers are usable.
func rewriteLoopbackURL(rawURL, host string) string {
if rawURL == "" || host == "" {
return rawURL
}
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
switch strings.ToLower(u.Hostname()) {
case "localhost", "127.0.0.1", "0.0.0.0", "::1", "":
default:
return rawURL
}
port := u.Port()
if port == "" {
u.Host = host
} else {
u.Host = net.JoinHostPort(host, port)
}
return u.String()
} }
func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService { func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService {
@@ -1223,13 +1238,13 @@ func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseNam
defer cancel() defer cancel()
if h.dockerClient == nil { if h.dockerClient == nil {
fallbackURL := h.generateConnectionURL(DatabaseService{Name: databaseName, Type: dbType}) _ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{})
_ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{String: fallbackURL, Valid: fallbackURL != ""})
return return
} }
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables) connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables)
if err != nil { if err != nil {
log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err)
_ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{}) _ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{})
return return
} }
@@ -1337,7 +1352,7 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
if err != nil { if err != nil {
return "", err return "", err
} }
if err := waitForRuntimePortReadiness(ctx, hostPort); err != nil { if err := h.waitForDatabaseReadiness(ctx, containerID, hostPort, plan.Port.Port()); err != nil {
return "", err return "", err
} }
@@ -1371,12 +1386,24 @@ func (h *DatabaseHandler) resolvePublishedHostPort(ctx context.Context, containe
return "", fmt.Errorf("published host port not available") return "", fmt.Errorf("published host port not available")
} }
func waitForRuntimePortReadiness(ctx context.Context, hostPort string) error { // waitForDatabaseReadiness probes the database's TCP port until it accepts a
addr := net.JoinHostPort("127.0.0.1", strings.TrimSpace(hostPort)) // connection. The published port lives on host loopback, which a containerized
deadline := time.Now().Add(30 * time.Second) // backend cannot reach; in that case the database is additionally attached to
// the backend's own network and probed by container IP.
func (h *DatabaseHandler) waitForDatabaseReadiness(ctx context.Context, containerID, hostPort, containerPort string) error {
probeAddr := net.JoinHostPort("127.0.0.1", strings.TrimSpace(hostPort))
if backendNet := h.dockerClient.OwnNetworkName(ctx); backendNet != "" {
if err := h.dockerClient.ConnectNetwork(ctx, backendNet, containerID, network.EndpointSettings{}); err != nil {
log.Printf("containr: attach database %s to %s failed: %v", containerID, backendNet, err)
}
if ip := h.containerIPOnNetwork(ctx, containerID, backendNet); ip != "" {
probeAddr = net.JoinHostPort(ip, containerPort)
}
}
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 1500*time.Millisecond) conn, err := net.DialTimeout("tcp", probeAddr, 1500*time.Millisecond)
if err == nil { if err == nil {
_ = conn.Close() _ = conn.Close()
return nil return nil
@@ -1389,7 +1416,18 @@ func waitForRuntimePortReadiness(ctx context.Context, hostPort string) error {
} }
} }
return fmt.Errorf("database runtime did not become reachable on %s", addr) return fmt.Errorf("database runtime did not become reachable on %s", probeAddr)
}
func (h *DatabaseHandler) containerIPOnNetwork(ctx context.Context, containerID, networkName string) string {
inspect, err := h.dockerClient.GetContainer(ctx, containerID)
if err != nil || inspect.NetworkSettings == nil {
return ""
}
if ep, ok := inspect.NetworkSettings.Networks[networkName]; ok && ep != nil {
return strings.TrimSpace(ep.IPAddress)
}
return ""
} }
func (h *DatabaseHandler) ensureImage(ctx context.Context, imageRef string) error { func (h *DatabaseHandler) ensureImage(ctx context.Context, imageRef string) error {
+39 -7
View File
@@ -51,15 +51,16 @@ func TestDatabaseConnectionURLAndDefaultVersion(t *testing.T) {
} }
for _, tc := range cases { for _, tc := range cases {
url := handler.generateConnectionURL(DatabaseService{ plan, err := buildDatabaseRuntimePlan(tc.dbType, "example", nil)
Type: tc.dbType, if err != nil {
Name: "example", t.Fatalf("buildDatabaseRuntimePlan(%q) failed: %v", tc.dbType, err)
})
if url == "" {
t.Fatalf("generateConnectionURL returned empty for type %q", tc.dbType)
} }
url := plan.ConnectionURL("15432")
if len(tc.urlContains) > 0 && !strings.HasPrefix(url, tc.urlContains) { if len(tc.urlContains) > 0 && !strings.HasPrefix(url, tc.urlContains) {
t.Fatalf("generateConnectionURL(%q) = %q, expected prefix %q", tc.dbType, url, tc.urlContains) t.Fatalf("ConnectionURL(%q) = %q, expected prefix %q", tc.dbType, url, tc.urlContains)
}
if !strings.Contains(url, ":15432") {
t.Fatalf("ConnectionURL(%q) = %q, expected published port", tc.dbType, url)
} }
gotVersion := handler.getDefaultVersion(tc.dbType) gotVersion := handler.getDefaultVersion(tc.dbType)
@@ -69,6 +70,17 @@ func TestDatabaseConnectionURLAndDefaultVersion(t *testing.T) {
} }
} }
func TestResolveConnectionURLNeverFabricates(t *testing.T) {
handler := &DatabaseHandler{}
if got := handler.resolveConnectionURL(DatabaseService{Type: "postgresql", Name: "example"}); got != "" {
t.Fatalf("resolveConnectionURL with no stored URL = %q, want empty", got)
}
if got := handler.resolveConnectionURL(DatabaseService{ConnectionURL: "postgresql://u:p@localhost:15432/db"}); got != "postgresql://u:p@localhost:15432/db" {
t.Fatalf("resolveConnectionURL = %q, want stored URL", got)
}
}
func TestBuildDatabaseRuntimePlanSupportsAllTypes(t *testing.T) { func TestBuildDatabaseRuntimePlanSupportsAllTypes(t *testing.T) {
cases := []struct { cases := []struct {
dbType string dbType string
@@ -199,3 +211,23 @@ func TestHumanReadableBytes(t *testing.T) {
} }
} }
} }
func TestRewriteLoopbackURL(t *testing.T) {
tests := []struct {
name, raw, host, want string
}{
{"service url", "http://localhost:35981", "10.0.0.182", "http://10.0.0.182:35981"},
{"db url keeps creds", "postgresql://u:p@localhost:35432/db?sslmode=disable", "10.0.0.182", "postgresql://u:[email protected]:35432/db?sslmode=disable"},
{"ipv4 loopback", "redis://:[email protected]:36379/0", "host.lan", "redis://:[email protected]:36379/0"},
{"unspecified", "http://0.0.0.0:9000/x", "10.0.0.182", "http://10.0.0.182:9000/x"},
{"external host untouched", "https://example.com:443/x", "10.0.0.182", "https://example.com:443/x"},
{"empty raw", "", "10.0.0.182", ""},
{"empty host", "http://localhost:35981", "", "http://localhost:35981"},
{"no port", "http://localhost/path", "10.0.0.182", "http://10.0.0.182/path"},
}
for _, tt := range tests {
if got := rewriteLoopbackURL(tt.raw, tt.host); got != tt.want {
t.Errorf("%s: rewriteLoopbackURL(%q,%q) = %q, want %q", tt.name, tt.raw, tt.host, got, tt.want)
}
}
}
+7
View File
@@ -356,6 +356,12 @@ func runDeploymentAndSyncWithImage(
replicas = 1 replicas = 1
} }
var publishedPort int32
// Best effort: reuse the host port from the last live deployment so public
// URLs survive redeploys. Column exists post-migration; older DBs get
// ephemeral ports.
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort)
deployReq := &deployment.DeploymentRequest{ deployReq := &deployment.DeploymentRequest{
ProjectID: service.ProjectID.String(), ProjectID: service.ProjectID.String(),
ServiceID: service.ID.String(), ServiceID: service.ID.String(),
@@ -367,6 +373,7 @@ func runDeploymentAndSyncWithImage(
Environment: env, Environment: env,
Replicas: replicas, Replicas: replicas,
PublicPort: int32(service.Port), PublicPort: int32(service.Port),
PublishedPort: publishedPort,
Domain: service.Domain, Domain: service.Domain,
HealthPath: service.HealthCheckPath, HealthPath: service.HealthCheckPath,
RestartPolicy: service.RestartPolicy, RestartPolicy: service.RestartPolicy,
+32
View File
@@ -57,6 +57,33 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
} }
} }
haManager := ha.NewHighAvailabilityManager(scheduler, metricsCollector) haManager := ha.NewHighAvailabilityManager(scheduler, metricsCollector)
if db != nil && db.DB != nil {
haManager.WithPersistence(db.DB)
if err := haManager.LoadHealthChecks(context.Background()); err != nil {
log.Printf("Failed to restore health checks: %v", err)
}
}
if dockerClient != nil && db != nil {
// Service-bound checks probe the service DNS name on the project
// network — the backend container joins it on demand. The old
// 127.0.0.1 fallback stays for node-bound checks.
docker := dockerClient
haManager.WithServiceResolver(func(ctx context.Context, serviceID string) (string, int, error) {
var name, projectID string
var port int
err := db.QueryRowContext(ctx,
`SELECT name, project_id::text, COALESCE(port, 0) FROM services WHERE id = $1`,
serviceID,
).Scan(&name, &projectID, &port)
if err != nil {
return "", 0, err
}
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(projectID)); err != nil {
return "", 0, err
}
return name, port, nil
})
}
haAPIManager := NewHAManager(haManager) haAPIManager := NewHAManager(haManager)
// Initialize scaling handler // Initialize scaling handler
@@ -233,6 +260,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.GET("/user/profile", handleGetProfile) authed.GET("/user/profile", handleGetProfile)
authed.PUT("/user/profile", handleUpdateProfile) authed.PUT("/user/profile", handleUpdateProfile)
// Personal access tokens for CLI/MCP/agent auth.
authed.GET("/user/tokens", handleListUserTokens)
authed.POST("/user/tokens", handleCreateUserToken)
authed.DELETE("/user/tokens/:id", handleDeleteUserToken)
authed.GET("/notifications", handleListNotifications) authed.GET("/notifications", handleListNotifications)
authed.POST("/notifications/:id/read", handleMarkNotificationRead) authed.POST("/notifications/:id/read", handleMarkNotificationRead)
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead) authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
+31 -2
View File
@@ -36,6 +36,10 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
HealthPath: service.HealthCheckPath, HealthPath: service.HealthCheckPath,
RestartPolicy: service.RestartPolicy, 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 != "" { if cmd := strings.TrimSpace(service.Command); cmd != "" {
spec.Command = strings.Fields(cmd) spec.Command = strings.Fields(cmd)
} }
@@ -283,6 +287,13 @@ func handleGetServiceRuntime(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return 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}) c.JSON(http.StatusOK, gin.H{"runtime": state})
} }
@@ -302,6 +313,7 @@ func reconcileNow(c *gin.Context, engine *deployment.DeploymentEngine, db *datab
status := "stopped" status := "stopped"
if state != nil { if state != nil {
status = state.Status 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) _, _ = 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}) c.JSON(http.StatusOK, gin.H{"runtime": state})
@@ -397,6 +409,18 @@ func handleServiceRedeploy(c *gin.Context) {
reconcileNow(c, engine, db, service) 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. // liveServiceStatus reconciles the stored status with real container state.
// No-op when Docker is unavailable — the stored status is returned as-is. // No-op when Docker is unavailable — the stored status is returned as-is.
func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) { func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
@@ -415,10 +439,11 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
service.Status = state.Status service.Status = state.Status
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID) _, _ = 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 != "" { if service.Domain != "" {
service.PublicURL = "https://" + service.Domain service.PublicURL = "https://" + service.Domain
} else if len(state.URLs) > 0 { } else if len(state.URLs) > 0 {
service.PublicURL = state.URLs[0] service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c))
} }
} }
@@ -475,9 +500,13 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()
if _, err := engine.ReconcileService(ctx, spec); err != nil { state, err := engine.ReconcileService(ctx, spec)
if err != nil {
return err 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) _, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID)
return nil return nil
} }
+3 -1
View File
@@ -520,7 +520,9 @@ func handleUpdateService(c *gin.Context) {
} }
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() 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"` UptimeSeconds int64 `json:"uptime_seconds"`
DockerAvailable bool `json:"docker_available"` DockerAvailable bool `json:"docker_available"`
Docker map[string]any `json:"docker,omitempty"` Docker map[string]any `json:"docker,omitempty"`
// Scope names what the telemetry actually measures: the physical host for
// native dockerd, or the Docker Desktop VM when the daemon runs in one.
Scope string `json:"scope"`
CollectedAt string `json:"collected_at"` CollectedAt string `json:"collected_at"`
} }
@@ -159,6 +162,7 @@ func handleGetHostMonitoring(c *gin.Context) {
Storage: storage, Storage: storage,
Load: load, Load: load,
UptimeSeconds: uptime, UptimeSeconds: uptime,
Scope: "host",
CollectedAt: time.Now().UTC().Format(time.RFC3339), CollectedAt: time.Now().UTC().Format(time.RFC3339),
} }
@@ -173,6 +177,10 @@ func handleGetHostMonitoring(c *gin.Context) {
"images": info.Images, "images": info.Images,
"driver": info.Driver, "driver": info.Driver,
"server": info.ServerVersion, "server": info.ServerVersion,
"os": info.OperatingSystem,
}
if strings.Contains(strings.ToLower(info.OperatingSystem), "docker desktop") {
response.Scope = "docker-desktop-vm"
} }
} }
} }
+21 -5
View File
@@ -28,6 +28,7 @@ type ServiceTemplate struct {
Variables string `json:"variables" db:"variables"` Variables string `json:"variables" db:"variables"`
IsOfficial bool `json:"is_official" db:"is_official"` IsOfficial bool `json:"is_official" db:"is_official"`
OwnerID string `json:"owner_id,omitempty" db:"owner_id"` OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
IsPublic bool `json:"is_public" db:"is_public"`
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }
@@ -335,6 +336,16 @@ func handleCreateFromTemplate(c *gin.Context) {
return return
} }
// sqlc's schema predates the runtime columns; port and healthcheck_path are
// written via raw SQL like every other service mutation path.
if _, err = tx.ExecContext(ctx,
`UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3`,
config.Port, strings.TrimSpace(config.HealthCheck), serviceID,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
return
}
for key, value := range envVars { for key, value := range envVars {
if strings.TrimSpace(key) == "" { if strings.TrimSpace(key) == "" {
continue continue
@@ -383,6 +394,7 @@ type templateWriteRequest struct {
Logo string `json:"logo"` Logo string `json:"logo"`
Config json.RawMessage `json:"config" binding:"required"` Config json.RawMessage `json:"config" binding:"required"`
Variables json.RawMessage `json:"variables"` Variables json.RawMessage `json:"variables"`
IsPublic bool `json:"is_public"`
} }
var templateCategories = map[string]bool{ var templateCategories = map[string]bool{
@@ -483,6 +495,7 @@ func handleCreateTemplate(c *gin.Context) {
Config: req.Config, Config: req.Config,
Variables: templateVariablesParam(req.Variables), Variables: templateVariablesParam(req.Variables),
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true}, OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
IsPublic: req.IsPublic,
}); err != nil { }); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"})
return return
@@ -529,6 +542,7 @@ func handleUpdateTemplate(c *gin.Context) {
Logo: nullableText(req.Logo), Logo: nullableText(req.Logo),
Config: req.Config, Config: req.Config,
Variables: templateVariablesParam(req.Variables), Variables: templateVariablesParam(req.Variables),
IsPublic: req.IsPublic,
ID: templateID, ID: templateID,
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true}, OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
}) })
@@ -727,7 +741,7 @@ func SeedTemplates() []ServiceTemplate {
Name: "Background Worker", Name: "Background Worker",
Description: "Background job processing service", Description: "Background job processing service",
Category: "worker", Category: "worker",
Logo: "https://cdn.simpleicons.org/terminal", Logo: "https://cdn.simpleicons.org/gnubash",
Config: `{"type":"worker","runtime":"node","build_command":"npm install","start_command":"npm run worker"}`, Config: `{"type":"worker","runtime":"node","build_command":"npm install","start_command":"npm run worker"}`,
Variables: `[{"key":"WORKER_CONCURRENCY","label":"Concurrency","default":"4","required":false,"secret":false}]`, Variables: `[{"key":"WORKER_CONCURRENCY","label":"Concurrency","default":"4","required":false,"secret":false}]`,
IsOfficial: true, IsOfficial: true,
@@ -737,7 +751,7 @@ func SeedTemplates() []ServiceTemplate {
Name: "Cron Job", Name: "Cron Job",
Description: "Scheduled task runner", Description: "Scheduled task runner",
Category: "cron", Category: "cron",
Logo: "https://cdn.simpleicons.org/clock", Logo: "https://cdn.simpleicons.org/clockify",
Config: `{"type":"cron","runtime":"node","build_command":"npm install","start_command":"npm run cron"}`, Config: `{"type":"cron","runtime":"node","build_command":"npm install","start_command":"npm run cron"}`,
Variables: `[{"key":"CRON_SCHEDULE","label":"Schedule","default":"0 * * * *","required":true,"secret":false}]`, Variables: `[{"key":"CRON_SCHEDULE","label":"Schedule","default":"0 * * * *","required":true,"secret":false}]`,
IsOfficial: true, IsOfficial: true,
@@ -1013,10 +1027,11 @@ func defaultTemplateResources(serviceType string) (cpu, memory string) {
} }
} }
// templateVisibleTo reports whether the caller may see a template: official // templateVisibleTo reports whether the caller may see a template: official and
// templates are public, user templates are owner/admin only. // published (is_public) templates are public, other user templates are
// owner/admin only.
func templateVisibleTo(c *gin.Context, t ServiceTemplate) bool { func templateVisibleTo(c *gin.Context, t ServiceTemplate) bool {
if t.IsOfficial { if t.IsOfficial || t.IsPublic {
return true return true
} }
if contextIsAdmin(c) { if contextIsAdmin(c) {
@@ -1047,6 +1062,7 @@ func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
Variables: variables, Variables: variables,
IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool, IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool,
OwnerID: ownerID, OwnerID: ownerID,
IsPublic: row.IsPublic,
CreatedAt: templateNullTime(row.CreatedAt), CreatedAt: templateNullTime(row.CreatedAt),
UpdatedAt: templateNullTime(row.UpdatedAt), UpdatedAt: templateNullTime(row.UpdatedAt),
} }
+194
View File
@@ -0,0 +1,194 @@
package api
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"net/http"
"strings"
"time"
"containr/internal/database"
sqlcdb "containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Personal access tokens let the CLI, MCP server, and third-party agents
// authenticate without a browser session. The raw token (cnp_…) is
// returned exactly once at creation; only its sha256 hash persists.
var userTokenScopes = map[string]bool{"read": true, "write": true, "admin": true}
func generateUserToken() (token string, prefix string, hash string, err error) {
buf := make([]byte, 24)
if _, err = rand.Read(buf); err != nil {
return "", "", "", err
}
token = "cnp_" + hex.EncodeToString(buf)
prefix = token[:12] // cnp_ + 8 hex chars, enough to identify in a list
sum := sha256.Sum256([]byte(token))
return token, prefix, hex.EncodeToString(sum[:]), nil
}
func userTokenJSON(t sqlcdb.UserToken) gin.H {
row := gin.H{
"id": t.ID.String(),
"name": t.Name,
"key_prefix": t.KeyPrefix,
"scope": t.Scope,
}
if t.CreatedAt.Valid {
row["created_at"] = t.CreatedAt.Time
}
if t.LastUsedAt.Valid {
row["last_used_at"] = t.LastUsedAt.Time
}
if t.ExpiresAt.Valid {
row["expires_at"] = t.ExpiresAt.Time
}
return row
}
func handleCreateUserToken(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
var req struct {
Name string `json:"name"`
Scope string `json:"scope"`
ExpiresInDays int `json:"expires_in_days"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
if req.Scope == "" {
req.Scope = "write"
}
if !userTokenScopes[req.Scope] {
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be one of: read, write, admin"})
return
}
// A token can never outrank its owner: non-admin users cannot mint
// admin-scope tokens even for themselves.
if req.Scope == "admin" && !c.GetBool("is_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "admin scope requires a platform admin account"})
return
}
if req.ExpiresInDays < 0 || req.ExpiresInDays > 3650 {
c.JSON(http.StatusBadRequest, gin.H{"error": "expires_in_days must be between 0 (never) and 3650"})
return
}
token, prefix, hash, err := generateUserToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
expiresAt := sql.NullTime{}
if req.ExpiresInDays > 0 {
expiresAt = sql.NullTime{Time: time.Now().Add(time.Duration(req.ExpiresInDays) * 24 * time.Hour), Valid: true}
}
row, err := queries.CreateUserToken(c.Request.Context(), sqlcdb.CreateUserTokenParams{
UserID: ownerUUID,
Name: req.Name,
KeyPrefix: prefix,
TokenHash: hash,
Scope: req.Scope,
ExpiresAt: expiresAt,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token"})
return
}
LogAudit(userID, "user_token", row.ID.String(), "create", map[string]interface{}{"name": req.Name, "scope": req.Scope})
payload := userTokenJSON(row)
payload["token"] = token
c.JSON(http.StatusCreated, payload)
}
func handleListUserTokens(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
rows, err := queries.ListUserTokens(c.Request.Context(), ownerUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens"})
return
}
out := make([]gin.H, 0, len(rows))
for _, t := range rows {
out = append(out, userTokenJSON(t))
}
c.JSON(http.StatusOK, gin.H{"tokens": out})
}
func handleDeleteUserToken(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
tokenUUID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID"})
return
}
affected, err := queries.RevokeUserToken(c.Request.Context(), sqlcdb.RevokeUserTokenParams{
ID: tokenUUID,
UserID: ownerUUID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token"})
return
}
if affected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found"})
return
}
LogAudit(userID, "user_token", tokenUUID.String(), "revoke", nil)
c.JSON(http.StatusOK, gin.H{"message": "Token revoked"})
}
+47 -27
View File
@@ -168,33 +168,39 @@ type ProjectMember struct {
} }
type Service struct { type Service struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description sql.NullString `json:"description"` Description sql.NullString `json:"description"`
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`
EnvironmentID uuid.UUID `json:"environment_id"` EnvironmentID uuid.UUID `json:"environment_id"`
ServiceType string `json:"service_type"` ServiceType string `json:"service_type"`
SourceType string `json:"source_type"` SourceType string `json:"source_type"`
SourceUrl sql.NullString `json:"source_url"` SourceUrl sql.NullString `json:"source_url"`
ImageName sql.NullString `json:"image_name"` ImageName sql.NullString `json:"image_name"`
BuildCommand sql.NullString `json:"build_command"` BuildCommand sql.NullString `json:"build_command"`
StartCommand sql.NullString `json:"start_command"` StartCommand sql.NullString `json:"start_command"`
CpuLimit sql.NullInt32 `json:"cpu_limit"` CpuLimit sql.NullInt32 `json:"cpu_limit"`
MemoryLimit sql.NullInt32 `json:"memory_limit"` MemoryLimit sql.NullInt32 `json:"memory_limit"`
PublicUrl sql.NullString `json:"public_url"` PublicUrl sql.NullString `json:"public_url"`
HealthCheckUrl sql.NullString `json:"health_check_url"` HealthCheckUrl sql.NullString `json:"health_check_url"`
Status sql.NullString `json:"status"` Status sql.NullString `json:"status"`
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
Type sql.NullString `json:"type"` Type sql.NullString `json:"type"`
Image sql.NullString `json:"image"` Image sql.NullString `json:"image"`
Command sql.NullString `json:"command"` Command sql.NullString `json:"command"`
Environment sql.NullString `json:"environment"` Environment sql.NullString `json:"environment"`
GitRepo sql.NullString `json:"git_repo"` GitRepo sql.NullString `json:"git_repo"`
GitBranch sql.NullString `json:"git_branch"` GitBranch sql.NullString `json:"git_branch"`
BuildPath sql.NullString `json:"build_path"` BuildPath sql.NullString `json:"build_path"`
Cpu sql.NullString `json:"cpu"` Cpu sql.NullString `json:"cpu"`
Memory sql.NullString `json:"memory"` Memory sql.NullString `json:"memory"`
Replicas int32 `json:"replicas"`
Port int32 `json:"port"`
Domain string `json:"domain"`
HealthcheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
PublishedPort int32 `json:"published_port"`
} }
type ServiceTemplate struct { type ServiceTemplate struct {
@@ -207,6 +213,20 @@ type ServiceTemplate struct {
Variables pqtype.NullRawMessage `json:"variables"` Variables pqtype.NullRawMessage `json:"variables"`
IsOfficial sql.NullBool `json:"is_official"` IsOfficial sql.NullBool `json:"is_official"`
OwnerID uuid.NullUUID `json:"owner_id"` OwnerID uuid.NullUUID `json:"owner_id"`
IsPublic bool `json:"is_public"`
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
type UserToken struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
TokenHash string `json:"token_hash"`
Scope string `json:"scope"`
ExpiresAt sql.NullTime `json:"expires_at"`
LastUsedAt sql.NullTime `json:"last_used_at"`
RevokedAt sql.NullTime `json:"revoked_at"`
CreatedAt sql.NullTime `json:"created_at"`
}
@@ -26,6 +26,7 @@ type Querier interface {
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error) DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
DeleteAgent(ctx context.Context, id string) error DeleteAgent(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
@@ -46,6 +47,7 @@ type Querier interface {
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error) GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error) ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
@@ -66,9 +68,11 @@ type Querier interface {
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
@@ -77,6 +81,7 @@ type Querier interface {
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error) SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
TouchAgentAuthToken(ctx context.Context, tokenHash string) error TouchAgentAuthToken(ctx context.Context, tokenHash string) error
TouchUserToken(ctx context.Context, id uuid.UUID) error
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
@@ -125,7 +125,7 @@ func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServi
} }
const createUserTemplate = `-- name: CreateUserTemplate :exec const createUserTemplate = `-- name: CreateUserTemplate :exec
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id) INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id, is_public)
VALUES ( VALUES (
$1, $1,
$2, $2,
@@ -135,7 +135,8 @@ VALUES (
$6, $6,
$7, $7,
false, false,
$8 $8,
$9
) )
` `
@@ -148,6 +149,7 @@ type CreateUserTemplateParams struct {
Config json.RawMessage `json:"config"` Config json.RawMessage `json:"config"`
Variables pqtype.NullRawMessage `json:"variables"` Variables pqtype.NullRawMessage `json:"variables"`
OwnerID uuid.NullUUID `json:"owner_id"` OwnerID uuid.NullUUID `json:"owner_id"`
IsPublic bool `json:"is_public"`
} }
func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error { func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error {
@@ -160,6 +162,7 @@ func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplate
arg.Config, arg.Config,
arg.Variables, arg.Variables,
arg.OwnerID, arg.OwnerID,
arg.IsPublic,
) )
return err return err
} }
@@ -198,7 +201,7 @@ func (q *Queries) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUI
} }
const getServiceTemplateByID = `-- name: GetServiceTemplateByID :one const getServiceTemplateByID = `-- name: GetServiceTemplateByID :one
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates FROM service_templates
WHERE id = $1 WHERE id = $1
` `
@@ -216,6 +219,7 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic
&i.Variables, &i.Variables,
&i.IsOfficial, &i.IsOfficial,
&i.OwnerID, &i.OwnerID,
&i.IsPublic,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -223,10 +227,10 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic
} }
const listServiceTemplatesByCategoryForUser = `-- name: ListServiceTemplatesByCategoryForUser :many const listServiceTemplatesByCategoryForUser = `-- name: ListServiceTemplatesByCategoryForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates FROM service_templates
WHERE category = $1 WHERE category = $1
AND (is_official = true OR owner_id = $2) AND (is_official = true OR is_public = true OR owner_id = $2)
ORDER BY is_official DESC, name ASC ORDER BY is_official DESC, name ASC
` `
@@ -254,6 +258,7 @@ func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg
&i.Variables, &i.Variables,
&i.IsOfficial, &i.IsOfficial,
&i.OwnerID, &i.OwnerID,
&i.IsPublic,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@@ -271,9 +276,9 @@ func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg
} }
const listServiceTemplatesForUser = `-- name: ListServiceTemplatesForUser :many const listServiceTemplatesForUser = `-- name: ListServiceTemplatesForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates FROM service_templates
WHERE is_official = true OR owner_id = $1 WHERE is_official = true OR is_public = true OR owner_id = $1
ORDER BY is_official DESC, name ASC ORDER BY is_official DESC, name ASC
` `
@@ -296,6 +301,7 @@ func (q *Queries) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.
&i.Variables, &i.Variables,
&i.IsOfficial, &i.IsOfficial,
&i.OwnerID, &i.OwnerID,
&i.IsPublic,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@@ -320,9 +326,10 @@ SET name = $1,
logo = $4, logo = $4,
config = $5, config = $5,
variables = $6, variables = $6,
is_public = $7,
updated_at = NOW() updated_at = NOW()
WHERE id = $7 WHERE id = $8
AND owner_id = $8 AND owner_id = $9
AND is_official = false AND is_official = false
` `
@@ -333,6 +340,7 @@ type UpdateUserTemplateParams struct {
Logo sql.NullString `json:"logo"` Logo sql.NullString `json:"logo"`
Config json.RawMessage `json:"config"` Config json.RawMessage `json:"config"`
Variables pqtype.NullRawMessage `json:"variables"` Variables pqtype.NullRawMessage `json:"variables"`
IsPublic bool `json:"is_public"`
ID string `json:"id"` ID string `json:"id"`
OwnerID uuid.NullUUID `json:"owner_id"` OwnerID uuid.NullUUID `json:"owner_id"`
} }
@@ -345,6 +353,7 @@ func (q *Queries) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplate
arg.Logo, arg.Logo,
arg.Config, arg.Config,
arg.Variables, arg.Variables,
arg.IsPublic,
arg.ID, arg.ID,
arg.OwnerID, arg.OwnerID,
) )
@@ -0,0 +1,147 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: user_tokens.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const createUserToken = `-- name: CreateUserToken :one
INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at
`
type CreateUserTokenParams struct {
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
TokenHash string `json:"token_hash"`
Scope string `json:"scope"`
ExpiresAt sql.NullTime `json:"expires_at"`
}
func (q *Queries) CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error) {
row := q.db.QueryRowContext(ctx, createUserToken,
arg.UserID,
arg.Name,
arg.KeyPrefix,
arg.TokenHash,
arg.Scope,
arg.ExpiresAt,
)
var i UserToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.KeyPrefix,
&i.TokenHash,
&i.Scope,
&i.ExpiresAt,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const getUserTokenByHash = `-- name: GetUserTokenByHash :one
SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens
WHERE token_hash = $1
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
LIMIT 1
`
func (q *Queries) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) {
row := q.db.QueryRowContext(ctx, getUserTokenByHash, tokenHash)
var i UserToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.KeyPrefix,
&i.TokenHash,
&i.Scope,
&i.ExpiresAt,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const listUserTokens = `-- name: ListUserTokens :many
SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens
WHERE user_id = $1 AND revoked_at IS NULL
ORDER BY created_at DESC
`
func (q *Queries) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) {
rows, err := q.db.QueryContext(ctx, listUserTokens, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []UserToken{}
for rows.Next() {
var i UserToken
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.KeyPrefix,
&i.TokenHash,
&i.Scope,
&i.ExpiresAt,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const revokeUserToken = `-- name: RevokeUserToken :execrows
UPDATE user_tokens SET revoked_at = NOW()
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL
`
type RevokeUserTokenParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) {
result, err := q.db.ExecContext(ctx, revokeUserToken, arg.ID, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const touchUserToken = `-- name: TouchUserToken :exec
UPDATE user_tokens SET last_used_at = NOW()
WHERE id = $1 AND revoked_at IS NULL
`
func (q *Queries) TouchUserToken(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, touchUserToken, id)
return err
}
@@ -59,6 +59,9 @@ type ServiceConfig struct {
Domain string `json:"domain,omitempty"` Domain string `json:"domain,omitempty"`
// HealthPath is an HTTP path probed on PublicPort for container health. // HealthPath is an HTTP path probed on PublicPort for container health.
HealthPath string `json:"health_path,omitempty"` HealthPath string `json:"health_path,omitempty"`
// PublishedPort is the host port bound by the previous deployment; replica 0
// reuses it so public URLs survive restarts. 0 = pick ephemeral.
PublishedPort int32 `json:"published_port,omitempty"`
} }
type PortMapping struct { type PortMapping struct {
@@ -315,6 +318,7 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
Env: cfg.Environment, Env: cfg.Environment,
Replicas: cfg.Replicas, Replicas: cfg.Replicas,
Port: cfg.PublicPort, Port: cfg.PublicPort,
PublishedPort: cfg.PublishedPort,
Domain: cfg.Domain, Domain: cfg.Domain,
HealthPath: cfg.HealthPath, HealthPath: cfg.HealthPath,
RestartPolicy: cfg.RestartPolicy, RestartPolicy: cfg.RestartPolicy,
+121 -22
View File
@@ -5,8 +5,11 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@@ -38,6 +41,7 @@ type RuntimeSpec struct {
Env map[string]string // already resolved Env map[string]string // already resolved
Replicas int Replicas int
Port int32 // container port to expose publicly; 0 = none 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 Domain string // public hostname routed via Traefik; empty = none
HealthPath string // http path probed on Port for container healthcheck HealthPath string // http path probed on Port for container healthcheck
RestartPolicy string RestartPolicy string
@@ -58,10 +62,14 @@ type RuntimeState struct {
Desired int `json:"desired"` Desired int `json:"desired"`
Containers []RuntimeContainer `json:"containers"` Containers []RuntimeContainer `json:"containers"`
URLs []string `json:"urls"` 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 { // ProjectNetworkName is the deterministic docker network every service in a
// project joins — containr-proj-<first 12 hex chars of the project uuid>.
func ProjectNetworkName(projectID string) string {
short := strings.ReplaceAll(projectID, "-", "") short := strings.ReplaceAll(projectID, "-", "")
if len(short) > 12 { if len(short) > 12 {
short = short[:12] short = short[:12]
@@ -76,7 +84,7 @@ func serviceNamePrefix(serviceID string) string {
// EnsureProjectNetwork creates the per-project bridge network if missing. // EnsureProjectNetwork creates the per-project bridge network if missing.
// Containers on it resolve each other by service name (private networking). // Containers on it resolve each other by service name (private networking).
func (de *DeploymentEngine) EnsureProjectNetwork(ctx context.Context, projectID string) (string, error) { func (de *DeploymentEngine) EnsureProjectNetwork(ctx context.Context, projectID string) (string, error) {
name := projectNetworkName(projectID) name := ProjectNetworkName(projectID)
networks, err := de.dockerClient.ListNetworks(ctx) networks, err := de.dockerClient.ListNetworks(ctx)
if err != nil { if err != nil {
@@ -237,33 +245,72 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
Networks: endpoints, Networks: endpoints,
} }
var wantPort nat.Port
if spec.Port > 0 { if spec.Port > 0 {
port := nat.Port(fmt.Sprintf("%d/tcp", spec.Port)) wantPort = nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
cfg.ExposedPorts = nat.PortSet{port: struct{}{}} cfg.ExposedPorts = nat.PortSet{wantPort: 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,
}
}
} }
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 { if err != nil {
return "", err return "", err
} }
if err := de.dockerClient.StartContainer(ctx, id); err != nil {
return "", fmt.Errorf("start: %w", err)
}
return id, nil 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. // RuntimeState reads live container state for a service.
func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string, desired int) (*RuntimeState, error) { func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string, desired int) (*RuntimeState, error) {
containers, err := de.ListServiceContainers(ctx, serviceID) containers, err := de.ListServiceContainers(ctx, serviceID)
@@ -282,6 +329,7 @@ func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string,
for _, p := range c.Ports { for _, p := range c.Ports {
if p.PublicPort != 0 { if p.PublicPort != 0 {
state.URLs = append(state.URLs, fmt.Sprintf("http://localhost:%d", p.PublicPort)) state.URLs = append(state.URLs, fmt.Sprintf("http://localhost:%d", p.PublicPort))
state.Ports = append(state.Ports, p.PublicPort)
} }
} }
} }
@@ -299,6 +347,57 @@ func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string,
return state, nil 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. // StopService stops (but keeps) all replicas of a service.
func (de *DeploymentEngine) StopService(ctx context.Context, serviceID string) error { func (de *DeploymentEngine) StopService(ctx context.Context, serviceID string) error {
containers, err := de.ListServiceContainers(ctx, serviceID) containers, err := de.ListServiceContainers(ctx, serviceID)
@@ -359,7 +458,7 @@ func (de *DeploymentEngine) RemoveProjectContainers(ctx context.Context, project
// RemoveProjectNetwork removes the per-project bridge network if present. // RemoveProjectNetwork removes the per-project bridge network if present.
func (de *DeploymentEngine) RemoveProjectNetwork(ctx context.Context, projectID string) error { func (de *DeploymentEngine) RemoveProjectNetwork(ctx context.Context, projectID string) error {
return de.dockerClient.RemoveNetwork(ctx, projectNetworkName(projectID)) return de.dockerClient.RemoveNetwork(ctx, ProjectNetworkName(projectID))
} }
func replicaIndex(c container.Summary) int { func replicaIndex(c container.Summary) int {
+74 -1
View File
@@ -6,6 +6,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"os"
"sort"
"strings"
"time" "time"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
@@ -23,7 +26,8 @@ import (
// Client wraps the Docker client with additional functionality // Client wraps the Docker client with additional functionality
type Client struct { type Client struct {
cli *client.Client cli *client.Client
probeHost string
} }
// NewClient creates a new Docker client // NewClient creates a new Docker client
@@ -45,6 +49,75 @@ func NewClient() (*Client, error) {
return &Client{cli: cli}, nil return &Client{cli: cli}, nil
} }
// HostProbeHost returns an address through which host-published container
// ports are reachable. When this process runs inside a container, host
// loopback is unreachable; the bridge gateway IP routes to ports published
// on 0.0.0.0. Result is cached for the process lifetime.
func (c *Client) HostProbeHost(ctx context.Context) string {
if c.probeHost != "" {
return c.probeHost
}
host := "127.0.0.1"
if self, err := c.self(ctx); err == nil && self.NetworkSettings != nil {
for _, n := range self.NetworkSettings.Networks {
if n != nil && n.Gateway != "" {
host = n.Gateway
break
}
}
}
c.probeHost = host
return host
}
// OwnNetworkName returns the name of the platform network this process's
// container is attached to, or "" when not running containerized. Per-project
// and default Docker networks are never selected; among remaining networks
// the lexicographically first wins so the choice is deterministic.
func (c *Client) OwnNetworkName(ctx context.Context) string {
self, err := c.self(ctx)
if err != nil || self.NetworkSettings == nil {
return ""
}
names := make([]string, 0, len(self.NetworkSettings.Networks))
for name := range self.NetworkSettings.Networks {
switch {
case name == "bridge" || name == "host" || name == "none":
case strings.HasPrefix(name, "containr-proj-"):
default:
names = append(names, name)
}
}
sort.Strings(names)
if len(names) == 0 {
return ""
}
return names[0]
}
// self inspects the container this process runs in (hostname == container ID).
func (c *Client) self(ctx context.Context) (types.ContainerJSON, error) {
hostname, err := os.Hostname()
if err != nil || hostname == "" {
return types.ContainerJSON{}, fmt.Errorf("hostname unavailable")
}
return c.cli.ContainerInspect(ctx, hostname)
}
// ConnectSelfToNetwork attaches this process's container to the given network.
// No-op when already attached or when not running containerized.
func (c *Client) ConnectSelfToNetwork(ctx context.Context, networkName string) error {
hostname, err := os.Hostname()
if err != nil || hostname == "" {
return fmt.Errorf("hostname unavailable")
}
err = c.cli.NetworkConnect(ctx, networkName, hostname, &network.EndpointSettings{})
if err != nil && strings.Contains(strings.ToLower(err.Error()), "already exists") {
return nil
}
return err
}
// ListContainers returns all containers // ListContainers returns all containers
func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Container, error) { func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Container, error) {
return c.cli.ContainerList(ctx, container.ListOptions{ return c.cli.ContainerList(ctx, container.ListOptions{
+138 -16
View File
@@ -3,6 +3,7 @@ package ha
import ( import (
"bytes" "bytes"
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@@ -75,11 +76,13 @@ type HealthCheckConfig struct {
// HealthChecker performs health checks on services and nodes // HealthChecker performs health checks on services and nodes
type HealthChecker struct { type HealthChecker struct {
scheduler *deployment.Scheduler scheduler *deployment.Scheduler
checks map[string]*HealthCheck checks map[string]*HealthCheck
results map[string]*HealthCheckResult results map[string]*HealthCheckResult
mu sync.RWMutex mu sync.RWMutex
checkInterval time.Duration checkInterval time.Duration
serviceResolver func(ctx context.Context, serviceID string) (host string, port int, err error)
db *sql.DB
} }
// HealthCheck represents a health check configuration // HealthCheck represents a health check configuration
@@ -596,7 +599,11 @@ func (hc *HealthChecker) performHealthChecks(ctx context.Context) error {
result := hc.performHealthCheck(ctx, check) result := hc.performHealthCheck(ctx, check)
hc.mu.Lock() hc.mu.Lock()
hc.results[check.ID] = result hc.results[check.ID] = result
check.Status = result.Status
check.LastCheck = result.Timestamp
hc.mu.Unlock() hc.mu.Unlock()
// Persist last-known state so a restart resumes with honest history.
hc.persistCheck(check)
} }
return nil return nil
@@ -617,12 +624,20 @@ func (hc *HealthChecker) performHealthCheck(ctx context.Context, check *HealthCh
checkCtx, cancel := context.WithTimeout(ctx, timeout) checkCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
var err error host, port, err := hc.resolveTarget(ctx, check)
if err != nil {
result.Status = HealthStatusUnhealthy
result.Message = err.Error()
result.ErrorCode = "TARGET_UNRESOLVED"
result.Latency = time.Since(start)
return result
}
switch check.Type { switch check.Type {
case HealthCheckTypeHTTP: case HealthCheckTypeHTTP:
err = hc.performHTTPCheck(checkCtx, check) err = hc.performHTTPCheck(checkCtx, check, host, port)
case HealthCheckTypeTCP: case HealthCheckTypeTCP:
err = hc.performTCPCheck(checkCtx, check) err = hc.performTCPCheck(checkCtx, check, host, port)
case HealthCheckTypeCommand: case HealthCheckTypeCommand:
err = hc.performCommandCheck(checkCtx, check) err = hc.performCommandCheck(checkCtx, check)
default: default:
@@ -642,13 +657,11 @@ func (hc *HealthChecker) performHealthCheck(ctx context.Context, check *HealthCh
return result return result
} }
func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthCheck) error { func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthCheck, host string, port int) error {
host := hc.resolveHealthCheckHost(check)
protocol := strings.ToLower(strings.TrimSpace(check.Config.Protocol)) protocol := strings.ToLower(strings.TrimSpace(check.Config.Protocol))
if protocol == "" { if protocol == "" {
protocol = "http" protocol = "http"
} }
port := check.Config.Port
if port <= 0 { if port <= 0 {
if protocol == "https" { if protocol == "https" {
port = 443 port = 443
@@ -684,9 +697,7 @@ func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthChec
return nil return nil
} }
func (hc *HealthChecker) performTCPCheck(ctx context.Context, check *HealthCheck) error { func (hc *HealthChecker) performTCPCheck(ctx context.Context, check *HealthCheck, host string, port int) error {
host := hc.resolveHealthCheckHost(check)
port := check.Config.Port
if port <= 0 { if port <= 0 {
port = 80 port = 80
} }
@@ -723,6 +734,27 @@ func (hc *HealthChecker) performCommandCheck(ctx context.Context, check *HealthC
return nil return nil
} }
// WithServiceResolver lets the API layer plug in service-aware targeting: a
// ServiceID-bound check resolves to the service's DNS name on its project
// network instead of the backend container's loopback, where dialing
// 127.0.0.1:<host port> can never succeed.
func (m *HighAvailabilityManager) WithServiceResolver(fn func(ctx context.Context, serviceID string) (host string, port int, err error)) *HighAvailabilityManager {
m.healthChecker.serviceResolver = fn
return m
}
// WithPersistence attaches Postgres so configured checks survive restarts.
// Call LoadHealthChecks once during startup to hydrate state.
func (m *HighAvailabilityManager) WithPersistence(db *sql.DB) *HighAvailabilityManager {
m.healthChecker.db = db
return m
}
// LoadHealthChecks hydrates persisted health checks during startup.
func (m *HighAvailabilityManager) LoadHealthChecks(ctx context.Context) error {
return m.healthChecker.LoadHealthChecks(ctx)
}
func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string { func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string {
if strings.TrimSpace(check.NodeID) != "" && hc.scheduler != nil { if strings.TrimSpace(check.NodeID) != "" && hc.scheduler != nil {
nodes := hc.scheduler.GetNodes() nodes := hc.scheduler.GetNodes()
@@ -739,19 +771,109 @@ func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string {
return "127.0.0.1" return "127.0.0.1"
} }
// resolveTarget picks the dial target for a check: service-bound checks go to
// the service DNS name on the project network; node-bound and free-standing
// checks keep the old host behavior. A service-bound resolver failure is
// surfaced as an error — falling back to loopback would only produce a
// misleading "service down" result.
func (hc *HealthChecker) resolveTarget(ctx context.Context, check *HealthCheck) (string, int, error) {
port := check.Config.Port
if strings.TrimSpace(check.NodeID) == "" && check.ServiceID != "" && hc.serviceResolver != nil {
resolveCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
h, p, err := hc.serviceResolver(resolveCtx, check.ServiceID)
if err != nil {
return "", 0, fmt.Errorf("resolve service target: %w", err)
}
if h == "" {
return "", 0, fmt.Errorf("resolve service target: service %s not found", check.ServiceID)
}
if port <= 0 {
port = p
}
return h, port, nil
}
return hc.resolveHealthCheckHost(check), port, nil
}
// AddHealthCheck adds a new health check // AddHealthCheck adds a new health check
func (hc *HealthChecker) AddHealthCheck(check *HealthCheck) { func (hc *HealthChecker) AddHealthCheck(check *HealthCheck) {
hc.mu.Lock() hc.mu.Lock()
defer hc.mu.Unlock()
hc.checks[check.ID] = check hc.checks[check.ID] = check
hc.mu.Unlock()
hc.persistCheck(check)
} }
// RemoveHealthCheck removes a health check // RemoveHealthCheck removes a health check
func (hc *HealthChecker) RemoveHealthCheck(checkID string) { func (hc *HealthChecker) RemoveHealthCheck(checkID string) {
hc.mu.Lock() hc.mu.Lock()
defer hc.mu.Unlock()
delete(hc.checks, checkID) delete(hc.checks, checkID)
delete(hc.results, checkID) delete(hc.results, checkID)
hc.mu.Unlock()
if hc.db != nil {
_, _ = hc.db.ExecContext(context.Background(), `DELETE FROM ha_health_checks WHERE id = $1`, checkID)
}
}
// persistCheck upserts one check. Best effort — a write failure never blocks
// the in-memory path, matching the scaling-policy persistence style.
func (hc *HealthChecker) persistCheck(check *HealthCheck) {
if hc.db == nil {
return
}
cfg, _ := json.Marshal(check.Config)
var lastCheck interface{}
if !check.LastCheck.IsZero() {
lastCheck = check.LastCheck
}
_, _ = hc.db.ExecContext(context.Background(),
`INSERT INTO ha_health_checks (id, service_id, node_id, type, config, status, last_check, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
ON CONFLICT (id) DO UPDATE
SET service_id = EXCLUDED.service_id, node_id = EXCLUDED.node_id,
type = EXCLUDED.type, config = EXCLUDED.config,
status = EXCLUDED.status, last_check = EXCLUDED.last_check,
updated_at = now()`,
check.ID, check.ServiceID, check.NodeID, string(check.Type), cfg, string(check.Status), lastCheck,
)
}
// LoadHealthChecks hydrates checks persisted across restarts.
func (hc *HealthChecker) LoadHealthChecks(ctx context.Context) error {
if hc.db == nil {
return nil
}
rows, err := hc.db.QueryContext(ctx,
`SELECT id, service_id, node_id, type, config, status, last_check FROM ha_health_checks`)
if err != nil {
return err
}
defer rows.Close()
loaded := 0
for rows.Next() {
var check HealthCheck
var cfg []byte
var status, checkType string
var lastCheck sql.NullTime
if err := rows.Scan(&check.ID, &check.ServiceID, &check.NodeID, &checkType, &cfg, &status, &lastCheck); err != nil {
continue
}
check.Type = HealthCheckType(checkType)
check.Status = HealthStatus(status)
if lastCheck.Valid {
check.LastCheck = lastCheck.Time
}
_ = json.Unmarshal(cfg, &check.Config)
hc.mu.Lock()
hc.checks[check.ID] = &check
hc.mu.Unlock()
loaded++
}
if loaded > 0 {
log.Printf("Restored %d health checks", loaded)
}
return rows.Err()
} }
// GetHealthCheck returns one configured health check. // GetHealthCheck returns one configured health check.
+75 -1
View File
@@ -2,7 +2,10 @@ package middleware
import ( import (
"containr/internal/database" "containr/internal/database"
sqlcdb "containr/internal/database/sqlcdb"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -75,6 +78,14 @@ func RequestID() gin.HandlerFunc {
func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) { func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) {
tokenString, tokenErr, hasToken := extractJWTToken(c) tokenString, tokenErr, hasToken := extractJWTToken(c)
if tokenString != "" { if tokenString != "" {
// Personal access tokens (cnp_…) are not JWTs — they resolve
// against the user_tokens table by sha256 hash.
if strings.HasPrefix(tokenString, patPrefix) {
if id, mail, verified := resolvePersonalAccessToken(c, tokenString); verified {
return id, mail, "", hasToken, true
}
return "", "", "Invalid token", hasToken, false
}
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid { if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
userIDClaim, exists := claims["user_id"] userIDClaim, exists := claims["user_id"]
if exists { if exists {
@@ -101,12 +112,67 @@ func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *bette
return "", "", tokenErr, hasToken, false return "", "", tokenErr, hasToken, false
} }
const patPrefix = "cnp_"
// resolvePersonalAccessToken authenticates a `cnp_` bearer token against the
// user_tokens table (sha256 hash match, not revoked, not expired). On success
// it stamps last_used_at, records the token scope on the context, and
// returns the owner's id and email.
func resolvePersonalAccessToken(c *gin.Context, token string) (string, string, bool) {
dbValue, exists := c.Get("db")
if !exists {
return "", "", false
}
db, ok := dbValue.(*database.DB)
if !ok || db == nil || db.DB == nil {
return "", "", false
}
sum := sha256.Sum256([]byte(token))
queries := sqlcdb.New(db.DB)
row, err := queries.GetUserTokenByHash(c.Request.Context(), hex.EncodeToString(sum[:]))
if err != nil {
return "", "", false
}
var email string
if err := db.QueryRowContext(c.Request.Context(),
`SELECT email FROM users WHERE id = $1`, row.UserID).Scan(&email); err != nil {
return "", "", false
}
_ = queries.TouchUserToken(c.Request.Context(), row.ID)
c.Set("pat_scope", row.Scope)
return row.UserID.String(), email, true
}
// patScopeAllows reports whether a PAT-authenticated request may proceed.
// read scope permits only safe methods; write and admin are unrestricted at
// this layer (admin routes still require the admin scope AND a platform
// admin account — enforced via is_admin in setAuthenticatedContext).
func patScopeAllows(c *gin.Context) bool {
if c.GetString("pat_scope") != "read" {
return true
}
switch c.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
return false
}
// setAuthenticatedContext stores the resolved identity plus the admin flag on // setAuthenticatedContext stores the resolved identity plus the admin flag on
// the gin context so handlers and RequireAdmin can rely on it. // the gin context so handlers and RequireAdmin can rely on it.
func setAuthenticatedContext(c *gin.Context, userID, email string) { func setAuthenticatedContext(c *gin.Context, userID, email string) {
c.Set("user_id", userID) c.Set("user_id", userID)
c.Set("email", email) c.Set("email", email)
c.Set("is_admin", loadIsAdmin(c, userID)) isAdmin := loadIsAdmin(c, userID)
// A PAT can never outrank its scope: non-admin scopes cap the token at
// non-admin even when the owning user is a platform admin.
if scope := c.GetString("pat_scope"); scope != "" && scope != "admin" {
isAdmin = false
}
c.Set("is_admin", isAdmin)
} }
func loadIsAdmin(c *gin.Context, userID string) bool { func loadIsAdmin(c *gin.Context, userID string) bool {
@@ -133,6 +199,14 @@ func Auth(jwtSecret string) gin.HandlerFunc {
userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier) userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier)
if ok { if ok {
setAuthenticatedContext(c, userID, email) setAuthenticatedContext(c, userID, email)
if !patScopeAllows(c) {
c.JSON(http.StatusForbidden, gin.H{
"error": "Token scope is read-only",
"code": "SCOPE_INSUFFICIENT",
})
c.Abort()
return
}
c.Next() c.Next()
return return
} }
@@ -131,6 +131,61 @@ func TestAuthStoresStringUserIDForValidClaims(t *testing.T) {
} }
} }
func TestAuthRejectsPATWithoutDatabase(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(Auth("secret"))
router.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
// A cnp_-prefixed token takes the PAT path, not JWT parsing — without a
// db handle it must fail closed with 401.
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer cnp_"+strings.Repeat("ab", 24))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
}
}
func TestPATScopeAllows(t *testing.T) {
gin.SetMode(gin.TestMode)
cases := []struct {
name string
scope string
method string
want bool
}{
{"read allows GET", "read", http.MethodGet, true},
{"read allows HEAD", "read", http.MethodHead, true},
{"read allows OPTIONS", "read", http.MethodOptions, true},
{"read blocks POST", "read", http.MethodPost, false},
{"read blocks DELETE", "read", http.MethodDelete, false},
{"write allows POST", "write", http.MethodPost, true},
{"write allows DELETE", "write", http.MethodDelete, true},
{"admin allows PATCH", "admin", http.MethodPatch, true},
{"no scope (session/JWT) unrestricted", "", http.MethodPost, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(tc.method, "/x", nil)
if tc.scope != "" {
c.Set("pat_scope", tc.scope)
}
if got := patScopeAllows(c); got != tc.want {
t.Fatalf("patScopeAllows(scope=%q, method=%s) = %v, want %v", tc.scope, tc.method, got, tc.want)
}
})
}
}
func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string { func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string {
t.Helper() t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
@@ -0,0 +1,11 @@
-- +goose Up
-- Railway-style template publishing: owners can mark a user template public so
-- it appears in the anonymous catalog (GET /api/v1/templates) and on the
-- landing page. Official templates stay official; is_public only matters for
-- user templates.
ALTER TABLE public.service_templates ADD COLUMN IF NOT EXISTS is_public boolean NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS idx_service_templates_public ON public.service_templates(is_public) WHERE is_public;
-- +goose Down
DROP INDEX IF EXISTS public.idx_service_templates_public;
ALTER TABLE public.service_templates DROP COLUMN IF EXISTS is_public;
@@ -0,0 +1,8 @@
-- +goose Up
-- Stable public host port for service replica 0. 0 means "not yet assigned";
-- the runtime writes the ephemeral port it picked back here so restarts and
-- redeploys keep the same public endpoint instead of churning.
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS published_port integer NOT NULL DEFAULT 0;
-- +goose Down
ALTER TABLE public.services DROP COLUMN IF EXISTS published_port;
@@ -0,0 +1,18 @@
-- +goose Up
-- Persisted HA health checks. Previously checks lived only in memory, so a
-- backend restart silently deleted every configured check while services kept
-- running unmonitored.
CREATE TABLE IF NOT EXISTS ha_health_checks (
id uuid PRIMARY KEY,
service_id uuid NOT NULL REFERENCES services(id) ON DELETE CASCADE,
node_id varchar(255) NOT NULL DEFAULT '',
type varchar(20) NOT NULL,
config jsonb NOT NULL DEFAULT '{}'::jsonb,
status varchar(20) NOT NULL DEFAULT 'unknown',
last_check timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE IF EXISTS ha_health_checks;
@@ -0,0 +1,25 @@
-- +goose Up
-- Personal access tokens for CLI/MCP/agent authentication.
-- Only sha256 hashes are stored — raw tokens (cnp_…) are shown once at
-- creation and never persisted. Scope gates what the token may do:
-- read = GET/HEAD only, write = all non-admin routes, admin = everything
-- (admin still requires the owning user to be a platform admin).
CREATE TABLE IF NOT EXISTS public.user_tokens (
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
name character varying(255) NOT NULL,
key_prefix character varying(16) NOT NULL,
token_hash text NOT NULL UNIQUE,
scope character varying(20) NOT NULL DEFAULT 'write'
CHECK (scope IN ('read', 'write', 'admin')),
expires_at timestamp with time zone,
last_used_at timestamp with time zone,
revoked_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_tokens_user_id ON public.user_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_user_tokens_token_hash ON public.user_tokens(token_hash);
-- +goose Down
DROP TABLE IF EXISTS public.user_tokens;
+9 -7
View File
@@ -1,18 +1,18 @@
-- name: ListServiceTemplatesForUser :many -- name: ListServiceTemplatesForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates FROM service_templates
WHERE is_official = true OR owner_id = sqlc.narg(owner_id) WHERE is_official = true OR is_public = true OR owner_id = sqlc.narg(owner_id)
ORDER BY is_official DESC, name ASC; ORDER BY is_official DESC, name ASC;
-- name: ListServiceTemplatesByCategoryForUser :many -- name: ListServiceTemplatesByCategoryForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates FROM service_templates
WHERE category = sqlc.arg(category) WHERE category = sqlc.arg(category)
AND (is_official = true OR owner_id = sqlc.narg(owner_id)) AND (is_official = true OR is_public = true OR owner_id = sqlc.narg(owner_id))
ORDER BY is_official DESC, name ASC; ORDER BY is_official DESC, name ASC;
-- name: GetServiceTemplateByID :one -- name: GetServiceTemplateByID :one
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates FROM service_templates
WHERE id = $1; WHERE id = $1;
@@ -39,7 +39,7 @@ SET name = EXCLUDED.name,
updated_at = NOW(); updated_at = NOW();
-- name: CreateUserTemplate :exec -- name: CreateUserTemplate :exec
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id) INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id, is_public)
VALUES ( VALUES (
sqlc.arg(id), sqlc.arg(id),
sqlc.arg(name), sqlc.arg(name),
@@ -49,7 +49,8 @@ VALUES (
sqlc.arg(config), sqlc.arg(config),
sqlc.narg(variables), sqlc.narg(variables),
false, false,
sqlc.arg(owner_id) sqlc.arg(owner_id),
sqlc.arg(is_public)
); );
-- name: UpdateUserTemplate :execrows -- name: UpdateUserTemplate :execrows
@@ -60,6 +61,7 @@ SET name = sqlc.arg(name),
logo = sqlc.narg(logo), logo = sqlc.narg(logo),
config = sqlc.arg(config), config = sqlc.arg(config),
variables = sqlc.narg(variables), variables = sqlc.narg(variables),
is_public = sqlc.arg(is_public),
updated_at = NOW() updated_at = NOW()
WHERE id = sqlc.arg(id) WHERE id = sqlc.arg(id)
AND owner_id = sqlc.arg(owner_id) AND owner_id = sqlc.arg(owner_id)
+24
View File
@@ -0,0 +1,24 @@
-- name: CreateUserToken :one
INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: ListUserTokens :many
SELECT * FROM user_tokens
WHERE user_id = $1 AND revoked_at IS NULL
ORDER BY created_at DESC;
-- name: GetUserTokenByHash :one
SELECT * FROM user_tokens
WHERE token_hash = $1
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
LIMIT 1;
-- name: TouchUserToken :exec
UPDATE user_tokens SET last_used_at = NOW()
WHERE id = $1 AND revoked_at IS NULL;
-- name: RevokeUserToken :execrows
UPDATE user_tokens SET revoked_at = NOW()
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL;
+21 -1
View File
@@ -52,7 +52,13 @@ CREATE TABLE services (
git_branch VARCHAR(100), git_branch VARCHAR(100),
build_path VARCHAR(500), build_path VARCHAR(500),
cpu VARCHAR(50), cpu VARCHAR(50),
memory VARCHAR(50) memory VARCHAR(50),
replicas INTEGER NOT NULL DEFAULT 1,
port INTEGER NOT NULL DEFAULT 0,
domain VARCHAR(255) NOT NULL DEFAULT '',
healthcheck_path VARCHAR(255) NOT NULL DEFAULT '',
restart_policy VARCHAR(50) NOT NULL DEFAULT 'unless-stopped',
published_port INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE deployments ( CREATE TABLE deployments (
@@ -84,6 +90,7 @@ CREATE TABLE service_templates (
variables JSONB DEFAULT '[]', variables JSONB DEFAULT '[]',
is_official BOOLEAN DEFAULT false, is_official BOOLEAN DEFAULT false,
owner_id UUID, owner_id UUID,
is_public BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
@@ -197,3 +204,16 @@ CREATE TABLE notifications (
read_at TIMESTAMP WITH TIME ZONE, read_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
CREATE TABLE user_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
key_prefix VARCHAR(16) NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
scope VARCHAR(20) NOT NULL DEFAULT 'write',
expires_at TIMESTAMP WITH TIME ZONE,
last_used_at TIMESTAMP WITH TIME ZONE,
revoked_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
+2
View File
@@ -0,0 +1,2 @@
.vercel
.env*
+1 -1
View File
@@ -32,7 +32,7 @@ describe('App smoke routes', () => {
expect(await screen.findByRole('heading', { name: /^projects\s*_?$/i })).toBeInTheDocument(); expect(await screen.findByRole('heading', { name: /^projects\s*_?$/i })).toBeInTheDocument();
expect(screen.getByText(/recent deployments/i)).toBeInTheDocument(); expect(screen.getByText(/recent deployments/i)).toBeInTheDocument();
expect(screen.getAllByText(/core-services/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/core platform/i).length).toBeGreaterThan(0);
}); });
it('renders builds page in demo mode', async () => { it('renders builds page in demo mode', async () => {
+28 -10
View File
@@ -1,6 +1,6 @@
import { Link, 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 { useQuery } from '@tanstack/react-query';
import { ShieldCheck } from 'lucide-react'; import { Compass, ShieldCheck } from 'lucide-react';
import { PlatformShell } from './layout/PlatformShell'; import { PlatformShell } from './layout/PlatformShell';
import { ProjectsPage } from '@/features/projects/pages/ProjectsPage'; import { ProjectsPage } from '@/features/projects/pages/ProjectsPage';
import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage'; import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage';
@@ -23,14 +23,10 @@ import {
} from '@/features/secondary/pages'; } from '@/features/secondary/pages';
import { SignInPage, SignUpPage } from '@/features/auth/pages'; import { SignInPage, SignUpPage } from '@/features/auth/pages';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { getCurrentUserProfile } from '@/lib/api-client'; import { getCurrentUserProfile } from '@/lib/api-client';
import { ErrorBoundary, LoadingState } from '@/shared/components'; import { ErrorBoundary, LoadingState } from '@/shared/components';
function useDemoMode() {
const location = useLocation();
return new URLSearchParams(location.search).get('demo') === '1';
}
function AuthRequired() { function AuthRequired() {
const location = useLocation(); const location = useLocation();
const isDemoMode = useDemoMode(); const isDemoMode = useDemoMode();
@@ -107,6 +103,26 @@ function AdminRequired() {
return <Outlet />; return <Outlet />;
} }
function NotFoundPage() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-6 text-center">
<Compass size={32} className="text-[var(--text-tertiary)]" />
<div>
<h1 className="text-lg font-semibold text-[var(--text-primary)]">Page not found</h1>
<p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">
The page you are looking for does not exist or was moved.
</p>
</div>
<Link
to="/"
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Back to home
</Link>
</div>
);
}
export default function App() { export default function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
@@ -116,10 +132,12 @@ export default function App() {
<Route path="/auth/sign-up" element={<SignUpPage />} /> <Route path="/auth/sign-up" element={<SignUpPage />} />
{/* Public browsing — approved projects, templates, docs. */} {/* 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 element={<PlatformShell />}> <Route element={<PlatformShell />}>
<Route path="/projects" element={<ProjectsPage />} /> <Route path="/projects" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
<Route path="/templates" element={<TemplatesPage />} /> <Route path="/templates" element={<TemplatesPage />} />
<Route path="/databases" element={<DatabasesPage />} /> <Route path="/databases" element={<DatabasesPage />} />
<Route path="/ha" element={<HighAvailabilityPage />} /> <Route path="/ha" element={<HighAvailabilityPage />} />
@@ -140,14 +158,14 @@ export default function App() {
<Route element={<AdminRequired />}> <Route element={<AdminRequired />}>
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<AdminPage />} />
</Route> </Route>
<Route path="*" element={<NotFoundPage />} />
</Route> </Route>
{/* Full-bleed design demo — renders its own chrome, no app shell */} {/* Full-bleed design demo — renders its own chrome, no app shell */}
<Route element={<AuthRequired />}> <Route element={<AuthRequired />}>
<Route path="/metrics-demo" element={<ServiceMetricsDashboard />} /> <Route path="/metrics-demo" element={<ServiceMetricsDashboard />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</ErrorBoundary> </ErrorBoundary>
); );
+63 -38
View File
@@ -37,6 +37,7 @@ import {
pullUpgradeImage, pullUpgradeImage,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { signOutAuthSession } from '@/lib/auth-client'; import { signOutAuthSession } from '@/lib/auth-client';
import { isDemoSearch } from '@/lib/demo-mode';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useToast } from '@/shared/components'; import { useToast } from '@/shared/components';
@@ -103,8 +104,18 @@ export function PlatformShell() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showToast } = useToast(); const { showToast } = useToast();
const isDemoMode = new URLSearchParams(location.search).get('demo') === '1'; const isDemoMode = isDemoSearch(location.search);
const href = (target: string) => (isDemoMode ? `${target}?demo=1` : target); const href = (target: string) => (isDemoMode ? `${target}?demo=1` : target);
// Auth/admin-scoped pages have no demo fixtures — keep the demo nav honest.
const visibleNavSections = isDemoMode
? navSections
.map((section) =>
section.title === 'Workspace'
? { ...section, items: section.items.filter((item) => item.href === '/docs') }
: section,
)
.filter((section) => section.items.length > 0)
: navSections;
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme()); const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
const [sidebarExpanded, setSidebarExpanded] = useState<boolean>(() => getInitialSidebar()); const [sidebarExpanded, setSidebarExpanded] = useState<boolean>(() => getInitialSidebar());
const [notificationsOpen, setNotificationsOpen] = useState(false); const [notificationsOpen, setNotificationsOpen] = useState(false);
@@ -118,7 +129,7 @@ export function PlatformShell() {
staleTime: 60_000, staleTime: 60_000,
retry: false, retry: false,
}); });
const isAdmin = isDemoMode || Boolean(profileQuery.data?.isAdmin); const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
const upgradeQuery = useQuery({ const upgradeQuery = useQuery({
queryKey: ['upgrade-status'], queryKey: ['upgrade-status'],
queryFn: getUpgradeStatus, queryFn: getUpgradeStatus,
@@ -174,8 +185,8 @@ export function PlatformShell() {
const isActiveRoute = (itemHref: string) => const isActiveRoute = (itemHref: string) =>
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`); location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
const userName = profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account'; const userName = isDemoMode ? 'Demo workspace' : profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account';
const userEmail = profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session'; const userEmail = isDemoMode ? 'Sample data · read-only' : profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session';
const initials = userName const initials = userName
.split(' ') .split(' ')
.map((part) => part[0]) .map((part) => part[0])
@@ -220,7 +231,7 @@ export function PlatformShell() {
</div> </div>
<nav className="mt-3 flex flex-1 flex-col gap-4 overflow-y-auto px-2"> <nav className="mt-3 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
{navSections.map((section) => ( {visibleNavSections.map((section) => (
<div key={section.title}> <div key={section.title}>
{sidebarExpanded ? ( {sidebarExpanded ? (
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]"> <p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]">
@@ -297,27 +308,31 @@ export function PlatformShell() {
<p className="truncate text-xs text-[var(--text-tertiary)]">{userEmail}</p> <p className="truncate text-xs text-[var(--text-tertiary)]">{userEmail}</p>
</div> </div>
<DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" /> <DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" />
<DropdownMenu.Item asChild> {!isDemoMode ? (
<button type="button" onClick={() => navigate(href('/settings'))} className={menuItemClass}> <>
<User size={14} /> Profile & account <DropdownMenu.Item asChild>
</button> <button type="button" onClick={() => navigate(href('/settings'))} className={menuItemClass}>
</DropdownMenu.Item> <User size={14} /> Profile & account
<DropdownMenu.Item asChild> </button>
<button type="button" onClick={() => navigate(href('/settings'))} className={menuItemClass}> </DropdownMenu.Item>
<Settings size={14} /> Settings <DropdownMenu.Item asChild>
</button> <button type="button" onClick={() => navigate(href('/settings'))} className={menuItemClass}>
</DropdownMenu.Item> <Settings size={14} /> Settings
<DropdownMenu.Item asChild> </button>
<button type="button" onClick={() => navigate(href('/settings/audit-logs'))} className={menuItemClass}> </DropdownMenu.Item>
<ScrollText size={14} /> Audit logs <DropdownMenu.Item asChild>
</button> <button type="button" onClick={() => navigate(href('/settings/audit-logs'))} className={menuItemClass}>
</DropdownMenu.Item> <ScrollText size={14} /> Audit logs
{isAdmin ? ( </button>
<DropdownMenu.Item asChild> </DropdownMenu.Item>
<button type="button" onClick={() => navigate(href('/admin'))} className={menuItemClass}> {isAdmin ? (
<ShieldCheck size={14} /> Admin console <DropdownMenu.Item asChild>
</button> <button type="button" onClick={() => navigate(href('/admin'))} className={menuItemClass}>
</DropdownMenu.Item> <ShieldCheck size={14} /> Admin console
</button>
</DropdownMenu.Item>
) : null}
</>
) : null} ) : null}
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
<button <button
@@ -331,13 +346,23 @@ export function PlatformShell() {
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" /> <DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" />
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
<button {isDemoMode ? (
type="button" <button
onClick={() => void signOut()} type="button"
className={`${menuItemClass} text-[var(--error)] hover:!bg-[var(--error-soft)] hover:!text-[var(--error)]`} onClick={() => navigate('/')}
> className={`${menuItemClass} text-[var(--error)] hover:!bg-[var(--error-soft)] hover:!text-[var(--error)]`}
<LogOut size={14} /> Sign out >
</button> <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.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
@@ -382,7 +407,7 @@ export function PlatformShell() {
</div> </div>
<div className="ml-auto flex items-center gap-4"> <div className="ml-auto flex items-center gap-4">
<span className="v-env">env:production</span> <span className="v-env">{isDemoMode ? 'env:demo' : 'env:production'}</span>
<button <button
type="button" type="button"
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))} onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
@@ -393,11 +418,11 @@ export function PlatformShell() {
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />} {theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
</button> </button>
{isAdmin ? ( {isAdmin && !isDemoMode ? (
<button <button
type="button" type="button"
onClick={() => pullMutation.mutate()} onClick={() => pullMutation.mutate()}
disabled={isDemoMode || pullMutation.isPending || !upgradeQuery.data?.imageRef} disabled={pullMutation.isPending || !upgradeQuery.data?.imageRef}
className="inline-flex h-8 items-center gap-2 rounded-[var(--radius-md)] px-3 text-xs font-semibold text-[var(--accent-on)] disabled:cursor-not-allowed disabled:opacity-50" 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)' }} style={{ background: 'var(--accent-primary)' }}
title={upgradeQuery.data?.message || 'Pull latest configured image'} title={upgradeQuery.data?.message || 'Pull latest configured image'}
@@ -407,7 +432,7 @@ export function PlatformShell() {
</button> </button>
) : null} ) : null}
{signedIn ? ( {signedIn && !isDemoMode ? (
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
@@ -504,7 +529,7 @@ export function PlatformShell() {
</button> </button>
</div> </div>
<nav className="flex gap-1 overflow-x-auto px-3 pb-3"> <nav className="flex gap-1 overflow-x-auto px-3 pb-3">
{navSections.flatMap((section) => section.items).map((item) => { {visibleNavSections.flatMap((section) => section.items).map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = isActiveRoute(item.href); const isActive = isActiveRoute(item.href);
return ( return (
@@ -23,7 +23,8 @@ import {
type ProjectEntity, type ProjectEntity,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { LoadingState, useToast } from '@/shared/components'; import { useDemoMode } from '@/lib/demo-mode';
import { DemoRestricted, LoadingState, useToast } from '@/shared/components';
const statLabels: Record<string, string> = { const statLabels: Record<string, string> = {
users: 'Users', users: 'Users',
@@ -42,12 +43,13 @@ const statLabels: Record<string, string> = {
export function AdminPage() { export function AdminPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showToast } = useToast(); const { showToast } = useToast();
const sessionQuery = useAuthSession(); const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const myId = sessionQuery.data?.user.id; const myId = sessionQuery.data?.user.id;
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview }); const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview, enabled: !isDemoMode });
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers }); const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode });
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }) }); const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode });
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null); const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
const [editForm, setEditForm] = useState({ name: '', description: '' }); const [editForm, setEditForm] = useState({ name: '', description: '' });
@@ -99,6 +101,10 @@ export function AdminPage() {
onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined), onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined),
}); });
if (isDemoMode) {
return <DemoRestricted feature="The admin console" />;
}
if (overviewQuery.isPending) { if (overviewQuery.isPending) {
return <LoadingState message="Loading admin console…" className="py-24" />; return <LoadingState message="Loading admin console…" className="py-24" />;
} }
@@ -1,7 +1,9 @@
import { useDeferredValue, useState } from 'react'; import { useDeferredValue, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { listAuditLogs } from '@/lib/api-client'; import { listAuditLogs } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode';
import { formatDate, formatRelative } from '@/lib/time'; import { formatDate, formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components';
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -17,6 +19,7 @@ const inputClass =
'h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all'; 'h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all';
export function AuditLogsPage() { export function AuditLogsPage() {
const isDemoMode = useDemoMode();
const [resource, setResource] = useState(''); const [resource, setResource] = useState('');
const [action, setAction] = useState(''); const [action, setAction] = useState('');
const [actor, setActor] = useState(''); const [actor, setActor] = useState('');
@@ -42,8 +45,13 @@ export function AuditLogsPage() {
const since = option && 'ms' in option ? new Date(Date.now() - option.ms).toISOString() : undefined; const since = option && 'ms' in option ? new Date(Date.now() - option.ms).toISOString() : undefined;
return listAuditLogs({ ...filters, since }); return listAuditLogs({ ...filters, since });
}, },
enabled: !isDemoMode,
}); });
if (isDemoMode) {
return <DemoRestricted feature="Audit logs" />;
}
const logs = logsQuery.data ?? []; const logs = logsQuery.data ?? [];
const hasNext = logs.length === PAGE_SIZE; const hasNext = logs.length === PAGE_SIZE;
const hasFilters = Boolean(resource || action || actor || range); const hasFilters = Boolean(resource || action || actor || range);
@@ -1,6 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { import {
cancelBuild, cancelBuild,
getBuildLogs, getBuildLogs,
@@ -9,6 +8,8 @@ import {
type BuildStatus, type BuildStatus,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { useBuildUpdates } from '@/lib/use-build-updates'; import { useBuildUpdates } from '@/lib/use-build-updates';
import { useDemoMode } from '@/lib/demo-mode';
import { demoBuilds } from '@/lib/demo-data';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import { import {
Check, Check,
@@ -33,36 +34,6 @@ const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
{ value: 'cancelled', label: 'Cancelled' }, { value: 'cancelled', label: 'Cancelled' },
]; ];
const demoBuilds: BuildEntity[] = [
{
id: 'build-demo-01',
projectId: 'project-demo',
serviceId: 'service-api',
status: 'success',
progress: 100,
startedAt: new Date(Date.now() - 20 * 60_000).toISOString(),
completedAt: new Date(Date.now() - 18 * 60_000).toISOString(),
imageName: 'ghcr.io/containr/api',
imageTag: 'sha-1f2e3d4',
size: 156_000_000,
log: '[demo] Build finished successfully.',
metadata: { branch: 'main' },
},
{
id: 'build-demo-02',
projectId: 'project-demo',
serviceId: 'service-worker',
status: 'running',
progress: 54,
startedAt: new Date(Date.now() - 2 * 60_000).toISOString(),
imageName: 'ghcr.io/containr/worker',
imageTag: 'sha-9a8b7c6',
size: 0,
log: '[demo] Building image layers...',
metadata: { branch: 'feature/queue' },
},
];
function StatusBadge({ status }: { status: BuildStatus }) { function StatusBadge({ status }: { status: BuildStatus }) {
const config = { const config = {
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false }, success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
@@ -99,8 +70,7 @@ function bytesToHumanReadable(bytes: number): string {
export function BuildsPage() { export function BuildsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchParams] = useSearchParams(); const isDemoMode = useDemoMode();
const isDemoMode = searchParams.get('demo') === '1';
const [projectFilter, setProjectFilter] = useState(''); const [projectFilter, setProjectFilter] = useState('');
const [serviceFilter, setServiceFilter] = useState(''); const [serviceFilter, setServiceFilter] = useState('');
@@ -14,6 +14,8 @@ import {
import { listProjects, listTemplates } from '@/lib/api-client'; import { listProjects, listTemplates } from '@/lib/api-client';
import { DocsBrowser } from '@/features/docs/DocsBrowser'; import { DocsBrowser } from '@/features/docs/DocsBrowser';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { demoProjects, demoTemplates } from '@/lib/demo-data';
const features = [ const features = [
{ icon: Rocket, title: 'Git-push deploys', text: 'Connect a repository and ship. Builds, rollouts, and rollbacks are handled for you.' }, { icon: Rocket, title: 'Git-push deploys', text: 'Connect a repository and ship. Builds, rollouts, and rollbacks are handled for you.' },
@@ -25,14 +27,16 @@ const features = [
]; ];
export function LandingPage() { export function LandingPage() {
const sessionQuery = useAuthSession(); const isDemoMode = useDemoMode();
const signedIn = Boolean(sessionQuery.data); 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 }); const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000, enabled: !isDemoMode });
const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000 }); const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000, enabled: !isDemoMode });
const projects = projectsQuery.data ?? []; const projects = isDemoMode ? demoProjects : (projectsQuery.data ?? []);
const templates = (templatesQuery.data ?? []).slice(0, 12); const templates = (isDemoMode ? demoTemplates : (templatesQuery.data ?? [])).slice(0, 12);
return ( return (
<div className="min-h-screen bg-[var(--bg-void)] text-[var(--text-secondary)]"> <div className="min-h-screen bg-[var(--bg-void)] text-[var(--text-secondary)]">
@@ -51,7 +55,7 @@ export function LandingPage() {
<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="#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> <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 <Link
to="/projects" 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)]" 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 Browse
@@ -65,10 +69,10 @@ export function LandingPage() {
</Link> </Link>
) : ( ) : (
<Link <Link
to="/auth/sign-in" 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)]" 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)]"
> >
Sign in <ArrowRight size={13} /> {isDemoMode ? 'Live demo' : 'Sign in'} <ArrowRight size={13} />
</Link> </Link>
)} )}
</nav> </nav>
@@ -91,10 +95,10 @@ export function LandingPage() {
</p> </p>
<div className="mt-8 flex items-center justify-center gap-3"> <div className="mt-8 flex items-center justify-center gap-3">
<Link <Link
to="/projects" 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]" 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]"
> >
Browse projects <ArrowRight size={15} /> {isDemoMode ? 'Explore the live demo' : 'Browse projects'} <ArrowRight size={15} />
</Link> </Link>
<a <a
href="#docs" href="#docs"
@@ -152,7 +156,7 @@ export function LandingPage() {
</p> </p>
</div> </div>
<Link <Link
to="/templates" to={`/templates${demo}`}
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--accent-primary)] hover:underline" className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--accent-primary)] hover:underline"
> >
Browse all <ArrowRight size={13} /> Browse all <ArrowRight size={13} />
@@ -162,7 +166,7 @@ export function LandingPage() {
{templates.map((template) => ( {templates.map((template) => (
<Link <Link
key={template.id} key={template.id}
to="/templates" 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)]" 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"> <div className="flex items-center gap-2.5">
@@ -212,9 +216,9 @@ export function LandingPage() {
Containr — self-hosted container platform Containr — self-hosted container platform
</span> </span>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link to="/projects" className="hover:text-[var(--text-secondary)]">Projects</Link> <Link to={`/projects${demo}`} className="hover:text-[var(--text-secondary)]">Projects</Link>
<Link to="/templates" className="hover:text-[var(--text-secondary)]">Templates</Link> <Link to={`/templates${demo}`} className="hover:text-[var(--text-secondary)]">Templates</Link>
<Link to="/admin" className="hover:text-[var(--text-secondary)]">Admin</Link> {!isDemoMode ? <Link to="/admin" className="hover:text-[var(--text-secondary)]">Admin</Link> : null}
</div> </div>
</div> </div>
</footer> </footer>
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
createProject, createProject,
@@ -15,39 +15,20 @@ import {
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
const demoProjects: ProjectEntity[] = [ import { demoProjects } from '@/lib/demo-data';
{
id: 'project-demo',
name: 'core-services',
description: 'Sample project with mock services for UI preview.',
isApproved: true,
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.',
isApproved: true,
createdAt: new Date(Date.now() - 7 * 86_400_000).toISOString(),
updatedAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
stats: { service_count: 2, deployment_count: 5, running_services: 1, last_deployment: null },
},
];
const demoDeploys = [ const demoDeploys = [
{ id: 'd1', name: 'api-gateway', project: 'core-services', status: 'DEPLOYED', when: '4m' }, { id: 'd1', name: 'API Gateway', project: 'Core Platform', status: 'DEPLOYED', when: '4m' },
{ id: 'd2', name: 'web-frontend', project: 'core-services', status: 'BUILDING', when: '6m' }, { id: 'd2', name: 'Web Frontend', project: 'Core Platform', status: 'BUILDING', when: '6m' },
{ id: 'd3', name: 'worker', project: 'ml-pipeline', status: 'FAILED', when: '1h' }, { id: 'd3', name: 'Batch Evaluator', project: 'Inference Lab', status: 'FAILED', when: '1h' },
{ id: 'd4', name: 'site', project: 'growth-site', status: 'DEPLOYED', when: '2h' }, { id: 'd4', name: 'Marketing Site', project: 'Growth Surface', status: 'DEPLOYED', when: '2h' },
]; ];
function getHealthStatus(stats: ProjectStats): 'healthy' | 'degraded' | 'critical' { function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' | 'critical' {
if (stats.service_count === 0) return 'healthy'; if (stats.service_count === 0) return 'idle';
if (stats.running_services === stats.service_count) return 'healthy'; if (stats.running_services === stats.service_count) return 'healthy';
if (stats.running_services >= stats.service_count / 2) return 'degraded'; if (stats.running_services > 0) return 'degraded';
return 'critical'; return 'critical';
} }
@@ -55,6 +36,8 @@ function healthBadge(health: ReturnType<typeof getHealthStatus>) {
switch (health) { switch (health) {
case 'healthy': case 'healthy':
return { label: 'RUNNING', cls: 'v-st-ok' }; return { label: 'RUNNING', cls: 'v-st-ok' };
case 'idle':
return { label: 'IDLE', cls: '' };
case 'degraded': case 'degraded':
return { label: 'DEGRADED', cls: 'v-st-warn' }; return { label: 'DEGRADED', cls: 'v-st-warn' };
case 'critical': case 'critical':
@@ -89,7 +72,6 @@ function MiniCanvas({ project }: { project: ProjectEntity }) {
{Array.from({ length: shown }).map((_, i) => ( {Array.from({ length: shown }).map((_, i) => (
<div key={i} className="v-node" style={positions[i]}> <div key={i} className="v-node" style={positions[i]}>
<b className={`v-nd ${i < running ? '' : 'r'}`} /> <b className={`v-nd ${i < running ? '' : 'r'}`} />
svc{i + 1}
</div> </div>
))} ))}
{extra > 0 ? ( {extra > 0 ? (
@@ -104,6 +86,7 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string }
const health = getHealthStatus(project.stats); const health = getHealthStatus(project.stats);
const badge = healthBadge(health); const badge = healthBadge(health);
const envOk = health === 'healthy'; const envOk = health === 'healthy';
const envLabel = envOk ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
return ( return (
<article <article
@@ -120,10 +103,10 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string }
<div className="v-mono mt-3 flex items-center gap-2 text-[10.5px] text-[var(--text-tertiary)]"> <div className="v-mono mt-3 flex items-center gap-2 text-[10.5px] text-[var(--text-tertiary)]">
<i <i
className="inline-block h-1.5 w-1.5 rounded-full" className="inline-block h-1.5 w-1.5 rounded-full"
style={{ background: envOk ? 'var(--success)' : 'var(--warning)' }} style={{ background: envOk ? 'var(--success)' : health === 'idle' ? 'var(--text-tertiary)' : health === 'critical' ? 'var(--error)' : 'var(--warning)' }}
/> />
<span className="text-[var(--text-secondary)]"> <span className="text-[var(--text-secondary)]">
{envOk ? 'production' : 'degraded'} {envLabel}
</span> </span>
<span>·</span> <span>·</span>
<span> <span>
@@ -137,9 +120,8 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string }
export function ProjectsPage() { export function ProjectsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isDemoMode = searchParams.get('demo') === '1'; const isDemoMode = useDemoMode();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [isCreateOpen, setCreateOpen] = useState(false); const [isCreateOpen, setCreateOpen] = useState(false);
@@ -199,6 +181,7 @@ export function ProjectsPage() {
const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0); const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0);
const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0); const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0);
const host = hostQuery.data; const host = hostQuery.data;
const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'HOST';
const cpuPct = isDemoMode ? 14 : host && host.cpu.cores > 0 const cpuPct = isDemoMode ? 14 : host && host.cpu.cores > 0
? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100)) ? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100))
: null; : null;
@@ -240,7 +223,7 @@ export function ProjectsPage() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="search-box" style={{ width: 240 }}> <div className="search-box" style={{ width: 'min(240px, 42vw)' }}>
<Search size={13} /> <Search size={13} />
<input <input
value={search} value={search}
@@ -250,16 +233,18 @@ export function ProjectsPage() {
style={{ fontSize: 11.5 }} style={{ fontSize: 11.5 }}
/> />
</div> </div>
<button onClick={openCreate} className="v-btn"> {signedIn && (
+ new project <button onClick={openCreate} className="v-btn">
</button> + new project
</button>
)}
</div> </div>
</div> </div>
{/* Host stat row */} {/* Host stat row */}
<div className="mb-7 grid grid-cols-2 gap-3 lg:grid-cols-4"> <div className="mb-7 grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="v-stat"> <div className="v-stat">
<div className="v-k"><span>HOST.CPU</span><span className="v-live" /></div> <div className="v-k"><span>{hostLabel}.CPU</span><span className="v-live" /></div>
<div className="v-v">{cpuPct ?? '—'}<span>%</span></div> <div className="v-v">{cpuPct ?? '—'}<span>%</span></div>
{isDemoMode ? ( {isDemoMode ? (
<div className="v-spark"> <div className="v-spark">
@@ -275,13 +260,13 @@ export function ProjectsPage() {
</div> </div>
</div> </div>
<div className="v-stat"> <div className="v-stat">
<div className="v-k"><span>HOST.MEM</span><span className="v-live" /></div> <div className="v-k"><span>{hostLabel}.MEM</span><span className="v-live" /></div>
<div className="v-v">{memPct ?? '—'}<span>%</span></div> <div className="v-v">{memPct ?? '—'}<span>%</span></div>
<div className="v-meter"><i className={memPct !== null && memPct > 85 ? 'r' : 'y'} style={{ width: `${memPct ?? 0}%` }} /></div> <div className="v-meter"><i className={memPct !== null && memPct > 85 ? 'r' : 'y'} style={{ width: `${memPct ?? 0}%` }} /></div>
<div className="v-d">{isDemoMode ? '9.8G / 13G' : host ? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)}` : 'no telemetry'}</div> <div className="v-d">{isDemoMode ? '9.8G / 13G' : host ? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)}` : 'no telemetry'}</div>
</div> </div>
<div className="v-stat"> <div className="v-stat">
<div className="v-k"><span>HOST.DISK</span><span className="v-live" /></div> <div className="v-k"><span>{hostLabel}.DISK</span><span className="v-live" /></div>
<div className="v-v">{diskPct ?? '—'}<span>%</span></div> <div className="v-v">{diskPct ?? '—'}<span>%</span></div>
<div className="v-meter"><i className="r" style={{ width: `${diskPct ?? 0}%` }} /></div> <div className="v-meter"><i className="r" style={{ width: `${diskPct ?? 0}%` }} /></div>
<div className="v-d">{isDemoMode ? '338G of 465G · high' : host ? `${fmtGB(host.storage.used)} of ${fmtGB(host.storage.total)}` : 'no telemetry'}</div> <div className="v-d">{isDemoMode ? '338G of 465G · high' : host ? `${fmtGB(host.storage.used)} of ${fmtGB(host.storage.total)}` : 'no telemetry'}</div>
+279 -61
View File
@@ -17,6 +17,9 @@ import {
createAgentToken, createAgentToken,
listAgentTokens, listAgentTokens,
revokeAgentToken, revokeAgentToken,
createUserToken,
listUserTokens,
revokeUserToken,
listDatabases, listDatabases,
databaseAction, databaseAction,
updateDatabaseBackupSchedule, updateDatabaseBackupSchedule,
@@ -46,17 +49,35 @@ import {
updateVulnerability, updateVulnerability,
getSecurityMetrics, getSecurityMetrics,
type AgentAuthTokenCreated, type AgentAuthTokenCreated,
type UserTokenCreated,
type UserTokenScope,
type CreateDatabaseInput, type CreateDatabaseInput,
type DatabaseEntity, type DatabaseEntity,
type FailoverPolicy, type FailoverPolicy,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { demoDatabases } from '@/lib/demo-data'; import {
demoBuilds,
demoDatabases,
demoHAAlerts,
demoHAHealthResults,
demoHAPolicies,
demoHAStatus,
demoHostMonitoring,
demoProjects,
demoSecurityMetrics,
demoSecurityScans,
demoVulnerabilities,
getDemoServicesByProject,
} from '@/lib/demo-data';
import { ServiceIcon } from '@/features/workspace/canvas/ServiceIcon';
import { serviceAccent } from '@/features/workspace/canvas/service-visuals';
import { DocsBrowser } from '@/features/docs/DocsBrowser'; import { DocsBrowser } from '@/features/docs/DocsBrowser';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client'; import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client';
import { useBuildUpdates } from '@/lib/use-build-updates'; import { useBuildUpdates } from '@/lib/use-build-updates';
import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart } from '@/shared/components'; import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart, DemoRestricted } from '@/shared/components';
import { import {
Clock, Clock,
Activity, Activity,
@@ -203,25 +224,27 @@ function formatUptime(seconds: number): string {
export function UsagePage() { export function UsagePage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const sessionQuery = useAuthSession(); const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const profileQuery = useQuery({ const profileQuery = useQuery({
queryKey: ['user-profile'], queryKey: ['user-profile'],
queryFn: getCurrentUserProfile, queryFn: getCurrentUserProfile,
enabled: Boolean(sessionQuery.data), enabled: !isDemoMode && Boolean(sessionQuery.data),
retry: false, retry: false,
}); });
const signedIn = Boolean(sessionQuery.data); const signedIn = isDemoMode || Boolean(sessionQuery.data);
const isAdmin = Boolean(profileQuery.data?.isAdmin); const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
const buildsQuery = useQuery({ const buildsQuery = useQuery({
queryKey: ['usage-builds'], queryKey: ['usage-builds'],
queryFn: () => listBuilds({ page: 1, limit: 100 }), queryFn: () => listBuilds({ page: 1, limit: 100 }),
enabled: signedIn, enabled: signedIn && !isDemoMode,
retry: false, retry: false,
}); });
const hostQuery = useQuery({ const hostQuery = useQuery({
queryKey: ['usage-host-monitoring'], queryKey: ['usage-host-monitoring'],
queryFn: getHostMonitoring, queryFn: getHostMonitoring,
refetchInterval: 15_000, refetchInterval: 15_000,
enabled: !isDemoMode,
}); });
const agentsQuery = useQuery({ const agentsQuery = useQuery({
queryKey: ['usage-agents'], queryKey: ['usage-agents'],
@@ -251,9 +274,12 @@ export function UsagePage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }),
}); });
const builds = buildsQuery.data?.builds ?? []; const builds = useMemo(
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
[isDemoMode, buildsQuery.data],
);
const liveStatus = useBuildUpdates( const liveStatus = useBuildUpdates(
builds.map((build) => build.id), isDemoMode ? [] : builds.map((build) => build.id),
() => { () => {
queryClient.invalidateQueries({ queryKey: ['usage-builds'] }); queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
}, },
@@ -280,20 +306,19 @@ export function UsagePage() {
const avgBuildMinutes = averageBuildDurationMs / 60_000; const avgBuildMinutes = averageBuildDurationMs / 60_000;
const activeBuildPressure = runningBuilds + pendingBuilds; const activeBuildPressure = runningBuilds + pendingBuilds;
const uniqueServices = new Set(builds.map((build) => build.serviceId).filter(Boolean)).size; const uniqueServices = new Set(builds.map((build) => build.serviceId).filter(Boolean)).size;
const host = hostQuery.data; const host = isDemoMode ? demoHostMonitoring : hostQuery.data;
const agents = agentsQuery.data ?? []; const agents = agentsQuery.data ?? [];
// Accumulate host samples for the load sparkline (polled every 15s). // Accumulate host samples for the load sparkline (polled every 15s).
const [loadHistory, setLoadHistory] = useState<number[]>([]); const [loadHistory, setLoadHistory] = useState<number[]>([]);
useEffect(() => { useEffect(() => {
const sample = hostQuery.data; if (!host || host.cpu.cores <= 0) {
if (!sample || sample.cpu.cores <= 0) {
return; return;
} }
const loadPercent = Math.min(100, (sample.load.load1m / sample.cpu.cores) * 100); const loadPercent = Math.min(100, (host.load.load1m / host.cpu.cores) * 100);
// eslint-disable-next-line react-hooks/set-state-in-effect -- accumulating polled samples is a legitimate sync-to-external pattern // eslint-disable-next-line react-hooks/set-state-in-effect -- accumulating polled samples is a legitimate sync-to-external pattern
setLoadHistory((prev) => [...prev, loadPercent].slice(-24)); setLoadHistory((prev) => [...prev, loadPercent].slice(-24));
}, [hostQuery.data]); }, [host]);
const hostLoadPercent = host && host.cpu.cores > 0 const hostLoadPercent = host && host.cpu.cores > 0
? Math.min(100, (host.load.load1m / host.cpu.cores) * 100) ? Math.min(100, (host.load.load1m / host.cpu.cores) * 100)
@@ -307,14 +332,14 @@ export function UsagePage() {
const buildsPerDay = useMemo(() => { const buildsPerDay = useMemo(() => {
const days = 14; const days = 14;
const counts = new Array<number>(days).fill(0); const counts = new Array<number>(days).fill(0);
const timestamps = (buildsQuery.data?.builds ?? []) const timestamps = builds
.map((build) => new Date((build.startedAt ?? build.completedAt) as string).getTime()) .map((build) => new Date((build.startedAt ?? build.completedAt) as string).getTime())
.filter((t) => Number.isFinite(t)); .filter((t) => Number.isFinite(t));
if (timestamps.length === 0) { if (timestamps.length === 0) {
return counts; return counts;
} }
const anchor = Math.max(...timestamps); const anchor = Math.max(...timestamps);
(buildsQuery.data?.builds ?? []).forEach((build) => { builds.forEach((build) => {
const timestamp = build.startedAt ?? build.completedAt; const timestamp = build.startedAt ?? build.completedAt;
if (!timestamp) { if (!timestamp) {
return; return;
@@ -327,7 +352,7 @@ export function UsagePage() {
}); });
const peak = Math.max(...counts, 1); const peak = Math.max(...counts, 1);
return counts.map((count) => (count / peak) * 100); return counts.map((count) => (count / peak) * 100);
}, [buildsQuery.data]); }, [builds]);
const onlineAgents = agents.filter((agent) => agent.status === 'online' || agent.status === 'connecting').length; const onlineAgents = agents.filter((agent) => agent.status === 'online' || agent.status === 'connecting').length;
const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0); const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0); const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
@@ -382,12 +407,12 @@ containr-agent`;
</span> </span>
</div> </div>
{buildsQuery.isLoading ? ( {!isDemoMode && buildsQuery.isLoading ? (
<div className="py-16 text-center"> <div className="py-16 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" /> <Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading usage data...</p> <p className="mt-3 text-sm text-[var(--text-muted)]">Loading usage data...</p>
</div> </div>
) : buildsQuery.isError ? ( ) : !isDemoMode && buildsQuery.isError ? (
<div className="panel p-8 text-center"> <div className="panel p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center"> <div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<AlertCircle size={24} className="text-[var(--error)]" /> <AlertCircle size={24} className="text-[var(--error)]" />
@@ -455,7 +480,7 @@ containr-agent`;
</button> </button>
</div> </div>
{hostQuery.isError ? ( {!isDemoMode && hostQuery.isError ? (
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]"> <div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
Host monitoring unavailable. Host monitoring unavailable.
</div> </div>
@@ -672,9 +697,11 @@ containr-agent`;
export function PeoplePage() { export function PeoplePage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isDemoMode = useDemoMode();
const profileQuery = useQuery({ const profileQuery = useQuery({
queryKey: ['user-profile'], queryKey: ['user-profile'],
queryFn: getCurrentUserProfile, queryFn: getCurrentUserProfile,
enabled: !isDemoMode,
}); });
const [newUserName, setNewUserName] = useState(''); const [newUserName, setNewUserName] = useState('');
const [newUserEmail, setNewUserEmail] = useState(''); const [newUserEmail, setNewUserEmail] = useState('');
@@ -699,12 +726,18 @@ export function PeoplePage() {
const projectsQuery = useQuery({ const projectsQuery = useQuery({
queryKey: ['people-projects'], queryKey: ['people-projects'],
queryFn: () => listProjects(), queryFn: () => listProjects(),
enabled: !isDemoMode,
}); });
const auditLogsQuery = useQuery({ const auditLogsQuery = useQuery({
queryKey: ['people-audit-logs'], queryKey: ['people-audit-logs'],
queryFn: () => listAuditLogs({ page: 1, limit: 20 }), queryFn: () => listAuditLogs({ page: 1, limit: 20 }),
enabled: !isDemoMode,
}); });
if (isDemoMode) {
return <DemoRestricted feature="People and access management" />;
}
let membersBody = 'Owner-only mode currently. Multi-user collaboration is planned.'; let membersBody = 'Owner-only mode currently. Multi-user collaboration is planned.';
if (profileQuery.isLoading) { if (profileQuery.isLoading) {
@@ -1239,12 +1272,172 @@ function PlatformSettingsSection() {
); );
} }
const PAT_EXPIRY_OPTIONS: { label: string; days: number }[] = [
{ label: 'Never', days: 0 },
{ label: '30 days', days: 30 },
{ label: '90 days', days: 90 },
{ label: '1 year', days: 365 },
];
function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
const queryClient = useQueryClient();
const tokensQuery = useQuery({
queryKey: ['user-tokens'],
queryFn: listUserTokens,
});
const [tokenName, setTokenName] = useState('');
const [tokenScope, setTokenScope] = useState<UserTokenScope>('write');
const [expiryDays, setExpiryDays] = useState(0);
const [issuedToken, setIssuedToken] = useState<UserTokenCreated | null>(null);
const createMutation = useMutation({
mutationFn: () =>
createUserToken({
name: tokenName.trim(),
scope: tokenScope,
expiresInDays: expiryDays,
}),
onSuccess: (data) => {
setIssuedToken(data);
setTokenName('');
queryClient.invalidateQueries({ queryKey: ['user-tokens'] });
},
});
const revokeMutation = useMutation({
mutationFn: (id: string) => revokeUserToken(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['user-tokens'] }),
});
const tokens = tokensQuery.data ?? [];
const scopeOptions: { value: UserTokenScope; label: string }[] = isAdmin
? [
{ value: 'read', label: 'Read - GET requests only' },
{ value: 'write', label: 'Write - read and manage resources' },
{ value: 'admin', label: 'Admin - everything, incl. platform settings' },
]
: [
{ value: 'read', label: 'Read - GET requests only' },
{ value: 'write', label: 'Write - read and manage resources' },
];
return (
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Key size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Personal Access Tokens</h2>
<p className="text-xs text-[var(--text-tertiary)]">Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope</p>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<input
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
placeholder="Token name (e.g. laptop cli)"
className="h-9 flex-1 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
<select
value={tokenScope}
onChange={(e) => setTokenScope(e.target.value as UserTokenScope)}
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
>
{scopeOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<select
value={expiryDays}
onChange={(e) => setExpiryDays(Number(e.target.value))}
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
>
{PAT_EXPIRY_OPTIONS.map((option) => (
<option key={option.days} value={option.days}>{option.label}</option>
))}
</select>
<button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !tokenName.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: 'var(--accent-primary)' }}
>
{createMutation.isPending ? 'Creating…' : 'Create token'}
</button>
</div>
{issuedToken ? (
<div className="rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
<div className="flex items-center justify-between gap-2">
<span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
<button
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
>
<Copy size={11} />
Copy
</button>
</div>
<p className="mt-1 text-xs text-[var(--text-tertiary)]">
Shown once - store it now. Use with <span className="mono">containr auth login --token</span> or as a Bearer token.
</p>
</div>
) : null}
{createMutation.isError ? (
<p className="text-xs text-[var(--error)]">
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create token'}
</p>
) : null}
{tokensQuery.isLoading ? (
<div className="py-4 text-center">
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : tokens.length === 0 ? (
<p className="text-xs text-[var(--text-tertiary)]">No tokens yet. Create one to connect the CLI or an agent.</p>
) : (
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
{tokens.map((token) => (
<div key={token.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0">
<span className="text-[var(--text-primary)]">{token.name}</span>
<span className="ml-2 mono text-[var(--text-muted)]">{token.key_prefix}…</span>
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">{token.scope}</span>
<span className="ml-2 text-[var(--text-muted)]">
{token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'}
{token.expires_at ? ` · expires ${formatRelative(token.expires_at)}` : ''}
</span>
</div>
<button
onClick={() => {
if (window.confirm(`Revoke token "${token.name}"? Clients using it lose access immediately.`)) {
revokeMutation.mutate(token.id ?? '');
}
}}
disabled={revokeMutation.isPending}
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
>
Revoke
</button>
</div>
))}
</div>
)}
</div>
</section>
);
}
export function SettingsPage() { export function SettingsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isDemoMode = useDemoMode();
const profileQuery = useQuery({ const profileQuery = useQuery({
queryKey: ['settings-profile'], queryKey: ['settings-profile'],
queryFn: getCurrentUserProfile, queryFn: getCurrentUserProfile,
enabled: !isDemoMode,
}); });
const [nameDraft, setNameDraft] = useState<string | null>(null); const [nameDraft, setNameDraft] = useState<string | null>(null);
@@ -1308,6 +1501,10 @@ export function SettingsPage() {
refreshStorage(); refreshStorage();
}; };
if (isDemoMode) {
return <DemoRestricted feature="Account and platform settings" />;
}
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<SecondaryPageHeader <SecondaryPageHeader
@@ -1404,6 +1601,9 @@ export function SettingsPage() {
)} )}
</section> </section>
{/* Personal Access Tokens */}
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
{/* Platform Settings - owner only */} {/* Platform Settings - owner only */}
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null} {profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
@@ -1561,8 +1761,7 @@ const DATABASE_PLANS = ['hobby', 'starter', 'standard', 'business'] as const;
export function DatabasesPage() { export function DatabasesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchParams] = useSearchParams(); const isDemoMode = useDemoMode();
const isDemoMode = searchParams.get('demo') === '1';
const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data); const signedIn = isDemoMode || Boolean(sessionQuery.data);
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
@@ -1668,8 +1867,14 @@ export function DatabasesPage() {
onClick={() => setExpandedId(expanded ? null : (db.id ?? null))} onClick={() => setExpandedId(expanded ? null : (db.id ?? null))}
className="flex w-full items-center gap-3 p-4 text-left" className="flex w-full items-center gap-3 p-4 text-left"
> >
<div className="w-9 h-9 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center shrink-0"> <div
<Database size={17} className="text-[var(--accent-primary)]" /> className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center shrink-0"
style={{
background: `${serviceAccent({ name: db.type })}1c`,
color: serviceAccent({ name: db.type }),
}}
>
<ServiceIcon service={{ name: db.type, type: 'database' }} size={17} />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -2074,14 +2279,15 @@ const failoverStrategies = [
export function HighAvailabilityPage() { export function HighAvailabilityPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const sessionQuery = useAuthSession(); const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const profileQuery = useQuery({ const profileQuery = useQuery({
queryKey: ['user-profile'], queryKey: ['user-profile'],
queryFn: getCurrentUserProfile, queryFn: getCurrentUserProfile,
enabled: Boolean(sessionQuery.data), enabled: !isDemoMode && Boolean(sessionQuery.data),
retry: false, retry: false,
}); });
const isAdmin = Boolean(profileQuery.data?.isAdmin); const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
const [failoverReason, setFailoverReason] = useState(''); const [failoverReason, setFailoverReason] = useState('');
const [policyForm, setPolicyForm] = useState<{ const [policyForm, setPolicyForm] = useState<{
serviceId: string; serviceId: string;
@@ -2093,11 +2299,11 @@ export function HighAvailabilityPage() {
} | null>(null); } | null>(null);
const [policyProjectId, setPolicyProjectId] = useState(''); const [policyProjectId, setPolicyProjectId] = useState('');
const statusQuery = useQuery({ queryKey: ['ha-status'], queryFn: getHAStatus, refetchInterval: 15_000 }); const statusQuery = useQuery({ queryKey: ['ha-status'], queryFn: getHAStatus, refetchInterval: 15_000, enabled: !isDemoMode });
const policiesQuery = useQuery({ queryKey: ['ha-policies'], queryFn: listFailoverPolicies }); const policiesQuery = useQuery({ queryKey: ['ha-policies'], queryFn: listFailoverPolicies, enabled: !isDemoMode });
const alertsQuery = useQuery({ queryKey: ['ha-alerts'], queryFn: listActiveAlerts, refetchInterval: 15_000 }); const alertsQuery = useQuery({ queryKey: ['ha-alerts'], queryFn: listActiveAlerts, refetchInterval: 15_000, enabled: !isDemoMode });
const healthQuery = useQuery({ queryKey: ['ha-health'], queryFn: listHealthResults, refetchInterval: 30_000 }); const healthQuery = useQuery({ queryKey: ['ha-health'], queryFn: listHealthResults, refetchInterval: 30_000, enabled: !isDemoMode });
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects() }); const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode });
const servicesQuery = useQuery({ const servicesQuery = useQuery({
queryKey: ['services', policyProjectId], queryKey: ['services', policyProjectId],
queryFn: () => listServicesByProject(policyProjectId), queryFn: () => listServicesByProject(policyProjectId),
@@ -2142,7 +2348,10 @@ export function HighAvailabilityPage() {
onSuccess: invalidate, onSuccess: invalidate,
}); });
const status = statusQuery.data; const status = isDemoMode ? demoHAStatus : statusQuery.data;
const alerts = isDemoMode ? demoHAAlerts : alertsQuery.data ?? [];
const policies = isDemoMode ? demoHAPolicies : policiesQuery.data ?? [];
const healthResults = isDemoMode ? demoHAHealthResults : healthQuery.data ?? [];
const pageError = const pageError =
statusQuery.error ?? policiesQuery.error ?? alertsQuery.error ?? healthQuery.error ?? statusQuery.error ?? policiesQuery.error ?? alertsQuery.error ?? healthQuery.error ??
toggleMutation.error ?? failoverMutation.error ?? savePolicyMutation.error ?? toggleMutation.error ?? failoverMutation.error ?? savePolicyMutation.error ??
@@ -2158,7 +2367,7 @@ export function HighAvailabilityPage() {
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]"> <div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<ShieldCheck size={13} className="text-[var(--accent-primary)]" /> <ShieldCheck size={13} className="text-[var(--accent-primary)]" />
<span>HA state feeds the platform security posture.</span> <span>HA state feeds the platform security posture.</span>
<Link to="/security" className="font-medium text-[var(--accent-primary)] hover:underline"> <Link to={isDemoMode ? '/security?demo=1' : '/security'} className="font-medium text-[var(--accent-primary)] hover:underline">
Open Security → Open Security →
</Link> </Link>
</div> </div>
@@ -2246,13 +2455,13 @@ export function HighAvailabilityPage() {
<AlertCircle size={18} className="text-[var(--accent-primary)]" /> <AlertCircle size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Active alerts</h2> <h2 className="text-base font-semibold text-[var(--text-primary)]">Active alerts</h2>
</div> </div>
{alertsQuery.isLoading ? ( {!isDemoMode && alertsQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (alertsQuery.data ?? []).length === 0 ? ( ) : alerts.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No active alerts.</p> <p className="text-sm text-[var(--text-tertiary)]">No active alerts.</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{(alertsQuery.data ?? []).map((alert) => ( {alerts.map((alert) => (
<div <div
key={alert.id} key={alert.id}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3" className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
@@ -2398,13 +2607,13 @@ export function HighAvailabilityPage() {
</div> </div>
)} )}
{policiesQuery.isLoading ? ( {!isDemoMode && policiesQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (policiesQuery.data ?? []).length === 0 ? ( ) : policies.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No failover policies configured.</p> <p className="text-sm text-[var(--text-tertiary)]">No failover policies configured.</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{(policiesQuery.data ?? []).map((policy: FailoverPolicy) => ( {policies.map((policy: FailoverPolicy) => (
<div <div
key={policy.service_id} key={policy.service_id}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3" className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
@@ -2461,13 +2670,13 @@ export function HighAvailabilityPage() {
<HeartPulse size={18} className="text-[var(--accent-primary)]" /> <HeartPulse size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Health check results</h2> <h2 className="text-base font-semibold text-[var(--text-primary)]">Health check results</h2>
</div> </div>
{healthQuery.isLoading ? ( {!isDemoMode && healthQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (healthQuery.data ?? []).length === 0 ? ( ) : healthResults.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No health checks configured.</p> <p className="text-sm text-[var(--text-tertiary)]">No health checks configured.</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{(healthQuery.data ?? []).slice(0, 20).map((result, i) => ( {healthResults.slice(0, 20).map((result, i) => (
<div <div
key={`${result.check_id}-${i}`} key={`${result.check_id}-${i}`}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3" className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
@@ -2509,34 +2718,37 @@ const severityColor: Record<string, string> = {
export function SecurityPage() { export function SecurityPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const sessionQuery = useAuthSession(); const isDemoMode = useDemoMode();
const signedIn = Boolean(sessionQuery.data); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const [projectId, setProjectId] = useState(''); const signedIn = !isDemoMode && Boolean(sessionQuery.data);
const [projectId, setProjectId] = useState(isDemoMode ? 'demo-project-core' : '');
const [scanType, setScanType] = useState<string>('comprehensive'); const [scanType, setScanType] = useState<string>('comprehensive');
const [scanServiceId, setScanServiceId] = useState(''); const [scanServiceId, setScanServiceId] = useState('');
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects() }); const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode });
const projects = isDemoMode ? demoProjects : projectsQuery.data ?? [];
const servicesQuery = useQuery({ const servicesQuery = useQuery({
queryKey: ['services', projectId], queryKey: ['services', projectId],
queryFn: () => listServicesByProject(projectId), queryFn: () => listServicesByProject(projectId),
enabled: Boolean(projectId), enabled: !isDemoMode && Boolean(projectId),
}); });
const services = isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? [];
const metricsQuery = useQuery({ const metricsQuery = useQuery({
queryKey: ['security-metrics', projectId], queryKey: ['security-metrics', projectId],
queryFn: () => getSecurityMetrics(projectId), queryFn: () => getSecurityMetrics(projectId),
enabled: Boolean(projectId), enabled: !isDemoMode && Boolean(projectId),
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
const historyQuery = useQuery({ const historyQuery = useQuery({
queryKey: ['security-history', projectId], queryKey: ['security-history', projectId],
queryFn: () => getSecurityHistory(projectId), queryFn: () => getSecurityHistory(projectId),
enabled: Boolean(projectId), enabled: !isDemoMode && Boolean(projectId),
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
const vulnsQuery = useQuery({ const vulnsQuery = useQuery({
queryKey: ['security-vulns', projectId], queryKey: ['security-vulns', projectId],
queryFn: () => listVulnerabilities(projectId), queryFn: () => listVulnerabilities(projectId),
enabled: Boolean(projectId), enabled: !isDemoMode && Boolean(projectId),
}); });
const invalidate = () => { const invalidate = () => {
@@ -2559,8 +2771,14 @@ export function SecurityPage() {
onSuccess: invalidate, onSuccess: invalidate,
}); });
const metrics = metricsQuery.data; const metrics = isDemoMode ? demoSecurityMetrics : metricsQuery.data;
const vulns = (vulnsQuery.data ?? []).filter((v) => v.status !== 'resolved'); const scanHistory = isDemoMode
? demoSecurityScans.filter((s) => s.project_id === projectId)
: historyQuery.data ?? [];
const vulns = (isDemoMode
? demoVulnerabilities.filter((v) => v.project_id === projectId)
: vulnsQuery.data ?? []
).filter((v) => v.status !== 'resolved');
const pageError = const pageError =
projectsQuery.error ?? metricsQuery.error ?? historyQuery.error ?? vulnsQuery.error ?? projectsQuery.error ?? metricsQuery.error ?? historyQuery.error ?? vulnsQuery.error ??
scanMutation.error ?? vulnMutation.error; scanMutation.error ?? vulnMutation.error;
@@ -2575,7 +2793,7 @@ export function SecurityPage() {
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]"> <div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<HeartPulse size={13} className="text-[var(--accent-primary)]" /> <HeartPulse size={13} className="text-[var(--accent-primary)]" />
<span>Platform availability and failover live under High availability.</span> <span>Platform availability and failover live under High availability.</span>
<Link to="/ha" className="font-medium text-[var(--accent-primary)] hover:underline"> <Link to={isDemoMode ? '/ha?demo=1' : '/ha'} className="font-medium text-[var(--accent-primary)] hover:underline">
Open HA → Open HA →
</Link> </Link>
</div> </div>
@@ -2594,7 +2812,7 @@ export function SecurityPage() {
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] w-56" className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] w-56"
> >
<option value="">Select project…</option> <option value="">Select project…</option>
{(projectsQuery.data ?? []).map((p) => ( {projects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option> <option key={p.id} value={p.id}>{p.name}</option>
))} ))}
</select> </select>
@@ -2621,7 +2839,7 @@ export function SecurityPage() {
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]" className="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="">All services</option> <option value="">All services</option>
{(servicesQuery.data ?? []).map((s) => ( {services.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option> <option key={s.id} value={s.id}>{s.name}</option>
))} ))}
</select> </select>
@@ -2636,7 +2854,7 @@ export function SecurityPage() {
{scanMutation.isPending ? 'Starting…' : 'Run scan'} {scanMutation.isPending ? 'Starting…' : 'Run scan'}
</button> </button>
) : ( ) : (
<span className="self-end pb-2 text-xs text-[var(--text-tertiary)]">Sign in to run scans.</span> <span className="self-end pb-2 text-xs text-[var(--text-tertiary)]">{isDemoMode ? 'Read-only in demo.' : 'Sign in to run scans.'}</span>
)} )}
</> </>
)} )}
@@ -2679,7 +2897,7 @@ export function SecurityPage() {
<Shield size={18} className="text-[var(--accent-primary)]" /> <Shield size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Findings</h2> <h2 className="text-base font-semibold text-[var(--text-primary)]">Findings</h2>
</div> </div>
{vulnsQuery.isLoading ? ( {!isDemoMode && vulnsQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : vulns.length === 0 ? ( ) : vulns.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No open findings.</p> <p className="text-sm text-[var(--text-tertiary)]">No open findings.</p>
@@ -2735,13 +2953,13 @@ export function SecurityPage() {
<Clock size={18} className="text-[var(--accent-primary)]" /> <Clock size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Scan history</h2> <h2 className="text-base font-semibold text-[var(--text-primary)]">Scan history</h2>
</div> </div>
{historyQuery.isLoading ? ( {!isDemoMode && historyQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (historyQuery.data ?? []).length === 0 ? ( ) : scanHistory.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No scans yet.</p> <p className="text-sm text-[var(--text-tertiary)]">No scans yet.</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{(historyQuery.data ?? []).map((scan) => ( {scanHistory.map((scan) => (
<div <div
key={scan.id} key={scan.id}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3" className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
@@ -36,6 +36,9 @@ import {
type CronJobEntity, type CronJobEntity,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data'; import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
import { useDemoMode } from '@/lib/demo-mode';
import { ServiceIcon } from '@/features/workspace/canvas/ServiceIcon';
import { serviceAccent } from '@/features/workspace/canvas/service-visuals';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { getCurrentUserProfile } from '@/lib/api-client'; import { getCurrentUserProfile } from '@/lib/api-client';
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils'; import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
@@ -120,10 +123,10 @@ function StatusBadge({ status }: { status: string }) {
export function ServiceDetailPage() { export function ServiceDetailPage() {
const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>(); const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isDemoMode = searchParams.get('demo') === '1'; const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data); const signedIn = isDemoMode || Boolean(sessionQuery.data);
const profileQuery = useQuery({ const profileQuery = useQuery({
@@ -134,7 +137,10 @@ export function ServiceDetailPage() {
}); });
const isAdmin = Boolean(profileQuery.data?.isAdmin); const isAdmin = Boolean(profileQuery.data?.isAdmin);
const [activeSection, setActiveSection] = useState<ServiceSection>('metrics'); const [activeSection, setActiveSection] = useState<ServiceSection>(() => {
const requested = searchParams.get('section');
return sectionItems.some((item) => item.key === requested) ? (requested as ServiceSection) : 'metrics';
});
const [logTail, setLogTail] = useState('100'); const [logTail, setLogTail] = useState('100');
const [varDrafts, setVarDrafts] = useState<VariableDraft[] | null>(null); const [varDrafts, setVarDrafts] = useState<VariableDraft[] | null>(null);
const [bulkOpen, setBulkOpen] = useState(false); const [bulkOpen, setBulkOpen] = useState(false);
@@ -382,7 +388,7 @@ export function ServiceDetailPage() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] }); queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
queryClient.invalidateQueries({ queryKey: ['projects'] }); queryClient.invalidateQueries({ queryKey: ['projects'] });
navigate(`/projects/${projectId}`); navigate(isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`);
}, },
}); });
@@ -595,13 +601,12 @@ export function ServiceDetailPage() {
style={{ style={{
width: '46px', width: '46px',
height: '46px', height: '46px',
background: 'var(--accent-primary)', background: `${serviceAccent(service)}1f`,
color: serviceAccent(service),
marginRight: '14px' marginRight: '14px'
}} }}
> >
<svg width="22" height="22" viewBox="0 0 24 24" fill="white"> <ServiceIcon service={service} size={22} />
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/>
</svg>
</div> </div>
<div> <div>
<div className="flex items-center" style={{ gap: '10px' }}> <div className="flex items-center" style={{ gap: '10px' }}>
@@ -967,31 +972,60 @@ export function ServiceDetailPage() {
<p className="text-sm font-medium text-[var(--text-primary)]">Networking</p> <p className="text-sm font-medium text-[var(--text-primary)]">Networking</p>
<p className="text-xs text-[var(--text-tertiary)]">Public endpoints and ports</p> <p className="text-xs text-[var(--text-tertiary)]">Public endpoints and ports</p>
</div> </div>
{runtimeQuery.data?.health === 'healthy' && (
<span className="ml-auto v-st v-st-ok">HEALTHY</span>
)}
{runtimeQuery.data?.health === 'unhealthy' && (
<span className="ml-auto v-st v-st-fail">UNHEALTHY</span>
)}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]"> {service.domain && (
<div className="flex items-center gap-2"> <div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="w-2 h-2 rounded-full bg-[var(--success)]" /> <div className="flex items-center gap-2">
<span className="text-xs text-[var(--text-secondary)]">HTTPS</span> <div className="w-2 h-2 rounded-full bg-[var(--success)]" />
<span className="text-xs text-[var(--text-secondary)]">Domain</span>
</div>
<a
href={`https://${service.domain}`}
target="_blank"
rel="noopener noreferrer"
className="mono text-xs text-[var(--accent-primary)] hover:underline"
>
{service.domain}
</a>
</div> </div>
<a )}
href={`https://${service.name}.containr.dev`} {(runtimeQuery.data?.urls ?? []).map((url) => (
target="_blank" <div key={url} className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
rel="noopener noreferrer" <div className="flex items-center gap-2">
className="mono text-xs text-[var(--accent-primary)] hover:underline" <div className="w-2 h-2 rounded-full bg-[var(--success)]" />
> <span className="text-xs text-[var(--text-secondary)]">HTTP</span>
{service.name}.containr.dev </div>
</a> <a
</div> href={url}
target="_blank"
rel="noopener noreferrer"
className="mono text-xs text-[var(--accent-primary)] hover:underline"
>
{url.replace(/^https?:\/\//, '')}
</a>
</div>
))}
<div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]"> <div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--accent-secondary)]" /> <div className="w-2 h-2 rounded-full bg-[var(--accent-secondary)]" />
<span className="text-xs text-[var(--text-secondary)]">Port</span> <span className="text-xs text-[var(--text-secondary)]">Internal</span>
</div> </div>
<span className="mono text-xs text-[var(--text-primary)]">8080 → 443</span> <span className="mono text-xs text-[var(--text-primary)]">{service.name}{service.port ? `:${service.port}` : ''}</span>
</div> </div>
</div> </div>
{!service.domain && (runtimeQuery.data?.urls ?? []).length === 0 && (
<p className="mt-3 text-xs text-[var(--text-tertiary)]">
No public endpoint. Publish a port or set a domain in Settings → Networking to expose this service.
</p>
)}
</div> </div>
)} )}
</div> </div>
@@ -10,11 +10,12 @@ import {
listProjects, listProjects,
listTemplates, listTemplates,
updateTemplate, updateTemplate,
type TemplateDetailEntity,
type TemplateEntity, type TemplateEntity,
type TemplateWriteInput, type TemplateWriteInput,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { demoProjects, demoTemplates, demoTemplateDetails } from '@/lib/demo-data';
import { import {
Search, Search,
Filter, Filter,
@@ -37,327 +38,6 @@ import {
X, X,
} from 'lucide-react'; } from 'lucide-react';
const demoTemplates: TemplateEntity[] = [
{
id: 'tpl-react',
name: 'React Application',
description: 'Single-page frontend with Vite and static serving runtime.',
category: 'frontend',
logo: 'https://cdn.simpleicons.org/react',
configRaw: '{"runtime":"node"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
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,
},
{
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,
},
{
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,
},
{
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,
},
{
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,
},
{
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,
},
];
const demoTemplateDetails: Record<string, TemplateDetailEntity> = {
'tpl-react': {
template: demoTemplates[0],
config: {
type: 'web',
runtime: 'node',
buildCommand: 'npm install && npm run build',
startCommand: 'npx serve -s dist',
port: 3000,
healthCheck: '/health',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'VITE_API_URL',
label: 'API URL',
defaultValue: 'https://api.example.com',
required: true,
secret: false,
description: 'Public API endpoint for frontend calls',
},
],
},
'tpl-go': {
template: demoTemplates[1],
config: {
type: 'web',
runtime: 'go',
buildCommand: 'go build -o app .',
startCommand: './app',
port: 8080,
healthCheck: '/health',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'GO_ENV',
label: 'Go Environment',
defaultValue: 'production',
required: false,
secret: false,
description: 'Runtime environment value',
},
],
},
'tpl-postgres': {
template: demoTemplates[2],
config: {
type: 'database',
runtime: 'postgres',
buildCommand: '',
startCommand: '',
port: 5432,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'POSTGRES_USER',
label: 'Username',
defaultValue: 'postgres',
required: true,
secret: false,
description: 'Database user',
},
{
key: 'POSTGRES_PASSWORD',
label: 'Password',
defaultValue: '',
required: true,
secret: true,
description: 'Database password',
},
],
},
'tpl-mysql': {
template: demoTemplates[3],
config: {
type: 'database',
runtime: 'mysql',
buildCommand: '',
startCommand: '',
port: 3306,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'MYSQL_DATABASE',
label: 'Database Name',
defaultValue: 'app',
required: true,
secret: false,
description: 'Initial database to create',
},
{
key: 'MYSQL_USER',
label: 'Username',
defaultValue: 'app',
required: true,
secret: false,
description: 'Application DB user',
},
{
key: 'MYSQL_PASSWORD',
label: 'User Password',
defaultValue: '',
required: true,
secret: true,
description: 'Application DB password',
},
{
key: 'MYSQL_ROOT_PASSWORD',
label: 'Root Password',
defaultValue: '',
required: true,
secret: true,
description: 'Root account password',
},
],
},
'tpl-mariadb': {
template: demoTemplates[4],
config: {
type: 'database',
runtime: 'mariadb',
buildCommand: '',
startCommand: '',
port: 3306,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'MARIADB_DATABASE',
label: 'Database Name',
defaultValue: 'app',
required: true,
secret: false,
description: 'Initial database to create',
},
{
key: 'MARIADB_USER',
label: 'Username',
defaultValue: 'app',
required: true,
secret: false,
description: 'Application DB user',
},
{
key: 'MARIADB_PASSWORD',
label: 'User Password',
defaultValue: '',
required: true,
secret: true,
description: 'Application DB password',
},
{
key: 'MARIADB_ROOT_PASSWORD',
label: 'Root Password',
defaultValue: '',
required: true,
secret: true,
description: 'Root account password',
},
],
},
'tpl-clickhouse': {
template: demoTemplates[5],
config: {
type: 'database',
runtime: 'clickhouse',
buildCommand: '',
startCommand: '',
port: 8123,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'CLICKHOUSE_DB',
label: 'Database Name',
defaultValue: 'app',
required: false,
secret: false,
description: 'Default database name',
},
{
key: 'CLICKHOUSE_USER',
label: 'Username',
defaultValue: 'default',
required: false,
secret: false,
description: 'ClickHouse user',
},
{
key: 'CLICKHOUSE_PASSWORD',
label: 'Password',
defaultValue: '',
required: false,
secret: true,
description: 'ClickHouse password',
},
],
},
'tpl-dragonfly': {
template: demoTemplates[6],
config: {
type: 'database',
runtime: 'dragonfly',
buildCommand: '',
startCommand: '',
port: 6379,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'DRAGONFLY_PASSWORD',
label: 'Password',
defaultValue: '',
required: false,
secret: true,
description: 'Optional Redis-compatible password',
},
],
},
};
const demoProjects = [
{ id: 'project-demo', name: 'Demo Project' },
{ id: 'project-internal', name: 'Internal Tooling' },
];
function toServiceName(value: string): string { function toServiceName(value: string): string {
const normalized = value const normalized = value
.toLowerCase() .toLowerCase()
@@ -428,8 +108,8 @@ const EMPTY_TEMPLATE_JSON = JSON.stringify(
export function TemplatesPage() { export function TemplatesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const isDemoMode = searchParams.get('demo') === '1'; const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data); const signedIn = isDemoMode || Boolean(sessionQuery.data);
@@ -445,11 +125,23 @@ export function TemplatesPage() {
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [editorJson, setEditorJson] = useState(EMPTY_TEMPLATE_JSON); const [editorJson, setEditorJson] = useState(EMPTY_TEMPLATE_JSON);
const [editingTemplate, setEditingTemplate] = useState<TemplateEntity | null>(null); const [editingTemplate, setEditingTemplate] = useState<TemplateEntity | null>(null);
const [editorIsPublic, setEditorIsPublic] = useState(false);
const [editorError, setEditorError] = useState<string | null>(null); const [editorError, setEditorError] = useState<string | null>(null);
const [categoryFilter, setCategoryFilter] = useState(''); const [categoryFilter, setCategoryFilter] = useState('');
const [searchFilter, setSearchFilter] = useState(''); const [searchFilter, setSearchFilter] = useState('');
const [selectedTemplateIdState, setSelectedTemplateId] = useState<string | null>(null); // Selection lives in the URL (?template=<id>) so catalog deep links and
// card clicks share one source of truth.
const templateParam = searchParams.get('template');
const selectTemplate = (id: string | null) => {
const next = new URLSearchParams(searchParams);
if (id) {
next.set('template', id);
} else {
next.delete('template');
}
setSearchParams(next, { replace: true });
};
const [deployProjectIdState, setDeployProjectId] = useState(searchParams.get('project') ?? ''); const [deployProjectIdState, setDeployProjectId] = useState(searchParams.get('project') ?? '');
const [deployNameByTemplate, setDeployNameByTemplate] = useState<Record<string, string>>({}); const [deployNameByTemplate, setDeployNameByTemplate] = useState<Record<string, string>>({});
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState< const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
@@ -507,11 +199,11 @@ export function TemplatesPage() {
if (filteredTemplates.length === 0) { if (filteredTemplates.length === 0) {
return null; return null;
} }
if (selectedTemplateIdState && filteredTemplates.some((template) => template.id === selectedTemplateIdState)) { if (templateParam && filteredTemplates.some((template) => template.id === templateParam)) {
return selectedTemplateIdState; return templateParam;
} }
return filteredTemplates[0].id; return filteredTemplates[0].id;
}, [filteredTemplates, selectedTemplateIdState]); }, [filteredTemplates, templateParam]);
const templateDetailQuery = useQuery({ const templateDetailQuery = useQuery({
queryKey: ['template-detail-page', selectedTemplateId], queryKey: ['template-detail-page', selectedTemplateId],
@@ -637,7 +329,7 @@ export function TemplatesPage() {
setEditorOpen(false); setEditorOpen(false);
setEditingTemplate(null); setEditingTemplate(null);
setEditorError(null); setEditorError(null);
setSelectedTemplateId(template.id); selectTemplate(template.id);
}, },
onError: (error) => { onError: (error) => {
setEditorError(error instanceof Error ? error.message : 'Failed to save template'); setEditorError(error instanceof Error ? error.message : 'Failed to save template');
@@ -648,13 +340,14 @@ export function TemplatesPage() {
mutationFn: (id: string) => deleteTemplate(id), mutationFn: (id: string) => deleteTemplate(id),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['templates-page'] }); queryClient.invalidateQueries({ queryKey: ['templates-page'] });
setSelectedTemplateId(null); selectTemplate(null);
}, },
}); });
const openCreateEditor = () => { const openCreateEditor = () => {
setEditingTemplate(null); setEditingTemplate(null);
setEditorJson(EMPTY_TEMPLATE_JSON); setEditorJson(EMPTY_TEMPLATE_JSON);
setEditorIsPublic(false);
setEditorError(null); setEditorError(null);
setEditorOpen(true); setEditorOpen(true);
}; };
@@ -687,6 +380,7 @@ export function TemplatesPage() {
2, 2,
), ),
); );
setEditorIsPublic(template.isPublic);
setEditorError(null); setEditorError(null);
setEditorOpen(true); setEditorOpen(true);
}; };
@@ -711,6 +405,7 @@ export function TemplatesPage() {
logo: body.logo ?? '', logo: body.logo ?? '',
config: body.config as Record<string, unknown>, config: body.config as Record<string, unknown>,
variables: Array.isArray(body.variables) ? body.variables : [], variables: Array.isArray(body.variables) ? body.variables : [],
isPublic: editorIsPublic,
}); });
}; };
@@ -846,7 +541,7 @@ export function TemplatesPage() {
return ( return (
<button <button
key={template.id} key={template.id}
onClick={() => setSelectedTemplateId(template.id)} onClick={() => selectTemplate(template.id)}
className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${ className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${
selected selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]' ? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
@@ -880,6 +575,11 @@ export function TemplatesPage() {
Mine Mine
</span> </span>
) : null} ) : null}
{!template.isOfficial && template.isPublic ? (
<span className="px-1.5 py-0.5 rounded bg-[var(--info-soft)] text-[var(--info)] text-[10px] font-semibold">
Public
</span>
) : null}
</div> </div>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span <span
@@ -955,6 +655,11 @@ export function TemplatesPage() {
Official Official
</span> </span>
)} )}
{!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && (
<span className="px-2 py-0.5 rounded-full bg-[var(--info-soft)] text-[var(--info)] text-xs font-medium">
Public
</span>
)}
</div> </div>
<p className="text-sm text-[var(--text-secondary)] mt-1">{selectedDetail.template.description}</p> <p className="text-sm text-[var(--text-secondary)] mt-1">{selectedDetail.template.description}</p>
</div> </div>
@@ -1230,9 +935,15 @@ export function TemplatesPage() {
</div> </div>
) : null} ) : null}
<div className="flex items-center justify-between border-t border-[var(--border-subtle)] px-5 py-3.5"> <div className="flex items-center justify-between border-t border-[var(--border-subtle)] px-5 py-3.5">
<p className="text-[11px] text-[var(--text-tertiary)]"> <label className="inline-flex cursor-pointer items-center gap-2 text-[11px] text-[var(--text-secondary)]">
Saved as a user template — only you (and admins) can edit or delete it. <input
</p> 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"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,26 @@
import { useState } from 'react';
import { serviceIcon, serviceIconUrl, serviceTypeIcon, type ServiceIconInput } from './service-visuals';
export function ServiceIcon({
service,
size = 16,
}: {
service: ServiceIconInput;
size?: number;
}) {
const [failed, setFailed] = useState(false);
const brand = serviceIcon(service);
if (brand && !failed) {
return (
<img
src={serviceIconUrl(brand)}
width={size}
height={size}
alt=""
loading="lazy"
onError={() => setFailed(true)}
/>
);
}
return serviceTypeIcon(service.type ?? 'service', size);
}
@@ -0,0 +1,112 @@
import type { ServiceEntity } from '@/lib/api-client';
import type { ServiceVariable } from '../auto-connections';
import { inferAutoConnections } from '../auto-connections';
export const NODE_WIDTH = 240;
export const NODE_HEIGHT = 104;
// Multiples of the 26px canvas grid so laid-out nodes land on the lattice.
export const COL_GAP = 338;
export const ROW_GAP = 182;
type LinkEdge = { sourceServiceId: string; targetServiceId: string };
// Layered left-to-right layout: a service's depth is the longest chain of
// dependencies beneath it, so consumers sit left and datastores right.
// Unconnected services get their own trailing column separated by a gap.
export function computeLayeredLayout(
services: ServiceEntity[],
edges: LinkEdge[],
): Map<string, { x: number; y: number }> {
const positions = new Map<string, { x: number; y: number }>();
if (services.length === 0) {
return positions;
}
const ids = new Set(services.map((service) => service.id));
const outgoing = new Map<string, string[]>();
const connected = new Set<string>();
for (const edge of edges) {
if (!ids.has(edge.sourceServiceId) || !ids.has(edge.targetServiceId)) {
continue;
}
connected.add(edge.sourceServiceId);
connected.add(edge.targetServiceId);
const list = outgoing.get(edge.sourceServiceId) ?? [];
list.push(edge.targetServiceId);
outgoing.set(edge.sourceServiceId, list);
}
const depthMemo = new Map<string, number>();
const depthOf = (id: string, trail: Set<string>): number => {
const memoized = depthMemo.get(id);
if (memoized !== undefined) {
return memoized;
}
if (trail.has(id)) {
return 0; // cycle guard
}
trail.add(id);
let best = 0;
for (const target of outgoing.get(id) ?? []) {
best = Math.max(best, depthOf(target, trail) + 1);
}
trail.delete(id);
depthMemo.set(id, best);
return best;
};
const connectedServices = services
.filter((service) => connected.has(service.id))
.sort((a, b) => a.name.localeCompare(b.name));
const looseServices = services
.filter((service) => !connected.has(service.id))
.sort((a, b) => a.name.localeCompare(b.name));
const depthById = new Map(connectedServices.map((service) => [service.id, depthOf(service.id, new Set())]));
const maxDepth = Math.max(0, ...depthById.values());
// Column = maxDepth - depth so deeper consumers render further left.
const columns = new Map<number, ServiceEntity[]>();
for (const service of connectedServices) {
const col = maxDepth - (depthById.get(service.id) ?? 0);
const list = columns.get(col) ?? [];
list.push(service);
columns.set(col, list);
}
const connectedColCount = columns.size;
const maxRows = Math.max(
1,
...Array.from(columns.values()).map((list) => list.length),
looseServices.length,
);
const gridOffset = (value: number) => Math.round(value / 26) * 26;
for (const [col, list] of columns) {
const offsetY = gridOffset(((maxRows - list.length) * ROW_GAP) / 2);
list.forEach((service, row) => {
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
});
}
looseServices.forEach((service, row) => {
const col = connectedColCount > 0 ? connectedColCount : 0;
const offsetY = gridOffset(((maxRows - looseServices.length) * ROW_GAP) / 2);
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
});
return positions;
}
export function layoutFromVariables(
services: ServiceEntity[],
variablesByService: Record<string, ServiceVariable[]>,
): Map<string, { x: number; y: number }> {
const links = inferAutoConnections(services, variablesByService);
return computeLayeredLayout(
services,
links.map((link) => link.edge),
);
}
@@ -0,0 +1,415 @@
import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client';
import type { ServiceVariable } from '../auto-connections';
import { ServiceIcon } from './ServiceIcon';
import { serviceAccent } from './service-visuals';
import { formatRelative } from '@/lib/time';
import {
ArrowUpRight,
Copy,
Globe,
KeyRound,
Layers,
Link2,
Loader2,
Play,
Rocket,
Square,
Trash2,
X,
} from 'lucide-react';
import { useState } from 'react';
export type InspectorGroup = { id: string; title: string };
export type InspectorConnection = {
id: string;
outbound: boolean;
peer: string;
peerId: string;
reasons: string[];
};
export type InspectorActions = {
deploy: () => void;
start: () => void;
restart: () => void;
stop: () => void;
remove: () => void;
pending: boolean;
deployPending: boolean;
restartPending: boolean;
};
function statusLabel(status: string): string {
switch (status) {
case 'running':
return 'Online';
case 'deployed':
return 'Deployed';
case 'building':
return 'Building';
case 'deploying':
return 'Deploying';
case 'pending':
return 'Pending';
case 'failed':
return 'Failed';
case 'stopped':
return 'Stopped';
case 'rolling_back':
return 'Rolling back';
default:
return status;
}
}
function statusColor(status: string): string {
switch (status) {
case 'running':
case 'deployed':
return 'var(--success)';
case 'failed':
return 'var(--error)';
case 'building':
case 'deploying':
case 'pending':
case 'rolling_back':
return 'var(--warning)';
default:
return 'var(--text-tertiary)';
}
}
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{title}</p>
{action}
</div>
{children}
</div>
);
}
function SectionLink({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className="text-[10px] text-[var(--accent-primary)] hover:underline flex items-center gap-0.5"
>
{children}
<ArrowUpRight size={9} />
</button>
);
}
export function ServiceInspector({
service,
variables,
connections,
groups,
groupId,
readOnly,
actions,
onClose,
onOpenService,
onOpenSection,
onSelectPeer,
onAssignGroup,
onNewGroupFor,
}: {
service: ServiceEntity;
variables: ServiceVariable[];
connections: InspectorConnection[];
groups: InspectorGroup[];
groupId: string | undefined;
readOnly?: boolean;
actions: InspectorActions;
onClose: () => void;
onOpenService: (serviceId: string) => void;
onOpenSection: (serviceId: string, section: string) => void;
onSelectPeer: (serviceId: string) => void;
onAssignGroup: (serviceId: string, groupId: string | null) => void;
onNewGroupFor: (serviceId: string) => void;
}) {
const accent = serviceAccent(service);
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
const [copied, setCopied] = useState(false);
const copyAddress = () => {
const text = publicUrl ?? (service.port ? `${service.name}:${service.port}` : service.name);
void navigator.clipboard?.writeText(text).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
});
};
const configRows: [string, string | undefined][] = [
['Image', service.image],
['Command', service.command],
['Port', service.port ? String(service.port) : undefined],
['Domain', service.domain],
['Git repo', service.gitRepo],
['Branch', service.gitBranch],
['Build path', service.buildPath],
['CPU', service.cpu],
['Memory', service.memory],
['Restart', service.restartPolicy],
['Healthcheck', service.healthcheckPath],
];
const visibleConfig = configRows.filter(([, value]) => value);
return (
<aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[312px] panel-glass flex flex-col overflow-hidden">
<div className="flex items-center gap-3 px-4 pt-4 pb-3 border-b border-[var(--border-subtle)]">
<div
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0"
style={{ background: `${accent}1c`, color: accent }}
>
<ServiceIcon service={service} size={16} />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{service.name}</h3>
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span style={{ color: statusColor(service.status) }}>{statusLabel(service.status)}</span>
<span>· {service.type}</span>
{service.environment && service.environment !== 'production' && <span>· {service.environment}</span>}
</div>
</div>
<button
type="button"
onClick={onClose}
className="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]"
title="Close (Esc)"
>
<X size={14} />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{(publicUrl || service.port) && (
<Section title="Address">
<div className="flex items-center gap-1.5 group">
<Globe size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
{publicUrl ? (
<a
href={publicUrl}
target="_blank"
rel="noopener noreferrer"
className="mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
>
{publicUrl.replace(/^https?:\/\//, '')}
</a>
) : (
<span className="mono text-[11px] text-[var(--text-secondary)]">
{service.name}:{service.port}
</span>
)}
<button
type="button"
onClick={copyAddress}
className="ml-auto text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
title="Copy address"
>
{copied ? <span className="text-[10px] text-[var(--success)]">Copied</span> : <Copy size={11} />}
</button>
</div>
{publicUrl && service.port ? (
<p className="mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5 pl-4">
{service.name}:{service.port}
</p>
) : null}
</Section>
)}
<Section title="Group">
<div className="flex items-center gap-2">
<Layers size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
<select
value={groupId ?? ''}
onChange={(event) => {
const next = event.target.value;
if (next === '__new__') {
onNewGroupFor(service.id);
return;
}
onAssignGroup(service.id, next === '' ? null : next);
}}
className="nodrag flex-1 h-7 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-[11px] text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]"
>
<option value="">Ungrouped</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.title}
</option>
))}
<option value="__new__">+ New group…</option>
</select>
</div>
</Section>
<Section
title={`Variables (${variables.length})`}
action={
<SectionLink onClick={() => onOpenSection(service.id, 'variables')}>
Manage
</SectionLink>
}
>
{variables.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]">No variables yet.</p>
) : (
<div className="space-y-1">
{variables.slice(0, 6).map((variable) => (
<div
key={variable.key}
className="flex items-center gap-2 px-2 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/50"
>
<KeyRound size={9} className="text-[var(--text-tertiary)] flex-shrink-0" />
<span className="mono text-[10px] text-[var(--text-primary)] truncate font-medium">
{variable.key}
</span>
<span className="mono text-[10px] text-[var(--text-tertiary)] truncate ml-auto max-w-[130px]" title={variable.isSecret ? 'Secret' : variable.value}>
{variable.isSecret ? '••••••••' : variable.value}
</span>
</div>
))}
{variables.length > 6 && (
<p className="text-[10px] text-[var(--text-muted)] pl-1">+{variables.length - 6} more</p>
)}
</div>
)}
</Section>
<Section
title="Config"
action={
<SectionLink onClick={() => onOpenSection(service.id, 'config')}>
Edit
</SectionLink>
}
>
{visibleConfig.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]">No configuration set.</p>
) : (
<dl className="space-y-1">
{visibleConfig.map(([label, value]) => (
<div key={label} className="flex items-baseline gap-2">
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">{label}</dt>
<dd className="mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}>
{value}
</dd>
</div>
))}
{(service.replicas ?? 0) > 0 && (
<div className="flex items-baseline gap-2">
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">Replicas</dt>
<dd className="mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd>
</div>
)}
</dl>
)}
</Section>
<Section title={`Connections (${connections.length})`}>
{connections.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]">
No inferred connections. Reference another service via{' '}
<span className="mono">{'{{variable}}'}</span> placeholders in env vars.
</p>
) : (
<div className="space-y-1.5">
{connections.map((conn) => (
<button
key={conn.id}
type="button"
onClick={() => onSelectPeer(conn.peerId)}
className="w-full text-left px-2.5 py-2 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 hover:bg-[var(--surface-muted)] transition-colors"
>
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
<Link2 size={10} className="text-[var(--accent-primary)] flex-shrink-0" />
<span className="truncate font-medium">{conn.outbound ? `→ ${conn.peer}` : `← ${conn.peer}`}</span>
</div>
<p className="mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4">
{conn.reasons.join(' · ')}
</p>
</button>
))}
</div>
)}
</Section>
{service.updatedAt && (
<p className="text-[10px] text-[var(--text-muted)] pt-1">
Updated {formatRelative(service.updatedAt)}
</p>
)}
</div>
<div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2">
<button
type="button"
onClick={() => onOpenService(service.id)}
className="w-full flex items-center justify-center gap-2 h-9 rounded-[var(--radius-md)] text-[var(--accent-on)] text-[13px] font-medium"
style={{ background: 'var(--accent-primary)' }}
>
<ArrowUpRight size={14} />
Open service
</button>
<div className="grid grid-cols-3 gap-1.5">
<button
type="button"
disabled={actions.pending || readOnly}
onClick={actions.deploy}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
{actions.deployPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
Deploy
</button>
{service.status !== 'running' ? (
<button
type="button"
disabled={actions.pending || readOnly}
onClick={actions.start}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
<Play size={11} />
Start
</button>
) : (
<button
type="button"
disabled={actions.pending || readOnly}
onClick={actions.restart}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
{actions.restartPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
Restart
</button>
)}
<button
type="button"
disabled={actions.pending || readOnly || service.status !== 'running'}
onClick={actions.stop}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
<Square size={11} />
Stop
</button>
</div>
<button
type="button"
disabled={actions.pending || readOnly}
onClick={actions.remove}
className="w-full flex items-center justify-center gap-1.5 h-8 rounded-[var(--radius-sm)] text-[11px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-40"
>
<Trash2 size={11} />
Delete service
</button>
</div>
</aside>
);
}
@@ -1,8 +1,10 @@
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'; import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
import type { ServiceEntity } from '@/lib/api-client'; import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client'; import { serviceStatusClass } from '@/lib/api-client';
import { Box, Globe, Database, Terminal, ExternalLink, Copy, Layers } from 'lucide-react'; import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { serviceIcon, serviceAccent } from './service-visuals';
import { ServiceIcon } from './ServiceIcon';
export type ServiceNodeData = { export type ServiceNodeData = {
service: ServiceEntity; service: ServiceEntity;
@@ -12,37 +14,13 @@ export type ServiceNodeData = {
export type GroupNodeData = { export type GroupNodeData = {
title: string; title: string;
onRename?: (groupId: string, title: string) => void;
renameNonce?: number;
}; };
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>; export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>; export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
function 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 { function statusLabel(status: string): string {
switch (status) { switch (status) {
case 'running': 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>) { export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
const { service, selected } = data; 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 replicas = service.replicas ?? 0;
const stateColor = statusColor(service.status); const stateColor = statusColor(service.status);
const isTransient = TRANSIENT_STATUSES.has(service.status);
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl; const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
const internalAddr = service.port ? `${service.name}:${service.port}` : service.name; const internalAddr = service.port ? `${service.name}:${service.port}` : service.name;
return ( return (
<div <div
className={`rounded-[var(--radius-lg)] border transition-all duration-200 group relative overflow-hidden ${ className={`w-full rounded-[var(--radius-lg)] border transition-[border-color,box-shadow,transform] duration-150 group relative overflow-hidden ${
selected selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]' ? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-lg' : 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-[0_12px_32px_rgba(0,0,0,0.45)] hover:-translate-y-px'
}`} }`}
style={{ minWidth: 220, maxWidth: 280 }}
> >
<div <div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
style={{ background: `${typeColor}10` }} style={{ background: `${typeColor}08` }}
/> />
<Handle id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" /> <Handle id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
@@ -105,22 +86,26 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
style={{ background: stateColor }} style={{ background: stateColor }}
/> />
<div className="relative p-4"> <div className="relative px-3.5 pt-3.5 pb-3">
<div className="flex items-start gap-3 mb-3"> <div className="flex items-center gap-3 mb-2.5">
<div <div
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${ className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
selected ? 'ring-2 ring-white/20' : ''
}`}
style={{ style={{
background: selected ? typeColor : `${typeColor}20`, background: selected && !brand ? typeColor : `${typeColor}1c`,
color: selected ? 'white' : typeColor, 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>
<div className="min-w-0 flex-1 pt-0.5"> <div className="min-w-0 flex-1">
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4> <h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5"> {service.name}
</h4>
<p className="text-[10.5px] text-[var(--text-tertiary)] truncate mt-0.5">
{service.type} {service.type}
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''} {service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
</p> </p>
@@ -133,7 +118,7 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
)} )}
</div> </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" /> <ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
{publicUrl ? ( {publicUrl ? (
<a <a
@@ -141,12 +126,12 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} 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?:\/\//, '')} {publicUrl.replace(/^https?:\/\//, '')}
</a> </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} {internalAddr}
</span> </span>
)} )}
@@ -164,14 +149,14 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
</div> </div>
<div className="flex items-center justify-between"> <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={`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)} {statusLabel(service.status)}
</span> </span>
</div> </div>
{service.image && ( {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} {service.image}
</span> </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 [isHovered, setIsHovered] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(data.title);
// Context-menu rename requests bump the nonce; enter edit mode when it
// changes. Render-phase state adjustment — the React-sanctioned pattern.
const [seenRenameNonce, setSeenRenameNonce] = useState(data.renameNonce ?? 0);
const incomingNonce = data.renameNonce ?? 0;
if (incomingNonce > seenRenameNonce) {
setSeenRenameNonce(incomingNonce);
setDraft(data.title);
setEditing(true);
}
const commit = () => {
const next = draft.trim() || data.title;
setEditing(false);
if (next !== data.title) {
data.onRename?.(id, next);
}
};
return ( return (
<div <div
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden" className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative"
style={{ style={{
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)', borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)',
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30', background: isHovered ? 'var(--accent-primary-soft)' : 'rgba(255,255,255,0.02)',
}} }}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<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="relative p-4">
<div className="flex items-center justify-between"> <div className="flex items-center gap-2.5">
<div className="flex items-center gap-2.5"> <div
<div className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200" style={{
style={{ background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)', color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)', }}
}} >
> <Box size={14} />
<Box size={14} />
</div>
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3>
</div> </div>
{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> </div>
<p <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, zoom: 1,
}; };
export function createDefaultCanvasMetadata(services: ServiceEntity[]): ProjectCanvasMetadata { export function createDefaultCanvasMetadata(
services: ServiceEntity[],
layout?: Map<string, CanvasPoint>,
): ProjectCanvasMetadata {
const nodes = services.map((service, index) => { const nodes = services.map((service, index) => {
const col = index % 3; const col = index % 3;
const row = Math.floor(index / 3); const row = Math.floor(index / 3);
return { return {
serviceId: service.id, serviceId: service.id,
position: { position: layout?.get(service.id) ?? {
x: 70 + col * 260, x: 70 + col * 260,
y: 80 + row * 170, y: 80 + row * 170,
}, },
@@ -19,6 +19,7 @@ import {
type CreateServiceInput, type CreateServiceInput,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { getDemoProjectById, getDemoServicesByProject, getDemoVariablesByProject } from '@/lib/demo-data'; import { getDemoProjectById, getDemoServicesByProject, getDemoVariablesByProject } from '@/lib/demo-data';
import { useDemoMode } from '@/lib/demo-mode';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { formatDate, formatRelative } from '@/lib/time'; import { formatDate, formatRelative } from '@/lib/time';
import type { ServiceVariable } from '../auto-connections'; import type { ServiceVariable } from '../auto-connections';
@@ -466,7 +467,7 @@ export function ProjectWorkspacePage() {
const toast = useToast(); const toast = useToast();
const activeView = (searchParams.get('view') as WorkspaceView | null) ?? 'canvas'; const activeView = (searchParams.get('view') as WorkspaceView | null) ?? 'canvas';
const isDemoMode = searchParams.get('demo') === '1'; const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data); const signedIn = isDemoMode || Boolean(sessionQuery.data);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
@@ -620,6 +621,8 @@ export function ProjectWorkspacePage() {
isDemoMode isDemoMode
? `/projects/${projectId}/services/${serviceId}?demo=1` ? `/projects/${projectId}/services/${serviceId}?demo=1`
: `/projects/${projectId}/services/${serviceId}`; : `/projects/${projectId}/services/${serviceId}`;
const serviceSectionHref = (serviceId: string, section: string) =>
`${serviceHref(serviceId)}${isDemoMode ? '&' : '?'}section=${section}`;
if (!isDemoMode && projectQuery.isLoading) { if (!isDemoMode && projectQuery.isLoading) {
return ( return (
@@ -644,7 +647,7 @@ export function ProjectWorkspacePage() {
This project may have been deleted or you don't have access. This project may have been deleted or you don't have access.
</p> </p>
<button <button
onClick={() => navigate('/projects')} onClick={() => navigate(isDemoMode ? '/projects?demo=1' : '/projects')}
className="mt-6 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors" className="mt-6 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
> >
Back to Projects Back to Projects
@@ -654,131 +657,116 @@ export function ProjectWorkspacePage() {
); );
} }
const canvasView = activeView === 'canvas';
const projectsHref = isDemoMode ? '/projects?demo=1' : '/projects';
return ( return (
<div className="min-h-screen"> <div className="flex h-dvh min-h-0 flex-col bg-[var(--bg-void)]">
{/* Header */} {/* Slim workspace bar — tabs + project meta, canvas fills everything below */}
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm"> <div className="flex h-12 shrink-0 items-center gap-3 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 px-4 backdrop-blur-sm">
<div className="w-full px-8 py-4"> <button
<div className="flex items-center justify-between gap-4"> onClick={() => navigate(projectsHref)}
<div className="flex items-center gap-4"> className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors shrink-0"
>
<ArrowLeft size={14} />
<span className="hidden sm:inline">Projects</span>
</button>
<div className="w-px h-4 bg-[var(--border-subtle)] shrink-0" />
<h1 className="v-title text-sm shrink-0 min-w-0 truncate">{project.name}<span className="v-cursor">_</span></h1>
<span className="hidden lg:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)] shrink-0">
{services.length} service{services.length !== 1 ? 's' : ''} · {runningServices} running
</span>
{/* View tabs */}
<nav className="flex items-center gap-0.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-0.5 mx-2 overflow-x-auto min-w-0">
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
const active = activeView === item.key;
const Icon = item.icon;
return (
<button <button
onClick={() => navigate('/projects')} key={item.key}
className="flex items-center gap-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors" onClick={() => setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('view', item.key);
return next;
})}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap ${
active
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
}`}
> >
<ArrowLeft size={16} /> <Icon size={13} />
<span>Projects</span> <span className="hidden md:inline">{item.label}</span>
</button> </button>
<div className="w-px h-5 bg-[var(--border-subtle)]" /> );
<div> })}
<h1 className="v-title">{project.name}<span className="v-cursor">_</span></h1> </nav>
<p className="text-sm text-[var(--text-secondary)]">{project.description || 'No description'}</p>
</div>
</div>
<div className="flex items-center gap-6"> <div className="ml-auto flex items-center gap-2 shrink-0">
{/* Stats */} <div className="hidden xl:block">
<div className="hidden md:flex items-center gap-6"> <LiveIndicator isLive={runningServices > 0} />
<div className="text-center">
<p className="text-2xl font-semibold text-[var(--text-primary)]">{services.length}</p>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Services</p>
</div>
<div className="text-center">
<p className="text-2xl font-semibold text-[var(--success)]">{runningServices}</p>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Running</p>
</div>
<div className="w-px h-8 bg-[var(--border-subtle)]" />
<LiveIndicator isLive={runningServices > 0} />
<div className="w-px h-8 bg-[var(--border-subtle)]" />
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
<Clock size={12} />
<span>{formatRelative(project.updatedAt)}</span>
</div>
</div>
{/* Search / Command Palette */}
<button
onClick={() => setCommandPaletteOpen(true)}
className="hidden md:flex items-center gap-2 h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-sm hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
>
<Search size={14} />
<span>Search...</span>
<kbd className="ml-2 px-1.5 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
</button>
{/* Add Service Button */}
{!isDemoMode && (
<button
onClick={openAddService}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg hover:shadow-xl transition-all"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={16} />
<span className="hidden sm:inline">Add Service</span>
</button>
)}
</div>
</div> </div>
<span className="hidden xl:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
<Clock size={11} />
{formatRelative(project.updatedAt)}
</span>
<button
onClick={() => setCommandPaletteOpen(true)}
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-xs hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
>
<Search size={12} />
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
</button>
{!isDemoMode && signedIn && (
<button
onClick={openAddService}
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={14} />
<span className="hidden sm:inline">Add Service</span>
</button>
)}
</div> </div>
</div> </div>
{/* Demo Mode Banner */} {/* Content */}
{isDemoMode && ( {canvasView ? (
<div className="w-full px-8 py-4"> <div className="relative flex-1 min-h-0">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50"> <ProjectCanvas
<div className="flex items-center gap-2 text-sm text-[var(--warning)]"> projectId={project.id}
<Sparkles size={16} /> services={services}
<span>Demo mode active — using sample data for preview</span> variablesByService={variablesByService}
onAddService={openAddService}
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
onOpenSection={(serviceId, section) => navigate(serviceSectionHref(serviceId, section))}
readOnly={isDemoMode || !signedIn}
/>
{isDemoMode && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 pointer-events-none">
<span className="canvas-pill flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium text-[var(--warning)]">
<Sparkles size={12} />
Demo data
</span>
</div>
)}
</div>
) : (
<div className="flex-1 min-h-0 overflow-y-auto">
{isDemoMode && (
<div className="w-full px-8 pt-6">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active - using sample data for preview</span>
</div>
</div> </div>
</div> </div>
</div> )}
)} <div className="w-full px-8 py-6">
{/* Main Content */}
<div className="w-full px-8 py-6">
<div className="grid grid-cols-1 lg:grid-cols-[200px_1fr] gap-6">
{/* Sidebar Navigation */}
<aside className="lg:sticky lg:top-6 lg:h-fit">
<nav className="flex lg:flex-col gap-1 overflow-x-auto lg:overflow-visible pb-2 lg:pb-0">
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
const active = activeView === item.key;
const Icon = item.icon;
return (
<button
key={item.key}
onClick={() => setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('view', item.key);
return next;
})}
className={`flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-md)] transition-all whitespace-nowrap ${
active
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
}`}
>
<Icon size={18} />
<span className="text-sm font-medium">{item.label}</span>
{active && (
<div className="hidden lg:block absolute left-0 w-0.5 h-5 bg-[var(--accent-primary)] rounded-full" />
)}
</button>
);
})}
</nav>
</aside>
{/* Content Area */}
<section className="min-w-0"> <section className="min-w-0">
{activeView === 'canvas' && (
<ProjectCanvas
projectId={project.id}
services={services}
variablesByService={variablesByService}
onAddService={openAddService}
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
/>
)}
{activeView === 'observability' && ( {activeView === 'observability' && (
<div className="space-y-6"> <div className="space-y-6">
{/* Health Summary */} {/* Health Summary */}
@@ -965,6 +953,7 @@ export function ProjectWorkspacePage() {
</section> </section>
</div> </div>
</div> </div>
)}
{/* Create Service Dialog */} {/* Create Service Dialog */}
{!isDemoMode && ( {!isDemoMode && (
+19 -9
View File
@@ -171,9 +171,13 @@ export function saveCanvasMetadata(projectId: string, metadata: ProjectCanvasMet
canvasStorage()?.setItem(canvasStorageKey(projectId), JSON.stringify(metadata)); canvasStorage()?.setItem(canvasStorageKey(projectId), JSON.stringify(metadata));
} }
export function loadCanvasMetadata(projectId: string, services: ServiceEntity[]): ProjectCanvasMetadata { export function loadCanvasMetadata(
projectId: string,
services: ServiceEntity[],
layout?: Map<string, { x: number; y: number }>,
): ProjectCanvasMetadata {
const key = canvasStorageKey(projectId); const key = canvasStorageKey(projectId);
const fallback = createDefaultCanvasMetadata(services); const fallback = createDefaultCanvasMetadata(services, layout);
const serviceIds = new Set(services.map((service) => service.id)); const serviceIds = new Set(services.map((service) => service.id));
const raw = canvasStorage()?.getItem(key); const raw = canvasStorage()?.getItem(key);
@@ -195,7 +199,14 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[])
const nodeMap = new Map(parsed.nodes.map((node) => [node.serviceId, node])); const nodeMap = new Map(parsed.nodes.map((node) => [node.serviceId, node]));
const normalizedNodes: CanvasNodeLayout[] = []; const normalizedNodes: CanvasNodeLayout[] = [];
for (const [index, service] of services.entries()) { // Services added after the layout was saved park in a trailing column to
// the right of everything placed so far, instead of overlapping nodes the
// user already arranged.
const placedXs = parsed.nodes.map((node) => node.position.x);
let parkedRow = 0;
const parkedX = placedXs.length > 0 ? Math.max(...placedXs) + 320 : 70;
for (const service of services) {
const existing = nodeMap.get(service.id); const existing = nodeMap.get(service.id);
if (existing) { if (existing) {
normalizedNodes.push({ normalizedNodes.push({
@@ -206,15 +217,14 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[])
continue; continue;
} }
const col = index % 3; const parked = layout?.get(service.id);
const row = Math.floor(index / 3);
normalizedNodes.push({ normalizedNodes.push({
serviceId: service.id, serviceId: service.id,
position: { position: parked ?? { x: parkedX, y: 80 + parkedRow * 160 },
x: 70 + col * 260,
y: 80 + row * 170,
},
}); });
if (!parked) {
parkedRow += 1;
}
} }
const seenEdgeIds = new Set<string>(); const seenEdgeIds = new Set<string>();
+196
View File
@@ -231,6 +231,153 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/user/tokens": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List personal access tokens
* @description Returns the caller's non-revoked personal access token metadata. Token values and hashes are never returned.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Token list */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
tokens?: components["schemas"]["UserToken"][];
};
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
put?: never;
/**
* Create personal access token
* @description Issues a `cnp_` bearer token for CLI/MCP/agent authentication. The raw token is returned exactly once and never stored — only its SHA-256 hash persists. Admin-scope tokens require a platform admin account.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateUserTokenRequest"];
};
};
responses: {
/** @description Token created; raw value returned once */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserTokenCreated"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Admin scope requires platform admin */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/user/tokens/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/**
* Revoke personal access token
* @description Marks one of the caller's tokens as revoked. Clients presenting it are rejected from then on.
*/
delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Token revoked */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
message?: string;
};
};
};
/** @description Token not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects": { "/projects": {
parameters: { parameters: {
query?: never; query?: never;
@@ -637,7 +784,15 @@ export interface paths {
desired?: number; desired?: number;
/** @enum {string} */ /** @enum {string} */
status?: "running" | "degraded" | "stopped"; status?: "running" | "degraded" | "stopped";
/** @description Reachable service URLs; rewritten to the request Host for remote clients. Null/empty when nothing is published. */
urls?: string[]; urls?: string[];
/** @description Host ports actually bound for replica 0+; persisted on the service so restarts reuse the same public port. Null/empty when nothing is published. */
ports?: number[];
/**
* @description Result of the backend's HTTP probe of the service on its project network. Omitted when the service declares no healthcheck path.
* @enum {string}
*/
health?: "healthy" | "unhealthy" | "unreachable";
containers?: { containers?: {
id?: string; id?: string;
name?: string; name?: string;
@@ -7806,6 +7961,8 @@ export interface components {
is_official?: boolean; is_official?: boolean;
/** @description Owning user ID for user-created templates; null for official presets */ /** @description Owning user ID for user-created templates; null for official presets */
owner_id?: string | null; owner_id?: string | null;
/** @description Whether a user-created template is published to the public catalog */
is_public?: boolean;
/** /**
* Format: date-time * Format: date-time
* @description Creation timestamp * @description Creation timestamp
@@ -7830,6 +7987,8 @@ export interface components {
config: Record<string, never>; config: Record<string, never>;
/** @description Configurable variables exposed at deploy time */ /** @description Configurable variables exposed at deploy time */
variables?: components["schemas"]["TemplateVariable"][]; variables?: components["schemas"]["TemplateVariable"][];
/** @description Publish this template to the public catalog (visible anonymously) */
is_public?: boolean;
}; };
TemplateConfig: { TemplateConfig: {
/** @description Service type for created service */ /** @description Service type for created service */
@@ -8295,7 +8454,13 @@ export interface components {
images?: number; images?: number;
driver?: string; driver?: string;
server?: 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 */ /** Format: date-time */
collected_at?: string; collected_at?: string;
}; };
@@ -8458,6 +8623,37 @@ export interface components {
/** @description Human-readable label for the token (e.g. node name) */ /** @description Human-readable label for the token (e.g. node name) */
label?: string; label?: string;
}; };
UserToken: {
/** Format: uuid */
id: string;
name: string;
/** @description First 12 chars of the token, for identification in lists */
key_prefix: string;
/** @enum {string} */
scope: "read" | "write" | "admin";
/** Format: date-time */
expires_at?: string;
/** Format: date-time */
last_used_at?: string;
/** Format: date-time */
created_at: string;
};
UserTokenCreated: components["schemas"]["UserToken"] & {
/** @description Raw cnp_ token value, returned exactly once at creation */
token: string;
};
CreateUserTokenRequest: {
/** @description Human-readable label (e.g. "laptop cli", "ci deploy") */
name: string;
/**
* @description read = GET/HEAD only; write = all non-admin routes; admin = everything (requires admin account)
* @default write
* @enum {string}
*/
scope: "read" | "write" | "admin";
/** @description 0 or omitted = never expires */
expires_in_days?: number;
};
AgentCapabilities: { AgentCapabilities: {
container_runtimes?: string[]; container_runtimes?: string[];
supported_architectures?: string[]; supported_architectures?: string[];
+1 -1
View File
@@ -52,7 +52,7 @@ export const bundledDocs: BundledDoc[] = [
"path": "guides/OPERATIONS.md", "path": "guides/OPERATIONS.md",
"section": "guides", "section": "guides",
"title": "Operations", "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" "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\n## Personal access tokens\n\n**Settings → Personal Access Tokens**: issue `cnp_…` bearer tokens for the\nCLI, MCP server, and scripts — the same privileges as your account, bounded\nby scope. The raw token is shown once at creation; only its SHA-256 hash is\nstored.\n\nScopes:\n\n- `read` — `GET`/`HEAD`/`OPTIONS` only; mutations return `403 SCOPE_INSUFFICIENT`.\n- `write` — everything except admin routes.\n- `admin` — full access; requires a platform admin account, and the token is\n still capped at the owner's privileges.\n\nA token can never outrank its owner: non-admin scopes report\n`is_admin = false` even on admin accounts.\n\n```\nGET /api/v1/user/tokens list (metadata only, never hashes)\nPOST /api/v1/user/tokens {name, scope?, expires_in_days?} → raw token once\nDELETE /api/v1/user/tokens/:id revoke (owner-scoped)\n```\n\nUse as `Authorization: Bearer cnp_…`, or `containr auth login cnp_…`.\nRevocation takes effect on the next request; `last_used_at` updates on each\nauthenticated call.\n"
}, },
{ {
"path": "guides/README_BACKEND.md", "path": "guides/README_BACKEND.md",
+88 -3
View File
@@ -634,13 +634,13 @@ textarea {
.edge-premium .react-flow__edge-path { .edge-premium .react-flow__edge-path {
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: 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; transition: filter 0.2s ease, stroke-width 0.2s ease;
} }
.edge-premium:hover .react-flow__edge-path { .edge-premium:hover .react-flow__edge-path {
filter: drop-shadow(0 0 8px rgba(156, 126, 240, 0.5)); filter: drop-shadow(0 0 8px rgba(180, 227, 74, 0.45));
stroke-width: 3px; stroke-width: 2.6px;
} }
/* ============================================ /* ============================================
@@ -1523,6 +1523,7 @@ textarea {
background: var(--accent-primary); color: var(--accent-on); background: var(--accent-primary); color: var(--accent-on);
border: none; border-radius: 8px; padding: 9px 17px; border: none; border-radius: 8px; padding: 9px 17px;
font: 700 12px 'JetBrains Mono', monospace; cursor: pointer; font: 700 12px 'JetBrains Mono', monospace; cursor: pointer;
white-space: nowrap; flex-shrink: 0;
transition: background 0.15s, transform 0.15s; transition: background 0.15s, transform 0.15s;
box-shadow: 0 2px 12px rgba(180, 227, 74, 0.22); box-shadow: 0 2px 12px rgba(180, 227, 74, 0.22);
} }
@@ -1534,6 +1535,7 @@ textarea {
background: var(--surface-card); color: var(--text-secondary); background: var(--surface-card); color: var(--text-secondary);
border: 1px solid var(--border-subtle); border-radius: 8px; padding: 8px 16px; border: 1px solid var(--border-subtle); border-radius: 8px; padding: 8px 16px;
font: 600 12px 'JetBrains Mono', monospace; cursor: pointer; transition: 0.15s; font: 600 12px 'JetBrains Mono', monospace; cursor: pointer; transition: 0.15s;
white-space: nowrap; flex-shrink: 0;
} }
.v-btn-ghost:hover { color: var(--text-primary); border-color: var(--border-default); } .v-btn-ghost:hover { color: var(--text-primary); border-color: var(--border-default); }
@@ -1685,3 +1687,86 @@ textarea {
margin: 24px 0; margin: 24px 0;
} }
.docs-article img { max-width: 100%; border-radius: var(--radius-md); } .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;
}
}
+49 -10
View File
@@ -68,6 +68,8 @@ export type ServiceRuntime = {
desired: number; desired: number;
status: 'running' | 'degraded' | 'stopped'; status: 'running' | 'degraded' | 'stopped';
urls: string[]; urls: string[];
ports: number[];
health?: 'healthy' | 'unhealthy' | 'unreachable' | '';
containers: RuntimeContainer[]; containers: RuntimeContainer[];
}; };
@@ -124,6 +126,7 @@ export type TemplateEntity = {
variablesRaw: string; variablesRaw: string;
isOfficial: boolean; isOfficial: boolean;
ownerId: string | null; ownerId: string | null;
isPublic: boolean;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
}; };
@@ -222,7 +225,9 @@ export type HostMonitoring = {
images?: number; images?: number;
driver?: string; driver?: string;
server?: string; server?: string;
os?: string;
}; };
scope: 'host' | 'docker-desktop-vm';
collectedAt: string; collectedAt: string;
}; };
@@ -424,7 +429,10 @@ async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
throw new ApiError(message, response.status); 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> { async function requestText(path: string, init?: RequestInit): Promise<string> {
@@ -615,6 +623,7 @@ function normalizeTemplate(template: RawServiceTemplate): TemplateEntity | null
variablesRaw: template.variables ?? '', variablesRaw: template.variables ?? '',
isOfficial: Boolean(template.is_official), isOfficial: Boolean(template.is_official),
ownerId: template.owner_id ?? null, ownerId: template.owner_id ?? null,
isPublic: Boolean(template.is_public),
createdAt: template.created_at, createdAt: template.created_at,
updatedAt: template.updated_at, updatedAt: template.updated_at,
}; };
@@ -881,6 +890,7 @@ function normalizeHostMonitoring(payload: RawHostMonitoring): HostMonitoring {
uptimeSeconds: payload.uptime_seconds ?? 0, uptimeSeconds: payload.uptime_seconds ?? 0,
dockerAvailable: Boolean(payload.docker_available), dockerAvailable: Boolean(payload.docker_available),
docker: payload.docker, docker: payload.docker,
scope: payload.scope === 'docker-desktop-vm' ? 'docker-desktop-vm' : 'host',
collectedAt: payload.collected_at ?? '', collectedAt: payload.collected_at ?? '',
}; };
} }
@@ -983,6 +993,34 @@ export async function revokeAgentToken(id: string): Promise<void> {
await requestJson(`/agent-tokens/${id}`, { method: 'DELETE' }); await requestJson(`/agent-tokens/${id}`, { method: 'DELETE' });
} }
export type UserToken = components['schemas']['UserToken'];
export type UserTokenCreated = components['schemas']['UserTokenCreated'];
export type UserTokenScope = 'read' | 'write' | 'admin';
export async function createUserToken(input: {
name: string;
scope?: UserTokenScope;
expiresInDays?: number;
}): Promise<UserTokenCreated> {
return requestJson<UserTokenCreated>(`/user/tokens`, {
method: 'POST',
body: JSON.stringify({
name: input.name,
scope: input.scope ?? 'write',
expires_in_days: input.expiresInDays ?? 0,
}),
});
}
export async function listUserTokens(): Promise<UserToken[]> {
const payload = await requestJson<{ tokens?: UserToken[] }>(`/user/tokens`);
return payload.tokens ?? [];
}
export async function revokeUserToken(id: string): Promise<void> {
await requestJson(`/user/tokens/${id}`, { method: 'DELETE' });
}
export async function createProject(input: CreateProjectInput): Promise<ProjectEntity> { export async function createProject(input: CreateProjectInput): Promise<ProjectEntity> {
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects`, { const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects`, {
method: 'POST', method: 'POST',
@@ -1075,16 +1113,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> { export async function getServiceRuntime(serviceId: string): Promise<ServiceRuntime> {
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/runtime`); const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/runtime`);
return ( return normalizeRuntime(payload.runtime);
payload.runtime ?? {
desired: 0,
status: 'stopped',
urls: [],
containers: [],
}
);
} }
async function serviceAction(serviceId: string, action: 'start' | 'stop' | 'restart'): Promise<void> { async function serviceAction(serviceId: string, action: 'start' | 'stop' | 'restart'): Promise<void> {
@@ -1099,7 +1136,7 @@ export async function redeployService(serviceId: string): Promise<ServiceRuntime
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/redeploy`, { const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/redeploy`, {
method: 'POST', method: 'POST',
}); });
return payload.runtime ?? { desired: 0, status: 'stopped', urls: [], containers: [] }; return normalizeRuntime(payload.runtime);
} }
function normalizeServiceVariables(rows: RawServiceVariable[] | undefined): ServiceVariable[] { function normalizeServiceVariables(rows: RawServiceVariable[] | undefined): ServiceVariable[] {
@@ -1358,6 +1395,7 @@ export type TemplateWriteInput = {
logo?: string; logo?: string;
config: Record<string, unknown>; config: Record<string, unknown>;
variables?: TemplateVariableEntity[]; variables?: TemplateVariableEntity[];
isPublic?: boolean;
}; };
function templateWriteBody(input: TemplateWriteInput): string { function templateWriteBody(input: TemplateWriteInput): string {
@@ -1368,6 +1406,7 @@ function templateWriteBody(input: TemplateWriteInput): string {
logo: input.logo ?? '', logo: input.logo ?? '',
config: input.config, config: input.config,
variables: input.variables ?? [], variables: input.variables ?? [],
is_public: input.isPublic ?? false,
}); });
} }
+613 -13
View File
@@ -1,4 +1,20 @@
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'; import type { ServiceVariable } from '@/features/workspace/auto-connections';
export const demoProjects: ProjectEntity[] = [ export const demoProjects: ProjectEntity[] = [
@@ -10,9 +26,9 @@ export const demoProjects: ProjectEntity[] = [
createdAt: '2026-03-12T09:30:00Z', createdAt: '2026-03-12T09:30:00Z',
updatedAt: '2026-03-31T09:10:00Z', updatedAt: '2026-03-31T09:10:00Z',
stats: { stats: {
service_count: 4, service_count: 5,
deployment_count: 27, deployment_count: 27,
running_services: 3, running_services: 4,
last_deployment: '2026-03-31T08:42:00Z', last_deployment: '2026-03-31T08:42:00Z',
}, },
}, },
@@ -55,9 +71,11 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'web', type: 'web',
status: 'running', status: 'running',
environment: 'production', environment: 'production',
image: 'ghcr.io/containr/web:2026.03.31', image: 'ghcr.io/containr/web-react:v42',
command: 'npm run serve', command: 'npm run serve',
gitBranch: 'main', gitBranch: 'main',
port: 3000,
domain: 'web.containr.local',
createdAt: '2026-03-12T10:00:00Z', createdAt: '2026-03-12T10:00:00Z',
updatedAt: '2026-03-31T08:42:00Z', updatedAt: '2026-03-31T08:42:00Z',
}, },
@@ -68,9 +86,11 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'web', type: 'web',
status: 'running', status: 'running',
environment: 'production', environment: 'production',
image: 'ghcr.io/containr/api:2026.03.31', image: 'ghcr.io/containr/api-go:v2.4.1',
command: './server', command: './server',
gitBranch: 'main', gitBranch: 'main',
port: 8080,
domain: 'api.containr.local',
createdAt: '2026-03-12T10:02:00Z', createdAt: '2026-03-12T10:02:00Z',
updatedAt: '2026-03-31T08:43:00Z', updatedAt: '2026-03-31T08:43:00Z',
}, },
@@ -81,9 +101,10 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'worker', type: 'worker',
status: 'building', status: 'building',
environment: 'production', environment: 'production',
image: 'ghcr.io/containr/worker:2026.03.31', image: 'ghcr.io/containr/worker:edge',
command: './worker', command: './worker',
gitBranch: 'main', gitBranch: 'main',
port: 9100,
createdAt: '2026-03-12T10:05:00Z', createdAt: '2026-03-12T10:05:00Z',
updatedAt: '2026-03-31T08:44:00Z', updatedAt: '2026-03-31T08:44:00Z',
}, },
@@ -94,12 +115,27 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'database', type: 'database',
status: 'running', status: 'running',
environment: 'production', environment: 'production',
image: 'postgres:15-alpine', image: 'postgres:16',
command: 'postgres', command: 'postgres',
gitBranch: 'main', gitBranch: 'main',
port: 5432,
createdAt: '2026-03-12T10:08:00Z', createdAt: '2026-03-12T10:08:00Z',
updatedAt: '2026-03-31T08:40: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': [ 'demo-project-growth': [
{ {
@@ -109,9 +145,11 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'web', type: 'web',
status: 'running', status: 'running',
environment: 'production', environment: 'production',
image: 'ghcr.io/containr/marketing:latest', image: 'nginx:1.27-alpine',
command: 'npm run start', command: 'nginx -g daemon off;',
gitBranch: 'main', gitBranch: 'main',
port: 8080,
domain: 'growth.containr.local',
createdAt: '2026-03-03T09:00:00Z', createdAt: '2026-03-03T09:00:00Z',
updatedAt: '2026-03-30T12:10:00Z', updatedAt: '2026-03-30T12:10:00Z',
}, },
@@ -122,9 +160,10 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'web', type: 'web',
status: 'running', status: 'running',
environment: 'production', environment: 'production',
image: 'ghcr.io/containr/campaign-api:latest', image: 'node:20-alpine',
command: './api', command: './api',
gitBranch: 'main', gitBranch: 'main',
port: 3000,
createdAt: '2026-03-03T09:04:00Z', createdAt: '2026-03-03T09:04:00Z',
updatedAt: '2026-03-30T12:08:00Z', updatedAt: '2026-03-30T12:08:00Z',
}, },
@@ -135,9 +174,10 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'database', type: 'database',
status: 'running', status: 'running',
environment: 'production', environment: 'production',
image: 'redis:7-alpine', image: 'dragonfly:1.25',
command: 'redis-server', command: 'redis-server',
gitBranch: 'main', gitBranch: 'main',
port: 6379,
createdAt: '2026-03-03T09:07:00Z', createdAt: '2026-03-03T09:07:00Z',
updatedAt: '2026-03-30T12:07:00Z', updatedAt: '2026-03-30T12:07:00Z',
}, },
@@ -150,9 +190,10 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'web', type: 'web',
status: 'running', status: 'running',
environment: 'production', environment: 'production',
image: 'ghcr.io/containr/inference-api:latest', image: 'python:3.12-slim',
command: './serve', command: './serve',
gitBranch: 'main', gitBranch: 'main',
port: 8000,
createdAt: '2026-02-18T08:00:00Z', createdAt: '2026-02-18T08:00:00Z',
updatedAt: '2026-03-29T18:00:00Z', updatedAt: '2026-03-29T18:00:00Z',
}, },
@@ -163,7 +204,7 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
type: 'worker', type: 'worker',
status: 'failed', status: 'failed',
environment: 'production', environment: 'production',
image: 'ghcr.io/containr/evaluator:latest', image: 'python:3.12-slim',
command: './run-jobs', command: './run-jobs',
gitBranch: 'main', gitBranch: 'main',
createdAt: '2026-02-18T08:03:00Z', createdAt: '2026-02-18T08:03:00Z',
@@ -195,6 +236,7 @@ export const demoVariablesByProject: Record<string, Record<string, ServiceVariab
] satisfies ServiceVariable[] ] satisfies ServiceVariable[]
), ),
'demo-svc-postgres': [], 'demo-svc-postgres': [],
'demo-svc-redis': [],
}, },
'demo-project-growth': { 'demo-project-growth': {
'demo-growth-site': ( 'demo-growth-site': (
@@ -332,3 +374,561 @@ export const demoCronJobsByService: Record<string, CronJobEntity[]> = {
export function getDemoCronJobsByService(serviceId: string): CronJobEntity[] { export function getDemoCronJobsByService(serviceId: string): CronJobEntity[] {
return demoCronJobsByService[serviceId] ?? []; 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 ( return (
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}> <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}> <RechartsLineChart data={chartData}>
<Line <Line
type={smooth ? 'monotone' : 'linear'} type={smooth ? 'monotone' : 'linear'}
@@ -105,7 +105,7 @@ export function LineAreaChart({
return ( return (
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}> <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}> <AreaChart data={chartData}>
<defs> <defs>
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1"> <linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
@@ -142,7 +142,7 @@ export function MultiLineChart({
return ( return (
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}> <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}> <AreaChart data={chartData}>
<defs> <defs>
{datasets.map((dataset, index) => { {datasets.map((dataset, index) => {
@@ -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>
);
}
@@ -1,3 +1,4 @@
export * from './DemoGate';
export * from './ErrorBoundary'; export * from './ErrorBoundary';
export * from './LoadingSpinner'; export * from './LoadingSpinner';
export * from './Toast'; 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 // Split vendor libraries
vendor: ['react', 'react-dom', '@tanstack/react-query'], vendor: ['react', 'react-dom', '@tanstack/react-query'],
ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu', 'lucide-react'], 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 // Optimize chunk naming for better caching
chunkFileNames: 'assets/js/[name]-[hash].js', chunkFileNames: 'assets/js/[name]-[hash].js',
+173
View File
@@ -152,6 +152,97 @@ paths:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
/user/tokens:
get:
tags:
- User
summary: List personal access tokens
description: Returns the caller's non-revoked personal access token metadata. Token values and hashes are never returned.
security:
- bearerAuth: []
responses:
'200':
description: Token list
content:
application/json:
schema:
type: object
properties:
tokens:
type: array
items:
$ref: '#/components/schemas/UserToken'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
post:
tags:
- User
summary: Create personal access token
description: Issues a `cnp_` bearer token for CLI/MCP/agent authentication. The raw token is returned exactly once and never stored — only its SHA-256 hash persists. Admin-scope tokens require a platform admin account.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserTokenRequest'
responses:
'201':
description: Token created; raw value returned once
content:
application/json:
schema:
$ref: '#/components/schemas/UserTokenCreated'
'400':
description: Invalid input
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: Admin scope requires platform admin
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/user/tokens/{id}:
delete:
tags:
- User
summary: Revoke personal access token
description: Marks one of the caller's tokens as revoked. Clients presenting it are rejected from then on.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Token revoked
content:
application/json:
schema:
type: object
properties:
message:
type: string
'404':
description: Token not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# Projects # Projects
/projects: /projects:
get: get:
@@ -458,6 +549,16 @@ paths:
type: array type: array
items: items:
type: string type: string
description: Reachable service URLs; rewritten to the request Host for remote clients. Null/empty when nothing is published.
ports:
type: array
items:
type: integer
description: Host ports actually bound for replica 0+; persisted on the service so restarts reuse the same public port. Null/empty when nothing is published.
health:
type: string
enum: [healthy, unhealthy, unreachable]
description: Result of the backend's HTTP probe of the service on its project network. Omitted when the service declares no healthcheck path.
containers: containers:
type: array type: array
items: items:
@@ -5216,6 +5317,9 @@ components:
type: string type: string
nullable: true nullable: true
description: Owning user ID for user-created templates; null for official presets 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: created_at:
type: string type: string
format: date-time format: date-time
@@ -5251,6 +5355,9 @@ components:
items: items:
$ref: '#/components/schemas/TemplateVariable' $ref: '#/components/schemas/TemplateVariable'
description: Configurable variables exposed at deploy time description: Configurable variables exposed at deploy time
is_public:
type: boolean
description: Publish this template to the public catalog (visible anonymously)
TemplateConfig: TemplateConfig:
type: object type: object
@@ -6046,6 +6153,12 @@ components:
type: string type: string
server: server:
type: string 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: collected_at:
type: string type: string
format: date-time format: date-time
@@ -6373,6 +6486,66 @@ components:
type: string type: string
description: Human-readable label for the token (e.g. node name) description: Human-readable label for the token (e.g. node name)
UserToken:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
key_prefix:
type: string
description: First 12 chars of the token, for identification in lists
scope:
type: string
enum: [read, write, admin]
expires_at:
type: string
format: date-time
last_used_at:
type: string
format: date-time
created_at:
type: string
format: date-time
required:
- id
- name
- key_prefix
- scope
- created_at
UserTokenCreated:
allOf:
- $ref: '#/components/schemas/UserToken'
- type: object
properties:
token:
type: string
description: Raw cnp_ token value, returned exactly once at creation
required:
- token
CreateUserTokenRequest:
type: object
properties:
name:
type: string
description: Human-readable label (e.g. "laptop cli", "ci deploy")
scope:
type: string
enum: [read, write, admin]
default: write
description: read = GET/HEAD only; write = all non-admin routes; admin = everything (requires admin account)
expires_in_days:
type: integer
minimum: 0
maximum: 3650
description: 0 or omitted = never expires
required:
- name
AgentCapabilities: AgentCapabilities:
type: object type: object
properties: properties:
+27
View File
@@ -90,3 +90,30 @@ backend restart.
once), copy the prefilled install command, revoke tokens when done. Agents once), copy the prefilled install command, revoke tokens when done. Agents
register over `POST /api/agents/register` and heartbeat via register over `POST /api/agents/register` and heartbeat via
`POST /api/agents/heartbeat`. `POST /api/agents/heartbeat`.
## Personal access tokens
**Settings → Personal Access Tokens**: issue `cnp_…` bearer tokens for the
CLI, MCP server, and scripts — the same privileges as your account, bounded
by scope. The raw token is shown once at creation; only its SHA-256 hash is
stored.
Scopes:
- `read` — `GET`/`HEAD`/`OPTIONS` only; mutations return `403 SCOPE_INSUFFICIENT`.
- `write` — everything except admin routes.
- `admin` — full access; requires a platform admin account, and the token is
still capped at the owner's privileges.
A token can never outrank its owner: non-admin scopes report
`is_admin = false` even on admin accounts.
```
GET /api/v1/user/tokens list (metadata only, never hashes)
POST /api/v1/user/tokens {name, scope?, expires_in_days?} → raw token once
DELETE /api/v1/user/tokens/:id revoke (owner-scoped)
```
Use as `Authorization: Bearer cnp_…`, or `containr auth login cnp_…`.
Revocation takes effect on the next request; `last_used_at` updates on each
authenticated call.
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.
+379
View File
@@ -0,0 +1,379 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Containr - Self-hosted platform for containerized services</title>
<meta name="description" content="Containr turns any Docker host into a full deployment platform. Visual project canvases, git-push builds, managed databases, preview environments, and HA failover on your own hardware.">
<meta property="og:title" content="Containr">
<meta property="og:description" content="Railway-style project canvases on your own hardware. Open source, MIT licensed.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://containr-lyart.vercel.app/">
<meta property="og:image" content="https://containr-lyart.vercel.app/screenshots/canvas.png">
<link rel="canonical" href="https://containr-lyart.vercel.app/">
<meta name="theme-color" content="#0c0d0f">
<link rel="icon" type="image/svg+xml" href="containr.svg">
<script>document.documentElement.classList.add('js');</script>
<link rel="stylesheet" href="style.css">
</head>
<body>
<nav>
<div class="wrap nav-inner">
<a href="#" class="brand">
<img src="containr.svg" alt="Containr logo">
contain<em>r</em>
</a>
<div class="nav-links">
<a href="#features">Features</a>
<a href="#product">Product</a>
<a href="templates.html">Templates</a>
<a href="https://github.com/Dvorinka/Containr/tree/main/docs/guides" target="_blank" rel="noopener">Docs</a>
</div>
<div class="nav-right">
<a class="gh-link" href="https://github.com/Dvorinka/Containr" target="_blank" rel="noopener" aria-label="Containr on GitHub">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
</a>
<a class="btn btn-primary" href="#install">Get started</a>
</div>
</div>
</nav>
<header class="hero">
<div class="wrap">
<span class="pill">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><rect x="3" y="7" width="18" height="12" rx="2"/><path d="M8 7v12M13 7v12M18 7v12M6 22h12"/></svg>
Self-hosted PaaS · MIT license
</span>
<h1>Deploy containers on <em>your own cloud</em></h1>
<p class="sub">Containr turns a Docker host into a full deployment platform. Git builds, managed databases, previews, failover.</p>
<div class="hero-cta">
<a class="btn btn-primary" href="#install">
Get started
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
</a>
<a class="btn btn-ghost" href="https://github.com/Dvorinka/Containr" target="_blank" rel="noopener">View on GitHub</a>
</div>
<div class="shot-frame reveal">
<div class="shot-bar"><span class="addr">containr &#8250; core-platform &#8250; canvas</span></div>
<div class="cv" aria-label="Containr project canvas preview">
<div class="cv-top">
<span class="cv-back">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M11 18l-6-6 6-6"/></svg>
Projects
</span>
<b>Core Platform<span class="cv-cursor">_</span></b>
<span class="cv-meta">5 services &#183; 4 running</span>
<span class="cv-tabs">
<i class="on">Canvas</i><i>Observability</i><i>Logs</i><i>Settings</i>
</span>
<span class="cv-active">&#9679; ACTIVE</span>
</div>
<div class="cv-stage">
<svg class="cv-edges" viewBox="0 0 1000 360" preserveAspectRatio="none" aria-hidden="true">
<path d="M 222 101 C 287 101 287 177 358 177" />
<path class="live" d="M 222 238 C 287 238 287 177 358 177" />
<path d="M 536 168 C 618 168 618 83 706 83" />
<path d="M 536 195 C 618 195 618 216 706 216" />
</svg>
<svg class="cv-edges-m" viewBox="0 0 1000 360" preserveAspectRatio="none" aria-hidden="true">
<path d="M 468 105 C 494 105 494 185 520 185" />
<path class="live" d="M 468 278 C 494 278 494 185 520 185" />
<path d="M 949 62 C 985 62 985 155 949 155" />
<path d="M 949 300 C 985 300 985 215 949 215" />
</svg>
<div class="cv-pill env"><i></i>production<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></div>
<div class="cv-node cv-n1">
<div class="cv-row">
<span class="cv-ic" style="background:rgba(97,218,251,.14);color:#61dafb"><img src="https://cdn.simpleicons.org/react/61dafb" alt="" onerror="this.replaceWith('R')"></span>
<div><h4>Web Frontend</h4><p>web</p></div>
</div>
<div class="cv-addr"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg><span>web.containr.local</span></div>
<div class="cv-foot"><span class="cv-st run"></span><em>running</em><code>ghcr.io/web:v42</code></div>
</div>
<div class="cv-node cv-n2">
<div class="cv-row">
<span class="cv-ic" style="background:rgba(180,227,74,.14);color:var(--accent)"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 17 6-6-6-6M12 19h8"/></svg></span>
<div><h4>Queue Worker</h4><p>worker</p></div>
</div>
<div class="cv-addr"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="m9 9 6 6M15 9l-6 6"/></svg><span>queue-worker</span></div>
<div class="cv-foot"><span class="cv-st dep"></span><em style="color:#f2c94c">deploying</em><code>worker:edge</code></div>
</div>
<div class="cv-node sel cv-n3">
<div class="cv-row">
<span class="cv-ic" style="background:rgba(0,173,216,.14);color:#00add8"><img src="https://cdn.simpleicons.org/go/00add8" alt="" onerror="this.replaceWith('G')"></span>
<div><h4>API Gateway</h4><p>web &#183; 2 replicas</p></div>
</div>
<div class="cv-addr"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg><span>api.containr.local</span></div>
<div class="cv-foot"><span class="cv-st run"></span><em>running</em><code>api:v2.4.1</code></div>
</div>
<div class="cv-node cv-n4">
<div class="cv-row">
<span class="cv-ic" style="background:rgba(65,105,225,.16);color:#7ab8ff"><img src="https://cdn.simpleicons.org/postgresql/7ab8ff" alt="" onerror="this.replaceWith('P')"></span>
<div><h4>Postgres</h4><p>database</p></div>
</div>
<div class="cv-addr"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.66 3.58 3 8 3s8-1.34 8-3V5"/></svg><span>postgres:5432</span></div>
<div class="cv-foot"><span class="cv-st run"></span><em>running</em><code>postgres:16</code></div>
</div>
<div class="cv-node cv-n5">
<div class="cv-row">
<span class="cv-ic" style="background:rgba(255,68,68,.13);color:#ff6b6b"><img src="https://cdn.simpleicons.org/redis/ff6b6b" alt="" onerror="this.replaceWith('R')"></span>
<div><h4>Redis Cache</h4><p>database</p></div>
</div>
<div class="cv-addr"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.66 3.58 3 8 3s8-1.34 8-3V5"/></svg><span>redis:6379</span></div>
<div class="cv-foot"><span class="cv-st run"></span><em>running</em><code>dragonfly:1</code></div>
</div>
<div class="cv-pill zoom">
<span>&#8722;</span><b>100%</b><span>+</span><i></i>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 15-4.5 4.5M9 15l6-6M8 4l.9 2.6L11.5 8 9 9.5 6.4 8 8 5.4zM19 13l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7zM13 3l.6 1.7L15.3 5l-1.7.6L13 7.3 12.4 5.6 10.7 5l1.7-.6z"/></svg>
</div>
<div class="cv-minimap"><i style="left:6%;top:14%"></i><i style="left:6%;top:52%"></i><i style="left:38%;top:35%"></i><i style="left:72%;top:9%"></i><i style="left:72%;top:46%"></i></div>
</div>
</div>
</div>
</div>
</header>
<div class="install" id="install">
<div class="wrap">
<div class="install-inner reveal">
<div class="install-copy">
<h2>One command, running stack</h2>
<p>Requires Docker and openssl. Installs into ./containr, never overwrites your .env.</p>
</div>
<div class="cmd">
<code id="install-cmd">curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh | bash</code>
<button class="copy-btn" id="copy-btn" aria-label="Copy install command">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
</div>
</div>
</div>
<section id="features">
<div class="wrap">
<div class="sec-head reveal">
<h2>A control plane, not a pile of YAML</h2>
<p>Everything a deployment platform needs, running on hardware you already own. One install, one dashboard.</p>
</div>
<div class="bento">
<div class="cell wide reveal">
<div>
<div class="icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><path d="M10 6.5h5.5a2 2 0 0 1 2 2V14M6.5 10v5.5a2 2 0 0 0 2 2H14"/></svg>
</div>
<h3>Visual project canvas</h3>
<p>Services appear as nodes on a Railway-style canvas. Drag, group, and wire them together; connections and shared variables are inferred automatically. Start, stop, restart, or roll back with a right-click.</p>
</div>
<div class="cell-media"><img src="screenshots/service-detail.png" alt="Service view with metrics, logs, and configuration tabs" loading="lazy"></div>
</div>
<div class="cell tinted reveal">
<div class="icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.66 3.58 3 8 3s8-1.34 8-3V5"/><path d="M4 11v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"/></svg>
</div>
<h3>Managed databases</h3>
<p>Provision engines from the dashboard, with backups and restore built in.</p>
<div class="chips">
<span>PostgreSQL</span><span>MySQL</span><span>MariaDB</span><span>MongoDB</span><span>Redis</span><span>Dragonfly</span><span>ClickHouse</span>
</div>
</div>
<div class="cell reveal">
<div class="icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="12" cy="18" r="2.5"/><path d="M8 7.5 10.5 16M16 7.5 13.5 16M8.5 6h7"/></svg>
</div>
<h3>Private networking</h3>
<p>Every project gets an isolated Docker network. Services reach each other by name.</p>
<div class="snippet">web:3000 <b>&#8594;</b> ${{service.KEY}}</div>
</div>
<div class="cell reveal">
<div class="icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v12"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="6" r="2.5"/><path d="M18 8.5a5 5 0 0 1-5 5H9"/></svg>
</div>
<h3>Git-push deploys</h3>
<p>Connect GitHub, GitLab, Bitbucket, or Gitea. Builds, history, and image rollback included.</p>
</div>
<div class="cell reveal">
<div class="icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 17l6-6 4 4 8-8"/><path d="M14 7h7v7"/></svg>
</div>
<h3>HA and autoscaling</h3>
<p>Failover policies, health checks, node agents, and metric-driven replica scaling.</p>
</div>
<div class="cell reveal">
<div class="icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 4 6v6c0 5 3.4 8.6 8 10 4.6-1.4 8-5 8-10V6l-8-4Z"/><path d="m9 12 2 2 4-4"/></svg>
</div>
<h3>Admin and audit</h3>
<p>Approval console, user management, audit logs, and vulnerability scans behind sign-in.</p>
</div>
</div>
</div>
</section>
<section id="product">
<div class="wrap">
<div class="sec-head reveal">
<h2>The whole fleet on one screen</h2>
<p>Logs, metrics, builds, and rollbacks without leaving the dashboard. Host telemetry included.</p>
</div>
<div class="gallery reveal">
<div class="shot-tabs" role="tablist" aria-label="Product screenshots">
<button class="shot-tab on" role="tab" aria-selected="true" data-shot="dashboard" data-cap="Projects dashboard with host CPU, memory, and disk telemetry.">Dashboard</button>
<button class="shot-tab" role="tab" aria-selected="false" data-shot="canvas" data-cap="Visual service canvas with auto-inferred connections.">Canvas</button>
<button class="shot-tab" role="tab" aria-selected="false" data-shot="service-detail" data-cap="Service detail with metrics, logs, and config tabs.">Service detail</button>
<button class="shot-tab" role="tab" aria-selected="false" data-shot="databases" data-cap="Managed databases with backup and restore.">Databases</button>
</div>
<img id="gallery-img" src="screenshots/dashboard.png" alt="Containr dashboard" loading="lazy">
<p class="cap" id="gallery-cap">Projects dashboard with host CPU, memory, and disk telemetry.</p>
</div>
</div>
</section>
<section id="templates">
<div class="wrap">
<div class="sec-head reveal">
<h2>A catalog worth browsing</h2>
<p>Thirty official templates plus community presets. <a href="templates.html" style="color: var(--accent);">Browse the full catalog</a> or publish your own.</p>
</div>
<div class="marquee reveal" aria-label="Template catalog">
<div class="marquee-track" id="marquee-track">
<span class="tpl"><img src="https://cdn.simpleicons.org/n8n/8b919e" alt="" loading="lazy" onerror="this.remove()">n8n</span>
<span class="tpl"><img src="https://cdn.simpleicons.org/nextcloud/8b919e" alt="" loading="lazy" onerror="this.remove()">Nextcloud</span>
<span class="tpl"><img src="https://cdn.simpleicons.org/immich/8b919e" alt="" loading="lazy" onerror="this.remove()">Immich</span>
<span class="tpl"><img src="https://cdn.simpleicons.org/vaultwarden/8b919e" alt="" loading="lazy" onerror="this.remove()">Vaultwarden</span>
<span class="tpl"><img src="https://cdn.simpleicons.org/grafana/8b919e" alt="" loading="lazy" onerror="this.remove()">Grafana</span>
<span class="tpl"><img src="https://cdn.simpleicons.org/supabase/8b919e" alt="" loading="lazy" onerror="this.remove()">Supabase</span>
<span class="tpl"><img src="https://cdn.simpleicons.org/gitea/8b919e" alt="" loading="lazy" onerror="this.remove()">Gitea</span>
<span class="tpl"><img src="https://cdn.simpleicons.org/uptimekuma/8b919e" alt="" loading="lazy" onerror="this.remove()">Uptime Kuma</span>
<span class="tpl"><img src="https://cdn.simpleicons.org/pihole/8b919e" alt="" loading="lazy" onerror="this.remove()">Pi-hole</span>
<span class="tpl"><img src="https://cdn.simpleicons.org/jellyfin/8b919e" alt="" loading="lazy" onerror="this.remove()">Jellyfin</span>
<span class="tpl"><img src="https://cdn.simpleicons.org/meilisearch/8b919e" alt="" loading="lazy" onerror="this.remove()">Meilisearch</span>
<span class="tpl"><img src="https://cdn.simpleicons.org/mastodon/8b919e" alt="" loading="lazy" onerror="this.remove()">Mastodon</span>
<span class="tpl"><img src="https://cdn.simpleicons.org/homeassistant/8b919e" alt="" loading="lazy" onerror="this.remove()">Home Assistant</span>
<span class="tpl"><img src="https://cdn.simpleicons.org/plausibleanalytics/8b919e" alt="" loading="lazy" onerror="this.remove()">Plausible</span>
<span class="tpl"><img src="https://cdn.simpleicons.org/minio/8b919e" alt="" loading="lazy" onerror="this.remove()">MinIO</span>
<span class="tpl"><img src="https://cdn.simpleicons.org/appwrite/8b919e" alt="" loading="lazy" onerror="this.remove()">Appwrite</span>
</div>
</div>
</div>
</section>
<section>
<div class="wrap">
<div class="sec-head reveal">
<h2>Open for visitors, locked for owners</h2>
<p>The platform doubles as a public showcase. Approved projects are browsable anonymously; secrets never are.</p>
</div>
<div class="access reveal">
<div>
<h3>Anonymous</h3>
<ul>
<li>Browse approved projects</li>
<li>Template catalog and docs</li>
<li>HA status overview</li>
</ul>
</div>
<div>
<h3>Signed in</h3>
<ul>
<li>Projects, services, databases</li>
<li>Logs, variables, builds, deploys</li>
<li>Custom templates</li>
</ul>
</div>
<div>
<h3>Admin</h3>
<ul>
<li>Project approval and moderation</li>
<li>User flags and agent tokens</li>
<li>Platform settings</li>
</ul>
</div>
</div>
</div>
</section>
<div class="cta">
<div class="wrap reveal">
<h2>Run your own cloud.</h2>
<p>One Docker host is all it takes. Free, open source, and yours to keep.</p>
<div class="hero-cta">
<a class="btn btn-primary" href="#install">
Get started
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
</a>
<a class="btn btn-ghost" href="https://github.com/Dvorinka/Containr" target="_blank" rel="noopener">Star on GitHub</a>
</div>
</div>
</div>
<footer>
<div class="wrap foot">
<span class="l"><img src="containr.svg" alt="">Containr - MIT License</span>
<nav>
<a href="https://github.com/Dvorinka/Containr" target="_blank" rel="noopener">GitHub</a>
<a href="https://github.com/Dvorinka/Containr/tree/main/docs" target="_blank" rel="noopener">Docs</a>
<a href="https://github.com/Dvorinka/Containr/blob/main/CONTRIBUTING.md" target="_blank" rel="noopener">Contribute</a>
<a href="https://github.com/Dvorinka/Containr/blob/main/SECURITY.md" target="_blank" rel="noopener">Security</a>
</nav>
</div>
</footer>
<script>
// Copy install command
document.getElementById('copy-btn').addEventListener('click', async (e) => {
const cmd = document.getElementById('install-cmd').textContent;
try {
await navigator.clipboard.writeText(cmd);
} catch {
const r = document.createRange();
r.selectNodeContents(document.getElementById('install-cmd'));
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
}
const btn = e.currentTarget;
btn.style.color = 'var(--accent)';
setTimeout(() => (btn.style.color = ''), 1200);
});
// Screenshot gallery tabs
const img = document.getElementById('gallery-img');
const cap = document.getElementById('gallery-cap');
document.querySelectorAll('.shot-tab').forEach((tab) => {
tab.addEventListener('click', () => {
document.querySelectorAll('.shot-tab').forEach((t) => {
t.classList.remove('on');
t.setAttribute('aria-selected', 'false');
});
tab.classList.add('on');
tab.setAttribute('aria-selected', 'true');
img.src = 'screenshots/' + tab.dataset.shot + '.png';
img.alt = 'Containr ' + tab.textContent.trim() + ' screenshot';
cap.textContent = tab.dataset.cap;
});
});
// Duplicate marquee track for seamless loop
const track = document.getElementById('marquee-track');
track.innerHTML += track.innerHTML;
// Scroll reveal
const io = new IntersectionObserver(
(entries) => entries.forEach((en) => en.isIntersecting && en.target.classList.add('in')),
{ threshold: 0.12 }
);
document.querySelectorAll('.reveal').forEach((el) => io.observe(el));
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+624
View File
@@ -0,0 +1,624 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('fonts/inter-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('fonts/inter-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url('fonts/jbmono-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url('fonts/jbmono-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
:root {
--bg-void: #0c0d0f;
--bg-base: #0e0f12;
--surface-card: #131518;
--surface-card-hover: #1a1d22;
--surface-muted: #1a1d22;
--border-subtle: rgba(255, 255, 255, 0.06);
--border-default: rgba(255, 255, 255, 0.1);
--border-strong: rgba(255, 255, 255, 0.18);
--text-primary: #ecedf0;
--text-secondary: #8b919e;
--text-tertiary: #575d68;
--accent: #b4e34a;
--accent-dim: rgba(180, 227, 74, 0.12);
--accent-on: #131507;
--mono: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
--sans: 'Inter', system-ui, -apple-system, sans-serif;
--radius: 14px;
--radius-sm: 9px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
font-family: var(--sans);
background: var(--bg-void);
color: var(--text-secondary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.5;
}
body::before {
content: '';
position: fixed;
inset: 0;
background: radial-gradient(1000px 480px at 50% -8%, rgba(180, 227, 74, 0.05), transparent 60%);
pointer-events: none;
}
::selection { background: rgba(180, 227, 74, 0.25); color: var(--text-primary); }
a { color: inherit; text-decoration: none; }
img { display: block; max-width: 100%; }
.wrap { max-width: 1120px; margin: 0 auto; padding: 0 24px; }
/* ---------- nav ---------- */
nav {
position: sticky;
top: 0;
z-index: 50;
border-bottom: 1px solid var(--border-subtle);
background: rgba(14, 15, 18, 0.82);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.nav-inner {
display: flex;
align-items: center;
height: 60px;
gap: 28px;
}
.brand {
display: flex;
align-items: center;
gap: 9px;
font-weight: 800;
font-size: 15px;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.brand img { width: 26px; height: 26px; border-radius: 7px; }
.brand em { font-style: normal; color: var(--accent); }
.nav-links { display: flex; gap: 4px; margin-left: 8px; }
.nav-links a {
font-size: 13px;
padding: 6px 12px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
transition: color 0.15s, background 0.15s;
}
.nav-links a:hover { color: var(--text-primary); background: var(--surface-muted); }
.nav-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.gh-link {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
transition: color 0.15s, border-color 0.15s;
}
.gh-link:hover { color: var(--text-primary); border-color: var(--border-strong); }
.btn {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 600;
padding: 8px 16px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
cursor: pointer;
transition: transform 0.15s, border-color 0.15s, background 0.15s;
white-space: nowrap;
}
.btn-primary { background: var(--accent); color: var(--accent-on); }
.btn-primary:hover { transform: translateY(-1px); }
.btn-ghost { border-color: var(--border-default); color: var(--text-secondary); }
.btn-ghost:hover { color: var(--text-primary); border-color: var(--border-strong); }
/* ---------- hero ---------- */
.hero { padding: 84px 0 0; }
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-tertiary);
border: 1px solid var(--border-subtle);
background: var(--surface-card);
padding: 6px 14px;
border-radius: 999px;
}
.pill svg { color: var(--accent); }
.hero h1 {
margin-top: 22px;
font-size: clamp(34px, 5.4vw, 58px);
font-weight: 800;
letter-spacing: -0.035em;
line-height: 1.06;
color: var(--text-primary);
max-width: 22ch;
}
.hero h1 em { font-style: normal; color: var(--accent); }
.hero .sub {
margin-top: 18px;
font-size: 15.5px;
line-height: 1.65;
max-width: 46ch;
}
.hero-cta { display: flex; gap: 12px; margin-top: 30px; flex-wrap: wrap; }
.hero-cta .btn { font-size: 14px; padding: 11px 22px; }
.shot-frame {
margin-top: 56px;
border: 1px solid var(--border-default);
border-radius: var(--radius);
background: var(--surface-card);
overflow: hidden;
box-shadow: 0 30px 80px -30px rgba(0, 0, 0, 0.7);
}
.shot-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
border-bottom: 1px solid var(--border-subtle);
font-family: var(--mono);
font-size: 11px;
color: var(--text-tertiary);
}
.shot-bar .addr {
flex: 1;
text-align: center;
background: var(--bg-void);
border: 1px solid var(--border-subtle);
border-radius: 6px;
padding: 3px 0;
max-width: 320px;
margin: 0 auto;
}
.shot-frame img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; object-position: top; }
/* ---------- install ---------- */
.install { padding: 72px 0 0; }
.install-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 28px;
border: 1px solid var(--border-default);
border-radius: var(--radius);
background: var(--bg-base);
padding: 26px 30px;
flex-wrap: wrap;
}
.install-copy h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.01em; }
.install-copy p { font-size: 13px; margin-top: 4px; }
.cmd {
display: flex;
align-items: center;
gap: 0;
font-family: var(--mono);
font-size: 12.5px;
background: var(--bg-void);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
overflow: hidden;
max-width: 100%;
}
.cmd code {
padding: 11px 14px;
color: var(--text-primary);
white-space: nowrap;
overflow-x: auto;
scrollbar-width: none;
}
.cmd code::-webkit-scrollbar { display: none; }
.cmd code::before { content: '$ '; color: var(--accent); }
.copy-btn {
border: none;
border-left: 1px solid var(--border-subtle);
background: transparent;
color: var(--text-tertiary);
padding: 11px 13px;
cursor: pointer;
display: flex;
align-items: center;
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
}
.copy-btn:hover { color: var(--accent); background: var(--surface-muted); }
/* ---------- sections ---------- */
section { padding: 96px 0 0; }
.sec-head { margin-bottom: 40px; }
.sec-head h2 {
font-size: clamp(24px, 3vw, 32px);
font-weight: 800;
letter-spacing: -0.025em;
color: var(--text-primary);
max-width: 22ch;
}
.sec-head p { margin-top: 10px; font-size: 14.5px; max-width: 56ch; }
/* ---------- bento ---------- */
.bento {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 14px;
}
.cell {
grid-column: span 2;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--surface-card);
padding: 22px;
transition: border-color 0.15s, transform 0.15s;
position: relative;
overflow: hidden;
}
.cell:hover { border-color: var(--border-default); transform: translateY(-2px); }
.cell.wide { grid-column: span 4; display: flex; gap: 26px; align-items: stretch; }
.cell h3 { font-size: 14.5px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.01em; }
.cell p { font-size: 12.5px; line-height: 1.6; margin-top: 8px; color: var(--text-tertiary); }
.cell .icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--accent-dim);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 14px;
}
.cell.wide .icon { margin-bottom: 12px; }
.cell-media {
flex: 0 0 46%;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--border-subtle);
align-self: center;
}
.cell-media img { width: 100%; height: 100%; object-fit: cover; object-position: left top; }
.cell.tinted { background: linear-gradient(160deg, rgba(180, 227, 74, 0.09), rgba(180, 227, 74, 0.02) 55%), var(--surface-card); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; }
.chips span {
font-family: var(--mono);
font-size: 10.5px;
color: var(--text-secondary);
background: var(--bg-void);
border: 1px solid var(--border-subtle);
border-radius: 6px;
padding: 3px 8px;
}
.snippet {
margin-top: 14px;
font-family: var(--mono);
font-size: 11.5px;
background: var(--bg-void);
border: 1px solid var(--border-subtle);
border-radius: 8px;
padding: 10px 12px;
color: var(--text-secondary);
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
}
.snippet b { color: var(--accent); font-weight: 500; }
/* ---------- screenshots ---------- */
.shot-tabs { display: flex; gap: 6px; margin-bottom: 18px; flex-wrap: wrap; }
.shot-tab {
font-family: var(--mono);
font-size: 11.5px;
color: var(--text-tertiary);
background: transparent;
border: 1px solid var(--border-subtle);
border-radius: 999px;
padding: 7px 15px;
cursor: pointer;
transition: all 0.15s;
}
.shot-tab:hover { color: var(--text-secondary); border-color: var(--border-default); }
.shot-tab.on { color: var(--accent); border-color: rgba(180, 227, 74, 0.4); background: var(--accent-dim); }
.gallery img { width: 100%; border-radius: var(--radius); border: 1px solid var(--border-default); }
.gallery .cap { font-size: 12.5px; color: var(--text-tertiary); margin-top: 12px; }
/* ---------- templates marquee ---------- */
.marquee {
overflow: hidden;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-base);
padding: 18px 0;
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
-webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}
.marquee-track {
display: flex;
gap: 10px;
width: max-content;
animation: scroll 46s linear infinite;
}
@keyframes scroll { to { transform: translateX(-50%); } }
.tpl {
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
font-weight: 600;
color: var(--text-secondary);
border: 1px solid var(--border-subtle);
background: var(--surface-card);
border-radius: 999px;
padding: 8px 16px;
white-space: nowrap;
}
.tpl img { width: 15px; height: 15px; }
/* ---------- access ---------- */
.access {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0;
border-top: 1px solid var(--border-subtle);
}
.access > div { padding: 28px 28px 4px 0; }
.access > div + div { padding-left: 28px; border-left: 1px solid var(--border-subtle); }
.access h3 {
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
}
.access ul { list-style: none; margin-top: 14px; }
.access li { font-size: 13px; padding: 5px 0; color: var(--text-secondary); }
.access li::before { content: '- '; color: var(--text-tertiary); }
/* ---------- cta ---------- */
.cta { text-align: center; padding: 110px 0; }
.cta h2 {
font-size: clamp(30px, 4.5vw, 46px);
font-weight: 800;
letter-spacing: -0.03em;
color: var(--text-primary);
}
.cta p { margin: 14px auto 0; font-size: 14.5px; max-width: 46ch; }
.cta .hero-cta { justify-content: center; }
/* ---------- footer ---------- */
footer { border-top: 1px solid var(--border-subtle); }
.foot {
display: flex;
align-items: center;
justify-content: space-between;
padding: 26px 0;
font-size: 12.5px;
color: var(--text-tertiary);
flex-wrap: wrap;
gap: 14px;
}
.foot .l { display: flex; align-items: center; gap: 8px; }
.foot img { width: 18px; height: 18px; border-radius: 5px; }
.foot nav { display: flex; gap: 20px; position: static; border: none; background: none; backdrop-filter: none; -webkit-backdrop-filter: none; }
.foot nav a:hover { color: var(--text-primary); }
/* ---------- reveal (JS-gated: content stays visible without JS) ---------- */
.js .reveal { opacity: 0; transform: translateY(16px); transition: opacity 0.55s ease, transform 0.55s ease; }
.js .reveal.in { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.marquee-track { animation: none; }
.reveal { opacity: 1; transform: none; transition: none; }
.btn-primary:hover, .cell:hover { transform: none; }
}
/* ---------- responsive ---------- */
@media (max-width: 860px) {
.nav-links { display: none; }
.bento { grid-template-columns: repeat(2, 1fr); }
.cell, .cell.wide { grid-column: span 2; }
.cell.wide { flex-direction: column; }
.cell-media { flex: none; }
.access { grid-template-columns: 1fr; }
.access > div { padding: 22px 0; }
.access > div + div { padding-left: 0; border-left: none; border-top: 1px solid var(--border-subtle); }
section { padding: 72px 0 0; }
.hero { padding-top: 56px; }
}
@media (max-width: 520px) {
.bento { grid-template-columns: 1fr; }
.cell, .cell.wide { grid-column: span 1; }
.install-inner { padding: 20px; }
}
/* ---------- hero canvas mockup ---------- */
.cv { background: #0a0b0d; }
.cv-top {
display: flex;
align-items: center;
gap: 14px;
padding: 9px 16px;
border-bottom: 1px solid var(--border-subtle);
background: #101216;
}
.cv-back { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text-tertiary); }
.cv-top b { font-size: 13px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.01em; }
.cv-meta { font-family: var(--mono); font-size: 10.5px; color: var(--text-tertiary); }
.cv-tabs {
margin-left: auto;
display: inline-flex;
gap: 2px;
background: var(--bg-void);
border: 1px solid var(--border-subtle);
border-radius: 999px;
padding: 3px;
}
.cv-tabs i { font-style: normal; font-size: 10.5px; color: var(--text-tertiary); padding: 4px 11px; border-radius: 999px; }
.cv-tabs i.on { color: var(--text-primary); background: var(--surface-muted); }
.cv-active { font-family: var(--mono); font-size: 9.5px; letter-spacing: 0.08em; color: #5ee6a0; }
.cv-cursor { font-style: normal; color: var(--accent); animation: cv-blink 1.1s steps(2, start) infinite; }
@keyframes cv-blink { to { visibility: hidden; } }
.cv-stage {
position: relative;
height: 380px;
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.055) 1px, transparent 1px);
background-size: 22px 22px;
overflow: hidden;
}
.cv-edges, .cv-edges-m { position: absolute; inset: 0; width: 100%; height: 100%; }
.cv-edges-m { display: none; }
.cv-edges path, .cv-edges-m path {
fill: none;
stroke: rgba(180, 227, 74, 0.42);
stroke-width: 2;
stroke-linecap: round;
filter: drop-shadow(0 0 4px rgba(180, 227, 74, 0.25));
}
.cv-edges .live, .cv-edges-m .live { stroke-dasharray: 7 9; animation: cv-dash 1.1s linear infinite; }
@keyframes cv-dash { to { stroke-dashoffset: -16; } }
.cv-node {
position: absolute;
width: 200px;
background: var(--surface-card);
border: 1px solid var(--border-subtle);
border-radius: 14px;
padding: 12px 12px 10px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.42);
transition: border-color 0.2s;
}
.cv-node:hover { border-color: var(--border-default); }
.cv-node.sel {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent), 0 16px 44px rgba(0, 0, 0, 0.55);
}
.cv-n1 { left: 3.2%; top: 12%; }
.cv-n2 { left: 3.2%; top: 50%; }
.cv-n3 { left: 35.2%; top: 33%; }
.cv-n4 { left: 70%; top: 7%; }
.cv-n5 { left: 70%; top: 44%; }
.cv-row { display: flex; align-items: center; gap: 10px; }
.cv-ic {
width: 34px; height: 34px;
border-radius: 9px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
font-weight: 800; font-size: 14px;
}
.cv-ic img { width: 18px; height: 18px; }
.cv-node h4 { font-size: 12.5px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.01em; }
.cv-row p { font-size: 10px; color: var(--text-tertiary); margin-top: 2px; }
.cv-addr {
display: flex; align-items: center; gap: 6px;
margin-top: 10px;
padding: 5px 8px;
background: rgba(26, 29, 34, 0.55);
border: 1px solid var(--border-subtle);
border-radius: 7px;
color: var(--text-tertiary);
}
.cv-addr span {
font-family: var(--mono); font-size: 9.5px; color: var(--text-secondary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cv-foot { display: flex; align-items: center; gap: 6px; margin-top: 10px; }
.cv-st { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.cv-st.run { background: #5ee6a0; }
.cv-st.dep { background: #f2c94c; animation: cv-pulse 1.4s ease-in-out infinite; }
@keyframes cv-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.cv-foot em { font-style: normal; font-size: 9px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #5ee6a0; }
.cv-foot code {
margin-left: auto; font-family: var(--mono); font-size: 9px; color: var(--text-tertiary);
max-width: 92px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cv-pill {
position: absolute;
display: inline-flex; align-items: center; gap: 7px;
background: rgba(19, 21, 24, 0.85);
backdrop-filter: blur(12px);
border: 1px solid var(--border-default);
border-radius: 999px;
padding: 6px 12px;
font-family: var(--mono); font-size: 10px; color: var(--text-secondary);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
}
.cv-pill.env { left: 14px; top: 14px; }
.cv-pill.env i { width: 6px; height: 6px; border-radius: 50%; background: #5ee6a0; }
.cv-pill.env svg { color: var(--text-tertiary); }
.cv-pill.zoom { left: 14px; bottom: 14px; padding: 5px 11px; gap: 9px; }
.cv-pill.zoom b { font-weight: 500; font-size: 10px; }
.cv-pill.zoom i { width: 1px; height: 12px; background: var(--border-subtle); }
.cv-pill.zoom svg { color: var(--text-tertiary); }
.cv-chip {
position: absolute; top: 14px; left: 50%; transform: translateX(-50%);
font-family: var(--mono); font-size: 9.5px; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--accent); background: var(--accent-dim);
border: 1px solid rgba(180, 227, 74, 0.3);
border-radius: 999px; padding: 4px 10px;
}
.cv-minimap {
position: absolute; right: 14px; bottom: 14px;
width: 112px; height: 68px;
border: 1px solid var(--border-default); border-radius: 8px;
background: #0e1013; overflow: hidden;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
}
.cv-minimap i { position: absolute; width: 15px; height: 10px; border-radius: 2px; background: rgba(180, 227, 74, 0.45); }
@media (max-width: 720px) {
.cv-stage { height: 360px; }
.cv-edges { display: none; }
.cv-edges-m { display: block; }
.cv-node { width: 150px; padding: 9px 9px 8px; border-radius: 11px; }
.cv-node h4 { font-size: 11px; }
.cv-ic { width: 27px; height: 27px; border-radius: 7px; }
.cv-ic img { width: 14px; height: 14px; }
.cv-addr span { font-size: 8.5px; }
.cv-foot code { display: none; }
.cv-n1 { left: 4%; top: 14%; }
.cv-n2 { left: 4%; top: 62%; }
.cv-n3 { left: 52%; top: 36%; }
.cv-n4 { left: 52%; top: 2%; }
.cv-n5 { left: 52%; top: 68%; }
.cv-back, .cv-meta, .cv-minimap, .cv-pill.zoom { display: none; }
.cv-tabs i:not(.on) { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.cv-edges .live, .cv-edges-m .live, .cv-st.dep, .cv-cursor { animation: none; }
}
+608
View File
@@ -0,0 +1,608 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Template catalog - Containr</title>
<meta name="description" content="Deploy-ready service templates for Containr. Official presets plus community templates published from live instances.">
<meta name="theme-color" content="#0c0d0f">
<link rel="icon" type="image/svg+xml" href="containr.svg">
<script>document.documentElement.classList.add('js');</script>
<link rel="stylesheet" href="style.css">
<style>
.tpl-hero { padding: 72px 0 0; }
.tpl-hero h1 {
font-size: clamp(28px, 4vw, 42px);
font-weight: 800;
letter-spacing: -0.03em;
color: var(--text-primary);
max-width: 20ch;
}
.tpl-hero .sub { margin-top: 12px; font-size: 15px; max-width: 56ch; }
.source-tag {
display: inline-flex;
align-items: center;
gap: 7px;
margin-top: 16px;
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.source-tag i {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-tertiary);
}
.source-tag.live i { background: var(--accent); }
.toolbar {
display: flex;
align-items: center;
gap: 10px;
margin: 36px 0 22px;
flex-wrap: wrap;
}
.search {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 220px;
max-width: 340px;
background: var(--surface-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 9px 13px;
color: var(--text-tertiary);
}
.search input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: 13px;
font-family: var(--sans);
}
.search input::placeholder { color: var(--text-tertiary); }
.cats { display: flex; gap: 6px; flex-wrap: wrap; }
.cat {
font-family: var(--mono);
font-size: 11px;
color: var(--text-tertiary);
background: transparent;
border: 1px solid var(--border-subtle);
border-radius: 999px;
padding: 7px 13px;
cursor: pointer;
transition: all 0.15s;
}
.cat:hover { color: var(--text-secondary); border-color: var(--border-default); }
.cat.on { color: var(--accent); border-color: rgba(180, 227, 74, 0.4); background: var(--accent-dim); }
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
padding-bottom: 96px;
}
.tcard {
text-align: left;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--surface-card);
padding: 20px;
cursor: pointer;
transition: border-color 0.15s, transform 0.15s;
font-family: var(--sans);
color: inherit;
display: flex;
flex-direction: column;
}
.tcard:hover { border-color: var(--border-default); transform: translateY(-2px); }
.tcard .top { display: flex; align-items: center; gap: 12px; }
.tcard .logo {
width: 38px;
height: 38px;
border-radius: 10px;
background: var(--surface-muted);
border: 1px solid var(--border-subtle);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
font-weight: 800;
font-size: 15px;
color: var(--accent);
}
.tcard .logo img { width: 20px; height: 20px; }
.tcard h3 { font-size: 14px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.01em; }
.badge {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 999px;
padding: 2px 8px;
flex-shrink: 0;
}
.badge-official { background: rgba(242, 201, 76, 0.12); color: #f2c94c; }
.badge-community { background: var(--accent-dim); color: var(--accent); }
.tcard .desc {
margin-top: 12px;
font-size: 12.5px;
line-height: 1.55;
color: var(--text-tertiary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
}
.tcard .meta {
margin-top: 14px;
display: flex;
align-items: center;
gap: 8px;
font-family: var(--mono);
font-size: 10.5px;
color: var(--text-tertiary);
}
.tcard .meta .catname { color: var(--accent); }
.tcard .meta .rt { margin-left: auto; }
.grid .empty {
grid-column: 1 / -1;
text-align: center;
padding: 60px 0;
color: var(--text-tertiary);
font-size: 13px;
}
.publish-card {
border: 1px dashed var(--border-default);
background: transparent;
align-items: center;
justify-content: center;
text-align: center;
gap: 8px;
}
.publish-card:hover { border-color: rgba(180, 227, 74, 0.4); }
.publish-card .plus {
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--accent-dim);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
}
.publish-card h3 { color: var(--text-secondary); }
.publish-card p { font-size: 12px; color: var(--text-tertiary); max-width: 26ch; }
/* ---------- detail modal ---------- */
.modal-back {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal {
width: 100%;
max-width: 640px;
max-height: 86vh;
overflow-y: auto;
background: var(--bg-base);
border: 1px solid var(--border-default);
border-radius: var(--radius);
padding: 26px 28px;
}
.modal .mhead { display: flex; align-items: flex-start; gap: 14px; }
.modal .mhead .logo {
width: 46px;
height: 46px;
border-radius: 12px;
background: var(--surface-muted);
border: 1px solid var(--border-subtle);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 18px;
color: var(--accent);
overflow: hidden;
flex-shrink: 0;
}
.modal .mhead .logo img { width: 24px; height: 24px; }
.modal .mhead h2 { font-size: 19px; font-weight: 800; color: var(--text-primary); letter-spacing: -0.02em; }
.modal .mhead .badges { display: flex; gap: 6px; margin-top: 6px; }
.modal .x {
margin-left: auto;
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 4px;
border-radius: 6px;
}
.modal .x:hover { color: var(--text-primary); background: var(--surface-muted); }
.modal .mdesc { margin-top: 14px; font-size: 13.5px; line-height: 1.6; color: var(--text-secondary); }
.msec { margin-top: 22px; }
.msec h4 {
font-family: var(--mono);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-tertiary);
margin-bottom: 10px;
}
.kv { display: flex; flex-wrap: wrap; gap: 8px; }
.kv span {
font-family: var(--mono);
font-size: 11px;
color: var(--text-secondary);
background: var(--bg-void);
border: 1px solid var(--border-subtle);
border-radius: 7px;
padding: 5px 10px;
}
.kv span b { color: var(--text-tertiary); font-weight: 500; }
.vars { display: grid; gap: 6px; }
.vrow {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
background: var(--bg-void);
border: 1px solid var(--border-subtle);
border-radius: 8px;
padding: 8px 12px;
}
.vrow code { font-family: var(--mono); font-size: 11.5px; color: var(--text-primary); }
.vrow .lbl { color: var(--text-tertiary); font-size: 11.5px; }
.vrow .tags { margin-left: auto; display: flex; gap: 5px; }
.vrow .tags i {
font-style: normal;
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 2px 7px;
border-radius: 999px;
background: var(--surface-muted);
color: var(--text-tertiary);
}
.vrow .tags i.req { color: var(--accent); background: var(--accent-dim); }
.vrow .tags i.sec { color: #f2c94c; background: rgba(242, 201, 76, 0.1); }
.mfoot { margin-top: 24px; display: flex; gap: 10px; flex-wrap: wrap; }
.hint { margin-top: 16px; font-size: 11.5px; color: var(--text-tertiary); line-height: 1.6; }
.hint code { font-family: var(--mono); color: var(--text-secondary); }
[hidden] { display: none !important; }
@media (max-width: 900px) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 560px) {
.grid { grid-template-columns: 1fr; }
.modal { padding: 20px; }
}
</style>
</head>
<body>
<nav>
<div class="wrap nav-inner">
<a href="index.html" class="brand">
<img src="containr.svg" alt="Containr logo">
contain<em>r</em>
</a>
<div class="nav-links">
<a href="index.html#features">Features</a>
<a href="index.html#product">Product</a>
<a href="templates.html">Templates</a>
<a href="https://github.com/Dvorinka/Containr/tree/main/docs/guides" target="_blank" rel="noopener">Docs</a>
</div>
<div class="nav-right">
<a class="gh-link" href="https://github.com/Dvorinka/Containr" target="_blank" rel="noopener" aria-label="Containr on GitHub">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
</a>
<a class="btn btn-primary" href="index.html#install">Get started</a>
</div>
</div>
</nav>
<header class="tpl-hero">
<div class="wrap">
<h1>Template catalog</h1>
<p class="sub">Deploy-ready service presets plus templates published by the community. Pick one, import it into your instance, deploy.</p>
<span class="source-tag" id="source-tag"><i></i><span id="source-label">loading catalog</span></span>
<div class="toolbar">
<label class="search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="q" type="search" placeholder="Search templates" autocomplete="off">
</label>
<div class="cats" id="cats"></div>
</div>
</div>
</header>
<main>
<div class="wrap">
<div class="grid" id="grid"></div>
</div>
</main>
<div class="modal-back" id="modal-back" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="m-title">
<div class="mhead">
<span class="logo" id="m-logo"></span>
<div>
<h2 id="m-title"></h2>
<div class="badges" id="m-badges"></div>
</div>
<button class="x" id="m-close" aria-label="Close">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<p class="mdesc" id="m-desc"></p>
<div class="msec" id="m-meta-sec">
<h4>Runtime</h4>
<div class="kv" id="m-meta"></div>
</div>
<div class="msec" id="m-vars-sec" hidden>
<h4>Variables</h4>
<div class="vars" id="m-vars"></div>
</div>
<div class="mfoot">
<button class="btn btn-primary" id="m-copy">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy template JSON
</button>
<a class="btn btn-ghost" href="https://github.com/Dvorinka/Containr/tree/main/docs/guides" target="_blank" rel="noopener">Deploy guide</a>
</div>
<p class="hint">In your instance: <code>Templates &rarr; New template &rarr; Upload .json</code> (or paste the copied JSON). Deploy it to any project from there.</p>
</div>
</div>
<footer>
<div class="wrap foot">
<span class="l"><img src="containr.svg" alt="">Containr - MIT License</span>
<nav>
<a href="https://github.com/Dvorinka/Containr" target="_blank" rel="noopener">GitHub</a>
<a href="https://github.com/Dvorinka/Containr/tree/main/docs" target="_blank" rel="noopener">Docs</a>
<a href="https://github.com/Dvorinka/Containr/blob/main/CONTRIBUTING.md" target="_blank" rel="noopener">Contribute</a>
<a href="https://github.com/Dvorinka/Containr/blob/main/SECURITY.md" target="_blank" rel="noopener">Security</a>
</nav>
</div>
</footer>
<script>
// The catalog comes from a live Containr instance when one is configured.
// Point it at an instance once with ?api=https://host (persisted in
// localStorage); with no instance configured the bundled official
// snapshot renders immediately.
const params = new URLSearchParams(location.search);
const paramApi = params.get('api');
if (paramApi) localStorage.setItem('containr-api', paramApi.replace(/\/$/, ''));
const API_BASE = (localStorage.getItem('containr-api') || '').replace(/\/$/, '');
const grid = document.getElementById('grid');
const catsEl = document.getElementById('cats');
const qEl = document.getElementById('q');
const sourceTag = document.getElementById('source-tag');
const sourceLabel = document.getElementById('source-label');
let templates = [];
let activeCat = '';
let query = '';
let current = null;
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
async function loadTemplates() {
if (API_BASE) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 4000);
try {
const res = await fetch(API_BASE + '/api/v1/templates', { signal: ctrl.signal });
if (!res.ok) throw new Error('api ' + res.status);
const data = await res.json();
const list = Array.isArray(data.templates) ? data.templates : [];
if (!list.length) throw new Error('empty catalog');
setTemplates(list, 'live: ' + API_BASE.replace(/^https?:\/\//, ''), true);
return;
} catch {
// fall through to the bundled snapshot
} finally {
clearTimeout(timer);
}
}
const res = await fetch('templates.json');
const data = await res.json();
setTemplates(data.templates || [], 'official snapshot - connect an instance with ?api=', false);
}
function setTemplates(list, label, live) {
const seen = new Set();
templates = list.filter((t) => t && t.id && !seen.has(t.id) && seen.add(t.id));
sourceLabel.textContent = label;
sourceTag.classList.toggle('live', live);
renderCats();
render();
}
function runtime(t) {
try { return (JSON.parse(t.config || '{}').runtime || ''); } catch { return ''; }
}
function cfg(t) {
try { return JSON.parse(t.config || '{}'); } catch { return {}; }
}
function vars(t) {
try { const v = JSON.parse(t.variables || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
}
const CATS = [['', 'All'], ['web', 'Web'], ['frontend', 'Frontend'], ['database', 'Databases'], ['app', 'Apps'], ['worker', 'Workers'], ['cron', 'Cron'], ['custom', 'Custom']];
function renderCats() {
const present = new Set(templates.map((t) => (t.category || 'custom').toLowerCase()));
catsEl.innerHTML = CATS.filter(([k]) => k === '' || present.has(k))
.map(([k, label]) => `<button class="cat${k === activeCat ? ' on' : ''}" data-cat="${k}">${label}</button>`)
.join('');
catsEl.querySelectorAll('.cat').forEach((b) =>
b.addEventListener('click', () => { activeCat = b.dataset.cat; renderCats(); render(); })
);
}
function logoHtml(t, size) {
const letter = esc((t.name || '?').trim().charAt(0).toUpperCase());
if (t.logo) {
return `<img src="${esc(t.logo)}" alt="" loading="lazy" onerror="this.outerHTML='${letter}'">`;
}
return letter;
}
function badge(t) {
if (t.is_official) return '<span class="badge badge-official">Official</span>';
return '<span class="badge badge-community">Community</span>';
}
function filtered() {
const q = query.toLowerCase();
return templates.filter((t) => {
if (activeCat && (t.category || '').toLowerCase() !== activeCat) return false;
if (!q) return true;
return (t.name + ' ' + (t.description || '') + ' ' + (t.category || '')).toLowerCase().includes(q);
});
}
function render() {
const list = filtered();
let html = list.map((t) => `
<button class="tcard" data-id="${esc(t.id)}">
<div class="top">
<span class="logo">${logoHtml(t)}</span>
<h3>${esc(t.name)}</h3>
${badge(t)}
</div>
<p class="desc">${esc(t.description || 'No description.')}</p>
<div class="meta"><span class="catname">${esc(t.category || 'custom')}</span><span class="rt">${esc(runtime(t))}</span></div>
</button>`).join('');
html += `
<button class="tcard publish-card" id="publish-card">
<span class="plus">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
</span>
<h3>Publish yours</h3>
<p>Any template you mark public in your instance shows up in the catalog.</p>
</button>`;
if (!list.length) {
html += '<div class="empty">Nothing matches. Try a different search or category.</div>';
}
grid.innerHTML = html;
grid.querySelectorAll('.tcard[data-id]').forEach((card) =>
card.addEventListener('click', () => openModal(templates.find((t) => t.id === card.dataset.id)))
);
document.getElementById('publish-card').addEventListener('click', () => {
document.getElementById('modal-back').hidden = false;
showPublish();
});
}
// ---------- modal ----------
const back = document.getElementById('modal-back');
const closeModal = () => (back.hidden = true);
document.getElementById('m-close').addEventListener('click', closeModal);
back.addEventListener('click', (e) => { if (e.target === back) closeModal(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
function openModal(t) {
if (!t) return;
current = t;
document.getElementById('m-logo').innerHTML = logoHtml(t);
document.getElementById('m-title').textContent = t.name;
document.getElementById('m-badges').innerHTML =
badge(t) + `<span class="badge" style="background:var(--surface-muted);color:var(--text-tertiary)">${esc(t.category || 'custom')}</span>`;
document.getElementById('m-desc').textContent = t.description || 'No description.';
const c = cfg(t);
const meta = [];
if (c.type) meta.push(['type', c.type]);
if (c.runtime) meta.push(['runtime', c.runtime]);
if (c.port) meta.push(['port', c.port]);
if (c.build_command) meta.push(['build', c.build_command]);
if (c.start_command) meta.push(['start', c.start_command]);
if (c.health_check) meta.push(['health', c.health_check]);
document.getElementById('m-meta').innerHTML = meta.length
? meta.map(([k, v]) => `<span><b>${esc(k)}</b> ${esc(String(v))}</span>`).join('')
: '<span>self-contained image</span>';
const vs = vars(t);
const varsSec = document.getElementById('m-vars-sec');
varsSec.hidden = vs.length === 0;
document.getElementById('m-vars').innerHTML = vs.map((v) => `
<div class="vrow">
<code>${esc(v.key)}</code>
<span class="lbl">${esc(v.label || '')}</span>
<span class="tags">
${v.required ? '<i class="req">required</i>' : ''}
${v.secret ? '<i class="sec">secret</i>' : ''}
</span>
</div>`).join('');
back.hidden = false;
}
function showPublish() {
document.getElementById('m-logo').innerHTML = '+';
document.getElementById('m-title').textContent = 'Publish a template';
document.getElementById('m-badges').innerHTML = '<span class="badge badge-community">Community</span>';
document.getElementById('m-desc').textContent =
'Templates are published from inside a running Containr instance. Create a template (or edit one you own), tick "List in the public catalog", and it becomes browsable and deployable by anyone who can reach that instance.';
document.getElementById('m-meta').innerHTML =
'<span><b>1</b> Templates &rarr; New template</span><span><b>2</b> paste or upload JSON</span><span><b>3</b> tick public catalog</span>';
document.getElementById('m-vars-sec').hidden = true;
current = null;
}
document.getElementById('m-copy').addEventListener('click', async (e) => {
if (!current) return;
const c = cfg(current);
const payload = JSON.stringify({
name: current.name,
description: current.description || '',
category: current.category || 'custom',
logo: current.logo || '',
config: c,
variables: vars(current),
}, null, 2);
try { await navigator.clipboard.writeText(payload); } catch {}
const btn = e.currentTarget;
btn.style.color = 'var(--accent)';
setTimeout(() => (btn.style.color = ''), 1200);
});
let debounce;
qEl.addEventListener('input', () => {
clearTimeout(debounce);
debounce = setTimeout(() => { query = qEl.value.trim(); render(); }, 120);
});
loadTemplates();
</script>
</body>
</html>
+394
View File
@@ -0,0 +1,394 @@
{
"templates": [
{
"id": "tpl-nodejs",
"name": "Node.js Application",
"description": "Generic Node.js application with automatic dependency detection",
"category": "web",
"logo": "https://cdn.simpleicons.org/node.js",
"config": "{\"type\":\"web\",\"runtime\":\"node\",\"build_command\":\"npm install && npm run build\",\"start_command\":\"npm start\",\"port\":3000,\"health_check\":\"/health\"}",
"variables": "[{\"key\":\"NODE_ENV\",\"label\":\"Node Environment\",\"default\":\"production\",\"required\":false,\"secret\":false},{\"key\":\"NPM_TOKEN\",\"label\":\"NPM Token\",\"default\":\"\",\"required\":false,\"secret\":true}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"id": "tpl-react",
"name": "React Application",
"description": "React single-page application with Vite",
"category": "frontend",
"logo": "https://cdn.simpleicons.org/react",
"config": "{\"type\":\"web\",\"runtime\":\"node\",\"build_command\":\"npm install && npm run build\",\"start_command\":\"npx serve -s dist\",\"port\":3000}",
"variables": "[{\"key\":\"VITE_API_URL\",\"label\":\"API URL\",\"default\":\"\",\"required\":true,\"secret\":false}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"id": "tpl-python",
"name": "Python Application",
"description": "Python application with FastAPI/Flask support",
"category": "web",
"logo": "https://cdn.simpleicons.org/python",
"config": "{\"type\":\"web\",\"runtime\":\"python\",\"build_command\":\"pip install -r requirements.txt\",\"start_command\":\"python main.py\",\"port\":8000}",
"variables": "[{\"key\":\"PYTHON_VERSION\",\"label\":\"Python Version\",\"default\":\"3.11\",\"required\":false,\"secret\":false}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"id": "tpl-go",
"name": "Go Application",
"description": "Go backend service",
"category": "web",
"logo": "https://cdn.simpleicons.org/go",
"config": "{\"type\":\"web\",\"runtime\":\"go\",\"build_command\":\"go build -o app .\",\"start_command\":\"./app\",\"port\":8080}",
"variables": "[{\"key\":\"GO_VERSION\",\"label\":\"Go Version\",\"default\":\"1.21\",\"required\":false,\"secret\":false}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"id": "tpl-postgres",
"name": "PostgreSQL Database",
"description": "Managed PostgreSQL database",
"category": "database",
"logo": "https://cdn.simpleicons.org/postgresql",
"config": "{\"type\":\"database\",\"runtime\":\"postgres\",\"port\":5432}",
"variables": "[{\"key\":\"POSTGRES_USER\",\"label\":\"Username\",\"default\":\"postgres\",\"required\":true,\"secret\":false},{\"key\":\"POSTGRES_PASSWORD\",\"label\":\"Password\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"POSTGRES_DB\",\"label\":\"Database Name\",\"default\":\"app\",\"required\":true,\"secret\":false}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"id": "tpl-redis",
"name": "Redis Cache",
"description": "In-memory data store",
"category": "database",
"logo": "https://cdn.simpleicons.org/redis",
"config": "{\"type\":\"database\",\"runtime\":\"redis\",\"port\":6379}",
"variables": "[{\"key\":\"REDIS_PASSWORD\",\"label\":\"Password\",\"default\":\"\",\"required\":false,\"secret\":true}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"id": "tpl-mongodb",
"name": "MongoDB Database",
"description": "NoSQL document database",
"category": "database",
"logo": "https://cdn.simpleicons.org/mongodb",
"config": "{\"type\":\"database\",\"runtime\":\"mongodb\",\"port\":27017}",
"variables": "[{\"key\":\"MONGO_INITDB_ROOT_USERNAME\",\"label\":\"Root Username\",\"default\":\"admin\",\"required\":true,\"secret\":false},{\"key\":\"MONGO_INITDB_ROOT_PASSWORD\",\"label\":\"Root Password\",\"default\":\"\",\"required\":true,\"secret\":true}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"id": "tpl-mysql",
"name": "MySQL Database",
"description": "Managed MySQL database service",
"category": "database",
"logo": "https://cdn.simpleicons.org/mysql",
"config": "{\"type\":\"database\",\"runtime\":\"mysql\",\"port\":3306}",
"variables": "[{\"key\":\"MYSQL_DATABASE\",\"label\":\"Database Name\",\"default\":\"app\",\"required\":true,\"secret\":false},{\"key\":\"MYSQL_USER\",\"label\":\"Username\",\"default\":\"app\",\"required\":true,\"secret\":false},{\"key\":\"MYSQL_PASSWORD\",\"label\":\"User Password\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"MYSQL_ROOT_PASSWORD\",\"label\":\"Root Password\",\"default\":\"\",\"required\":true,\"secret\":true}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"id": "tpl-mariadb",
"name": "MariaDB Database",
"description": "Managed MariaDB database service",
"category": "database",
"logo": "https://cdn.simpleicons.org/mariadb",
"config": "{\"type\":\"database\",\"runtime\":\"mariadb\",\"port\":3306}",
"variables": "[{\"key\":\"MARIADB_DATABASE\",\"label\":\"Database Name\",\"default\":\"app\",\"required\":true,\"secret\":false},{\"key\":\"MARIADB_USER\",\"label\":\"Username\",\"default\":\"app\",\"required\":true,\"secret\":false},{\"key\":\"MARIADB_PASSWORD\",\"label\":\"User Password\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"MARIADB_ROOT_PASSWORD\",\"label\":\"Root Password\",\"default\":\"\",\"required\":true,\"secret\":true}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"id": "tpl-clickhouse",
"name": "ClickHouse Database",
"description": "Column-oriented analytics database",
"category": "database",
"logo": "https://cdn.simpleicons.org/clickhouse",
"config": "{\"type\":\"database\",\"runtime\":\"clickhouse\",\"port\":8123}",
"variables": "[{\"key\":\"CLICKHOUSE_DB\",\"label\":\"Database Name\",\"default\":\"app\",\"required\":false,\"secret\":false},{\"key\":\"CLICKHOUSE_USER\",\"label\":\"Username\",\"default\":\"default\",\"required\":false,\"secret\":false},{\"key\":\"CLICKHOUSE_PASSWORD\",\"label\":\"Password\",\"default\":\"\",\"required\":false,\"secret\":true}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"id": "tpl-dragonfly",
"name": "Dragonfly Database",
"description": "Redis-compatible in-memory data store powered by Dragonfly",
"category": "database",
"logo": "https://cdn.simpleicons.org/redis",
"config": "{\"type\":\"database\",\"runtime\":\"dragonfly\",\"port\":6379}",
"variables": "[{\"key\":\"DRAGONFLY_PASSWORD\",\"label\":\"Password\",\"default\":\"\",\"required\":false,\"secret\":true}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"id": "tpl-worker",
"name": "Background Worker",
"description": "Background job processing service",
"category": "worker",
"logo": "",
"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}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"id": "tpl-cron",
"name": "Cron Job",
"description": "Scheduled task runner",
"category": "cron",
"logo": "",
"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}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"id": "tpl-docker",
"name": "Docker Image",
"description": "Deploy from any Docker image",
"category": "custom",
"logo": "https://cdn.simpleicons.org/docker",
"config": "{\"type\":\"web\",\"runtime\":\"docker\",\"port\":80}",
"variables": "[{\"key\":\"IMAGE\",\"label\":\"Docker Image\",\"default\":\"\",\"required\":true,\"secret\":false},{\"key\":\"TAG\",\"label\":\"Image Tag\",\"default\":\"latest\",\"required\":false,\"secret\":false}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"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}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"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}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"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}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"id": "tpl-uptime-kuma",
"name": "Uptime Kuma",
"description": "Self-hosted monitoring tool for services and websites",
"category": "app",
"logo": "https://cdn.simpleicons.org/uptimekuma",
"config": "{\"type\":\"web\",\"runtime\":\"louislam/uptime-kuma:1\",\"port\":3001,\"health_check\":\"/\"}",
"variables": "[]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"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}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"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": "[]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"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}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"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}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"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}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"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}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"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}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"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}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"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": "[]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"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}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"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}]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
},
{
"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": "[]",
"is_official": true,
"is_public": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
]
}