Compare commits

...
8 Commits
Author SHA1 Message Date
Tomáš Dvořák 954a1d1f80 Merge pull request #38 from Dvorinka/feat/operations-page
feat(ops): operations page — live jobs, queue depth, failures
2026-10-09 09:57:22 +02:00
Tomáš Dvořák cf7afd2ba7 Merge pull request #37 from Dvorinka/feat/outbound-webhooks
feat: outbound webhooks — signed event delivery
2026-10-09 09:56:45 +02:00
Tomas DvorakandDevin f524d95894 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>
2026-10-09 09:51:38 +02:00
Tomas Dvorak f499678964 feat: outbound webhooks — signed event delivery + delivery log
Every audited action now emits a "resource.action" event (bare actions
get resource-qualified; already-qualified actions like service.clone
pass through). Webhooks subscribe per-user with exact names, resource.*
wildcards, or *.

- schema: outbound_webhooks + webhook_deliveries (sqlc + goose)
- dispatcher: 4 workers, 512-queue, 3 attempts with backoff, 10s timeout;
  deliveries record status/http-code/body(4KiB)/duration
- signing: X-Containr-Signature sha256=<hmac> over raw body;
  X-Containr-Event + X-Containr-Delivery headers; custom headers per hook
- api: CRUD + /deliveries + /test (ping) under authed group
- fix: SetAuditDB never called — LogAudit was a silent no-op at every
  call site; now wired at boot
- coverage: service create/delete audited (previously uninstrumented)
- cli: containr webhooks list|create|update|delete|deliveries|test
- mcp: 6 tools, delete requires confirm
- frontend: /settings/webhooks page — list/create/expand-deliveries/
  test/disable/delete, secret-shown-once banner; nav + account menu
- openapi + regenerated types; CLI.md + docs snapshot; roadmap ticks
2026-10-09 09:44:08 +02:00
Tomáš Dvořák 64cbf82dd8 Merge pull request #36 from Dvorinka/feat/compose-import-project
feat: direct docker-compose import into projects
2026-10-09 09:43:15 +02:00
Tomas Dvorak 5c2f2861bd feat: direct docker-compose import into projects
POST /projects/:id/import-compose deploys a compose file straight into a
project — no template row required. Well-known DB images become managed
databases attached to the project network under their service name as DNS
alias; other services deploy in dependency order via deployTemplateGraph.

- api: extract composeToSpecs converter (shared by the template
  import-compose preview and the new project endpoint); fix dropped env
  warnings (pointer receiver); replace insertion sort with sort.Strings
- cascade: database_services.project_id links graph-provisioned DBs to
  their project so project delete tears down runtime + row (previously
  leaked container + network)
- cli: new `containr import` group, `import compose <project> <file|->`
- mcp: containr_projects_import_compose tool
- openapi + regenerated frontend types; docs snapshot + CLI.md
- frontend: compose import modal on the project workspace toolbar
- tests: composeToSpecs conversion, ordering, db-image map, warnings
2026-10-09 09:33:38 +02:00
Tomáš Dvořák dca8aaa7b3 Merge pull request #35 from Dvorinka/fix/mobile-auth-gate
fix(mobile): gate authed routes — cold-start redbox fix
2026-10-09 05:49:29 +02:00
Tomas DvorakandDevin 6946357a7b fix(mobile): gate authed routes behind Stack.Protected — cold-start redbox
'/' redirected to /(tabs) unconditionally; tab screens call useApi()
which throws 'not signed in' during render — before AuthGate's effect
redirect could run. Sign-out hit the same throw.

- index redirects based on session state (api ? tabs : login)
- authed screens wrapped in Stack.Protected guard={!!api}
- found by HP-WIN11 emulator visual pass

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 05:45:44 +02:00
39 changed files with 3613 additions and 76 deletions

No files matched your search

+15 -9
View File
@@ -86,7 +86,7 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
| Multi-tenant organisations + custom RBAC (per-resource CRUD, create-limits, read-scope) | 🟡 `is_admin` + `project_members` | Defer full RBAC; design PAT scopes role-compatible | | Multi-tenant organisations + custom RBAC (per-resource CRUD, create-limits, read-scope) | 🟡 `is_admin` + `project_members` | Defer full RBAC; design PAT scopes role-compatible |
| Team invite links/emails | 🟡 manual user create only | `POST /invites` → link join | | Team invite links/emails | 🟡 manual user create only | `POST /invites` → link join |
| User API keys → full REST API | ❌ **the big gap** — session/JWT only | PATs (Phase A) | | User API keys → full REST API | ❌ **the big gap** — session/JWT only | PATs (Phase A) |
| Outbound webhooks (collection events, HMAC, custom headers) | ❌ | `webhooks` table + delivery log | | Outbound webhooks (collection events, HMAC, custom headers) | ✅ `outbound_webhooks` + `webhook_deliveries`, HMAC `X-Containr-Signature`, wildcard events, 3-retry, UI page | — |
| Banners (global + per-tenant, dismissible) | ❌ | `banners` table + top bar | | Banners (global + per-tenant, dismissible) | ❌ | `banners` table + top bar |
| White-label (logo/favicon/title/OG/theme colors) | ❌ | `app_settings.branding` — **Containr remains the default brand** | | White-label (logo/favicon/title/OG/theme colors) | ❌ | `app_settings.branding` — **Containr remains the default brand** |
| Activity feed (icon/severity/category/metadata, 90-day TTL job) | 🟡 `audit_logs` coarser | Enrich + project feed UI | | Activity feed (icon/severity/category/metadata, 90-day TTL job) | 🟡 `audit_logs` coarser | Enrich + project feed UI |
@@ -278,9 +278,10 @@ and wired refs, one click or one CLI call.
## 8. Phase G — Platform polish ## 8. Phase G — Platform polish
- [ ] **Outbound webhooks** — `webhooks(url, secret, events[], headers, - [x] **Outbound webhooks** — `outbound_webhooks(url, secret, events[],
enabled)` + `webhook_deliveries` log; HMAC `X-Containr-Signature`; headers, enabled)` + `webhook_deliveries` log; HMAC
events: `deployment.* service.* database.* security.* agent.*`. `X-Containr-Signature`; wildcard events on every audited action;
CLI + MCP + `/settings/webhooks` page with delivery log + test ping.
- [ ] **Activity feed** — enrich `audit_logs` with `severity category - [ ] **Activity feed** — enrich `audit_logs` with `severity category
icon label metadata`; per-project timeline + global feed; TTL job. icon label metadata`; per-project timeline + global feed; TTL job.
- [ ] **Banners** — `banners(title, body, level, scope, active)`; top - [ ] **Banners** — `banners(title, body, level, scope, active)`; top
@@ -289,9 +290,12 @@ and wired refs, one click or one CLI call.
favicon, accent override, docs/support links. `GET /api/v1/branding` favicon, accent override, docs/support links. `GET /api/v1/branding`
public + cached. **Containr/Vertice remains default;** this is for public + cached. **Containr/Vertice remains default;** this is for
self-hosters who rebrand their instance. self-hosters who rebrand their instance.
- [ ] **Operations page** — unified view of running/queued/failed jobs - [x] **Operations page** — `GET /operations` aggregates active
(deploys, builds, backups, cron, scans, agent commands) — dflow's deployments, in-memory deploy-queue depth (`Queue.Snapshot()`),
bubble panel as a real page; flush/stuck-job tools once queue exists. 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`); - [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`);
`nodes/:id/terminal` via agent (dflow's wetty without the extra `nodes/:id/terminal` via agent (dflow's wetty without the extra
container). container).
@@ -345,8 +349,10 @@ and wired refs, one click or one CLI call.
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST - [ ] **dflow importer** — `containr migrate dflow` via Payload REST
export or Mongo dump: servers→nodes, projects, services(+vars/ export or Mongo dump: servers→nodes, projects, services(+vars/
domains/volumes), databases, templates. Onboards dflow refugees. domains/volumes), databases, templates. Onboards dflow refugees.
- [ ] **`containr import compose`** — Phase-E parser straight to a - [x] **`containr import compose`** — `POST /projects/:id/import-compose`
project (no template intermediate). deploys a compose file straight into a project; CLI `import compose`,
MCP tool, workspace import modal; project delete cascades to
graph-provisioned managed databases (`database_services.project_id`).
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL` - [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL`
fetch/publish; bundled catalog works offline (dflow's self-contained fetch/publish; bundled catalog works offline (dflow's self-contained
principle — nothing phones home). principle — nothing phones home).
+4
View File
@@ -52,6 +52,8 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in
if err != nil { if err != nil {
} }
emitWebhookEvent(userID, resource, resourceID, action, details)
} }
func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) { func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) {
@@ -79,6 +81,8 @@ func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, de
if err != nil { if err != nil {
} }
emitWebhookEvent(userIDStr, resource, resourceID, action, details)
} }
var auditDB *database.DB var auditDB *database.DB
+10
View File
@@ -23,6 +23,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/google/uuid"
dockercontainer "github.com/docker/docker/api/types/container" dockercontainer "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
@@ -378,6 +380,13 @@ func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID stri
now := time.Now() now := time.Now()
version := h.getDefaultVersion(dbType) version := h.getDefaultVersion(dbType)
var projectID uuid.NullUUID
if req.ProjectID != "" {
if parsed, parseErr := uuid.Parse(req.ProjectID); parseErr == nil {
projectID = uuid.NullUUID{UUID: parsed, Valid: true}
}
}
err = h.queries.CreateDatabaseService(ctx, sqlcdb.CreateDatabaseServiceParams{ err = h.queries.CreateDatabaseService(ctx, sqlcdb.CreateDatabaseServiceParams{
ID: databaseID, ID: databaseID,
UserID: userID, UserID: userID,
@@ -388,6 +397,7 @@ func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID stri
Plan: plan, Plan: plan,
Region: region, Region: region,
PublicPort: req.PublicPort, PublicPort: req.PublicPort,
ProjectID: projectID,
CreatedAt: sql.NullTime{Time: now, Valid: true}, CreatedAt: sql.NullTime{Time: now, Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true}, UpdatedAt: sql.NullTime{Time: now, Valid: true},
}) })
+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,
})
}
+23
View File
@@ -7,6 +7,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"log"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -297,6 +298,28 @@ func handleDeleteProject(c *gin.Context) {
return return
} }
// Managed databases provisioned into this project (graph templates,
// compose imports) carry project_id and must be torn down first — their
// containers are not labelled containr.project and hold references to the
// project network.
if dbHandlerValue, exists := c.Get("database_handler"); exists && dbHandlerValue != nil {
if dbHandler, ok := dbHandlerValue.(*DatabaseHandler); ok {
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
projectDBs, listErr := dbHandler.queries.ListDatabaseServicesByProject(ctx, uuid.NullUUID{UUID: projectID, Valid: true})
if listErr == nil {
for _, projectDB := range projectDBs {
if projectDB.Provider != "external" {
if err := dbHandler.deleteManagedDatabaseRuntime(projectDB.ID); err != nil {
log.Printf("delete project %s: database %s runtime removal failed: %v", projectID, projectDB.ID, err)
}
}
_ = dbHandler.queries.DeleteDatabaseServiceByID(ctx, projectDB.ID)
}
}
cancel()
}
}
// Remove live containers and the project network before dropping rows. // Remove live containers and the project network before dropping rows.
// Best-effort: Docker may be unavailable; DB cleanup proceeds regardless. // Best-effort: Docker may be unavailable; DB cleanup proceeds regardless.
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil { if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
+17
View File
@@ -23,6 +23,11 @@ import (
) )
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) { func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
// Audit + outbound webhook delivery need a package-level handle for
// call sites that don't carry a gin context.
SetAuditDB(db)
StartWebhookDispatcher()
// Expose Better Auth through backend so frontend can use a single backend origin. // Expose Better Auth through backend so frontend can use a single backend origin.
setupAuthProxyRoutes(router, cfg, db) setupAuthProxyRoutes(router, cfg, db)
@@ -328,11 +333,23 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// WebSocket endpoint // WebSocket endpoint
authed.GET("/ws", handleWebSocket) 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)
authed.PATCH("/webhooks/:id", handleUpdateOutboundWebhook)
authed.DELETE("/webhooks/:id", handleDeleteOutboundWebhook)
authed.GET("/webhooks/:id/deliveries", handleListOutboundWebhookDeliveries)
authed.POST("/webhooks/:id/test", handleTestOutboundWebhook)
// Owned-resource mutations — handlers scope to the caller and // Owned-resource mutations — handlers scope to the caller and
// admit admins. New projects land unapproved until an admin // admit admins. New projects land unapproved until an admin
// publishes them. // publishes them.
authed.POST("/projects", handleCreateProject) authed.POST("/projects", handleCreateProject)
authed.POST("/projects/:id/services", handleCreateService) authed.POST("/projects/:id/services", handleCreateService)
authed.POST("/projects/:id/import-compose", handleImportProjectCompose)
authed.PUT("/projects/:id", handleUpdateProject) authed.PUT("/projects/:id", handleUpdateProject)
authed.DELETE("/projects/:id", handleDeleteProject) authed.DELETE("/projects/:id", handleDeleteProject)
+6
View File
@@ -454,6 +454,10 @@ func handleCreateService(c *gin.Context) {
} }
service.Domains = loadServiceDomains(db.(*database.DB), service.ID) service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
LogAuditWithRequest(c, "service", service.ID.String(), "create", map[string]interface{}{
"name": service.Name, "type": service.Type, "project_id": service.ProjectID,
})
c.JSON(http.StatusCreated, gin.H{"service": service}) c.JSON(http.StatusCreated, gin.H{"service": service})
} }
@@ -876,5 +880,7 @@ func handleDeleteService(c *gin.Context) {
return return
} }
LogAuditWithRequest(c, "service", serviceID.String(), "delete", map[string]interface{}{})
c.JSON(http.StatusOK, gin.H{"message": "Service deleted successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Service deleted successfully"})
} }
+116 -32
View File
@@ -12,6 +12,7 @@ import (
"math/big" "math/big"
"net/http" "net/http"
"regexp" "regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -948,6 +949,88 @@ func handleDeployInlineGraph(c *gin.Context) {
}) })
} }
// handleImportProjectCompose deploys a docker-compose file straight into a
// project — parse → graph specs → deployTemplateGraph, no template row.
func handleImportProjectCompose(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
ctx := c.Request.Context()
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
queries := sqlcdb.New(db.DB)
ownerID, err := queries.GetProjectOwnerID(ctx, projectID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch project"})
return
}
if ownerID.String() != userID && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var req struct {
ComposeYAML string `json:"compose_yaml" binding:"required"`
Variables map[string]string `json:"variables"`
Plan string `json:"plan"`
Region string `json:"region"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.ComposeYAML) > 512*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Compose file exceeds 512 KiB"})
return
}
var file composeFileDef
if err := yaml.Unmarshal([]byte(req.ComposeYAML), &file); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid compose YAML: " + err.Error()})
return
}
specs, warnings, convErr := composeToSpecs(&file)
if convErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": convErr.Error()})
return
}
var engine *deployment.DeploymentEngine
if v, exists := c.Get("deployment_engine"); exists && v != nil {
engine, _ = v.(*deployment.DeploymentEngine)
}
var dbHandler *DatabaseHandler
if v, exists := c.Get("database_handler"); exists {
dbHandler, _ = v.(*DatabaseHandler)
}
created, fail := deployTemplateGraph(c, db, engine, dbHandler, projectID, userID,
specs, nil, req.Variables, req.Plan, req.Region, "compose-import")
if fail != nil {
status := http.StatusBadRequest
if (*fail)["code"] == "CONFLICT" {
status = http.StatusConflict
}
c.JSON(status, *fail)
return
}
c.JSON(http.StatusCreated, gin.H{
"resource": "stack",
"created": created,
"warnings": warnings,
})
}
func dedupStrings(in []string) []string { func dedupStrings(in []string) []string {
seen := map[string]bool{} seen := map[string]bool{}
out := make([]string, 0, len(in)) out := make([]string, 0, len(in))
@@ -1012,26 +1095,38 @@ func handleImportCompose(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid compose YAML: " + err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid compose YAML: " + err.Error()})
return return
} }
if len(file.Services) == 0 { services, warnings, convErr := composeToSpecs(&file)
c.JSON(http.StatusBadRequest, gin.H{"error": "Compose file defines no services"}) if convErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": convErr.Error()})
return return
} }
warnings := []string{}
services := make([]TemplateServiceSpec, 0, len(file.Services))
variables := []TemplateVariable{} variables := []TemplateVariable{}
// Compose service names become keys; declaration order follows YAML config := TemplateConfig{Version: 2, Services: services}
// document order — yaml.v3 maps lose order, so sort keys for stability. configJSON, _ := json.Marshal(config)
c.JSON(http.StatusOK, gin.H{
"config": json.RawMessage(configJSON),
"variables": variables,
"warnings": warnings,
})
}
// composeToSpecs converts a parsed compose file into graph service specs.
// Compose service names become keys; declaration order follows YAML document
// order — yaml.v3 maps lose order, so sort keys for stability.
func composeToSpecs(file *composeFileDef) ([]TemplateServiceSpec, []string, error) {
if len(file.Services) == 0 {
return nil, nil, fmt.Errorf("compose file defines no services")
}
warnings := []string{}
services := make([]TemplateServiceSpec, 0, len(file.Services))
names := make([]string, 0, len(file.Services)) names := make([]string, 0, len(file.Services))
for name := range file.Services { for name := range file.Services {
names = append(names, name) names = append(names, name)
} }
for i := 1; i < len(names); i++ { sort.Strings(names)
for j := i; j > 0 && names[j] < names[j-1]; j-- {
names[j], names[j-1] = names[j-1], names[j]
}
}
for _, name := range names { for _, name := range names {
svc := file.Services[name] svc := file.Services[name]
@@ -1062,17 +1157,15 @@ func handleImportCompose(c *gin.Context) {
if engineHint != "" && svc.Build == nil { if engineHint != "" && svc.Build == nil {
spec.Type = "database" spec.Type = "database"
spec.Runtime = engineHint spec.Runtime = engineHint
spec.Environment = composeEnvironment(svc.Environment, warnings, key) spec.Environment = composeEnvironment(svc.Environment, &warnings, key)
} else { } else {
spec.Type = "web" spec.Type = "web"
spec.Runtime = imageName spec.Runtime = imageName
spec.Environment = composeEnvironment(svc.Environment, warnings, key) spec.Environment = composeEnvironment(svc.Environment, &warnings, key)
spec.Port = composeFirstPort(svc.Ports) spec.Port = composeFirstPort(svc.Ports)
spec.StartCommand = composeCommand(svc.Command) spec.StartCommand = composeCommand(svc.Command)
if svc.Build != nil { if svc.Build != nil && imageName == "" {
if imageName == "" { warnings = append(warnings, fmt.Sprintf("%s: build contexts are not supported — publish an image or link a git repo", key))
warnings = append(warnings, fmt.Sprintf("%s: build contexts are not supported — publish an image or link a git repo", key))
}
} }
if spec.Runtime == "" { if spec.Runtime == "" {
warnings = append(warnings, fmt.Sprintf("%s: no image defined — set a runtime before deploying", key)) warnings = append(warnings, fmt.Sprintf("%s: no image defined — set a runtime before deploying", key))
@@ -1089,28 +1182,19 @@ func handleImportCompose(c *gin.Context) {
} }
if len(services) == 0 { if len(services) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Compose file produced no importable services"}) return nil, warnings, fmt.Errorf("compose file produced no importable services")
return
} }
return services, warnings, nil
config := TemplateConfig{Version: 2, Services: services}
configJSON, _ := json.Marshal(config)
c.JSON(http.StatusOK, gin.H{
"config": json.RawMessage(configJSON),
"variables": variables,
"warnings": warnings,
})
} }
func composeEnvironment(env interface{}, warnings []string, svcKey string) map[string]string { func composeEnvironment(env interface{}, warnings *[]string, svcKey string) map[string]string {
out := map[string]string{} out := map[string]string{}
switch e := env.(type) { switch e := env.(type) {
case map[string]interface{}: case map[string]interface{}:
for k, v := range e { for k, v := range e {
if v == nil { if v == nil {
// `KEY:` in a list/map forwards the host value — flag it. // `KEY:` in a list/map forwards the host value — flag it.
warnings = append(warnings, fmt.Sprintf("%s: env %s has no value — set it at deploy", svcKey, k)) *warnings = append(*warnings, fmt.Sprintf("%s: env %s has no value — set it at deploy", svcKey, k))
continue continue
} }
out[k] = fmt.Sprintf("%v", v) out[k] = fmt.Sprintf("%v", v)
@@ -1124,7 +1208,7 @@ func composeEnvironment(env interface{}, warnings []string, svcKey string) map[s
if idx := strings.Index(s, "="); idx > 0 { if idx := strings.Index(s, "="); idx > 0 {
out[s[:idx]] = s[idx+1:] out[s[:idx]] = s[idx+1:]
} else { } else {
warnings = append(warnings, fmt.Sprintf("%s: env %s has no value — set it at deploy", svcKey, s)) *warnings = append(*warnings, fmt.Sprintf("%s: env %s has no value — set it at deploy", svcKey, s))
} }
} }
} }
@@ -0,0 +1,126 @@
package api
import (
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func composeToSpecsForTest(t *testing.T, y string) ([]TemplateServiceSpec, []string, error) {
t.Helper()
var file composeFileDef
if err := yaml.Unmarshal([]byte(y), &file); err != nil {
t.Fatalf("yaml: %v", err)
}
return composeToSpecs(&file)
}
func TestComposeToSpecsConvertsStack(t *testing.T) {
specs, warnings, err := composeToSpecsForTest(t, `services:
web:
image: traefik/whoami:latest
ports:
- "80"
environment:
GREETING: hello-compose
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: pgsecret123
POSTGRES_DB: appdb
`)
if err != nil {
t.Fatal(err)
}
if len(specs) != 2 {
t.Fatalf("want 2 specs, got %d", len(specs))
}
db, web := specs[0], specs[1]
if db.Key != "db" || db.Type != "database" || db.Runtime != "postgresql" {
t.Errorf("db spec wrong: %+v", db)
}
if db.Environment["POSTGRES_PASSWORD"] != "pgsecret123" {
t.Errorf("db env lost: %v", db.Environment)
}
if web.Key != "web" || web.Type != "web" || web.Runtime != "traefik/whoami:latest" {
t.Errorf("web spec wrong: %+v", web)
}
if web.Port != 80 {
t.Errorf("web port = %d, want 80", web.Port)
}
if len(web.DependsOn) != 1 || web.DependsOn[0] != "db" {
t.Errorf("web depends_on = %v, want [db]", web.DependsOn)
}
if len(warnings) != 0 {
t.Errorf("unexpected warnings: %v", warnings)
}
}
func TestComposeToSpecsDeterministicOrder(t *testing.T) {
y := `services:
zebra:
image: alpine
alpha:
image: alpine
`
for i := 0; i < 5; i++ {
specs, _, err := composeToSpecsForTest(t, y)
if err != nil {
t.Fatal(err)
}
if specs[0].Key != "alpha" || specs[1].Key != "zebra" {
t.Fatalf("unstable order: %v", specs)
}
}
}
func TestComposeToSpecsDatabaseImages(t *testing.T) {
cases := map[string]string{
"postgres:16": "postgresql",
"redis:7": "redis",
"mysql:8": "mysql",
"mariadb:11": "mariadb",
"mongo:7": "mongodb",
"clickhouse:24": "clickhouse",
}
for image, want := range cases {
specs, _, err := composeToSpecsForTest(t, "services:\n db:\n image: "+image+"\n")
if err != nil {
t.Fatalf("%s: %v", image, err)
}
if specs[0].Type != "database" || specs[0].Runtime != want {
t.Errorf("%s → %+v, want database/%s", image, specs[0], want)
}
}
}
func TestComposeToSpecsNoServices(t *testing.T) {
_, _, err := composeToSpecsForTest(t, "version: '3'\n")
if err == nil || !strings.Contains(err.Error(), "no services") {
t.Errorf("want no-services error, got %v", err)
}
}
func TestComposeToSpecsWarnings(t *testing.T) {
_, warnings, err := composeToSpecsForTest(t, `services:
app:
build: .
orphan:
image: alpine
environment:
- UNSET_VAR
`)
if err != nil {
t.Fatal(err)
}
joined := strings.Join(warnings, "\n")
if !strings.Contains(joined, "build contexts are not supported") {
t.Errorf("missing build warning: %v", warnings)
}
if !strings.Contains(joined, "UNSET_VAR") {
t.Errorf("missing env warning: %v", warnings)
}
}
+543
View File
@@ -0,0 +1,543 @@
package api
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
// Outbound webhooks: every audited action (LogAudit) emits an event named
// "resource.action" (e.g. "service.deploy"). Webhooks subscribe with an
// event list supporting "service.*" and "*" wildcards. Deliveries are
// HMAC-signed and retried up to 3 times with backoff; each attempt lands
// in webhook_deliveries.
var (
webhookQueue = make(chan webhookJob, 512)
webhookWorkers sync.Once
)
type webhookJob struct {
Webhook sqlcdb.OutboundWebhook
Event string
Payload []byte
}
type webhookEventPayload struct {
Event string `json:"event"`
Resource string `json:"resource"`
ResourceID string `json:"resource_id"`
Action string `json:"action"`
UserID string `json:"user_id,omitempty"`
Details map[string]interface{} `json:"details,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
// emitWebhookEvent fans an audited action out to all enabled webhooks whose
// event list matches. Called from LogAudit — fire-and-forget.
func emitWebhookEvent(userID, resource, resourceID, action string, details map[string]interface{}) {
db := GetAuditDB()
if db == nil {
return
}
// Actions that already carry a resource prefix ("service.clone") pass
// through unchanged; bare actions ("create") get resource-qualified.
event := action
if !strings.Contains(action, ".") {
event = resource + "." + action
}
queries := sqlcdb.New(db.DB)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
hooks, err := queries.ListEnabledOutboundWebhooks(ctx)
if err != nil || len(hooks) == 0 {
return
}
payload, err := json.Marshal(webhookEventPayload{
Event: event, Resource: resource, ResourceID: resourceID,
Action: action, UserID: userID, Details: details, Timestamp: time.Now().UTC(),
})
if err != nil {
return
}
uid, uidErr := uuid.Parse(strings.TrimSpace(userID))
for _, hook := range hooks {
if !webhookEventMatches(hook.Events, event) {
continue
}
// Webhooks only receive events for their owner — unless subscribed to "*"
// is deliberate admin reach; owner scoping keeps per-user isolation.
if uidErr != nil || hook.UserID != uid {
continue
}
select {
case webhookQueue <- webhookJob{Webhook: hook, Event: event, Payload: payload}:
default:
// Queue full: drop rather than block the request path.
}
}
}
func webhookEventMatches(events json.RawMessage, event string) bool {
var subs []string
if err := json.Unmarshal(events, &subs); err != nil || len(subs) == 0 {
return false
}
for _, sub := range subs {
sub = strings.TrimSpace(sub)
if sub == "*" || sub == event {
return true
}
if strings.HasSuffix(sub, ".*") && strings.HasPrefix(event, strings.TrimSuffix(sub, "*")) {
return true
}
}
return false
}
// StartWebhookDispatcher launches delivery workers; call once at boot.
func StartWebhookDispatcher() {
webhookWorkers.Do(func() {
for i := 0; i < 4; i++ {
go webhookDeliveryWorker()
}
})
}
func webhookDeliveryWorker() {
for job := range webhookQueue {
deliverWebhook(job)
}
}
var webhookHTTPClient = &http.Client{Timeout: 10 * time.Second}
func deliverWebhook(job webhookJob) {
db := GetAuditDB()
if db == nil {
return
}
queries := sqlcdb.New(db.DB)
ctx := context.Background()
deliveryID := uuid.New()
_ = queries.CreateWebhookDelivery(ctx, sqlcdb.CreateWebhookDeliveryParams{
ID: deliveryID,
WebhookID: job.Webhook.ID,
Event: job.Event,
Payload: pqtype.NullRawMessage{RawMessage: job.Payload, Valid: true},
Status: "pending",
})
var lastStatus string = "failed"
var respStatus sql.NullInt32
var respBody sql.NullString
var durationMs sql.NullInt32
var deliveredAt sql.NullTime
for attempt := int32(1); attempt <= 3; attempt++ {
start := time.Now()
status, code, body := postWebhook(job, deliveryID, attempt)
durationMs = sql.NullInt32{Int32: int32(time.Since(start).Milliseconds()), Valid: true}
respStatus = code
respBody = body
if status == "success" {
lastStatus = "success"
deliveredAt = sql.NullTime{Time: time.Now().UTC(), Valid: true}
_ = queries.UpdateWebhookDeliveryResult(ctx, sqlcdb.UpdateWebhookDeliveryResultParams{
ID: deliveryID,
Status: lastStatus,
ResponseStatus: respStatus,
ResponseBody: respBody,
Attempts: attempt,
DurationMs: durationMs,
DeliveredAt: deliveredAt,
})
return
}
if attempt < 3 {
time.Sleep(time.Duration(attempt*attempt) * 2 * time.Second)
}
}
_ = queries.UpdateWebhookDeliveryResult(ctx, sqlcdb.UpdateWebhookDeliveryResultParams{
ID: deliveryID,
Status: lastStatus,
ResponseStatus: respStatus,
ResponseBody: respBody,
Attempts: 3,
DurationMs: durationMs,
DeliveredAt: deliveredAt,
})
}
func postWebhook(job webhookJob, deliveryID uuid.UUID, attempt int32) (string, sql.NullInt32, sql.NullString) {
req, err := http.NewRequest(http.MethodPost, job.Webhook.Url, bytes.NewReader(job.Payload))
if err != nil {
return "failed", sql.NullInt32{}, sql.NullString{String: err.Error(), Valid: true}
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Containr-Webhook/1.0")
req.Header.Set("X-Containr-Event", job.Event)
req.Header.Set("X-Containr-Delivery", deliveryID.String())
if job.Webhook.Secret != "" {
mac := hmac.New(sha256.New, []byte(job.Webhook.Secret))
mac.Write(job.Payload)
req.Header.Set("X-Containr-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
if job.Webhook.Headers.Valid {
var custom map[string]string
if err := json.Unmarshal(job.Webhook.Headers.RawMessage, &custom); err == nil {
for k, v := range custom {
req.Header.Set(k, v)
}
}
}
resp, err := webhookHTTPClient.Do(req)
if err != nil {
return "failed", sql.NullInt32{}, sql.NullString{String: err.Error(), Valid: true}
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
code := sql.NullInt32{Int32: int32(resp.StatusCode), Valid: true}
bodyStr := sql.NullString{String: string(body), Valid: len(body) > 0}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return "success", code, bodyStr
}
return "failed", code, bodyStr
}
// ---------- HTTP API ----------
type outboundWebhookResponse struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Secret string `json:"secret,omitempty"`
Events []string `json:"events"`
Headers map[string]string `json:"headers,omitempty"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func outboundWebhookJSON(row sqlcdb.OutboundWebhook, revealSecret bool) outboundWebhookResponse {
var events []string
_ = json.Unmarshal(row.Events, &events)
if events == nil {
events = []string{}
}
var headers map[string]string
if row.Headers.Valid {
_ = json.Unmarshal(row.Headers.RawMessage, &headers)
}
resp := outboundWebhookResponse{
ID: row.ID.String(),
Name: row.Name,
URL: row.Url,
Events: events,
Headers: headers,
Enabled: row.Enabled,
}
if revealSecret {
resp.Secret = row.Secret
}
if row.CreatedAt.Valid {
resp.CreatedAt = row.CreatedAt.Time
}
if row.UpdatedAt.Valid {
resp.UpdatedAt = row.UpdatedAt.Time
}
return resp
}
func validWebhookURL(raw string) error {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return errors.New("url must be a valid http(s) URL")
}
return nil
}
type webhookMutationRequest struct {
Name string `json:"name"`
URL string `json:"url"`
Secret *string `json:"secret"`
Events []string `json:"events"`
Headers map[string]string `json:"headers"`
Enabled *bool `json:"enabled"`
}
func handleListOutboundWebhooks(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
rows, err := sqlcdb.New(db.DB).ListOutboundWebhooksByUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list webhooks"})
return
}
out := make([]outboundWebhookResponse, 0, len(rows))
for _, row := range rows {
out = append(out, outboundWebhookJSON(row, false))
}
c.JSON(http.StatusOK, gin.H{"webhooks": out})
}
func handleCreateOutboundWebhook(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
var req webhookMutationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
respondError(c, http.StatusBadRequest, "VALIDATION", "name is required")
return
}
if err := validWebhookURL(req.URL); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
return
}
if len(req.Events) == 0 {
respondError(c, http.StatusBadRequest, "VALIDATION", "at least one event subscription is required (e.g. \"service.*\" or \"*\")")
return
}
secret := ""
if req.Secret != nil {
secret = strings.TrimSpace(*req.Secret)
}
if secret == "" {
buf := make([]byte, 24)
_, _ = rand.Read(buf)
secret = hex.EncodeToString(buf)
}
eventsJSON, _ := json.Marshal(req.Events)
var headersJSON pqtype.NullRawMessage
if len(req.Headers) > 0 {
if raw, err := json.Marshal(req.Headers); err == nil {
headersJSON = pqtype.NullRawMessage{RawMessage: raw, Valid: true}
}
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
db := c.MustGet("db").(*database.DB)
id := uuid.New()
err := sqlcdb.New(db.DB).CreateOutboundWebhook(c.Request.Context(), sqlcdb.CreateOutboundWebhookParams{
ID: id,
UserID: userID,
Name: req.Name,
Url: strings.TrimSpace(req.URL),
Secret: secret,
Events: eventsJSON,
Headers: headersJSON,
Enabled: enabled,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create webhook"})
return
}
LogAuditWithRequest(c, "webhook", id.String(), "create", map[string]interface{}{"name": req.Name})
c.JSON(http.StatusCreated, outboundWebhookJSON(sqlcdb.OutboundWebhook{
ID: id, Name: req.Name, Url: strings.TrimSpace(req.URL), Secret: secret,
Events: eventsJSON, Headers: headersJSON, Enabled: enabled,
CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
}, true))
}
func handleUpdateOutboundWebhook(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
webhookID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
row, err := queries.GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{
ID: webhookID, UserID: userID,
})
if errors.Is(err, sql.ErrNoRows) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found")
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load webhook"})
return
}
var req webhookMutationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
name := row.Name
if req.Name != "" {
name = strings.TrimSpace(req.Name)
}
targetURL := row.Url
if req.URL != "" {
if err := validWebhookURL(req.URL); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
return
}
targetURL = strings.TrimSpace(req.URL)
}
secret := row.Secret
if req.Secret != nil {
secret = strings.TrimSpace(*req.Secret)
}
eventsJSON := row.Events
if req.Events != nil {
if len(req.Events) == 0 {
respondError(c, http.StatusBadRequest, "VALIDATION", "at least one event subscription is required")
return
}
eventsJSON, _ = json.Marshal(req.Events)
}
headersJSON := row.Headers
if req.Headers != nil {
if raw, err := json.Marshal(req.Headers); err == nil {
headersJSON = pqtype.NullRawMessage{RawMessage: raw, Valid: true}
}
}
enabled := row.Enabled
if req.Enabled != nil {
enabled = *req.Enabled
}
err = queries.UpdateOutboundWebhook(c.Request.Context(), sqlcdb.UpdateOutboundWebhookParams{
ID: webhookID, UserID: userID,
Name: name, Url: targetURL, Secret: secret,
Events: eventsJSON, Headers: headersJSON, Enabled: enabled,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update webhook"})
return
}
LogAuditWithRequest(c, "webhook", webhookID.String(), "update", map[string]interface{}{"name": name})
row.Name, row.Url, row.Secret, row.Events, row.Headers, row.Enabled = name, targetURL, secret, eventsJSON, headersJSON, enabled
c.JSON(http.StatusOK, outboundWebhookJSON(row, true))
}
func handleDeleteOutboundWebhook(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
webhookID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
return
}
db := c.MustGet("db").(*database.DB)
err = sqlcdb.New(db.DB).DeleteOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.DeleteOutboundWebhookByIDAndUserParams{
ID: webhookID, UserID: userID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete webhook"})
return
}
LogAuditWithRequest(c, "webhook", webhookID.String(), "delete", map[string]interface{}{})
c.JSON(http.StatusOK, gin.H{"message": "Webhook deleted"})
}
func handleListOutboundWebhookDeliveries(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
webhookID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
if _, err := queries.GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{
ID: webhookID, UserID: userID,
}); errors.Is(err, sql.ErrNoRows) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found")
return
}
rows, err := queries.ListWebhookDeliveriesByWebhook(c.Request.Context(), webhookID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list deliveries"})
return
}
c.JSON(http.StatusOK, gin.H{"deliveries": rows})
}
func handleTestOutboundWebhook(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
webhookID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
return
}
db := c.MustGet("db").(*database.DB)
row, err := sqlcdb.New(db.DB).GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{
ID: webhookID, UserID: userID,
})
if errors.Is(err, sql.ErrNoRows) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found")
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load webhook"})
return
}
if !row.Enabled {
respondError(c, http.StatusBadRequest, "VALIDATION", "Webhook is disabled")
return
}
payload, _ := json.Marshal(webhookEventPayload{
Event: "ping", Resource: "webhook", ResourceID: webhookID.String(),
Action: "ping", UserID: userID.String(), Timestamp: time.Now().UTC(),
})
select {
case webhookQueue <- webhookJob{Webhook: row, Event: "ping", Payload: payload}:
c.JSON(http.StatusAccepted, gin.H{"message": "Test delivery queued"})
default:
respondError(c, http.StatusServiceUnavailable, "UNAVAILABLE", "Delivery queue is full")
}
}
+33
View File
@@ -0,0 +1,33 @@
package api
import (
"encoding/json"
"testing"
)
func TestWebhookEventMatches(t *testing.T) {
subs := func(list ...string) json.RawMessage {
raw, _ := json.Marshal(list)
return raw
}
cases := []struct {
events json.RawMessage
event string
want bool
}{
{subs("service.*"), "service.deploy", true},
{subs("service.*"), "database.create", false},
{subs("*"), "service.deploy", true},
{subs("deployment.fail", "service.*"), "deployment.fail", true},
{subs("deployment.fail"), "deployment.success", false},
{subs(" ping "), "ping", true},
{subs(), "service.deploy", false},
{json.RawMessage(`"not-a-list"`), "service.deploy", false},
{json.RawMessage(`invalid`), "service.deploy", false},
}
for i, tc := range cases {
if got := webhookEventMatches(tc.events, tc.event); got != tc.want {
t.Errorf("case %d: matches(%s, %q) = %v, want %v", i, tc.events, tc.event, got, tc.want)
}
}
}
@@ -0,0 +1,57 @@
package commands
import (
"fmt"
"io"
"os"
"github.com/spf13/cobra"
)
var importVars []string
var ImportCmd = &cobra.Command{
Use: "import",
Short: "Import external definitions into Containr",
}
var importComposeCmd = &cobra.Command{
Use: "compose <project-id> <compose.yml|->",
Short: "Deploy a docker-compose file straight into a project (no template)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
var raw []byte
var err error
if args[1] == "-" {
raw, err = io.ReadAll(os.Stdin)
} else {
raw, err = os.ReadFile(args[1])
}
if err != nil {
return fmt.Errorf("cannot read compose file: %w", err)
}
vars, err := parseKVFlags(importVars)
if err != nil {
return err
}
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{"compose_yaml": string(raw)}
if len(vars) > 0 {
body["variables"] = vars
}
data, err := c.Do("POST", "/projects/"+args[0]+"/import-compose", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
func init() {
importComposeCmd.Flags().StringArrayVar(&importVars, "var", nil, "Variable override (KEY=VALUE, repeatable)")
ImportCmd.AddCommand(importComposeCmd)
}
@@ -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
},
}
@@ -0,0 +1,216 @@
package commands
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
var webhookEvents []string
var webhookHeaders []string
var webhookSecret string
var webhookName string
// WebhooksCmd manages outbound webhooks — signed HTTP event delivery.
var WebhooksCmd = &cobra.Command{
Use: "webhooks",
Aliases: []string{"hooks"},
Short: "Manage outbound webhooks (signed event delivery)",
}
var webhookListCmd = &cobra.Command{
Use: "list",
Short: "List webhooks",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/webhooks", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "webhooks")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, w := range items {
events := ""
if ev, ok := w["events"].([]interface{}); ok {
names := make([]string, 0, len(ev))
for _, e := range ev {
if s, ok := e.(string); ok {
names = append(names, s)
}
}
events = strings.Join(names, ",")
}
rows = append(rows, []string{
str(w, "id"), str(w, "name"), str(w, "url"), events, str(w, "enabled"),
})
}
printRows(data, []string{"ID", "NAME", "URL", "EVENTS", "ENABLED"}, rows)
return nil
},
}
func webhookBody(req map[string]interface{}) (map[string]interface{}, error) {
if len(webhookEvents) > 0 {
req["events"] = webhookEvents
}
if webhookSecret != "" {
req["secret"] = webhookSecret
}
if webhookName != "" {
req["name"] = webhookName
}
if len(webhookHeaders) > 0 {
hdrs, err := parseKVFlags(webhookHeaders)
if err != nil {
return nil, err
}
req["headers"] = hdrs
}
return req, nil
}
var webhookCreateCmd = &cobra.Command{
Use: "create <name> <url>",
Short: "Create a webhook (events repeat with --event; use \"*\" for all)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
if len(webhookEvents) == 0 {
return fmt.Errorf("at least one --event is required (e.g. --event 'service.*' or --event '*')")
}
c, err := client()
if err != nil {
return err
}
body, err := webhookBody(map[string]interface{}{"name": args[0], "url": args[1]})
if err != nil {
return err
}
data, err := c.Do("POST", "/webhooks", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var webhookUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a webhook (--name --url --event --secret --header --enabled/--disabled)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body, err := webhookBody(map[string]interface{}{})
if err != nil {
return err
}
if url, _ := cmd.Flags().GetString("url"); url != "" {
body["url"] = url
}
if cmd.Flags().Changed("enabled") {
body["enabled"] = true
}
if cmd.Flags().Changed("disabled") {
body["enabled"] = false
}
data, err := c.Do("PATCH", "/webhooks/"+args[0], body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var webhookDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a webhook",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !Confirm("Delete webhook " + args[0] + "?") {
return nil
}
c, err := client()
if err != nil {
return err
}
data, err := c.Do("DELETE", "/webhooks/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var webhookDeliveriesCmd = &cobra.Command{
Use: "deliveries <id>",
Short: "Show recent delivery attempts for a webhook",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/webhooks/"+args[0]+"/deliveries", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "deliveries")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, d := range items {
rows = append(rows, []string{
str(d, "id"), str(d, "event"), str(d, "status"),
str(d, "response_status"), str(d, "attempts"), relTime(d, "created_at"),
})
}
printRows(data, []string{"ID", "EVENT", "STATUS", "HTTP", "TRIES", "AGE"}, rows)
return nil
},
}
var webhookTestCmd = &cobra.Command{
Use: "test <id>",
Short: "Queue a test ping delivery",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/webhooks/"+args[0]+"/test", map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
func init() {
webhookCreateCmd.Flags().StringArrayVar(&webhookEvents, "event", nil, "Event subscription (e.g. 'service.*', 'deployment.fail', '*'; repeatable)")
webhookCreateCmd.Flags().StringVar(&webhookSecret, "secret", "", "Signing secret (auto-generated when omitted)")
webhookCreateCmd.Flags().StringArrayVar(&webhookHeaders, "header", nil, "Extra request header KEY=VALUE (repeatable)")
webhookUpdateCmd.Flags().StringVar(&webhookName, "name", "", "New name")
webhookUpdateCmd.Flags().String("url", "", "New URL")
webhookUpdateCmd.Flags().StringArrayVar(&webhookEvents, "event", nil, "Replace event subscriptions (repeatable)")
webhookUpdateCmd.Flags().StringVar(&webhookSecret, "secret", "", "New signing secret")
webhookUpdateCmd.Flags().StringArrayVar(&webhookHeaders, "header", nil, "Replace request headers KEY=VALUE (repeatable)")
webhookUpdateCmd.Flags().Bool("enabled", false, "Enable the webhook")
webhookUpdateCmd.Flags().Bool("disabled", false, "Disable the webhook")
WebhooksCmd.AddCommand(webhookListCmd, webhookCreateCmd, webhookUpdateCmd, webhookDeleteCmd, webhookDeliveriesCmd, webhookTestCmd)
}
+3
View File
@@ -79,6 +79,7 @@ func init() {
rootCmd.AddCommand(commands.DatabasesCmd) rootCmd.AddCommand(commands.DatabasesCmd)
rootCmd.AddCommand(commands.CronCmd) rootCmd.AddCommand(commands.CronCmd)
rootCmd.AddCommand(commands.TemplatesCmd) rootCmd.AddCommand(commands.TemplatesCmd)
rootCmd.AddCommand(commands.ImportCmd)
rootCmd.AddCommand(commands.NodesCmd) rootCmd.AddCommand(commands.NodesCmd)
rootCmd.AddCommand(commands.ScalingCmd) rootCmd.AddCommand(commands.ScalingCmd)
rootCmd.AddCommand(commands.HACmd) rootCmd.AddCommand(commands.HACmd)
@@ -86,6 +87,8 @@ func init() {
rootCmd.AddCommand(commands.GatewayCmd) rootCmd.AddCommand(commands.GatewayCmd)
rootCmd.AddCommand(commands.AdminCmd) rootCmd.AddCommand(commands.AdminCmd)
rootCmd.AddCommand(commands.NotificationsCmd) rootCmd.AddCommand(commands.NotificationsCmd)
rootCmd.AddCommand(commands.WebhooksCmd)
rootCmd.AddCommand(commands.OperationsCmd)
rootCmd.AddCommand(commands.UpCmd) rootCmd.AddCommand(commands.UpCmd)
} }
@@ -8,6 +8,8 @@ package sqlcdb
import ( import (
"context" "context"
"database/sql" "database/sql"
"github.com/google/uuid"
) )
const countDatabaseServicesByUserAndName = `-- name: CountDatabaseServicesByUserAndName :one const countDatabaseServicesByUserAndName = `-- name: CountDatabaseServicesByUserAndName :one
@@ -55,22 +57,23 @@ func (q *Queries) CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBa
} }
const createDatabaseService = `-- name: CreateDatabaseService :exec const createDatabaseService = `-- name: CreateDatabaseService :exec
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, created_at, updated_at) INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, project_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
` `
type CreateDatabaseServiceParams struct { type CreateDatabaseServiceParams struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Status string `json:"status"` Status string `json:"status"`
Version string `json:"version"` Version string `json:"version"`
Plan string `json:"plan"` Plan string `json:"plan"`
Region string `json:"region"` Region string `json:"region"`
PublicPort bool `json:"public_port"` PublicPort bool `json:"public_port"`
CreatedAt sql.NullTime `json:"created_at"` ProjectID uuid.NullUUID `json:"project_id"`
UpdatedAt sql.NullTime `json:"updated_at"` CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
} }
func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error { func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error {
@@ -84,6 +87,7 @@ func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseS
arg.Plan, arg.Plan,
arg.Region, arg.Region,
arg.PublicPort, arg.PublicPort,
arg.ProjectID,
arg.CreatedAt, arg.CreatedAt,
arg.UpdatedAt, arg.UpdatedAt,
) )
@@ -154,6 +158,16 @@ func (q *Queries) DatabaseServiceExistsByIDAndUser(ctx context.Context, arg Data
return exists, err return exists, err
} }
const deleteDatabaseServiceByID = `-- name: DeleteDatabaseServiceByID :exec
DELETE FROM database_services
WHERE id = $1
`
func (q *Queries) DeleteDatabaseServiceByID(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deleteDatabaseServiceByID, id)
return err
}
const deleteDatabaseServiceByIDAndUser = `-- name: DeleteDatabaseServiceByIDAndUser :exec const deleteDatabaseServiceByIDAndUser = `-- name: DeleteDatabaseServiceByIDAndUser :exec
DELETE FROM database_services DELETE FROM database_services
WHERE id = $1 AND user_id = $2 WHERE id = $1 AND user_id = $2
@@ -199,7 +213,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
} }
const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.project_id, database_services.created_at, database_services.updated_at
FROM database_services FROM database_services
WHERE id = $1 WHERE id = $1
` `
@@ -228,6 +242,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
&i.ExternalSsl, &i.ExternalSsl,
&i.PublicPort, &i.PublicPort,
&i.BackupTargetID, &i.BackupTargetID,
&i.ProjectID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -235,7 +250,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
} }
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.project_id, database_services.created_at, database_services.updated_at
FROM database_services FROM database_services
WHERE id = $1 AND user_id = $2 WHERE id = $1 AND user_id = $2
` `
@@ -269,6 +284,7 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
&i.ExternalSsl, &i.ExternalSsl,
&i.PublicPort, &i.PublicPort,
&i.BackupTargetID, &i.BackupTargetID,
&i.ProjectID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -289,7 +305,7 @@ func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (str
} }
const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.project_id, database_services.created_at, database_services.updated_at
FROM database_services FROM database_services
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@@ -324,6 +340,7 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic
&i.ExternalSsl, &i.ExternalSsl,
&i.PublicPort, &i.PublicPort,
&i.BackupTargetID, &i.BackupTargetID,
&i.ProjectID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@@ -387,8 +404,62 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
return items, nil return items, nil
} }
const listDatabaseServicesByProject = `-- name: ListDatabaseServicesByProject :many
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.project_id, database_services.created_at, database_services.updated_at
FROM database_services
WHERE project_id = $1
ORDER BY created_at DESC
`
func (q *Queries) ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error) {
rows, err := q.db.QueryContext(ctx, listDatabaseServicesByProject, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []DatabaseService{}
for rows.Next() {
var i DatabaseService
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Type,
&i.Status,
&i.Version,
&i.Plan,
&i.Region,
&i.ConnectionUrl,
&i.BackupSchedule,
&i.NextBackupAt,
&i.Provider,
&i.ExternalHost,
&i.ExternalPort,
&i.ExternalName,
&i.ExternalUsername,
&i.ExternalPassword,
&i.ExternalSsl,
&i.PublicPort,
&i.BackupTargetID,
&i.ProjectID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.project_id, database_services.created_at, database_services.updated_at
FROM database_services FROM database_services
WHERE user_id = $1 WHERE user_id = $1
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -424,6 +495,7 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
&i.ExternalSsl, &i.ExternalSsl,
&i.PublicPort, &i.PublicPort,
&i.BackupTargetID, &i.BackupTargetID,
&i.ProjectID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@@ -116,6 +116,7 @@ type DatabaseService struct {
ExternalSsl bool `json:"external_ssl"` ExternalSsl bool `json:"external_ssl"`
PublicPort bool `json:"public_port"` PublicPort bool `json:"public_port"`
BackupTargetID sql.NullString `json:"backup_target_id"` BackupTargetID sql.NullString `json:"backup_target_id"`
ProjectID uuid.NullUUID `json:"project_id"`
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
@@ -174,6 +175,19 @@ type Notification struct {
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
} }
type OutboundWebhook struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
Url string `json:"url"`
Secret string `json:"secret"`
Events json.RawMessage `json:"events"`
Headers pqtype.NullRawMessage `json:"headers"`
Enabled bool `json:"enabled"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type Project struct { type Project struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -285,3 +299,17 @@ type UserToken struct {
RevokedAt sql.NullTime `json:"revoked_at"` RevokedAt sql.NullTime `json:"revoked_at"`
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
} }
type WebhookDelivery struct {
ID uuid.UUID `json:"id"`
WebhookID uuid.UUID `json:"webhook_id"`
Event string `json:"event"`
Payload pqtype.NullRawMessage `json:"payload"`
Status string `json:"status"`
ResponseStatus sql.NullInt32 `json:"response_status"`
ResponseBody sql.NullString `json:"response_body"`
Attempts int32 `json:"attempts"`
DurationMs sql.NullInt32 `json:"duration_ms"`
CreatedAt sql.NullTime `json:"created_at"`
DeliveredAt sql.NullTime `json:"delivered_at"`
}
@@ -27,14 +27,18 @@ type Querier interface {
CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error
CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error
CreateNotification(ctx context.Context, arg CreateNotificationParams) error CreateNotification(ctx context.Context, arg CreateNotificationParams) error
CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error) CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error) DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
DeleteAgent(ctx context.Context, id string) error DeleteAgent(ctx context.Context, id string) error
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
DeleteDatabaseServiceByID(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error) GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
@@ -49,6 +53,8 @@ type Querier interface {
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error) GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error) GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error)
GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
@@ -65,9 +71,12 @@ type Querier interface {
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error) ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error)
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error) ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error)
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error) ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error)
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListPendingProjects(ctx context.Context) ([]Project, error) ListPendingProjects(ctx context.Context) ([]Project, error)
// Visibility model: a project is readable when it is approved for public // Visibility model: a project is readable when it is approved for public
@@ -77,6 +86,7 @@ type Querier interface {
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error)
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
@@ -100,8 +110,10 @@ type Querier interface {
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error) UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
} }
@@ -0,0 +1,329 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: webhooks.sql
package sqlcdb
import (
"context"
"database/sql"
"encoding/json"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
const createOutboundWebhook = `-- name: CreateOutboundWebhook :exec
INSERT INTO outbound_webhooks (id, user_id, name, url, secret, events, headers, enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
type CreateOutboundWebhookParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
Url string `json:"url"`
Secret string `json:"secret"`
Events json.RawMessage `json:"events"`
Headers pqtype.NullRawMessage `json:"headers"`
Enabled bool `json:"enabled"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error {
_, err := q.db.ExecContext(ctx, createOutboundWebhook,
arg.ID,
arg.UserID,
arg.Name,
arg.Url,
arg.Secret,
arg.Events,
arg.Headers,
arg.Enabled,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const createWebhookDelivery = `-- name: CreateWebhookDelivery :exec
INSERT INTO webhook_deliveries (id, webhook_id, event, payload, status, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
`
type CreateWebhookDeliveryParams struct {
ID uuid.UUID `json:"id"`
WebhookID uuid.UUID `json:"webhook_id"`
Event string `json:"event"`
Payload pqtype.NullRawMessage `json:"payload"`
Status string `json:"status"`
CreatedAt sql.NullTime `json:"created_at"`
}
func (q *Queries) CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error {
_, err := q.db.ExecContext(ctx, createWebhookDelivery,
arg.ID,
arg.WebhookID,
arg.Event,
arg.Payload,
arg.Status,
arg.CreatedAt,
)
return err
}
const deleteOutboundWebhookByIDAndUser = `-- name: DeleteOutboundWebhookByIDAndUser :exec
DELETE FROM outbound_webhooks
WHERE id = $1 AND user_id = $2
`
type DeleteOutboundWebhookByIDAndUserParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error {
_, err := q.db.ExecContext(ctx, deleteOutboundWebhookByIDAndUser, arg.ID, arg.UserID)
return err
}
const getOutboundWebhookByID = `-- name: GetOutboundWebhookByID :one
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
FROM outbound_webhooks
WHERE id = $1
`
func (q *Queries) GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error) {
row := q.db.QueryRowContext(ctx, getOutboundWebhookByID, id)
var i OutboundWebhook
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Url,
&i.Secret,
&i.Events,
&i.Headers,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getOutboundWebhookByIDAndUser = `-- name: GetOutboundWebhookByIDAndUser :one
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
FROM outbound_webhooks
WHERE id = $1 AND user_id = $2
`
type GetOutboundWebhookByIDAndUserParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error) {
row := q.db.QueryRowContext(ctx, getOutboundWebhookByIDAndUser, arg.ID, arg.UserID)
var i OutboundWebhook
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Url,
&i.Secret,
&i.Events,
&i.Headers,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listEnabledOutboundWebhooks = `-- name: ListEnabledOutboundWebhooks :many
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
FROM outbound_webhooks
WHERE enabled
`
func (q *Queries) ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error) {
rows, err := q.db.QueryContext(ctx, listEnabledOutboundWebhooks)
if err != nil {
return nil, err
}
defer rows.Close()
items := []OutboundWebhook{}
for rows.Next() {
var i OutboundWebhook
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Url,
&i.Secret,
&i.Events,
&i.Headers,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listOutboundWebhooksByUser = `-- name: ListOutboundWebhooksByUser :many
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
FROM outbound_webhooks
WHERE user_id = $1
ORDER BY created_at DESC
`
func (q *Queries) ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error) {
rows, err := q.db.QueryContext(ctx, listOutboundWebhooksByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []OutboundWebhook{}
for rows.Next() {
var i OutboundWebhook
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Url,
&i.Secret,
&i.Events,
&i.Headers,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWebhookDeliveriesByWebhook = `-- name: ListWebhookDeliveriesByWebhook :many
SELECT webhook_deliveries.id, webhook_deliveries.webhook_id, webhook_deliveries.event, webhook_deliveries.payload, webhook_deliveries.status, webhook_deliveries.response_status, webhook_deliveries.response_body, webhook_deliveries.attempts, webhook_deliveries.duration_ms, webhook_deliveries.created_at, webhook_deliveries.delivered_at
FROM webhook_deliveries
WHERE webhook_id = $1
ORDER BY created_at DESC
LIMIT 100
`
func (q *Queries) ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error) {
rows, err := q.db.QueryContext(ctx, listWebhookDeliveriesByWebhook, webhookID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []WebhookDelivery{}
for rows.Next() {
var i WebhookDelivery
if err := rows.Scan(
&i.ID,
&i.WebhookID,
&i.Event,
&i.Payload,
&i.Status,
&i.ResponseStatus,
&i.ResponseBody,
&i.Attempts,
&i.DurationMs,
&i.CreatedAt,
&i.DeliveredAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateOutboundWebhook = `-- name: UpdateOutboundWebhook :exec
UPDATE outbound_webhooks
SET name = $1, url = $2, secret = $3, events = $4, headers = $5, enabled = $6, updated_at = $7
WHERE id = $8 AND user_id = $9
`
type UpdateOutboundWebhookParams struct {
Name string `json:"name"`
Url string `json:"url"`
Secret string `json:"secret"`
Events json.RawMessage `json:"events"`
Headers pqtype.NullRawMessage `json:"headers"`
Enabled bool `json:"enabled"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error {
_, err := q.db.ExecContext(ctx, updateOutboundWebhook,
arg.Name,
arg.Url,
arg.Secret,
arg.Events,
arg.Headers,
arg.Enabled,
arg.UpdatedAt,
arg.ID,
arg.UserID,
)
return err
}
const updateWebhookDeliveryResult = `-- name: UpdateWebhookDeliveryResult :exec
UPDATE webhook_deliveries
SET status = $1, response_status = $2, response_body = $3, attempts = $4, duration_ms = $5, delivered_at = $6
WHERE id = $7
`
type UpdateWebhookDeliveryResultParams struct {
Status string `json:"status"`
ResponseStatus sql.NullInt32 `json:"response_status"`
ResponseBody sql.NullString `json:"response_body"`
Attempts int32 `json:"attempts"`
DurationMs sql.NullInt32 `json:"duration_ms"`
DeliveredAt sql.NullTime `json:"delivered_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error {
_, err := q.db.ExecContext(ctx, updateWebhookDeliveryResult,
arg.Status,
arg.ResponseStatus,
arg.ResponseBody,
arg.Attempts,
arg.DurationMs,
arg.DeliveredAt,
arg.ID,
)
return err
}
+21
View File
@@ -129,6 +129,27 @@ func (q *Queue) Cancel(serviceID, deploymentID uuid.UUID) CancelState {
return NotFound 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 // Do runs fn through the queue and waits for completion — for synchronous
// endpoints (redeploy) that still must not overlap an in-flight deployment. // 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 // 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) { func TestEnqueuePosition(t *testing.T) {
q := New() q := New()
svc := uuid.New() svc := uuid.New()
+10
View File
@@ -134,11 +134,21 @@ func init() {
register(spec{"containr_templates_plan", "Dry-resolve a template: expanded expressions, service refs, missing variables. Args: id, variables (object).", "POST", "/templates/{id}/plan", []string{"id"}, nil, false}) register(spec{"containr_templates_plan", "Dry-resolve a template: expanded expressions, service refs, missing variables. Args: id, variables (object).", "POST", "/templates/{id}/plan", []string{"id"}, nil, false})
register(spec{"containr_templates_import_compose", "Convert docker-compose YAML into a v2 template graph config. Args: compose_yaml.", "POST", "/templates/import/compose", nil, []string{"compose_yaml"}, false}) register(spec{"containr_templates_import_compose", "Convert docker-compose YAML into a v2 template graph config. Args: compose_yaml.", "POST", "/templates/import/compose", nil, []string{"compose_yaml"}, false})
register(spec{"containr_templates_deploy_graph", "Deploy an ad-hoc service graph without a stored template. Args: project_id, config ({services:[...]}), variables, plan, region.", "POST", "/templates/deploy", nil, []string{"project_id", "config", "variables", "plan", "region"}, false}) register(spec{"containr_templates_deploy_graph", "Deploy an ad-hoc service graph without a stored template. Args: project_id, config ({services:[...]}), variables, plan, region.", "POST", "/templates/deploy", nil, []string{"project_id", "config", "variables", "plan", "region"}, false})
register(spec{"containr_projects_import_compose", "Deploy a docker-compose file straight into a project. Args: id (project), compose_yaml, variables, plan, region.", "POST", "/projects/{id}/import-compose", []string{"id"}, []string{"compose_yaml", "variables", "plan", "region"}, false})
// Notifications // Notifications
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false}) register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false}) register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false})
register(spec{"containr_webhooks_list", "List outbound webhooks", "GET", "/webhooks", nil, nil, false})
register(spec{"containr_webhooks_create", "Create an outbound webhook. Args: name, url, events (list, e.g. [\"service.*\",\"*\"]), secret (auto-generated if omitted), headers, enabled.", "POST", "/webhooks", nil, []string{"name", "url", "events", "secret", "headers", "enabled"}, false})
register(spec{"containr_webhooks_update", "Update an outbound webhook. Args: id, plus name/url/secret/events/headers/enabled to change.", "PATCH", "/webhooks/{id}", []string{"id"}, []string{"name", "url", "secret", "events", "headers", "enabled"}, false})
register(spec{"containr_webhooks_delete", "Delete an outbound webhook. Args: id. Requires confirm=true.", "DELETE", "/webhooks/{id}", []string{"id"}, nil, true})
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) // Nodes / agent tokens (admin)
register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false}) 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}) register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
@@ -0,0 +1,12 @@
-- +goose Up
-- Link managed databases provisioned by graph template deploys / compose
-- imports to their project so deleting the project cleans them up too.
ALTER TABLE database_services
ADD COLUMN IF NOT EXISTS project_id uuid NULL REFERENCES projects(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_database_services_project_id
ON database_services(project_id);
-- +goose Down
DROP INDEX IF EXISTS idx_database_services_project_id;
ALTER TABLE database_services DROP COLUMN IF EXISTS project_id;
@@ -0,0 +1,36 @@
-- +goose Up
-- Outbound webhooks: user-owned HTTP endpoints that receive signed
-- platform events (resource.action), with a delivery log.
CREATE TABLE IF NOT EXISTS outbound_webhooks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name varchar(255) NOT NULL,
url varchar(500) NOT NULL,
secret text NOT NULL DEFAULT '',
events jsonb NOT NULL DEFAULT '[]'::jsonb,
headers jsonb,
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
CREATE TABLE IF NOT EXISTS webhook_deliveries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
webhook_id uuid NOT NULL REFERENCES outbound_webhooks(id) ON DELETE CASCADE,
event varchar(255) NOT NULL,
payload jsonb,
status varchar(50) NOT NULL DEFAULT 'pending',
response_status integer,
response_body text,
attempts integer NOT NULL DEFAULT 0,
duration_ms integer,
created_at timestamptz DEFAULT now(),
delivered_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_outbound_webhooks_user ON outbound_webhooks(user_id);
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook ON webhook_deliveries(webhook_id, created_at DESC);
-- +goose Down
DROP TABLE IF EXISTS webhook_deliveries;
DROP TABLE IF EXISTS outbound_webhooks;
+12 -2
View File
@@ -35,8 +35,18 @@ FROM database_services
WHERE user_id = sqlc.arg(user_id) AND LOWER(name) = LOWER(sqlc.arg(name)); WHERE user_id = sqlc.arg(user_id) AND LOWER(name) = LOWER(sqlc.arg(name));
-- name: CreateDatabaseService :exec -- name: CreateDatabaseService :exec
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, created_at, updated_at) INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, project_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11); VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12);
-- name: ListDatabaseServicesByProject :many
SELECT database_services.*
FROM database_services
WHERE project_id = $1
ORDER BY created_at DESC;
-- name: DeleteDatabaseServiceByID :exec
DELETE FROM database_services
WHERE id = $1;
-- name: SetDatabaseServicePublicPortByIDAndUser :exec -- name: SetDatabaseServicePublicPortByIDAndUser :exec
UPDATE database_services UPDATE database_services
+49
View File
@@ -0,0 +1,49 @@
-- name: ListOutboundWebhooksByUser :many
SELECT outbound_webhooks.*
FROM outbound_webhooks
WHERE user_id = $1
ORDER BY created_at DESC;
-- name: GetOutboundWebhookByIDAndUser :one
SELECT outbound_webhooks.*
FROM outbound_webhooks
WHERE id = $1 AND user_id = $2;
-- name: ListEnabledOutboundWebhooks :many
SELECT outbound_webhooks.*
FROM outbound_webhooks
WHERE enabled;
-- name: CreateOutboundWebhook :exec
INSERT INTO outbound_webhooks (id, user_id, name, url, secret, events, headers, enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
-- name: UpdateOutboundWebhook :exec
UPDATE outbound_webhooks
SET name = $1, url = $2, secret = $3, events = $4, headers = $5, enabled = $6, updated_at = $7
WHERE id = $8 AND user_id = $9;
-- name: DeleteOutboundWebhookByIDAndUser :exec
DELETE FROM outbound_webhooks
WHERE id = $1 AND user_id = $2;
-- name: CreateWebhookDelivery :exec
INSERT INTO webhook_deliveries (id, webhook_id, event, payload, status, created_at)
VALUES ($1, $2, $3, $4, $5, $6);
-- name: UpdateWebhookDeliveryResult :exec
UPDATE webhook_deliveries
SET status = $1, response_status = $2, response_body = $3, attempts = $4, duration_ms = $5, delivered_at = $6
WHERE id = $7;
-- name: ListWebhookDeliveriesByWebhook :many
SELECT webhook_deliveries.*
FROM webhook_deliveries
WHERE webhook_id = $1
ORDER BY created_at DESC
LIMIT 100;
-- name: GetOutboundWebhookByID :one
SELECT outbound_webhooks.*
FROM outbound_webhooks
WHERE id = $1;
+28
View File
@@ -148,6 +148,7 @@ CREATE TABLE database_services (
external_ssl BOOLEAN NOT NULL DEFAULT false, external_ssl BOOLEAN NOT NULL DEFAULT false,
public_port BOOLEAN NOT NULL DEFAULT false, public_port BOOLEAN NOT NULL DEFAULT false,
backup_target_id VARCHAR(255), backup_target_id VARCHAR(255),
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
@@ -274,3 +275,30 @@ CREATE TABLE user_tokens (
revoked_at TIMESTAMP WITH TIME ZONE, revoked_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
CREATE TABLE outbound_webhooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
url VARCHAR(500) NOT NULL,
secret TEXT NOT NULL DEFAULT '',
events JSONB NOT NULL DEFAULT '[]'::jsonb,
headers JSONB,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE webhook_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
webhook_id UUID NOT NULL REFERENCES outbound_webhooks(id) ON DELETE CASCADE,
event VARCHAR(255) NOT NULL,
payload JSONB,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
response_status INTEGER,
response_body TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
delivered_at TIMESTAMP WITH TIME ZONE
);
+4
View File
@@ -9,6 +9,8 @@ import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetrics
import { BuildsPage } from '@/features/builds/pages/BuildsPage'; import { BuildsPage } from '@/features/builds/pages/BuildsPage';
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage'; import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage'; import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
import { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage';
import { OperationsPage } from '@/features/operations/pages/OperationsPage';
import { AdminPage } from '@/features/admin/pages/AdminPage'; import { AdminPage } from '@/features/admin/pages/AdminPage';
import { LandingPage } from '@/features/landing/LandingPage'; import { LandingPage } from '@/features/landing/LandingPage';
import { import {
@@ -141,6 +143,7 @@ export default function App() {
<Route path="/templates" element={<TemplatesPage />} /> <Route path="/templates" element={<TemplatesPage />} />
<Route path="/databases" element={<DatabasesPage />} /> <Route path="/databases" element={<DatabasesPage />} />
<Route path="/ha" element={<HighAvailabilityPage />} /> <Route path="/ha" element={<HighAvailabilityPage />} />
<Route path="/operations" element={<OperationsPage />} />
<Route path="/security" element={<SecurityPage />} /> <Route path="/security" element={<SecurityPage />} />
<Route path="/usage" element={<UsagePage />} /> <Route path="/usage" element={<UsagePage />} />
<Route path="/docs" element={<DocsPage />} /> <Route path="/docs" element={<DocsPage />} />
@@ -151,6 +154,7 @@ export default function App() {
<Route path="/people" element={<PeoplePage />} /> <Route path="/people" element={<PeoplePage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/audit-logs" element={<AuditLogsPage />} /> <Route path="/settings/audit-logs" element={<AuditLogsPage />} />
<Route path="/settings/webhooks" element={<WebhooksPage />} />
<Route path="/showcase" element={<ComponentShowcase />} /> <Route path="/showcase" element={<ComponentShowcase />} />
</Route> </Route>
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { import {
Activity,
Bell, Bell,
BookOpen, BookOpen,
ChartBar, ChartBar,
@@ -27,6 +28,7 @@ import {
UploadCloud, UploadCloud,
User, User,
Users, Users,
Webhook,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getCurrentUserProfile, getCurrentUserProfile,
@@ -56,6 +58,7 @@ const navSections: NavSection[] = [
{ {
title: 'Operate', title: 'Operate',
items: [ items: [
{ label: 'Operations', href: '/operations', icon: Activity, hint: 'Live jobs & failures' },
{ label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' }, { label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' },
{ label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' }, { label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' },
{ label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' }, { label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' },
@@ -67,6 +70,7 @@ const navSections: NavSection[] = [
items: [ items: [
{ label: 'People', href: '/people', icon: Users, hint: 'Members & access' }, { label: 'People', href: '/people', icon: Users, hint: 'Members & access' },
{ label: 'Audit Logs', href: '/settings/audit-logs', icon: ScrollText, hint: 'Activity trail' }, { label: 'Audit Logs', href: '/settings/audit-logs', icon: ScrollText, hint: 'Activity trail' },
{ label: 'Webhooks', href: '/settings/webhooks', icon: Webhook, hint: 'Signed event delivery' },
{ label: 'Settings', href: '/settings', icon: Settings, hint: 'Platform preferences' }, { label: 'Settings', href: '/settings', icon: Settings, hint: 'Platform preferences' },
{ label: 'Docs', href: '/docs', icon: BookOpen, hint: 'Guides & reference' }, { label: 'Docs', href: '/docs', icon: BookOpen, hint: 'Guides & reference' },
], ],
@@ -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>
);
}
@@ -0,0 +1,363 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
createWebhook,
deleteWebhook,
listWebhookDeliveries,
listWebhooks,
testWebhook,
updateWebhook,
type OutboundWebhook,
type WebhookDelivery,
} from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode';
import { formatRelative } from '@/lib/time';
import { DemoRestricted, useToast } from '@/shared/components';
import {
Webhook,
Plus,
Trash2,
Send,
Loader2,
ChevronDown,
ChevronRight,
Copy,
X,
} from 'lucide-react';
const inputClass =
'h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all';
const eventSuggestions = ['*', 'service.*', 'project.*', 'database.*', 'deployment.*', 'template.*'];
function statusClass(status: WebhookDelivery['status']): string {
switch (status) {
case 'success':
return 'text-[var(--success)]';
case 'failed':
return 'text-[var(--error)]';
default:
return 'text-[var(--warning)]';
}
}
function WebhookRow({
webhook,
onDelete,
onToggle,
onTest,
testing,
}: {
webhook: OutboundWebhook;
onDelete: () => void;
onToggle: () => void;
onTest: () => void;
testing: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const deliveriesQuery = useQuery({
queryKey: ['webhook-deliveries', webhook.id],
queryFn: () => listWebhookDeliveries(webhook.id),
enabled: expanded,
refetchInterval: expanded ? 10000 : false,
});
return (
<div className="panel p-4">
<div className="flex items-center gap-3">
<button onClick={() => setExpanded((v) => !v)} className="text-[var(--text-tertiary)]">
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--text-primary)]">{webhook.name}</span>
{!webhook.enabled && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-muted)]">
disabled
</span>
)}
</div>
<p className="text-xs text-[var(--text-muted)] mono truncate">{webhook.url}</p>
</div>
<div className="hidden md:flex gap-1">
{webhook.events.map((ev) => (
<span
key={ev}
className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] mono"
>
{ev}
</span>
))}
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={onTest}
disabled={testing || !webhook.enabled}
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--accent-primary)] disabled:opacity-40 transition-colors"
title="Send test ping"
>
{testing ? <Loader2 size={15} className="animate-spin" /> : <Send size={15} />}
</button>
<button
onClick={onToggle}
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors text-xs"
>
{webhook.enabled ? 'Disable' : 'Enable'}
</button>
<button
onClick={onDelete}
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--error)] transition-colors"
title="Delete webhook"
>
<Trash2 size={15} />
</button>
</div>
</div>
{expanded && (
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
<p className="text-[11px] uppercase tracking-wider text-[var(--text-muted)] mb-2">
Recent deliveries
</p>
{deliveriesQuery.isLoading ? (
<Loader2 size={14} className="animate-spin text-[var(--text-muted)]" />
) : (deliveriesQuery.data ?? []).length === 0 ? (
<p className="text-xs text-[var(--text-muted)]">No deliveries yet.</p>
) : (
<div className="space-y-1">
{(deliveriesQuery.data ?? []).slice(0, 20).map((d) => (
<div key={d.id} className="flex items-center gap-3 text-xs mono">
<span className={statusClass(d.status)}>{d.status}</span>
<span className="text-[var(--text-secondary)]">{d.event}</span>
{d.response_status != null && (
<span className="text-[var(--text-muted)]">HTTP {d.response_status}</span>
)}
<span className="text-[var(--text-muted)]">{d.attempts} tries</span>
{d.duration_ms != null && (
<span className="text-[var(--text-muted)]">{d.duration_ms}ms</span>
)}
<span className="text-[var(--text-muted)] ml-auto">{formatRelative(d.created_at)}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
export function WebhooksPage() {
const isDemoMode = useDemoMode();
const queryClient = useQueryClient();
const toast = useToast();
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({ name: '', url: '', events: [] as string[] });
const [secret, setSecret] = useState<string | null>(null);
const [testingId, setTestingId] = useState<string | null>(null);
const webhooksQuery = useQuery({
queryKey: ['webhooks'],
queryFn: listWebhooks,
enabled: !isDemoMode,
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['webhooks'] });
const createMutation = useMutation({
mutationFn: () => createWebhook(form),
onSuccess: (created) => {
invalidate();
setCreating(false);
setForm({ name: '', url: '', events: [] });
if (created.secret) {
setSecret(created.secret);
}
},
onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Failed to create webhook', 'error'),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteWebhook(id),
onSuccess: invalidate,
onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Delete failed', 'error'),
});
const toggleMutation = useMutation({
mutationFn: (w: OutboundWebhook) => updateWebhook(w.id, { enabled: !w.enabled }),
onSuccess: invalidate,
});
const testMutation = useMutation({
mutationFn: (id: string) => testWebhook(id),
onMutate: (id) => setTestingId(id),
onSettled: () => setTestingId(null),
onSuccess: () => toast.showToast('Test ping queued', 'info'),
onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Test failed', 'error'),
});
if (isDemoMode) {
return <DemoRestricted feature="Outbound webhooks" />;
}
const toggleEvent = (ev: string) =>
setForm((f) => ({
...f,
events: f.events.includes(ev) ? f.events.filter((e) => e !== ev) : [...f.events, ev],
}));
return (
<div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]">
<div className="w-full px-8 py-5 flex items-center justify-between">
<div>
<h1 className="v-title">Webhooks<span className="v-cursor">_</span></h1>
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
Signed HTTP delivery for every platform event — deploys, restarts, deletes
</p>
</div>
<button
onClick={() => setCreating(true)}
className="flex items-center gap-1.5 h-9 px-4 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)]"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={15} />
New webhook
</button>
</div>
</div>
<div className="w-full px-8 py-6 space-y-4 max-w-4xl">
{secret && (
<div className="panel-soft p-4 flex items-center gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--text-primary)]">Signing secret — shown once</p>
<p className="text-xs text-[var(--text-muted)]">
Verify deliveries with <span className="mono">X-Containr-Signature</span>
</p>
<code className="block mt-2 text-xs mono text-[var(--accent-primary)] break-all">{secret}</code>
</div>
<button
onClick={() => {
navigator.clipboard?.writeText(secret);
toast.showToast('Secret copied', 'success');
}}
className="p-2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
>
<Copy size={15} />
</button>
<button onClick={() => setSecret(null)} className="p-2 text-[var(--text-tertiary)]">
<X size={15} />
</button>
</div>
)}
{webhooksQuery.isLoading ? (
<Loader2 size={18} className="animate-spin text-[var(--text-muted)]" />
) : (webhooksQuery.data ?? []).length === 0 ? (
<div className="panel p-8 text-center">
<Webhook size={28} className="mx-auto text-[var(--text-muted)] mb-3" />
<p className="text-sm text-[var(--text-secondary)]">No webhooks yet.</p>
<p className="text-xs text-[var(--text-muted)] mt-1">
Subscribe an HTTP endpoint to platform events like <span className="mono">service.*</span> or{' '}
<span className="mono">deployment.fail</span>.
</p>
</div>
) : (
(webhooksQuery.data ?? []).map((w) => (
<WebhookRow
key={w.id}
webhook={w}
testing={testingId === w.id}
onTest={() => testMutation.mutate(w.id)}
onToggle={() => toggleMutation.mutate(w)}
onDelete={() => {
if (window.confirm(`Delete webhook "${w.name}"?`)) {
deleteMutation.mutate(w.id);
}
}}
/>
))
)}
</div>
{creating && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreating(false)} />
<div className="relative w-full max-w-md panel p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">New webhook</h3>
<div className="space-y-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Name
</label>
<input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
className={`${inputClass} w-full`}
placeholder="deploy-alerts"
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Endpoint URL
</label>
<input
value={form.url}
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
className={`${inputClass} w-full mono`}
placeholder="https://example.com/hooks/containr"
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Events
</label>
<div className="flex flex-wrap gap-2">
{eventSuggestions.map((ev) => (
<button
key={ev}
onClick={() => toggleEvent(ev)}
className={`px-2.5 py-1 rounded-[var(--radius-md)] border text-xs mono transition-colors ${
form.events.includes(ev)
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)]'
}`}
>
{ev}
</button>
))}
</div>
<p className="mt-2 text-[11px] text-[var(--text-muted)]">
Events are <span className="mono">resource.action</span> — wildcards like{' '}
<span className="mono">service.*</span> match the whole resource.
</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<button
onClick={() => setCreating(false)}
className="h-10 px-4 rounded-[var(--radius-md)] text-sm text-[var(--text-secondary)]"
>
Cancel
</button>
<button
onClick={() => createMutation.mutate()}
disabled={
createMutation.isPending ||
!form.name.trim() ||
!form.url.trim() ||
form.events.length === 0
}
className="h-10 px-5 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50 flex items-center gap-2"
style={{ background: 'var(--accent-primary)' }}
>
{createMutation.isPending && <Loader2 size={14} className="animate-spin" />}
Create
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -8,6 +8,7 @@ import {
createManagedDatabase, createManagedDatabase,
createService, createService,
getProjectById, getProjectById,
importComposeIntoProject,
listConnectedGitRepositories, listConnectedGitRepositories,
listGitBranches, listGitBranches,
listGitProviders, listGitProviders,
@@ -45,6 +46,7 @@ import {
Search, Search,
GitBranch, GitBranch,
Container, Container,
FileUp,
} from 'lucide-react'; } from 'lucide-react';
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings'; type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
@@ -473,6 +475,8 @@ export function ProjectWorkspacePage() {
const signedIn = isDemoMode || Boolean(sessionQuery.data); const signedIn = isDemoMode || Boolean(sessionQuery.data);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [composeYaml, setComposeYaml] = useState('');
const openAddService = () => { const openAddService = () => {
if (!signedIn) { if (!signedIn) {
@@ -556,6 +560,25 @@ export function ProjectWorkspacePage() {
}, },
}); });
const composeImportMutation = useMutation({
mutationFn: (yaml: string) => importComposeIntoProject(projectId, yaml),
onSuccess: (result) => {
setImportOpen(false);
setComposeYaml('');
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
const count = result.created.length;
const warn = result.warnings.length > 0 ? ` (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})` : '';
toast.showToast(`Imported ${count} resource${count === 1 ? '' : 's'} from compose${warn}`, 'success');
for (const warning of result.warnings) {
toast.showToast(warning, 'warning');
}
},
onError: (err) => {
toast.showToast(err instanceof Error ? err.message : 'Compose import failed', 'error');
},
});
const project = useMemo( const project = useMemo(
() => (isDemoMode ? getDemoProjectById(projectId) : projectQuery.data), () => (isDemoMode ? getDemoProjectById(projectId) : projectQuery.data),
[isDemoMode, projectId, projectQuery.data], [isDemoMode, projectId, projectQuery.data],
@@ -721,14 +744,24 @@ export function ProjectWorkspacePage() {
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd> <kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
</button> </button>
{!isDemoMode && signedIn && ( {!isDemoMode && signedIn && (
<button <>
onClick={openAddService} <button
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all" onClick={() => setImportOpen(true)}
style={{ background: 'var(--accent-primary)' }} className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] hover:text-[var(--text-primary)] transition-colors"
> title="Deploy a docker-compose file into this project"
<Plus size={14} /> >
<span className="hidden sm:inline">Add Service</span> <FileUp size={14} />
</button> <span className="hidden sm:inline">Compose</span>
</button>
<button
onClick={openAddService}
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={14} />
<span className="hidden sm:inline">Add Service</span>
</button>
</>
)} )}
</div> </div>
</div> </div>
@@ -973,6 +1006,49 @@ export function ProjectWorkspacePage() {
/> />
)} )}
{/* Compose Import */}
{importOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setImportOpen(false)} />
<div className="relative w-full max-w-2xl panel p-6">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<FileUp size={20} className="text-[var(--accent-primary)]" />
</div>
<h3 className="text-xl font-semibold text-[var(--text-primary)]">Import docker-compose</h3>
</div>
<p className="text-sm text-[var(--text-secondary)]">
Paste a docker-compose.yml. Services deploy in dependency order; postgres, mysql, redis, mongo,
mariadb, clickhouse and dragonfly images become managed databases reachable by service name.
</p>
<textarea
value={composeYaml}
onChange={(e) => setComposeYaml(e.target.value)}
spellCheck={false}
className="mt-5 w-full h-72 px-4 py-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm resize-none"
placeholder={'services:\n web:\n image: nginx:alpine\n ports:\n - "80"\n db:\n image: postgres:16-alpine\n environment:\n POSTGRES_PASSWORD: secret'}
/>
<div className="mt-5 flex justify-end gap-2">
<button
onClick={() => setImportOpen(false)}
className="h-10 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Cancel
</button>
<button
onClick={() => composeImportMutation.mutate(composeYaml)}
disabled={composeImportMutation.isPending || composeYaml.trim().length === 0}
className="h-10 px-5 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50 transition-all flex items-center gap-2"
style={{ background: 'var(--accent-primary)' }}
>
{composeImportMutation.isPending && <Loader2 size={14} className="animate-spin" />}
Deploy stack
</button>
</div>
</div>
</div>
)}
{/* Command Palette */} {/* Command Palette */}
<CommandPalette <CommandPalette
open={commandPaletteOpen} open={commandPaletteOpen}
+423
View File
@@ -3190,6 +3190,90 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/projects/{id}/import-compose": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Deploy a docker-compose file into a project
* @description Parses a docker-compose YAML document and deploys it directly into
* the project — no template row. Well-known database images
* (postgres, mysql, redis, mongo, mariadb, clickhouse, dragonfly)
* become managed databases attached to the project network under
* their service name as DNS alias; other services are created and
* deployed in dependency order. Returns the created resources plus
* any import warnings (unsupported keys, missing values).
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description Full docker-compose YAML document (max 512 KiB) */
compose_yaml: string;
variables?: {
[key: string]: string;
};
plan?: string;
region?: string;
};
};
};
responses: {
/** @description Stack deployed */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
resource?: string;
created?: Record<string, never>[];
warnings?: string[];
};
};
};
/** @description Invalid compose YAML or unresolved references */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Access denied */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Project not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/agent-tokens": { "/agent-tokens": {
parameters: { parameters: {
query?: never; query?: never;
@@ -3423,6 +3507,308 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/webhooks": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List outbound webhooks */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Webhooks for the caller (secrets masked) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
webhooks?: components["schemas"]["OutboundWebhook"][];
};
};
};
};
};
put?: never;
/**
* Create an outbound webhook
* @description Receives signed POST deliveries for every audited action matching
* the event list — "resource.action" names, "resource.*" wildcards,
* or "*" for everything. The secret is returned once; deliveries
* carry X-Containr-Signature: sha256=<hmac> over the raw body.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
name: string;
url: string;
/** @description Signing secret (auto-generated when omitted) */
secret?: string;
/**
* @example [
* "service.*",
* "deployment.fail"
* ]
*/
events: string[];
headers?: {
[key: string]: string;
};
/** @default true */
enabled?: boolean;
};
};
};
responses: {
/** @description Webhook created (secret revealed once) */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OutboundWebhook"];
};
};
/** @description Invalid name, URL, or empty event list */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/webhooks/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete a webhook */
delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Deleted */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MessageResponse"];
};
};
};
};
options?: never;
head?: never;
/** Update a webhook */
patch: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
name?: string;
url?: string;
secret?: string;
events?: string[];
headers?: {
[key: string]: string;
};
enabled?: boolean;
};
};
};
responses: {
/** @description Updated webhook */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OutboundWebhook"];
};
};
/** @description Webhook not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
trace?: never;
};
"/webhooks/{id}/deliveries": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List delivery attempts for a webhook */
get: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Recent deliveries (newest first, max 100) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
deliveries?: components["schemas"]["WebhookDelivery"][];
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/webhooks/{id}/test": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Queue a test ping delivery */
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Delivery queued */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MessageResponse"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
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": { "/audit-logs": {
parameters: { parameters: {
query?: never; query?: never;
@@ -9839,6 +10225,43 @@ export interface components {
notifications?: components["schemas"]["Notification"][]; notifications?: components["schemas"]["Notification"][];
unread?: number; unread?: number;
}; };
OutboundWebhook: {
/** Format: uuid */
id?: string;
name?: string;
url?: string;
/** @description Only present on create/update responses */
secret?: string;
events?: string[];
headers?: {
[key: string]: string;
};
enabled?: boolean;
/** Format: date-time */
created_at?: string;
/** Format: date-time */
updated_at?: string;
};
WebhookDelivery: {
/** Format: uuid */
id?: string;
/** Format: uuid */
webhook_id?: string;
event?: string;
payload?: {
[key: string]: unknown;
};
/** @enum {string} */
status?: "pending" | "success" | "failed";
response_status?: number | null;
response_body?: string | null;
attempts?: number;
duration_ms?: number | null;
/** Format: date-time */
created_at?: string;
/** Format: date-time */
delivered_at?: string | null;
};
AuditLogListResponse: { AuditLogListResponse: {
audit_logs?: components["schemas"]["AuditLog"][]; audit_logs?: components["schemas"]["AuditLog"][];
}; };
+1 -1
View File
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
"path": "guides/CLI.md", "path": "guides/CLI.md",
"section": "guides", "section": "guides",
"title": "Containr CLI", "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)\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\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\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\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 <id>\ncontainr up --project <id> --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.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --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 <id>` to apply immediately.\n\n`voLine 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", "path": "guides/CLOUDFLARE_SETUP.md",
+118
View File
@@ -1748,6 +1748,124 @@ export async function importComposeTemplate(composeYaml: string): Promise<Compos
return { config: payload.config, warnings: payload.warnings ?? [] }; return { config: payload.config, warnings: payload.warnings ?? [] };
} }
export type ProjectComposeImportResult = {
created: Array<{ key: string; kind: string; id: string; name: string; deployment_id?: string }>;
warnings: string[];
};
export async function importComposeIntoProject(
projectId: string,
composeYaml: string,
): Promise<ProjectComposeImportResult> {
const payload = await requestJson<{
created?: ProjectComposeImportResult['created'];
warnings?: string[];
}>(`/projects/${projectId}/import-compose`, {
method: 'POST',
body: JSON.stringify({ compose_yaml: composeYaml }),
});
return { created: payload.created ?? [], warnings: payload.warnings ?? [] };
}
export type OutboundWebhook = {
id: string;
name: string;
url: string;
secret?: string;
events: string[];
headers?: Record<string, string>;
enabled: boolean;
created_at: string;
updated_at: string;
};
export type WebhookDelivery = {
id: string;
webhook_id: string;
event: string;
status: 'pending' | 'success' | 'failed';
response_status?: number | null;
response_body?: string | null;
attempts: number;
duration_ms?: number | null;
created_at: string;
delivered_at?: string | null;
};
export async function listWebhooks(): Promise<OutboundWebhook[]> {
const payload = await requestJson<{ webhooks?: OutboundWebhook[] }>('/webhooks');
return payload.webhooks ?? [];
}
export type WebhookInput = {
name: string;
url: string;
events: string[];
secret?: string;
headers?: Record<string, string>;
enabled?: boolean;
};
export async function createWebhook(input: WebhookInput): Promise<OutboundWebhook> {
return requestJson<OutboundWebhook>('/webhooks', { method: 'POST', body: JSON.stringify(input) });
}
export async function updateWebhook(id: string, input: Partial<WebhookInput>): Promise<OutboundWebhook> {
return requestJson<OutboundWebhook>(`/webhooks/${id}`, { method: 'PATCH', body: JSON.stringify(input) });
}
export async function deleteWebhook(id: string): Promise<void> {
await requestJson(`/webhooks/${id}`, { method: 'DELETE' });
}
export async function listWebhookDeliveries(id: string): Promise<WebhookDelivery[]> {
const payload = await requestJson<{ deliveries?: WebhookDelivery[] }>(`/webhooks/${id}/deliveries`);
return payload.deliveries ?? [];
}
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 { export function serviceStatusClass(status: ServiceStatus): string {
switch (status) { switch (status) {
case 'running': case 'running':
+9 -6
View File
@@ -19,10 +19,11 @@ function AuthGate() {
useEffect(() => { useEffect(() => {
if (!ready) return; if (!ready) return;
const inLogin = segments[0] === 'login'; const inLogin = segments[0] === 'login';
if (!api && !inLogin) router.replace('/login'); if (api && inLogin) router.replace('/');
else if (api && inLogin) router.replace('/');
}, [ready, api, segments, router]); }, [ready, api, segments, router]);
if (!ready) return null;
return ( return (
<Stack <Stack
screenOptions={{ screenOptions={{
@@ -31,11 +32,13 @@ function AuthGate() {
contentStyle: { backgroundColor: colors.bg }, contentStyle: { backgroundColor: colors.bg },
}} }}
> >
<Stack.Protected guard={!!api}>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="project/[id]" options={{ title: 'Project' }} />
<Stack.Screen name="service/[id]" options={{ title: 'Service' }} />
<Stack.Screen name="service/logs" options={{ title: 'Logs' }} />
</Stack.Protected>
<Stack.Screen name="login" options={{ headerShown: false }} /> <Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="project/[id]" options={{ title: 'Project' }} />
<Stack.Screen name="service/[id]" options={{ title: 'Service' }} />
<Stack.Screen name="service/logs" options={{ title: 'Logs' }} />
</Stack> </Stack>
); );
} }
+4 -1
View File
@@ -1,5 +1,8 @@
import { Redirect } from 'expo-router'; import { Redirect } from 'expo-router';
import { useSession } from '../session';
export default function Index() { export default function Index() {
return <Redirect href="/(tabs)" />; const { ready, api } = useSession();
if (!ready) return null;
return <Redirect href={api ? '/(tabs)' : '/login'} />;
} }
+363
View File
@@ -2149,6 +2149,72 @@ paths:
deployment_id: deployment_id:
type: string type: string
/projects/{id}/import-compose:
post:
tags:
- Projects
summary: Deploy a docker-compose file into a project
description: |
Parses a docker-compose YAML document and deploys it directly into
the project — no template row. Well-known database images
(postgres, mysql, redis, mongo, mariadb, clickhouse, dragonfly)
become managed databases attached to the project network under
their service name as DNS alias; other services are created and
deployed in dependency order. Returns the created resources plus
any import warnings (unsupported keys, missing values).
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- compose_yaml
properties:
compose_yaml:
type: string
description: Full docker-compose YAML document (max 512 KiB)
variables:
type: object
additionalProperties:
type: string
plan:
type: string
region:
type: string
responses:
'201':
description: Stack deployed
content:
application/json:
schema:
type: object
properties:
resource:
type: string
created:
type: array
items:
type: object
warnings:
type: array
items:
type: string
'400':
description: Invalid compose YAML or unresolved references
'403':
description: Access denied
'404':
description: Project not found
# Agent Onboarding Tokens # Agent Onboarding Tokens
/agent-tokens: /agent-tokens:
post: post:
@@ -2284,6 +2350,237 @@ paths:
schema: schema:
$ref: '#/components/schemas/MessageResponse' $ref: '#/components/schemas/MessageResponse'
# Outbound Webhooks
/webhooks:
get:
tags:
- Webhooks
summary: List outbound webhooks
security:
- bearerAuth: []
responses:
'200':
description: Webhooks for the caller (secrets masked)
content:
application/json:
schema:
type: object
properties:
webhooks:
type: array
items:
$ref: '#/components/schemas/OutboundWebhook'
post:
tags:
- Webhooks
summary: Create an outbound webhook
description: |
Receives signed POST deliveries for every audited action matching
the event list — "resource.action" names, "resource.*" wildcards,
or "*" for everything. The secret is returned once; deliveries
carry X-Containr-Signature: sha256=<hmac> over the raw body.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
- url
- events
properties:
name:
type: string
url:
type: string
secret:
type: string
description: Signing secret (auto-generated when omitted)
events:
type: array
items:
type: string
example: ["service.*", "deployment.fail"]
headers:
type: object
additionalProperties:
type: string
enabled:
type: boolean
default: true
responses:
'201':
description: Webhook created (secret revealed once)
content:
application/json:
schema:
$ref: '#/components/schemas/OutboundWebhook'
'400':
description: Invalid name, URL, or empty event list
/webhooks/{id}:
patch:
tags:
- Webhooks
summary: Update a webhook
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
url:
type: string
secret:
type: string
events:
type: array
items:
type: string
headers:
type: object
additionalProperties:
type: string
enabled:
type: boolean
responses:
'200':
description: Updated webhook
content:
application/json:
schema:
$ref: '#/components/schemas/OutboundWebhook'
'404':
description: Webhook not found
delete:
tags:
- Webhooks
summary: Delete a webhook
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Deleted
content:
application/json:
schema:
$ref: '#/components/schemas/MessageResponse'
/webhooks/{id}/deliveries:
get:
tags:
- Webhooks
summary: List delivery attempts for a webhook
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Recent deliveries (newest first, max 100)
content:
application/json:
schema:
type: object
properties:
deliveries:
type: array
items:
$ref: '#/components/schemas/WebhookDelivery'
/webhooks/{id}/test:
post:
tags:
- Webhooks
summary: Queue a test ping delivery
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'202':
description: Delivery queued
content:
application/json:
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
/audit-logs: /audit-logs:
get: get:
@@ -6887,6 +7184,72 @@ components:
unread: unread:
type: integer type: integer
OutboundWebhook:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
url:
type: string
secret:
type: string
description: Only present on create/update responses
events:
type: array
items:
type: string
headers:
type: object
additionalProperties:
type: string
enabled:
type: boolean
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
WebhookDelivery:
type: object
properties:
id:
type: string
format: uuid
webhook_id:
type: string
format: uuid
event:
type: string
payload:
type: object
additionalProperties: true
status:
type: string
enum: [pending, success, failed]
response_status:
type: integer
nullable: true
response_body:
type: string
nullable: true
attempts:
type: integer
duration_ms:
type: integer
nullable: true
created_at:
type: string
format: date-time
delivered_at:
type: string
format: date-time
nullable: true
AuditLogListResponse: AuditLogListResponse:
type: object type: object
properties: properties:
+11
View File
@@ -66,6 +66,11 @@ templates list|get|create|delete|deploy|plan|import-compose|deploy-graph
import-compose <compose.yml|-> converts a compose file into import-compose <compose.yml|-> converts a compose file into
a v2 graph config; deploy-graph <project-id> <config.json|-> a v2 graph config; deploy-graph <project-id> <config.json|->
deploys an ad-hoc service graph deploys an ad-hoc service graph
import compose <project-id> <compose.yml|->
deploys a docker-compose file straight into a project —
no template row. DB images become managed databases
attached under their service name; --var K=V overrides
template variables
nodes list|get|delete|tokens node agents + onboarding tokens (admin) nodes list|get|delete|tokens node agents + onboarding tokens (admin)
scaling status|policies|services|scale scaling status|policies|services|scale
ha status|alerts|health|policies|enable|disable|failover ha status|alerts|health|policies|enable|disable|failover
@@ -73,6 +78,12 @@ security scan|vulnerabilities|metrics
gateway services|keys|analytics (admin) gateway services|keys|analytics (admin)
admin overview|users|settings|audit-logs admin overview|users|settings|audit-logs
notifications list|read notifications list|read
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 up deploy the current directory
``` ```