feat(ops): /operations endpoint + page — live jobs, queue depth, failures

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>
This commit is contained in:
Tomas DvorakandDevin committed 2026-10-09 09:51:38 +02:00
1 parent f499678964
commit f524d95894
16 files changed
+613 -4

No files matched your search

+6 -3
View File
@@ -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).
+158
View File
@@ -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,
})
}
+3
View File
@@ -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)
@@ -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
},
}
+1
View File
@@ -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)
}
+21
View File
@@ -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
@@ -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()
+2
View File
@@ -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})
+2
View File
@@ -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() {
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/databases" element={<DatabasesPage />} />
<Route path="/ha" element={<HighAvailabilityPage />} />
<Route path="/operations" element={<OperationsPage />} />
<Route path="/security" element={<SecurityPage />} />
<Route path="/usage" element={<UsagePage />} />
<Route path="/docs" element={<DocsPage />} />
@@ -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' },
@@ -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 (
<section className="panel p-5">
<div className="flex items-center gap-2 mb-4">
<Icon size={16} className="text-[var(--accent-primary)]" />
<h2 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h2>
<span className="text-xs text-[var(--text-muted)] mono">({count})</span>
</div>
{children}
</section>
);
}
function EmptyLine({ text }: { text: string }) {
return <p className="text-xs text-[var(--text-muted)]">{text}</p>;
}
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 <DemoRestricted feature="Operations" />;
}
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 (
<div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]">
<div className="w-full px-8 py-5">
<h1 className="v-title">Operations<span className="v-cursor">_</span></h1>
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
Live view of deploys, queues, cron runs, and backups
</p>
</div>
</div>
{opsQuery.isLoading ? (
<div className="w-full px-8 py-12 flex justify-center">
<Loader2 size={20} className="animate-spin text-[var(--text-muted)]" />
</div>
) : (
<div className="w-full px-8 py-6 space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">In flight</p>
<p className="mt-1 text-2xl font-semibold text-[var(--accent-primary)]">{active.length}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Queued</p>
<p className="mt-1 text-2xl font-semibold text-[var(--warning)]">{queuedTotal}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Failed 24h</p>
<p className="mt-1 text-2xl font-semibold text-[var(--error)]">{failed.length}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Backups 24h</p>
<p className="mt-1 text-2xl font-semibold text-[var(--text-secondary)]">{backups.length}</p>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<Section title="Active deployments" icon={Activity} count={active.length}>
{active.length === 0 ? (
<EmptyLine text="Nothing deploying right now." />
) : (
<div className="space-y-2">
{active.map((d) => (
<button
key={d.id}
onClick={() => navigate(`/services/${d.service_id}`)}
className="w-full flex items-center gap-3 px-3 py-2 rounded-[var(--radius-md)] hover:bg-[var(--surface-muted)] text-left transition-colors"
>
<Loader2 size={13} className="animate-spin text-[var(--accent-primary)] shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-sm text-[var(--text-primary)]">{d.service_name}</span>
<span className="text-xs text-[var(--text-muted)]"> · {d.project_name}</span>
</div>
<span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span>
<span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span>
</button>
))}
</div>
)}
</Section>
<Section title="Failed — last 24h" icon={AlertTriangle} count={failed.length}>
{failed.length === 0 ? (
<EmptyLine text="No recent failures." />
) : (
<div className="space-y-2">
{failed.map((d) => (
<button
key={d.id}
onClick={() => navigate(`/services/${d.service_id}`)}
className="w-full flex items-center gap-3 px-3 py-2 rounded-[var(--radius-md)] hover:bg-[var(--surface-muted)] text-left transition-colors"
>
<AlertTriangle size={13} className="text-[var(--error)] shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-sm text-[var(--text-primary)]">{d.service_name}</span>
<span className="text-xs text-[var(--text-muted)]"> · {d.project_name}</span>
{d.error && (
<p className="text-[11px] text-[var(--error)]/80 truncate">{d.error}</p>
)}
</div>
<span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span>
<span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span>
</button>
))}
</div>
)}
</Section>
<Section title="Cron — last 24h" icon={Timer} count={cron.length}>
{cron.length === 0 ? (
<EmptyLine text="No cron runs in the last day." />
) : (
<div className="space-y-1.5">
{cron.map((r) => (
<div key={r.id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
<span className={`mono ${statusClass(r.status)}`}>{r.status}</span>
<span className="text-[var(--text-primary)]">{r.job_name}</span>
<span className="mono text-[var(--text-muted)]">{r.schedule}</span>
{r.error && <span className="text-[var(--error)]/80 truncate">{r.error}</span>}
<span className="ml-auto text-[var(--text-muted)]">{formatRelative(r.started_at)}</span>
</div>
))}
</div>
)}
</Section>
<Section title="Backups — last 24h" icon={Database} count={backups.length}>
{backups.length === 0 ? (
<EmptyLine text="No backups in the last day." />
) : (
<div className="space-y-1.5">
{backups.map((b) => (
<div key={b.id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
<span className={`mono ${statusClass(b.status)}`}>{b.status}</span>
<span className="text-[var(--text-primary)]">{b.database_name}</span>
<span className="text-[var(--text-muted)]">{b.size}</span>
<span className="ml-auto text-[var(--text-muted)]">{formatRelative(b.created_at)}</span>
</div>
))}
</div>
)}
</Section>
</div>
{queue.length > 0 && (
<Section title="Deploy queue depth" icon={Clock} count={queue.length}>
<div className="space-y-1.5">
{queue.map((q) => (
<div key={q.service_id} className="flex items-center gap-3 px-3 py-1.5 text-xs mono">
<span className="text-[var(--text-secondary)]">{q.service_id.slice(0, 8)}</span>
<span className={q.running ? 'text-[var(--accent-primary)]' : 'text-[var(--text-muted)]'}>
{q.running ? 'running' : 'idle'}
</span>
<span className="text-[var(--warning)]">{q.queued} queued</span>
</div>
))}
</div>
</Section>
)}
</div>
)}
</div>
);
}
+52
View File
@@ -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<string, never>[];
deploy_queue?: {
service_id?: string;
running?: boolean;
queued?: number;
}[];
recent_failures?: Record<string, never>[];
cron_runs?: Record<string, never>[];
backups?: Record<string, never>[];
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/audit-logs": {
parameters: {
query?: never;
+1 -1
View File
@@ -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 <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> 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 <id> <file.tar.gz|-> 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 <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> 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 <db-id> --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 <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\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 <name> <url>\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\Line truncated
"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 <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> 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 <id> <file.tar.gz|-> 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 <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> 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 <db-id> --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 <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\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 <name> <url>\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 Line truncated
},
{
"path": "guides/CLOUDFLARE_SETUP.md",
+39
View File
@@ -1827,6 +1827,45 @@ export async function testWebhook(id: string): Promise<void> {
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<OperationsView> {
return requestJson<OperationsView>('/operations');
}
export function serviceStatusClass(status: ServiceStatus): string {
switch (status) {
case 'running':
+48
View File
@@ -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:
+2
View File
@@ -82,6 +82,8 @@ webhooks list|create|update|delete|deliveries|test
outbound signed webhooks — create <name> <url>
--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
```