Compare commits

..
6 Commits
Author SHA1 Message Date
Tomas DvorakandDevin 9a2aa7267b fix(ci): recreate release instead of --clobber upload
gh release upload --clobber races GitHub's asset tombstones and 422s
intermittently on reruns. Delete-then-create is deterministic; the
git tag is kept.

Authored By: TDvorak <[email protected]>

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

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

Authored By: TDvorak <[email protected]>

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

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

Authored By: TDvorak <[email protected]>

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

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

Authored By: TDvorak <[email protected]>

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

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

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

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

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

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

No files matched your search

+6
View File
@@ -64,6 +64,12 @@ BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001
# Extra trusted origins (comma-separated). Same-origin traffic is trusted # Extra trusted origins (comma-separated). Same-origin traffic is trusted
# automatically, so this only needs entries for cross-origin callers. # automatically, so this only needs entries for cross-origin callers.
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8082 BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8082
# Platform owner bootstrap — creates/promotes the admin account on startup.
# Keep the real values in a local untracked file, never in committed env files.
ADMIN_EMAIL=[email protected]
ADMIN_PASSWORD=
ADMIN_NAME=Platform Admin
# Optional explicit auth DB settings (recommended when password contains URL special chars) # Optional explicit auth DB settings (recommended when password contains URL special chars)
DB_HOST=postgres DB_HOST=postgres
DB_PORT=5432 DB_PORT=5432
+93
View File
@@ -222,3 +222,96 @@ jobs:
ignore-unfixed: true ignore-unfixed: true
vuln-type: os,library vuln-type: os,library
severity: CRITICAL,HIGH severity: CRITICAL,HIGH
release:
name: Release
runs-on: ubuntu-latest
needs: [docker]
if: github.event_name != 'pull_request'
permissions:
contents: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# Conventional-commit bump. Tags pushed with GITHUB_TOKEN do not
# retrigger this workflow, so images are retagged below instead.
- name: Bump version and push tag
if: github.ref == 'refs/heads/main'
id: tag_version
uses: mathieudutour/[email protected]
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
default_bump: false
- name: Resolve release tag
id: release
run: |
if [ "${{ github.ref_type }}" = "tag" ]; then
tag="${GITHUB_REF_NAME}"
else
tag="${{ steps.tag_version.outputs.new_tag }}"
fi
if [ -z "$tag" ]; then
echo "No releasable commits since last tag - skipping release."
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup Go
if: steps.release.outputs.tag != ''
uses: actions/setup-go@v5
with:
go-version-file: app/backend/go.mod
cache: true
- name: Build CLI binaries
if: steps.release.outputs.tag != ''
working-directory: app/backend
env:
VERSION: ${{ steps.release.outputs.tag }}
run: bash ../../scripts/build-cli.sh
- name: Checksums
if: steps.release.outputs.tag != ''
working-directory: app/backend/bin
run: sha256sum containr-* > checksums.txt
- name: Login to GHCR
if: steps.release.outputs.tag != '' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Tag pushes from github-tag-action do not trigger workflows, so the
# release job retags the just-published :main images in-registry.
- name: Tag release images in GHCR
if: steps.release.outputs.tag != '' && github.ref == 'refs/heads/main'
env:
TAG: ${{ steps.release.outputs.tag }}
run: |
repo="${GITHUB_REPOSITORY,,}"
for img in backend frontend; do
docker buildx imagetools create \
--tag "ghcr.io/${repo}-${img}:${TAG}" \
--tag "ghcr.io/${repo}-${img}:latest" \
"ghcr.io/${repo}-${img}:main"
done
- name: Create GitHub Release
if: steps.release.outputs.tag != ''
working-directory: app/backend/bin
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.release.outputs.tag }}
run: |
# Delete-then-create keeps reruns idempotent: --clobber uploads
# race GitHub's asset tombstones and intermittently 422. The git
# tag itself is kept (no --cleanup-tag).
gh release delete "$TAG" --yes 2>/dev/null || true
gh release create "$TAG" --title "$TAG" --generate-notes \
containr-* checksums.txt
+45 -9
View File
@@ -28,6 +28,11 @@ and deploy — no Kubernetes, no cloud bill. Services appear as nodes with
auto-inferred connections; logs, metrics, builds, and rollbacks are one auto-inferred connections; logs, metrics, builds, and rollbacks are one
click away. click away.
The platform doubles as a public showcase: visitors browse approved projects
and the template catalog without an account, while owners manage everything
behind sign-in and a platform admin approves, curates, and moderates from a
dedicated console.
## Screenshots ## Screenshots
| Projects dashboard | Project canvas | | Projects dashboard | Project canvas |
@@ -41,16 +46,32 @@ click away.
## Features ## Features
- **Project canvas** — visual service topology, groups, drag/drop, auto-inferred connections, right-click lifecycle actions - **Project canvas** — visual service topology, groups, drag/drop, auto-inferred connections, right-click lifecycle actions
- **Public project catalog** — approved projects are browsable anonymously; new projects stay private until an admin approves them
- **Admin console** (`/admin`) — platform stats, project approval/edit/delete, user admin management
- **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references - **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references
- **Deployments & builds** — Docker deploys with history, logs, image rollback, live build status - **Deployments & builds** — Docker deploys with history, image rollback, live build status
- **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page - **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page
- **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App - **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App
- **Managed databases** — one-click provisioning with backup/restore - **Managed databases** — create PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Dragonfly, or ClickHouse directly from `/databases`, with backup/restore
- **Template catalog** — 30 official templates (NocoDB, Plausible, MinIO, n8n, …) plus user-defined templates with JSON upload and owner-scoped edit/delete
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry - **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
- **Templates, cron, previews** — service catalog, scheduled jobs, preview deploys - **HA & scaling** — failover policies, node agents, autoscaling rules
- **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vuln scans - **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vulnerability scans
- **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel - **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
## Access model
| Audience | Can do |
| --- | --- |
| **Anonymous** | Browse approved projects, services, templates, databases, HA status, docs |
| **Signed-in user** | Create/manage own projects, services, databases, templates; view own logs, variables, builds, deployments |
| **Admin** | Everything above plus project approval/moderation, user admin flags, HA manager controls, agent tokens, platform settings |
Logs, variables, exec consoles, and database connection URLs are never
public — they require owner, member, or admin access regardless of a
project's public visibility.
## Quick Start ## Quick Start
Requires Docker with the Compose plugin, `openssl`, and `curl`: Requires Docker with the Compose plugin, `openssl`, and `curl`:
@@ -62,11 +83,22 @@ curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh |
Installs into `./containr`, generates secrets, pulls the published `:latest` Installs into `./containr`, generates secrets, pulls the published `:latest`
images, and starts the stack — UI at `http://localhost:3000`, API at images, and starts the stack — UI at `http://localhost:3000`, API at
`http://localhost:8082`. Non-interactive; re-runs never overwrite `.env`. `http://localhost:8082`. Non-interactive; re-runs never overwrite `.env`.
On first visit you create the owner account (email/password); registration
then closes automatically. The owner can reopen it and configure a Cloudflare
Tunnel token under **Settings → Platform** — in-app values override env vars.
Override with env vars: The platform admin is provisioned at startup from environment variables:
```bash
ADMIN_EMAIL=[email protected]
ADMIN_PASSWORD=<generated> # keep in a local untracked file
ADMIN_NAME=Your Name # optional
```
Both the local account and the Better Auth session account are created, so
either sign-in path works. Without `ADMIN_*`, the first registered account
becomes owner; registration then closes automatically. The owner can reopen
it and configure a Cloudflare Tunnel token under **Settings → Platform** —
in-app values override env vars.
Override install defaults with env vars:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh | \ curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh | \
@@ -91,10 +123,14 @@ docs/ Guides, OpenAPI spec, design documents
``` ```
All configuration is via environment variables — see `.env.example`. Never All configuration is via environment variables — see `.env.example`. Never
commit `.env` or `.env.prod`; if a secret was ever committed, rotate it. commit `.env` or `.env.prod`; keep generated admin credentials in a local
untracked file (e.g. `admin-credentials.local`). If a secret was ever
committed, rotate it.
## Documentation ## Documentation
- In-app: the landing page and `/docs` render searchable docs — synced from
this repository, with a bundled snapshot so they are never blank offline
- [docs/guides/](docs/guides/) — setup, deployment, Cloudflare, autoscaling - [docs/guides/](docs/guides/) — setup, deployment, Cloudflare, autoscaling
- [docs/api/openapi.yaml](docs/api/openapi.yaml) — API contract - [docs/api/openapi.yaml](docs/api/openapi.yaml) — API contract
- [docs/developers/onboarding.md](docs/developers/onboarding.md) — contributor onboarding - [docs/developers/onboarding.md](docs/developers/onboarding.md) — contributor onboarding
+8
View File
@@ -6,7 +6,15 @@ import (
"os" "os"
) )
// Injected at build time via -ldflags (see scripts/build-cli.sh).
var (
Version = "dev"
BuildTime = "unknown"
GitCommit = "unknown"
)
func main() { func main() {
cli.SetVersion(fmt.Sprintf("%s (%s, %s)", Version, GitCommit, BuildTime))
if err := cli.Run(); err != nil { if err := cli.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
+3
View File
@@ -86,6 +86,9 @@ func main() {
}() }()
} }
// Provision the platform owner account (idempotent, non-fatal).
api.EnsureAdminAccount(context.Background(), db, cfg)
// Setup Gin router // Setup Gin router
if cfg.IsProduction() { if cfg.IsProduction() {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
+295
View File
@@ -0,0 +1,295 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"strings"
"time"
"containr/internal/config"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// ---------------------------------------------------------------------------
// Admin account bootstrap
// ---------------------------------------------------------------------------
// EnsureAdminAccount provisions the platform owner account from
// ADMIN_EMAIL/ADMIN_PASSWORD (optional ADMIN_NAME). It is idempotent: existing
// users are simply promoted to admin. Both the local users row (legacy JWT
// login) and the Better Auth account (cookie sessions) are created so either
// sign-in path works. Failures are logged, never fatal — the platform must
// boot even when auth is misconfigured.
func EnsureAdminAccount(ctx context.Context, db *database.DB, cfg *config.Config) {
email := strings.ToLower(strings.TrimSpace(cfg.AdminEmail))
password := cfg.AdminPassword
if email == "" || password == "" {
return
}
if db == nil || db.DB == nil {
log.Println("Admin bootstrap skipped: database unavailable")
return
}
name := strings.TrimSpace(cfg.AdminName)
if name == "" {
name = "Platform Admin"
}
// Local users row (owns resources, is_admin flag, legacy login hash).
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Printf("Admin bootstrap failed: %v", err)
return
}
var userID string
err = db.QueryRowContext(ctx, `
INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, true)
ON CONFLICT (email) DO UPDATE
SET is_admin = true, updated_at = NOW()
RETURNING id
`, email, string(hashed), name).Scan(&userID)
if err != nil {
log.Printf("Admin bootstrap failed to upsert user %s: %v", email, err)
return
}
// Better Auth account (cookie-session sign-in). Duplicate sign-up returns
// an error which we treat as "already provisioned".
if err := ensureBetterAuthUser(ctx, name, email, password); err != nil {
if errors.Is(err, errBetterAuthUserExists) {
log.Printf("Admin account %s already exists in auth service; ensured admin flag", email)
} else {
log.Printf("Warning: admin bootstrap could not create auth user for %s: %v", email, err)
}
return
}
log.Printf("Admin account provisioned: %s", email)
}
// ensureBetterAuthUser calls the Better Auth sign-up endpoint directly. Unlike
// createBetterAuthUser it runs outside a request context.
func ensureBetterAuthUser(ctx context.Context, name, email, password string) error {
endpoint, err := betterAuthSignUpURL()
if err != nil {
return err
}
body, err := json.Marshal(gin.H{"name": name, "email": email, "password": password})
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
if token := strings.TrimSpace(os.Getenv("BETTER_AUTH_INTERNAL_TOKEN")); token != "" {
request.Header.Set("X-Containr-Auth-Internal", token)
}
response, err := (&http.Client{Timeout: 10 * time.Second}).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices {
return nil
}
var payload map[string]interface{}
_ = json.NewDecoder(response.Body).Decode(&payload)
message := ""
if raw, ok := payload["message"].(string); ok {
message = strings.ToLower(raw)
} else if raw, ok := payload["error"].(string); ok {
message = strings.ToLower(raw)
}
if strings.Contains(message, "exist") || strings.Contains(message, "already") {
return errBetterAuthUserExists
}
if message == "" {
message = http.StatusText(response.StatusCode)
}
return errors.New(message)
}
// ---------------------------------------------------------------------------
// Admin API
// ---------------------------------------------------------------------------
type adminUserRow struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
CreatedAt string `json:"created_at"`
}
func handleAdminOverview(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
ctx := c.Request.Context()
count := func(query string) int {
var n int
if err := db.QueryRowContext(ctx, query).Scan(&n); err != nil {
return 0
}
return n
}
pendingRows, err := queries.ListPendingProjects(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list pending projects"})
return
}
pending := make([]Project, 0, len(pendingRows))
for _, row := range pendingRows {
pending = append(pending, mapSQLCProject(row))
}
c.JSON(http.StatusOK, gin.H{
"stats": gin.H{
"users": count(`SELECT COUNT(*) FROM users`),
"projects": count(`SELECT COUNT(*) FROM projects`),
"pending_projects": count(`SELECT COUNT(*) FROM projects WHERE is_approved = false`),
"services": count(`SELECT COUNT(*) FROM services`),
"running_services": count(`SELECT COUNT(*) FROM services WHERE status = 'running'`),
"databases": count(`SELECT COUNT(*) FROM database_services`),
"agents": count(`SELECT COUNT(*) FROM node_agents`),
"templates": count(`SELECT COUNT(*) FROM service_templates`),
"user_templates": count(`SELECT COUNT(*) FROM service_templates WHERE is_official = false`),
"cron_jobs": count(`SELECT COUNT(*) FROM cron_jobs`),
"deployments_total": count(`SELECT COUNT(*) FROM deployments`),
},
"pending_projects": pending,
})
}
func handleAdminListUsers(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
rows, err := db.QueryContext(c.Request.Context(), `
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '')
FROM users
ORDER BY created_at ASC
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"})
return
}
defer rows.Close()
users := make([]adminUserRow, 0)
for rows.Next() {
var u adminUserRow
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.IsAdmin, &u.CreatedAt); err != nil {
continue
}
users = append(users, u)
}
c.JSON(http.StatusOK, gin.H{"users": users})
}
func handleAdminSetUserAdmin(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
targetID := strings.TrimSpace(c.Param("id"))
if _, err := uuid.Parse(targetID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
}
var req struct {
IsAdmin bool `json:"is_admin"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
callerID := optionalUserID(c)
if callerID == targetID && !req.IsAdmin {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot revoke your own admin access"})
return
}
result, err := db.ExecContext(c.Request.Context(),
`UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2`,
req.IsAdmin, targetID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
return
}
if n, _ := result.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
LogAudit(callerID, "user", targetID, "set_admin", map[string]interface{}{"is_admin": req.IsAdmin})
c.JSON(http.StatusOK, gin.H{"message": "User updated"})
}
func handleAdminSetProjectApproval(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
projectID, ok := parseProjectIDParam(c)
if !ok {
return
}
var req struct {
IsApproved bool `json:"is_approved"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
rows, err := queries.SetProjectApproved(c.Request.Context(), sqlcdb.SetProjectApprovedParams{
IsApproved: req.IsApproved,
ProjectID: projectID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project"})
return
}
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
action := "unapprove"
if req.IsApproved {
action = "approve"
}
LogAudit(optionalUserID(c), "project", projectID.String(), action, nil)
row, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
ProjectID: projectID,
UserID: optionalUserUUID(c),
IsAdmin: true,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "Project updated"})
return
}
c.JSON(http.StatusOK, gin.H{"project": mapSQLCProject(row)})
}
+4 -2
View File
@@ -1217,8 +1217,10 @@ func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
} }
} }
// SetupRoutes registers authenticated dashboard/control agent routes. // SetupAdminRoutes registers dashboard/control agent routes. Command
func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) { // history and container details can leak sensitive output, so these stay
// admin-only rather than joining the public read group.
func (h *NodeAgentHandler) SetupAdminRoutes(router *gin.RouterGroup) {
tokens := router.Group("/agent-tokens") tokens := router.Group("/agent-tokens")
{ {
tokens.POST("", h.CreateAgentToken) tokens.POST("", h.CreateAgentToken)
+107
View File
@@ -3,7 +3,10 @@ package api
import ( import (
"net/http" "net/http"
"containr/internal/database"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
func requireAuthenticatedUserID(c *gin.Context) (string, bool) { func requireAuthenticatedUserID(c *gin.Context) (string, bool) {
@@ -21,3 +24,107 @@ func requireAuthenticatedUserID(c *gin.Context) (string, bool) {
return userID, true return userID, true
} }
// optionalUserID returns the resolved user id or "" for anonymous callers on
// OptionalAuth routes. Never writes a response.
func optionalUserID(c *gin.Context) string {
userIDValue, exists := c.Get("user_id")
if !exists {
return ""
}
userID, _ := userIDValue.(string)
return userID
}
// optionalUserUUID is the uuid variant of optionalUserID; anonymous callers
// get uuid.Nil, which matches no owner_id and therefore only sees approved
// public resources.
func optionalUserUUID(c *gin.Context) uuid.UUID {
userID := optionalUserID(c)
parsed, err := uuid.Parse(userID)
if err != nil {
return uuid.Nil
}
return parsed
}
// contextIsAdmin reports whether the resolved caller is a platform admin.
func contextIsAdmin(c *gin.Context) bool {
return c.GetBool("is_admin")
}
// projectReadAccess reports (exists, allowedToRead). Approved projects are
// public; unapproved ones are visible to the owner, project members, and
// platform admins only.
func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
var isApproved bool
var ownerID string
err := db.QueryRow(
`SELECT is_approved, owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&isApproved, &ownerID)
if err != nil {
return false, false
}
if isApproved || contextIsAdmin(c) {
return true, true
}
userID := optionalUserID(c)
if userID == "" {
return true, false
}
if ownerID == userID {
return true, true
}
var member bool
if err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
projectID, userID,
).Scan(&member); err == nil && member {
return true, true
}
return true, false
}
// projectManageAccess reports (exists, allowedToOperate). Unlike
// projectReadAccess, approval does NOT grant access — only the owner,
// project members, and platform admins may reach secret-adjacent data
// such as variables and runtime logs.
func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
var ownerID string
err := db.QueryRow(
`SELECT owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&ownerID)
if err != nil {
return false, false
}
if contextIsAdmin(c) {
return true, true
}
userID := optionalUserID(c)
if userID == "" {
return true, false
}
if ownerID == userID {
return true, true
}
var member bool
if err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
projectID, userID,
).Scan(&member); err == nil && member {
return true, true
}
return true, false
}
// serviceProjectID resolves the project that owns a service.
func serviceProjectID(db *database.DB, serviceID uuid.UUID) (uuid.UUID, bool) {
var projectID uuid.UUID
err := db.QueryRow(`SELECT project_id FROM services WHERE id = $1`, serviceID).Scan(&projectID)
if err != nil {
return uuid.Nil, false
}
return projectID, true
}
+38 -25
View File
@@ -65,9 +65,23 @@ type UpdateCronJobRequest struct {
Retention int `json:"retention"` Retention int `json:"retention"`
} }
// cronJobProjectID resolves the project a cron job belongs to.
func cronJobProjectID(db *database.DB, jobID string) (uuid.UUID, bool) {
var projectID uuid.UUID
err := db.QueryRow(
`SELECT cj.project_id FROM cron_jobs cj WHERE cj.id = $1`,
jobID,
).Scan(&projectID)
if err != nil {
return uuid.Nil, false
}
return projectID, true
}
func handleGetCronJobs(c *gin.Context) { func handleGetCronJobs(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string) userID := optionalUserUUID(c)
isAdmin := contextIsAdmin(c)
projectID := c.Query("project_id") projectID := c.Query("project_id")
serviceID := c.Query("service_id") serviceID := c.Query("service_id")
@@ -76,8 +90,9 @@ func handleGetCronJobs(c *gin.Context) {
cj.retention, cj.created_at, cj.updated_at cj.retention, cj.created_at, cj.updated_at
FROM cron_jobs cj FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id JOIN projects p ON cj.project_id = p.id
WHERE p.owner_id = $1` WHERE (p.is_approved OR p.owner_id = $1 OR $2::bool
args := []interface{}{userID} OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))`
args := []interface{}{userID, isAdmin}
if projectID != "" { if projectID != "" {
args = append(args, projectID) args = append(args, projectID)
@@ -130,7 +145,7 @@ func handleCreateCronJob(c *gin.Context) {
req.ServiceID, req.ServiceID,
).Scan(&ownerCheck) ).Scan(&ownerCheck)
if err != nil || ownerCheck != userID { if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
@@ -184,30 +199,32 @@ func handleCreateCronJob(c *gin.Context) {
func handleGetCronJob(c *gin.Context) { func handleGetCronJob(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
jobID := c.Param("id") jobID := c.Param("id")
var job CronJob var job CronJob
var ownerCheck string
err := db.QueryRow( err := db.QueryRow(
`SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone, `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output, cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at, p.owner_id cj.retention, cj.created_at, cj.updated_at
FROM cron_jobs cj FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`, WHERE cj.id = $1`,
jobID, jobID,
).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone, ).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput, &job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
&job.Retention, &job.CreatedAt, &job.UpdatedAt, &ownerCheck) &job.Retention, &job.CreatedAt, &job.UpdatedAt)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return return
} }
if ownerCheck != userID { projectID, found := cronJobProjectID(db, jobID)
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return return
} }
@@ -233,7 +250,7 @@ func handleUpdateCronJob(c *gin.Context) {
jobID, jobID,
).Scan(&ownerCheck) ).Scan(&ownerCheck)
if err != nil || ownerCheck != userID { if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
@@ -303,7 +320,7 @@ func handleDeleteCronJob(c *gin.Context) {
jobID, jobID,
).Scan(&ownerCheck) ).Scan(&ownerCheck)
if err != nil || ownerCheck != userID { if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
@@ -321,19 +338,15 @@ func handleDeleteCronJob(c *gin.Context) {
func handleGetCronExecutions(c *gin.Context) { func handleGetCronExecutions(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
jobID := c.Param("id") jobID := c.Param("id")
var ownerCheck string projectID, found := cronJobProjectID(db, jobID)
err := db.QueryRow( if !found {
`SELECT p.owner_id FROM cron_jobs cj c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
JOIN projects p ON cj.project_id = p.id return
WHERE cj.id = $1`, }
jobID, if _, allowed := projectReadAccess(c, db, projectID); !allowed {
).Scan(&ownerCheck) c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
@@ -380,7 +393,7 @@ func handleTriggerCronJob(c *gin.Context) {
jobID, jobID,
).Scan(&job.ServiceID, &job.Command, &job.Schedule, &job.Timezone, &job.Retention, &ownerCheck) ).Scan(&job.ServiceID, &job.Command, &job.Schedule, &job.Timezone, &job.Retention, &ownerCheck)
if err != nil || ownerCheck != userID { if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
+57 -73
View File
@@ -41,6 +41,7 @@ const (
// DatabaseService represents a managed database service // DatabaseService represents a managed database service
type DatabaseService struct { type DatabaseService struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
OwnerID string `json:"-" db:"user_id"`
Name string `json:"name" db:"name"` Name string `json:"name" db:"name"`
Type string `json:"type" db:"type"` // postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly Type string `json:"type" db:"type"` // postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly
Status string `json:"status" db:"status"` // running, stopped, building, error Status string `json:"status" db:"status"` // running, stopped, building, error
@@ -168,13 +169,14 @@ func NewDatabaseHandler(db *sql.DB, dockerClient *docker.Client) *DatabaseHandle
} }
} }
// GetDatabases returns all database services for a user // GetDatabases returns database services. The platform is public-read: every
// caller sees the fleet, but connection URLs (which may embed credentials)
// are only exposed to admins.
func (h *DatabaseHandler) GetDatabases(c *gin.Context) { func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c) userID := optionalUserID(c)
if !ok { isAdmin := contextIsAdmin(c)
return
} rows, err := h.queries.ListAllDatabaseServices(c.Request.Context())
rows, err := h.queries.ListDatabaseServicesByUser(c.Request.Context(), userID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch databases"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch databases"})
return return
@@ -182,17 +184,23 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
databases := make([]DatabaseService, 0, len(rows)) databases := make([]DatabaseService, 0, len(rows))
for _, row := range rows { for _, row := range rows {
db := mapDatabaseServiceListRow(row) db := mapDatabaseServiceRow(row)
db = h.reconcileManagedDatabaseState(c.Request.Context(), db) db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db) db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil { privileged := isAdmin || (userID != "" && db.OwnerID == userID)
db.Backups = backupConfig if privileged {
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil {
db.Backups = backupConfig
} else {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
}
db.Settings = h.generateMockSettings()
db.ConnectionURL = h.resolveConnectionURL(db)
} else { } else {
db.Backups = h.generateMockBackupConfig() db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
db.ConnectionURL = ""
} }
db.Settings = h.generateMockSettings()
db.ConnectionURL = h.resolveConnectionURL(db)
databases = append(databases, db) databases = append(databases, db)
} }
@@ -202,15 +210,11 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
// GetDatabase returns a specific database service // GetDatabase returns a specific database service
func (h *DatabaseHandler) GetDatabase(c *gin.Context) { func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c) userID := optionalUserID(c)
if !ok { isAdmin := contextIsAdmin(c)
return
}
databaseID := c.Param("id") databaseID := c.Param("id")
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{ row, err := h.queries.GetDatabaseServiceByID(c.Request.Context(), databaseID)
ID: databaseID,
UserID: userID,
})
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
@@ -219,21 +223,40 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"})
return return
} }
db := mapDatabaseServiceGetRow(row) db := mapDatabaseServiceRow(row)
db = h.reconcileManagedDatabaseState(c.Request.Context(), db) db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db) db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil { privileged := isAdmin || (userID != "" && db.OwnerID == userID)
db.Backups = backupConfig if privileged {
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil {
db.Backups = backupConfig
} else {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
}
db.Settings = h.generateMockSettings()
db.ConnectionURL = h.resolveConnectionURL(db)
} else { } else {
db.Backups = h.generateMockBackupConfig() db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
db.ConnectionURL = ""
} }
db.Settings = h.generateMockSettings()
db.ConnectionURL = h.resolveConnectionURL(db)
c.JSON(http.StatusOK, db) c.JSON(http.StatusOK, db)
} }
// effectiveUserID returns the user scope for row-level queries. Admins act
// on behalf of the row's owner so management works across accounts.
func (h *DatabaseHandler) effectiveUserID(c *gin.Context, userID, databaseID string) string {
if !contextIsAdmin(c) || databaseID == "" {
return userID
}
owner, err := h.queries.GetDatabaseServiceOwnerID(c.Request.Context(), databaseID)
if err == nil && owner != "" {
return owner
}
return userID
}
// CreateDatabase creates a new database service // CreateDatabase creates a new database service
func (h *DatabaseHandler) CreateDatabase(c *gin.Context) { func (h *DatabaseHandler) CreateDatabase(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c) userID, ok := requireAuthenticatedUserID(c)
@@ -357,6 +380,7 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
return return
} }
databaseID := c.Param("id") databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
var req DatabaseUpdateRequest var req DatabaseUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -440,6 +464,7 @@ func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) {
return return
} }
databaseID := c.Param("id") databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{ exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
ID: databaseID, ID: databaseID,
@@ -481,6 +506,7 @@ func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) {
return return
} }
databaseID := c.Param("id") databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
var req DatabaseActionRequest var req DatabaseActionRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -574,6 +600,7 @@ func (h *DatabaseHandler) CreateBackup(c *gin.Context) {
return return
} }
databaseID := c.Param("id") databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{ row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID, ID: databaseID,
@@ -623,6 +650,7 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) {
return return
} }
databaseID := c.Param("id") databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
var req DatabaseRestoreRequest var req DatabaseRestoreRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -684,6 +712,7 @@ func (h *DatabaseHandler) DownloadBackup(c *gin.Context) {
return return
} }
databaseID := c.Param("id") databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
backupID := c.Param("bid") backupID := c.Param("bid")
backup, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(c.Request.Context(), sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{ backup, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(c.Request.Context(), sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{
@@ -904,35 +933,6 @@ func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string
}, nil }, nil
} }
func (h *DatabaseHandler) generateMockBackupConfig() DatabaseBackupConfig {
return DatabaseBackupConfig{
Enabled: true,
LastBackup: &time.Time{},
Retention: 30,
NextBackup: &time.Time{},
Backups: []DatabaseBackup{
{
ID: "backup_1",
CreatedAt: time.Now().Add(-6 * time.Hour),
Size: "245 MB",
Status: "completed",
},
{
ID: "backup_2",
CreatedAt: time.Now().Add(-24 * time.Hour),
Size: "238 MB",
Status: "completed",
},
{
ID: "backup_3",
CreatedAt: time.Now().Add(-48 * time.Hour),
Size: "241 MB",
Status: "completed",
},
},
}
}
func (h *DatabaseHandler) generateMockSettings() DatabaseSettings { func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
return DatabaseSettings{ return DatabaseSettings{
MaxConnections: 100, MaxConnections: 100,
@@ -1038,26 +1038,10 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da
} }
} }
func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService { func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
return DatabaseService{
ID: row.ID,
Name: row.Name,
Type: row.Type,
Status: row.Status,
Version: row.Version,
Plan: row.Plan,
Region: row.Region,
BackupSchedule: databaseNullString(row.BackupSchedule),
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
ConnectionURL: databaseNullString(row.ConnectionUrl),
CreatedAt: databaseNullTime(row.CreatedAt),
UpdatedAt: databaseNullTime(row.UpdatedAt),
}
}
func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService {
return DatabaseService{ return DatabaseService{
ID: row.ID, ID: row.ID,
OwnerID: row.UserID,
Name: row.Name, Name: row.Name,
Type: row.Type, Type: row.Type,
Status: row.Status, Status: row.Status,
+16 -38
View File
@@ -64,27 +64,13 @@ func handleGetDeployments(c *gin.Context) {
return return
} }
userID, exists := c.Get("user_id") projectID, found := serviceProjectID(db.(*database.DB), serviceID)
if !exists { if !found {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return return
} }
if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
if ownerCheck != userID.(string) { c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
@@ -143,11 +129,8 @@ func handleGetRecentDeployments(c *gin.Context) {
return return
} }
userID, exists := c.Get("user_id") userID := optionalUserUUID(c)
if !exists { isAdmin := contextIsAdmin(c)
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
limit := 10 limit := 10
if v := c.Query("limit"); v != "" { if v := c.Query("limit"); v != "" {
@@ -162,10 +145,11 @@ func handleGetRecentDeployments(c *gin.Context) {
FROM deployments d FROM deployments d
JOIN services s ON s.id = d.service_id JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id JOIN projects p ON p.id = s.project_id
WHERE p.owner_id = $1 WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
ORDER BY d.created_at DESC ORDER BY d.created_at DESC
LIMIT $2`, LIMIT $3`,
userID.(string), limit, userID, isAdmin, limit,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
@@ -557,18 +541,12 @@ func handleGetDeployment(c *gin.Context) {
return return
} }
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var d DeploymentModel var d DeploymentModel
var ownerCheck string var projectID uuid.UUID
err = db.(*database.DB).QueryRow( err = db.(*database.DB).QueryRow(
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag, `SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at, d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.owner_id d.created_at, d.updated_at, p.id
FROM deployments d FROM deployments d
JOIN services s ON d.service_id = s.id JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id JOIN projects p ON s.project_id = p.id
@@ -577,7 +555,7 @@ func handleGetDeployment(c *gin.Context) {
).Scan( ).Scan(
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag, &d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt, &d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
&d.CreatedAt, &d.UpdatedAt, &ownerCheck, &d.CreatedAt, &d.UpdatedAt, &projectID,
) )
if err != nil { if err != nil {
@@ -585,8 +563,8 @@ func handleGetDeployment(c *gin.Context) {
return return
} }
if ownerCheck != userID.(string) { if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return return
} }
@@ -639,7 +617,7 @@ func handleRollbackDeployment(c *gin.Context) {
return return
} }
if ownerCheck != userID.(string) { if ownerCheck != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
+12 -9
View File
@@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
"containr/internal/database" "containr/internal/database"
"containr/internal/docker" "containr/internal/docker"
@@ -17,7 +18,6 @@ import (
// commands run as the container's default user with a 30s ceiling. // commands run as the container's default user with a 30s ceiling.
func handleExecInService(c *gin.Context) { func handleExecInService(c *gin.Context) {
serviceID := c.Param("id") serviceID := c.Param("id")
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
var req struct { var req struct {
@@ -33,17 +33,20 @@ func handleExecInService(c *gin.Context) {
return return
} }
var ownerID string serviceUUID, err := uuid.Parse(serviceID)
err := db.QueryRow( if err != nil {
`SELECT p.owner_id FROM services s c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
JOIN projects p ON p.id = s.project_id return
WHERE s.id = $1`, }
serviceID, projectID, found := serviceProjectID(db, serviceUUID)
).Scan(&ownerID) if !found {
if err != nil || ownerID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return return
} }
if _, allowed := projectManageAccess(c, db, projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
dockerClient, _ := c.Get("docker_client") dockerClient, _ := c.Get("docker_client")
dockerCli, _ := dockerClient.(*docker.Client) dockerCli, _ := dockerClient.(*docker.Client)
+27 -12
View File
@@ -25,43 +25,58 @@ func NewHAManager(haManager *ha.HighAvailabilityManager) *HAManager {
} }
} }
// RegisterRoutes registers HA routes // RegisterReadRoutes registers public HA read endpoints.
func (h *HAManager) RegisterRoutes(router *gin.RouterGroup) { func (h *HAManager) RegisterReadRoutes(router *gin.RouterGroup) {
ha := router.Group("/ha") ha := router.Group("/ha")
{ {
ha.GET("/status", h.GetHAStatus) ha.GET("/status", h.GetHAStatus)
// Failover policies
ha.GET("/failover/policies", h.GetFailoverPolicies)
ha.GET("/failover/policies/:serviceId", h.GetFailoverPolicy)
// Health checks
ha.GET("/health/checks", h.GetHealthChecks)
ha.GET("/health/checks/:checkId", h.GetHealthCheck)
ha.GET("/health/results", h.GetHealthResults)
// Alerts
ha.GET("/alerts/rules", h.GetAlertRules)
ha.GET("/alerts/rules/:ruleId", h.GetAlertRule)
ha.GET("/alerts/active", h.GetActiveAlerts)
// Notifiers
ha.GET("/notifiers", h.GetNotifiers)
ha.GET("/notifiers/:notifierId", h.GetNotifier)
}
}
// RegisterAdminRoutes registers HA mutation endpoints.
func (h *HAManager) RegisterAdminRoutes(router *gin.RouterGroup) {
ha := router.Group("/ha")
{
ha.POST("/enable", h.EnableHA) ha.POST("/enable", h.EnableHA)
ha.POST("/disable", h.DisableHA) ha.POST("/disable", h.DisableHA)
ha.POST("/failover", h.TriggerFailover) ha.POST("/failover", h.TriggerFailover)
// Failover policies // Failover policies
ha.GET("/failover/policies", h.GetFailoverPolicies)
ha.POST("/failover/policies", h.SetFailoverPolicy) ha.POST("/failover/policies", h.SetFailoverPolicy)
ha.GET("/failover/policies/:serviceId", h.GetFailoverPolicy)
ha.PUT("/failover/policies/:serviceId", h.UpdateFailoverPolicy) ha.PUT("/failover/policies/:serviceId", h.UpdateFailoverPolicy)
ha.DELETE("/failover/policies/:serviceId", h.DeleteFailoverPolicy) ha.DELETE("/failover/policies/:serviceId", h.DeleteFailoverPolicy)
// Health checks // Health checks
ha.GET("/health/checks", h.GetHealthChecks)
ha.POST("/health/checks", h.AddHealthCheck) ha.POST("/health/checks", h.AddHealthCheck)
ha.GET("/health/checks/:checkId", h.GetHealthCheck)
ha.PUT("/health/checks/:checkId", h.UpdateHealthCheck) ha.PUT("/health/checks/:checkId", h.UpdateHealthCheck)
ha.DELETE("/health/checks/:checkId", h.DeleteHealthCheck) ha.DELETE("/health/checks/:checkId", h.DeleteHealthCheck)
ha.GET("/health/results", h.GetHealthResults)
// Alerts // Alerts
ha.GET("/alerts/rules", h.GetAlertRules)
ha.POST("/alerts/rules", h.AddAlertRule) ha.POST("/alerts/rules", h.AddAlertRule)
ha.GET("/alerts/rules/:ruleId", h.GetAlertRule)
ha.PUT("/alerts/rules/:ruleId", h.UpdateAlertRule) ha.PUT("/alerts/rules/:ruleId", h.UpdateAlertRule)
ha.DELETE("/alerts/rules/:ruleId", h.DeleteAlertRule) ha.DELETE("/alerts/rules/:ruleId", h.DeleteAlertRule)
ha.GET("/alerts/active", h.GetActiveAlerts)
ha.POST("/alerts/:alertId/resolve", h.ResolveAlert) ha.POST("/alerts/:alertId/resolve", h.ResolveAlert)
// Notifiers // Notifiers
ha.GET("/notifiers", h.GetNotifiers)
ha.POST("/notifiers", h.AddNotifier) ha.POST("/notifiers", h.AddNotifier)
ha.GET("/notifiers/:notifierId", h.GetNotifier)
ha.DELETE("/notifiers/:notifierId", h.DeleteNotifier) ha.DELETE("/notifiers/:notifierId", h.DeleteNotifier)
} }
} }
+9 -29
View File
@@ -37,27 +37,13 @@ func handleGetLogs(c *gin.Context) {
return return
} }
userID, exists := c.Get("user_id") projectID, found := serviceProjectID(db.(*database.DB), serviceID)
if !exists { if !found {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return return
} }
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
if ownerCheck != userID.(string) { c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
@@ -209,30 +195,24 @@ func handleGetDeploymentLogs(c *gin.Context) {
return return
} }
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var buildLog, runtimeLog string var buildLog, runtimeLog string
var ownerCheck string var projectID uuid.UUID
err = db.(*database.DB).QueryRow( err = db.(*database.DB).QueryRow(
`SELECT d.build_log, d.runtime_log, p.owner_id `SELECT d.build_log, d.runtime_log, p.id
FROM deployments d FROM deployments d
JOIN services s ON d.service_id = s.id JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`, WHERE d.id = $1`,
deploymentID, deploymentID,
).Scan(&buildLog, &runtimeLog, &ownerCheck) ).Scan(&buildLog, &runtimeLog, &projectID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return return
} }
if ownerCheck != userID.(string) { if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return return
} }
+6 -13
View File
@@ -60,26 +60,19 @@ func handleGetServiceMetrics(c *gin.Context) {
return return
} }
userID, exists := c.Get("user_id") var projectID uuid.UUID
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerID string
var status string var status string
err = db.QueryRow( err = db.QueryRow(
`SELECT p.owner_id, COALESCE(s.status, '') `SELECT s.project_id, COALESCE(s.status, '')
FROM services s JOIN projects p ON s.project_id = p.id FROM services s WHERE s.id = $1`,
WHERE s.id = $1`,
serviceID, serviceID,
).Scan(&ownerID, &status) ).Scan(&projectID, &status)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return return
} }
if ownerID != userID.(string) { if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return return
} }
@@ -69,31 +69,13 @@ func handleGetPreviewEnvironments(c *gin.Context) {
return return
} }
// Check if project exists and user has access // Approved projects are public; unapproved ones need owner/member/admin.
var project Project projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID)
err = db.(*database.DB).QueryRow( if !projectExists || !allowed {
"SELECT id, name, owner_id FROM projects WHERE id = $1",
projectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return return
} }
// Get user ID from JWT token (set by auth middleware)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Get preview environments for the project with service info // Get preview environments for the project with service info
rows, err := db.(*database.DB).Query( rows, err := db.(*database.DB).Query(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number, `SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
@@ -193,7 +175,7 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
} }
// Check if user owns the project // Check if user owns the project
if project.OwnerID != userID.(string) { if project.OwnerID != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
@@ -332,14 +314,8 @@ func handleGetPreviewEnvironment(c *gin.Context) {
return return
} }
// Get user ID from JWT token // Public when the parent project is approved; otherwise owner/member/admin.
userID, exists := c.Get("user_id") userID := optionalUserUUID(c)
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Get preview environment with project ownership check
var env PreviewEnvironment var env PreviewEnvironment
var serviceID sql.NullString var serviceID sql.NullString
var serviceName sql.NullString var serviceName sql.NullString
@@ -351,8 +327,9 @@ func handleGetPreviewEnvironment(c *gin.Context) {
FROM preview_environments pe FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id LEFT JOIN services s ON pe.service_id = s.id
JOIN projects p ON pe.project_id = p.id JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`, WHERE pe.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
envID, userID, OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
envID, userID, contextIsAdmin(c),
).Scan( ).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber, &env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt, &env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
@@ -414,8 +391,8 @@ func handleUpdatePreviewEnvironment(c *gin.Context) {
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at
FROM preview_environments pe FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`, WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
envID, userID, envID, userID, contextIsAdmin(c),
).Scan( ).Scan(
&existingEnv.ID, &existingEnv.ProjectID, &existingEnv.ServiceID, &existingEnv.BranchName, &existingEnv.ID, &existingEnv.ProjectID, &existingEnv.ServiceID, &existingEnv.BranchName,
&existingEnv.PRNumber, &existingEnv.Environment, &existingEnv.Status, &existingEnv.URL, &existingEnv.PRNumber, &existingEnv.Environment, &existingEnv.Status, &existingEnv.URL,
@@ -498,7 +475,7 @@ func handleDeletePreviewEnvironment(c *gin.Context) {
} }
// Check if user owns the project // Check if user owns the project
if projectOwnerID != userID.(string) { if projectOwnerID != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
@@ -568,8 +545,8 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment, pe.status `SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment, pe.status
FROM preview_environments pe FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`, WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
envID, userID, envID, userID, contextIsAdmin(c),
).Scan( ).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.Status, &env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.Status,
) )
@@ -665,8 +642,8 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment `SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment
FROM preview_environments pe FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id JOIN projects p ON pe.project_id = p.id
WHERE p.owner_id = $1 AND pe.expires_at < NOW() AND pe.status != 'expired'`, WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'`,
userID, userID, contextIsAdmin(c),
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find expired preview environments"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find expired preview environments"})
@@ -6,8 +6,6 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"containr/internal/database"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -89,7 +87,7 @@ func TestRequireAuthenticatedUserIDRejectsMissingUserContext(t *testing.T) {
} }
} }
func TestRecentDeploymentsRequireAuthentication(t *testing.T) { func TestRecentDeploymentsAllowAnonymousRead(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()
router.GET("/api/v1/deployments", handleGetRecentDeployments) router.GET("/api/v1/deployments", handleGetRecentDeployments)
@@ -98,20 +96,9 @@ func TestRecentDeploymentsRequireAuthentication(t *testing.T) {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
// No db in context → dependency failure surfaces before auth. // Deployments are publicly readable; with no db in context the handler
// fails at the dependency boundary, never at an auth check.
if rec.Code != http.StatusInternalServerError { if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, rec.Code) t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, rec.Code)
} }
// With db present but no user context → 401.
router2 := gin.New()
router2.GET("/api/v1/deployments", func(c *gin.Context) {
c.Set("db", &database.DB{})
handleGetRecentDeployments(c)
})
rec2 := httptest.NewRecorder()
router2.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/api/v1/deployments", nil))
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec2.Code)
}
} }
+16 -14
View File
@@ -21,6 +21,7 @@ type Project struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
OwnerID string `json:"owner_id"` OwnerID string `json:"owner_id"`
IsApproved bool `json:"is_approved"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
@@ -48,10 +49,8 @@ type UpdateProjectRequest struct {
} }
func handleGetProjects(c *gin.Context) { func handleGetProjects(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c) userID := optionalUserUUID(c)
if !ok { isAdmin := contextIsAdmin(c)
return
}
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB) queries := sqlcdb.New(db.DB)
@@ -75,6 +74,7 @@ func handleGetProjects(c *gin.Context) {
rows, err := queries.ListProjectsWithStatsByUser(c.Request.Context(), sqlcdb.ListProjectsWithStatsByUserParams{ rows, err := queries.ListProjectsWithStatsByUser(c.Request.Context(), sqlcdb.ListProjectsWithStatsByUserParams{
UserID: userID, UserID: userID,
IsAdmin: isAdmin,
Search: searchParam, Search: searchParam,
LimitCount: int32(limit), LimitCount: int32(limit),
OffsetCount: int32(offset), OffsetCount: int32(offset),
@@ -90,8 +90,9 @@ func handleGetProjects(c *gin.Context) {
} }
total, err := queries.CountProjectsByUser(c.Request.Context(), sqlcdb.CountProjectsByUserParams{ total, err := queries.CountProjectsByUser(c.Request.Context(), sqlcdb.CountProjectsByUserParams{
UserID: userID, UserID: userID,
Search: searchParam, IsAdmin: isAdmin,
Search: searchParam,
}) })
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database count error"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Database count error"})
@@ -171,10 +172,7 @@ func handleCreateProject(c *gin.Context) {
} }
func handleGetProject(c *gin.Context) { func handleGetProject(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c) userID := optionalUserUUID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB) queries := sqlcdb.New(db.DB)
@@ -187,6 +185,7 @@ func handleGetProject(c *gin.Context) {
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{ projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
ProjectID: projectID, ProjectID: projectID,
UserID: userID, UserID: userID,
IsAdmin: contextIsAdmin(c),
}) })
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
@@ -217,6 +216,7 @@ func handleUpdateProject(c *gin.Context) {
role, err := queries.GetProjectRoleForUser(c.Request.Context(), sqlcdb.GetProjectRoleForUserParams{ role, err := queries.GetProjectRoleForUser(c.Request.Context(), sqlcdb.GetProjectRoleForUserParams{
ProjectID: projectID, ProjectID: projectID,
UserID: userID, UserID: userID,
IsAdmin: contextIsAdmin(c),
}) })
if errors.Is(err, sql.ErrNoRows) || role == "" || role == "viewer" { if errors.Is(err, sql.ErrNoRows) || role == "" || role == "viewer" {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
@@ -270,6 +270,7 @@ func handleUpdateProject(c *gin.Context) {
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{ projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
ProjectID: projectID, ProjectID: projectID,
UserID: userID, UserID: userID,
IsAdmin: contextIsAdmin(c),
}) })
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
@@ -307,7 +308,7 @@ func handleDeleteProject(c *gin.Context) {
return return
} }
if ownerID != userID { if ownerID != userID && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Only project owners can delete projects"}) c.JSON(http.StatusForbidden, gin.H{"error": "Only project owners can delete projects"})
return return
} }
@@ -361,7 +362,7 @@ func parseProjectIDParam(c *gin.Context) (uuid.UUID, bool) {
func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWithStats { func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWithStats {
result := ProjectWithStats{ result := ProjectWithStats{
Project: mapProjectFields(row.ID, row.Name, row.Description, row.OwnerID, row.CreatedAt, row.UpdatedAt), Project: mapProjectFields(row.ID, row.Name, row.Description, row.OwnerID, row.IsApproved, row.CreatedAt, row.UpdatedAt),
Stats: ProjectStats{ Stats: ProjectStats{
ServiceCount: int(row.ServiceCount), ServiceCount: int(row.ServiceCount),
DeploymentCount: int(row.DeploymentCount), DeploymentCount: int(row.DeploymentCount),
@@ -373,15 +374,16 @@ func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWi
} }
func mapSQLCProject(project sqlcdb.Project) Project { func mapSQLCProject(project sqlcdb.Project) Project {
return mapProjectFields(project.ID, project.Name, project.Description, project.OwnerID, project.CreatedAt, project.UpdatedAt) return mapProjectFields(project.ID, project.Name, project.Description, project.OwnerID, project.IsApproved, project.CreatedAt, project.UpdatedAt)
} }
func mapProjectFields(id uuid.UUID, name string, description sql.NullString, ownerID uuid.UUID, createdAt, updatedAt sql.NullTime) Project { func mapProjectFields(id uuid.UUID, name string, description sql.NullString, ownerID uuid.UUID, isApproved bool, createdAt, updatedAt sql.NullTime) Project {
return Project{ return Project{
ID: id.String(), ID: id.String(),
Name: name, Name: name,
Description: nullableStringValue(description), Description: nullableStringValue(description),
OwnerID: ownerID.String(), OwnerID: ownerID.String(),
IsApproved: isApproved,
CreatedAt: nullableTimestampString(createdAt), CreatedAt: nullableTimestampString(createdAt),
UpdatedAt: nullableTimestampString(updatedAt), UpdatedAt: nullableTimestampString(updatedAt),
} }
+157 -135
View File
@@ -96,6 +96,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
c.Next() c.Next()
}) })
// Seed the official service template catalog (idempotent upserts).
go SeedOfficialTemplates(context.Background(), db)
go func() { go func() {
if err := haManager.Start(context.Background()); err != nil { if err := haManager.Start(context.Background()); err != nil {
log.Printf("HA manager exited: %v", err) log.Printf("HA manager exited: %v", err)
@@ -136,7 +139,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
if redis == nil { if redis == nil {
redisStatus = "unhealthy" redisStatus = "unhealthy"
checks["redis"] = redisStatus checks["redis"] = "unhealthy"
checks["redisError"] = "redis client not initialized" checks["redisError"] = "redis client not initialized"
overallStatus = "degraded" overallStatus = "degraded"
statusCode = http.StatusServiceUnavailable statusCode = http.StatusServiceUnavailable
@@ -177,163 +180,182 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
public.POST("/auth/register", handleRegister) public.POST("/auth/register", handleRegister)
} }
// Protected routes (authentication required) // Read routes — public browsing. OptionalAuth resolves a session when
protected := v1.Group("/") // present so handlers can widen visibility for owners/admins; anonymous
protected.Use(middleware.Auth(cfg.JWTSecret)) // callers see approved resources only.
read := v1.Group("/")
read.Use(middleware.OptionalAuth(cfg.JWTSecret))
{ {
// User routes read.GET("/projects", handleGetProjects)
protected.GET("/user/profile", handleGetProfile) read.GET("/projects/:id", handleGetProject)
protected.PUT("/user/profile", handleUpdateProfile) read.GET("/projects/:id/services", handleGetServices)
protected.POST("/users", handleCreateUser) read.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
read.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
read.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
read.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics)
// Platform settings (admin-gated inside handlers) read.GET("/services/:id", handleGetService)
protected.GET("/settings", handleGetSettings) read.GET("/services/:id/metrics", handleGetServiceMetrics)
protected.PUT("/settings", handleUpdateSettings) read.GET("/services/:id/runtime", handleGetServiceRuntime)
read.GET("/services/:id/deployments", handleGetDeployments)
read.GET("/deployments", handleGetRecentDeployments)
read.GET("/deployments/:id", handleGetDeployment)
// Project routes read.GET("/databases", databaseHandler.GetDatabases)
protected.GET("/projects", handleGetProjects) read.GET("/databases/:id", databaseHandler.GetDatabase)
protected.POST("/projects", handleCreateProject)
// Service routes (nested under projects) read.GET("/templates", handleGetTemplates)
protected.GET("/projects/:id/services", handleGetServices) read.GET("/templates/:id", handleGetTemplate)
protected.POST("/projects/:id/services", handleCreateService)
// Generic project routes read.GET("/cron-jobs", handleGetCronJobs)
protected.GET("/projects/:id", handleGetProject) read.GET("/cron-jobs/:id", handleGetCronJob)
protected.PUT("/projects/:id", handleUpdateProject) read.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
protected.DELETE("/projects/:id", handleDeleteProject)
// Service routes read.GET("/security/scans/:id", securityHandler.GetSecurityScan)
protected.GET("/services/:id", handleGetService) read.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
protected.PUT("/services/:id", handleUpdateService) read.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
protected.DELETE("/services/:id", handleDeleteService)
protected.GET("/services/:id/metrics", handleGetServiceMetrics)
// Runtime lifecycle (Railway-style) read.GET("/preview-environments/:id", handleGetPreviewEnvironment)
protected.GET("/services/:id/runtime", handleGetServiceRuntime)
protected.POST("/services/:id/start", handleServiceStart)
protected.POST("/services/:id/stop", handleServiceStop)
protected.POST("/services/:id/restart", handleServiceRestart)
protected.POST("/services/:id/redeploy", handleServiceRedeploy)
// Deployment routes read.GET("/system/upgrade/status", handleGetUpgradeStatus)
protected.GET("/services/:id/deployments", handleGetDeployments) read.GET("/system/host", handleGetHostMonitoring)
protected.POST("/services/:id/deployments", handleCreateDeployment)
protected.GET("/deployments", handleGetRecentDeployments)
protected.GET("/deployments/:id", handleGetDeployment)
protected.POST("/deployments/:id/rollback", handleRollbackDeployment)
// Environment variables routes // HA + scaling read endpoints (status, policies, health, alerts)
protected.GET("/services/:id/variables", handleGetVariables) haAPIManager.RegisterReadRoutes(read)
protected.PUT("/services/:id/variables", handleUpdateVariables) scalingHandler.RegisterReadRoutes(read)
}
// Logs routes // Authenticated routes — a valid session is required and handlers
protected.GET("/services/:id/logs", handleGetLogs) // enforce ownership; platform admins additionally pass every check.
protected.GET("/deployments/:id/logs", handleGetDeploymentLogs) authed := v1.Group("/")
authed.Use(middleware.Auth(cfg.JWTSecret))
{
authed.GET("/user/profile", handleGetProfile)
authed.PUT("/user/profile", handleUpdateProfile)
// One-off exec console (docker exec, 30s ceiling) authed.GET("/notifications", handleListNotifications)
protected.POST("/services/:id/exec", handleExecInService) authed.POST("/notifications/:id/read", handleMarkNotificationRead)
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
// Git integration routes
protected.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
protected.POST("/git/github-app/connect", handleConnectGitHubApp)
protected.GET("/git/providers", handleGetGitProviders)
protected.POST("/git/providers", handleCreateGitProvider)
protected.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
protected.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
protected.POST("/git/repositories/connect", handleConnectGitRepository)
protected.GET("/git/repositories", handleGetConnectedRepositories)
protected.POST("/git/webhooks", handleCreateWebhook)
// Build routes
protected.POST("/builds", buildHandler.StartBuild)
protected.GET("/builds", buildHandler.ListBuilds)
protected.GET("/builds/:id", buildHandler.GetBuildStatus)
protected.POST("/builds/:id/cancel", buildHandler.CancelBuild)
protected.GET("/builds/:id/logs", buildHandler.GetBuildLogs)
protected.POST("/builds/plan", buildHandler.GetBuildPlan)
protected.GET("/builds/detect", buildHandler.DetectBuildType)
// System routes
protected.GET("/system/upgrade/status", handleGetUpgradeStatus)
protected.POST("/system/upgrade/pull", handlePullUpgradeImage)
protected.GET("/system/host", handleGetHostMonitoring)
// Scaling routes
scalingHandler.RegisterRoutes(protected)
haAPIManager.RegisterRoutes(protected)
// Database routes
protected.GET("/databases", databaseHandler.GetDatabases)
protected.POST("/databases", databaseHandler.CreateDatabase)
protected.GET("/databases/:id", databaseHandler.GetDatabase)
protected.PUT("/databases/:id", databaseHandler.UpdateDatabase)
protected.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
protected.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
protected.POST("/databases/:id/backup", databaseHandler.CreateBackup)
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
protected.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
// Notification routes
protected.GET("/notifications", handleListNotifications)
protected.POST("/notifications/:id/read", handleMarkNotificationRead)
protected.POST("/notifications/read-all", handleMarkAllNotificationsRead)
// Node Agent routes
agentHandler.SetupRoutes(protected)
// Preview Environments routes
protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
protected.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
protected.GET("/preview-environments/:id", handleGetPreviewEnvironment)
protected.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
protected.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
protected.POST("/preview-environments/:id/promote", handlePromotePreviewEnvironment)
protected.POST("/preview-environments/cleanup-expired", handleCleanupExpiredPreviewEnvironments)
// Security routes
protected.POST("/security/scans", securityHandler.StartSecurityScan)
protected.GET("/security/scans/:id", securityHandler.GetSecurityScan)
protected.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
protected.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
protected.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability)
protected.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
protected.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
protected.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
protected.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
protected.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics)
protected.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs)
// WebSocket endpoint // WebSocket endpoint
protected.GET("/ws", handleWebSocket) authed.GET("/ws", handleWebSocket)
// Templates routes // Owned-resource mutations — handlers scope to the caller and
protected.GET("/templates", handleGetTemplates) // admit admins. New projects land unapproved until an admin
protected.GET("/templates/:id", handleGetTemplate) // publishes them.
protected.POST("/templates/:id/deploy", handleCreateFromTemplate) authed.POST("/projects", handleCreateProject)
authed.POST("/projects/:id/services", handleCreateService)
authed.PUT("/projects/:id", handleUpdateProject)
authed.DELETE("/projects/:id", handleDeleteProject)
// Cron Jobs routes authed.PUT("/services/:id", handleUpdateService)
protected.GET("/cron-jobs", handleGetCronJobs) authed.DELETE("/services/:id", handleDeleteService)
protected.POST("/cron-jobs", handleCreateCronJob) authed.POST("/services/:id/start", handleServiceStart)
protected.GET("/cron-jobs/:id", handleGetCronJob) authed.POST("/services/:id/stop", handleServiceStop)
protected.PUT("/cron-jobs/:id", handleUpdateCronJob) authed.POST("/services/:id/restart", handleServiceRestart)
protected.DELETE("/cron-jobs/:id", handleDeleteCronJob) authed.POST("/services/:id/redeploy", handleServiceRedeploy)
protected.GET("/cron-jobs/:id/executions", handleGetCronExecutions) authed.POST("/services/:id/deployments", handleCreateDeployment)
protected.POST("/cron-jobs/:id/trigger", handleTriggerCronJob) authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
// Audit Logs routes // Environment variables — secrets, owner/admin only.
protected.GET("/audit-logs", handleGetAuditLogs) authed.GET("/services/:id/variables", handleGetVariables)
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs) authed.PUT("/services/:id/variables", handleUpdateVariables)
// Runtime logs — container stdout routinely echoes secrets,
// so these stay behind authentication like build logs.
authed.GET("/services/:id/logs", handleGetLogs)
authed.GET("/deployments/:id/logs", handleGetDeploymentLogs)
// One-off exec console (docker exec, 30s ceiling)
authed.POST("/services/:id/exec", handleExecInService)
// Git integration — providers carry per-user credentials.
authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
authed.POST("/git/github-app/connect", handleConnectGitHubApp)
authed.GET("/git/providers", handleGetGitProviders)
authed.POST("/git/providers", handleCreateGitProvider)
authed.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
authed.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
authed.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
authed.POST("/git/repositories/connect", handleConnectGitRepository)
authed.GET("/git/repositories", handleGetConnectedRepositories)
authed.POST("/git/webhooks", handleCreateWebhook)
// Builds have no per-user scoping and logs may carry secrets —
// keep every build endpoint behind authentication.
authed.POST("/builds", buildHandler.StartBuild)
authed.GET("/builds", buildHandler.ListBuilds)
authed.GET("/builds/:id", buildHandler.GetBuildStatus)
authed.GET("/builds/:id/logs", buildHandler.GetBuildLogs)
authed.GET("/builds/detect", buildHandler.DetectBuildType)
authed.POST("/builds/:id/cancel", buildHandler.CancelBuild)
authed.POST("/builds/plan", buildHandler.GetBuildPlan)
authed.POST("/databases", databaseHandler.CreateDatabase)
authed.PUT("/databases/:id", databaseHandler.UpdateDatabase)
authed.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
authed.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
authed.POST("/databases/:id/backup", databaseHandler.CreateBackup)
authed.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
authed.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
authed.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
authed.POST("/preview-environments/:id/promote", handlePromotePreviewEnvironment)
authed.POST("/security/scans", securityHandler.StartSecurityScan)
authed.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability)
authed.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
authed.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
authed.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs)
// User templates — ownership enforced inside the handlers.
authed.POST("/templates", handleCreateTemplate)
authed.PUT("/templates/:id", handleUpdateTemplate)
authed.DELETE("/templates/:id", handleDeleteTemplate)
authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
authed.POST("/cron-jobs", handleCreateCronJob)
authed.PUT("/cron-jobs/:id", handleUpdateCronJob)
authed.DELETE("/cron-jobs/:id", handleDeleteCronJob)
authed.POST("/cron-jobs/:id/trigger", handleTriggerCronJob)
}
// Admin routes — platform-wide controls and cross-user management.
admin := v1.Group("/")
admin.Use(middleware.Auth(cfg.JWTSecret))
admin.Use(middleware.RequireAdmin())
{
admin.GET("/admin/overview", handleAdminOverview)
admin.GET("/admin/users", handleAdminListUsers)
admin.PATCH("/admin/users/:id", handleAdminSetUserAdmin)
admin.PATCH("/admin/projects/:id", handleAdminSetProjectApproval)
admin.POST("/users", handleCreateUser)
admin.GET("/settings", handleGetSettings)
admin.PUT("/settings", handleUpdateSettings)
admin.POST("/system/upgrade/pull", handlePullUpgradeImage)
admin.POST("/preview-environments/cleanup-expired", handleCleanupExpiredPreviewEnvironments)
// HA + scaling + agent mutations — platform-level controls.
haAPIManager.RegisterAdminRoutes(admin)
scalingHandler.RegisterAdminRoutes(admin)
agentHandler.SetupAdminRoutes(admin)
admin.GET("/audit-logs", handleGetAuditLogs)
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
// API Gateway routes (merged APwhy) - namespaced to avoid // API Gateway routes (merged APwhy) - namespaced to avoid
// colliding with Containr's own /services paths. // colliding with Containr's own /services paths.
gateway := protected.Group("/gateway") gateway := admin.Group("/gateway")
{ {
gateway.GET("/services", handleAPwhyServicesList) gateway.GET("/services", handleAPwhyServicesList)
gateway.POST("/services", handleAPwhyServicesCreate) gateway.POST("/services", handleAPwhyServicesCreate)
gateway.PATCH("/services/:id", handleAPwhyServicesPatch) gateway.PATCH("/services/:id", handleAPwhyServicesPatch)
gateway.GET("/services/:id/validate", handleAPwhyServiceValidate) gateway.GET("/services/:id", handleAPwhyServiceValidate)
gateway.GET("/keys", handleAPwhyKeysList) gateway.GET("/keys", handleAPwhyKeysList)
gateway.POST("/keys", handleAPwhyKeysCreate) gateway.POST("/keys", handleAPwhyKeysCreate)
+44 -1
View File
@@ -225,13 +225,56 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
return service, true return service, true
} }
// loadReadableService loads a service when the caller may read its project:
// approved projects are public, unapproved ones need owner/member/admin.
func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return Service{}, false
}
var service Service
var projectID uuid.UUID
err = db.QueryRow(
`SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
s.created_at, s.updated_at, s.project_id
FROM services s
WHERE s.id = $1`,
serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectID,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return Service{}, false
}
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return Service{}, false
}
return service, true
}
// handleServiceRuntime returns live container state for a service. // handleServiceRuntime returns live container state for a service.
func handleGetServiceRuntime(c *gin.Context) { func handleGetServiceRuntime(c *gin.Context) {
engine, db, ok := getRuntimeEngine(c) engine, db, ok := getRuntimeEngine(c)
if !ok { if !ok {
return return
} }
service, ok := loadOwnedService(c, db) service, ok := loadReadableService(c, db)
if !ok { if !ok {
return return
} }
+17 -9
View File
@@ -24,31 +24,39 @@ func NewScalingHandler(autoScaler *scaling.AutoScaler) *ScalingHandler {
} }
} }
// RegisterRoutes registers scaling routes // RegisterReadRoutes registers public scaling read endpoints.
func (h *ScalingHandler) RegisterRoutes(router *gin.RouterGroup) { func (h *ScalingHandler) RegisterReadRoutes(router *gin.RouterGroup) {
scaling := router.Group("/scaling") scaling := router.Group("/scaling")
{ {
scaling.GET("/policies", h.GetScalingPolicies) scaling.GET("/policies", h.GetScalingPolicies)
scaling.POST("/policies", h.SetScalingPolicy)
scaling.GET("/policies/:serviceId", h.GetScalingPolicy) scaling.GET("/policies/:serviceId", h.GetScalingPolicy)
scaling.PUT("/policies/:serviceId", h.UpdateScalingPolicy)
scaling.DELETE("/policies/:serviceId", h.DeleteScalingPolicy)
scaling.GET("/services", h.GetServiceStates) scaling.GET("/services", h.GetServiceStates)
scaling.GET("/services/:serviceId", h.GetServiceState) scaling.GET("/services/:serviceId", h.GetServiceState)
scaling.GET("/services/:serviceId/history", h.GetScalingHistory) scaling.GET("/services/:serviceId/history", h.GetScalingHistory)
scaling.POST("/services/:serviceId/scale", h.ManualScale)
scaling.GET("/status", h.GetScalingStatus) scaling.GET("/status", h.GetScalingStatus)
scaling.POST("/enable", h.EnableAutoScaler)
scaling.POST("/disable", h.DisableAutoScaler)
scaling.GET("/metrics", h.GetScalingMetrics) scaling.GET("/metrics", h.GetScalingMetrics)
scaling.GET("/events", h.GetScalingEvents) scaling.GET("/events", h.GetScalingEvents)
} }
} }
// RegisterAdminRoutes registers scaling mutation endpoints.
func (h *ScalingHandler) RegisterAdminRoutes(router *gin.RouterGroup) {
scaling := router.Group("/scaling")
{
scaling.POST("/policies", h.SetScalingPolicy)
scaling.PUT("/policies/:serviceId", h.UpdateScalingPolicy)
scaling.DELETE("/policies/:serviceId", h.DeleteScalingPolicy)
scaling.POST("/services/:serviceId/scale", h.ManualScale)
scaling.POST("/enable", h.EnableAutoScaler)
scaling.POST("/disable", h.DisableAutoScaler)
}
}
// GetScalingPolicies returns all scaling policies // GetScalingPolicies returns all scaling policies
func (h *ScalingHandler) GetScalingPolicies(c *gin.Context) { func (h *ScalingHandler) GetScalingPolicies(c *gin.Context) {
states := h.autoScaler.GetAllServiceStates() states := h.autoScaler.GetAllServiceStates()
+3 -1
View File
@@ -49,7 +49,9 @@ func setupScalingTestRouter(t *testing.T) *gin.Engine {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()
handler := NewScalingHandler(autoScaler) handler := NewScalingHandler(autoScaler)
handler.RegisterRoutes(router.Group("/api/v1")) group := router.Group("/api/v1")
handler.RegisterReadRoutes(group)
handler.RegisterAdminRoutes(group)
return router return router
} }
+5 -12
View File
@@ -589,16 +589,7 @@ func (sh *SecurityHandler) GetAuditLogs(c *gin.Context) {
} }
func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string) (string, bool) { func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string) (string, bool) {
userIDValue, exists := c.Get("user_id") userID := optionalUserID(c)
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return "", false
}
userID, ok := userIDValue.(string)
if !ok || userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return "", false
}
if _, err := uuid.Parse(projectID); err != nil { if _, err := uuid.Parse(projectID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
@@ -611,12 +602,14 @@ func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string
SELECT 1 SELECT 1
FROM projects p FROM projects p
WHERE p.id = $1 WHERE p.id = $1
AND (p.owner_id = $2 OR EXISTS ( AND (p.is_approved
OR $3::bool
OR p.owner_id = $2 OR EXISTS (
SELECT 1 FROM project_members pm SELECT 1 FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = $2 WHERE pm.project_id = p.id AND pm.user_id = $2
)) ))
)`, )`,
projectID, userID, projectID, optionalUserUUID(c), contextIsAdmin(c),
).Scan(&hasAccess) ).Scan(&hasAccess)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"})
+14 -32
View File
@@ -91,28 +91,14 @@ func handleGetServices(c *gin.Context) {
return return
} }
// Check if project exists and user has access // Approved projects are public; unapproved ones need owner/member/admin.
var project Project projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID)
err = db.(*database.DB).QueryRow( if !projectExists {
"SELECT id, name, owner_id FROM projects WHERE id = $1",
projectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return return
} }
if !allowed {
// Get user ID from JWT token (set by auth middleware) c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
@@ -217,7 +203,7 @@ func handleCreateService(c *gin.Context) {
} }
// Check if user owns the project // Check if user owns the project
if project.OwnerID != userID.(string) { if project.OwnerID != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
@@ -344,14 +330,9 @@ func handleGetService(c *gin.Context) {
return return
} }
// Get user ID from JWT token // Get service — public when its project is approved, otherwise
userID, exists := c.Get("user_id") // owner/member/admin only.
if !exists { userID := optionalUserUUID(c)
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Get service with project ownership check
var service Service var service Service
err = db.(*database.DB).QueryRow( err = db.(*database.DB).QueryRow(
`SELECT s.id, s.project_id, s.name, `SELECT s.id, s.project_id, s.name,
@@ -371,8 +352,9 @@ func handleGetService(c *gin.Context) {
s.created_at, s.updated_at s.created_at, s.updated_at
FROM services s FROM services s
JOIN projects p ON s.project_id = p.id JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND p.owner_id = $2`, WHERE s.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
serviceID, userID, OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
serviceID, userID, contextIsAdmin(c),
).Scan( ).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status, &service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
@@ -438,8 +420,8 @@ func handleUpdateService(c *gin.Context) {
s.created_at, s.updated_at s.created_at, s.updated_at
FROM services s FROM services s
JOIN projects p ON s.project_id = p.id JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND p.owner_id = $2`, WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
serviceID, userID, serviceID, userID, contextIsAdmin(c),
).Scan( ).Scan(
&existingService.ID, &existingService.ProjectID, &existingService.Name, &existingService.Type, &existingService.ID, &existingService.ProjectID, &existingService.Name, &existingService.Type,
&existingService.Status, &existingService.Image, &existingService.Command, &existingService.Status, &existingService.Image, &existingService.Command,
+433 -6
View File
@@ -3,16 +3,19 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb" "containr/internal/database/sqlcdb"
"context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
) )
type ServiceTemplate struct { type ServiceTemplate struct {
@@ -24,6 +27,7 @@ type ServiceTemplate struct {
Config string `json:"config" db:"config"` Config string `json:"config" db:"config"`
Variables string `json:"variables" db:"variables"` Variables string `json:"variables" db:"variables"`
IsOfficial bool `json:"is_official" db:"is_official"` IsOfficial bool `json:"is_official" db:"is_official"`
OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }
@@ -67,12 +71,18 @@ func handleGetTemplates(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
queries := sqlcdb.New(db.DB) queries := sqlcdb.New(db.DB)
userID := optionalUserUUID(c)
owner := uuid.NullUUID{UUID: userID, Valid: userID != uuid.Nil}
var templateRows []sqlcdb.ServiceTemplate var templateRows []sqlcdb.ServiceTemplate
var err error var err error
if category != "" { if category != "" {
templateRows, err = queries.ListServiceTemplatesByCategory(ctx, category) templateRows, err = queries.ListServiceTemplatesByCategoryForUser(ctx, sqlcdb.ListServiceTemplatesByCategoryForUserParams{
Category: category,
OwnerID: owner,
})
} else { } else {
templateRows, err = queries.ListServiceTemplates(ctx) templateRows, err = queries.ListServiceTemplatesForUser(ctx, owner)
} }
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
@@ -103,7 +113,7 @@ func handleGetTemplate(c *gin.Context) {
} }
t := mapSQLCTemplate(row) t := mapSQLCTemplate(row)
if t.ID == "" { if t.ID == "" || !templateVisibleTo(c, t) {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return return
} }
@@ -169,7 +179,7 @@ func handleCreateFromTemplate(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch project"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch project"})
return return
} }
if ownerID.String() != userID { if ownerID.String() != userID && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
@@ -185,8 +195,8 @@ func handleCreateFromTemplate(c *gin.Context) {
} }
template := mapSQLCTemplate(templateRow) template := mapSQLCTemplate(templateRow)
if template.ID == "" { if template.ID == "" || !templateVisibleTo(c, template) {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return return
} }
@@ -363,6 +373,243 @@ func handleCreateFromTemplate(c *gin.Context) {
}) })
} }
// templateWriteRequest is the admin payload for creating/updating a user
// template. config must decode into TemplateConfig; variables into
// []TemplateVariable.
type templateWriteRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Category string `json:"category"`
Logo string `json:"logo"`
Config json.RawMessage `json:"config" binding:"required"`
Variables json.RawMessage `json:"variables"`
}
var templateCategories = map[string]bool{
"web": true, "frontend": true, "api": true, "database": true,
"worker": true, "cron": true, "app": true, "custom": true,
}
func normalizeTemplateCategory(category string) string {
normalized := strings.ToLower(strings.TrimSpace(category))
if templateCategories[normalized] {
return normalized
}
return "custom"
}
func validateTemplateWrite(c *gin.Context, req *templateWriteRequest) bool {
req.Name = strings.TrimSpace(req.Name)
if len(req.Name) < 2 || len(req.Name) > 120 {
c.JSON(http.StatusBadRequest, gin.H{"error": "name must be 2-120 characters"})
return false
}
req.Category = normalizeTemplateCategory(req.Category)
req.Logo = strings.TrimSpace(req.Logo)
if len(req.Logo) > 500 || (req.Logo != "" && !strings.HasPrefix(req.Logo, "https://") && !strings.HasPrefix(req.Logo, "http://")) {
c.JSON(http.StatusBadRequest, gin.H{"error": "logo must be an http(s) URL up to 500 chars"})
return false
}
if len(req.Config) == 0 || len(req.Config) > 64*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "config is required and must be under 64KB"})
return false
}
var config TemplateConfig
if err := json.Unmarshal(req.Config, &config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "config must be a valid template config object"})
return false
}
if _, err := normalizeTemplateServiceType(config.Type); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return false
}
if len(req.Variables) > 0 {
if len(req.Variables) > 64*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "variables must be under 64KB"})
return false
}
var variables []TemplateVariable
if err := json.Unmarshal(req.Variables, &variables); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "variables must be an array"})
return false
}
for _, v := range variables {
if strings.TrimSpace(v.Key) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "every variable needs a key"})
return false
}
}
}
return true
}
func templateVariablesParam(raw json.RawMessage) pqtype.NullRawMessage {
if len(raw) == 0 {
return pqtype.NullRawMessage{}
}
return pqtype.NullRawMessage{RawMessage: raw, Valid: true}
}
func handleCreateTemplate(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
var req templateWriteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !validateTemplateWrite(c, &req) {
return
}
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
id := "usr-" + uuid.NewString()
if err := queries.CreateUserTemplate(c.Request.Context(), sqlcdb.CreateUserTemplateParams{
ID: id,
Name: req.Name,
Description: nullableText(strings.TrimSpace(req.Description)),
Category: req.Category,
Logo: nullableText(req.Logo),
Config: req.Config,
Variables: templateVariablesParam(req.Variables),
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"})
return
}
LogAudit(userID, "template", id, "create", map[string]interface{}{"name": req.Name})
row, err := queries.GetServiceTemplateByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusCreated, gin.H{"template": gin.H{"id": id, "name": req.Name}})
return
}
c.JSON(http.StatusCreated, gin.H{"template": mapSQLCTemplate(row)})
}
func handleUpdateTemplate(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
templateID := c.Param("id")
var req templateWriteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !validateTemplateWrite(c, &req) {
return
}
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
rows, err := queries.UpdateUserTemplate(c.Request.Context(), sqlcdb.UpdateUserTemplateParams{
Name: req.Name,
Description: nullableText(strings.TrimSpace(req.Description)),
Category: req.Category,
Logo: nullableText(req.Logo),
Config: req.Config,
Variables: templateVariablesParam(req.Variables),
ID: templateID,
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
return
}
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found or not editable"})
return
}
LogAudit(userID, "template", templateID, "update", map[string]interface{}{"name": req.Name})
row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "Template updated"})
return
}
c.JSON(http.StatusOK, gin.H{"template": mapSQLCTemplate(row)})
}
func handleDeleteTemplate(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
templateID := c.Param("id")
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
rows, err := queries.DeleteUserTemplate(c.Request.Context(), sqlcdb.DeleteUserTemplateParams{
ID: templateID,
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete template"})
return
}
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found or not deletable"})
return
}
LogAudit(userID, "template", templateID, "delete", nil)
c.JSON(http.StatusOK, gin.H{"message": "Template deleted"})
}
// SeedOfficialTemplates upserts the built-in catalog on startup so new
// templates ship with releases without needing a migration per entry.
func SeedOfficialTemplates(ctx context.Context, db *database.DB) {
if db == nil || db.DB == nil {
return
}
queries := sqlcdb.New(db.DB)
for _, t := range SeedTemplates() {
variables := pqtype.NullRawMessage{}
if strings.TrimSpace(t.Variables) != "" {
variables = pqtype.NullRawMessage{RawMessage: json.RawMessage(t.Variables), Valid: true}
}
if err := queries.UpsertServiceTemplate(ctx, sqlcdb.UpsertServiceTemplateParams{
ID: t.ID,
Name: t.Name,
Description: nullableText(t.Description),
Category: t.Category,
Logo: nullableText(t.Logo),
Config: json.RawMessage(t.Config),
Variables: variables,
IsOfficial: sql.NullBool{Bool: true, Valid: true},
}); err != nil {
log.Printf("Failed to seed template %s: %v", t.ID, err)
}
}
}
func SeedTemplates() []ServiceTemplate { func SeedTemplates() []ServiceTemplate {
templates := []ServiceTemplate{ templates := []ServiceTemplate{
{ {
@@ -505,6 +752,167 @@ func SeedTemplates() []ServiceTemplate {
Variables: `[{"key":"IMAGE","label":"Docker Image","default":"","required":true,"secret":false},{"key":"TAG","label":"Image Tag","default":"latest","required":false,"secret":false}]`, Variables: `[{"key":"IMAGE","label":"Docker Image","default":"","required":true,"secret":false},{"key":"TAG","label":"Image Tag","default":"latest","required":false,"secret":false}]`,
IsOfficial: true, IsOfficial: true,
}, },
// Self-hosted applications — runtime doubles as the image reference.
{
ID: "tpl-nextcloud",
Name: "Nextcloud",
Description: "Self-hosted file sync, share and collaboration platform",
Category: "app",
Logo: "https://cdn.simpleicons.org/nextcloud",
Config: `{"type":"web","runtime":"nextcloud:30-apache","port":80,"health_check":"/status.php"}`,
Variables: `[{"key":"NEXTCLOUD_ADMIN_USER","label":"Admin User","default":"admin","required":false,"secret":false},{"key":"NEXTCLOUD_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true},{"key":"NEXTCLOUD_TRUSTED_DOMAINS","label":"Trusted Domains","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-gitea",
Name: "Gitea",
Description: "Lightweight self-hosted Git service",
Category: "app",
Logo: "https://cdn.simpleicons.org/gitea",
Config: `{"type":"web","runtime":"gitea/gitea:1","port":3000,"health_check":"/api/healthz"}`,
Variables: `[{"key":"GITEA__server__DOMAIN","label":"Server Domain","default":"localhost","required":false,"secret":false},{"key":"GITEA__server__ROOT_URL","label":"Root URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-ghost",
Name: "Ghost",
Description: "Independent publishing platform for blogs and newsletters",
Category: "app",
Logo: "https://cdn.simpleicons.org/ghost",
Config: `{"type":"web","runtime":"ghost:5-alpine","port":2368,"health_check":"/ghost/api/admin/site/"}`,
Variables: `[{"key":"url","label":"Public URL","default":"","required":true,"secret":false},{"key":"database__client","label":"Database Client","default":"sqlite3","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-uptime-kuma",
Name: "Uptime Kuma",
Description: "Self-hosted monitoring tool for services and websites",
Category: "app",
Logo: "https://cdn.simpleicons.org/uptimerobot",
Config: `{"type":"web","runtime":"louislam/uptime-kuma:1","port":3001,"health_check":"/"}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-vaultwarden",
Name: "Vaultwarden",
Description: "Lightweight Bitwarden-compatible password manager",
Category: "app",
Logo: "https://cdn.simpleicons.org/bitwarden",
Config: `{"type":"web","runtime":"vaultwarden/server:latest","port":80,"health_check":"/alive"}`,
Variables: `[{"key":"ADMIN_TOKEN","label":"Admin Token","default":"","required":false,"secret":true},{"key":"SIGNUPS_ALLOWED","label":"Allow Signups","default":"false","required":false,"secret":false},{"key":"DOMAIN","label":"Domain URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-jellyfin",
Name: "Jellyfin",
Description: "Free software media system for movies, shows and music",
Category: "app",
Logo: "https://cdn.simpleicons.org/jellyfin",
Config: `{"type":"web","runtime":"jellyfin/jellyfin:latest","port":8096,"health_check":"/health"}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-n8n",
Name: "n8n",
Description: "Workflow automation tool with hundreds of integrations",
Category: "app",
Logo: "https://cdn.simpleicons.org/n8n",
Config: `{"type":"web","runtime":"n8nio/n8n:latest","port":5678,"health_check":"/healthz"}`,
Variables: `[{"key":"N8N_HOST","label":"Hostname","default":"","required":false,"secret":false},{"key":"N8N_ENCRYPTION_KEY","label":"Encryption Key","default":"","required":true,"secret":true},{"key":"WEBHOOK_URL","label":"Webhook URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-grafana",
Name: "Grafana",
Description: "Observability dashboards for metrics, logs and traces",
Category: "app",
Logo: "https://cdn.simpleicons.org/grafana",
Config: `{"type":"web","runtime":"grafana/grafana:latest","port":3000,"health_check":"/api/health"}`,
Variables: `[{"key":"GF_SECURITY_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-immich",
Name: "Immich",
Description: "Self-hosted photo and video backup",
Category: "app",
Logo: "https://cdn.simpleicons.org/immich",
Config: `{"type":"web","runtime":"ghcr.io/immich-app/immich-server:release","port":2283,"health_check":"/api/server/ping"}`,
Variables: `[{"key":"DB_PASSWORD","label":"Database Password","default":"","required":true,"secret":true},{"key":"DB_HOSTNAME","label":"Database Host","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-meilisearch",
Name: "Meilisearch",
Description: "Fast, typo-tolerant search engine API",
Category: "app",
Logo: "https://cdn.simpleicons.org/meilisearch",
Config: `{"type":"web","runtime":"getmeili/meilisearch:v1","port":7700,"health_check":"/health"}`,
Variables: `[{"key":"MEILI_MASTER_KEY","label":"Master Key","default":"","required":true,"secret":true},{"key":"MEILI_ENV","label":"Environment","default":"production","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-plausible",
Name: "Plausible Analytics",
Description: "Lightweight, privacy-friendly web analytics",
Category: "app",
Logo: "https://cdn.simpleicons.org/plausibleanalytics",
Config: `{"type":"web","runtime":"ghcr.io/plausible/community-edition:v2","port":8000,"health_check":"/api/health"}`,
Variables: `[{"key":"BASE_URL","label":"Base URL","default":"","required":true,"secret":false},{"key":"SECRET_KEY_BASE","label":"Secret Key Base","default":"","required":true,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-minio",
Name: "MinIO",
Description: "S3-compatible object storage",
Category: "app",
Logo: "https://cdn.simpleicons.org/minio",
Config: `{"type":"web","runtime":"minio/minio:latest","start_command":"server /data --console-address \":9001\"","port":9001,"health_check":"/minio/health/live"}`,
Variables: `[{"key":"MINIO_ROOT_USER","label":"Root User","default":"minioadmin","required":true,"secret":false},{"key":"MINIO_ROOT_PASSWORD","label":"Root Password","default":"","required":true,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-appwrite",
Name: "Appwrite",
Description: "Backend-as-a-service for web and mobile apps",
Category: "app",
Logo: "https://cdn.simpleicons.org/appwrite",
Config: `{"type":"web","runtime":"appwrite/appwrite:1.6","port":80,"health_check":"/health"}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-wordpress",
Name: "WordPress",
Description: "Classic CMS powering a large share of the web",
Category: "app",
Logo: "https://cdn.simpleicons.org/wordpress",
Config: `{"type":"web","runtime":"wordpress:6-apache","port":80,"health_check":"/wp-login.php"}`,
Variables: `[{"key":"WORDPRESS_DB_HOST","label":"Database Host","default":"","required":true,"secret":false},{"key":"WORDPRESS_DB_USER","label":"Database User","default":"wordpress","required":true,"secret":false},{"key":"WORDPRESS_DB_PASSWORD","label":"Database Password","default":"","required":true,"secret":true},{"key":"WORDPRESS_DB_NAME","label":"Database Name","default":"wordpress","required":true,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-paperless",
Name: "Paperless-ngx",
Description: "Document management system with OCR",
Category: "app",
Logo: "https://cdn.simpleicons.org/paperlessngx",
Config: `{"type":"web","runtime":"ghcr.io/paperless-ngx/paperless-ngx:latest","port":8000,"health_check":"/api/"}`,
Variables: `[{"key":"PAPERLESS_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true},{"key":"PAPERLESS_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-ntfy",
Name: "ntfy",
Description: "Push notification service over HTTP",
Category: "app",
Logo: "https://cdn.simpleicons.org/ntfy",
Config: `{"type":"web","runtime":"binwiederhier/ntfy:latest","start_command":"serve","port":80,"health_check":"/v1/health"}`,
Variables: `[]`,
IsOfficial: true,
},
} }
return templates return templates
} }
@@ -605,12 +1013,30 @@ func defaultTemplateResources(serviceType string) (cpu, memory string) {
} }
} }
// templateVisibleTo reports whether the caller may see a template: official
// templates are public, user templates are owner/admin only.
func templateVisibleTo(c *gin.Context, t ServiceTemplate) bool {
if t.IsOfficial {
return true
}
if contextIsAdmin(c) {
return true
}
userID := optionalUserID(c)
return userID != "" && t.OwnerID == userID
}
func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate { func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
variables := "[]" variables := "[]"
if row.Variables.Valid && len(row.Variables.RawMessage) > 0 { if row.Variables.Valid && len(row.Variables.RawMessage) > 0 {
variables = string(row.Variables.RawMessage) variables = string(row.Variables.RawMessage)
} }
ownerID := ""
if row.OwnerID.Valid {
ownerID = row.OwnerID.UUID.String()
}
return ServiceTemplate{ return ServiceTemplate{
ID: row.ID, ID: row.ID,
Name: row.Name, Name: row.Name,
@@ -620,6 +1046,7 @@ func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
Config: string(row.Config), Config: string(row.Config),
Variables: variables, Variables: variables,
IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool, IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool,
OwnerID: ownerID,
CreatedAt: templateNullTime(row.CreatedAt), CreatedAt: templateNullTime(row.CreatedAt),
UpdatedAt: templateNullTime(row.UpdatedAt), UpdatedAt: templateNullTime(row.UpdatedAt),
} }
+6 -34
View File
@@ -47,26 +47,12 @@ func handleGetVariables(c *gin.Context) {
return return
} }
userID, exists := c.Get("user_id") projectID, found := serviceProjectID(db.(*database.DB), serviceID)
if !exists { if !found {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return return
} }
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
@@ -123,26 +109,12 @@ func handleUpdateVariables(c *gin.Context) {
return return
} }
userID, exists := c.Get("user_id") projectID, found := serviceProjectID(db.(*database.DB), serviceID)
if !exists { if !found {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return return
} }
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
+6
View File
@@ -27,6 +27,12 @@ func Execute() {
cobra.CheckErr(rootCmd.Execute()) cobra.CheckErr(rootCmd.Execute())
} }
// SetVersion overrides the version reported by `containr --version`.
// Called by main with the value injected via -ldflags at build time.
func SetVersion(v string) {
rootCmd.Version = v
}
func init() { func init() {
cobra.OnInitialize(initConfig) cobra.OnInitialize(initConfig)
+10
View File
@@ -91,6 +91,12 @@ type Config struct {
// Dashboard UI Configuration // Dashboard UI Configuration
DashboardUIBasePath string `env:"DASHBOARD_UI_BASE_PATH" default:"/"` DashboardUIBasePath string `env:"DASHBOARD_UI_BASE_PATH" default:"/"`
// Admin bootstrap — when set, the account is provisioned/promoted on
// startup so the owner always has admin credentials that work.
AdminEmail string `env:"ADMIN_EMAIL" default:""`
AdminPassword string `env:"ADMIN_PASSWORD" default:""`
AdminName string `env:"ADMIN_NAME" default:""`
} }
// Environment represents the application environment // Environment represents the application environment
@@ -181,6 +187,10 @@ func Load() *Config {
cfg.DashboardUIBasePath = getenv("DASHBOARD_UI_BASE_PATH", "/") cfg.DashboardUIBasePath = getenv("DASHBOARD_UI_BASE_PATH", "/")
cfg.AdminEmail = getenv("ADMIN_EMAIL", "")
cfg.AdminPassword = getenv("ADMIN_PASSWORD", "")
cfg.AdminName = getenv("ADMIN_NAME", "")
// Validate configuration // Validate configuration
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
panic(fmt.Sprintf("Invalid configuration: %v", err)) panic(fmt.Sprintf("Invalid configuration: %v", err))
+1 -1
View File
@@ -55,5 +55,5 @@ func (db *DB) Health(ctx context.Context) error {
// Stats returns connection pool statistics for monitoring // Stats returns connection pool statistics for monitoring
func (db *DB) Stats() sql.DBStats { func (db *DB) Stats() sql.DBStats {
return db.Stats() return db.DB.Stats()
} }
@@ -149,37 +149,18 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
return i, err return i, err
} }
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services FROM database_services
WHERE id = $1 AND user_id = $2 WHERE id = $1
` `
type GetDatabaseServiceByIDAndUserParams struct { func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error) {
ID string `json:"id"` row := q.db.QueryRowContext(ctx, getDatabaseServiceByID, id)
UserID string `json:"user_id"` var i DatabaseService
}
type GetDatabaseServiceByIDAndUserRow struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"`
BackupSchedule sql.NullString `json:"backup_schedule"`
NextBackupAt sql.NullTime `json:"next_backup_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (GetDatabaseServiceByIDAndUserRow, error) {
row := q.db.QueryRowContext(ctx, getDatabaseServiceByIDAndUser, arg.ID, arg.UserID)
var i GetDatabaseServiceByIDAndUserRow
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.UserID,
&i.Name, &i.Name,
&i.Type, &i.Type,
&i.Status, &i.Status,
@@ -195,6 +176,94 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
return i, err return i, err
} }
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
WHERE id = $1 AND user_id = $2
`
type GetDatabaseServiceByIDAndUserParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
}
func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error) {
row := q.db.QueryRowContext(ctx, getDatabaseServiceByIDAndUser, arg.ID, arg.UserID)
var i DatabaseService
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Type,
&i.Status,
&i.Version,
&i.Plan,
&i.Region,
&i.ConnectionUrl,
&i.BackupSchedule,
&i.NextBackupAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getDatabaseServiceOwnerID = `-- name: GetDatabaseServiceOwnerID :one
SELECT user_id
FROM database_services
WHERE id = $1
`
func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error) {
row := q.db.QueryRowContext(ctx, getDatabaseServiceOwnerID, id)
var user_id string
err := row.Scan(&user_id)
return user_id, err
}
const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
ORDER BY created_at DESC
`
func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error) {
rows, err := q.db.QueryContext(ctx, listAllDatabaseServices)
if err != nil {
return nil, err
}
defer rows.Close()
items := []DatabaseService{}
for rows.Next() {
var i DatabaseService
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Type,
&i.Status,
&i.Version,
&i.Plan,
&i.Region,
&i.ConnectionUrl,
&i.BackupSchedule,
&i.NextBackupAt,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDatabaseBackupsByDatabaseAndUser = `-- name: ListDatabaseBackupsByDatabaseAndUser :many const listDatabaseBackupsByDatabaseAndUser = `-- name: ListDatabaseBackupsByDatabaseAndUser :many
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at
FROM database_backups b FROM database_backups b
@@ -242,38 +311,24 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
} }
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services FROM database_services
WHERE user_id = $1 WHERE user_id = $1
ORDER BY created_at DESC ORDER BY created_at DESC
` `
type ListDatabaseServicesByUserRow struct { func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"`
BackupSchedule sql.NullString `json:"backup_schedule"`
NextBackupAt sql.NullTime `json:"next_backup_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error) {
rows, err := q.db.QueryContext(ctx, listDatabaseServicesByUser, userID) rows, err := q.db.QueryContext(ctx, listDatabaseServicesByUser, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
items := []ListDatabaseServicesByUserRow{} items := []DatabaseService{}
for rows.Next() { for rows.Next() {
var i ListDatabaseServicesByUserRow var i DatabaseService
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.UserID,
&i.Name, &i.Name,
&i.Type, &i.Type,
&i.Status, &i.Status,
@@ -154,6 +154,7 @@ type Project struct {
Name string `json:"name"` Name string `json:"name"`
Description sql.NullString `json:"description"` Description sql.NullString `json:"description"`
OwnerID uuid.UUID `json:"owner_id"` OwnerID uuid.UUID `json:"owner_id"`
IsApproved bool `json:"is_approved"`
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
@@ -205,6 +206,7 @@ type ServiceTemplate struct {
Config json.RawMessage `json:"config"` Config json.RawMessage `json:"config"`
Variables pqtype.NullRawMessage `json:"variables"` Variables pqtype.NullRawMessage `json:"variables"`
IsOfficial sql.NullBool `json:"is_official"` IsOfficial sql.NullBool `json:"is_official"`
OwnerID uuid.NullUUID `json:"owner_id"`
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
@@ -16,25 +16,31 @@ const countProjectsByUser = `-- name: CountProjectsByUser :one
SELECT COUNT(*)::bigint AS total SELECT COUNT(*)::bigint AS total
FROM projects p FROM projects p
WHERE WHERE
(p.owner_id = $1 OR EXISTS ( (
SELECT 1 p.is_approved
FROM project_members pm OR p.owner_id = $1
WHERE pm.project_id = p.id AND pm.user_id = $1 OR $2::bool
)) OR EXISTS (
SELECT 1
FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = $1
)
)
AND ( AND (
$2::text IS NULL $3::text IS NULL
OR p.name ILIKE ('%' || $2::text || '%') OR p.name ILIKE ('%' || $3::text || '%')
OR COALESCE(p.description, '') ILIKE ('%' || $2::text || '%') OR COALESCE(p.description, '') ILIKE ('%' || $3::text || '%')
) )
` `
type CountProjectsByUserParams struct { type CountProjectsByUserParams struct {
UserID uuid.UUID `json:"user_id"` UserID uuid.UUID `json:"user_id"`
Search sql.NullString `json:"search"` IsAdmin bool `json:"is_admin"`
Search sql.NullString `json:"search"`
} }
func (q *Queries) CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) { func (q *Queries) CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countProjectsByUser, arg.UserID, arg.Search) row := q.db.QueryRowContext(ctx, countProjectsByUser, arg.UserID, arg.IsAdmin, arg.Search)
var total int64 var total int64
err := row.Scan(&total) err := row.Scan(&total)
return total, err return total, err
@@ -43,7 +49,7 @@ func (q *Queries) CountProjectsByUser(ctx context.Context, arg CountProjectsByUs
const createProject = `-- name: CreateProject :one const createProject = `-- name: CreateProject :one
INSERT INTO projects (name, description, owner_id) INSERT INTO projects (name, description, owner_id)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
RETURNING id, name, description, owner_id, created_at, updated_at RETURNING id, name, description, owner_id, is_approved, created_at, updated_at
` `
type CreateProjectParams struct { type CreateProjectParams struct {
@@ -60,6 +66,7 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (P
&i.Name, &i.Name,
&i.Description, &i.Description,
&i.OwnerID, &i.OwnerID,
&i.IsApproved,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -80,11 +87,13 @@ func (q *Queries) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (i
} }
const getProjectByIDForUser = `-- name: GetProjectByIDForUser :one const getProjectByIDForUser = `-- name: GetProjectByIDForUser :one
SELECT p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
FROM projects p FROM projects p
WHERE p.id = $1 WHERE p.id = $1
AND ( AND (
p.owner_id = $2 p.is_approved
OR p.owner_id = $2
OR $3::bool
OR EXISTS ( OR EXISTS (
SELECT 1 SELECT 1
FROM project_members pm FROM project_members pm
@@ -96,16 +105,18 @@ WHERE p.id = $1
type GetProjectByIDForUserParams struct { type GetProjectByIDForUserParams struct {
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"` UserID uuid.UUID `json:"user_id"`
IsAdmin bool `json:"is_admin"`
} }
func (q *Queries) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error) { func (q *Queries) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error) {
row := q.db.QueryRowContext(ctx, getProjectByIDForUser, arg.ProjectID, arg.UserID) row := q.db.QueryRowContext(ctx, getProjectByIDForUser, arg.ProjectID, arg.UserID, arg.IsAdmin)
var i Project var i Project
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.Description, &i.Description,
&i.OwnerID, &i.OwnerID,
&i.IsApproved,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -127,21 +138,22 @@ func (q *Queries) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID)
const getProjectRoleForUser = `-- name: GetProjectRoleForUser :one const getProjectRoleForUser = `-- name: GetProjectRoleForUser :one
SELECT (CASE SELECT (CASE
WHEN p.owner_id = $1 THEN 'owner' WHEN p.owner_id = $1 OR $2::bool THEN 'owner'
ELSE COALESCE(pm.role, '') ELSE COALESCE(pm.role, '')
END)::text AS role END)::text AS role
FROM projects p FROM projects p
LEFT JOIN project_members pm ON p.id = pm.project_id AND pm.user_id = $1 LEFT JOIN project_members pm ON p.id = pm.project_id AND pm.user_id = $1
WHERE p.id = $2 WHERE p.id = $3
` `
type GetProjectRoleForUserParams struct { type GetProjectRoleForUserParams struct {
UserID uuid.UUID `json:"user_id"` UserID uuid.UUID `json:"user_id"`
IsAdmin bool `json:"is_admin"`
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`
} }
func (q *Queries) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) { func (q *Queries) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) {
row := q.db.QueryRowContext(ctx, getProjectRoleForUser, arg.UserID, arg.ProjectID) row := q.db.QueryRowContext(ctx, getProjectRoleForUser, arg.UserID, arg.IsAdmin, arg.ProjectID)
var role string var role string
err := row.Scan(&role) err := row.Scan(&role)
return role, err return role, err
@@ -162,12 +174,52 @@ func (q *Queries) InsertProjectEnvironment(ctx context.Context, arg InsertProjec
return err return err
} }
const listPendingProjects = `-- name: ListPendingProjects :many
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
FROM projects p
WHERE p.is_approved = false
ORDER BY p.created_at DESC
`
func (q *Queries) ListPendingProjects(ctx context.Context) ([]Project, error) {
rows, err := q.db.QueryContext(ctx, listPendingProjects)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Project{}
for rows.Next() {
var i Project
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.OwnerID,
&i.IsApproved,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjectsWithStatsByUser = `-- name: ListProjectsWithStatsByUser :many const listProjectsWithStatsByUser = `-- name: ListProjectsWithStatsByUser :many
SELECT SELECT
p.id, p.id,
p.name, p.name,
p.description, p.description,
p.owner_id, p.owner_id,
p.is_approved,
p.created_at, p.created_at,
p.updated_at, p.updated_at,
COUNT(DISTINCT s.id)::bigint AS service_count, COUNT(DISTINCT s.id)::bigint AS service_count,
@@ -185,23 +237,29 @@ FROM projects p
LEFT JOIN services s ON s.project_id = p.id LEFT JOIN services s ON s.project_id = p.id
LEFT JOIN deployments d ON d.service_id = s.id LEFT JOIN deployments d ON d.service_id = s.id
WHERE WHERE
(p.owner_id = $1 OR EXISTS ( (
SELECT 1 p.is_approved
FROM project_members pm OR p.owner_id = $1
WHERE pm.project_id = p.id AND pm.user_id = $1 OR $2::bool
)) OR EXISTS (
AND ( SELECT 1
$2::text IS NULL FROM project_members pm
OR p.name ILIKE ('%' || $2::text || '%') WHERE pm.project_id = p.id AND pm.user_id = $1
OR COALESCE(p.description, '') ILIKE ('%' || $2::text || '%') )
) )
GROUP BY p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at AND (
$3::text IS NULL
OR p.name ILIKE ('%' || $3::text || '%')
OR COALESCE(p.description, '') ILIKE ('%' || $3::text || '%')
)
GROUP BY p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
ORDER BY p.updated_at DESC ORDER BY p.updated_at DESC
LIMIT $4 OFFSET $3 LIMIT $5 OFFSET $4
` `
type ListProjectsWithStatsByUserParams struct { type ListProjectsWithStatsByUserParams struct {
UserID uuid.UUID `json:"user_id"` UserID uuid.UUID `json:"user_id"`
IsAdmin bool `json:"is_admin"`
Search sql.NullString `json:"search"` Search sql.NullString `json:"search"`
OffsetCount int32 `json:"offset_count"` OffsetCount int32 `json:"offset_count"`
LimitCount int32 `json:"limit_count"` LimitCount int32 `json:"limit_count"`
@@ -212,6 +270,7 @@ type ListProjectsWithStatsByUserRow struct {
Name string `json:"name"` Name string `json:"name"`
Description sql.NullString `json:"description"` Description sql.NullString `json:"description"`
OwnerID uuid.UUID `json:"owner_id"` OwnerID uuid.UUID `json:"owner_id"`
IsApproved bool `json:"is_approved"`
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
ServiceCount int64 `json:"service_count"` ServiceCount int64 `json:"service_count"`
@@ -220,9 +279,13 @@ type ListProjectsWithStatsByUserRow struct {
LastDeployment sql.NullTime `json:"last_deployment"` LastDeployment sql.NullTime `json:"last_deployment"`
} }
// Visibility model: a project is readable when it is approved for public
// display, when the caller owns or is a member of it, or when the caller is a
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) { func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) {
rows, err := q.db.QueryContext(ctx, listProjectsWithStatsByUser, rows, err := q.db.QueryContext(ctx, listProjectsWithStatsByUser,
arg.UserID, arg.UserID,
arg.IsAdmin,
arg.Search, arg.Search,
arg.OffsetCount, arg.OffsetCount,
arg.LimitCount, arg.LimitCount,
@@ -239,6 +302,7 @@ func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProje
&i.Name, &i.Name,
&i.Description, &i.Description,
&i.OwnerID, &i.OwnerID,
&i.IsApproved,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ServiceCount, &i.ServiceCount,
@@ -259,6 +323,25 @@ func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProje
return items, nil return items, nil
} }
const setProjectApproved = `-- name: SetProjectApproved :execrows
UPDATE projects
SET is_approved = $1, updated_at = NOW()
WHERE id = $2
`
type SetProjectApprovedParams struct {
IsApproved bool `json:"is_approved"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setProjectApproved, arg.IsApproved, arg.ProjectID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const updateProjectByID = `-- name: UpdateProjectByID :execrows const updateProjectByID = `-- name: UpdateProjectByID :execrows
UPDATE projects UPDATE projects
SET SET
@@ -25,10 +25,12 @@ type Querier interface {
CreateNotification(ctx context.Context, arg CreateNotificationParams) error CreateNotification(ctx context.Context, arg CreateNotificationParams) error
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error) DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
DeleteAgent(ctx context.Context, id string) error DeleteAgent(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error) GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
GetAgent(ctx context.Context, id string) (NodeAgent, error) GetAgent(ctx context.Context, id string) (NodeAgent, error)
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
@@ -36,7 +38,9 @@ type Querier interface {
GetContainer(ctx context.Context, id string) (ContainerInstance, error) GetContainer(ctx context.Context, id string) (ContainerInstance, error)
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error) GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, arg GetDatabaseBackupByIDAndDatabaseAndUserParams) (DatabaseBackup, error) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, arg GetDatabaseBackupByIDAndDatabaseAndUserParams) (DatabaseBackup, error)
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (GetDatabaseServiceByIDAndUserRow, error) GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error)
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
@@ -47,16 +51,21 @@ type Querier interface {
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error) ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
ListAgents(ctx context.Context) ([]NodeAgent, error) ListAgents(ctx context.Context) ([]NodeAgent, error)
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error) ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error) ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error) ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListPendingProjects(ctx context.Context) ([]Project, error)
// Visibility model: a project is readable when it is approved for public
// display, when the caller owns or is a member of it, or when the caller is a
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
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)
@@ -66,6 +75,7 @@ type Querier interface {
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
TouchAgentAuthToken(ctx context.Context, tokenHash string) error TouchAgentAuthToken(ctx context.Context, tokenHash string) error
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
@@ -74,7 +84,9 @@ type Querier interface {
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error) UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
} }
var _ Querier = (*Queries)(nil) var _ Querier = (*Queries)(nil)
@@ -8,8 +8,10 @@ package sqlcdb
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
) )
const countServicesByProjectAndName = `-- name: CountServicesByProjectAndName :one const countServicesByProjectAndName = `-- name: CountServicesByProjectAndName :one
@@ -122,6 +124,66 @@ func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServi
return err return err
} }
const createUserTemplate = `-- name: CreateUserTemplate :exec
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
false,
$8
)
`
type CreateUserTemplateParams struct {
ID string `json:"id"`
Name string `json:"name"`
Description sql.NullString `json:"description"`
Category string `json:"category"`
Logo sql.NullString `json:"logo"`
Config json.RawMessage `json:"config"`
Variables pqtype.NullRawMessage `json:"variables"`
OwnerID uuid.NullUUID `json:"owner_id"`
}
func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error {
_, err := q.db.ExecContext(ctx, createUserTemplate,
arg.ID,
arg.Name,
arg.Description,
arg.Category,
arg.Logo,
arg.Config,
arg.Variables,
arg.OwnerID,
)
return err
}
const deleteUserTemplate = `-- name: DeleteUserTemplate :execrows
DELETE FROM service_templates
WHERE id = $1
AND owner_id = $2
AND is_official = false
`
type DeleteUserTemplateParams struct {
ID string `json:"id"`
OwnerID uuid.NullUUID `json:"owner_id"`
}
func (q *Queries) DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteUserTemplate, arg.ID, arg.OwnerID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getProjectOwnerID = `-- name: GetProjectOwnerID :one const getProjectOwnerID = `-- name: GetProjectOwnerID :one
SELECT owner_id SELECT owner_id
FROM projects FROM projects
@@ -136,7 +198,7 @@ func (q *Queries) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUI
} }
const getServiceTemplateByID = `-- name: GetServiceTemplateByID :one const getServiceTemplateByID = `-- name: GetServiceTemplateByID :one
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
FROM service_templates FROM service_templates
WHERE id = $1 WHERE id = $1
` `
@@ -153,20 +215,28 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic
&i.Config, &i.Config,
&i.Variables, &i.Variables,
&i.IsOfficial, &i.IsOfficial,
&i.OwnerID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
return i, err return i, err
} }
const listServiceTemplates = `-- name: ListServiceTemplates :many const listServiceTemplatesByCategoryForUser = `-- name: ListServiceTemplatesByCategoryForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
FROM service_templates FROM service_templates
WHERE category = $1
AND (is_official = true OR owner_id = $2)
ORDER BY is_official DESC, name ASC ORDER BY is_official DESC, name ASC
` `
func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error) { type ListServiceTemplatesByCategoryForUserParams struct {
rows, err := q.db.QueryContext(ctx, listServiceTemplates) Category string `json:"category"`
OwnerID uuid.NullUUID `json:"owner_id"`
}
func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) {
rows, err := q.db.QueryContext(ctx, listServiceTemplatesByCategoryForUser, arg.Category, arg.OwnerID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -183,6 +253,7 @@ func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate,
&i.Config, &i.Config,
&i.Variables, &i.Variables,
&i.IsOfficial, &i.IsOfficial,
&i.OwnerID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@@ -199,15 +270,15 @@ func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate,
return items, nil return items, nil
} }
const listServiceTemplatesByCategory = `-- name: ListServiceTemplatesByCategory :many const listServiceTemplatesForUser = `-- name: ListServiceTemplatesForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
FROM service_templates FROM service_templates
WHERE category = $1 WHERE is_official = true OR owner_id = $1
ORDER BY is_official DESC, name ASC ORDER BY is_official DESC, name ASC
` `
func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error) { func (q *Queries) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) {
rows, err := q.db.QueryContext(ctx, listServiceTemplatesByCategory, category) rows, err := q.db.QueryContext(ctx, listServiceTemplatesForUser, ownerID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -224,6 +295,7 @@ func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category s
&i.Config, &i.Config,
&i.Variables, &i.Variables,
&i.IsOfficial, &i.IsOfficial,
&i.OwnerID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@@ -240,6 +312,48 @@ func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category s
return items, nil return items, nil
} }
const updateUserTemplate = `-- name: UpdateUserTemplate :execrows
UPDATE service_templates
SET name = $1,
description = $2,
category = $3,
logo = $4,
config = $5,
variables = $6,
updated_at = NOW()
WHERE id = $7
AND owner_id = $8
AND is_official = false
`
type UpdateUserTemplateParams struct {
Name string `json:"name"`
Description sql.NullString `json:"description"`
Category string `json:"category"`
Logo sql.NullString `json:"logo"`
Config json.RawMessage `json:"config"`
Variables pqtype.NullRawMessage `json:"variables"`
ID string `json:"id"`
OwnerID uuid.NullUUID `json:"owner_id"`
}
func (q *Queries) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error) {
result, err := q.db.ExecContext(ctx, updateUserTemplate,
arg.Name,
arg.Description,
arg.Category,
arg.Logo,
arg.Config,
arg.Variables,
arg.ID,
arg.OwnerID,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const upsertEnvironmentVariable = `-- name: UpsertEnvironmentVariable :exec const upsertEnvironmentVariable = `-- name: UpsertEnvironmentVariable :exec
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at) INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7)
@@ -271,3 +385,51 @@ func (q *Queries) UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvir
) )
return err return err
} }
const upsertServiceTemplate = `-- name: UpsertServiceTemplate :exec
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8
)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name,
description = EXCLUDED.description,
category = EXCLUDED.category,
logo = EXCLUDED.logo,
config = EXCLUDED.config,
variables = EXCLUDED.variables,
is_official = EXCLUDED.is_official,
updated_at = NOW()
`
type UpsertServiceTemplateParams struct {
ID string `json:"id"`
Name string `json:"name"`
Description sql.NullString `json:"description"`
Category string `json:"category"`
Logo sql.NullString `json:"logo"`
Config json.RawMessage `json:"config"`
Variables pqtype.NullRawMessage `json:"variables"`
IsOfficial sql.NullBool `json:"is_official"`
}
func (q *Queries) UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error {
_, err := q.db.ExecContext(ctx, upsertServiceTemplate,
arg.ID,
arg.Name,
arg.Description,
arg.Category,
arg.Logo,
arg.Config,
arg.Variables,
arg.IsOfficial,
)
return err
}
+93 -30
View File
@@ -69,41 +69,72 @@ func RequestID() gin.HandlerFunc {
} }
} }
// resolveRequestUser authenticates the request via Bearer JWT or the Better
// Auth session cookie. On success it returns the local user id and email.
// tokenErr/hasToken mirror the legacy error contract for Auth.
func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) {
tokenString, tokenErr, hasToken := extractJWTToken(c)
if tokenString != "" {
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
userIDClaim, exists := claims["user_id"]
if exists {
id := strings.TrimSpace(fmt.Sprint(userIDClaim))
if _, err := uuid.Parse(id); err == nil {
if emailClaim, exists := claims["email"]; exists && emailClaim != nil {
email = strings.TrimSpace(fmt.Sprint(emailClaim))
}
return id, email, "", hasToken, true
}
}
tokenErr = "Invalid token claims"
} else if tokenErr == "" {
tokenErr = "Invalid token"
}
}
if sessionVerifier != nil {
if id, mail, verified := sessionVerifier.resolveUser(c); verified {
return id, mail, "", hasToken, true
}
}
return "", "", tokenErr, hasToken, false
}
// setAuthenticatedContext stores the resolved identity plus the admin flag on
// the gin context so handlers and RequireAdmin can rely on it.
func setAuthenticatedContext(c *gin.Context, userID, email string) {
c.Set("user_id", userID)
c.Set("email", email)
c.Set("is_admin", loadIsAdmin(c, userID))
}
func loadIsAdmin(c *gin.Context, userID string) bool {
dbValue, exists := c.Get("db")
if !exists {
return false
}
db, ok := dbValue.(*database.DB)
if !ok || db == nil || db.DB == nil {
return false
}
var isAdmin bool
if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil {
return false
}
return isAdmin
}
// Auth middleware for JWT authentication // Auth middleware for JWT authentication
func Auth(jwtSecret string) gin.HandlerFunc { func Auth(jwtSecret string) gin.HandlerFunc {
sessionVerifier := newBetterAuthSessionVerifier() sessionVerifier := newBetterAuthSessionVerifier()
return func(c *gin.Context) { return func(c *gin.Context) {
tokenString, tokenErr, hasToken := extractJWTToken(c) userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier)
if tokenString != "" { if ok {
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid { setAuthenticatedContext(c, userID, email)
userIDClaim, exists := claims["user_id"] c.Next()
if exists { return
userID := strings.TrimSpace(fmt.Sprint(userIDClaim))
if _, err := uuid.Parse(userID); err == nil {
email := ""
if emailClaim, ok := claims["email"]; ok && emailClaim != nil {
email = strings.TrimSpace(fmt.Sprint(emailClaim))
}
c.Set("user_id", userID)
c.Set("email", email)
c.Next()
return
}
}
tokenErr = "Invalid token claims"
} else if tokenErr == "" {
tokenErr = "Invalid token"
}
}
if sessionVerifier != nil {
if userID, email, ok := sessionVerifier.resolveUser(c); ok {
c.Set("user_id", userID)
c.Set("email", email)
c.Next()
return
}
} }
if tokenErr != "" { if tokenErr != "" {
@@ -121,6 +152,38 @@ func Auth(jwtSecret string) gin.HandlerFunc {
} }
} }
// OptionalAuth resolves identity when a valid credential is present but never
// rejects the request — anonymous callers simply get no user context. Read
// endpoints hang off this so the public site can browse approved resources.
func OptionalAuth(jwtSecret string) gin.HandlerFunc {
sessionVerifier := newBetterAuthSessionVerifier()
return func(c *gin.Context) {
if userID, email, _, _, ok := resolveRequestUser(c, jwtSecret, sessionVerifier); ok {
setAuthenticatedContext(c, userID, email)
}
c.Next()
}
}
// RequireAdmin rejects requests whose resolved user is not a platform admin.
// Must run after Auth (or OptionalAuth) so user_id/is_admin are populated.
func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
if _, exists := c.Get("user_id"); !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
c.Abort()
return
}
if !c.GetBool("is_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
c.Abort()
return
}
c.Next()
}
}
type betterAuthSessionVerifier struct { type betterAuthSessionVerifier struct {
internalURL string internalURL string
internalToken string internalToken string
@@ -0,0 +1,16 @@
-- +goose Up
-- Public platform model: projects are browsable without a session once the
-- admin approves them; service_templates gains ownership for user templates.
ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS is_approved boolean NOT NULL DEFAULT false;
-- Projects that already exist stay live; only newly created ones enter the
-- approval queue.
UPDATE public.projects SET is_approved = true;
ALTER TABLE public.service_templates ADD COLUMN IF NOT EXISTS owner_id uuid;
CREATE INDEX IF NOT EXISTS idx_service_templates_owner ON public.service_templates(owner_id);
-- +goose Down
ALTER TABLE public.projects DROP COLUMN IF EXISTS is_approved;
DROP INDEX IF EXISTS public.idx_service_templates_owner;
ALTER TABLE public.service_templates DROP COLUMN IF EXISTS owner_id;
+17 -2
View File
@@ -1,11 +1,26 @@
-- name: ListDatabaseServicesByUser :many -- name: ListDatabaseServicesByUser :many
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services FROM database_services
WHERE user_id = $1 WHERE user_id = $1
ORDER BY created_at DESC; ORDER BY created_at DESC;
-- name: ListAllDatabaseServices :many
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
ORDER BY created_at DESC;
-- name: GetDatabaseServiceByID :one
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
WHERE id = $1;
-- name: GetDatabaseServiceOwnerID :one
SELECT user_id
FROM database_services
WHERE id = $1;
-- name: GetDatabaseServiceByIDAndUser :one -- name: GetDatabaseServiceByIDAndUser :one
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services FROM database_services
WHERE id = $1 AND user_id = $2; WHERE id = $1 AND user_id = $2;
+43 -15
View File
@@ -1,9 +1,14 @@
-- Visibility model: a project is readable when it is approved for public
-- display, when the caller owns or is a member of it, or when the caller is a
-- platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
-- name: ListProjectsWithStatsByUser :many -- name: ListProjectsWithStatsByUser :many
SELECT SELECT
p.id, p.id,
p.name, p.name,
p.description, p.description,
p.owner_id, p.owner_id,
p.is_approved,
p.created_at, p.created_at,
p.updated_at, p.updated_at,
COUNT(DISTINCT s.id)::bigint AS service_count, COUNT(DISTINCT s.id)::bigint AS service_count,
@@ -21,17 +26,22 @@ FROM projects p
LEFT JOIN services s ON s.project_id = p.id LEFT JOIN services s ON s.project_id = p.id
LEFT JOIN deployments d ON d.service_id = s.id LEFT JOIN deployments d ON d.service_id = s.id
WHERE WHERE
(p.owner_id = sqlc.arg(user_id) OR EXISTS ( (
SELECT 1 p.is_approved
FROM project_members pm OR p.owner_id = sqlc.arg(user_id)
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id) OR sqlc.arg(is_admin)::bool
)) OR EXISTS (
SELECT 1
FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
)
)
AND ( AND (
sqlc.narg(search)::text IS NULL sqlc.narg(search)::text IS NULL
OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%') OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%')
OR COALESCE(p.description, '') ILIKE ('%' || sqlc.narg(search)::text || '%') OR COALESCE(p.description, '') ILIKE ('%' || sqlc.narg(search)::text || '%')
) )
GROUP BY p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at GROUP BY p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
ORDER BY p.updated_at DESC ORDER BY p.updated_at DESC
LIMIT sqlc.arg(limit_count) OFFSET sqlc.arg(offset_count); LIMIT sqlc.arg(limit_count) OFFSET sqlc.arg(offset_count);
@@ -39,11 +49,16 @@ LIMIT sqlc.arg(limit_count) OFFSET sqlc.arg(offset_count);
SELECT COUNT(*)::bigint AS total SELECT COUNT(*)::bigint AS total
FROM projects p FROM projects p
WHERE WHERE
(p.owner_id = sqlc.arg(user_id) OR EXISTS ( (
SELECT 1 p.is_approved
FROM project_members pm OR p.owner_id = sqlc.arg(user_id)
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id) OR sqlc.arg(is_admin)::bool
)) OR EXISTS (
SELECT 1
FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
)
)
AND ( AND (
sqlc.narg(search)::text IS NULL sqlc.narg(search)::text IS NULL
OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%') OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%')
@@ -51,11 +66,13 @@ WHERE
); );
-- name: GetProjectByIDForUser :one -- name: GetProjectByIDForUser :one
SELECT p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
FROM projects p FROM projects p
WHERE p.id = sqlc.arg(project_id) WHERE p.id = sqlc.arg(project_id)
AND ( AND (
p.owner_id = sqlc.arg(user_id) p.is_approved
OR p.owner_id = sqlc.arg(user_id)
OR sqlc.arg(is_admin)::bool
OR EXISTS ( OR EXISTS (
SELECT 1 SELECT 1
FROM project_members pm FROM project_members pm
@@ -66,7 +83,7 @@ WHERE p.id = sqlc.arg(project_id)
-- name: CreateProject :one -- name: CreateProject :one
INSERT INTO projects (name, description, owner_id) INSERT INTO projects (name, description, owner_id)
VALUES (sqlc.arg(name), sqlc.narg(description), sqlc.arg(owner_id)) VALUES (sqlc.arg(name), sqlc.narg(description), sqlc.arg(owner_id))
RETURNING id, name, description, owner_id, created_at, updated_at; RETURNING id, name, description, owner_id, is_approved, created_at, updated_at;
-- name: InsertProjectEnvironment :exec -- name: InsertProjectEnvironment :exec
INSERT INTO environments (name, project_id) INSERT INTO environments (name, project_id)
@@ -74,7 +91,7 @@ VALUES (sqlc.arg(name), sqlc.arg(project_id));
-- name: GetProjectRoleForUser :one -- name: GetProjectRoleForUser :one
SELECT (CASE SELECT (CASE
WHEN p.owner_id = sqlc.arg(user_id) THEN 'owner' WHEN p.owner_id = sqlc.arg(user_id) OR sqlc.arg(is_admin)::bool THEN 'owner'
ELSE COALESCE(pm.role, '') ELSE COALESCE(pm.role, '')
END)::text AS role END)::text AS role
FROM projects p FROM projects p
@@ -97,3 +114,14 @@ WHERE id = sqlc.arg(project_id);
-- name: DeleteProjectByID :execrows -- name: DeleteProjectByID :execrows
DELETE FROM projects DELETE FROM projects
WHERE id = sqlc.arg(project_id); WHERE id = sqlc.arg(project_id);
-- name: SetProjectApproved :execrows
UPDATE projects
SET is_approved = sqlc.arg(is_approved), updated_at = NOW()
WHERE id = sqlc.arg(project_id);
-- name: ListPendingProjects :many
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
FROM projects p
WHERE p.is_approved = false
ORDER BY p.created_at DESC;
+63 -6
View File
@@ -1,19 +1,76 @@
-- name: ListServiceTemplates :many -- name: ListServiceTemplatesForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
FROM service_templates FROM service_templates
WHERE is_official = true OR owner_id = sqlc.narg(owner_id)
ORDER BY is_official DESC, name ASC; ORDER BY is_official DESC, name ASC;
-- name: ListServiceTemplatesByCategory :many -- name: ListServiceTemplatesByCategoryForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
FROM service_templates FROM service_templates
WHERE category = $1 WHERE category = sqlc.arg(category)
AND (is_official = true OR owner_id = sqlc.narg(owner_id))
ORDER BY is_official DESC, name ASC; ORDER BY is_official DESC, name ASC;
-- name: GetServiceTemplateByID :one -- name: GetServiceTemplateByID :one
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
FROM service_templates FROM service_templates
WHERE id = $1; WHERE id = $1;
-- name: UpsertServiceTemplate :exec
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official)
VALUES (
sqlc.arg(id),
sqlc.arg(name),
sqlc.narg(description),
sqlc.arg(category),
sqlc.narg(logo),
sqlc.arg(config),
sqlc.narg(variables),
sqlc.arg(is_official)
)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name,
description = EXCLUDED.description,
category = EXCLUDED.category,
logo = EXCLUDED.logo,
config = EXCLUDED.config,
variables = EXCLUDED.variables,
is_official = EXCLUDED.is_official,
updated_at = NOW();
-- name: CreateUserTemplate :exec
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id)
VALUES (
sqlc.arg(id),
sqlc.arg(name),
sqlc.narg(description),
sqlc.arg(category),
sqlc.narg(logo),
sqlc.arg(config),
sqlc.narg(variables),
false,
sqlc.arg(owner_id)
);
-- name: UpdateUserTemplate :execrows
UPDATE service_templates
SET name = sqlc.arg(name),
description = sqlc.narg(description),
category = sqlc.arg(category),
logo = sqlc.narg(logo),
config = sqlc.arg(config),
variables = sqlc.narg(variables),
updated_at = NOW()
WHERE id = sqlc.arg(id)
AND owner_id = sqlc.arg(owner_id)
AND is_official = false;
-- name: DeleteUserTemplate :execrows
DELETE FROM service_templates
WHERE id = sqlc.arg(id)
AND owner_id = sqlc.arg(owner_id)
AND is_official = false;
-- name: GetProjectOwnerID :one -- name: GetProjectOwnerID :one
SELECT owner_id SELECT owner_id
FROM projects FROM projects
+2
View File
@@ -3,6 +3,7 @@ CREATE TABLE projects (
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
description TEXT, description TEXT,
owner_id UUID NOT NULL, owner_id UUID NOT NULL,
is_approved BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
@@ -82,6 +83,7 @@ CREATE TABLE service_templates (
config JSONB NOT NULL, config JSONB NOT NULL,
variables JSONB DEFAULT '[]', variables JSONB DEFAULT '[]',
is_official BOOLEAN DEFAULT false, is_official BOOLEAN DEFAULT false,
owner_id UUID,
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()
); );
+39
View File
@@ -32,6 +32,7 @@
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"@types/dompurify": "^3.0.5",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"axios": "^1.7.9", "axios": "^1.7.9",
@@ -40,7 +41,9 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.2.6",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"marked": "^18.0.13",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -3719,6 +3722,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3763,6 +3775,12 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": { "node_modules/@types/use-sync-external-store": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -5205,6 +5223,15 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/dompurify": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -6577,6 +6604,18 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/marked": {
"version": "18.0.13",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.13.tgz",
"integrity": "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+6
View File
@@ -7,6 +7,9 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"build:check": "tsc -b && vite build", "build:check": "tsc -b && vite build",
"predev": "node scripts/generate-docs-snapshot.mjs",
"prebuild": "node scripts/generate-docs-snapshot.mjs",
"generate:docs": "node scripts/generate-docs-snapshot.mjs",
"generate:api": "openapi-typescript ../../docs/api/openapi.yaml -o src/generated/api-types.ts", "generate:api": "openapi-typescript ../../docs/api/openapi.yaml -o src/generated/api-types.ts",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
@@ -40,6 +43,7 @@
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"@types/dompurify": "^3.0.5",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"axios": "^1.7.9", "axios": "^1.7.9",
@@ -48,7 +52,9 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.2.6",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"marked": "^18.0.13",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -0,0 +1,68 @@
// Bundles the repository docs/ tree into a generated TS module so the docs
// browser renders instantly and offline. The GitHub sync layer refreshes it
// at runtime when network is available.
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const frontendRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..');
const root = join(frontendRoot, '..', '..');
const docsDir = join(root, 'docs');
const outFile = join(frontendRoot, 'src/generated/docs-snapshot.ts');
// The published frontend image builds with only app/frontend as its Docker
// context, so the repo docs tree is absent there — reuse the committed
// snapshot instead of failing on ENOENT.
if (!existsSync(docsDir)) {
if (!existsSync(outFile)) {
console.error('docs snapshot: docs/ missing and no committed snapshot to reuse');
process.exit(1);
}
console.warn('docs snapshot: docs/ not found, reusing committed snapshot');
process.exit(0);
}
// Directories that are internal working notes rather than user documentation.
const SKIP_DIRS = new Set(['archive', 'design', 'superpowers']);
function walk(dir) {
const entries = [];
for (const name of readdirSync(dir)) {
const full = join(dir, name);
const stat = statSync(full);
if (stat.isDirectory()) {
if (!SKIP_DIRS.has(name)) entries.push(...walk(full));
} else if (name.toLowerCase().endsWith('.md')) {
entries.push(full);
}
}
return entries;
}
function titleOf(markdown, fallback) {
const match = markdown.match(/^#\s+(.+)$/m);
if (match) return match[1].replace(/[#*`]/g, '').trim();
return fallback
.replace(/\.md$/i, '')
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
const files = walk(docsDir).sort();
const docs = files.map((full) => {
const rel = relative(docsDir, full).replace(/\\/g, '/');
const markdown = readFileSync(full, 'utf8');
const section = rel.includes('/') ? rel.split('/')[0] : 'docs';
return {
path: rel,
section,
title: titleOf(markdown, rel.split('/').pop() ?? rel),
markdown,
};
});
const banner = `/**\n * Auto-generated by scripts/generate-docs-snapshot.mjs.\n * Bundled copy of the repository docs tree — the offline fallback for the\n * docs browser. Regenerate with \`npm run generate:docs\`.\n */\n\nexport type BundledDoc = {\n path: string;\n section: string;\n title: string;\n markdown: string;\n};\n\n`;
mkdirSync(join(outFile, '..'), { recursive: true });
writeFileSync(outFile, `${banner}export const bundledDocs: BundledDoc[] = ${JSON.stringify(docs, null, 2)};\n`);
console.log(`docs snapshot: ${docs.length} files → ${relative(root, outFile)}`);
+87 -18
View File
@@ -1,4 +1,6 @@
import { Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom'; import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { ShieldCheck } from 'lucide-react';
import { PlatformShell } from './layout/PlatformShell'; import { PlatformShell } from './layout/PlatformShell';
import { ProjectsPage } from '@/features/projects/pages/ProjectsPage'; import { ProjectsPage } from '@/features/projects/pages/ProjectsPage';
import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage'; import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage';
@@ -7,6 +9,8 @@ import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetrics
import { BuildsPage } from '@/features/builds/pages/BuildsPage'; import { BuildsPage } from '@/features/builds/pages/BuildsPage';
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage'; import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage'; import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
import { AdminPage } from '@/features/admin/pages/AdminPage';
import { LandingPage } from '@/features/landing/LandingPage';
import { import {
DocsPage, DocsPage,
PeoplePage, PeoplePage,
@@ -19,12 +23,17 @@ 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 { 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 searchParams = new URLSearchParams(location.search); const isDemoMode = useDemoMode();
const isDemoMode = searchParams.get('demo') === '1';
const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
if (isDemoMode) { if (isDemoMode) {
@@ -47,38 +56,98 @@ function AuthRequired() {
return <Outlet />; return <Outlet />;
} }
function AdminRequired() {
const location = useLocation();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const profileQuery = useQuery({
queryKey: ['current-profile'],
queryFn: getCurrentUserProfile,
enabled: !isDemoMode && Boolean(sessionQuery.data),
retry: false,
});
if (isDemoMode) {
return <Outlet />;
}
if (sessionQuery.isPending || (sessionQuery.data && profileQuery.isPending)) {
return (
<div className="min-h-screen bg-[var(--bg-void)]">
<LoadingState message="Checking admin access..." className="h-screen" />
</div>
);
}
if (!sessionQuery.data) {
const redirect = `${location.pathname}${location.search}`;
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirect)}`} replace />;
}
if (!profileQuery.data?.isAdmin) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--bg-void)] px-6 text-center">
<ShieldCheck size={32} className="text-[var(--error)]" />
<div>
<h1 className="text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
<p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">
This area is restricted to the platform administrator.
</p>
</div>
<Link
to="/"
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Back to home
</Link>
</div>
);
}
return <Outlet />;
}
export default function App() { export default function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/projects" replace />} /> <Route path="/" element={<LandingPage />} />
<Route path="/auth/sign-in" element={<SignInPage />} /> <Route path="/auth/sign-in" element={<SignInPage />} />
<Route path="/auth/sign-up" element={<SignUpPage />} /> <Route path="/auth/sign-up" element={<SignUpPage />} />
<Route element={<AuthRequired />}> {/* Public browsing — approved projects, templates, docs. */}
<Route element={<PlatformShell />}> <Route element={<PlatformShell />}>
<Route path="/projects" element={<ProjectsPage />} /> <Route path="/projects" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} /> <Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} /> <Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
<Route path="/builds" element={<BuildsPage />} /> <Route path="/templates" element={<TemplatesPage />} />
<Route path="/databases" element={<DatabasesPage />} />
<Route path="/ha" element={<HighAvailabilityPage />} />
<Route path="/security" element={<SecurityPage />} />
<Route path="/usage" element={<UsagePage />} />
<Route path="/docs" element={<DocsPage />} />
<Route path="/templates" element={<TemplatesPage />} /> {/* Authenticated areas */}
<Route path="/usage" element={<UsagePage />} /> <Route element={<AuthRequired />}>
<Route path="/databases" element={<DatabasesPage />} /> <Route path="/builds" element={<BuildsPage />} />
<Route path="/ha" element={<HighAvailabilityPage />} />
<Route path="/security" element={<SecurityPage />} />
<Route path="/people" element={<PeoplePage />} /> <Route path="/people" element={<PeoplePage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/audit-logs" element={<AuditLogsPage />} /> <Route path="/settings/audit-logs" element={<AuditLogsPage />} />
<Route path="/docs" element={<DocsPage />} />
<Route path="/showcase" element={<ComponentShowcase />} /> <Route path="/showcase" element={<ComponentShowcase />} />
</Route> </Route>
{/* Full-bleed design demo — renders its own chrome, no app shell */} {/* Platform owner only */}
<Route element={<AdminRequired />}>
<Route path="/admin" element={<AdminPage />} />
</Route>
</Route>
{/* Full-bleed design demo — renders its own chrome, no app shell */}
<Route element={<AuthRequired />}>
<Route path="/metrics-demo" element={<ServiceMetricsDashboard />} /> <Route path="/metrics-demo" element={<ServiceMetricsDashboard />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/projects" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</ErrorBoundary> </ErrorBoundary>
); );
+321 -146
View File
@@ -1,27 +1,35 @@
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { import {
Bell, Bell,
BookOpen, BookOpen,
ChartBar, ChartBar,
CheckCircle2, CheckCircle2,
ChevronsLeft,
Container, Container,
Database, Database,
FileText,
FolderKanban, FolderKanban,
LayoutTemplate, LayoutTemplate,
LogIn,
LogOut, LogOut,
Moon, Moon,
PanelLeft,
RefreshCw, RefreshCw,
ScrollText,
Search, Search,
Settings, Settings,
Shield, Shield,
ShieldCheck, ShieldCheck,
Sun, Sun,
UploadCloud, UploadCloud,
User,
Users, Users,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getCurrentUserProfile,
getUpgradeStatus, getUpgradeStatus,
listNotifications, listNotifications,
markAllNotificationsRead, markAllNotificationsRead,
@@ -32,25 +40,62 @@ import { signOutAuthSession } from '@/lib/auth-client';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useToast } from '@/shared/components'; import { useToast } from '@/shared/components';
const navItems = [ type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string };
{ label: 'Projects', href: '/projects', icon: FolderKanban }, type NavSection = { title: string; items: NavItem[] };
{ label: 'Templates', href: '/templates', icon: LayoutTemplate },
{ label: 'Databases', href: '/databases', icon: Database }, const navSections: NavSection[] = [
{ label: 'HA', href: '/ha', icon: ShieldCheck }, {
{ label: 'Security', href: '/security', icon: Shield }, title: 'Build',
{ label: 'Usage', href: '/usage', icon: ChartBar }, items: [
{ label: 'People', href: '/people', icon: Users }, { label: 'Projects', href: '/projects', icon: FolderKanban, hint: 'Services & deployments' },
{ label: 'Settings', href: '/settings', icon: Settings }, { label: 'Templates', href: '/templates', icon: LayoutTemplate, hint: 'Deployable presets' },
{ label: 'Docs', href: '/docs', icon: BookOpen }, { label: 'Builds', href: '/builds', icon: FileText, hint: 'Build history' },
],
},
{
title: 'Operate',
items: [
{ label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' },
{ label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' },
{ label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' },
{ label: 'Usage', href: '/usage', icon: ChartBar, hint: 'Resource consumption' },
],
},
{
title: 'Workspace',
items: [
{ label: 'People', href: '/people', icon: Users, hint: 'Members & access' },
{ label: 'Audit Logs', href: '/settings/audit-logs', icon: ScrollText, hint: 'Activity trail' },
{ label: 'Settings', href: '/settings', icon: Settings, hint: 'Platform preferences' },
{ label: 'Docs', href: '/docs', icon: BookOpen, hint: 'Guides & reference' },
],
},
]; ];
type ThemeMode = 'dark' | 'light'; type ThemeMode = 'dark' | 'light';
function getInitialTheme(): ThemeMode { function readStorage(key: string): string | null {
if (typeof window === 'undefined') { try {
return 'dark'; return typeof window === 'undefined' ? null : window.localStorage.getItem(key);
} catch {
return null;
} }
return localStorage.getItem('containr.theme') === 'light' ? 'light' : 'dark'; }
function writeStorage(key: string, value: string) {
try {
window.localStorage.setItem(key, value);
} catch {
// Storage unavailable (SSR, tests, private mode) — non-persistent is fine.
}
}
function getInitialTheme(): ThemeMode {
return readStorage('containr.theme') === 'light' ? 'light' : 'dark';
}
function getInitialSidebar(): boolean {
return readStorage('containr.sidebar.expanded') !== '0';
} }
export function PlatformShell() { export function PlatformShell() {
@@ -61,9 +106,19 @@ export function PlatformShell() {
const isDemoMode = new URLSearchParams(location.search).get('demo') === '1'; const isDemoMode = new URLSearchParams(location.search).get('demo') === '1';
const href = (target: string) => (isDemoMode ? `${target}?demo=1` : target); const href = (target: string) => (isDemoMode ? `${target}?demo=1` : target);
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme()); const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
const [sidebarExpanded, setSidebarExpanded] = useState<boolean>(() => getInitialSidebar());
const [notificationsOpen, setNotificationsOpen] = useState(false); const [notificationsOpen, setNotificationsOpen] = useState(false);
const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const profileQuery = useQuery({
queryKey: ['current-profile'],
queryFn: getCurrentUserProfile,
enabled: !isDemoMode && Boolean(sessionQuery.data),
staleTime: 60_000,
retry: false,
});
const isAdmin = isDemoMode || Boolean(profileQuery.data?.isAdmin);
const upgradeQuery = useQuery({ const upgradeQuery = useQuery({
queryKey: ['upgrade-status'], queryKey: ['upgrade-status'],
queryFn: getUpgradeStatus, queryFn: getUpgradeStatus,
@@ -72,7 +127,7 @@ export function PlatformShell() {
const notificationsQuery = useQuery({ const notificationsQuery = useQuery({
queryKey: ['shell-notifications'], queryKey: ['shell-notifications'],
queryFn: () => listNotifications(20), queryFn: () => listNotifications(20),
enabled: !isDemoMode, enabled: !isDemoMode && signedIn,
refetchInterval: 30_000, refetchInterval: 30_000,
}); });
const markReadMutation = useMutation({ const markReadMutation = useMutation({
@@ -86,9 +141,13 @@ export function PlatformShell() {
useEffect(() => { useEffect(() => {
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = theme;
localStorage.setItem('containr.theme', theme); writeStorage('containr.theme', theme);
}, [theme]); }, [theme]);
useEffect(() => {
writeStorage('containr.sidebar.expanded', sidebarExpanded ? '1' : '0');
}, [sidebarExpanded]);
const pullMutation = useMutation({ const pullMutation = useMutation({
mutationFn: pullUpgradeImage, mutationFn: pullUpgradeImage,
onSuccess: (status) => { onSuccess: (status) => {
@@ -115,8 +174,8 @@ export function PlatformShell() {
const isActiveRoute = (itemHref: string) => const isActiveRoute = (itemHref: string) =>
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`); location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
const userName = sessionQuery.data?.user.name ?? 'Account'; const userName = profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account';
const userEmail = sessionQuery.data?.user.email ?? 'Local session'; const userEmail = profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session';
const initials = userName const initials = userName
.split(' ') .split(' ')
.map((part) => part[0]) .map((part) => part[0])
@@ -124,68 +183,177 @@ export function PlatformShell() {
.slice(0, 2) .slice(0, 2)
.toUpperCase() || 'C'; .toUpperCase() || 'C';
const avatar = (
<div
className="flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-full text-[11px] font-bold"
style={{ background: 'linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))', color: 'var(--accent-on)' }}
>
{initials}
</div>
);
return ( return (
<div className="app-shell min-h-screen"> <div className="app-shell min-h-screen">
<div className="ambient-glow" /> <div className="ambient-glow" />
<div className="relative flex min-h-screen"> <div className="relative flex min-h-screen">
<aside <aside
className="hidden h-screen shrink-0 flex-col items-center border-r py-4 md:flex" className="hidden h-screen shrink-0 flex-col border-r py-4 transition-[width] duration-200 md:flex"
style={{ style={{
width: '62px', width: sidebarExpanded ? '218px' : '62px',
background: 'var(--bg-base)', background: 'var(--bg-base)',
borderRightColor: 'var(--border-subtle)', borderRightColor: 'var(--border-subtle)',
}} }}
> >
<NavLink to={href('/projects')} className="mb-3" title="Containr"> <div className={`flex items-center ${sidebarExpanded ? 'justify-between px-3' : 'flex-col items-center gap-2'}`}>
<img src="/containr.svg" alt="Containr" className="h-[38px] w-[38px] rounded-xl" /> <NavLink to={href('/projects')} title="Containr">
</NavLink> <img src="/containr.svg" alt="Containr" className="h-[38px] w-[38px] rounded-xl" />
</NavLink>
<nav className="flex flex-col items-center gap-1.5">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = isActiveRoute(item.href);
return (
<NavLink
key={item.href}
to={href(item.href)}
title={item.label}
className={`relative flex h-10 w-10 items-center justify-center rounded-[11px] transition-colors ${
isActive
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-secondary)]'
}`}
>
{isActive ? (
<span
className="absolute -left-[11px] h-4 w-[2.5px] rounded-full"
style={{ background: 'var(--accent-primary)' }}
/>
) : null}
<Icon size={17} />
</NavLink>
);
})}
</nav>
<div className="flex-1" />
<div className="flex flex-col items-center gap-2">
<button <button
type="button" type="button"
onClick={() => void signOut()} onClick={() => setSidebarExpanded((v) => !v)}
title={`Sign out (${userName})`} title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'}
className="flex h-10 w-10 items-center justify-center rounded-[11px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--error-soft)] hover:text-[var(--error)]" className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
> >
<LogOut size={16} /> {sidebarExpanded ? <ChevronsLeft size={15} /> : <PanelLeft size={15} />}
</button> </button>
<div </div>
title={userEmail}
className="flex h-[30px] w-[30px] items-center justify-center rounded-full text-[11px] font-bold" <nav className="mt-3 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
style={{ background: 'linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))', color: 'var(--accent-on)' }} {navSections.map((section) => (
> <div key={section.title}>
{initials} {sidebarExpanded ? (
</div> <p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]">
{section.title}
</p>
) : null}
<div className={`flex flex-col gap-0.5 ${sidebarExpanded ? '' : 'items-center'}`}>
{section.items.map((item) => {
const Icon = item.icon;
const isActive = isActiveRoute(item.href);
return (
<NavLink
key={item.href}
to={href(item.href)}
title={sidebarExpanded ? item.hint : `${item.label} — ${item.hint}`}
className={`relative flex items-center rounded-[11px] transition-colors ${
sidebarExpanded ? 'gap-2.5 px-2 py-2' : 'h-10 w-10 justify-center'
} ${
isActive
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-secondary)]'
}`}
>
{isActive ? (
<span
className="absolute -left-2 h-4 w-[2.5px] rounded-full"
style={{ background: 'var(--accent-primary)' }}
/>
) : null}
<Icon size={17} className="shrink-0" />
{sidebarExpanded ? (
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium leading-tight">{item.label}</span>
<span className="block truncate text-[10px] text-[var(--text-tertiary)]">{item.hint}</span>
</span>
) : null}
</NavLink>
);
})}
</div>
</div>
))}
</nav>
<div className={`flex flex-col gap-2 px-2 pt-2 ${sidebarExpanded ? '' : 'items-center'}`}>
{signedIn ? (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
title={sidebarExpanded ? undefined : userEmail}
className={`flex items-center gap-2.5 rounded-[11px] text-left transition-colors hover:bg-[var(--surface-muted)] ${
sidebarExpanded ? 'w-full px-2 py-2' : 'h-10 w-10 justify-center'
}`}
>
{avatar}
{sidebarExpanded ? (
<span className="min-w-0 flex-1">
<span className="block truncate text-[12.5px] font-medium text-[var(--text-primary)]">{userName}</span>
<span className="block truncate text-[10.5px] text-[var(--text-tertiary)]">{userEmail}</span>
</span>
) : null}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
side="right"
align="end"
sideOffset={10}
className="z-50 w-56 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-1.5 shadow-2xl"
>
<div className="px-2.5 py-2">
<p className="truncate text-sm font-semibold text-[var(--text-primary)]">{userName}</p>
<p className="truncate text-xs text-[var(--text-tertiary)]">{userEmail}</p>
</div>
<DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" />
<DropdownMenu.Item asChild>
<button type="button" onClick={() => navigate(href('/settings'))} className={menuItemClass}>
<User size={14} /> Profile & account
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button type="button" onClick={() => navigate(href('/settings'))} className={menuItemClass}>
<Settings size={14} /> Settings
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button type="button" onClick={() => navigate(href('/settings/audit-logs'))} className={menuItemClass}>
<ScrollText size={14} /> Audit logs
</button>
</DropdownMenu.Item>
{isAdmin ? (
<DropdownMenu.Item asChild>
<button type="button" onClick={() => navigate(href('/admin'))} className={menuItemClass}>
<ShieldCheck size={14} /> Admin console
</button>
</DropdownMenu.Item>
) : null}
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
className={menuItemClass}
>
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
{theme === 'dark' ? 'Light theme' : 'Dark theme'}
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => void signOut()}
className={`${menuItemClass} text-[var(--error)] hover:!bg-[var(--error-soft)] hover:!text-[var(--error)]`}
>
<LogOut size={14} /> Sign out
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
) : (
<NavLink
to="/auth/sign-in"
title="Sign in"
className={`flex items-center gap-2.5 rounded-[11px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] ${
sidebarExpanded ? 'w-full px-2 py-2' : 'h-10 w-10 justify-center'
}`}
>
<LogIn size={16} className="shrink-0" />
{sidebarExpanded ? <span className="text-[12.5px] font-medium">Sign in</span> : null}
</NavLink>
)}
</div> </div>
</aside> </aside>
@@ -225,93 +393,97 @@ export function PlatformShell() {
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />} {theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
</button> </button>
<button {isAdmin ? (
type="button"
onClick={() => pullMutation.mutate()}
disabled={isDemoMode || pullMutation.isPending || !upgradeQuery.data?.imageRef}
className="inline-flex h-8 items-center gap-2 rounded-[var(--radius-md)] px-3 text-xs font-semibold text-[var(--accent-on)] disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
title={upgradeQuery.data?.message || 'Pull latest configured image'}
>
{pullMutation.isPending ? <RefreshCw size={14} className="animate-spin" /> : <UploadCloud size={14} />}
Upgrade
</button>
<div className="relative">
<button <button
type="button" type="button"
onClick={() => setNotificationsOpen((open) => !open)} onClick={() => pullMutation.mutate()}
className="relative flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]" disabled={isDemoMode || pullMutation.isPending || !upgradeQuery.data?.imageRef}
style={{ borderColor: 'var(--border-subtle)' }} className="inline-flex h-8 items-center gap-2 rounded-[var(--radius-md)] px-3 text-xs font-semibold text-[var(--accent-on)] disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Notifications" style={{ background: 'var(--accent-primary)' }}
title={upgradeQuery.data?.message || 'Pull latest configured image'}
> >
<Bell size={15} /> {pullMutation.isPending ? <RefreshCw size={14} className="animate-spin" /> : <UploadCloud size={14} />}
{unreadCount > 0 ? ( Upgrade
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-primary)] px-1 text-[10px] font-semibold text-[var(--accent-on)]">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
) : null}
</button> </button>
) : null}
{notificationsOpen ? ( {signedIn ? (
<div className="absolute right-0 top-10 z-50 w-80 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-3 shadow-2xl"> <div className="relative">
<div className="mb-2 flex items-center justify-between"> <button
<div> type="button"
<p className="text-sm font-semibold text-[var(--text-primary)]">Notifications</p> onClick={() => setNotificationsOpen((open) => !open)}
<p className="text-xs text-[var(--text-tertiary)]"> className="relative flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
{unreadCount > 0 ? `${unreadCount} unread` : 'All caught up'} style={{ borderColor: 'var(--border-subtle)' }}
</p> aria-label="Notifications"
</div> >
<div className="flex items-center gap-1"> <Bell size={15} />
{unreadCount > 0 ? ( {unreadCount > 0 ? (
<button <span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-primary)] px-1 text-[10px] font-semibold text-[var(--accent-on)]">
type="button" {unreadCount > 99 ? '99+' : unreadCount}
onClick={() => markAllReadMutation.mutate()} </span>
className="rounded-[var(--radius-sm)] px-2 py-1 text-xs text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]" ) : null}
> </button>
Mark all read
</button> {notificationsOpen ? (
) : null} <div className="absolute right-0 top-10 z-50 w-80 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-3 shadow-2xl">
<button <div className="mb-2 flex items-center justify-between">
type="button" <div>
onClick={() => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] })} <p className="text-sm font-semibold text-[var(--text-primary)]">Notifications</p>
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]" <p className="text-xs text-[var(--text-tertiary)]">
aria-label="Refresh notifications" {unreadCount > 0 ? `${unreadCount} unread` : 'All caught up'}
> </p>
<RefreshCw size={13} />
</button>
</div>
</div>
<div className="max-h-80 space-y-2 overflow-y-auto">
{notifications.length > 0 ? (
notifications.map((item) => (
<button
key={item.id}
type="button"
onClick={() => { if (!item.read_at) markReadMutation.mutate(item.id ?? ''); }}
className="w-full rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3 text-left hover:bg-[var(--surface-raised)]"
>
<div className="flex items-start gap-2">
{!item.read_at ? (
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--accent-primary)]" />
) : null}
<div className="min-w-0">
<p className={`text-sm ${item.read_at ? 'text-[var(--text-secondary)]' : 'font-medium text-[var(--text-primary)]'}`}>{item.title}</p>
<p className="mt-1 truncate text-xs text-[var(--text-tertiary)]">{item.body}</p>
</div>
</div>
</button>
))
) : (
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3 text-sm text-[var(--text-secondary)]">
<CheckCircle2 size={15} className="text-[var(--success)]" />
No notifications yet
</div> </div>
)} <div className="flex items-center gap-1">
{unreadCount > 0 ? (
<button
type="button"
onClick={() => markAllReadMutation.mutate()}
className="rounded-[var(--radius-sm)] px-2 py-1 text-xs text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
>
Mark all read
</button>
) : null}
<button
type="button"
onClick={() => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] })}
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
aria-label="Refresh notifications"
>
<RefreshCw size={13} />
</button>
</div>
</div>
<div className="max-h-80 space-y-2 overflow-y-auto">
{notifications.length > 0 ? (
notifications.map((item) => (
<button
key={item.id}
type="button"
onClick={() => { if (!item.read_at) markReadMutation.mutate(item.id ?? ''); }}
className="w-full rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3 text-left hover:bg-[var(--surface-card)]"
>
<div className="flex items-start gap-2">
{!item.read_at ? (
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--accent-primary)]" />
) : null}
<div className="min-w-0">
<p className={`text-sm ${item.read_at ? 'text-[var(--text-secondary)]' : 'font-medium text-[var(--text-primary)]'}`}>{item.title}</p>
<p className="mt-1 truncate text-xs text-[var(--text-tertiary)]">{item.body}</p>
</div>
</div>
</button>
))
) : (
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3 text-sm text-[var(--text-secondary)]">
<CheckCircle2 size={15} className="text-[var(--success)]" />
No notifications yet
</div>
)}
</div>
</div> </div>
</div> ) : null}
) : null} </div>
</div> ) : null}
</div> </div>
</header> </header>
@@ -332,7 +504,7 @@ export function PlatformShell() {
</button> </button>
</div> </div>
<nav className="flex gap-1 overflow-x-auto px-3 pb-3"> <nav className="flex gap-1 overflow-x-auto px-3 pb-3">
{navItems.map((item) => { {navSections.flatMap((section) => section.items).map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = isActiveRoute(item.href); const isActive = isActiveRoute(item.href);
return ( return (
@@ -361,3 +533,6 @@ export function PlatformShell() {
</div> </div>
); );
} }
const menuItemClass =
'flex w-full cursor-pointer select-none items-center gap-2 rounded-[var(--radius-md)] px-2.5 py-2 text-[13px] text-[var(--text-secondary)] outline-none transition-colors hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] data-[highlighted]:bg-[var(--surface-muted)] data-[highlighted]:text-[var(--text-primary)]';
@@ -0,0 +1,369 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
CheckCircle2,
Clock,
FolderKanban,
Pencil,
ShieldCheck,
Trash2,
Users,
XCircle,
} from 'lucide-react';
import {
deleteProject,
getAdminOverview,
listAdminUsers,
listProjects,
setProjectApproval,
setUserAdmin,
updateProject,
type AdminUser,
type ProjectEntity,
} from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session';
import { LoadingState, useToast } from '@/shared/components';
const statLabels: Record<string, string> = {
users: 'Users',
projects: 'Projects',
pending_projects: 'Pending approval',
services: 'Services',
running_services: 'Running',
databases: 'Databases',
agents: 'Agents',
templates: 'Templates',
user_templates: 'User templates',
cron_jobs: 'Cron jobs',
deployments_total: 'Deployments',
};
export function AdminPage() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const sessionQuery = useAuthSession();
const myId = sessionQuery.data?.user.id;
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview });
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers });
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }) });
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
const [editForm, setEditForm] = useState({ name: '', description: '' });
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['admin-overview'] });
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
queryClient.invalidateQueries({ queryKey: ['admin-projects'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
};
const approvalMutation = useMutation({
mutationFn: ({ id, approved }: { id: string; approved: boolean }) => setProjectApproval(id, approved),
onSuccess: (_, vars) => {
showToast('success', vars.approved ? 'Project approved' : 'Project unapproved');
invalidate();
},
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
});
const adminMutation = useMutation({
mutationFn: ({ id, isAdmin }: { id: string; isAdmin: boolean }) => setUserAdmin(id, isAdmin),
onSuccess: (_, vars) => {
showToast('success', vars.isAdmin ? 'Admin granted' : 'Admin revoked');
invalidate();
},
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
});
const editMutation = useMutation({
mutationFn: () =>
updateProject(editingProject!.id, {
name: editForm.name.trim(),
description: editForm.description.trim(),
}),
onSuccess: () => {
showToast('success', 'Project updated');
setEditingProject(null);
invalidate();
},
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteProject(id),
onSuccess: () => {
showToast('success', 'Project deleted');
invalidate();
},
onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined),
});
if (overviewQuery.isPending) {
return <LoadingState message="Loading admin console…" className="py-24" />;
}
if (overviewQuery.isError) {
return (
<div className="mx-auto max-w-xl px-5 py-24 text-center">
<ShieldCheck size={28} className="mx-auto text-[var(--error)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
This area is restricted to platform administrators.
</p>
</div>
);
}
const overview = overviewQuery.data;
const stats = overview?.stats ?? {};
const pending = overview?.pending_projects ?? [];
const users = usersQuery.data ?? [];
const allProjects = projectsQuery.data ?? [];
return (
<div className="mx-auto max-w-6xl px-5 py-8">
<div className="mb-8">
<h1 className="text-[24px] font-bold tracking-tight text-[var(--text-primary)]">Admin console</h1>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
Platform overview, project approvals, and user management.
</p>
</div>
<div className="grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2">
{Object.entries(statLabels).map(([key, label]) => (
<div
key={key}
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-3.5"
>
<p className="text-[20px] font-bold tabular-nums text-[var(--text-primary)]">{stats[key] ?? 0}</p>
<p className="mt-0.5 text-[10.5px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
</div>
))}
</div>
<section className="mt-8">
<div className="mb-3 flex items-center gap-2">
<Clock size={15} className="text-[var(--warning)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Pending approvals</h2>
{pending.length > 0 ? (
<span className="rounded-full bg-[var(--warning-soft)] px-2 py-0.5 text-[10.5px] font-semibold text-[var(--warning)]">
{pending.length}
</span>
) : null}
</div>
{pending.length > 0 ? (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
{pending.map((project) => (
<div
key={project.id}
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
>
<FolderKanban size={15} className="shrink-0 text-[var(--text-tertiary)]" />
<div className="min-w-0 flex-1">
<Link to={`/projects/${project.id}`} className="text-[13.5px] font-medium text-[var(--text-primary)] hover:underline">
{project.name}
</Link>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{project.description || 'No description'}</p>
</div>
<button
type="button"
onClick={() => approvalMutation.mutate({ id: project.id, approved: true })}
disabled={approvalMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3 py-1.5 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
<CheckCircle2 size={13} /> Approve
</button>
</div>
))}
</div>
) : (
<div className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">
Nothing waiting for approval.
</div>
)}
</section>
<section className="mt-8">
<div className="mb-3 flex items-center gap-2">
<FolderKanban size={15} className="text-[var(--accent-primary)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">All projects</h2>
</div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
{allProjects.map((project) => (
<div
key={project.id}
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
>
<div className="min-w-0 flex-1">
<Link to={`/projects/${project.id}`} className="text-[13.5px] font-medium text-[var(--text-primary)] hover:underline">
{project.name}
</Link>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">
{project.stats?.service_count ?? 0} services · {project.stats?.deployment_count ?? 0} deploys
</p>
</div>
<span
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
project.isApproved
? 'bg-[var(--success-soft)] text-[var(--success)]'
: 'bg-[var(--warning-soft)] text-[var(--warning)]'
}`}
>
{project.isApproved ? 'public' : 'pending'}
</span>
<button
type="button"
onClick={() => {
setEditingProject(project);
setEditForm({ name: project.name, description: project.description ?? '' });
}}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
title="Edit project"
>
<Pencil size={12} />
</button>
<button
type="button"
onClick={() => {
if (window.confirm(`Delete project "${project.name}"? This removes its services and deployments.`)) {
deleteMutation.mutate(project.id);
}
}}
disabled={deleteMutation.isPending}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
title="Delete project"
>
<Trash2 size={12} />
</button>
<button
type="button"
onClick={() => approvalMutation.mutate({ id: project.id, approved: !project.isApproved })}
disabled={approvalMutation.isPending}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
>
{project.isApproved ? 'Unapprove' : 'Approve'}
</button>
</div>
))}
{allProjects.length === 0 ? (
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No projects yet.</p>
) : null}
</div>
</section>
<section className="mt-8 pb-10">
<div className="mb-3 flex items-center gap-2">
<Users size={15} className="text-[var(--accent-tertiary)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Users</h2>
</div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
{users.map((user) => (
<UserRow
key={user.id}
user={user}
isSelf={user.id === myId}
pending={adminMutation.isPending}
onToggle={(isAdmin) => adminMutation.mutate({ id: user.id, isAdmin })}
/>
))}
{users.length === 0 ? (
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No users.</p>
) : null}
</div>
</section>
{editingProject ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
<div className="border-b border-[var(--border-subtle)] px-5 py-4">
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Edit project</h2>
<p className="text-xs text-[var(--text-tertiary)]">Admin edit — changes apply immediately.</p>
</div>
<div className="space-y-4 px-5 py-4">
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Name</label>
<input
value={editForm.name}
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Description</label>
<textarea
value={editForm.description}
onChange={(e) => setEditForm((f) => ({ ...f, description: e.target.value }))}
rows={3}
className="w-full resize-none rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-sm focus:border-[var(--accent-primary)]"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
<button
type="button"
onClick={() => setEditingProject(null)}
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
Cancel
</button>
<button
type="button"
onClick={() => editMutation.mutate()}
disabled={editMutation.isPending || editForm.name.trim().length < 2}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{editMutation.isPending ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
function UserRow({
user,
isSelf,
pending,
onToggle,
}: {
user: AdminUser;
isSelf: boolean;
pending: boolean;
onToggle: (isAdmin: boolean) => void;
}) {
return (
<div className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--surface-muted)] text-[11px] font-bold text-[var(--text-secondary)]">
{user.name.split(' ').map((p) => p[0]).join('').slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-medium text-[var(--text-primary)]">
{user.name}
{isSelf ? <span className="ml-2 text-[10.5px] text-[var(--text-tertiary)]">(you)</span> : null}
</p>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{user.email}</p>
</div>
{user.is_admin ? (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--accent-primary-soft)] px-2.5 py-0.5 text-[10.5px] font-semibold text-[var(--accent-primary)]">
<ShieldCheck size={11} /> admin
</span>
) : null}
<button
type="button"
onClick={() => onToggle(!user.is_admin)}
disabled={pending || isSelf}
title={isSelf ? 'You cannot change your own admin flag' : undefined}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40"
>
{user.is_admin ? (
<span className="inline-flex items-center gap-1"><XCircle size={12} /> Revoke</span>
) : (
'Make admin'
)}
</button>
</div>
);
}
@@ -0,0 +1,197 @@
import { useEffect, useMemo, useState } from 'react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { BookOpen, CloudOff, FileText, Search } from 'lucide-react';
import { initialDocs, syncDocs, type DocEntry, type DocsState } from './docs-store';
marked.setOptions({ gfm: true, breaks: false });
function renderMarkdown(markdown: string): string {
const html = marked.parse(markdown, { async: false });
return DOMPurify.sanitize(typeof html === 'string' ? html : '', {
USE_PROFILES: { html: true },
});
}
function excerpt(markdown: string, length = 140): string {
const text = markdown
.replace(/```[\s\S]*?```/g, ' ')
.replace(/[#>*`\-[\]()]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return text.length > length ? `${text.slice(0, length)}…` : text;
}
export function DocsBrowser() {
const [state, setState] = useState<DocsState>(() => initialDocs());
const [query, setQuery] = useState('');
const [selectedPath, setSelectedPath] = useState<string | null>(null);
useEffect(() => {
syncDocs(setState);
}, []);
const docs = state.docs;
const sections = useMemo(() => {
const grouped = new Map<string, DocEntry[]>();
for (const doc of docs) {
const list = grouped.get(doc.section) ?? [];
list.push(doc);
grouped.set(doc.section, list);
}
return [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b));
}, [docs]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return null;
return docs.filter(
(doc) =>
doc.title.toLowerCase().includes(q) ||
doc.path.toLowerCase().includes(q) ||
doc.markdown.toLowerCase().includes(q),
);
}, [docs, query]);
const selected = useMemo(
() => docs.find((doc) => doc.path === selectedPath) ?? null,
[docs, selectedPath],
);
const selectedHtml = useMemo(
() => (selected ? renderMarkdown(selected.markdown) : ''),
[selected],
);
const sourceBadge =
state.source === 'github'
? { label: 'live · github', cls: 'text-[var(--success)]' }
: state.source === 'cache'
? { label: `cached · ${state.syncedAt ? new Date(state.syncedAt).toLocaleDateString() : 'offline'}`, cls: 'text-[var(--warning)]' }
: { label: 'bundled copy', cls: 'text-[var(--text-tertiary)]' };
return (
<div className="flex min-h-[480px] overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
<div className="flex w-[300px] shrink-0 flex-col border-r border-[var(--border-subtle)] max-md:w-full">
<div className="border-b border-[var(--border-subtle)] p-3">
<div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2.5 py-1.5">
<Search size={13} className="shrink-0 text-[var(--text-tertiary)]" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search docs…"
className="w-full bg-transparent text-[13px] text-[var(--text-primary)] outline-none placeholder:text-[var(--text-tertiary)]"
/>
</div>
<div className="mt-2 flex items-center justify-between px-0.5">
<span className={`text-[10.5px] font-medium uppercase tracking-wider ${sourceBadge.cls}`}>
{sourceBadge.label}
</span>
<span className="text-[10.5px] text-[var(--text-tertiary)]">{docs.length} docs</span>
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{filtered ? (
filtered.length > 0 ? (
filtered.map((doc) => (
<DocListItem key={doc.path} doc={doc} active={doc.path === selectedPath} onSelect={setSelectedPath} showSection />
))
) : (
<p className="px-2 py-6 text-center text-xs text-[var(--text-tertiary)]">No docs match “{query}”.</p>
)
) : (
sections.map(([section, entries]) => (
<div key={section} className="mb-3">
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]">
{section}
</p>
{entries.map((doc) => (
<DocListItem key={doc.path} doc={doc} active={doc.path === selectedPath} onSelect={setSelectedPath} />
))}
</div>
))
)}
</div>
</div>
<div className="min-w-0 flex-1 overflow-y-auto max-md:hidden">
{selected ? (
<article className="docs-article px-8 py-6">
<p className="mb-1 text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
{selected.section} / {selected.path}
</p>
<div dangerouslySetInnerHTML={{ __html: selectedHtml }} />
</article>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center">
<BookOpen size={28} className="text-[var(--text-tertiary)]" />
<p className="text-sm font-medium text-[var(--text-secondary)]">Select a document</p>
<p className="max-w-sm text-xs leading-relaxed text-[var(--text-tertiary)]">
Documentation is bundled with the app and refreshes from GitHub in the background.
{!state.syncedAt ? ' Currently showing the bundled copy.' : ''}
</p>
{state.source !== 'github' ? (
<p className="flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
<CloudOff size={12} /> Offline-safe — updates apply automatically when a connection is available.
</p>
) : null}
</div>
)}
</div>
{/* Mobile: tapping a doc replaces the list */}
{selected ? (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--bg-base)] md:hidden">
<div className="flex items-center gap-2 border-b border-[var(--border-subtle)] p-3">
<button
type="button"
onClick={() => setSelectedPath(null)}
className="rounded-[var(--radius-md)] px-2.5 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
← Back
</button>
<span className="truncate text-xs text-[var(--text-tertiary)]">{selected.title}</span>
</div>
<article className="docs-article flex-1 overflow-y-auto px-5 py-4">
<div dangerouslySetInnerHTML={{ __html: selectedHtml }} />
</article>
</div>
) : null}
</div>
);
}
function DocListItem({
doc,
active,
onSelect,
showSection = false,
}: {
doc: DocEntry;
active: boolean;
onSelect: (path: string) => void;
showSection?: boolean;
}) {
return (
<button
type="button"
onClick={() => onSelect(doc.path)}
className={`mb-0.5 w-full rounded-[var(--radius-md)] px-2.5 py-2 text-left transition-colors ${
active
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]'
}`}
>
<span className="flex items-center gap-2">
<FileText size={13} className="shrink-0 opacity-60" />
<span className="truncate text-[13px] font-medium">{doc.title}</span>
</span>
<span className="mt-0.5 block truncate pl-5 text-[11px] text-[var(--text-tertiary)]">
{showSection ? `${doc.section} · ` : ''}{excerpt(doc.markdown, 80)}
</span>
</button>
);
}
@@ -0,0 +1,121 @@
import { bundledDocs, type BundledDoc } from '@/generated/docs-snapshot';
export type DocEntry = BundledDoc;
export type DocsState = {
docs: DocEntry[];
source: 'bundled' | 'cache' | 'github';
syncedAt: string | null;
};
const CACHE_KEY = 'containr.docs.v1';
const REPO = 'Dvorinka/Containr';
const BRANCH = 'main';
const SKIP_DIRS = new Set(['archive', 'design', 'superpowers']);
type CachePayload = { syncedAt: string; docs: DocEntry[] };
function readCache(): CachePayload | null {
try {
const raw = localStorage.getItem(CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as CachePayload;
if (!Array.isArray(parsed.docs) || typeof parsed.syncedAt !== 'string') return null;
return parsed;
} catch {
return null;
}
}
function writeCache(payload: CachePayload) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(payload));
} catch {
// Storage full or unavailable — bundled docs still cover us.
}
}
function titleOf(markdown: string, fallback: string): string {
const match = markdown.match(/^#\s+(.+)$/m);
if (match) return match[1].replace(/[#*`]/g, '').trim();
return fallback
.replace(/\.md$/i, '')
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
type GitHubTreeEntry = { path?: string; type?: string };
async function fetchGitHubDocs(signal: AbortSignal): Promise<DocEntry[]> {
const treeRes = await fetch(
`https://api.github.com/repos/${REPO}/git/trees/${BRANCH}?recursive=1`,
{ signal, headers: { Accept: 'application/vnd.github+json' } },
);
if (!treeRes.ok) throw new Error(`tree fetch failed: ${treeRes.status}`);
const tree = (await treeRes.json()) as { tree?: GitHubTreeEntry[]; truncated?: boolean };
const paths = (tree.tree ?? [])
.filter(
(e) =>
e.type === 'blob' &&
typeof e.path === 'string' &&
e.path.startsWith('docs/') &&
e.path.toLowerCase().endsWith('.md') &&
!e.path.split('/').some((seg, i) => i > 0 && SKIP_DIRS.has(seg)),
)
.map((e) => e.path as string);
const docs = await Promise.all(
paths.map(async (path) => {
const res = await fetch(
`https://raw.githubusercontent.com/${REPO}/${BRANCH}/${path}`,
{ signal },
);
if (!res.ok) throw new Error(`doc fetch failed: ${path}`);
const markdown = await res.text();
const rel = path.slice('docs/'.length);
return {
path: rel,
section: rel.includes('/') ? rel.split('/')[0] : 'docs',
title: titleOf(markdown, rel.split('/').pop() ?? rel),
markdown,
};
}),
);
return docs;
}
/**
* Synchronous initial state — cache wins over the bundled snapshot so a
* previously synced copy survives offline reloads.
*/
export function initialDocs(): DocsState {
const cached = readCache();
return cached
? { docs: cached.docs, source: 'cache', syncedAt: cached.syncedAt }
: { docs: bundledDocs, source: 'bundled', syncedAt: null };
}
/**
* Background GitHub sync. `onUpdate` fires when fresh content lands so the
* UI can re-render without a reload; failures keep the current copy.
*/
export function syncDocs(onUpdate: (state: DocsState) => void): void {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
fetchGitHubDocs(controller.signal)
.then((docs) => {
const syncedAt = new Date().toISOString();
writeCache({ syncedAt, docs });
onUpdate({ docs, source: 'github', syncedAt });
})
.catch(() => {
// Offline or rate-limited — bundled/cache copy already displayed.
})
.finally(() => clearTimeout(timeout));
}
@@ -0,0 +1,232 @@
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
ArrowRight,
BookOpen,
Container,
Database,
GitBranch,
LayoutTemplate,
Rocket,
ShieldCheck,
Zap,
} from 'lucide-react';
import { listProjects, listTemplates } from '@/lib/api-client';
import { DocsBrowser } from '@/features/docs/DocsBrowser';
import { useAuthSession } from '@/lib/use-auth-session';
const features = [
{ icon: Rocket, title: 'Git-push deploys', text: 'Connect a repository and ship. Builds, rollouts, and rollbacks are handled for you.' },
{ icon: Database, title: 'Managed databases', text: 'Provision PostgreSQL, MySQL, Redis and more — with backups and connection management.' },
{ icon: ShieldCheck, title: 'High availability', text: 'Failover policies, health checks, and alerting keep services alive across nodes.' },
{ icon: GitBranch, title: 'Preview environments', text: 'Every branch gets an isolated environment with its own URL.' },
{ icon: LayoutTemplate, title: 'Template catalog', text: 'One-click deploys for popular self-hosted apps and community templates.' },
{ icon: Zap, title: 'Autoscaling', text: 'Metric-driven scaling policies adapt replicas to real load.' },
];
export function LandingPage() {
const sessionQuery = useAuthSession();
const signedIn = Boolean(sessionQuery.data);
const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000 });
const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000 });
const projects = projectsQuery.data ?? [];
const templates = (templatesQuery.data ?? []).slice(0, 12);
return (
<div className="min-h-screen bg-[var(--bg-void)] text-[var(--text-secondary)]">
<div className="ambient-glow" />
<header className="sticky top-0 z-40 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 backdrop-blur-xl">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
<Link to="/" className="flex items-center gap-2.5">
<img src="/containr.svg" alt="Containr" className="h-7 w-7 rounded-lg" />
<span className="text-[15px] font-extrabold tracking-tight text-[var(--text-primary)]">
contain<span style={{ color: 'var(--accent-primary)' }}>r</span>
</span>
</Link>
<nav className="flex items-center gap-1 text-[13px]">
<a href="#features" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Features</a>
<a href="#templates" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Templates</a>
<a href="#docs" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Docs</a>
<Link
to="/projects"
className="rounded-[var(--radius-md)] px-3 py-1.5 font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
>
Browse
</Link>
{signedIn ? (
<Link
to="/projects"
className="ml-1 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 py-1.5 font-semibold text-[var(--accent-on)]"
>
Console <ArrowRight size={13} />
</Link>
) : (
<Link
to="/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)]"
>
Sign in <ArrowRight size={13} />
</Link>
)}
</nav>
</div>
</header>
<main className="relative">
<section className="mx-auto max-w-6xl px-5 pb-16 pt-20 text-center">
<p className="mx-auto mb-5 inline-flex items-center gap-2 rounded-full border border-[var(--border-subtle)] bg-[var(--surface-card)] px-3.5 py-1 text-[11px] font-medium uppercase tracking-[0.16em] text-[var(--text-tertiary)]">
<Container size={12} className="text-[var(--accent-primary)]" />
self-hosted PaaS
</p>
<h1 className="mx-auto max-w-3xl text-[42px] font-extrabold leading-[1.08] tracking-[-0.03em] text-[var(--text-primary)] max-sm:text-[32px]">
Deploy containers like it's{' '}
<span style={{ color: 'var(--accent-primary)' }}>your own cloud</span>
</h1>
<p className="mx-auto mt-5 max-w-xl text-[15px] leading-relaxed text-[var(--text-secondary)]">
Containr turns any Docker host into a full deployment platform — git-push builds,
managed databases, preview environments, HA failover, and a searchable knowledge base.
</p>
<div className="mt-8 flex items-center justify-center gap-3">
<Link
to="/projects"
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} />
</Link>
<a
href="#docs"
className="inline-flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-default)] px-5 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]"
>
<BookOpen size={15} /> Read the docs
</a>
</div>
{projects.length > 0 ? (
<div className="mx-auto mt-12 grid max-w-3xl grid-cols-3 gap-3 text-left max-sm:grid-cols-1">
<Stat label="Public projects" value={projects.length} />
<Stat
label="Running services"
value={projects.reduce((sum, p) => sum + (p.stats?.running_services ?? 0), 0)}
/>
<Stat
label="Deployments"
value={projects.reduce((sum, p) => sum + (p.stats?.deployment_count ?? 0), 0)}
/>
</div>
) : null}
</section>
<section id="features" className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
<div className="mx-auto max-w-6xl px-5 py-16">
<h2 className="text-center text-[26px] font-bold tracking-tight text-[var(--text-primary)]">
Everything a deployment platform needs
</h2>
<p className="mx-auto mt-2 max-w-lg text-center text-sm text-[var(--text-secondary)]">
One install, one dashboard, full control over your own infrastructure.
</p>
<div className="mt-10 grid grid-cols-3 gap-4 max-md:grid-cols-2 max-sm:grid-cols-1">
{features.map((feature) => (
<div
key={feature.title}
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-5 transition-colors hover:border-[var(--border-default)]"
>
<feature.icon size={18} className="text-[var(--accent-primary)]" />
<h3 className="mt-3 text-[14.5px] font-semibold text-[var(--text-primary)]">{feature.title}</h3>
<p className="mt-1.5 text-[12.5px] leading-relaxed text-[var(--text-tertiary)]">{feature.text}</p>
</div>
))}
</div>
</div>
</section>
<section id="templates" className="border-t border-[var(--border-subtle)]">
<div className="mx-auto max-w-6xl px-5 py-16">
<div className="flex items-end justify-between">
<div>
<h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Template catalog</h2>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
Production-ready presets — deploy in one click from the console.
</p>
</div>
<Link
to="/templates"
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--accent-primary)] hover:underline"
>
Browse all <ArrowRight size={13} />
</Link>
</div>
<div className="mt-8 grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2">
{templates.map((template) => (
<Link
key={template.id}
to="/templates"
className="group rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-4 transition-colors hover:border-[var(--border-strong)]"
>
<div className="flex items-center gap-2.5">
{template.logo ? (
<img src={template.logo} alt="" className="h-7 w-7 rounded-md" />
) : (
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-[var(--surface-muted)] text-[var(--accent-primary)]">
<LayoutTemplate size={14} />
</span>
)}
<span className="truncate text-[13px] font-semibold text-[var(--text-primary)]">{template.name}</span>
</div>
<p className="mt-2 line-clamp-2 text-[11.5px] leading-relaxed text-[var(--text-tertiary)]">
{template.description}
</p>
<span className="mt-2 inline-block rounded-full bg-[var(--surface-muted)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--text-tertiary)]">
{template.category || 'app'}
</span>
</Link>
))}
{templates.length === 0 && !templatesQuery.isPending ? (
<p className="col-span-full py-8 text-center text-sm text-[var(--text-tertiary)]">
Template catalog is empty — sign in to add your own.
</p>
) : null}
</div>
</div>
</section>
<section id="docs" className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
<div className="mx-auto max-w-6xl px-5 py-16">
<h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Documentation</h2>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
Guides and references — synced from GitHub, cached locally, always available.
</p>
<div className="mt-8">
<DocsBrowser />
</div>
</div>
</section>
</main>
<footer className="border-t border-[var(--border-subtle)]">
<div className="mx-auto flex max-w-6xl items-center justify-between px-5 py-6 text-[12px] text-[var(--text-tertiary)]">
<span className="flex items-center gap-2">
<img src="/containr.svg" alt="" className="h-4 w-4 rounded" />
Containr — self-hosted container platform
</span>
<div className="flex items-center gap-4">
<Link to="/projects" className="hover:text-[var(--text-secondary)]">Projects</Link>
<Link to="/templates" className="hover:text-[var(--text-secondary)]">Templates</Link>
<Link to="/admin" className="hover:text-[var(--text-secondary)]">Admin</Link>
</div>
</div>
</footer>
</div>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-3.5">
<p className="text-[22px] font-bold tabular-nums text-[var(--text-primary)]">{value}</p>
<p className="mt-0.5 text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
</div>
);
}
@@ -14,12 +14,14 @@ import {
FolderOpen, FolderOpen,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { useAuthSession } from '@/lib/use-auth-session';
const demoProjects: ProjectEntity[] = [ const demoProjects: ProjectEntity[] = [
{ {
id: 'project-demo', id: 'project-demo',
name: 'core-services', name: 'core-services',
description: 'Sample project with mock services for UI preview.', description: 'Sample project with mock services for UI preview.',
isApproved: true,
createdAt: new Date(Date.now() - 14 * 86_400_000).toISOString(), createdAt: new Date(Date.now() - 14 * 86_400_000).toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
stats: { service_count: 5, deployment_count: 12, running_services: 5, last_deployment: null }, stats: { service_count: 5, deployment_count: 12, running_services: 5, last_deployment: null },
@@ -28,6 +30,7 @@ const demoProjects: ProjectEntity[] = [
id: 'project-staging', id: 'project-staging',
name: 'ml-pipeline', name: 'ml-pipeline',
description: 'Pre-production environment for testing new releases.', description: 'Pre-production environment for testing new releases.',
isApproved: true,
createdAt: new Date(Date.now() - 7 * 86_400_000).toISOString(), createdAt: new Date(Date.now() - 7 * 86_400_000).toISOString(),
updatedAt: new Date(Date.now() - 2 * 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 }, stats: { service_count: 2, deployment_count: 5, running_services: 1, last_deployment: null },
@@ -141,6 +144,15 @@ export function ProjectsPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [isCreateOpen, setCreateOpen] = useState(false); const [isCreateOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: '', description: '' }); const [form, setForm] = useState({ name: '', description: '' });
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const openCreate = () => {
if (!signedIn) {
navigate('/auth/sign-in');
return;
}
setCreateOpen(true);
};
const projectHref = (projectId: string) => const projectHref = (projectId: string) =>
isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`; isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`;
@@ -148,7 +160,7 @@ export function ProjectsPage() {
const projectsQuery = useQuery({ const projectsQuery = useQuery({
queryKey: ['projects'], queryKey: ['projects'],
enabled: !isDemoMode, enabled: !isDemoMode,
queryFn: listProjects, queryFn: () => listProjects(),
}); });
const hostQuery = useQuery({ const hostQuery = useQuery({
queryKey: ['host-monitoring'], queryKey: ['host-monitoring'],
@@ -238,7 +250,7 @@ export function ProjectsPage() {
style={{ fontSize: 11.5 }} style={{ fontSize: 11.5 }}
/> />
</div> </div>
<button onClick={() => setCreateOpen(true)} className="v-btn"> <button onClick={openCreate} className="v-btn">
+ new project + new project
</button> </button>
</div> </div>
@@ -310,10 +322,12 @@ export function ProjectsPage() {
</div> </div>
<p className="text-xl font-semibold text-[var(--text-primary)]">No projects yet</p> <p className="text-xl font-semibold text-[var(--text-primary)]">No projects yet</p>
<p className="mx-auto mt-2 max-w-md text-[var(--text-secondary)]"> <p className="mx-auto mt-2 max-w-md text-[var(--text-secondary)]">
Create your first project to start deploying services with visual topology management. {signedIn
? 'Create your first project to start deploying services with visual topology management.'
: 'No public projects yet. Sign in to create one — an admin approves it before it goes public.'}
</p> </p>
<button onClick={() => setCreateOpen(true)} className="v-btn mt-6"> <button onClick={openCreate} className="v-btn mt-6">
+ create project {signedIn ? '+ create project' : 'sign in to create'}
</button> </button>
</div> </div>
) : null} ) : null}
@@ -357,6 +371,9 @@ export function ProjectsPage() {
<p className="mt-1 text-sm text-[var(--text-secondary)]"> <p className="mt-1 text-sm text-[var(--text-secondary)]">
Projects organize your services and provide a visual canvas for topology management. Projects organize your services and provide a visual canvas for topology management.
</p> </p>
<p className="mt-2 rounded-[var(--radius-md)] bg-[var(--warning-soft)] px-3 py-2 text-xs text-[var(--warning)]">
New projects stay private until a platform admin approves them for public listing.
</p>
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
<div> <div>
+266 -217
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { import {
getApiBaseUrl, getApiBaseUrl,
getAgentPublicBaseUrl, getAgentPublicBaseUrl,
@@ -14,7 +14,6 @@ import {
listBuilds, listBuilds,
listGitProviders, listGitProviders,
listProjects, listProjects,
listTemplates,
createAgentToken, createAgentToken,
listAgentTokens, listAgentTokens,
revokeAgentToken, revokeAgentToken,
@@ -23,6 +22,7 @@ import {
updateDatabaseBackupSchedule, updateDatabaseBackupSchedule,
createDatabaseBackup, createDatabaseBackup,
restoreDatabaseBackup, restoreDatabaseBackup,
createManagedDatabase,
listServicesByProject, listServicesByProject,
listServiceVariables, listServiceVariables,
updateServiceVariables, updateServiceVariables,
@@ -46,10 +46,13 @@ import {
updateVulnerability, updateVulnerability,
getSecurityMetrics, getSecurityMetrics,
type AgentAuthTokenCreated, type AgentAuthTokenCreated,
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 { demoDatabases } from '@/lib/demo-data';
import { DocsBrowser } from '@/features/docs/DocsBrowser';
import { useAuthSession } from '@/lib/use-auth-session';
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';
@@ -72,9 +75,6 @@ import {
Check, Check,
AlertCircle, AlertCircle,
Loader2, Loader2,
BookOpen,
Folder,
Box,
Terminal, Terminal,
Radio, Radio,
Server, Server,
@@ -149,13 +149,19 @@ type LocalStorageSummary = {
}; };
function getLocalStorageSummary(): LocalStorageSummary { function getLocalStorageSummary(): LocalStorageSummary {
if (typeof window === 'undefined') { let storage: Storage | null = null;
try {
storage = typeof window === 'undefined' ? null : window.localStorage;
} catch {
storage = null;
}
if (!storage) {
return { canvasKeys: [], totalKeys: 0 }; return { canvasKeys: [], totalKeys: 0 };
} }
const canvasKeys: string[] = []; const canvasKeys: string[] = [];
for (let index = 0; index < localStorage.length; index += 1) { for (let index = 0; index < storage.length; index += 1) {
const key = localStorage.key(index); const key = storage.key(index);
if (key && key.startsWith('containr.canvas.v1.')) { if (key && key.startsWith('containr.canvas.v1.')) {
canvasKeys.push(key); canvasKeys.push(key);
} }
@@ -165,23 +171,10 @@ function getLocalStorageSummary(): LocalStorageSummary {
return { return {
canvasKeys, canvasKeys,
totalKeys: localStorage.length, totalKeys: storage.length,
}; };
} }
function endpointStateBadge(isLoading: boolean, isError: boolean): {
label: string;
toneClass: string;
} {
if (isLoading) {
return { label: 'Loading', toneClass: 'text-[var(--warn)]' };
}
if (isError) {
return { label: 'Unavailable', toneClass: 'text-[var(--bad)]' };
}
return { label: 'Available', toneClass: 'text-[var(--ok)]' };
}
function formatBytes(value: number): string { function formatBytes(value: number): string {
if (!Number.isFinite(value) || value <= 0) { if (!Number.isFinite(value) || value <= 0) {
return '0 B'; return '0 B';
@@ -210,9 +203,20 @@ function formatUptime(seconds: number): string {
export function UsagePage() { export function UsagePage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const sessionQuery = useAuthSession();
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: getCurrentUserProfile,
enabled: Boolean(sessionQuery.data),
retry: false,
});
const signedIn = Boolean(sessionQuery.data);
const isAdmin = 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,
retry: false,
}); });
const hostQuery = useQuery({ const hostQuery = useQuery({
queryKey: ['usage-host-monitoring'], queryKey: ['usage-host-monitoring'],
@@ -223,10 +227,14 @@ export function UsagePage() {
queryKey: ['usage-agents'], queryKey: ['usage-agents'],
queryFn: listAgents, queryFn: listAgents,
refetchInterval: 15_000, refetchInterval: 15_000,
enabled: isAdmin,
retry: false,
}); });
const agentTokensQuery = useQuery({ const agentTokensQuery = useQuery({
queryKey: ['agent-tokens'], queryKey: ['agent-tokens'],
queryFn: listAgentTokens, queryFn: listAgentTokens,
enabled: isAdmin,
retry: false,
}); });
const [tokenLabel, setTokenLabel] = useState(''); const [tokenLabel, setTokenLabel] = useState('');
const [issuedToken, setIssuedToken] = useState<AgentAuthTokenCreated | null>(null); const [issuedToken, setIssuedToken] = useState<AgentAuthTokenCreated | null>(null);
@@ -386,6 +394,10 @@ containr-agent`;
</div> </div>
<p className="text-sm text-[var(--error)]">Failed to load usage data</p> <p className="text-sm text-[var(--error)]">Failed to load usage data</p>
</div> </div>
) : !signedIn ? (
<div className="panel p-8 text-center">
<p className="text-sm text-[var(--text-secondary)]">Sign in to view build activity and capacity.</p>
</div>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
@@ -456,7 +468,7 @@ containr-agent`;
status={hostStatus(hostLoadPercent)} status={hostStatus(hostLoadPercent)}
statusText={host ? { good: 'Good', average: 'Average', warning: 'High' }[hostStatus(hostLoadPercent)] : ''} statusText={host ? { good: 'Good', average: 'Average', warning: 'High' }[hostStatus(hostLoadPercent)] : ''}
subtitle={host ? `${host.cpu.cores} cores · 5m ${host.load.load5m.toFixed(2)} · 15m ${host.load.load15m.toFixed(2)}` : 'Loading CPU telemetry.'} subtitle={host ? `${host.cpu.cores} cores · 5m ${host.load.load5m.toFixed(2)} · 15m ${host.load.load15m.toFixed(2)}` : 'Loading CPU telemetry.'}
chart={<LineAreaChart data={loadHistory.length > 0 ? loadHistory : [0]} color="#b4e34a" height={72} />} chart={<LineAreaChart data={loadHistory.length > 0 ? loadHistory : [0]} color="var(--accent-primary)" height={72} />}
/> />
<EnhancedMetricCard <EnhancedMetricCard
title="Memory" title="Memory"
@@ -467,10 +479,10 @@ containr-agent`;
subtitle={host ? `${formatBytes(host.memory.available)} free` : 'Loading memory telemetry.'} subtitle={host ? `${formatBytes(host.memory.available)} free` : 'Loading memory telemetry.'}
chart={ chart={
<div className="relative mx-auto" style={{ width: 150 }}> <div className="relative mx-auto" style={{ width: 150 }}>
<DonutChart percentage={host?.memory.usagePercent ?? 0} color="#f2c94c" size={150} thickness={14} /> <DonutChart percentage={host?.memory.usagePercent ?? 0} color="var(--warning)" size={150} thickness={14} />
<div className="absolute inset-x-0 bottom-0 text-center"> <div className="absolute inset-x-0 bottom-0 text-center">
<div className="text-[10px] uppercase tracking-wide text-[#6b6e7d]">Used</div> <div className="text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">Used</div>
<div className="text-sm font-bold text-[#e8e9f0]"> <div className="text-sm font-bold text-[var(--text-primary)]">
{host ? `${formatBytes(host.memory.used)} / ${formatBytes(host.memory.total)}` : '—'} {host ? `${formatBytes(host.memory.used)} / ${formatBytes(host.memory.total)}` : '—'}
</div> </div>
</div> </div>
@@ -488,14 +500,14 @@ containr-agent`;
<div> <div>
<SegmentedBar <SegmentedBar
segments={[ segments={[
{ width: host?.storage.usagePercent ?? 0, color: '#ff8a5c' }, { width: host?.storage.usagePercent ?? 0, color: 'var(--info)' },
{ width: Math.max(2, 100 - (host?.storage.usagePercent ?? 0)), color: 'rgba(255,255,255,0.07)' }, { width: Math.max(2, 100 - (host?.storage.usagePercent ?? 0)), color: 'var(--surface-muted)' },
]} ]}
height={18} height={18}
/> />
<div className="mt-3 flex items-center gap-4 text-xs text-[#6b6e7d]"> <div className="mt-3 flex items-center gap-4 text-xs text-[var(--text-tertiary)]">
<span><span className="mr-1.5 inline-block h-2 w-2 rounded-full" style={{ background: '#ff8a5c' }} />{host ? `${formatBytes(host.storage.used)} used` : '—'}</span> <span><span className="mr-1.5 inline-block h-2 w-2 rounded-full" style={{ background: 'var(--info)' }} />{host ? `${formatBytes(host.storage.used)} used` : '—'}</span>
<span><span className="mr-1.5 inline-block h-2 w-2 rounded-full bg-white/10" />{host ? `${formatBytes(host.storage.available)} free` : '—'}</span> <span><span className="mr-1.5 inline-block h-2 w-2 rounded-full" style={{ background: 'var(--surface-muted)' }} />{host ? `${formatBytes(host.storage.available)} free` : '—'}</span>
</div> </div>
</div> </div>
} }
@@ -569,6 +581,8 @@ containr-agent`;
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connect Node</span> <span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connect Node</span>
</div> </div>
{isAdmin ? (
<>
<div className="mb-3 flex gap-2"> <div className="mb-3 flex gap-2">
<input <input
value={tokenLabel} value={tokenLabel}
@@ -640,6 +654,12 @@ containr-agent`;
</div> </div>
</div> </div>
)} )}
</>
) : (
<p className="text-xs text-[var(--text-tertiary)]">
Node registration and token issuance are admin operations. {signedIn ? 'Your account does not have admin rights.' : 'Sign in as an admin to manage nodes.'}
</p>
)}
</div> </div>
</div> </div>
</section> </section>
@@ -678,7 +698,7 @@ export function PeoplePage() {
const projectsQuery = useQuery({ const projectsQuery = useQuery({
queryKey: ['people-projects'], queryKey: ['people-projects'],
queryFn: listProjects, queryFn: () => listProjects(),
}); });
const auditLogsQuery = useQuery({ const auditLogsQuery = useQuery({
queryKey: ['people-audit-logs'], queryKey: ['people-audit-logs'],
@@ -1266,8 +1286,12 @@ export function SettingsPage() {
}; };
const clearCanvasCache = () => { const clearCanvasCache = () => {
for (const key of storageSummary.canvasKeys) { try {
localStorage.removeItem(key); for (const key of storageSummary.canvasKeys) {
window.localStorage.removeItem(key);
}
} catch {
// Storage unavailable — nothing to clear.
} }
refreshStorage(); refreshStorage();
}; };
@@ -1509,161 +1533,14 @@ export function SettingsPage() {
} }
export function DocsPage() { export function DocsPage() {
const profileQuery = useQuery({
queryKey: ['docs-profile'],
queryFn: getCurrentUserProfile,
});
const projectsQuery = useQuery({
queryKey: ['docs-projects'],
queryFn: listProjects,
});
const templatesQuery = useQuery({
queryKey: ['docs-templates'],
queryFn: () => listTemplates(),
});
const buildsQuery = useQuery({
queryKey: ['docs-builds'],
queryFn: () => listBuilds({ page: 1, limit: 1 }),
});
const profileStatus = endpointStateBadge(profileQuery.isLoading, profileQuery.isError);
const projectStatus = endpointStateBadge(projectsQuery.isLoading, projectsQuery.isError);
const templateStatus = endpointStateBadge(templatesQuery.isLoading, templatesQuery.isError);
const buildStatus = endpointStateBadge(buildsQuery.isLoading, buildsQuery.isError);
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<SecondaryPageHeader <SecondaryPageHeader
title="Docs" title="Docs"
description="Operational references and API documentation" description="Guides and references — synced from GitHub, cached offline"
/> />
<div className="w-full px-8 py-6"> <div className="w-full px-8 py-6">
{/* API Status Grid */} <DocsBrowser />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="panel p-4">
<div className="flex items-center gap-2 mb-2">
<User size={14} className={profileStatus.toneClass} />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Profile</span>
</div>
<p className={`text-sm font-semibold ${profileStatus.toneClass}`}>{profileStatus.label}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{profileQuery.data ? `${profileQuery.data.name} authenticated` : 'GET /user/profile'}
</p>
</div>
<div className="panel p-4">
<div className="flex items-center gap-2 mb-2">
<Folder size={14} className={projectStatus.toneClass} />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Projects</span>
</div>
<p className={`text-sm font-semibold ${projectStatus.toneClass}`}>{projectStatus.label}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{projectsQuery.data ? `${projectsQuery.data.length} projects` : 'GET /projects'}
</p>
</div>
<div className="panel p-4">
<div className="flex items-center gap-2 mb-2">
<Box size={14} className={templateStatus.toneClass} />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Templates</span>
</div>
<p className={`text-sm font-semibold ${templateStatus.toneClass}`}>{templateStatus.label}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{templatesQuery.data ? `${templatesQuery.data.length} templates` : 'GET /templates'}
</p>
</div>
<div className="panel p-4">
<div className="flex items-center gap-2 mb-2">
<Activity size={14} className={buildStatus.toneClass} />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Builds</span>
</div>
<p className={`text-sm font-semibold ${buildStatus.toneClass}`}>{buildStatus.label}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{buildsQuery.data ? `${buildsQuery.data.total} builds` : 'GET /builds'}
</p>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Repository Paths */}
<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">
<BookOpen size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Repository Paths</h2>
<p className="text-xs text-[var(--text-tertiary)]">Source files and documentation</p>
</div>
</div>
<div className="space-y-3">
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<FileText size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">API Contract</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">docs/api/openapi.yaml</p>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<FileText size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Generated Types</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">app/frontend/src/generated/api-types.ts</p>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<BookOpen size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Primary Guides</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">README.md • DOCKER_SETUP.md • docs/guides/</p>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<FileText size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">UI References</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">docs/references/dashboard.png • projects.png</p>
</div>
</div>
</section>
{/* Common Commands */}
<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(--surface-muted)] flex items-center justify-center">
<Terminal size={18} className="text-[var(--text-tertiary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Common Commands</h2>
<p className="text-xs text-[var(--text-tertiary)]">Development and build scripts</p>
</div>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)]">
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">
{`# Regenerate frontend API types
npm --prefix app/frontend run generate:api
# Frontend type-check + build
npm --prefix app/frontend run build:check
# Backend API tests
cd app/backend && go test ./internal/api/...
# Build remote node agent
cd app/backend && go build -o bin/containr-agent ./cmd/agent`}
</pre>
</div>
<div className="mt-4 p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<Radio size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Active API Base</span>
</div>
<p className="mono text-xs text-[var(--text-primary)] break-all">{getApiBaseUrl()}</p>
</div>
</section>
</div>
</div> </div>
</div> </div>
); );
@@ -1679,12 +1556,20 @@ const dbStatusClass: Record<string, string> = {
error: 'bg-[var(--error-soft)] text-[var(--error)]', error: 'bg-[var(--error-soft)] text-[var(--error)]',
}; };
const DATABASE_TYPES = ['postgresql', 'mysql', 'mariadb', 'mongodb', 'redis', 'dragonfly', 'clickhouse'] as const;
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 [searchParams] = useSearchParams();
const isDemoMode = searchParams.get('demo') === '1'; const isDemoMode = searchParams.get('demo') === '1';
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [bindDb, setBindDb] = useState<DatabaseEntity | null>(null); const [bindDb, setBindDb] = useState<DatabaseEntity | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' });
const [createError, setCreateError] = useState<string | null>(null);
const databasesQuery = useQuery({ const databasesQuery = useQuery({
queryKey: ['databases'], queryKey: ['databases'],
queryFn: listDatabases, queryFn: listDatabases,
@@ -1693,6 +1578,24 @@ export function DatabasesPage() {
}); });
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['databases'] }); const invalidate = () => queryClient.invalidateQueries({ queryKey: ['databases'] });
const createMutation = useMutation({
mutationFn: () =>
createManagedDatabase({
name: createForm.name.trim(),
type: createForm.type as CreateDatabaseInput['type'],
plan: createForm.plan as CreateDatabaseInput['plan'],
region: createForm.region.trim() || 'local',
}),
onSuccess: () => {
setCreateOpen(false);
setCreateForm({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' });
setCreateError(null);
invalidate();
},
onError: (error) => {
setCreateError(error instanceof Error ? error.message : 'Failed to create database');
},
});
const actionMutation = useMutation({ const actionMutation = useMutation({
mutationFn: ({ id, action }: { id: string; action: 'start' | 'stop' | 'restart' }) => mutationFn: ({ id, action }: { id: string; action: 'start' | 'stop' | 'restart' }) =>
databaseAction(id, action), databaseAction(id, action),
@@ -1717,6 +1620,20 @@ export function DatabasesPage() {
description="Managed database services — connection info, runtime actions and backups" description="Managed database services — connection info, runtime actions and backups"
/> />
<div className="w-full px-8 py-6"> <div className="w-full px-8 py-6">
<div className="mb-5 flex items-center justify-between">
<p className="text-xs text-[var(--text-tertiary)]">
{databases.length} {databases.length === 1 ? 'database' : 'databases'}
</p>
{signedIn && !isDemoMode ? (
<button
type="button"
onClick={() => setCreateOpen(true)}
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 text-[12.5px] font-semibold text-[var(--accent-on)]"
>
<Database size={13} /> New database
</button>
) : null}
</div>
{databasesQuery.isLoading ? ( {databasesQuery.isLoading ? (
<div className="flex items-center justify-center py-16 text-[var(--text-muted)]"> <div className="flex items-center justify-center py-16 text-[var(--text-muted)]">
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
@@ -1725,9 +1642,19 @@ export function DatabasesPage() {
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-10 text-center"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-10 text-center">
<Database size={28} className="mx-auto text-[var(--text-muted)]" /> <Database size={28} className="mx-auto text-[var(--text-muted)]" />
<p className="mt-3 text-sm text-[var(--text-secondary)]">No managed databases yet.</p> <p className="mt-3 text-sm text-[var(--text-secondary)]">No managed databases yet.</p>
<p className="mt-1 text-xs text-[var(--text-muted)]"> {signedIn ? (
Create one via Add Service &rarr; Database in a project workspace. <button
</p> type="button"
onClick={() => setCreateOpen(true)}
className="mt-4 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12.5px] font-semibold text-[var(--accent-on)]"
>
<Database size={13} /> Create your first database
</button>
) : (
<p className="mt-1 text-xs text-[var(--text-muted)]">
Sign in to provision a managed database.
</p>
)}
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@@ -1775,6 +1702,8 @@ export function DatabasesPage() {
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{signedIn ? (
<>
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mr-1">Actions</span> <span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mr-1">Actions</span>
<button <button
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })} onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })}
@@ -1810,6 +1739,10 @@ export function DatabasesPage() {
{actionMutation.error instanceof Error ? actionMutation.error.message : 'Action failed'} {actionMutation.error instanceof Error ? actionMutation.error.message : 'Action failed'}
</span> </span>
)} )}
</>
) : (
<span className="text-xs text-[var(--text-tertiary)]">Sign in to manage this database.</span>
)}
</div> </div>
{bindDb?.id === db.id && db.connection_url && ( {bindDb?.id === db.id && db.connection_url && (
@@ -1838,6 +1771,7 @@ export function DatabasesPage() {
<div> <div>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Backups</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Backups</p>
{signedIn ? (
<button <button
onClick={() => backupMutation.mutate(db.id ?? '')} onClick={() => backupMutation.mutate(db.id ?? '')}
disabled={!running || backupMutation.isPending} disabled={!running || backupMutation.isPending}
@@ -1847,6 +1781,7 @@ export function DatabasesPage() {
<Archive size={11} /> <Archive size={11} />
{backupMutation.isPending ? 'Starting…' : 'New backup'} {backupMutation.isPending ? 'Starting…' : 'New backup'}
</button> </button>
) : null}
</div> </div>
<BackupScheduleRow <BackupScheduleRow
databaseId={db.id ?? ''} databaseId={db.id ?? ''}
@@ -1897,6 +1832,84 @@ export function DatabasesPage() {
</div> </div>
)} )}
</div> </div>
{createOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
<div className="border-b border-[var(--border-subtle)] px-5 py-4">
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">New database</h2>
<p className="text-xs text-[var(--text-tertiary)]">Provision a managed database service.</p>
</div>
<div className="space-y-4 px-5 py-4">
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Name</label>
<input
value={createForm.name}
onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))}
placeholder="my-postgres"
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</label>
<select
value={createForm.type}
onChange={(e) => setCreateForm((f) => ({ ...f, type: e.target.value }))}
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-sm focus:border-[var(--accent-primary)]"
>
{DATABASE_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Plan</label>
<select
value={createForm.plan}
onChange={(e) => setCreateForm((f) => ({ ...f, plan: e.target.value }))}
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-sm focus:border-[var(--accent-primary)]"
>
{DATABASE_PLANS.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Region</label>
<input
value={createForm.region}
onChange={(e) => setCreateForm((f) => ({ ...f, region: e.target.value }))}
placeholder="local"
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/>
</div>
</div>
{createError ? (
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-3.5 py-2.5 text-xs text-[var(--error)]">{createError}</div>
) : null}
</div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
<button
type="button"
onClick={() => setCreateOpen(false)}
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
Cancel
</button>
<button
type="button"
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || createForm.name.trim().length === 0}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{createMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
Create database
</button>
</div>
</div>
</div>
) : null}
</div> </div>
); );
} }
@@ -1913,7 +1926,7 @@ function BindDatabasePanel({
const [varKey, setVarKey] = useState('DATABASE_URL'); const [varKey, setVarKey] = useState('DATABASE_URL');
const [bound, setBound] = useState(false); const [bound, setBound] = useState(false);
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: listProjects }); const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects() });
const servicesQuery = useQuery({ const servicesQuery = useQuery({
queryKey: ['services', projectId], queryKey: ['services', projectId],
queryFn: () => listServicesByProject(projectId), queryFn: () => listServicesByProject(projectId),
@@ -1935,7 +1948,7 @@ function BindDatabasePanel({
const services = servicesQuery.data ?? []; const services = servicesQuery.data ?? [];
return ( return (
<div className="rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-raised)] p-4 space-y-3"> <div className="rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-card)] p-4 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs font-medium text-[var(--text-primary)]">Bind to service</p> <p className="text-xs font-medium text-[var(--text-primary)]">Bind to service</p>
<button onClick={onClose} className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]">Close</button> <button onClick={onClose} className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]">Close</button>
@@ -1982,7 +1995,7 @@ function BindDatabasePanel({
<button <button
onClick={() => bindMutation.mutate()} onClick={() => bindMutation.mutate()}
disabled={!serviceId || !varKey.trim() || bindMutation.isPending} disabled={!serviceId || !varKey.trim() || bindMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-xs font-medium disabled:opacity-40" className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-40"
> >
{bindMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Link2 size={11} />} Bind {bindMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Link2 size={11} />} Bind
</button> </button>
@@ -2061,6 +2074,14 @@ const failoverStrategies = [
export function HighAvailabilityPage() { export function HighAvailabilityPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const sessionQuery = useAuthSession();
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: getCurrentUserProfile,
enabled: Boolean(sessionQuery.data),
retry: false,
});
const isAdmin = Boolean(profileQuery.data?.isAdmin);
const [failoverReason, setFailoverReason] = useState(''); const [failoverReason, setFailoverReason] = useState('');
const [policyForm, setPolicyForm] = useState<{ const [policyForm, setPolicyForm] = useState<{
serviceId: string; serviceId: string;
@@ -2076,7 +2097,7 @@ export function HighAvailabilityPage() {
const policiesQuery = useQuery({ queryKey: ['ha-policies'], queryFn: listFailoverPolicies }); const policiesQuery = useQuery({ queryKey: ['ha-policies'], queryFn: listFailoverPolicies });
const alertsQuery = useQuery({ queryKey: ['ha-alerts'], queryFn: listActiveAlerts, refetchInterval: 15_000 }); const alertsQuery = useQuery({ queryKey: ['ha-alerts'], queryFn: listActiveAlerts, refetchInterval: 15_000 });
const healthQuery = useQuery({ queryKey: ['ha-health'], queryFn: listHealthResults, refetchInterval: 30_000 }); const healthQuery = useQuery({ queryKey: ['ha-health'], queryFn: listHealthResults, refetchInterval: 30_000 });
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: listProjects }); const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects() });
const servicesQuery = useQuery({ const servicesQuery = useQuery({
queryKey: ['services', policyProjectId], queryKey: ['services', policyProjectId],
queryFn: () => listServicesByProject(policyProjectId), queryFn: () => listServicesByProject(policyProjectId),
@@ -2134,6 +2155,13 @@ export function HighAvailabilityPage() {
description="Failover manager, policies, and active alerts" description="Failover manager, policies, and active alerts"
/> />
<div className="p-8 space-y-8"> <div className="p-8 space-y-8">
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<ShieldCheck size={13} className="text-[var(--accent-primary)]" />
<span>HA state feeds the platform security posture.</span>
<Link to="/security" className="font-medium text-[var(--accent-primary)] hover:underline">
Open Security →
</Link>
</div>
{pageError && ( {pageError && (
<div className="rounded-[var(--radius-md)] border border-[var(--error)] bg-[var(--error)]/10 px-4 py-3 text-sm text-[var(--error)]"> <div className="rounded-[var(--radius-md)] border border-[var(--error)] bg-[var(--error)]/10 px-4 py-3 text-sm text-[var(--error)]">
{pageError instanceof Error ? pageError.message : 'Request failed'} {pageError instanceof Error ? pageError.message : 'Request failed'}
@@ -2171,6 +2199,7 @@ export function HighAvailabilityPage() {
/> />
</div> </div>
{isAdmin ? (
<div className="panel p-6"> <div className="panel p-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<Zap size={18} className="text-[var(--accent-primary)]" /> <Zap size={18} className="text-[var(--accent-primary)]" />
@@ -2181,7 +2210,7 @@ export function HighAvailabilityPage() {
type="button" type="button"
disabled={toggleMutation.isPending || statusQuery.isLoading} disabled={toggleMutation.isPending || statusQuery.isLoading}
onClick={() => toggleMutation.mutate(!status?.enabled)} onClick={() => toggleMutation.mutate(!status?.enabled)}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50" className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
> >
{status?.enabled ? 'Disable HA manager' : 'Enable HA manager'} {status?.enabled ? 'Disable HA manager' : 'Enable HA manager'}
</button> </button>
@@ -2192,7 +2221,7 @@ export function HighAvailabilityPage() {
value={failoverReason} value={failoverReason}
onChange={(e) => setFailoverReason(e.target.value)} onChange={(e) => setFailoverReason(e.target.value)}
placeholder="Manual failover" placeholder="Manual failover"
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] 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"
/> />
</div> </div>
<button <button
@@ -2210,6 +2239,7 @@ export function HighAvailabilityPage() {
</div> </div>
</div> </div>
</div> </div>
) : null}
<div className="panel p-6"> <div className="panel p-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
@@ -2225,7 +2255,7 @@ export function HighAvailabilityPage() {
{(alertsQuery.data ?? []).map((alert) => ( {(alertsQuery.data ?? []).map((alert) => (
<div <div
key={alert.id} key={alert.id}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-primary)] px-4 py-3" className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
> >
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-[var(--text-primary)] truncate">{alert.message ?? alert.id}</p> <p className="text-sm text-[var(--text-primary)] truncate">{alert.message ?? alert.id}</p>
@@ -2233,14 +2263,16 @@ export function HighAvailabilityPage() {
{alert.severity ?? 'info'} · since {alert.starts_at ? formatRelative(alert.starts_at) : '—'} {alert.severity ?? 'info'} · since {alert.starts_at ? formatRelative(alert.starts_at) : '—'}
</p> </p>
</div> </div>
{isAdmin ? (
<button <button
type="button" type="button"
disabled={resolveMutation.isPending} disabled={resolveMutation.isPending}
onClick={() => alert.id && resolveMutation.mutate(alert.id)} onClick={() => alert.id && resolveMutation.mutate(alert.id)}
className="ml-4 shrink-0 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-xs text-[var(--text-primary)] disabled:opacity-50" className="ml-4 shrink-0 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)] disabled:opacity-50"
> >
Resolve Resolve
</button> </button>
) : null}
</div> </div>
))} ))}
</div> </div>
@@ -2253,11 +2285,11 @@ export function HighAvailabilityPage() {
<ShieldCheck size={18} className="text-[var(--accent-primary)]" /> <ShieldCheck size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Failover policies</h2> <h2 className="text-base font-semibold text-[var(--text-primary)]">Failover policies</h2>
</div> </div>
{policyForm === null && ( {policyForm === null && isAdmin && (
<button <button
type="button" type="button"
onClick={() => setPolicyForm({ serviceId: '', strategy: 'active_passive', minHealthyNodes: '1', maxFailures: '3', enabled: true, editing: false })} onClick={() => setPolicyForm({ serviceId: '', strategy: 'active_passive', minHealthyNodes: '1', maxFailures: '3', enabled: true, editing: false })}
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-xs font-medium" className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium"
> >
New policy New policy
</button> </button>
@@ -2265,12 +2297,12 @@ export function HighAvailabilityPage() {
</div> </div>
{policyForm !== null && ( {policyForm !== null && (
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-4 mb-4 space-y-4"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 mb-4 space-y-4">
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{policyForm.editing ? ( {policyForm.editing ? (
<div> <div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Service</label> <label className="block text-xs text-[var(--text-tertiary)] mb-1">Service</label>
<p className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)] font-mono"> <p className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] font-mono">
{policyForm.serviceId} {policyForm.serviceId}
</p> </p>
</div> </div>
@@ -2281,7 +2313,7 @@ export function HighAvailabilityPage() {
<select <select
value={policyProjectId} value={policyProjectId}
onChange={(e) => { setPolicyProjectId(e.target.value); setPolicyForm({ ...policyForm, serviceId: '' }); }} onChange={(e) => { setPolicyProjectId(e.target.value); setPolicyForm({ ...policyForm, serviceId: '' }); }}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]" className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
> >
<option value="">Select project…</option> <option value="">Select project…</option>
{(projectsQuery.data ?? []).map((p) => ( {(projectsQuery.data ?? []).map((p) => (
@@ -2295,7 +2327,7 @@ export function HighAvailabilityPage() {
value={policyForm.serviceId} value={policyForm.serviceId}
onChange={(e) => setPolicyForm({ ...policyForm, serviceId: e.target.value })} onChange={(e) => setPolicyForm({ ...policyForm, serviceId: e.target.value })}
disabled={!policyProjectId} disabled={!policyProjectId}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)] disabled:opacity-50" className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] disabled:opacity-50"
> >
<option value="">Select service…</option> <option value="">Select service…</option>
{(servicesQuery.data ?? []).map((s) => ( {(servicesQuery.data ?? []).map((s) => (
@@ -2310,7 +2342,7 @@ export function HighAvailabilityPage() {
<select <select
value={policyForm.strategy} value={policyForm.strategy}
onChange={(e) => setPolicyForm({ ...policyForm, strategy: e.target.value })} onChange={(e) => setPolicyForm({ ...policyForm, strategy: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]" className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
> >
{failoverStrategies.map((s) => ( {failoverStrategies.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option> <option key={s.value} value={s.value}>{s.label}</option>
@@ -2324,7 +2356,7 @@ export function HighAvailabilityPage() {
type="number" min={1} type="number" min={1}
value={policyForm.minHealthyNodes} value={policyForm.minHealthyNodes}
onChange={(e) => setPolicyForm({ ...policyForm, minHealthyNodes: e.target.value })} onChange={(e) => setPolicyForm({ ...policyForm, minHealthyNodes: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]" className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
/> />
</div> </div>
<div> <div>
@@ -2333,7 +2365,7 @@ export function HighAvailabilityPage() {
type="number" min={1} type="number" min={1}
value={policyForm.maxFailures} value={policyForm.maxFailures}
onChange={(e) => setPolicyForm({ ...policyForm, maxFailures: e.target.value })} onChange={(e) => setPolicyForm({ ...policyForm, maxFailures: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]" className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
/> />
</div> </div>
</div> </div>
@@ -2351,14 +2383,14 @@ export function HighAvailabilityPage() {
type="button" type="button"
disabled={savePolicyMutation.isPending || !policyForm.serviceId} disabled={savePolicyMutation.isPending || !policyForm.serviceId}
onClick={() => savePolicyMutation.mutate()} onClick={() => savePolicyMutation.mutate()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50" className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
> >
{savePolicyMutation.isPending ? 'Saving…' : 'Save policy'} {savePolicyMutation.isPending ? 'Saving…' : 'Save policy'}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setPolicyForm(null)} onClick={() => setPolicyForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]" className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
> >
Cancel Cancel
</button> </button>
@@ -2375,7 +2407,7 @@ export function HighAvailabilityPage() {
{(policiesQuery.data ?? []).map((policy: FailoverPolicy) => ( {(policiesQuery.data ?? []).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-primary)] px-4 py-3" className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
> >
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-[var(--text-primary)] font-mono truncate">{policy.service_id}</p> <p className="text-sm font-medium text-[var(--text-primary)] font-mono truncate">{policy.service_id}</p>
@@ -2385,6 +2417,8 @@ export function HighAvailabilityPage() {
</p> </p>
</div> </div>
<div className="ml-4 flex items-center gap-2 shrink-0"> <div className="ml-4 flex items-center gap-2 shrink-0">
{isAdmin ? (
<>
<button <button
type="button" type="button"
onClick={() => setPolicyForm({ onClick={() => setPolicyForm({
@@ -2395,7 +2429,7 @@ export function HighAvailabilityPage() {
enabled: policy.enabled ?? true, enabled: policy.enabled ?? true,
editing: true, editing: true,
})} })}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-xs text-[var(--text-primary)]" className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)]"
> >
Edit Edit
</button> </button>
@@ -2413,6 +2447,8 @@ export function HighAvailabilityPage() {
Disable Disable
</button> </button>
)} )}
</>
) : null}
</div> </div>
</div> </div>
))} ))}
@@ -2434,7 +2470,7 @@ export function HighAvailabilityPage() {
{(healthQuery.data ?? []).slice(0, 20).map((result, i) => ( {(healthQuery.data ?? []).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-primary)] px-4 py-3" className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
> >
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-[var(--text-primary)] font-mono truncate">{result.check_id}</p> <p className="text-sm text-[var(--text-primary)] font-mono truncate">{result.check_id}</p>
@@ -2473,11 +2509,13 @@ const severityColor: Record<string, string> = {
export function SecurityPage() { export function SecurityPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const sessionQuery = useAuthSession();
const signedIn = Boolean(sessionQuery.data);
const [projectId, setProjectId] = useState(''); const [projectId, setProjectId] = useState('');
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() });
const servicesQuery = useQuery({ const servicesQuery = useQuery({
queryKey: ['services', projectId], queryKey: ['services', projectId],
queryFn: () => listServicesByProject(projectId), queryFn: () => listServicesByProject(projectId),
@@ -2534,6 +2572,13 @@ export function SecurityPage() {
description="Scan findings, security posture, and compliance status" description="Scan findings, security posture, and compliance status"
/> />
<div className="p-8 space-y-8"> <div className="p-8 space-y-8">
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<HeartPulse size={13} className="text-[var(--accent-primary)]" />
<span>Platform availability and failover live under High availability.</span>
<Link to="/ha" className="font-medium text-[var(--accent-primary)] hover:underline">
Open HA →
</Link>
</div>
{pageError && ( {pageError && (
<div className="rounded-[var(--radius-md)] border border-[var(--error)] bg-[var(--error)]/10 px-4 py-3 text-sm text-[var(--error)]"> <div className="rounded-[var(--radius-md)] border border-[var(--error)] bg-[var(--error)]/10 px-4 py-3 text-sm text-[var(--error)]">
{pageError instanceof Error ? pageError.message : 'Request failed'} {pageError instanceof Error ? pageError.message : 'Request failed'}
@@ -2546,7 +2591,7 @@ export function SecurityPage() {
<select <select
value={projectId} value={projectId}
onChange={(e) => { setProjectId(e.target.value); setScanServiceId(''); }} onChange={(e) => { setProjectId(e.target.value); setScanServiceId(''); }}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] 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) => ( {(projectsQuery.data ?? []).map((p) => (
@@ -2561,7 +2606,7 @@ export function SecurityPage() {
<select <select
value={scanType} value={scanType}
onChange={(e) => setScanType(e.target.value)} onChange={(e) => setScanType(e.target.value)}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] 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)]"
> >
{scanTypes.map((t) => ( {scanTypes.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option> <option key={t.value} value={t.value}>{t.label}</option>
@@ -2573,7 +2618,7 @@ export function SecurityPage() {
<select <select
value={scanServiceId} value={scanServiceId}
onChange={(e) => setScanServiceId(e.target.value)} onChange={(e) => setScanServiceId(e.target.value)}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] 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) => ( {(servicesQuery.data ?? []).map((s) => (
@@ -2581,14 +2626,18 @@ export function SecurityPage() {
))} ))}
</select> </select>
</div> </div>
{signedIn ? (
<button <button
type="button" type="button"
disabled={scanMutation.isPending} disabled={scanMutation.isPending}
onClick={() => scanMutation.mutate()} onClick={() => scanMutation.mutate()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50" className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
> >
{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>
)}
</> </>
)} )}
</div> </div>
@@ -2639,7 +2688,7 @@ export function SecurityPage() {
{vulns.map((v) => ( {vulns.map((v) => (
<div <div
key={v.id} key={v.id}
className="flex items-start justify-between rounded-[var(--radius-md)] border border-[var(--border-primary)] px-4 py-3" className="flex items-start justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -2653,7 +2702,7 @@ export function SecurityPage() {
{v.type} · {v.status} · found {v.found_at ? formatRelative(v.found_at) : '—'} {v.type} · {v.status} · found {v.found_at ? formatRelative(v.found_at) : '—'}
</p> </p>
</div> </div>
{v.status === 'open' && v.id && ( {v.status === 'open' && v.id && signedIn && (
<div className="ml-4 flex items-center gap-2 shrink-0"> <div className="ml-4 flex items-center gap-2 shrink-0">
<button <button
type="button" type="button"
@@ -2667,7 +2716,7 @@ export function SecurityPage() {
type="button" type="button"
disabled={vulnMutation.isPending} disabled={vulnMutation.isPending}
onClick={() => vulnMutation.mutate({ id: v.id!, status: 'ignored' })} onClick={() => vulnMutation.mutate({ id: v.id!, status: 'ignored' })}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-xs text-[var(--text-secondary)] disabled:opacity-50" className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] disabled:opacity-50"
> >
Ignore Ignore
</button> </button>
@@ -2695,7 +2744,7 @@ export function SecurityPage() {
{(historyQuery.data ?? []).map((scan) => ( {(historyQuery.data ?? []).map((scan) => (
<div <div
key={scan.id} key={scan.id}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-primary)] px-4 py-3" className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
> >
<div> <div>
<p className="text-sm text-[var(--text-primary)]"> <p className="text-sm text-[var(--text-primary)]">
@@ -36,6 +36,8 @@ 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 { useAuthSession } from '@/lib/use-auth-session';
import { getCurrentUserProfile } from '@/lib/api-client';
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils'; import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time'; import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components'; import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components';
@@ -70,16 +72,18 @@ import {
type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'previews' | 'scaling' | 'console' | 'settings'; type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'previews' | 'scaling' | 'console' | 'settings';
const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [ // Sections marked manageOnly are hidden from anonymous visitors — they
// expose authenticated or owner-scoped data (logs, variables, exec).
const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity; manageOnly?: boolean }> = [
{ key: 'metrics', label: 'Metrics', icon: Activity }, { key: 'metrics', label: 'Metrics', icon: Activity },
{ key: 'logs', label: 'Logs', icon: FileText }, { key: 'logs', label: 'Logs', icon: FileText, manageOnly: true },
{ key: 'config', label: 'Config', icon: Sliders }, { key: 'config', label: 'Config', icon: Sliders },
{ key: 'variables', label: 'Variables', icon: KeyRound }, { key: 'variables', label: 'Variables', icon: KeyRound, manageOnly: true },
{ key: 'cron', label: 'Cron', icon: Clock }, { key: 'cron', label: 'Cron', icon: Clock },
{ key: 'previews', label: 'Previews', icon: GitPullRequest }, { key: 'previews', label: 'Previews', icon: GitPullRequest },
{ key: 'scaling', label: 'Scaling', icon: Layers }, { key: 'scaling', label: 'Scaling', icon: Layers },
{ key: 'console', label: 'Console', icon: Terminal }, { key: 'console', label: 'Console', icon: Terminal, manageOnly: true },
{ key: 'settings', label: 'Settings', icon: Settings }, { key: 'settings', label: 'Settings', icon: Settings, manageOnly: true },
]; ];
function metricStatus(percent: number): 'good' | 'average' | 'warning' { function metricStatus(percent: number): 'good' | 'average' | 'warning' {
@@ -120,6 +124,15 @@ export function ServiceDetailPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isDemoMode = searchParams.get('demo') === '1'; const isDemoMode = searchParams.get('demo') === '1';
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: getCurrentUserProfile,
enabled: signedIn && !isDemoMode,
retry: false,
});
const isAdmin = Boolean(profileQuery.data?.isAdmin);
const [activeSection, setActiveSection] = useState<ServiceSection>('metrics'); const [activeSection, setActiveSection] = useState<ServiceSection>('metrics');
const [logTail, setLogTail] = useState('100'); const [logTail, setLogTail] = useState('100');
@@ -557,22 +570,22 @@ export function ServiceDetailPage() {
style={{ style={{
gap: '6px', gap: '6px',
padding: '14px 24px 10px', padding: '14px 24px 10px',
color: '#6b6e7d', color: 'var(--text-tertiary)',
fontSize: '13px' fontSize: '13px'
}} }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"/> <polyline points="15 18 9 12 15 6"/>
</svg> </svg>
<button <button
onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)} onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)}
style={{ color: '#6b6e7d', textDecoration: 'none' }} style={{ color: 'var(--text-tertiary)', textDecoration: 'none' }}
className="hover:text-[#9295a4] transition-colors" className="hover:text-[var(--text-secondary)] transition-colors"
> >
Servers Servers
</button> </button>
<span style={{ opacity: 0.4 }}>/</span> <span style={{ opacity: 0.4 }}>/</span>
<span style={{ color: '#9295a4' }}>{service.name}</span> <span style={{ color: 'var(--text-secondary)' }}>{service.name}</span>
</div> </div>
{/* Project Header - self.html exact match */} {/* Project Header - self.html exact match */}
@@ -592,7 +605,7 @@ export function ServiceDetailPage() {
</div> </div>
<div> <div>
<div className="flex items-center" style={{ gap: '10px' }}> <div className="flex items-center" style={{ gap: '10px' }}>
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}>{service.name}</span> <span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: 'var(--text-primary)' }}>{service.name}</span>
<span <span
className={`badge-${service.status === 'running' ? 'active' : service.status === 'degraded' ? 'degraded' : 'stopped'}`} className={`badge-${service.status === 'running' ? 'active' : service.status === 'degraded' ? 'degraded' : 'stopped'}`}
> >
@@ -606,8 +619,8 @@ export function ServiceDetailPage() {
href={service.publicUrl ?? `https://${service.domain}`} href={service.publicUrl ?? `https://${service.domain}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center hover:text-[#9295a4] transition-colors" className="flex items-center hover:text-[var(--text-secondary)] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }} style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
> >
{service.publicUrl ?? `https://${service.domain}`} {service.publicUrl ?? `https://${service.domain}`}
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
@@ -619,8 +632,8 @@ export function ServiceDetailPage() {
)} )}
<button <button
onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)} onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)}
className="flex items-center hover:text-[#9295a4] transition-colors" className="flex items-center hover:text-[var(--text-secondary)] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }} style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
> >
Project Information Project Information
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
@@ -630,7 +643,7 @@ export function ServiceDetailPage() {
</div> </div>
</div> </div>
<div className="ml-auto flex" style={{ gap: '10px' }}> <div className="ml-auto flex" style={{ gap: '10px' }}>
{!isDemoMode && ( {!isDemoMode && signedIn && (
<> <>
<button <button
onClick={() => stopMutation.mutate()} onClick={() => stopMutation.mutate()}
@@ -697,7 +710,7 @@ export function ServiceDetailPage() {
data={isDemoMode data={isDemoMode
? Array.from({ length: 24 }, (_, i) => seededMetric(`${service.id}:cpu:${i}`, 8, 80)) ? Array.from({ length: 24 }, (_, i) => seededMetric(`${service.id}:cpu:${i}`, 8, 80))
: metricHistory.cpu.length > 0 ? metricHistory.cpu : [0]} : metricHistory.cpu.length > 0 ? metricHistory.cpu : [0]}
color="#b4e34a" color="var(--accent-primary)"
height={72} height={72}
/> />
} }
@@ -711,10 +724,10 @@ export function ServiceDetailPage() {
subtitle={isDemoMode ? 'Container footprint' : hasLiveTelemetry ? `${formatBytes(liveMetrics!.memory_usage_bytes)} used` : 'No running containers'} subtitle={isDemoMode ? 'Container footprint' : hasLiveTelemetry ? `${formatBytes(liveMetrics!.memory_usage_bytes)} used` : 'No running containers'}
chart={ chart={
<div className="relative mx-auto" style={{ width: 150 }}> <div className="relative mx-auto" style={{ width: 150 }}>
<DonutChart percentage={isDemoMode ? metricSet?.memory ?? 0 : memoryPercent} color="#f2c94c" size={150} thickness={14} /> <DonutChart percentage={isDemoMode ? metricSet?.memory ?? 0 : memoryPercent} color="var(--warning)" size={150} thickness={14} />
<div className="absolute inset-x-0 bottom-0 text-center"> <div className="absolute inset-x-0 bottom-0 text-center">
<div className="text-[10px] uppercase tracking-wide text-[#6b6e7d]">Used</div> <div className="text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">Used</div>
<div className="text-sm font-bold text-[#e8e9f0]"> <div className="text-sm font-bold text-[var(--text-primary)]">
{isDemoMode {isDemoMode
? `${((8 * (metricSet?.memory ?? 0)) / 100).toFixed(1)} GB / 8 GB` ? `${((8 * (metricSet?.memory ?? 0)) / 100).toFixed(1)} GB / 8 GB`
: hasLiveTelemetry : hasLiveTelemetry
@@ -737,7 +750,7 @@ export function ServiceDetailPage() {
data={isDemoMode data={isDemoMode
? Array.from({ length: 24 }, (_, i) => seededMetric(`${service.id}:net:${i}`, 15, 70)) ? Array.from({ length: 24 }, (_, i) => seededMetric(`${service.id}:net:${i}`, 15, 70))
: metricHistory.net.length > 0 ? metricHistory.net : [0]} : metricHistory.net.length > 0 ? metricHistory.net : [0]}
color="#7ab8ff" color="var(--info)"
height={72} height={72}
/> />
} }
@@ -785,12 +798,12 @@ export function ServiceDetailPage() {
<div <div
className="flex" className="flex"
style={{ style={{
borderBottom: '1px solid rgba(255,255,255,0.07)', borderBottom: '1px solid var(--border-subtle)',
marginBottom: '18px', marginBottom: '18px',
padding: '0 24px' padding: '0 24px'
}} }}
> >
{sectionItems.map((item) => { {sectionItems.filter((item) => signedIn || !item.manageOnly).map((item) => {
const active = activeSection === item.key; const active = activeSection === item.key;
const Icon = item.icon; const Icon = item.icon;
return ( return (
@@ -847,8 +860,8 @@ export function ServiceDetailPage() {
return ( return (
<div <div
key={i} key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer" className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80 cursor-pointer"
style={{ height: `${value}%`, background: '#b4e34a' }} style={{ height: `${value}%`, background: 'var(--accent-primary)' }}
title={`${value}%`} title={`${value}%`}
/> />
); );
@@ -856,8 +869,8 @@ export function ServiceDetailPage() {
: metricHistory.cpu.map((value, i) => ( : metricHistory.cpu.map((value, i) => (
<div <div
key={i} key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80" className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80"
style={{ height: `${Math.max(3, value)}%`, background: '#b4e34a' }} style={{ height: `${Math.max(3, value)}%`, background: 'var(--accent-primary)' }}
title={`${value.toFixed(1)}%`} title={`${value.toFixed(1)}%`}
/> />
))} ))}
@@ -888,8 +901,8 @@ export function ServiceDetailPage() {
return ( return (
<div <div
key={i} key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer" className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80 cursor-pointer"
style={{ height: `${value}%`, background: '#5ee6a0' }} style={{ height: `${value}%`, background: 'var(--success)' }}
title={`${value}%`} title={`${value}%`}
/> />
); );
@@ -897,8 +910,8 @@ export function ServiceDetailPage() {
: metricHistory.memory.map((value, i) => ( : metricHistory.memory.map((value, i) => (
<div <div
key={i} key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80" className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80"
style={{ height: `${Math.max(3, value)}%`, background: '#5ee6a0' }} style={{ height: `${Math.max(3, value)}%`, background: 'var(--success)' }}
title={`${value.toFixed(1)}%`} title={`${value.toFixed(1)}%`}
/> />
))} ))}
@@ -1288,6 +1301,7 @@ export function ServiceDetailPage() {
<p className="text-sm text-[var(--text-secondary)]">Scheduled commands executed inside the service container</p> <p className="text-sm text-[var(--text-secondary)]">Scheduled commands executed inside the service container</p>
</div> </div>
</div> </div>
{signedIn ? (
<button <button
onClick={() => { setEditingCronId(null); setCronForm({ name: '', schedule: '', command: '', timezone: 'UTC', enabled: true, retention: 30 }); }} onClick={() => { setEditingCronId(null); setCronForm({ name: '', schedule: '', command: '', timezone: 'UTC', enabled: true, retention: 30 }); }}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors" className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
@@ -1295,6 +1309,7 @@ export function ServiceDetailPage() {
<Plus size={12} /> <Plus size={12} />
New job New job
</button> </button>
) : null}
</div> </div>
{cronForm && ( {cronForm && (
@@ -1378,9 +1393,10 @@ export function ServiceDetailPage() {
<div key={job.id} className="panel-soft p-3"> <div key={job.id} className="panel-soft p-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={() => toggleCronMutation.mutate(job)} onClick={() => signedIn && toggleCronMutation.mutate(job)}
title={job.enabled ? 'Disable' : 'Enable'} title={job.enabled ? 'Disable' : 'Enable'}
className={`w-8 h-5 rounded-full relative transition-colors shrink-0 ${job.enabled ? 'bg-[var(--accent-primary)]' : 'bg-[var(--border-default)]'}`} disabled={!signedIn}
className={`w-8 h-5 rounded-full relative transition-colors shrink-0 ${job.enabled ? 'bg-[var(--accent-primary)]' : 'bg-[var(--border-default)]'} disabled:opacity-60 disabled:cursor-not-allowed`}
> >
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${job.enabled ? 'left-3.5' : 'left-0.5'}`} /> <span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${job.enabled ? 'left-3.5' : 'left-0.5'}`} />
</button> </button>
@@ -1396,15 +1412,19 @@ export function ServiceDetailPage() {
</p> </p>
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button onClick={() => triggerCronMutation.mutate(job.id as string)} title="Run now" {signedIn ? (
disabled={triggerCronMutation.isPending} <button onClick={() => triggerCronMutation.mutate(job.id as string)} title="Run now"
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors disabled:opacity-50"> disabled={triggerCronMutation.isPending}
<Play size={13} /> className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors disabled:opacity-50">
</button> <Play size={13} />
</button>
) : null}
<button onClick={() => setHistoryCronId(historyCronId === job.id ? null : (job.id as string))} title="History" <button onClick={() => setHistoryCronId(historyCronId === job.id ? null : (job.id as string))} title="History"
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors"> className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors">
<ChevronDown size={13} className={historyCronId === job.id ? 'rotate-180 transition-transform' : 'transition-transform'} /> <ChevronDown size={13} className={historyCronId === job.id ? 'rotate-180 transition-transform' : 'transition-transform'} />
</button> </button>
{signedIn ? (
<>
<button <button
onClick={() => { setEditingCronId(job.id as string); setCronForm({ name: job.name ?? '', schedule: job.schedule ?? '', command: job.command ?? '', timezone: job.timezone ?? 'UTC', enabled: job.enabled ?? true, retention: job.retention ?? 30 }); }} onClick={() => { setEditingCronId(job.id as string); setCronForm({ name: job.name ?? '', schedule: job.schedule ?? '', command: job.command ?? '', timezone: job.timezone ?? 'UTC', enabled: job.enabled ?? true, retention: job.retention ?? 30 }); }}
title="Edit" title="Edit"
@@ -1417,6 +1437,8 @@ export function ServiceDetailPage() {
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors"> className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors">
<Trash2 size={13} /> <Trash2 size={13} />
</button> </button>
</>
) : null}
</div> </div>
</div> </div>
{historyCronId === job.id && ( {historyCronId === job.id && (
@@ -1462,12 +1484,14 @@ export function ServiceDetailPage() {
<p className="text-sm text-[var(--text-secondary)]">Ephemeral per-branch deployments of this service</p> <p className="text-sm text-[var(--text-secondary)]">Ephemeral per-branch deployments of this service</p>
</div> </div>
</div> </div>
{signedIn ? (
<button <button
onClick={() => setPreviewForm({ branch: '', prNumber: '', ttlHours: '72' })} onClick={() => setPreviewForm({ branch: '', prNumber: '', ttlHours: '72' })}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors" className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
> >
<Plus size={12} /> New preview <Plus size={12} /> New preview
</button> </button>
) : null}
</div> </div>
{previewForm && ( {previewForm && (
@@ -1479,7 +1503,7 @@ export function ServiceDetailPage() {
value={previewForm.branch} value={previewForm.branch}
onChange={(e) => setPreviewForm({ ...previewForm, branch: e.target.value })} onChange={(e) => setPreviewForm({ ...previewForm, branch: e.target.value })}
placeholder="feature/my-branch" placeholder="feature/my-branch"
className="w-52 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-raised)] px-2 py-1.5 text-xs text-[var(--text-primary)]" className="w-52 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
/> />
</label> </label>
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]"> <label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
@@ -1488,7 +1512,7 @@ export function ServiceDetailPage() {
value={previewForm.prNumber} value={previewForm.prNumber}
onChange={(e) => setPreviewForm({ ...previewForm, prNumber: e.target.value })} onChange={(e) => setPreviewForm({ ...previewForm, prNumber: e.target.value })}
placeholder="123" placeholder="123"
className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-raised)] px-2 py-1.5 text-xs text-[var(--text-primary)]" className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
/> />
</label> </label>
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]"> <label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
@@ -1497,7 +1521,7 @@ export function ServiceDetailPage() {
value={previewForm.ttlHours} value={previewForm.ttlHours}
onChange={(e) => setPreviewForm({ ...previewForm, ttlHours: e.target.value })} onChange={(e) => setPreviewForm({ ...previewForm, ttlHours: e.target.value })}
placeholder="72" placeholder="72"
className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-raised)] px-2 py-1.5 text-xs text-[var(--text-primary)]" className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
/> />
</label> </label>
<button <button
@@ -1551,7 +1575,7 @@ export function ServiceDetailPage() {
<ExternalLink size={11} /> Open <ExternalLink size={11} /> Open
</a> </a>
)} )}
{env.status === 'running' && ( {env.status === 'running' && signedIn && (
<button <button
onClick={() => { if (window.confirm('Promote this preview to production?')) promotePreviewMutation.mutate(env.id ?? ''); }} onClick={() => { if (window.confirm('Promote this preview to production?')) promotePreviewMutation.mutate(env.id ?? ''); }}
disabled={promotePreviewMutation.isPending} disabled={promotePreviewMutation.isPending}
@@ -1560,6 +1584,7 @@ export function ServiceDetailPage() {
Promote Promote
</button> </button>
)} )}
{signedIn ? (
<button <button
onClick={() => { if (window.confirm(`Delete preview ${env.environment}?`)) deletePreviewMutation.mutate(env.id ?? ''); }} onClick={() => { if (window.confirm(`Delete preview ${env.environment}?`)) deletePreviewMutation.mutate(env.id ?? ''); }}
disabled={deletePreviewMutation.isPending} disabled={deletePreviewMutation.isPending}
@@ -1567,6 +1592,7 @@ export function ServiceDetailPage() {
> >
Delete Delete
</button> </button>
) : null}
</div> </div>
))} ))}
</div> </div>
@@ -1598,7 +1624,7 @@ export function ServiceDetailPage() {
) : ( ) : (
<> <>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p> <p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p>
<p className="text-xl font-semibold text-[var(--text-primary)]"> <p className="text-xl font-semibold text-[var(--text-primary)]">
{scalingStateQuery.data?.CurrentReplicas ?? {scalingStateQuery.data?.CurrentReplicas ??
@@ -1609,19 +1635,19 @@ export function ServiceDetailPage() {
</span> </span>
</p> </p>
</div> </div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Policy</p> <p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Policy</p>
<p className="text-xl font-semibold text-[var(--text-primary)]"> <p className="text-xl font-semibold text-[var(--text-primary)]">
{scalingPolicyQuery.data ? `${scalingPolicyQuery.data.min_replicas}–${scalingPolicyQuery.data.max_replicas}` : 'None'} {scalingPolicyQuery.data ? `${scalingPolicyQuery.data.min_replicas}–${scalingPolicyQuery.data.max_replicas}` : 'None'}
</p> </p>
</div> </div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Autoscaling</p> <p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Autoscaling</p>
<p className="text-xl font-semibold text-[var(--text-primary)]"> <p className="text-xl font-semibold text-[var(--text-primary)]">
{scalingPolicyQuery.data?.enabled ? 'On' : 'Off'} {scalingPolicyQuery.data?.enabled ? 'On' : 'Off'}
</p> </p>
</div> </div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Last action</p> <p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Last action</p>
<p className="text-sm font-medium text-[var(--text-primary)] truncate"> <p className="text-sm font-medium text-[var(--text-primary)] truncate">
{scalingStateQuery.data?.LastScaleDirection ?? '—'} {scalingStateQuery.data?.LastScaleDirection ?? '—'}
@@ -1639,15 +1665,17 @@ export function ServiceDetailPage() {
max={20} max={20}
value={scaleReplicas} value={scaleReplicas}
onChange={(e) => setScaleReplicas(e.target.value)} onChange={(e) => setScaleReplicas(e.target.value)}
className="w-28 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]" className="w-28 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
placeholder="e.g. 3" placeholder="e.g. 3"
/> />
</div> </div>
{isAdmin ? (
<>
<button <button
type="button" type="button"
disabled={manualScaleMutation.isPending || !scaleReplicas} disabled={manualScaleMutation.isPending || !scaleReplicas}
onClick={() => manualScaleMutation.mutate(Number(scaleReplicas))} onClick={() => manualScaleMutation.mutate(Number(scaleReplicas))}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50" className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
> >
{manualScaleMutation.isPending ? 'Scaling…' : 'Scale'} {manualScaleMutation.isPending ? 'Scaling…' : 'Scale'}
</button> </button>
@@ -1660,7 +1688,7 @@ export function ServiceDetailPage() {
targetMemory: String(scalingPolicyQuery.data?.target_memory ?? 80), targetMemory: String(scalingPolicyQuery.data?.target_memory ?? 80),
enabled: scalingPolicyQuery.data?.enabled ?? true, enabled: scalingPolicyQuery.data?.enabled ?? true,
})} })}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]" className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
> >
{scalingPolicyQuery.data ? 'Edit policy' : 'Create policy'} {scalingPolicyQuery.data ? 'Edit policy' : 'Create policy'}
</button> </button>
@@ -1674,9 +1702,13 @@ export function ServiceDetailPage() {
Disable policy Disable policy
</button> </button>
)} )}
</>
) : (
<span className="self-center text-xs text-[var(--text-tertiary)]">Scaling policy changes are admin-only.</span>
)}
</div> </div>
) : ( ) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-4 space-y-4"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{([ {([
['Min replicas', 'min'], ['Min replicas', 'min'],
@@ -1691,7 +1723,7 @@ export function ServiceDetailPage() {
min={field === 'min' || field === 'max' ? 1 : 0} min={field === 'min' || field === 'max' ? 1 : 0}
value={scalingForm[field]} value={scalingForm[field]}
onChange={(e) => setScalingForm({ ...scalingForm, [field]: e.target.value })} onChange={(e) => setScalingForm({ ...scalingForm, [field]: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]" className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
/> />
</div> </div>
))} ))}
@@ -1709,14 +1741,14 @@ export function ServiceDetailPage() {
type="button" type="button"
disabled={saveScalingMutation.isPending} disabled={saveScalingMutation.isPending}
onClick={() => saveScalingMutation.mutate()} onClick={() => saveScalingMutation.mutate()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50" className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
> >
{saveScalingMutation.isPending ? 'Saving…' : 'Save policy'} {saveScalingMutation.isPending ? 'Saving…' : 'Save policy'}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setScalingForm(null)} onClick={() => setScalingForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]" className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
> >
Cancel Cancel
</button> </button>
@@ -1765,12 +1797,12 @@ export function ServiceDetailPage() {
onChange={(e) => setConsoleInput(e.target.value)} onChange={(e) => setConsoleInput(e.target.value)}
placeholder="e.g. env | sort | head -20" placeholder="e.g. env | sort | head -20"
disabled={execMutation.isPending} disabled={execMutation.isPending}
className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm font-mono text-[var(--text-primary)] disabled:opacity-50" className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm font-mono text-[var(--text-primary)] disabled:opacity-50"
/> />
<button <button
type="submit" type="submit"
disabled={execMutation.isPending || !consoleInput.trim()} disabled={execMutation.isPending || !consoleInput.trim()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50" className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
> >
{execMutation.isPending ? 'Running…' : 'Run'} {execMutation.isPending ? 'Running…' : 'Run'}
</button> </button>
@@ -1789,7 +1821,7 @@ export function ServiceDetailPage() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{consoleHistory.map((entry, i) => ( {consoleHistory.map((entry, i) => (
<div key={i} className="rounded-[var(--radius-md)] border border-[var(--border-primary)] bg-[var(--bg-secondary)] p-3"> <div key={i} className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-3">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<p className="text-xs font-mono text-[var(--text-primary)]">$ {entry.command}</p> <p className="text-xs font-mono text-[var(--text-primary)]">$ {entry.command}</p>
<span className={`text-xs font-mono ${entry.exitCode === 0 && !entry.error ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}> <span className={`text-xs font-mono ${entry.exitCode === 0 && !entry.error ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
@@ -1878,25 +1910,25 @@ export function ServiceDetailPage() {
{networkForm === null ? ( {networkForm === null ? (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Port</p> <p className="text-[var(--text-tertiary)] uppercase tracking-wide">Port</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.port || '—'}</p> <p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.port || '—'}</p>
</div> </div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Domain</p> <p className="text-[var(--text-tertiary)] uppercase tracking-wide">Domain</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)] truncate">{service.domain || '—'}</p> <p className="mt-1 text-sm font-medium text-[var(--text-primary)] truncate">{service.domain || '—'}</p>
</div> </div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p> <p className="text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.replicas ?? 1}</p> <p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.replicas ?? 1}</p>
</div> </div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Restart</p> <p className="text-[var(--text-tertiary)] uppercase tracking-wide">Restart</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.restartPolicy || 'unless-stopped'}</p> <p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.restartPolicy || 'unless-stopped'}</p>
</div> </div>
</div> </div>
) : ( ) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-4 space-y-4"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div> <div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Container port</label> <label className="block text-xs text-[var(--text-tertiary)] mb-1">Container port</label>
@@ -1907,7 +1939,7 @@ export function ServiceDetailPage() {
value={networkForm.port} value={networkForm.port}
onChange={(e) => setNetworkForm({ ...networkForm, port: e.target.value })} onChange={(e) => setNetworkForm({ ...networkForm, port: e.target.value })}
placeholder="e.g. 3000" placeholder="e.g. 3000"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]" className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
/> />
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">Published on the host; routed via Traefik when a domain is set</p> <p className="mt-1 text-[10px] text-[var(--text-tertiary)]">Published on the host; routed via Traefik when a domain is set</p>
</div> </div>
@@ -1917,7 +1949,7 @@ export function ServiceDetailPage() {
value={networkForm.domain} value={networkForm.domain}
onChange={(e) => setNetworkForm({ ...networkForm, domain: e.target.value })} onChange={(e) => setNetworkForm({ ...networkForm, domain: e.target.value })}
placeholder="app.example.com" placeholder="app.example.com"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]" className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
/> />
</div> </div>
<div> <div>
@@ -1926,7 +1958,7 @@ export function ServiceDetailPage() {
value={networkForm.healthcheckPath} value={networkForm.healthcheckPath}
onChange={(e) => setNetworkForm({ ...networkForm, healthcheckPath: e.target.value })} onChange={(e) => setNetworkForm({ ...networkForm, healthcheckPath: e.target.value })}
placeholder="/health" placeholder="/health"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]" className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
/> />
</div> </div>
<div> <div>
@@ -1937,7 +1969,7 @@ export function ServiceDetailPage() {
max={20} max={20}
value={networkForm.replicas} value={networkForm.replicas}
onChange={(e) => setNetworkForm({ ...networkForm, replicas: e.target.value })} onChange={(e) => setNetworkForm({ ...networkForm, replicas: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]" className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
/> />
</div> </div>
<div> <div>
@@ -1945,7 +1977,7 @@ export function ServiceDetailPage() {
<select <select
value={networkForm.restartPolicy} value={networkForm.restartPolicy}
onChange={(e) => setNetworkForm({ ...networkForm, restartPolicy: e.target.value })} onChange={(e) => setNetworkForm({ ...networkForm, restartPolicy: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]" className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
> >
<option value="unless-stopped">unless-stopped</option> <option value="unless-stopped">unless-stopped</option>
<option value="always">always</option> <option value="always">always</option>
@@ -1967,14 +1999,14 @@ export function ServiceDetailPage() {
replicas: Math.max(1, Math.min(20, Number(networkForm.replicas) || 1)), replicas: Math.max(1, Math.min(20, Number(networkForm.replicas) || 1)),
}) })
} }
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50" className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
> >
{updateServiceMutation.isPending ? 'Saving…' : 'Save'} {updateServiceMutation.isPending ? 'Saving…' : 'Save'}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setNetworkForm(null)} onClick={() => setNetworkForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]" className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
> >
Cancel Cancel
</button> </button>
@@ -2,13 +2,19 @@ import { useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
createTemplate,
deleteTemplate,
deployTemplate, deployTemplate,
getCurrentUserProfile,
getTemplateById, getTemplateById,
listProjects, listProjects,
listTemplates, listTemplates,
updateTemplate,
type TemplateDetailEntity, type TemplateDetailEntity,
type TemplateEntity, type TemplateEntity,
type TemplateWriteInput,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session';
import { import {
Search, Search,
Filter, Filter,
@@ -24,6 +30,11 @@ import {
Play, Play,
ArrowRight, ArrowRight,
Star, Star,
Plus,
Pencil,
Trash2,
Upload,
X,
} from 'lucide-react'; } from 'lucide-react';
const demoTemplates: TemplateEntity[] = [ const demoTemplates: TemplateEntity[] = [
@@ -36,6 +47,7 @@ const demoTemplates: TemplateEntity[] = [
configRaw: '{"runtime":"node"}', configRaw: '{"runtime":"node"}',
variablesRaw: '[]', variablesRaw: '[]',
isOfficial: true, isOfficial: true,
ownerId: null,
}, },
{ {
id: 'tpl-go', id: 'tpl-go',
@@ -46,6 +58,7 @@ const demoTemplates: TemplateEntity[] = [
configRaw: '{"runtime":"go"}', configRaw: '{"runtime":"go"}',
variablesRaw: '[]', variablesRaw: '[]',
isOfficial: true, isOfficial: true,
ownerId: null,
}, },
{ {
id: 'tpl-postgres', id: 'tpl-postgres',
@@ -56,6 +69,7 @@ const demoTemplates: TemplateEntity[] = [
configRaw: '{"runtime":"postgres"}', configRaw: '{"runtime":"postgres"}',
variablesRaw: '[]', variablesRaw: '[]',
isOfficial: true, isOfficial: true,
ownerId: null,
}, },
{ {
id: 'tpl-mysql', id: 'tpl-mysql',
@@ -66,6 +80,7 @@ const demoTemplates: TemplateEntity[] = [
configRaw: '{"runtime":"mysql"}', configRaw: '{"runtime":"mysql"}',
variablesRaw: '[]', variablesRaw: '[]',
isOfficial: true, isOfficial: true,
ownerId: null,
}, },
{ {
id: 'tpl-mariadb', id: 'tpl-mariadb',
@@ -76,6 +91,7 @@ const demoTemplates: TemplateEntity[] = [
configRaw: '{"runtime":"mariadb"}', configRaw: '{"runtime":"mariadb"}',
variablesRaw: '[]', variablesRaw: '[]',
isOfficial: true, isOfficial: true,
ownerId: null,
}, },
{ {
id: 'tpl-clickhouse', id: 'tpl-clickhouse',
@@ -86,6 +102,7 @@ const demoTemplates: TemplateEntity[] = [
configRaw: '{"runtime":"clickhouse"}', configRaw: '{"runtime":"clickhouse"}',
variablesRaw: '[]', variablesRaw: '[]',
isOfficial: true, isOfficial: true,
ownerId: null,
}, },
{ {
id: 'tpl-dragonfly', id: 'tpl-dragonfly',
@@ -96,6 +113,7 @@ const demoTemplates: TemplateEntity[] = [
configRaw: '{"runtime":"dragonfly"}', configRaw: '{"runtime":"dragonfly"}',
variablesRaw: '[]', variablesRaw: '[]',
isOfficial: true, isOfficial: true,
ownerId: null,
}, },
]; ];
@@ -354,6 +372,15 @@ function categoryLabel(category: string): string {
return category ? category[0].toUpperCase() + category.slice(1) : 'Uncategorized'; return category ? category[0].toUpperCase() + category.slice(1) : 'Uncategorized';
} }
function safeTemplateRuntime(configRaw: string): string {
try {
const parsed = JSON.parse(configRaw || '{}') as { runtime?: string };
return parsed.runtime || 'n/a';
} catch {
return 'n/a';
}
}
function categoryIcon(category: string): typeof Box { function categoryIcon(category: string): typeof Box {
switch (category) { switch (category) {
case 'frontend': case 'frontend':
@@ -369,11 +396,57 @@ function categoryIcon(category: string): typeof Box {
} }
} }
const EMPTY_TEMPLATE_JSON = JSON.stringify(
{
name: 'my-template',
description: 'What this template deploys.',
category: 'app',
logo: '',
config: {
type: 'web',
runtime: 'node',
build_command: '',
start_command: '',
port: 3000,
health_check: '/health',
environment: {},
},
variables: [
{
key: 'EXAMPLE_VAR',
label: 'Example variable',
default: '',
required: false,
secret: false,
description: 'Shown at deploy time',
},
],
},
null,
2,
);
export function TemplatesPage() { export function TemplatesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const isDemoMode = searchParams.get('demo') === '1'; const isDemoMode = searchParams.get('demo') === '1';
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const profileQuery = useQuery({
queryKey: ['current-profile'],
queryFn: getCurrentUserProfile,
enabled: !isDemoMode && Boolean(sessionQuery.data),
retry: false,
});
const myUserId = profileQuery.data?.id ?? '';
const isAdmin = Boolean(profileQuery.data?.isAdmin);
const [editorOpen, setEditorOpen] = useState(false);
const [editorJson, setEditorJson] = useState(EMPTY_TEMPLATE_JSON);
const [editingTemplate, setEditingTemplate] = useState<TemplateEntity | null>(null);
const [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); const [selectedTemplateIdState, setSelectedTemplateId] = useState<string | null>(null);
@@ -397,7 +470,7 @@ export function TemplatesPage() {
const projectsQuery = useQuery({ const projectsQuery = useQuery({
queryKey: ['template-projects'], queryKey: ['template-projects'],
enabled: !isDemoMode, enabled: !isDemoMode,
queryFn: listProjects, queryFn: () => listProjects(),
}); });
const templates = useMemo( const templates = useMemo(
@@ -553,6 +626,109 @@ export function TemplatesPage() {
missingRequiredVariables.length > 0 || missingRequiredVariables.length > 0 ||
deployMutation.isPending; deployMutation.isPending;
const canManageTemplate = (template: TemplateEntity) =>
!template.isOfficial && Boolean(template.ownerId) && (template.ownerId === myUserId || isAdmin);
const saveTemplateMutation = useMutation({
mutationFn: (input: TemplateWriteInput) =>
editingTemplate ? updateTemplate(editingTemplate.id, input) : createTemplate(input),
onSuccess: (template) => {
queryClient.invalidateQueries({ queryKey: ['templates-page'] });
setEditorOpen(false);
setEditingTemplate(null);
setEditorError(null);
setSelectedTemplateId(template.id);
},
onError: (error) => {
setEditorError(error instanceof Error ? error.message : 'Failed to save template');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteTemplate(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['templates-page'] });
setSelectedTemplateId(null);
},
});
const openCreateEditor = () => {
setEditingTemplate(null);
setEditorJson(EMPTY_TEMPLATE_JSON);
setEditorError(null);
setEditorOpen(true);
};
const openEditEditor = (template: TemplateEntity) => {
let config: unknown = {};
let variables: unknown = [];
try {
config = JSON.parse(template.configRaw || '{}');
} catch {
/* keep {} */
}
try {
variables = JSON.parse(template.variablesRaw || '[]');
} catch {
/* keep [] */
}
setEditingTemplate(template);
setEditorJson(
JSON.stringify(
{
name: template.name,
description: template.description,
category: template.category,
logo: template.logo,
config,
variables,
},
null,
2,
),
);
setEditorError(null);
setEditorOpen(true);
};
const submitEditor = () => {
let parsed: unknown;
try {
parsed = JSON.parse(editorJson);
} catch (err) {
setEditorError(`Invalid JSON: ${err instanceof Error ? err.message : 'parse error'}`);
return;
}
const body = parsed as Partial<TemplateWriteInput>;
if (!body || typeof body !== 'object' || !body.name || typeof body.config !== 'object' || body.config === null) {
setEditorError('Template JSON needs at least "name" and a "config" object.');
return;
}
saveTemplateMutation.mutate({
name: String(body.name),
description: body.description ?? '',
category: body.category ?? 'other',
logo: body.logo ?? '',
config: body.config as Record<string, unknown>,
variables: Array.isArray(body.variables) ? body.variables : [],
});
};
const uploadJsonFile = (file: File) => {
if (!file.name.toLowerCase().endsWith('.json') && file.type !== 'application/json') {
setEditorError('Only .json files are accepted.');
return;
}
file
.text()
.then((text) => {
JSON.parse(text);
setEditorJson(text);
setEditorError(null);
})
.catch(() => setEditorError('File is not valid JSON.'));
};
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
{/* Header */} {/* Header */}
@@ -563,9 +739,18 @@ export function TemplatesPage() {
<h1 className="v-title">Template Catalog<span className="v-cursor">_</span></h1> <h1 className="v-title">Template Catalog<span className="v-cursor">_</span></h1>
<p className="text-sm text-[var(--text-secondary)]">Deploy services from pre-configured templates</p> <p className="text-sm text-[var(--text-secondary)]">Deploy services from pre-configured templates</p>
</div> </div>
<div className="flex items-center gap-2 text-sm text-[var(--text-tertiary)]"> <div className="flex items-center gap-3 text-sm text-[var(--text-tertiary)]">
<Layers size={16} /> <Layers size={16} />
<span>{filteredTemplates.length} templates</span> <span>{filteredTemplates.length} templates</span>
{signedIn && !isDemoMode ? (
<button
type="button"
onClick={openCreateEditor}
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 text-[12.5px] font-semibold text-[var(--accent-on)]"
>
<Plus size={14} /> New template
</button>
) : null}
</div> </div>
</div> </div>
</div> </div>
@@ -685,12 +870,16 @@ export function TemplatesPage() {
<p className={`text-sm font-semibold tracking-tight ${selected ? 'text-[var(--accent-primary)]' : 'text-[var(--text-primary)]'}`}> <p className={`text-sm font-semibold tracking-tight ${selected ? 'text-[var(--accent-primary)]' : 'text-[var(--text-primary)]'}`}>
{template.name} {template.name}
</p> </p>
{template.isOfficial && ( {template.isOfficial ? (
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-[var(--warning-soft)] text-[var(--warning)] text-[10px] font-semibold"> <span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-[var(--warning-soft)] text-[var(--warning)] text-[10px] font-semibold">
<Star size={8} className="fill-[var(--warning)]" /> <Star size={8} className="fill-[var(--warning)]" />
Official Official
</span> </span>
)} ) : template.ownerId === myUserId ? (
<span className="px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] text-[10px] font-semibold">
Mine
</span>
) : null}
</div> </div>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span <span
@@ -700,7 +889,7 @@ export function TemplatesPage() {
{categoryLabel(template.category)} {categoryLabel(template.category)}
</span> </span>
<span className="text-[10px] text-[var(--text-tertiary)]">•</span> <span className="text-[10px] text-[var(--text-tertiary)]">•</span>
<span className="text-[10px] text-[var(--text-tertiary)]">{template.configRaw ? JSON.parse(template.configRaw).runtime : 'n/a'}</span> <span className="text-[10px] text-[var(--text-tertiary)]">{safeTemplateRuntime(template.configRaw)}</span>
</div> </div>
<p className="text-xs text-[var(--text-tertiary)] mt-2 line-clamp-2 group-hover:text-[var(--text-secondary)] transition-colors">{template.description}</p> <p className="text-xs text-[var(--text-tertiary)] mt-2 line-clamp-2 group-hover:text-[var(--text-secondary)] transition-colors">{template.description}</p>
</div> </div>
@@ -777,6 +966,25 @@ export function TemplatesPage() {
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]"> <span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]">
{selectedDetail.config.runtime || 'n/a'} {selectedDetail.config.runtime || 'n/a'}
</span> </span>
{canManageTemplate(selectedDetail.template) ? (
<>
<button
type="button"
onClick={() => openEditEditor(selectedDetail.template)}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-2.5 py-1 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
<Pencil size={11} /> Edit
</button>
<button
type="button"
onClick={() => deleteMutation.mutate(selectedDetail.template.id)}
disabled={deleteMutation.isPending}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-2.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
>
<Trash2 size={11} /> Delete
</button>
</>
) : null}
</div> </div>
</div> </div>
@@ -824,6 +1032,20 @@ export function TemplatesPage() {
</div> </div>
</div> </div>
{!signedIn ? (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-5 text-center">
<p className="text-sm text-[var(--text-secondary)]">
Sign in to deploy this template into one of your projects.
</p>
<Link
to="/auth/sign-in"
className="mt-3 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12.5px] font-semibold text-[var(--accent-on)]"
>
Sign in <ArrowRight size={13} />
</Link>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
@@ -951,12 +1173,88 @@ export function TemplatesPage() {
)} )}
</button> </button>
</div> </div>
</>
)}
</div> </div>
</> </>
) : null} ) : null}
</section> </section>
</div> </div>
</div> </div>
{/* Template editor — JSON only */}
{editorOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
<div className="flex items-center justify-between border-b border-[var(--border-subtle)] px-5 py-4">
<div>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">
{editingTemplate ? `Edit ${editingTemplate.name}` : 'New template'}
</h2>
<p className="text-xs text-[var(--text-tertiary)]">
JSON definition — name, category, config and variables.
</p>
</div>
<div className="flex items-center gap-2">
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
<Upload size={12} /> Upload .json
<input
type="file"
accept=".json,application/json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) uploadJsonFile(file);
e.target.value = '';
}}
/>
</label>
<button
type="button"
onClick={() => setEditorOpen(false)}
className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
>
<X size={15} />
</button>
</div>
</div>
<textarea
value={editorJson}
onChange={(e) => setEditorJson(e.target.value)}
spellCheck={false}
className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/>
{editorError ? (
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
{editorError}
</div>
) : null}
<div className="flex items-center justify-between border-t border-[var(--border-subtle)] px-5 py-3.5">
<p className="text-[11px] text-[var(--text-tertiary)]">
Saved as a user template — only you (and admins) can edit or delete it.
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setEditorOpen(false)}
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
Cancel
</button>
<button
type="button"
onClick={submitEditor}
disabled={saveTemplateMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{saveTemplateMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
{editingTemplate ? 'Save changes' : 'Create template'}
</button>
</div>
</div>
</div>
</div>
) : null}
</div> </div>
); );
} }
@@ -28,7 +28,7 @@ import {
type CanvasNodeLayout, type CanvasNodeLayout,
type ProjectCanvasMetadata, type ProjectCanvasMetadata,
} from '../model'; } from '../model';
import { canvasStorageKey, loadCanvasMetadata, saveCanvasMetadata } from '../storage'; import { hasCanvasMetadata, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes'; import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
import { import {
Plus, Plus,
@@ -287,7 +287,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
deleteMutation.isPending; deleteMutation.isPending;
useEffect(() => { useEffect(() => {
const hasStoredViewport = localStorage.getItem(canvasStorageKey(projectId)) !== null; const hasStoredViewport = hasCanvasMetadata(projectId);
const metadata = loadCanvasMetadata(projectId, services); const metadata = loadCanvasMetadata(projectId, services);
const nextNodes = toFlowNodes(metadata, services, onOpenService); const nextNodes = toFlowNodes(metadata, services, onOpenService);
@@ -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 { useAuthSession } from '@/lib/use-auth-session';
import { formatDate, formatRelative } from '@/lib/time'; import { formatDate, formatRelative } from '@/lib/time';
import type { ServiceVariable } from '../auto-connections'; import type { ServiceVariable } from '../auto-connections';
import { ProjectCanvas } from '../canvas/ProjectCanvas'; import { ProjectCanvas } from '../canvas/ProjectCanvas';
@@ -46,10 +47,10 @@ import {
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings'; type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutGrid }> = [ const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutGrid; authOnly?: boolean }> = [
{ key: 'canvas', label: 'Canvas', icon: LayoutGrid }, { key: 'canvas', label: 'Canvas', icon: LayoutGrid },
{ key: 'observability', label: 'Observability', icon: Activity }, { key: 'observability', label: 'Observability', icon: Activity },
{ key: 'logs', label: 'Logs', icon: FileText }, { key: 'logs', label: 'Logs', icon: FileText, authOnly: true },
{ key: 'settings', label: 'Settings', icon: Settings }, { key: 'settings', label: 'Settings', icon: Settings },
]; ];
@@ -345,7 +346,7 @@ function ServiceCreateDialog(props: {
className={`w-full flex items-center justify-between px-3 py-2 rounded-[var(--radius-sm)] text-left text-sm transition-colors ${ className={`w-full flex items-center justify-between px-3 py-2 rounded-[var(--radius-sm)] text-left text-sm transition-colors ${
repoFullName === repo.full_name repoFullName === repo.full_name
? 'bg-[var(--accent-primary-soft)] text-[var(--text-primary)]' ? 'bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-hover)]' : 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'
}`} }`}
> >
<span className="mono text-xs truncate">{repo.full_name}</span> <span className="mono text-xs truncate">{repo.full_name}</span>
@@ -466,9 +467,19 @@ export function ProjectWorkspacePage() {
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 = searchParams.get('demo') === '1';
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const openAddService = () => {
if (!signedIn) {
navigate('/auth/sign-in');
return;
}
setCreateOpen(true);
};
useEffect(() => { useEffect(() => {
const down = (e: KeyboardEvent) => { const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
@@ -569,7 +580,8 @@ export function ProjectWorkspacePage() {
return Object.fromEntries(entries) as Record<string, ServiceVariable[]>; return Object.fromEntries(entries) as Record<string, ServiceVariable[]>;
}, },
enabled: !isDemoMode && services.length > 0, enabled: !isDemoMode && signedIn && services.length > 0,
retry: false,
}); });
const variablesByService = isDemoMode ? getDemoVariablesByProject(projectId) : variablesQuery.data ?? {}; const variablesByService = isDemoMode ? getDemoVariablesByProject(projectId) : variablesQuery.data ?? {};
@@ -599,7 +611,8 @@ export function ProjectWorkspacePage() {
}) })
.slice(0, 200); .slice(0, 200);
}, },
enabled: !isDemoMode && activeView === 'logs' && services.length > 0, enabled: !isDemoMode && signedIn && activeView === 'logs' && services.length > 0,
retry: false,
}); });
const runningServices = useMemo(() => services.filter((service) => service.status === 'running').length, [services]); const runningServices = useMemo(() => services.filter((service) => service.status === 'running').length, [services]);
@@ -695,7 +708,7 @@ export function ProjectWorkspacePage() {
{/* Add Service Button */} {/* Add Service Button */}
{!isDemoMode && ( {!isDemoMode && (
<button <button
onClick={() => setCreateOpen(true)} 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" 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)' }} style={{ background: 'var(--accent-primary)' }}
> >
@@ -726,7 +739,7 @@ export function ProjectWorkspacePage() {
{/* Sidebar Navigation */} {/* Sidebar Navigation */}
<aside className="lg:sticky lg:top-6 lg:h-fit"> <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"> <nav className="flex lg:flex-col gap-1 overflow-x-auto lg:overflow-visible pb-2 lg:pb-0">
{viewItems.map((item) => { {viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
const active = activeView === item.key; const active = activeView === item.key;
const Icon = item.icon; const Icon = item.icon;
return ( return (
@@ -761,7 +774,7 @@ export function ProjectWorkspacePage() {
projectId={project.id} projectId={project.id}
services={services} services={services}
variablesByService={variablesByService} variablesByService={variablesByService}
onAddService={() => setCreateOpen(true)} onAddService={openAddService}
onOpenService={(serviceId) => navigate(serviceHref(serviceId))} onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
/> />
)} )}
@@ -973,6 +986,10 @@ export function ProjectWorkspacePage() {
open={commandPaletteOpen} open={commandPaletteOpen}
onClose={() => setCommandPaletteOpen(false)} onClose={() => setCommandPaletteOpen(false)}
onAddService={(type) => { onAddService={(type) => {
if (!signedIn) {
navigate('/auth/sign-in');
return;
}
setCreateOpen(true); setCreateOpen(true);
toast.showToast(`Creating ${type} service...`, 'info'); toast.showToast(`Creating ${type} service...`, 'info');
}} }}
+15 -3
View File
@@ -151,12 +151,24 @@ export function canvasStorageKey(projectId: string): string {
return `containr.canvas.v1.${projectId}`; return `containr.canvas.v1.${projectId}`;
} }
function canvasStorage(): Storage | null {
try {
return typeof window === 'undefined' ? null : window.localStorage;
} catch {
return null;
}
}
export function hasCanvasMetadata(projectId: string): boolean {
return canvasStorage()?.getItem(canvasStorageKey(projectId)) != null;
}
export function clearCanvasMetadata(projectId: string): void { export function clearCanvasMetadata(projectId: string): void {
localStorage.removeItem(canvasStorageKey(projectId)); canvasStorage()?.removeItem(canvasStorageKey(projectId));
} }
export function saveCanvasMetadata(projectId: string, metadata: ProjectCanvasMetadata): void { export function saveCanvasMetadata(projectId: string, metadata: ProjectCanvasMetadata): void {
localStorage.setItem(canvasStorageKey(projectId), JSON.stringify(metadata)); canvasStorage()?.setItem(canvasStorageKey(projectId), JSON.stringify(metadata));
} }
export function loadCanvasMetadata(projectId: string, services: ServiceEntity[]): ProjectCanvasMetadata { export function loadCanvasMetadata(projectId: string, services: ServiceEntity[]): ProjectCanvasMetadata {
@@ -164,7 +176,7 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[])
const fallback = createDefaultCanvasMetadata(services); const fallback = createDefaultCanvasMetadata(services);
const serviceIds = new Set(services.map((service) => service.id)); const serviceIds = new Set(services.map((service) => service.id));
const raw = localStorage.getItem(key); const raw = canvasStorage()?.getItem(key);
if (!raw) { if (!raw) {
saveCanvasMetadata(projectId, fallback); saveCanvasMetadata(projectId, fallback);
return fallback; return fallback;
+302 -2
View File
@@ -1441,6 +1441,79 @@ export interface paths {
}; };
}; };
put?: never; put?: never;
/**
* Create a user template
* @description Create a user-owned template (JSON definition). Requires authentication; the caller becomes the template owner.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TemplateWriteRequest"];
};
};
responses: {
/** @description Template created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
template?: components["schemas"]["ServiceTemplate"];
};
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/overview": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Platform overview
* @description Admin-only aggregate statistics plus the pending project approval queue.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Platform overview */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
stats?: {
[key: string]: number;
};
pending_projects?: components["schemas"]["Project"][];
};
};
};
};
};
put?: never;
post?: never; post?: never;
delete?: never; delete?: never;
options?: never; options?: never;
@@ -1448,6 +1521,146 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/admin/users": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List users
* @description Admin-only listing of all platform users.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Users retrieved */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
users?: components["schemas"]["AdminUser"][];
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/users/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Set user admin flag
* @description Grant or revoke platform admin. Admins cannot revoke their own flag.
*/
patch: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
is_admin: boolean;
};
};
};
responses: {
/** @description User updated */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
message?: string;
};
};
};
};
};
trace?: never;
};
"/admin/projects/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Set project approval
* @description Approve or unapprove a project for public listing.
*/
patch: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
is_approved: boolean;
};
};
};
responses: {
/** @description Project updated */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
project?: components["schemas"]["Project"];
message?: string;
};
};
};
};
};
trace?: never;
};
"/templates/{id}": { "/templates/{id}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1482,9 +1695,69 @@ export interface paths {
}; };
}; };
}; };
put?: never; /**
* Update a user template
* @description Update a user-created template. Official templates cannot be modified; only the owner or an admin may update.
*/
put: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Template ID */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TemplateWriteRequest"];
};
};
responses: {
/** @description Template updated */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
template?: components["schemas"]["ServiceTemplate"];
};
};
};
};
};
post?: never; post?: never;
delete?: never; /**
* Delete a user template
* @description Delete a user-created template. Official templates cannot be deleted; only the owner or an admin may delete.
*/
delete: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Template ID */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Template deleted */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
message?: string;
};
};
};
};
};
options?: never; options?: never;
head?: never; head?: never;
patch?: never; patch?: never;
@@ -7095,6 +7368,15 @@ export interface components {
/** @description Optional avatar image URL */ /** @description Optional avatar image URL */
avatar_url?: string; avatar_url?: string;
}; };
AdminUser: {
id?: string;
/** Format: email */
email?: string;
name?: string;
avatar_url?: string;
is_admin?: boolean;
created_at?: string;
};
Project: { Project: {
/** @description Project ID */ /** @description Project ID */
id?: string; id?: string;
@@ -7104,6 +7386,8 @@ export interface components {
description?: string; description?: string;
/** @description Project owner ID */ /** @description Project owner ID */
owner_id?: string; owner_id?: string;
/** @description Whether the project is approved for public listing. New projects default to false and appear publicly only after admin approval. */
is_approved?: boolean;
/** /**
* Format: date-time * Format: date-time
* @description Project creation timestamp * @description Project creation timestamp
@@ -7520,6 +7804,8 @@ export interface components {
variables?: string; variables?: string;
/** @description Whether template is an official preset */ /** @description Whether template is an official preset */
is_official?: boolean; is_official?: boolean;
/** @description Owning user ID for user-created templates; null for official presets */
owner_id?: string | null;
/** /**
* Format: date-time * Format: date-time
* @description Creation timestamp * @description Creation timestamp
@@ -7531,6 +7817,20 @@ export interface components {
*/ */
updated_at?: string; updated_at?: string;
}; };
TemplateWriteRequest: {
/** @description Template display name */
name: string;
/** @description Template description */
description?: string;
/** @description Template category (web, database, cache, queue, monitoring, storage, app, other) */
category?: string;
/** @description Template logo URL */
logo?: string;
/** @description Template config object (type, runtime, commands, port, environment) */
config: Record<string, never>;
/** @description Configurable variables exposed at deploy time */
variables?: components["schemas"]["TemplateVariable"][];
};
TemplateConfig: { TemplateConfig: {
/** @description Service type for created service */ /** @description Service type for created service */
type?: string; type?: string;
@@ -0,0 +1,81 @@
/**
* Auto-generated by scripts/generate-docs-snapshot.mjs.
* Bundled copy of the repository docs tree — the offline fallback for the
* docs browser. Regenerate with `npm run generate:docs`.
*/
export type BundledDoc = {
path: string;
section: string;
title: string;
markdown: string;
};
export const bundledDocs: BundledDoc[] = [
{
"path": "developers/onboarding.md",
"section": "developers",
"title": "Developer Onboarding Guide",
"markdown": "# Developer Onboarding Guide\n\nWelcome to the Containr development team! This guide will help you get set up and contribute effectively to the project.\n\n## Table of Contents\n\n- [Prerequisites](#prerequisites)\n- [Development Setup](#development-setup)\n- [Project Structure](#project-structure)\n- [Development Workflow](#development-workflow)\n- [Testing](#testing)\n- [Code Style](#code-style)\n- [Debugging](#debugging)\n- [Contributing](#contributing)\n- [Resources](#resources)\n\n## Prerequisites\n\nBefore you begin, ensure you have the following installed:\n\n### Required Tools\n- **Go 1.24+** - Backend development\n- **Node.js 18+** - Frontend development\n- **Docker & Docker Compose** - Container development\n- **Git** - Version control\n- **Make** - Build automation\n\n### Recommended Tools\n- **VS Code** - Code editor with extensions\n- **Postman** - API testing\n- **Docker Desktop** - Container management\n- **PostgreSQL Client** - Database management\n\n### VS Code Extensions\n```json\n{\n \"recommendations\": [\n \"golang.go\",\n \"bradlc.vscode-tailwindcss\",\n \"esbenp.prettier-vscode\",\n \"ms-vscode.vscode-typescript-next\",\n \"ms-vscode-remote.remote-containers\",\n \"ms-kubernetes-tools.vscode-kubernetes-tools\"\n ]\n}\n```\n\n## Development Setup\n\n### 1. Clone the Repository\n\n```bash\ngit clone https://github.com/your-org/containr.git\ncd containr\n```\n\n### 2. Environment Setup\n\n```bash\n# Copy environment template\ncp .env.example .env\n\n# Edit environment variables\nnano .env\n```\n\nKey environment variables:\n```bash\n# Development settings\nDOMAIN=localhost\nENVIRONMENT=development\n\n# Database\nPOSTGRES_USER=containr\nPOSTGRES_PASSWORD=containr\nPOSTGRES_DB=containr\n\n# Redis\nREDIS_PASSWORD=containr\n\n# JWT\nJWT_SECRET=your-super-secret-jwt-key-here\n\n# Proxmox (optional)\nPROXMOX_BASE_URL=https://your-proxmox:8006\nPROXMOX_USERNAME=root@pam\nPROXMOX_PASSWORD=your-proxmox-password\n```\n\n### 3. Initialize Development Environment\n\n```bash\n# Initialize all services\nmake init\n\n# Start development mode\nmake dev\n```\n\nThis will start:\n- **Frontend**: http://localhost (React/Vite)\n- **API**: http://api.localhost (Go backend)\n- **Traefik Dashboard**: http://localhost:8080\n- **Database**: PostgreSQL on port 5432\n- **Redis**: Cache on port 6379\n\n### 4. Verify Setup\n\n```bash\n# Check service status\nmake status\n\n# View logs\nmake logs\n\n# Test API\ncurl http://api.localhost/health\n```\n\n## Project Structure\n\n```\ncontainr/\n├── cmd/ # Application entry points\n│ ├── server/ # Main server application\n│ └── cli/ # CLI tool\n├── internal/ # Private application code\n│ ├── api/ # HTTP handlers and routes\n│ ├── build/ # Build system (Nixpacks, Docker)\n│ ├── cli/ # CLI commands and utilities\n│ ├── config/ # Configuration management\n│ ├── database/ # Database operations\n│ ├── deployment/ # Deployment engine\n│ ├── docker/ # Docker client wrapper\n│ ├── metrics/ # Metrics collection\n│ ├── middleware/ # HTTP middleware\n│ ├── proxmox/ # Proxmox integration\n│ ├── scaling/ # Auto-scaling engine\n│ └── security/ # Security scanning\n├── migrations_goose/ # Goose-managed database migrations\n├── pkg/ # Public library code\n├── scripts/ # Build and utility scripts\n├── src/ # Frontend source code\n│ ├── components/ # React components\n│ ├── hooks/ # Custom React hooks\n│ ├── contexts/ # React contexts\n│ └── utils/ # Frontend utilities\n├── docs/ # Documentation\n├── tests/ # Test files\n└── docker-compose.yml # Development environment\n```\n\n## Development Workflow\n\n### 1. Create a Feature Branch\n\n```bash\ngit checkout -b feature/your-feature-name\n```\n\n### 2. Make Your Changes\n\n#### Backend Changes\n- Add new API endpoints in `internal/api/`\n- Update database schema in `migrations_goose/` (goose format: `-- +goose Up/Down`)\n- Add tests in `tests/`\n- Update documentation\n\n#### Frontend Changes\n- Create components in `src/components/`\n- Add hooks in `src/hooks/`\n- Update routing in `src/App.tsx`\n- Follow existing patterns\n\n### 3. Test Your Changes\n\n```bash\n# Run backend tests\ngo test ./...\n\n# Run frontend tests\ncd src && npm test\n\n# Test full integration\nmake up\n```\n\n### 4. Commit Your Changes\n\n```bash\n# Stage changes\ngit add .\n\n# Commit with conventional message\ngit commit -m \"feat: add user authentication\"\n\n# Push to your fork\ngit push origin feature/your-feature-name\Line truncated
},
{
"path": "guides/APP_DEPLOY_GUIDE.md",
"section": "guides",
"title": "🎮 Docker Template Manager - In-App Deployment System",
"markdown": "# 🎮 Docker Template Manager - In-App Deployment System\n\n## 🚀 **Fully Integrated App-Based Deployment**\n\nI've created a **complete in-app deployment system** that runs entirely within your web application - no terminal needed! \n\n### 📱 **What You Get**\n\n#### **🎮 Visual Template Manager**\n- **Grid view** of all 20 Docker templates\n- **One-click deployment** with visual feedback\n- **Real-time progress** tracking\n- **Live deployment logs**\n- **Status indicators** (idle, deploying, running, stopped)\n- **Access URLs** with clickable links\n\n#### **📊 Dashboard Features**\n- **Template cards** with icons and descriptions\n- **Difficulty badges** (Easy, Medium, Hard)\n- **Category filtering** (Analytics, Media, Storage, etc.)\n- **Deployment statistics** (total, deployed, running)\n- **Success rate** tracking\n\n#### **🎯 Interactive Controls**\n- **Deploy button** - One-click deployment\n- **Stop button** - Stop running services\n- **Remove button** - Clean up deployments\n- **Deploy All** - Deploy all easy templates\n- **Filter tabs** - View by category or status\n\n### 🖥️ **How It Works in Your App**\n\n#### **1. Visual Template Selection**\n```typescript\n// Users see this in the app:\n📊 Glance Dashboard [Deploy]\n📈 Umami Analytics [Deploy] \n📝 Memos [Deploy]\n🔍 MeiliSearch [Deploy]\n🎬 Plex [Deploy]\n🎥 Jellyfin [Deploy]\n☁️ Nextcloud [Deploy]\n```\n\n#### **2. One-Click Deployment**\n- **Click \"Deploy\"** → Automatic deployment starts\n- **Progress bar** shows deployment progress\n- **Live logs** show what's happening\n- **Status changes** from idle → deploying → running\n- **Access URLs** appear when complete\n\n#### **3. Management Interface**\n- **View details** of any template\n- **Monitor deployment** in real-time\n- **Stop/start** services as needed\n- **Access URLs** with clickable links\n- **View logs** for troubleshooting\n\n### 🎨 **User Experience**\n\n#### **📱 Mobile-Friendly**\n- **Responsive design** works on all devices\n- **Touch-friendly** buttons and controls\n- **Scrollable** template grid\n- **Collapsible** details panel\n\n#### **🌐 Browser Integration**\n- **Click URLs** to open services in new tabs\n- **Real-time updates** without page refresh\n- **Smooth animations** and transitions\n- **Status indicators** with icons\n\n#### **📊 Visual Feedback**\n```typescript\n// Status indicators users see:\n🟢 Running (green checkmark)\n🔵 Deploying (blue pulsing circle)\n🔴 Error (red alert)\n⚪ Stopped (gray square)\n⭕ Idle (gray circle)\n```\n\n### 🔧 **Technical Implementation**\n\n#### **React Component Structure**\n```typescript\nsrc/components/\n├── DockerTemplateManagerApp.tsx # Main component\n├── DockerTemplateManager.tsx # Full component (20 templates)\n└── AppDocker.tsx # App wrapper\n```\n\n#### **State Management**\n```typescript\n// Real-time state:\n- templates: Template[] // All template data\n- selectedTemplate: Template // Currently selected\n- deploymentLogs: Logs[] // Deployment logs\n- activeTab: string // Filter tab\n```\n\n#### **Deployment Simulation**\n```typescript\n// Automatic deployment steps:\n1. Check dependencies\n2. Create deployment directory\n3. Extract docker-compose.yml\n4. Create environment variables\n5. Generate secrets\n6. Pull Docker images\n7. Start services\n8. Show access URLs\n```\n\n### 🎯 **How to Use It**\n\n#### **1. Add to Your App**\n```typescript\n// In your main App.tsx\nimport DockerTemplateManager from './components/DockerTemplateManagerApp';\n\nfunction App() {\n return (\n <div className=\"min-h-screen bg-gray-50\">\n <DockerTemplateManager />\n </div>\n );\n}\n```\n\n#### **2. Start the Development Server**\n```bash\nnpm start\n# or\nyarn start\n```\n\n#### **3. Open in Browser**\n```\nhttp://localhost:3000\n```\n\n#### **4. Deploy Templates**\n- **Browse** the template grid\n- **Click** any template to see details\n- **Click \"Deploy\"** to start deployment\n- **Watch** the progress in real-time\n- **Access** your service via the provided URLs\n\n### 📊 **Template Categories**\n\n#### **🟢 Easy Templates** (1-2 min)\n- 📊 Glance Dashboard\n- 📈 Umami Analytics \n- 📝 Memos\n- 🔍 MeiliSearch\n- 📊 Uptime Kuma\n\n#### **🟡 Medium Templates** (3-5 min)\n- 🎬 Plex\n- 🎥 Jellyfin\n- 🛡️ Vaultwarden\n- 🌐 Traefik\n- 🚪 Pi-hole\n- 📁 Cloudreve\n- 🐙 Gitea\n- 🔄 n8n\n- 📊 Grafana\n\n#### **🔴 Hard Templates** (5-10 min)\n- ☁️ Nextcloud\n- 🏠 Home Assistant\n- 🦣 Mastodon\n- 📸 Immich\n- 🗄️ Supabase\n- 🔧 Appwrite\n\n### 🚀 **Features**\n\n#### **🎮 Interactive Elements**\n- **Template cards** with hover effects\n- **Progress bars** for deployment status\n- **Status badges** with colors\n- **Clickable URLs** that opeLine truncated
},
{
"path": "guides/AUTOSCALING.md",
"section": "guides",
"title": "Autoscaling",
"markdown": "# Autoscaling\n\nContainr has a built-in autoscaler that adjusts service replica counts against\nCPU/memory targets. Policies and scale events are held in backend memory —\nthey do not survive a backend restart (tracked on the roadmap).\n\n## Per-service policy\n\nOpen a service → **Scaling** section. Set:\n\n| Field | Meaning |\n|-------|---------|\n| Min replicas | Lower bound, ≥1 |\n| Max replicas | Upper bound |\n| Target CPU % | Scale up when average CPU exceeds this |\n| Target memory % | Scale up when average memory exceeds this |\n| Enabled | Whether the autoscaler evaluates this service |\n\nCreating a policy registers the service with the autoscaler and initializes\nits scaling state at `min_replicas`.\n\n## Manual scale\n\nThe Scaling section also has a replica input + **Scale** button for an\nimmediate change, clamped to the policy bounds when a policy exists\n(1–20 otherwise). Scaling dispatches to the node scheduler — without\nregistered node agents it fails with `no ready nodes available`.\n\n## API\n\n```\nGET /api/v1/scaling/policies/:serviceId\nPOST /api/v1/scaling/policies {service_id, min_replicas, max_replicas,\n target_cpu, target_memory, enabled}\nPUT /api/v1/scaling/policies/:serviceId\nDELETE /api/v1/scaling/policies/:serviceId (disables, does not remove)\nGET /api/v1/scaling/services/:serviceId (current/desired replicas)\nPOST /api/v1/scaling/services/:serviceId/scale {replicas, reason}\nGET /api/v1/scaling/services/:serviceId/history\nGET /api/v1/scaling/status | /metrics | /events\n```\n\n## Traffic distribution\n\nReplicas are scheduled across registered node agents. In front of them you\nstill want a load balancer — Traefik (bundled) discovers containers via\nlabels, or Cloudflare Tunnel can front the platform for external traffic.\nNeither provides autoscaling itself; they distribute traffic to whatever\nreplicas the autoscaler has placed.\n"
},
{
"path": "guides/AUTO_DEPLOY_GUIDE.md",
"section": "guides",
"title": "🚀 Zero-Interaction Docker Deployment System",
"markdown": "# 🚀 Zero-Interaction Docker Deployment System\n\n## 🎯 **Fully Automatic Deployment**\n\nI've created a **completely automated deployment system** that requires **ZERO user interaction**. Just run one command and it does everything!\n\n## ⚡ **One-Click Deployment**\n\n### **Option 1: Quick Start (Recommended)**\n```bash\n# Run the quick deploy script\n./scripts/quick-deploy.sh\n```\n\n**What happens:**\n1. 🎯 **Shows you 6 deployment options**\n2. 🚀 **Deploys your choice automatically**\n3. 🔧 **Handles everything** (secrets, directories, ports)\n4. 🌐 **Shows access URLs** when done\n\n### **Option 2: Direct Command**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh umami\n./scripts/auto-deploy.sh plex\n./scripts/auto-deploy.sh nextcloud\n```\n\n### **Option 3: Deploy Everything**\n```bash\n# Deploy all 20 templates at once\n./scripts/auto-deploy.sh --all\n```\n\n## 🔄 **What Happens Automatically**\n\n### **🔍 Dependency Check**\n```bash\n✅ Checks if Docker is installed\n✅ Installs Docker if missing\n✅ Checks if Docker Compose is installed \n✅ Installs Docker Compose if missing\n```\n\n### **📁 Directory Creation**\n```bash\n✅ Creates deployment directory\n✅ Creates required subdirectories\n✅ Sets proper permissions\n✅ Handles existing deployments\n```\n\n### **🔧 Configuration Setup**\n```bash\n✅ Extracts docker-compose.yml from template\n✅ Creates .env file with all variables\n✅ Auto-generates secrets and passwords\n✅ Fixes port conflicts automatically\n✅ Optimizes volume paths\n```\n\n### **🐳 Docker Deployment**\n```bash\n✅ Pulls all required images\n✅ Starts all services\n✅ Waits for services to be ready\n✅ Checks service health\n✅ Shows deployment status\n```\n\n### **🌐 Access Information**\n```bash\n✅ Shows all access URLs\n✅ Provides management commands\n✅ Shows deployment directory\n✅ Opens browser automatically (optional)\n```\n\n## 🎮 **Try It Now**\n\n### **Easiest Way:**\n```bash\n./scripts/quick-deploy.sh\n```\n\nYou'll see:\n```\n🚀 One-Click Docker Deployment\n============================\n\n🎯 Choose your deployment:\n\n1. 🌐 Deploy Glance Dashboard (Recommended - Simple & Fast)\n2. 📊 Deploy Umami Analytics (Web Analytics)\n3. 📝 Deploy Memos (Note-taking)\n4. 🔍 Deploy MeiliSearch (Search Engine)\n5. 📈 Deploy Uptime Kuma (Monitoring)\n6. 🚀 Deploy ALL Templates (Advanced)\n\nEnter your choice (1-6): 1\n\n🌐 Deploying Glance Dashboard...\n```\n\n### **Direct Way:**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh glance\n```\n\n## 📊 **Example Output**\n\nHere's what you'll see during deployment:\n\n```\n🚀 Auto-Deploying glance\n========================================\n🔄 Checking dependencies...\n✅ Docker is installed\n✅ Docker Compose is installed\n🔄 Auto-deploying glance...\n✅ Created deployment directory: /path/to/deployments/glance\n🔄 Extracting docker-compose.yml...\n✅ docker-compose.yml extracted and optimized\n🔄 Creating .env file...\n✅ .env file created (no variables required)\n🔄 Auto-deploying glance...\n🔄 Pulling Docker images...\n✅ Images pulled\n🔄 Starting services...\n✅ Services started successfully\n\n🎉 Deployment Complete!\n========================================\n✅ Access URLs:\n 🌐 http://localhost:8080 (port: 8080)\n\n✅ Management Commands:\n 📋 View logs: docker-compose logs -f\n 🛑 Stop services: docker-compose down\n 🔄 Restart: docker-compose restart\n 📊 Status: docker-compose ps\n 🗑️ Cleanup: docker-compose down -v\n\n✅ Deployment directory: /path/to/deployments/glance\n🎉 glance deployed successfully!\n```\n\n## 🔧 **Advanced Options**\n\n### **Custom Configuration**\n```bash\n# Deploy without auto-generating secrets\n./scripts/auto-deploy.sh --no-secrets umami\n\n# Deploy without creating directories\n./scripts/auto-deploy.sh --no-dirs plex\n\n# Deploy without auto-starting services\n./scripts/auto-deploy.sh --no-start nextcloud\n\n# Deploy and auto-open browser\n./scripts/auto-deploy.sh --open-browser glance\n```\n\n### **Batch Deployment**\n```bash\n# Deploy multiple templates\nfor template in umami glance memos; do\n ./scripts/auto-deploy.sh \"$template\"\ndone\n\n# Deploy all templates\n./scripts/auto-deploy.sh --all\n```\n\n## 🛡️ **Safety Features**\n\n### **Automatic Conflict Resolution**\n```bash\n✅ Detects port conflicts\n✅ Auto-assigns available ports\n✅ Handles existing deployments\n✅ Cleans up previous installations\n```\n\n### **Security**\n```bash\n✅ Generates strong random secrets\n✅ Uses secure default passwords\n✅ Isolates deployments in separate directories\n✅ Logs all actions for audit trail\n```\n\n### **Error Handling**\n```bash\n✅ Validates template existence\n✅ Checks service health\n✅ Provides detailed error messages\n✅ Offers rollback commands\n```\n\n## 📁 **Di Line truncated
},
{
"path": "guides/CLOUDFLARE_SETUP.md",
"section": "guides",
"title": "Cloudflare Tunnel Setup",
"markdown": "# Cloudflare Tunnel Setup\n\nThis guide explains how to set up Cloudflare tunnel for Containr, allowing you to expose your services without configuring a domain.\n\n## Prerequisites\n\n1. A Cloudflare account\n2. A domain (any domain, even a free one)\n\n## Setup Steps\n\n### 1. Create a Cloudflare Tunnel\n\n1. Log in to your [Cloudflare Dashboard](https://dash.cloudflare.com/)\n2. Go to **Zero Trust** → **Networks** → **Tunnels**\n3. Click **Create tunnel**\n4. Choose **Cloudflared** and click **Next**\n5. Give your tunnel a name (e.g., \"containr-tunnel\")\n6. Click **Save tunnel**\n\n### 2. Get Your Tunnel Token\n\nAfter creating the tunnel, Cloudflare will show you a command like:\n```bash\ncloudflared tunnel run <tunnel-id>\n```\n\nCopy the token from the `.cloudflared/config.yml` file or use the token provided by Cloudflare.\n\n### 3. Configure Containr\n\n1. Copy `.env.example` to `.env` if you haven't already:\n ```bash\n cp .env.example .env\n ```\n\n2. Edit `.env` and add your Cloudflare tunnel token:\n ```env\n CLOUDFLARED_TOKEN=your_copied_tunnel_token_here\n ```\n\n### 4. Start Services with Cloudflare Tunnel\n\n```bash\n# Start all services including cloudflared\nmake cloudflared-up\n\n# Or start manually\ndocker-compose --profile cloudflared up -d\n```\n\n### 5. Configure Tunnel Routes\n\nIn your Cloudflare dashboard:\n\n1. Go to your tunnel settings\n2. Add the following public hostnames:\n - `your-domain.com` → `http://traefik:80` (for frontend)\n - `api.your-domain.com` → `http://traefik:80` (for backend)\n - `traefik.your-domain.com` → `http://traefik:80` (for dashboard)\n\n### 6. Access Your Services\n\nOnce configured, you can access:\n- Frontend: `https://your-domain.com`\n- API: `https://api.your-domain.com`\n- Traefik Dashboard: `https://traefik.your-domain.com`\n\n## Management Commands\n\n```bash\n# Start with Cloudflare tunnel\nmake cloudflared-up\n\n# Stop Cloudflare tunnel\nmake cloudflared-down\n\n# View logs\ndocker-compose logs -f cloudflared\n\n# Check status\ndocker-compose ps\n```\n\n## Benefits\n\n- **No domain configuration required** in `.env`\n- **Automatic SSL** through Cloudflare\n- **DDoS protection** and security features\n- **Easy setup** - just need a tunnel token\n- **Works anywhere** - no port forwarding needed\n\n## Troubleshooting\n\n### Tunnel Not Connecting\n- Verify your `CLOUDFLARED_TOKEN` is correct\n- Check cloudflared logs: `docker-compose logs cloudflared`\n- Ensure your tunnel is active in Cloudflare dashboard\n\n### Services Not Accessible\n- Verify you've configured the public hostnames in Cloudflare\n- Check that all services are running: `docker-compose ps`\n- Ensure the tunnel routes point to `http://traefik:80`\n\n### Token Issues\n- Regenerate your tunnel token in Cloudflare dashboard\n- Make sure there are no extra spaces or newlines in the token\n\n## Alternative: Domain Mode\n\nIf you prefer traditional domain setup instead of Cloudflare tunnel:\n\n1. Configure your domain in `.env`:\n ```env\n DOMAIN=yourdomain.com\n [email protected]\n ```\n\n2. Use regular commands:\n ```bash\n make prod\n ```\n\nThis will use Let's Encrypt certificates instead of Cloudflare tunnel.\n"
},
{
"path": "guides/DOCKER_SETUP.md",
"section": "guides",
"title": "Docker Setup with Traefik",
"markdown": "# Docker Setup with Traefik\n\nThis guide will help you set up Containr with Docker, Traefik reverse proxy, and automatic SSL certificates.\n\n## Prerequisites\n\n- Docker and Docker Compose installed\n- A domain name pointing to your server's IP address\n- Port 80 and 443 open on your firewall\n\n## Quick Start\n\n1. **Clone and prepare the environment:**\n ```bash\n git clone <your-repo>\n cd containr\n cp .env.example .env\n ```\n\n2. **Configure your environment:**\n Edit `.env` file with your settings:\n ```bash\n nano .env\n ```\n \n Required changes:\n - `DOMAIN=yourdomain.com` - Your actual domain\n - `[email protected]` - Email for SSL certificates\n - `POSTGRES_PASSWORD` - Set a secure password\n - `REDIS_PASSWORD` - Set a secure password\n - `JWT_SECRET` - Generate a secure random string\n - `TRAEFIK_AUTH` - Generate basic auth for dashboard\n\n3. **Generate Traefik authentication:**\n ```bash\n # Install apache2-utils if needed\n sudo apt-get install apache2-utils\n \n # Generate username:password hash\n htpasswd -nb admin yourpassword\n \n # Update TRAEFIK_AUTH in .env with the output\n ```\n\n4. **Create necessary directories:**\n ```bash\n mkdir -p data/letsencrypt\n chmod 600 data/letsencrypt/acme.json\n ```\n\n5. **Start the services:**\n ```bash\n docker-compose up -d\n ```\n\n## Services and URLs\n\nAfter deployment, your services will be available at:\n\n- **Frontend**: `https://yourdomain.com`\n- **Backend API**: `https://api.yourdomain.com`\n- **Traefik Dashboard**: `https://traefik.yourdomain.com`\n\n## Architecture\n\n```\nInternet → Traefik (Port 80/443)\n ├── Frontend (React/Nginx)\n ├── Backend (Go API)\n ├── PostgreSQL (Database)\n └── Dragonfly (Cache)\n```\n\n## Configuration Files\n\n### Docker Compose\n- `docker-compose.yml` - Main orchestration file\n- Defines all services, networks, and volumes\n- Configures Traefik with automatic SSL\n\n### Traefik Configuration\n- `traefik.yml` - Static configuration\n- `traefik-dynamic.yml` - Dynamic routing rules\n- Automatic HTTP to HTTPS redirection\n- Security headers and rate limiting\n\n### Dockerfiles\n- `Dockerfile.backend` - Go backend with multi-stage build\n- `Dockerfile.frontend` - React frontend with Nginx\n- Both use non-root users for security\n\n## Security Features\n\n- **Automatic SSL** via Let's Encrypt\n- **HTTP to HTTPS** redirection\n- **Security headers** (HSTS, XSS protection, etc.)\n- **Rate limiting** on API endpoints\n- **Basic authentication** on Traefik dashboard\n- **Non-root containers** for all services\n- **Health checks** for all services\n\n## Monitoring and Logs\n\n### Traefik Dashboard\nAccess at `https://traefik.yourdomain.com` with your configured credentials.\n\n### Logs\n```bash\n# View all logs\ndocker-compose logs -f\n\n# View specific service logs\ndocker-compose logs -f traefik\ndocker-compose logs -f backend\ndocker-compose logs -f frontend\n```\n\n### Health Checks\nAll services include health checks:\n```bash\n# Check service status\ndocker-compose ps\n```\n\n## Maintenance\n\n### Updates\n```bash\n# Pull latest images\ndocker-compose pull\n\n# Recreate services with new images\ndocker-compose up -d --force-recreate\n```\n\n### Backups\n```bash\n# Backup PostgreSQL\ndocker-compose exec postgres pg_dump -U containr_user containr > backup.sql\n\n# Backup Dragonfly (snapshot)\ndocker-compose exec dragonfly redis-cli -a \"$REDIS_PASSWORD\" save\n```\n\n### SSL Certificates\nLet's Encrypt certificates are automatically renewed. Manual renewal:\n```bash\ndocker-compose exec traefik traefik api check-letsencrypt\n```\n\n## Development Mode\n\nFor local development without SSL:\n```bash\n# Create development override\ncat > docker-compose.override.yml << EOF\nversion: '3.8'\nservices:\n traefik:\n command:\n - \"--api.dashboard=true\"\n - \"--providers.docker=true\"\n - \"--entrypoints.web.address=:80\"\n - \"--log.level=DEBUG\"\n ports:\n - \"80:80\"\n - \"8080:8080\"\n labels:\n - \"traefik.http.routers.traefik.rule=Host(`localhost`)\"\n - \"traefik.http.routers.traefik.entrypoints=web\"\n - \"traefik.http.routers.traefik.service=api@internal\"\nEOF\n\n# Start with override\ndocker-compose up -d\n```\n\n## Troubleshooting\n\n### Common Issues\n\n1. **SSL Certificate Issues**\n ```bash\n # Check acme.json permissions\n ls -la data/letsencrypt/acme.json\n \n # Reset certificates\n rm data/letsencrypt/acme.json\n docker-compose restart traefik\n ```\n\n2. **Port Conflicts**\n ```bash\n # Check what's using ports\n sudo netstat -tlnp | grep :80\n sudo netstat -tlnp | grep :443\n ```\n\n3. **Database Connection**\n ```bash\n # Test database connection\n docker-compose exec backend ping postgres\n ```\n\n4. **Permission Issues**\n ```bash\n # Fix volume permissions\n sudo choLine truncated
},
{
"path": "guides/OPERATIONS.md",
"section": "guides",
"title": "Operations",
"markdown": "# Operations\n\nDay-2 features for running services: scheduled jobs, ad-hoc exec, managed\ndatabases, preview environments, security scans, and high availability.\n\n## Cron jobs\n\nService detail → **Cron**. Standard 5-field cron expressions (`robfig/cron`\nsyntax), per-job timezone, enable/disable, manual trigger, and execution\nhistory with captured stdout/stderr.\n\n- The scheduler ticks every minute and runs due jobs via `docker exec`\n (`sh -c <command>`) inside the service's container.\n- Executions are retained per-job (`retention` count) and visible in the UI.\n- Requires a running container — a stopped service's due job records a\n failure, it does not queue.\n\n```\nGET/POST /api/v1/cron-jobs PUT/DELETE /api/v1/cron-jobs/:id\nPOST /api/v1/cron-jobs/:id/trigger\nGET /api/v1/cron-jobs/:id/executions\n```\n\n## Exec console\n\nService detail → **Console**. One-off `sh -c` commands against the running\ncontainer: 30s ceiling, 64KB output cap, exit code + captured output shown\nper run. Debugging aid — not an interactive shell.\n\n```\nPOST /api/v1/services/:id/exec {command}\n```\n\n## Managed databases\n\n**/databases** page — provision PostgreSQL/Redis/MySQL/MariaDB/MongoDB/\nClickHouse/Dragonfly as managed containers on the `containr` network.\n\n- Start/stop/restart actions; connection URL with copy.\n- **Backups**: manual snapshot now, or set a cron expression\n (`backup_schedule`) — due backups run inside the same scheduler tick as\n cron jobs. Archives live in the `containr-db-backups` volume and are\n downloadable (`GET /databases/:id/backups/:bid/download`) and restorable.\n- **Bind to service**: injects `connection_url` into a service's variables\n as a secret (`DATABASE_URL` by default). Redeploy the service afterward —\n variables are read at deploy time.\n\n## Preview environments\n\nService detail → **Previews**. Create a preview per branch/PR with a TTL,\npromote it to production, or delete it.\n\nCurrent limitation: previews are bookkeeping records — they do not build,\ndeploy, or route a real container. The generated `*.preview.containr.local`\nURL is a placeholder. Real preview runtime is tracked on the roadmap.\n\n## Security scans\n\n**/security** page — pick a project, run a scan (dependency / configuration\n/ comprehensive, optionally scoped to one service).\n\nThe scanner is heuristic config analysis, not Trivy/Grype: it flags\nunpinned image tags, `http://` source/public URLs, `npm install` without\n`npm ci`, `pip install` without hashes, `curl|sh` build steps, missing\nhealth checks, and similar smells. Findings get severity badges and can be\nresolved or ignored; the page also shows a security score, open/resolved\ncounts, scan history, and compliance status.\n\n```\nPOST /api/v1/security/scans {project_id, service_id?, scan_type}\nGET /api/v1/projects/:id/vulnerabilities\nPUT /api/v1/vulnerabilities/:id {status: open|resolved|ignored}\nGET /api/v1/projects/:id/security/metrics\nGET /api/v1/projects/:id/security/history\n```\n\n## High availability\n\n**/ha** page — manager enable/disable, node/health-check/alert status\ncards, manual failover (with confirmation), per-service failover policies\n(project → service picker, strategy, min healthy nodes, max failures),\nactive alerts with resolve, and health check results.\n\nHA state is in-memory like autoscaling — policies and alerts reset on\nbackend restart.\n\n## Node agents\n\n**/usage** page → *Connect node*: issue a per-node token (`cagt_…`, shown\nonce), copy the prefilled install command, revoke tokens when done. Agents\nregister over `POST /api/agents/register` and heartbeat via\n`POST /api/agents/heartbeat`.\n"
},
{
"path": "guides/README_BACKEND.md",
"section": "guides",
"title": "Containr Backend Setup",
"markdown": "# Containr Backend Setup\n\n## Quick Start\n\n### Prerequisites\n- Go 1.21+\n- PostgreSQL 12+\n- Dragonfly or Redis-compatible cache (optional)\n\n### Environment Variables\n\nCreate a `.env` file or set these environment variables:\n\n```bash\n# Database\nDATABASE_URL=postgres://containr:password@localhost:5432/containr?sslmode=disable\nREDIS_URL=redis://localhost:6379\n\n# Server\nPORT=8080\nENVIRONMENT=development\n\n# Security\nJWT_SECRET=your-secret-key-change-in-production\n\n# Frontend (for CORS)\nFRONTEND_URL=http://localhost:3000\n```\n\n### Database Setup\n\n1. Create PostgreSQL database:\n```sql\nCREATE DATABASE containr;\nCREATE USER containr WITH PASSWORD 'password';\nGRANT ALL PRIVILEGES ON DATABASE containr TO containr;\n```\n\n2. Run migrations (handled automatically on server start)\n\n### Running the Server\n\n```bash\n# Build\ngo build -o bin/server cmd/server/main.go\n\n# Run\n./bin/server\n```\n\nThe server will start on `http://localhost:8080`\n\n### API Endpoints\n\n#### Health Check\n- `GET /health` - Server health status\n\n#### Authentication\n- `POST /api/v1/auth/login` - User login\n- `POST /api/v1/auth/register` - User registration\n\n#### User Profile\n- `GET /api/v1/user/profile` - Get user profile (requires auth)\n- `PUT /api/v1/user/profile` - Update user profile (requires auth)\n\n#### Projects\n- `GET /api/v1/projects` - List user projects (requires auth)\n- `POST /api/v1/projects` - Create project (requires auth)\n- `GET /api/v1/projects/:id` - Get project details (requires auth)\n- `PUT /api/v1/projects/:id` - Update project (requires auth)\n- `DELETE /api/v1/projects/:id` - Delete project (requires auth)\n\n### Authentication\n\nInclude JWT token in Authorization header:\n```\nAuthorization: Bearer <your-jwt-token>\n```\n\n### Example Usage\n\n1. Register a user:\n```bash\ncurl -X POST http://localhost:8080/api/v1/auth/register \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"email\": \"[email protected]\",\n \"password\": \"password123\",\n \"name\": \"Test User\"\n }'\n```\n\n2. Login:\n```bash\ncurl -X POST http://localhost:8080/api/v1/auth/login \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"email\": \"[email protected]\",\n \"password\": \"password123\"\n }'\n```\n\n3. Create a project (using token from login):\n```bash\ncurl -X POST http://localhost:8080/api/v1/projects \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer <token>\" \\\n -d '{\n \"name\": \"My Project\",\n \"description\": \"A test project\"\n }'\n```\n\n## Development\n\n### Project Structure\n```\ncmd/server/ - Main application entry point\ninternal/\n├── api/ - HTTP handlers and routes\n├── config/ - Configuration management\n├── database/ - Database connections and migrations\n└── middleware/ - HTTP middleware\nmigrations_goose/ - Goose-managed migrations (single migration path)\n```\n\n### Adding New Endpoints\n\n1. Create handler functions in `internal/api/`\n2. Add routes in `internal/api/routes.go`\n3. Update database schema if needed in `migrations_goose/` (goose format: `-- +goose Up/Down`)\n"
},
{
"path": "guides/SETUP_GUIDE.md",
"section": "guides",
"title": "Docker Template Auto-Setup Guide",
"markdown": "# Docker Template Auto-Setup Guide\n\n## 🚀 How It Works\n\nThe templates are **configuration files** that define how to run each service with Docker Compose. They don't auto-execute - you need to use the setup script to deploy them.\n\n## 📋 Quick Start\n\n### 1. Interactive Mode (Recommended)\n```bash\n# Run the setup script interactively\n./scripts/setup-template.sh\n```\n\n### 2. Direct Template Selection\n```bash\n# Setup a specific template\n./scripts/setup-template.sh umami\n\n# List all available templates\n./scripts/setup-template.sh --list\n\n# Setup only (don't deploy)\n./scripts/setup-template.sh --setup umami\n\n# Deploy existing setup\n./scripts/setup-template.sh --deploy umami\n```\n\n## 🔄 What the Script Does\n\n### Step 1: Dependency Check\n- ✅ Verifies Docker is installed\n- ✅ Verifies Docker Compose is installed\n- ✅ Checks template directory exists\n\n### Step 2: Template Selection\n- 📋 Shows available templates with descriptions\n- 🎯 Lets you choose interactively or via command line\n\n### Step 3: Project Setup\n- 📁 Creates deployment directory (`template-name-deployment/`)\n- 📄 Extracts `docker-compose.yml` from template\n- 🔧 Creates `.env` file template with required variables\n- ⚠️ Shows setup requirements and warnings\n\n### Step 4: Deployment (Optional)\n- 🐳 Pulls Docker images\n- 🚀 Starts services with `docker-compose up -d`\n- 📊 Shows service status and access URLs\n- 📝 Provides useful commands\n\n## 📁 Directory Structure After Setup\n\n```\nyour-project/\n├── scripts/\n│ └── setup-template.sh # Auto-setup script\n├── templates/\n│ ├── umami.md # Template documentation\n│ ├── plex.md\n│ ├── immich.md\n│ └── ...\n└── umami-deployment/ # Created by setup script\n ├── docker-compose.yml # Extracted from template\n ├── .env # Environment variables\n └── README.md # Setup instructions\n```\n\n## 🎯 Interactive Walkthrough Example\n\n### Running Umami Setup:\n```bash\n$ ./scripts/setup-template.sh\n\n========================================\nChecking Dependencies\n========================================\n✅ Docker is installed\nDocker version: 24.0.6\n✅ Docker Compose is installed\nDocker Compose version: 2.21.0\n✅ All dependencies are met\n\n========================================\nSelect Template\n========================================\n 1) umami - Umami Analytics - Privacy-focused web analytics\n 2) plex - Plex Media Server - Media streaming and organization\n 3) immich - Immich - Photo and video backup solution\n ...\nSelect a template (1-20): 1\n✅ Selected template: umami\n\n========================================\nSetting up umami\n========================================\n✅ Created project directory: /path/to/umami-deployment\n✅ docker-compose.yml extracted\n⚠️ This template requires environment variables:\n- `DATABASE_URL`: PostgreSQL connection string\n- `APP_SECRET`: Random string for application secrets\n✅ Created .env template file\n⚠️ Please edit umami-deployment/.env with your values before starting\n\nDo you want to deploy the template now? (y/N): y\n\n========================================\nDeploying umami\n========================================\n✅ Pulling Docker images...\n✅ Starting services...\n✅ Deployment completed!\n\n========================================\nAccess Information\n========================================\n✅ Service access URLs:\n • http://localhost:3000 (container port: 3000)\n\nUseful commands:\n • View logs: docker-compose logs -f\n • Stop services: docker-compose down\n • Restart services: docker-compose restart\n • Update services: docker-compose pull && docker-compose up -d\n```\n\n## 🔧 Manual Setup (Without Script)\n\nIf you prefer manual setup:\n\n1. **Choose a template** from `/templates/`\n2. **Copy the docker-compose.yml** section\n3. **Create a project directory**\n4. **Set up environment variables**\n5. **Run `docker-compose up -d`**\n\nExample for Umami:\n```bash\nmkdir umami-deployment\ncd umami-deployment\n\n# Copy docker-compose.yml from templates/umami.md\n# Create .env file with required variables\ndocker-compose up -d\n```\n\n## 🎛️ Available Templates\n\n| Template | Description | Difficulty |\n|----------|-------------|------------|\n| **umami** | Web Analytics | Easy |\n| **plex** | Media Server | Medium |\n| **immich** | Photo Backup | Medium |\n| **n8n** | Workflow Automation | Medium |\n| **supabase** | Backend Service | Hard |\n| **home-assistant** | Smart Home | Medium |\n| **uptime-kuma** | Monitoring | Easy |\n| **grafana** | Metrics Dashboard | Medium |\n| **traefik** | Reverse Proxy | Medium |\n| **memos** | Note-taking | Easy |\n| **meilisearch** | Search Engine | Easy |\n| **vaultwarden** | Password Manager | Line truncated
},
{
"path": "guides/advanced-configuration.md",
"section": "guides",
"title": "Advanced Configuration Guide",
"markdown": "# Advanced Configuration Guide\n\nThis guide covers advanced configuration options for optimizing your Containr deployments.\n\n## Table of Contents\n\n- [Build Configuration](#build-configuration)\n- [Environment Management](#environment-management)\n- [Resource Allocation](#resource-allocation)\n- [Networking](#networking)\n- [Security](#security)\n- [Scaling Policies](#scaling-policies)\n- [Custom Domains and SSL](#custom-domains-and-ssl)\n- [Health Checks](#health-checks)\n\n## Build Configuration\n\n### Nixpacks Configuration\n\nNixpacks is the default build system. You can customize it using a `nixpacks.toml` file:\n\n```toml\n# nixpacks.toml\n[phases.setup]\nnixPkgs = [\"...\", \"nodejs\", \"npm\"]\n\n[phases.build]\ncmds = [\"npm run build\"]\n\n[start]\ncmd = \"npm start\"\n\n[variables]\nNODE_ENV = \"production\"\nPORT = \"8080\"\n```\n\n### Custom Dockerfile\n\nFor complex applications, use a custom Dockerfile:\n\n```dockerfile\n# Dockerfile\nFROM node:18-alpine AS builder\n\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --only=production\n\nCOPY . .\nRUN npm run build\n\nFROM node:18-alpine AS runtime\n\nWORKDIR /app\nCOPY --from=builder /app/dist ./dist\nCOPY --from=builder /app/node_modules ./node_modules\nCOPY package*.json ./\n\nEXPOSE 8080\nUSER node\nCMD [\"npm\", \"start\"]\n```\n\n### Build Caching\n\nEnable build caching for faster deployments:\n\n1. Go to service settings\n2. Enable \"Build Caching\"\n3. Configure cache settings:\n - **Cache TTL**: Time to keep cached layers (default: 7 days)\n - **Max Cache Size**: Maximum cache size per service\n\n### Multi-Stage Builds\n\nOptimize image size with multi-stage builds:\n\n```dockerfile\n# Build stage\nFROM golang:1.21 AS builder\nWORKDIR /app\nCOPY go.mod go.sum ./\nRUN go mod download\nCOPY . .\nRUN CGO_ENABLED=0 go build -o app\n\n# Runtime stage\nFROM alpine:latest\nRUN apk --no-cache add ca-certificates\nWORKDIR /root/\nCOPY --from=builder /app/app .\nEXPOSE 8080\nCMD [\"./app\"]\n```\n\n## Environment Management\n\n### Environment Scopes\n\nVariables can be scoped at different levels:\n\n```bash\n# Project-level (shared across all services)\nDATABASE_URL=postgresql://...\n\n# Service-level (specific to one service)\nAPI_KEY=service-specific-key\n\n# Environment-specific (production only)\nNODE_ENV=production\nDEBUG=false\n```\n\n### Variable Types\n\n#### Plain Text Variables\n```bash\nAPI_KEY=your-api-key-here\n```\n\n#### Secret Variables\n```bash\n# Marked as secret in UI - masked in logs\nDATABASE_PASSWORD=super-secret-password\n```\n\n#### File Variables\nFor configuration files:\n```bash\n# Upload .env file as variable\nENV_FILE_CONTENT=KEY1=value1\\nKEY2=value2\n```\n\n### Dynamic Variables\n\nUse Containr's built-in variables:\n\n```bash\n# Auto-populated by Containr\nPORT=8080 # Assigned port\nCONTAINR_SERVICE_NAME=my-app # Service name\nCONTAINR_PROJECT_NAME=my-proj # Project name\nCONTAINR_DEPLOYMENT_ID=abc123 # Deployment ID\n```\n\n## Resource Allocation\n\n### CPU Allocation\n\nConfigure CPU limits and requests:\n\n```yaml\n# Service resource configuration\nresources:\n cpu:\n request: \"100m\" # Minimum CPU (0.1 core)\n limit: \"1000m\" # Maximum CPU (1 core)\n memory:\n request: \"128Mi\" # Minimum memory\n limit: \"512Mi\" # Maximum memory\n```\n\n### Memory Allocation\n\nMemory configuration examples:\n\n```yaml\n# Small service (API, worker)\nresources:\n memory: \"256Mi\"\n\n# Medium service (web app)\nresources:\n memory: \"512Mi\"\n\n# Large service (database, analytics)\nresources:\n memory: \"2Gi\"\n```\n\n### Storage Allocation\n\nPersistent storage for databases:\n\n```yaml\n# Database service\nresources:\n storage:\n size: \"10Gi\"\n type: \"ssd\"\n backup: true\n```\n\n## Networking\n\n### Service-to-Service Communication\n\nServices can communicate internally:\n\n```bash\n# Environment variables for internal URLs\nDATABASE_URL=postgresql://my-db:5432/myapp\nREDIS_URL=redis://my-redis:6379\nAPI_URL=http://my-api:8080\n```\n\n### Port Configuration\n\nConfigure service ports:\n\n```yaml\n# Service configuration\nnetworking:\n port: 8080 # Internal port\n public: true # Expose publicly\n health_check: \"/health\" # Health check endpoint\n```\n\n### Custom Domains\n\nSet up custom domains:\n\n1. Go to service settings → \"Domains\"\n2. Add your domain: `app.yourdomain.com`\n3. Configure DNS:\n ```\n CNAME app.yourdomain.com -> your-service.containr.app\n ```\n4. Wait for SSL certificate provisioning\n\n### Load Balancing\n\nConfigure load balancing strategies:\n\n```yaml\n# Load balancer configuration\nload_balancer:\n strategy: \"round_robin\" # round_robin, least_connections, ip_hash\n health_check:\n path: \"/health\"\n interval: \"30s\"\n timeout: \"5s\"\n retries: 3\n```\n\n## Security\n\n### Security Scans\n\nEnable automated security scanning:\n\n1. Go to project se Line truncated
},
{
"path": "guides/getting-started.md",
"section": "guides",
"title": "Getting Started with Containr",
"markdown": "# Getting Started with Containr\n\nWelcome to Containr! This guide will walk you through setting up your first project and deploying your first application.\n\n## Prerequisites\n\nBefore you begin, make sure you have:\n\n- A Containr instance running (either self-hosted or using our hosted version)\n- A Git repository with your application code\n- Basic knowledge of Docker and containerization concepts\n\n## Step 1: Create Your Account\n\n1. Navigate to your Containr instance URL\n2. Click \"Sign Up\" in the top-right corner\n3. Fill in your email, name, and password\n4. Verify your email address (if required)\n5. Log in to your new account\n\n## Step 2: Create Your First Project\n\n1. From the dashboard, click \"New Project\"\n2. Enter a project name (e.g., \"my-awesome-app\")\n3. Add an optional description\n4. Click \"Create Project\"\n\nYou'll be taken to your project's main canvas view.\n\n## Step 3: Connect Your Git Repository\n\n### Option A: Using GitHub (Recommended)\n\n1. Click \"Add Service\" → \"Connect Git Repository\"\n2. Select \"GitHub\" as the provider\n3. Click \"Connect to GitHub\" and authorize Containr\n4. Browse your repositories and select the one you want to deploy\n5. Choose the branch (usually `main` or `master`)\n6. Select the root directory if your app isn't in the root\n7. Click \"Connect Repository\"\n\n### Option B: Using Other Git Providers\n\nContainr also supports GitLab and Bitbucket:\n\n1. Click \"Add Service\" → \"Connect Git Repository\"\n2. Select your preferred provider\n3. Follow the authorization flow\n4. Select your repository and branch\n\n## Step 4: Configure Your Service\n\nAfter connecting your repository, Containr will:\n\n1. **Auto-detect your stack** (Node.js, Python, Go, etc.)\n2. **Generate a build plan** using Nixpacks\n3. **Show you the configuration**\n\n### Basic Configuration\n\n- **Service Name**: Give your service a descriptive name\n- **Build Command**: Auto-detected, but you can customize\n- **Start Command**: Auto-detected, but you can customize\n- **Environment Variables**: Add any needed variables\n\n### Example: Node.js Application\n\nFor a typical Node.js app:\n\n```bash\n# Build Command (auto-detected)\nnpm install && npm run build\n\n# Start Command (auto-detected)\nnpm start\n```\n\n### Example: Go Application\n\nFor a Go application:\n\n```bash\n# Build Command (auto-detected)\ngo build -o app .\n\n# Start Command (auto-detected)\n./app\n```\n\n## Step 5: Add Environment Variables\n\nMost applications need environment variables:\n\n1. Go to the \"Variables\" tab in your service\n2. Add your variables as key-value pairs:\n - `NODE_ENV=production`\n - `DATABASE_URL=postgresql://...`\n - `API_KEY=your-secret-key`\n3. Variables are automatically encrypted and injected at runtime\n\n## Step 6: Deploy Your Application\n\n1. Click \"Deploy\" in the top-right corner\n2. Choose your deployment method:\n - **Deploy Latest Commit**: Deploy the latest commit from your branch\n - **Manual Deploy**: Specify a commit SHA\n3. Click \"Start Deployment\"\n\nContainr will:\n1. Pull your code from Git\n2. Build a Docker image using Nixpacks\n3. Start the container\n4. Assign a public URL\n\n## Step 7: Monitor Your Deployment\n\nWhile deploying, you can:\n\n- **View Build Logs**: See the build process in real-time\n- **Check Status**: Monitor deployment progress\n- **Get Notifications**: Receive alerts on success or failure\n\nOnce deployed, you'll see:\n\n- **Public URL**: Your application is live at `https://your-service-name.containr.app`\n- **Status**: Green checkmark if running successfully\n- **Metrics**: CPU, memory, and network usage\n\n## Step 8: Add a Database (Optional)\n\nMost applications need a database:\n\n1. Click \"Add Service\" → \"Add Database\"\n2. Choose your database type:\n - **PostgreSQL**: For relational data\n - **Redis**: For caching and sessions\n - **MySQL**: For MySQL compatibility\n3. Configure:\n - Database name\n - Version\n - Size allocation\n4. Click \"Create Database\"\n\n### Connect Your Application\n\nOnce the database is created:\n\n1. Go to your service's \"Variables\" tab\n2. Add the database connection URL:\n ```\n DATABASE_URL=postgresql://username:password@hostname:port/database_name\n ```\n3. Redeploy your application\n\n## Step 9: Set Up Preview Environments\n\nEnable automatic preview environments for pull requests:\n\n1. Go to project settings\n2. Enable \"Preview Environments\"\n3. Configure:\n - Automatic cleanup (e.g., 7 days)\n - Branch patterns (e.g., `feature/*`, `fix/*`)\n4. Save settings\n\nNow when you create a pull request:\n- A preview environment is automatically created\n- You get a unique URL for testing\n- Environment is cleaned up after merging\n\n## Step 10: Configure Custom Domain (Optional)\n\nTo use your own domain:\n\n1. Go to your service's \"Settings\" tab\n2. Click \"Add Custom Domain\"\n3. Enter your domain (e.g., `app.yourdomain.co Line truncated
}
];
+103
View File
@@ -1582,3 +1582,106 @@ textarea {
.v-tick .sig.r { background: var(--error); } .v-tick .sig.r { background: var(--error); }
/* Rail active indicator handled via .nav-item.active::before */ /* Rail active indicator handled via .nav-item.active::before */
/* Rendered documentation */
.docs-article {
color: var(--text-secondary);
font-size: 13.5px;
line-height: 1.7;
max-width: 72ch;
}
.docs-article h1 {
color: var(--text-primary);
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 14px;
}
.docs-article h2 {
color: var(--text-primary);
font-size: 16.5px;
font-weight: 650;
margin: 26px 0 10px;
padding-top: 14px;
border-top: 1px solid var(--border-subtle);
}
.docs-article h3 {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
margin: 20px 0 8px;
}
.docs-article h4 {
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
margin: 16px 0 6px;
}
.docs-article p { margin: 10px 0; }
.docs-article a {
color: var(--accent-primary);
text-decoration: none;
border-bottom: 1px solid var(--accent-primary-glow);
}
.docs-article a:hover { border-bottom-color: var(--accent-primary); }
.docs-article ul, .docs-article ol {
margin: 10px 0;
padding-left: 22px;
}
.docs-article ul { list-style: disc; }
.docs-article ol { list-style: decimal; }
.docs-article li { margin: 4px 0; }
.docs-article li::marker { color: var(--text-tertiary); }
.docs-article code {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
background: var(--surface-muted);
border: 1px solid var(--border-subtle);
border-radius: 5px;
padding: 1.5px 5px;
color: var(--accent-secondary);
}
.docs-article pre {
background: var(--bg-void);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 14px 16px;
margin: 14px 0;
overflow-x: auto;
}
.docs-article pre code {
background: none;
border: none;
padding: 0;
font-size: 12px;
color: var(--text-secondary);
}
.docs-article blockquote {
border-left: 3px solid var(--accent-primary);
padding: 2px 0 2px 14px;
margin: 14px 0;
color: var(--text-tertiary);
}
.docs-article table {
width: 100%;
border-collapse: collapse;
margin: 14px 0;
font-size: 12.5px;
}
.docs-article th {
text-align: left;
font-weight: 600;
color: var(--text-primary);
padding: 8px 12px;
border-bottom: 1px solid var(--border-default);
}
.docs-article td {
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
}
.docs-article hr {
border: none;
border-top: 1px solid var(--border-subtle);
margin: 24px 0;
}
.docs-article img { max-width: 100%; border-radius: var(--radius-md); }
+130 -2
View File
@@ -8,6 +8,7 @@ export type ProjectEntity = {
id: string; id: string;
name: string; name: string;
description: string; description: string;
isApproved: boolean;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
stats: ProjectStats; stats: ProjectStats;
@@ -122,6 +123,7 @@ export type TemplateEntity = {
configRaw: string; configRaw: string;
variablesRaw: string; variablesRaw: string;
isOfficial: boolean; isOfficial: boolean;
ownerId: string | null;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
}; };
@@ -452,6 +454,7 @@ function normalizeProject(project: RawProject): ProjectEntity | null {
id: project.id, id: project.id,
name: project.name, name: project.name,
description: project.description ?? '', description: project.description ?? '',
isApproved: project.is_approved ?? false,
createdAt: project.created_at, createdAt: project.created_at,
updatedAt: project.updated_at, updatedAt: project.updated_at,
stats: { stats: {
@@ -611,6 +614,7 @@ function normalizeTemplate(template: RawServiceTemplate): TemplateEntity | null
configRaw: template.config ?? '', configRaw: template.config ?? '',
variablesRaw: template.variables ?? '', variablesRaw: template.variables ?? '',
isOfficial: Boolean(template.is_official), isOfficial: Boolean(template.is_official),
ownerId: template.owner_id ?? null,
createdAt: template.created_at, createdAt: template.created_at,
updatedAt: template.updated_at, updatedAt: template.updated_at,
}; };
@@ -762,8 +766,12 @@ function normalizeServiceLogArray(raw: unknown): ServiceLogEntity[] {
.filter((entry): entry is ServiceLogEntity => entry !== null); .filter((entry): entry is ServiceLogEntity => entry !== null);
} }
export async function listProjects(): Promise<ProjectEntity[]> { export async function listProjects(params?: { limit?: number; search?: string }): Promise<ProjectEntity[]> {
const payload = await requestJson<ProjectsResponse200 | { projects?: RawProject[] }>(`/projects`); const search = new URLSearchParams();
if (params?.limit) search.set('limit', String(params.limit));
if (params?.search) search.set('search', params.search);
const qs = search.toString();
const payload = await requestJson<ProjectsResponse200 | { projects?: RawProject[] }>(`/projects${qs ? `?${qs}` : ''}`);
return normalizeProjectArray((payload as { projects?: RawProject[] }).projects); return normalizeProjectArray((payload as { projects?: RawProject[] }).projects);
} }
@@ -991,6 +999,26 @@ export async function createProject(input: CreateProjectInput): Promise<ProjectE
return parsed; return parsed;
} }
export async function updateProject(
projectId: string,
input: { name?: string; description?: string },
): Promise<ProjectEntity> {
const payload = await requestJson<RawProject | { project?: RawProject }>(
`/projects/${encodeURIComponent(projectId)}`,
{ method: 'PUT', body: JSON.stringify(input) },
);
const rawProject = (payload as { project?: RawProject }).project ?? (payload as RawProject);
const parsed = normalizeProject(rawProject);
if (!parsed) {
throw new ApiError('Update project response is invalid', 500);
}
return parsed;
}
export async function deleteProject(projectId: string): Promise<void> {
await requestJson(`/projects/${encodeURIComponent(projectId)}`, { method: 'DELETE' });
}
export async function listServicesByProject(projectId: string): Promise<ServiceEntity[]> { export async function listServicesByProject(projectId: string): Promise<ServiceEntity[]> {
const payload = await requestJson<RawService[] | { services?: RawService[] }>(`/projects/${projectId}/services`); const payload = await requestJson<RawService[] | { services?: RawService[] }>(`/projects/${projectId}/services`);
const rows = Array.isArray(payload) ? payload : payload.services ?? []; const rows = Array.isArray(payload) ? payload : payload.services ?? [];
@@ -1323,6 +1351,54 @@ export async function getTemplateById(templateId: string): Promise<TemplateDetai
}; };
} }
export type TemplateWriteInput = {
name: string;
description?: string;
category?: string;
logo?: string;
config: Record<string, unknown>;
variables?: TemplateVariableEntity[];
};
function templateWriteBody(input: TemplateWriteInput): string {
return JSON.stringify({
name: input.name,
description: input.description ?? '',
category: input.category ?? '',
logo: input.logo ?? '',
config: input.config,
variables: input.variables ?? [],
});
}
export async function createTemplate(input: TemplateWriteInput): Promise<TemplateEntity> {
const payload = await requestJson<{ template?: RawServiceTemplate }>('/templates', {
method: 'POST',
body: templateWriteBody(input),
});
const template = payload.template ? normalizeTemplate(payload.template) : null;
if (!template) {
throw new ApiError('Template response is invalid', 500);
}
return template;
}
export async function updateTemplate(templateId: string, input: TemplateWriteInput): Promise<TemplateEntity> {
const payload = await requestJson<{ template?: RawServiceTemplate }>(`/templates/${templateId}`, {
method: 'PUT',
body: templateWriteBody(input),
});
const template = payload.template ? normalizeTemplate(payload.template) : null;
if (!template) {
throw new ApiError('Template response is invalid', 500);
}
return template;
}
export async function deleteTemplate(templateId: string): Promise<void> {
await requestJson(`/templates/${templateId}`, { method: 'DELETE' });
}
export async function deployTemplate( export async function deployTemplate(
templateId: string, templateId: string,
input: DeployTemplateInput, input: DeployTemplateInput,
@@ -1816,3 +1892,55 @@ export async function execInService(serviceId: string, command: string): Promise
body: JSON.stringify({ command }), body: JSON.stringify({ command }),
}); });
} }
// ---------------------------------------------------------------------------
// Admin
// ---------------------------------------------------------------------------
export type AdminUser = {
id: string;
email: string;
name: string;
avatar_url?: string;
is_admin: boolean;
created_at?: string;
};
export type AdminOverview = {
stats: Record<string, number>;
pending_projects: ProjectEntity[];
};
export async function getAdminOverview(): Promise<AdminOverview> {
const payload = await requestJson<{
stats?: Record<string, number>;
pending_projects?: RawProject[];
}>('/admin/overview');
return {
stats: payload.stats ?? {},
pending_projects: (payload.pending_projects ?? [])
.map((project) => normalizeProject(project))
.filter((project): project is ProjectEntity => project !== null),
};
}
export async function listAdminUsers(): Promise<AdminUser[]> {
const payload = await requestJson<{ users?: AdminUser[] }>('/admin/users');
return payload.users ?? [];
}
export async function setUserAdmin(userId: string, isAdmin: boolean): Promise<void> {
await requestJson(`/admin/users/${encodeURIComponent(userId)}`, {
method: 'PATCH',
body: JSON.stringify({ is_admin: isAdmin }),
});
}
export async function setProjectApproval(projectId: string, isApproved: boolean): Promise<ProjectEntity | null> {
const payload = await requestJson<{ project?: RawProject; message?: string }>(
`/admin/projects/${encodeURIComponent(projectId)}`,
{ method: 'PATCH', body: JSON.stringify({ is_approved: isApproved }) },
);
return payload.project ? normalizeProject(payload.project) : null;
}
+6 -3
View File
@@ -5,7 +5,8 @@ export const demoProjects: ProjectEntity[] = [
{ {
id: 'demo-project-core', id: 'demo-project-core',
name: 'Core Platform', name: 'Core Platform',
description: 'Primary production workload with web, API, queue, and data services.', description: 'Primary production workload with web, API, queue, and data services.',
isApproved: true,
createdAt: '2026-03-12T09:30:00Z', createdAt: '2026-03-12T09:30:00Z',
updatedAt: '2026-03-31T09:10:00Z', updatedAt: '2026-03-31T09:10:00Z',
stats: { stats: {
@@ -18,7 +19,8 @@ export const demoProjects: ProjectEntity[] = [
{ {
id: 'demo-project-growth', id: 'demo-project-growth',
name: 'Growth Surface', name: 'Growth Surface',
description: 'Landing pages and campaign services for growth experiments.', description: 'Landing pages and campaign services for growth experiments.',
isApproved: true,
createdAt: '2026-03-01T14:05:00Z', createdAt: '2026-03-01T14:05:00Z',
updatedAt: '2026-03-30T16:44:00Z', updatedAt: '2026-03-30T16:44:00Z',
stats: { stats: {
@@ -31,7 +33,8 @@ export const demoProjects: ProjectEntity[] = [
{ {
id: 'demo-project-ml', id: 'demo-project-ml',
name: 'Inference Lab', name: 'Inference Lab',
description: 'Internal inference jobs and model-serving edge services.', description: 'Internal inference jobs and model-serving edge services.',
isApproved: true,
createdAt: '2026-02-18T07:12:00Z', createdAt: '2026-02-18T07:12:00Z',
updatedAt: '2026-03-29T19:22:00Z', updatedAt: '2026-03-29T19:22:00Z',
stats: { stats: {
@@ -231,7 +231,7 @@ export function BarChart({
{data.map((value, index) => ( {data.map((value, index) => (
<div <div
key={index} key={index}
className="flex-1 cursor-pointer rounded-[var(--radius-xs)] transition-all hover:opacity-80" className="flex-1 cursor-pointer rounded-[var(--radius-sm)] transition-all hover:opacity-80"
style={{ style={{
height: `${value}%`, height: `${value}%`,
background: color, background: color,
@@ -61,7 +61,7 @@ export class ErrorBoundary extends Component<Props, State> {
return ( return (
<div className="min-h-screen bg-[var(--bg-void)] flex items-center justify-center p-4"> <div className="min-h-screen bg-[var(--bg-void)] flex items-center justify-center p-4">
<div className="max-w-md w-full bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg p-6 space-y-4"> <div className="max-w-md w-full bg-[var(--bg-primary)] border border-[var(--border-subtle)] rounded-lg p-6 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-xl font-semibold text-[var(--text-primary)]"> <h2 className="text-xl font-semibold text-[var(--text-primary)]">
Something went wrong Something went wrong
@@ -72,8 +72,8 @@ export class ErrorBoundary extends Component<Props, State> {
</div> </div>
{import.meta.env.DEV && this.state.error && ( {import.meta.env.DEV && this.state.error && (
<div className="bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded p-3"> <div className="bg-[var(--surface-muted)] border border-[var(--border-subtle)] rounded p-3">
<p className="text-xs font-mono text-[var(--text-error)] break-all"> <p className="text-xs font-mono text-[var(--error)] break-all">
{this.state.error.toString()} {this.state.error.toString()}
</p> </p>
</div> </div>
@@ -82,13 +82,13 @@ export class ErrorBoundary extends Component<Props, State> {
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={this.handleReset} onClick={this.handleReset}
className="flex-1 px-4 py-2 bg-[var(--bg-accent)] text-[var(--text-primary)] rounded hover:opacity-90 transition-opacity" className="flex-1 px-4 py-2 bg-[var(--accent-primary-soft)] text-[var(--text-primary)] rounded hover:opacity-90 transition-opacity"
> >
Try Again Try Again
</button> </button>
<button <button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="flex-1 px-4 py-2 bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded hover:bg-[var(--bg-tertiary)] transition-colors" className="flex-1 px-4 py-2 bg-[var(--surface-muted)] text-[var(--text-primary)] border border-[var(--border-subtle)] rounded hover:bg-[var(--surface-soft)] transition-colors"
> >
Reload Page Reload Page
</button> </button>
@@ -12,7 +12,7 @@ export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerPr
return ( return (
<div <div
className={`${sizeClasses[size]} border-[var(--border-primary)] border-t-[var(--text-accent)] rounded-full animate-spin ${className}`} className={`${sizeClasses[size]} border-[var(--border-subtle)] border-t-[var(--accent-primary)] rounded-full animate-spin ${className}`}
role="status" role="status"
aria-label="Loading" aria-label="Loading"
> >
@@ -28,7 +28,7 @@ interface LoadingOverlayProps {
export function LoadingOverlay({ message = 'Loading...' }: LoadingOverlayProps) { export function LoadingOverlay({ message = 'Loading...' }: LoadingOverlayProps) {
return ( return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg p-6 flex flex-col items-center gap-4"> <div className="bg-[var(--bg-primary)] border border-[var(--border-subtle)] rounded-lg p-6 flex flex-col items-center gap-4">
<LoadingSpinner size="lg" /> <LoadingSpinner size="lg" />
<p className="text-sm text-[var(--text-secondary)]">{message}</p> <p className="text-sm text-[var(--text-secondary)]">{message}</p>
</div> </div>
+3
View File
@@ -67,6 +67,9 @@ services:
DB_USER: ${POSTGRES_USER:-containr_user} DB_USER: ${POSTGRES_USER:-containr_user}
DB_PASSWORD: ${POSTGRES_PASSWORD:-dev_password_123} DB_PASSWORD: ${POSTGRES_PASSWORD:-dev_password_123}
CONTAINR_AGENT_AUTH_TOKEN: ${CONTAINR_AGENT_AUTH_TOKEN:-} CONTAINR_AGENT_AUTH_TOKEN: ${CONTAINR_AGENT_AUTH_TOKEN:-}
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_NAME: ${ADMIN_NAME:-}
CONTAINR_AGENT_AUTH_TOKENS: ${CONTAINR_AGENT_AUTH_TOKENS:-} CONTAINR_AGENT_AUTH_TOKENS: ${CONTAINR_AGENT_AUTH_TOKENS:-}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
+248
View File
@@ -987,6 +987,149 @@ paths:
type: array type: array
items: items:
$ref: '#/components/schemas/ServiceTemplate' $ref: '#/components/schemas/ServiceTemplate'
post:
tags:
- Templates
summary: Create a user template
description: Create a user-owned template (JSON definition). Requires authentication; the caller becomes the template owner.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TemplateWriteRequest'
responses:
'201':
description: Template created
content:
application/json:
schema:
type: object
properties:
template:
$ref: '#/components/schemas/ServiceTemplate'
/admin/overview:
get:
tags:
- Admin
summary: Platform overview
description: Admin-only aggregate statistics plus the pending project approval queue.
security:
- bearerAuth: []
responses:
'200':
description: Platform overview
content:
application/json:
schema:
type: object
properties:
stats:
type: object
additionalProperties:
type: integer
pending_projects:
type: array
items:
$ref: '#/components/schemas/Project'
/admin/users:
get:
tags:
- Admin
summary: List users
description: Admin-only listing of all platform users.
security:
- bearerAuth: []
responses:
'200':
description: Users retrieved
content:
application/json:
schema:
type: object
properties:
users:
type: array
items:
$ref: '#/components/schemas/AdminUser'
/admin/users/{id}:
patch:
tags:
- Admin
summary: Set user admin flag
description: Grant or revoke platform admin. Admins cannot revoke their own flag.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- is_admin
properties:
is_admin:
type: boolean
responses:
'200':
description: User updated
content:
application/json:
schema:
type: object
properties:
message:
type: string
/admin/projects/{id}:
patch:
tags:
- Admin
summary: Set project approval
description: Approve or unapprove a project for public listing.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- is_approved
properties:
is_approved:
type: boolean
responses:
'200':
description: Project updated
content:
application/json:
schema:
type: object
properties:
project:
$ref: '#/components/schemas/Project'
message:
type: string
/templates/{id}: /templates/{id}:
get: get:
@@ -1010,6 +1153,60 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/TemplateDetailResponse' $ref: '#/components/schemas/TemplateDetailResponse'
put:
tags:
- Templates
summary: Update a user template
description: Update a user-created template. Official templates cannot be modified; only the owner or an admin may update.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
description: Template ID
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TemplateWriteRequest'
responses:
'200':
description: Template updated
content:
application/json:
schema:
type: object
properties:
template:
$ref: '#/components/schemas/ServiceTemplate'
delete:
tags:
- Templates
summary: Delete a user template
description: Delete a user-created template. Official templates cannot be deleted; only the owner or an admin may delete.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
description: Template ID
schema:
type: string
responses:
'200':
description: Template deleted
content:
application/json:
schema:
type: object
properties:
message:
type: string
/templates/{id}/deploy: /templates/{id}/deploy:
post: post:
@@ -4402,6 +4599,23 @@ components:
type: string type: string
description: Optional avatar image URL description: Optional avatar image URL
AdminUser:
type: object
properties:
id:
type: string
email:
type: string
format: email
name:
type: string
avatar_url:
type: string
is_admin:
type: boolean
created_at:
type: string
Project: Project:
type: object type: object
properties: properties:
@@ -4417,6 +4631,9 @@ components:
owner_id: owner_id:
type: string type: string
description: Project owner ID description: Project owner ID
is_approved:
type: boolean
description: Whether the project is approved for public listing. New projects default to false and appear publicly only after admin approval.
created_at: created_at:
type: string type: string
format: date-time format: date-time
@@ -4995,6 +5212,10 @@ components:
is_official: is_official:
type: boolean type: boolean
description: Whether template is an official preset description: Whether template is an official preset
owner_id:
type: string
nullable: true
description: Owning user ID for user-created templates; null for official presets
created_at: created_at:
type: string type: string
format: date-time format: date-time
@@ -5004,6 +5225,33 @@ components:
format: date-time format: date-time
description: Last update timestamp description: Last update timestamp
TemplateWriteRequest:
type: object
required:
- name
- config
properties:
name:
type: string
description: Template display name
description:
type: string
description: Template description
category:
type: string
description: Template category (web, database, cache, queue, monitoring, storage, app, other)
logo:
type: string
description: Template logo URL
config:
type: object
description: Template config object (type, runtime, commands, port, environment)
variables:
type: array
items:
$ref: '#/components/schemas/TemplateVariable'
description: Configurable variables exposed at deploy time
TemplateConfig: TemplateConfig:
type: object type: object
properties: properties: