mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Merge pull request #38 from Dvorinka/feat/operations-page
feat(ops): operations page — live jobs, queue depth, failures
This commit is contained in:
16 files changed
+613
-4
No files matched your search
+6
-3
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
Reference in new issue
Block a user