From f524d95894e627b4562261809c94b71ed3ce1d37 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 9 Oct 2026 09:51:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(ops):=20/operations=20endpoint=20+=20page?= =?UTF-8?q?=20=E2=80=94=20live=20jobs,=20queue=20depth,=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/operations aggregates active deployments, per-service deploy-queue depth via Queue.Snapshot(), 24h deployment failures, cron runs and backups scoped to the caller's projects (admins see all). Frontend /operations page (Operate nav) shows in-flight count, queued depth, failures and backup stats with live refetch while deploys are active. CLI `containr operations` + MCP containr_operations_get. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP2.md | 9 +- app/backend/internal/api/operations.go | 158 +++++++++++++ app/backend/internal/api/routes.go | 3 + .../internal/cli/commands/operations.go | 24 ++ app/backend/internal/cli/root.go | 1 + app/backend/internal/deployqueue/queue.go | 21 ++ .../internal/deployqueue/queue_test.go | 34 +++ app/backend/internal/mcpserver/server.go | 2 + app/frontend/src/app/App.tsx | 2 + app/frontend/src/app/layout/PlatformShell.tsx | 2 + .../operations/pages/OperationsPage.tsx | 218 ++++++++++++++++++ app/frontend/src/generated/api-types.ts | 52 +++++ app/frontend/src/generated/docs-snapshot.ts | 2 +- app/frontend/src/lib/api-client.ts | 39 ++++ docs/api/openapi.yaml | 48 ++++ docs/guides/CLI.md | 2 + 16 files changed, 613 insertions(+), 4 deletions(-) create mode 100644 app/backend/internal/api/operations.go create mode 100644 app/backend/internal/cli/commands/operations.go create mode 100644 app/frontend/src/features/operations/pages/OperationsPage.tsx diff --git a/ROADMAP2.md b/ROADMAP2.md index d102bf4..da075e8 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -290,9 +290,12 @@ and wired refs, one click or one CLI call. favicon, accent override, docs/support links. `GET /api/v1/branding` public + cached. **Containr/Vertice remains default;** this is for self-hosters who rebrand their instance. -- [ ] **Operations page** — unified view of running/queued/failed jobs - (deploys, builds, backups, cron, scans, agent commands) — dflow's - bubble panel as a real page; flush/stuck-job tools once queue exists. +- [x] **Operations page** — `GET /operations` aggregates active + deployments, in-memory deploy-queue depth (`Queue.Snapshot()`), + 24h failures, cron runs and backups scoped to the caller's projects. + UI at `/operations` (Operate nav) with live refetch; `containr + operations` CLI + `containr_operations_get` MCP. Remaining: agent + commands once node fleet lands; flush/stuck-job tools. - [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`); `nodes/:id/terminal` via agent (dflow's wetty without the extra container). diff --git a/app/backend/internal/api/operations.go b/app/backend/internal/api/operations.go new file mode 100644 index 0000000..97c6fdc --- /dev/null +++ b/app/backend/internal/api/operations.go @@ -0,0 +1,158 @@ +package api + +import ( + "net/http" + "time" + + "containr/internal/database" + "containr/internal/deployqueue" + + "github.com/gin-gonic/gin" +) + +// GET /operations — a single cross-cutting view of platform work: active +// and recent deployments, the per-service deploy queue, recent cron runs, +// and recent backups. Scoped to the caller's projects (admins see all). + +type operationDeployment struct { + ID string `json:"id"` + ServiceID string `json:"service_id"` + ServiceName string `json:"service_name"` + ProjectName string `json:"project_name"` + Status string `json:"status"` + Image string `json:"image"` + Error *string `json:"error,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type operationCronRun struct { + ID string `json:"id"` + JobName string `json:"job_name"` + Schedule string `json:"schedule"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + Error *string `json:"error,omitempty"` +} + +type operationBackup struct { + ID string `json:"id"` + DatabaseID string `json:"database_id"` + DBName string `json:"database_name"` + Status string `json:"status"` + Size string `json:"size"` + CreatedAt time.Time `json:"created_at"` + Completed *time.Time `json:"completed_at,omitempty"` +} + +func handleGetOperations(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + db := c.MustGet("db").(*database.DB) + isAdmin := contextIsAdmin(c) + + // Same visibility rule as the deployment list endpoint. + visibility := `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)` + + active := []operationDeployment{} + rows, err := db.Query( + `SELECT d.id, d.service_id, s.name, p.name, d.status, + COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at + FROM deployments d + JOIN services s ON s.id = d.service_id + JOIN projects p ON p.id = s.project_id + WHERE d.status IN ('queued','pending','building','deploying','rolling_back') + AND (`+visibility+`) + ORDER BY d.created_at ASC`, userID, isAdmin) + if err == nil { + defer rows.Close() + for rows.Next() { + var d operationDeployment + var sid string + _ = rows.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt) + d.ServiceID = sid + active = append(active, d) + } + } + + recentFailed := []operationDeployment{} + rows2, err2 := db.Query( + `SELECT d.id, d.service_id, s.name, p.name, d.status, + COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at + FROM deployments d + JOIN services s ON s.id = d.service_id + JOIN projects p ON p.id = s.project_id + WHERE d.status IN ('failed','cancelled','rolled_back') + AND d.updated_at > NOW() - INTERVAL '24 hours' + AND (`+visibility+`) + ORDER BY d.updated_at DESC + LIMIT 20`, userID, isAdmin) + if err2 == nil { + defer rows2.Close() + for rows2.Next() { + var d operationDeployment + var sid string + _ = rows2.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt) + d.ServiceID = sid + recentFailed = append(recentFailed, d) + } + } + + cronRuns := []operationCronRun{} + rows3, err3 := db.Query( + `SELECT e.id, j.name, j.schedule, e.status, e.started_at, e.finished_at, e.error + FROM cron_executions e + JOIN cron_jobs j ON j.id = e.cron_job_id + JOIN projects p ON p.id = j.project_id + WHERE e.started_at > NOW() - INTERVAL '24 hours' + AND (`+visibility+`) + ORDER BY e.started_at DESC + LIMIT 30`, userID, isAdmin) + if err3 == nil { + defer rows3.Close() + for rows3.Next() { + var r operationCronRun + _ = rows3.Scan(&r.ID, &r.JobName, &r.Schedule, &r.Status, &r.StartedAt, &r.FinishedAt, &r.Error) + cronRuns = append(cronRuns, r) + } + } + + backups := []operationBackup{} + rows4, err4 := db.Query( + `SELECT b.id, b.database_id, ds.name, b.status, b.size, b.created_at, b.completed_at + FROM database_backups b + JOIN database_services ds ON ds.id = b.database_id + WHERE b.created_at > NOW() - INTERVAL '24 hours' + AND (ds.user_id = $1 OR $2::bool) + ORDER BY b.created_at DESC + LIMIT 20`, userID, isAdmin) + if err4 == nil { + defer rows4.Close() + for rows4.Next() { + var b operationBackup + _ = rows4.Scan(&b.ID, &b.DatabaseID, &b.DBName, &b.Status, &b.Size, &b.CreatedAt, &b.Completed) + backups = append(backups, b) + } + } + + var queue []deployqueue.ServiceQueueState + if queueValue, exists := c.Get("deploy_queue"); exists && queueValue != nil { + queue = queueValue.(*deployqueue.Queue).Snapshot() + } + if queue == nil { + queue = []deployqueue.ServiceQueueState{} + } + + c.JSON(http.StatusOK, gin.H{ + "active_deployments": active, + "recent_failures": recentFailed, + "cron_runs": cronRuns, + "backups": backups, + "deploy_queue": queue, + }) +} diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index d4d2a40..05c1738 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -333,6 +333,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // WebSocket endpoint authed.GET("/ws", handleWebSocket) + // Cross-cutting ops view — active/queued/failed work + authed.GET("/operations", handleGetOperations) + // Outbound webhooks — signed event delivery to user HTTP endpoints authed.GET("/webhooks", handleListOutboundWebhooks) authed.POST("/webhooks", handleCreateOutboundWebhook) diff --git a/app/backend/internal/cli/commands/operations.go b/app/backend/internal/cli/commands/operations.go new file mode 100644 index 0000000..f75201a --- /dev/null +++ b/app/backend/internal/cli/commands/operations.go @@ -0,0 +1,24 @@ +package commands + +import ( + "github.com/spf13/cobra" +) + +// OperationsCmd shows the cross-cutting operations view. +var OperationsCmd = &cobra.Command{ + Use: "operations", + Aliases: []string{"ops"}, + Short: "Show active and recent platform work (deploys, cron, backups)", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + data, err := c.Do("GET", "/operations", nil) + if err != nil { + return err + } + PrintRaw(data) + return nil + }, +} diff --git a/app/backend/internal/cli/root.go b/app/backend/internal/cli/root.go index 770b41a..946478e 100644 --- a/app/backend/internal/cli/root.go +++ b/app/backend/internal/cli/root.go @@ -88,6 +88,7 @@ func init() { rootCmd.AddCommand(commands.AdminCmd) rootCmd.AddCommand(commands.NotificationsCmd) rootCmd.AddCommand(commands.WebhooksCmd) + rootCmd.AddCommand(commands.OperationsCmd) rootCmd.AddCommand(commands.UpCmd) } diff --git a/app/backend/internal/deployqueue/queue.go b/app/backend/internal/deployqueue/queue.go index d6c583f..b194520 100644 --- a/app/backend/internal/deployqueue/queue.go +++ b/app/backend/internal/deployqueue/queue.go @@ -129,6 +129,27 @@ func (q *Queue) Cancel(serviceID, deploymentID uuid.UUID) CancelState { return NotFound } +// ServiceQueueState reports queue depth for one service. +type ServiceQueueState struct { + ServiceID uuid.UUID `json:"service_id"` + Running bool `json:"running"` + Queued int `json:"queued"` +} + +// Snapshot returns per-service queue depth for observability. +func (q *Queue) Snapshot() []ServiceQueueState { + q.mu.Lock() + defer q.mu.Unlock() + out := make([]ServiceQueueState, 0, len(q.states)) + for id, st := range q.states { + if !st.running && len(st.pending) == 0 { + continue + } + out = append(out, ServiceQueueState{ServiceID: id, Running: st.running, Queued: len(st.pending)}) + } + return out +} + // Do runs fn through the queue and waits for completion — for synchronous // endpoints (redeploy) that still must not overlap an in-flight deployment. // ctx bounds the wait, not the job; on timeout the job still runs to diff --git a/app/backend/internal/deployqueue/queue_test.go b/app/backend/internal/deployqueue/queue_test.go index 463934c..18dceaa 100644 --- a/app/backend/internal/deployqueue/queue_test.go +++ b/app/backend/internal/deployqueue/queue_test.go @@ -125,6 +125,40 @@ func TestCancelActiveCancelsContext(t *testing.T) { } } +func TestSnapshot(t *testing.T) { + q := New() + if got := q.Snapshot(); len(got) != 0 { + t.Fatalf("empty queue snapshot = %v, want empty", got) + } + + svc := uuid.New() + release := make(chan struct{}) + q.Enqueue(svc, Job{Run: func(ctx context.Context) { <-release }}) + q.Enqueue(svc, Job{Run: func(ctx context.Context) {}}) + q.Enqueue(svc, Job{Run: func(ctx context.Context) {}}) + + deadline := time.After(2 * time.Second) + for { + snap := q.Snapshot() + if len(snap) == 1 && snap[0].Running { + if snap[0].ServiceID != svc { + t.Fatalf("snapshot service = %v, want %v", snap[0].ServiceID, svc) + } + if snap[0].Queued != 2 { + t.Fatalf("queued = %d, want 2", snap[0].Queued) + } + close(release) + return + } + select { + case <-deadline: + t.Fatalf("snapshot never showed running job: %v", snap) + default: + time.Sleep(time.Millisecond) + } + } +} + func TestEnqueuePosition(t *testing.T) { q := New() svc := uuid.New() diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index fa01263..c82071c 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -147,6 +147,8 @@ func init() { register(spec{"containr_webhooks_deliveries", "List recent delivery attempts for a webhook. Args: id.", "GET", "/webhooks/{id}/deliveries", []string{"id"}, nil, false}) register(spec{"containr_webhooks_test", "Queue a test ping delivery for a webhook. Args: id.", "POST", "/webhooks/{id}/test", []string{"id"}, nil, false}) + register(spec{"containr_operations_get", "Cross-cutting operations view: active deployments, deploy queue depth, recent failures, cron runs, backups.", "GET", "/operations", nil, nil, false}) + // Nodes / agent tokens (admin) register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false}) register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false}) diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx index 00c20b9..bca9faa 100644 --- a/app/frontend/src/app/App.tsx +++ b/app/frontend/src/app/App.tsx @@ -10,6 +10,7 @@ import { BuildsPage } from '@/features/builds/pages/BuildsPage'; import { TemplatesPage } from '@/features/templates/pages/TemplatesPage'; import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage'; import { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage'; +import { OperationsPage } from '@/features/operations/pages/OperationsPage'; import { AdminPage } from '@/features/admin/pages/AdminPage'; import { LandingPage } from '@/features/landing/LandingPage'; import { @@ -142,6 +143,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/app/frontend/src/app/layout/PlatformShell.tsx b/app/frontend/src/app/layout/PlatformShell.tsx index 04c0cf4..8ca8627 100644 --- a/app/frontend/src/app/layout/PlatformShell.tsx +++ b/app/frontend/src/app/layout/PlatformShell.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import { + Activity, Bell, BookOpen, ChartBar, @@ -57,6 +58,7 @@ const navSections: NavSection[] = [ { title: 'Operate', items: [ + { label: 'Operations', href: '/operations', icon: Activity, hint: 'Live jobs & failures' }, { 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' }, diff --git a/app/frontend/src/features/operations/pages/OperationsPage.tsx b/app/frontend/src/features/operations/pages/OperationsPage.tsx new file mode 100644 index 0000000..b4f00c2 --- /dev/null +++ b/app/frontend/src/features/operations/pages/OperationsPage.tsx @@ -0,0 +1,218 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { getOperations, type OperationsView } from '@/lib/api-client'; +import { useDemoMode } from '@/lib/demo-mode'; +import { formatRelative } from '@/lib/time'; +import { DemoRestricted } from '@/shared/components'; +import { Activity, AlertTriangle, Clock, Database, Loader2, Timer } from 'lucide-react'; + +function statusClass(status: string): string { + switch (status) { + case 'deployed': + case 'completed': + case 'success': + return 'text-[var(--success)]'; + case 'failed': + case 'cancelled': + case 'rolled_back': + return 'text-[var(--error)]'; + case 'building': + case 'deploying': + case 'in_progress': + case 'running': + return 'text-[var(--accent-primary)]'; + default: + return 'text-[var(--warning)]'; + } +} + +function Section({ title, icon: Icon, count, children }: { + title: string; + icon: typeof Activity; + count: number; + children: React.ReactNode; +}) { + return ( +
+
+ +

{title}

+ ({count}) +
+ {children} +
+ ); +} + +function EmptyLine({ text }: { text: string }) { + return

{text}

; +} + +export function OperationsPage() { + const isDemoMode = useDemoMode(); + const navigate = useNavigate(); + + const opsQuery = useQuery({ + queryKey: ['operations'], + queryFn: getOperations, + enabled: !isDemoMode, + refetchInterval: (query) => + (query.state.data?.active_deployments?.length ?? 0) > 0 ? 3000 : 15000, + }); + + if (isDemoMode) { + return ; + } + + const ops: OperationsView | undefined = opsQuery.data; + const active = ops?.active_deployments ?? []; + const failed = ops?.recent_failures ?? []; + const cron = ops?.cron_runs ?? []; + const backups = ops?.backups ?? []; + const queue = ops?.deploy_queue ?? []; + const queuedTotal = queue.reduce((sum, q) => sum + q.queued, 0); + + return ( +
+
+
+

Operations_

+

+ Live view of deploys, queues, cron runs, and backups +

+
+
+ + {opsQuery.isLoading ? ( +
+ +
+ ) : ( +
+
+
+

In flight

+

{active.length}

+
+
+

Queued

+

{queuedTotal}

+
+
+

Failed 24h

+

{failed.length}

+
+
+

Backups 24h

+

{backups.length}

+
+
+ +
+
+ {active.length === 0 ? ( + + ) : ( +
+ {active.map((d) => ( + + ))} +
+ )} +
+ +
+ {failed.length === 0 ? ( + + ) : ( +
+ {failed.map((d) => ( + + ))} +
+ )} +
+ +
+ {cron.length === 0 ? ( + + ) : ( +
+ {cron.map((r) => ( +
+ {r.status} + {r.job_name} + {r.schedule} + {r.error && {r.error}} + {formatRelative(r.started_at)} +
+ ))} +
+ )} +
+ +
+ {backups.length === 0 ? ( + + ) : ( +
+ {backups.map((b) => ( +
+ {b.status} + {b.database_name} + {b.size} + {formatRelative(b.created_at)} +
+ ))} +
+ )} +
+
+ + {queue.length > 0 && ( +
+
+ {queue.map((q) => ( +
+ {q.service_id.slice(0, 8)} + + {q.running ? 'running' : 'idle'} + + {q.queued} queued +
+ ))} +
+
+ )} +
+ )} +
+ ); +} diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index f2f3a2f..02c0ac7 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -3757,6 +3757,58 @@ export interface paths { patch?: never; trace?: never; }; + "/operations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Cross-cutting operations view + * @description Single snapshot of platform work scoped to the caller's projects + * (admins see all): active deployments, in-memory deploy-queue + * depth per service, deployments that failed in the last 24h, + * cron executions and database backups from the last 24h. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Operations snapshot */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + active_deployments?: Record[]; + deploy_queue?: { + service_id?: string; + running?: boolean; + queued?: number; + }[]; + recent_failures?: Record[]; + cron_runs?: Record[]; + backups?: Record[]; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/audit-logs": { parameters: { query?: never; diff --git a/app/frontend/src/generated/docs-snapshot.ts b/app/frontend/src/generated/docs-snapshot.ts index 508d40a..7769fe9 100644 --- a/app/frontend/src/generated/docs-snapshot.ts +++ b/app/frontend/src/generated/docs-snapshot.ts @@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [ "path": "guides/CLI.md", "section": "guides", "title": "Containr CLI", - "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update --public/--public=false toggles it (container recreates, data persists)\ndatabases register register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose converts a compose file into\n a v2 graph config; deploy-graph \n deploys an ad-hoc service graph\nimport compose \n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create \n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## Build strategy and resources\n\nSource-built services pick a builder per service:\n\n```bash\ncontainr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static\ncontainr services update --builder static --static-cmd \"npm run build\" --static-dir dist\n```\n\n`auto` detects from repo contents (Dockerfile → railpack → nixpacks).\n`static` builds assets in a node stage and serves them from nginx —\n`--static-cmd` runs inside the build stage (default\n`npm ci && npm run build`), `--static-dir` is copied into the nginx image\n(default `dist`, exposed on port 80).\n\nResource reservations complement the hard `cpu`/`memory` limits:\n\n```bash\ncontainr services update --cpu-reserve 0.25 --memory-reserve 128Mi\n```\n\n`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/\n`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty\nstring clears. `app_settings.default_cpu`/`default_memory` change the\ninstance-level defaults used at service creation;\n`app_settings.capacity_policy=block` fails deployments whose requested\nmemory exceeds node capacity (default `warn` logs only).\n\n## Sleep mode (scale-to-zero)\n\nServices can scale to zero after an idle window and resume on traffic:\n\n```bash\ncontainr services update --sleep --sleep-idle 15 # enable, 15 min idle timeout\ncontainr services update --sleep=false # disable\ncontainr services sleep # sleep now\ncontainr services wake # resume now\ncontainr services env-check # flag empty vars + unresolved ${{refs}}\n```\n\nIdleness is measured as flat container network I/O over the configured\nwindow (swept every 30s). Sleeping removes the workload containers and —\nwhen the service has domains — installs a busybox placeholder that holds\nthe Traefik route at higher priority, serves a reloading \"waking\" page,\nand pings `GET /api/v1/internal/wake/:id` to reconcile the service back.\nThe API must be reachable from containers for the callback (containerized\ndeploys work via the shared network; bare-metal needs `HOST=0.0.0.0`).\n\n## Clone, move, registries\n\n`services clone` duplicates config, volumes, domains, access gates,\nbuilder settings, and variables — ciphertext secret values are copied\nverbatim (no decrypt/re-encrypt). The clone starts stopped:\n\n```bash\ncontainr services clone --name staging-api --project --environment preview\ncontainr services move # name conflicts → 409\n```\n\nNote: cloned domains are verbatim copies — the same hostname attached to\ntwo services makes Traefik routing ambiguous. Re-domain or undeploy one.\n\n`registries` stores private-image pull credentials, owner-scoped and\nencrypted at rest. Matched by image host on every image-sourced deploy:\n\n```bash\ncontainr registries add ghcr ghcr.io --username --password \ncontainr registries add hub docker.io --username --password # Docker Hub\ncontainr registries update --username # empty --password keeps stored\ncontainr registries remove \n```\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n" + "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update --public/--public=false toggles it (container recreates, data persists)\ndatabases register register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose converts a compose file into\n a v2 graph config; deploy-graph \n deploys an ad-hoc service graph\nimport compose \n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create \n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## Build strategy and resources\n\nSource-built services pick a builder per service:\n\n```bash\ncontainr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static\ncontainr services update --builder static --static-cmd \"npm run build\" --static-dir dist\n```\n\n`auto` detects from repo contents (Dockerfile → railpack → nixpacks).\n`static` builds assets in a node stage and serves them from nginx —\n`--static-cmd` runs inside the build stage (default\n`npm ci && npm run build`), `--static-dir` is copied into the nginx image\n(default `dist`, exposed on port 80).\n\nResource reservations complement the hard `cpu`/`memory` limits:\n\n```bash\ncontainr services update --cpu-reserve 0.25 --memory-reserve 128Mi\n```\n\n`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/\n`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty\nstring clears. `app_settings.default_cpu`/`default_memory` change the\ninstance-level defaults used at service creation;\n`app_settings.capacity_policy=block` fails deployments whose requested\nmemory exceeds node capacity (default `warn` logs only).\n\n## Sleep mode (scale-to-zero)\n\nServices can scale to zero after an idle window and resume on traffic:\n\n```bash\ncontainr services update --sleep --sleep-idle 15 # enable, 15 min idle timeout\ncontainr services update --sleep=false # disable\ncontainr services sleep # sleep now\ncontainr services wake # resume now\ncontainr services env-check # flag empty vars + unresolved ${{refs}}\n```\n\nIdleness is measured as flat container network I/O over the configured\nwindow (swept every 30s). Sleeping removes the workload containers and —\nwhen the service has domains — installs a busybox placeholder that holds\nthe Traefik route at higher priority, serves a reloading \"waking\" page,\nand pings `GET /api/v1/internal/wake/:id` to reconcile the service back.\nThe API must be reachable from containers for the callback (containerized\ndeploys work via the shared network; bare-metal needs `HOST=0.0.0.0`).\n\n## Clone, move, registries\n\n`services clone` duplicates config, volumes, domains, access gates,\nbuilder settings, and variables — ciphertext secret values are copied\nverbatim (no decrypt/re-encrypt). The clone starts stopped:\n\n```bash\ncontainr services clone --name staging-api --project --environment preview\ncontainr services move # name conflicts → 409\n```\n\nNote: cloned domains are verbatim copies — the same hostname attached to\ntwo services makes Traefik routing ambiguous. Re-domain or undeploy one.\n\n`registries` stores private-image pull credentials, owner-scoped and\nencrypted at rest. Matched by image host on every image-sourced deploy:\n\n```bash\ncontainr registries add ghcr ghcr.io --username --password \ncontainr registries add hub docker.io --username --password # Docker Hub\ncontainr registries update --username # empty --password keeps stored\ncontainr registries remove \n```\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n" }, { "path": "guides/CLOUDFLARE_SETUP.md", diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 69e387c..bae7c39 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1827,6 +1827,45 @@ export async function testWebhook(id: string): Promise { await requestJson(`/webhooks/${id}/test`, { method: 'POST', body: JSON.stringify({}) }); } +export type OperationsView = { + active_deployments: Array<{ + id: string; + service_id: string; + service_name: string; + project_name: string; + status: string; + image: string; + error?: string | null; + started_at?: string | null; + completed_at?: string | null; + created_at: string; + }>; + deploy_queue: Array<{ service_id: string; running: boolean; queued: number }>; + recent_failures: OperationsView['active_deployments']; + cron_runs: Array<{ + id: string; + job_name: string; + schedule: string; + status: string; + started_at: string; + finished_at?: string | null; + error?: string | null; + }>; + backups: Array<{ + id: string; + database_id: string; + database_name: string; + status: string; + size: string; + created_at: string; + completed_at?: string | null; + }>; +}; + +export async function getOperations(): Promise { + return requestJson('/operations'); +} + export function serviceStatusClass(status: ServiceStatus): string { switch (status) { case 'running': diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6bd7a7a..8955f56 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2533,6 +2533,54 @@ paths: schema: $ref: '#/components/schemas/MessageResponse' + /operations: + get: + tags: + - Operations + summary: Cross-cutting operations view + description: | + Single snapshot of platform work scoped to the caller's projects + (admins see all): active deployments, in-memory deploy-queue + depth per service, deployments that failed in the last 24h, + cron executions and database backups from the last 24h. + security: + - bearerAuth: [] + responses: + '200': + description: Operations snapshot + content: + application/json: + schema: + type: object + properties: + active_deployments: + type: array + items: + type: object + deploy_queue: + type: array + items: + type: object + properties: + service_id: + type: string + running: + type: boolean + queued: + type: integer + recent_failures: + type: array + items: + type: object + cron_runs: + type: array + items: + type: object + backups: + type: array + items: + type: object + # Audit Logs /audit-logs: get: diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index 123a808..3049c6c 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -82,6 +82,8 @@ webhooks list|create|update|delete|deliveries|test outbound signed webhooks — create --event 'service.*' (repeatable, '*' = all); deliveries are HMAC-signed (X-Containr-Signature) +operations live ops view: active deploys, queue depth, + 24h failures, cron runs, backups up deploy the current directory ```