Compare commits

...
Author SHA1 Message Date
Tomas DvorakandDevin a634e6960c feat: interactive terminal — WS-bridged docker exec PTY
GET /services/:id/terminal upgrades to a WebSocket attached to a
`docker exec` PTY in the service's running replica. JSON frames both
ways: stdin/resize out, stdout/exit/error in. Shell probe tries each
candidate (bash→sh) since `command -v` needs a shell to run; distroless
images get a clean "no shell" error instead of an OCI failure dump.

- docker.Client gains ExecAttach + ExecResize wrappers
- Frontend: xterm.js replaces the one-off console section — live PTY,
  fit-addon resizes forwarded over the socket
- CLI: `containr shell <id>` bridges the local TTY (raw mode, SIGWINCH
  resize forwarding); `containr exec` keeps the one-off command path
- Access mirrors service logs: project manage scope required

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 11:03:20 +02:00
Tomáš DvořákandDevin 5eac4a382b feat: activity feed — enriched audit events + global/project timelines (#45)
* feat(branding): white-label — public /branding, admin write, shell theming

app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.

useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(invites): team invite links — admin create/revoke, public accept

Admins mint single-use invite links (sha256-hashed token at rest,
optional email binding, configurable TTL). Public endpoints let the
accept page validate the link and register — working even while
public signup is disabled.

- migration + sqlc queries/model for user_invites
- POST /admin/invites (returns one-time token+url), GET list, DELETE revoke
- GET /auth/invites/:token public validation, POST /auth/accept-invite
- CLI containr invites list|create|revoke; MCP invites tools
- /auth/accept-invite page (expired/used/invalid states, bound-email lock)
- Admin page Team invites section: create, copy link, revoke
- OpenAPI + generated types + CLI guide

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(admin): impersonate user — 15-min token, audit-logged

POST /admin/users/:id/impersonate mints a short-lived bearer token
(impersonated_by claim) for support/debugging. Admin accounts cannot
be impersonated; every mint is audit-logged with actor + target.

- CLI: containr admin impersonate <user-id> (works via CONTAINR_TOKEN)
- MCP: containr_admin_impersonate
- Admin UI: per-user Impersonate action → token modal with usage hint
- OpenAPI + generated types

Verified live: token resolves to target, admin target 403, audit row.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(git): GitHub App manifest self-provisioning + app-level webhook

Admins create the instance GitHub App in-browser instead of hand-
setting GITHUB_APP_* env vars:

- POST /admin/git/github-app/manifest builds the manifest + URL;
  the frontend auto-submits it to github.com
- /git/github-app/callback page exchanges the code via
  POST /admin/git/github-app/convert; credentials persist in
  app_settings (precedence over env)
- GET /admin/git/github-app reports configured/source
- POST /api/git/github-app/webhook — single app-level push receiver,
  HMAC-verified, dispatches via extracted dispatchPushToServices
- install-url + installation-token paths resolve settings→env
- CLI: containr admin github-app status|manifest|convert; MCP tools
- Git providers UI shows "Set up GitHub App" for admins when
  unconfigured

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat: activity feed — enriched audit events, global + per-project timelines

audit_logs gains severity/category/label columns derived from
resource+action at write time; legacy rows are classified on read and
backfilled at boot in batches.

- GET /activity: global feed scoped to the caller's visible projects
  (admins see all); severity/category/resource/project_id/since
  filters, page+limit
- GET /projects/:id/activity: timeline correlated to the project's
  services, deployments, databases and the project record; access
  mirrors project read rules
- Retention worker prunes rows older than AUDIT_RETENTION_DAYS
  (default 90, 0 disables), running once at boot then daily
- project.create/delete now audited so timelines aren't empty
- CLI `containr activity` with filter flags; MCP tools
  containr_activity_list + containr_project_activity
- Frontend: /activity page (severity chips, icon per category) under
  Operate nav; project workspace gains an Activity view tab
- OpenAPI + generated types + CLI docs

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 10:52:15 +02:00
Tomáš DvořákandDevin 00e69b3733 feat(git): GitHub App manifest self-provisioning + app-level webhook (#44)
* feat(branding): white-label — public /branding, admin write, shell theming

app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.

useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(invites): team invite links — admin create/revoke, public accept

Admins mint single-use invite links (sha256-hashed token at rest,
optional email binding, configurable TTL). Public endpoints let the
accept page validate the link and register — working even while
public signup is disabled.

- migration + sqlc queries/model for user_invites
- POST /admin/invites (returns one-time token+url), GET list, DELETE revoke
- GET /auth/invites/:token public validation, POST /auth/accept-invite
- CLI containr invites list|create|revoke; MCP invites tools
- /auth/accept-invite page (expired/used/invalid states, bound-email lock)
- Admin page Team invites section: create, copy link, revoke
- OpenAPI + generated types + CLI guide

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(admin): impersonate user — 15-min token, audit-logged

POST /admin/users/:id/impersonate mints a short-lived bearer token
(impersonated_by claim) for support/debugging. Admin accounts cannot
be impersonated; every mint is audit-logged with actor + target.

- CLI: containr admin impersonate <user-id> (works via CONTAINR_TOKEN)
- MCP: containr_admin_impersonate
- Admin UI: per-user Impersonate action → token modal with usage hint
- OpenAPI + generated types

Verified live: token resolves to target, admin target 403, audit row.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(git): GitHub App manifest self-provisioning + app-level webhook

Admins create the instance GitHub App in-browser instead of hand-
setting GITHUB_APP_* env vars:

- POST /admin/git/github-app/manifest builds the manifest + URL;
  the frontend auto-submits it to github.com
- /git/github-app/callback page exchanges the code via
  POST /admin/git/github-app/convert; credentials persist in
  app_settings (precedence over env)
- GET /admin/git/github-app reports configured/source
- POST /api/git/github-app/webhook — single app-level push receiver,
  HMAC-verified, dispatches via extracted dispatchPushToServices
- install-url + installation-token paths resolve settings→env
- CLI: containr admin github-app status|manifest|convert; MCP tools
- Git providers UI shows "Set up GitHub App" for admins when
  unconfigured

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 10:43:12 +02:00
Tomáš DvořákandDevin 50e39fe140 feat(invites): team invite links — admin create/revoke, public accept (#42)
* feat(branding): white-label — public /branding, admin write, shell theming

app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.

useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(invites): team invite links — admin create/revoke, public accept

Admins mint single-use invite links (sha256-hashed token at rest,
optional email binding, configurable TTL). Public endpoints let the
accept page validate the link and register — working even while
public signup is disabled.

- migration + sqlc queries/model for user_invites
- POST /admin/invites (returns one-time token+url), GET list, DELETE revoke
- GET /auth/invites/:token public validation, POST /auth/accept-invite
- CLI containr invites list|create|revoke; MCP invites tools
- /auth/accept-invite page (expired/used/invalid states, bound-email lock)
- Admin page Team invites section: create, copy link, revoke
- OpenAPI + generated types + CLI guide

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 10:35:12 +02:00
Tomáš DvořákandDevin b47ced0135 feat(branding): white-label — public /branding, admin write, shell theming (#41)
app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.

useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 10:22:58 +02:00
Tomáš DvořákandDevin 9408464f12 feat(banners): instance announcements — admin CRUD, top bar, CLI (#39)
banners table (title/body/level/active/dismissible/schedule window).
GET /banners/active for signed-in users; admin CRUD under /admin/banners.
BannerBar renders above the header with per-banner dismissal
(localStorage); admin console gains an Announcements section;
`containr banners` manages them and `auth status` prints active ones.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 10:10:18 +02:00
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
78 changed files with 9144 additions and 174 deletions

No files matched your search

+3
View File
@@ -159,3 +159,6 @@ DOCKER_GID=0
# Optional: trust reverse proxy CIDR (default local Docker bridge used by Traefik) # Optional: trust reverse proxy CIDR (default local Docker bridge used by Traefik)
TRUSTED_PROXY_CIDR=172.20.0.0/16 TRUSTED_PROXY_CIDR=172.20.0.0/16
# Optional: audit log retention in days (default 90; 0 disables pruning)
# AUDIT_RETENTION_DAYS=90
+60 -27
View File
@@ -86,10 +86,10 @@ 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) | ✅ `/activity` + project view | — |
| Admin metrics dashboard (users/servers/queued/failed counts) | 🟡 `/admin` exists | Extend | | Admin metrics dashboard (users/servers/queued/failed counts) | 🟡 `/admin` exists | Extend |
| **Impersonate user** | ❌ | Admin support tool | | **Impersonate user** | ❌ | Admin support tool |
| In-app docs (content-collections markdown) | ✅ `/docs` | — | | In-app docs (content-collections markdown) | ✅ `/docs` | — |
@@ -278,29 +278,60 @@ 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;
- [ ] **Activity feed** — enrich `audit_logs` with `severity category CLI + MCP + `/settings/webhooks` page with delivery log + test ping.
icon label metadata`; per-project timeline + global feed; TTL job. - [x] **Activity feed** — `audit_logs` enriched with `severity`
- [ ] **Banners** — `banners(title, body, level, scope, active)`; top (`info|success|warning|error`), `category`, human `label`; derived at
bar in UI + surfaced to CLI on `auth status`. write time, classified on read for legacy rows, boot-time backfill.
- [ ] **White-label** — `app_settings.branding`: product name, logos, `GET /activity` (global, visibility-scoped, filters + `since`) and
favicon, accent override, docs/support links. `GET /api/v1/branding` `GET /projects/:id/activity` (owner/member/approved/admin). Frontend:
public + cached. **Containr/Vertice remains default;** this is for `/activity` page with severity chips + project workspace `Activity`
self-hosters who rebrand their instance. view. `containr activity` + `containr_activity_list`/`containr_project_activity`
- [ ] **Operations page** — unified view of running/queued/failed jobs MCP tools. Retention worker prunes > `AUDIT_RETENTION_DAYS` (default 90).
(deploys, builds, backups, cron, scans, agent commands) — dflow's - [x] **Banners** — `banners` table with title/body/level/active/
bubble panel as a real page; flush/stuck-job tools once queue exists. dismissible/schedule window. `GET /banners/active` for signed-in
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`); users; admin CRUD at `/admin/banners`. `BannerBar` renders above the
`nodes/:id/terminal` via agent (dflow's wetty without the extra header (per-banner dismiss via localStorage); admin console manages
container). them; `containr banners` CLI + `auth status` surfaces active ones.
- [ ] **Team invites** — `POST /invites` → signed link → join flow; - [x] **White-label** — `app_settings` `branding.*` keys: product name,
replaces manual-only user creation. logo/favicon URL, accent color, docs/support links. Public
- [ ] **Impersonate user** — admin support tool, audit-logged. `GET /api/v1/branding` (defaults = Containr); admin writes via
- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest` `PUT /settings`. `useBranding()` applies title/accent/favicon;
returns manifest + redirect; instance self-provisions its App `BrandWordmark` replaces hardcoded marks; admin Platform section
(dflow pattern — removes the env-setup barrier). edits all fields. MCP `containr_branding_get` + `settings_update`.
- [x] **Operations page** — `GET /operations` aggregates active
deployments, in-memory deploy-queue depth (`Queue.Snapshot()`),
24h failures, cron runs and backups scoped to the caller's projects.
UI at `/operations` (Operate nav) with live refetch; `containr
operations` CLI + `containr_operations_get` MCP. Remaining: agent
commands once node fleet lands; flush/stuck-job tools.
- [x] **Interactive terminal** — `GET /services/:id/terminal` upgrades
to a WebSocket bridged to a `docker exec` PTY (bash→sh fallback,
`TERM=xterm-256color`, resize frames). xterm.js Console section on the
service page; `containr shell <id>` bridges a local TTY; distroless
images get a clean "no shell" error. Remaining: `nodes/:id/terminal`
via agent.
- [x] **Team invites** — `POST /admin/invites` returns a one-time
link (sha256-hashed token at rest, optional email binding, TTL);
public `GET /auth/invites/:token` validates and
`POST /auth/accept-invite` registers — works while public signup is
closed. `/auth/accept-invite` page, admin manage/revoke UI,
`containr invites` CLI + MCP tools.
- [x] **Impersonate user** — `POST /admin/users/:id/impersonate`
mints a 15-minute bearer token (`impersonated_by` claim),
audit-logged; admin accounts are not impersonatable. CLI
`admin impersonate`, MCP tool, admin UI token modal. Remaining:
in-browser session swap needs a session bridge.
- [x] **GitHub App manifest flow** — `POST /admin/git/github-app/manifest`
returns manifest + submission URL; the callback page exchanges
`code` via `POST /admin/git/github-app/convert` and credentials
persist in `app_settings` (overriding `GITHUB_APP_*` env). New
public `POST /api/git/github-app/webhook` receives App-level push
events (HMAC-verified) and enqueues matching services. Settings→env
fallback wired into install-URL + installation-token paths.
`containr admin github-app` CLI + MCP + OpenAPI.
- [ ] **fork-sync trigger** — accept repo-sync events on webhook - [ ] **fork-sync trigger** — accept repo-sync events on webhook
receiver. receiver.
- [ ] **Dynamic filter framework** — port dflow's `filter.utils` - [ ] **Dynamic filter framework** — port dflow's `filter.utils`
@@ -345,8 +376,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).
+300
View File
@@ -0,0 +1,300 @@
package api
import (
"context"
"database/sql"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"containr/internal/database"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Activity feed — enriched audit_logs rows (severity/category/label) served
// as a global feed and per-project timelines. Visibility mirrors the
// deployment/operations scoping: own actions, plus events on resources
// inside visible projects (approved / owned / member; admin sees all).
const activityVisibility = `(
a.user_id = $1::uuid OR $2::bool
OR (a.resource = 'project' AND a.resource_id IN (
SELECT id FROM projects p WHERE p.is_approved OR p.owner_id = $1::uuid
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1::uuid)))
OR (a.resource = 'service' AND a.resource_id IN (
SELECT s.id FROM services s JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1::uuid
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1::uuid)))
OR (a.resource = 'deployment' AND a.resource_id IN (
SELECT d.id FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1::uuid
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1::uuid)))
OR (a.resource = 'database' AND a.resource_id IN (
SELECT ds.id::uuid FROM database_services ds JOIN projects p ON p.id = ds.project_id
WHERE p.is_approved OR p.owner_id = $1::uuid
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1::uuid)))
)`
const activitySelect = `SELECT
a.id, COALESCE(a.user_id::text, ''), COALESCE(u.email, ''),
a.resource, COALESCE(a.resource_id::text, ''), a.action,
COALESCE(a.details::text, '{}'),
COALESCE(a.ip_address::text, ''), COALESCE(a.user_agent, ''),
COALESCE(a.severity, 'info'), COALESCE(a.category, ''), COALESCE(a.label, ''),
a.created_at
FROM audit_logs a
LEFT JOIN users u ON u.id = a.user_id`
func scanActivityRows(rows *sql.Rows) []AuditLog {
var logs []AuditLog
for rows.Next() {
var l AuditLog
if err := rows.Scan(&l.ID, &l.UserID, &l.UserEmail, &l.Resource, &l.ResourceID,
&l.Action, &l.Details, &l.IPAddress, &l.UserAgent,
&l.Severity, &l.Category, &l.Label, &l.CreatedAt); err != nil {
continue
}
// Rows written before enrichment carry empty metadata — classify on read.
if l.Label == "" || l.Category == "" {
sev, cat, label := classifyAuditEvent(l.Resource, l.Action)
if l.Severity == "" || l.Severity == "info" {
l.Severity = sev
}
if l.Category == "" {
l.Category = cat
}
if l.Label == "" {
l.Label = label
}
}
logs = append(logs, l)
}
return logs
}
// GET /activity — global feed. Filters: severity, category, resource,
// project_id, since, page, limit.
func handleGetActivity(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
isAdmin := contextIsAdmin(c)
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
limit := parsePositiveInt(c.DefaultQuery("limit", "50"), 50)
if limit > 200 {
limit = 200
}
offset := (page - 1) * limit
conditions := []string{activityVisibility}
args := []interface{}{userID, isAdmin}
nextArg := 3
if v := strings.TrimSpace(c.Query("severity")); v != "" {
conditions = append(conditions, fmt.Sprintf("a.severity = $%d", nextArg))
args = append(args, v)
nextArg++
}
if v := strings.TrimSpace(c.Query("category")); v != "" {
conditions = append(conditions, fmt.Sprintf("a.category = $%d", nextArg))
args = append(args, v)
nextArg++
}
if v := strings.TrimSpace(c.Query("resource")); v != "" {
conditions = append(conditions, fmt.Sprintf("a.resource = $%d", nextArg))
args = append(args, v)
nextArg++
}
if v := strings.TrimSpace(c.Query("project_id")); v != "" {
if _, err := uuid.Parse(v); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "invalid project_id")
return
}
conditions = append(conditions, fmt.Sprintf(`(
(a.resource = 'project' AND a.resource_id = $%d::uuid)
OR (a.resource = 'service' AND a.resource_id IN (SELECT id FROM services WHERE project_id = $%d::uuid))
OR (a.resource = 'deployment' AND a.resource_id IN (
SELECT d.id FROM deployments d JOIN services s ON s.id = d.service_id WHERE s.project_id = $%d::uuid))
OR (a.resource = 'database' AND a.resource_id IN (SELECT id::uuid FROM database_services WHERE project_id = $%d::uuid))
)`, nextArg, nextArg, nextArg, nextArg))
args = append(args, v)
nextArg++
}
if v := strings.TrimSpace(c.Query("since")); v != "" {
sinceTime, err := time.Parse(time.RFC3339, v)
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "invalid since timestamp, expected RFC3339")
return
}
conditions = append(conditions, fmt.Sprintf("a.created_at >= $%d", nextArg))
args = append(args, sinceTime)
nextArg++
}
query := fmt.Sprintf(`%s WHERE %s ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`,
activitySelect, strings.Join(conditions, " AND "), nextArg, nextArg+1)
args = append(args, limit, offset)
rows, err := db.Query(query, args...)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to fetch activity")
return
}
defer rows.Close()
logs := scanActivityRows(rows)
if logs == nil {
logs = []AuditLog{}
}
c.JSON(http.StatusOK, gin.H{
"activity": logs,
"page": page,
"limit": limit,
"has_more": len(logs) == limit,
})
}
// GET /projects/:id/activity — per-project timeline. Resource correlation
// mirrors the global feed, pinned to this project.
func handleGetProjectActivity(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
projectID := strings.TrimSpace(c.Param("id"))
if _, err := uuid.Parse(projectID); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid project id")
return
}
// Access check: approved / owner / member / admin.
var visible bool
if err := db.QueryRow(
`SELECT p.is_approved OR p.owner_id = $2::uuid OR $3::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2::uuid)
FROM projects p WHERE p.id = $1`,
projectID, userID, contextIsAdmin(c),
).Scan(&visible); err != nil || !visible {
respondError(c, http.StatusNotFound, "NOT_FOUND", "project not found")
return
}
limit := parsePositiveInt(c.DefaultQuery("limit", "50"), 50)
if limit > 200 {
limit = 200
}
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
rows, err := db.Query(fmt.Sprintf(`%s WHERE (
(a.resource = 'project' AND a.resource_id = $1::uuid)
OR (a.resource = 'service' AND a.resource_id IN (SELECT id FROM services WHERE project_id = $1::uuid))
OR (a.resource = 'deployment' AND a.resource_id IN (
SELECT d.id FROM deployments d JOIN services s ON s.id = d.service_id WHERE s.project_id = $1::uuid))
OR (a.resource = 'database' AND a.resource_id IN (SELECT id::uuid FROM database_services WHERE project_id = $1::uuid))
) ORDER BY a.created_at DESC LIMIT $2 OFFSET $3`,
activitySelect), projectID, limit, (page-1)*limit)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to fetch activity")
return
}
defer rows.Close()
logs := scanActivityRows(rows)
if logs == nil {
logs = []AuditLog{}
}
c.JSON(http.StatusOK, gin.H{"activity": logs})
}
// StartAuditRetention prunes audit rows past the retention window once at
// boot and then daily. AUDIT_RETENTION_DAYS overrides the 90-day default;
// 0 disables pruning entirely.
func StartAuditRetention(ctx context.Context, db *database.DB) {
days := 90
if raw := strings.TrimSpace(os.Getenv("AUDIT_RETENTION_DAYS")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
days = parsed
}
}
// Backfill enrichment for rows written before the classifier existed,
// batched so a large audit table doesn't hold a giant transaction.
go backfillAuditMetadata(db)
if days <= 0 {
return
}
prune := func() {
res, err := db.Exec(`DELETE FROM audit_logs WHERE created_at < NOW() - ($1::int * INTERVAL '1 day')`, days)
if err != nil {
log.Printf("audit retention prune failed: %v", err)
return
}
if n, _ := res.RowsAffected(); n > 0 {
log.Printf("audit retention: pruned %d rows older than %d days", n, days)
}
}
prune()
go func() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
prune()
}
}
}()
}
// backfillAuditMetadata classifies rows written before severity/category/label
// existed so filters and the feed see uniform metadata.
func backfillAuditMetadata(db *database.DB) {
cursor := uuid.Nil.String()
for {
rows, err := db.Query(
`SELECT id::text, resource, action FROM audit_logs
WHERE label = '' AND id > $1::uuid ORDER BY id LIMIT 500`, cursor)
if err != nil {
log.Printf("audit backfill scan failed: %v", err)
return
}
type row struct{ id, resource, action string }
var batch []row
for rows.Next() {
var r row
if rows.Scan(&r.id, &r.resource, &r.action) == nil {
batch = append(batch, r)
}
}
rows.Close()
if len(batch) == 0 {
return
}
for _, r := range batch {
cursor = r.id
sev, cat, label := classifyAuditEvent(r.resource, r.action)
if _, err := db.Exec(
`UPDATE audit_logs SET severity = $1, category = $2, label = $3 WHERE id = $4::uuid`,
sev, cat, label, r.id,
); err != nil {
log.Printf("audit backfill update failed: %v", err)
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
package api
import "testing"
func TestClassifyAuditEvent(t *testing.T) {
cases := []struct {
resource, action string
wantSev, wantCat, wantLab string
}{
{"service", "service.create", "success", "service", "Service created"},
{"service", "service.delete", "warning", "service", "Service deleted"},
{"deployment", "deploy", "success", "deployment", "Deployment deployed"},
{"project", "project.create", "success", "project", "Project created"},
{"user_invite", "user_invite.create", "success", "system", "User invite created"},
{"user_token", "user_token.revoke", "warning", "security", "User token revoked"},
{"service", "deploy.fail", "error", "service", "Service fail"},
{"user", "impersonate", "warning", "user", "User impersonated"},
{"webhook", "update", "info", "webhook", "Webhook updated"},
}
for _, tc := range cases {
sev, cat, lab := classifyAuditEvent(tc.resource, tc.action)
if sev != tc.wantSev {
t.Errorf("%s/%s: severity = %q, want %q", tc.resource, tc.action, sev, tc.wantSev)
}
if cat != tc.wantCat {
t.Errorf("%s/%s: category = %q, want %q", tc.resource, tc.action, cat, tc.wantCat)
}
if lab != tc.wantLab {
t.Errorf("%s/%s: label = %q, want %q", tc.resource, tc.action, lab, tc.wantLab)
}
}
}
+51
View File
@@ -246,6 +246,57 @@ func handleAdminSetUserAdmin(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "User updated"}) c.JSON(http.StatusOK, gin.H{"message": "User updated"})
} }
// POST /admin/users/:id/impersonate — mints a 15-minute bearer token acting
// as the target user. Admin-only; admin accounts cannot be impersonated
// (would blur audit attribution). Every use is audit-logged at mint time;
// token carries `impersonated_by` for downstream attribution.
func handleAdminImpersonateUser(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
targetID := strings.TrimSpace(c.Param("id"))
if _, err := uuid.Parse(targetID); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid user id")
return
}
var email, name string
var isAdmin bool
err := db.QueryRowContext(c.Request.Context(),
`SELECT email, COALESCE(name, ''), is_admin FROM users WHERE id = $1`, targetID).
Scan(&email, &name, &isAdmin)
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "user not found")
return
}
if isAdmin {
respondError(c, http.StatusForbidden, "FORBIDDEN", "admin accounts cannot be impersonated")
return
}
actorID := optionalUserID(c)
if actorID == targetID {
respondError(c, http.StatusBadRequest, "VALIDATION", "cannot impersonate yourself")
return
}
expiresAt := time.Now().UTC().Add(15 * time.Minute)
jwtSecret := c.MustGet("jwt_secret").(string)
token, err := generateImpersonationJWT(targetID, email, actorID, jwtSecret, expiresAt)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate token")
return
}
LogAuditWithRequest(c, "user", targetID, "impersonate", map[string]interface{}{
"actor_id": actorID,
"target_email": email,
"expires_at": expiresAt.Format(time.RFC3339),
})
c.JSON(http.StatusOK, gin.H{
"token": token,
"expires_at": expiresAt.Format(time.RFC3339),
"user": gin.H{"id": targetID, "email": email, "name": name},
})
}
func handleAdminSetProjectApproval(c *gin.Context) { func handleAdminSetProjectApproval(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB) queries := sqlcdb.New(db.DB)
+77 -7
View File
@@ -23,9 +23,67 @@ type AuditLog struct {
Details string `json:"details" db:"details"` Details string `json:"details" db:"details"`
IPAddress string `json:"ip_address" db:"ip_address"` IPAddress string `json:"ip_address" db:"ip_address"`
UserAgent string `json:"user_agent" db:"user_agent"` UserAgent string `json:"user_agent" db:"user_agent"`
Severity string `json:"severity" db:"severity"`
Category string `json:"category" db:"category"`
Label string `json:"label" db:"label"`
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
} }
// auditVerbs maps action verbs to past-tense words for feed labels.
var auditVerbs = map[string]string{
"create": "created", "delete": "deleted", "update": "updated",
"deploy": "deployed", "clone": "cloned", "move": "moved",
"backup": "backed up", "restore": "restored", "revoke": "revoked",
"impersonate": "impersonated", "provision": "provisioned",
"accept": "accepted", "login": "logged in", "scale": "scaled",
"sleep": "put to sleep", "wake": "woke", "approve": "approved",
"set_admin": "admin flag changed", "provisioned": "provisioned",
}
// classifyAuditEvent derives severity/category/label for the activity feed.
// Actions may already carry the resource prefix ("service.clone") — it is
// stripped before matching.
func classifyAuditEvent(resource, action string) (severity, category, label string) {
verb := action
if idx := strings.LastIndex(verb, "."); idx >= 0 {
verb = verb[idx+1:]
}
category = resource
switch resource {
case "user_invite", "invite", "github_app", "banner", "settings":
category = "system"
case "user_token", "agent_token", "registry_credential":
category = "security"
}
severity = "info"
switch {
case strings.Contains(verb, "fail"), strings.Contains(verb, "error"), strings.Contains(verb, "denied"):
severity = "error"
case strings.Contains(verb, "delete"), strings.Contains(verb, "revoke"),
strings.Contains(verb, "remove"), strings.Contains(verb, "disable"),
strings.Contains(verb, "impersonate"):
severity = "warning"
case strings.Contains(verb, "create"), strings.Contains(verb, "success"),
strings.Contains(verb, "complete"), strings.Contains(verb, "accept"),
strings.Contains(verb, "provision"), strings.Contains(verb, "clone"),
strings.Contains(verb, "deploy"), strings.Contains(verb, "approve"):
severity = "success"
}
past := verb
if v, ok := auditVerbs[verb]; ok {
past = v
}
resName := strings.ReplaceAll(resource, "_", " ")
if resName != "" {
resName = strings.ToUpper(resName[:1]) + resName[1:]
}
label = resName + " " + past
return severity, category, label
}
type AuditLogDetail struct { type AuditLogDetail struct {
OldValue interface{} `json:"old_value,omitempty"` OldValue interface{} `json:"old_value,omitempty"`
NewValue interface{} `json:"new_value,omitempty"` NewValue interface{} `json:"new_value,omitempty"`
@@ -42,21 +100,27 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in
detailsJSON, _ := json.Marshal(details) detailsJSON, _ := json.Marshal(details)
resourceUUID := parseUUIDOrNil(resourceID) resourceUUID := parseUUIDOrNil(resourceID)
userUUID := parseUUIDOrNil(userID) userUUID := parseUUIDOrNil(userID)
severity, category, label := classifyAuditEvent(resource, action)
auditID := uuid.New().String() auditID := uuid.New().String()
_, err := db.Exec( _, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, created_at) `INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), time.Now().UTC(), auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), severity, category, label, time.Now().UTC(),
) )
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{}) {
userID, _ := c.Get("user_id") userID, _ := c.Get("user_id")
if details == nil {
details = map[string]interface{}{}
}
details["ip_address"] = c.ClientIP() details["ip_address"] = c.ClientIP()
details["user_agent"] = c.GetHeader("User-Agent") details["user_agent"] = c.GetHeader("User-Agent")
@@ -69,16 +133,19 @@ func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, de
} }
userUUID := parseUUIDOrNil(userIDStr) userUUID := parseUUIDOrNil(userIDStr)
resourceUUID := parseUUIDOrNil(resourceID) resourceUUID := parseUUIDOrNil(resourceID)
severity, category, label := classifyAuditEvent(resource, action)
auditID := uuid.New().String() auditID := uuid.New().String()
_, err := db.Exec( _, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, created_at) `INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9)`, VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12)`,
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), time.Now().UTC(), auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), severity, category, label, time.Now().UTC(),
) )
if err != nil { if err != nil {
} }
emitWebhookEvent(userIDStr, resource, resourceID, action, details)
} }
var auditDB *database.DB var auditDB *database.DB
@@ -151,6 +218,9 @@ func handleGetAuditLogs(c *gin.Context) {
COALESCE(a.details::text, '{}'), COALESCE(a.details::text, '{}'),
COALESCE(a.ip_address::text, ''), COALESCE(a.ip_address::text, ''),
COALESCE(a.user_agent, ''), COALESCE(a.user_agent, ''),
COALESCE(a.severity, 'info'),
COALESCE(a.category, ''),
COALESCE(a.label, ''),
a.created_at a.created_at
FROM audit_logs a FROM audit_logs a
LEFT JOIN users u ON u.id = a.user_id LEFT JOIN users u ON u.id = a.user_id
@@ -169,7 +239,7 @@ func handleGetAuditLogs(c *gin.Context) {
var logs []AuditLog var logs []AuditLog
for rows.Next() { for rows.Next() {
var log AuditLog var log AuditLog
err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt) err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.Severity, &log.Category, &log.Label, &log.CreatedAt)
if err != nil { if err != nil {
continue continue
} }
+13
View File
@@ -546,6 +546,19 @@ func generateJWT(userID, email, secret string) (string, error) {
return token.SignedString([]byte(secret)) return token.SignedString([]byte(secret))
} }
// generateImpersonationJWT mints a short-lived token acting as `userID`,
// tagged with the admin who minted it so downstream callers can attribute
// actions taken while impersonating.
func generateImpersonationJWT(userID, email, actorID, secret string, expiresAt time.Time) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": userID,
"email": email,
"impersonated_by": actorID,
"exp": expiresAt.Unix(),
})
return token.SignedString([]byte(secret))
}
// ValidateJWT validates a JWT token and returns the claims // ValidateJWT validates a JWT token and returns the claims
func ValidateJWT(tokenString, secret string) (jwt.MapClaims, error) { func ValidateJWT(tokenString, secret string) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
+225
View File
@@ -0,0 +1,225 @@
package api
import (
"database/sql"
"net/http"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Instance-wide announcement banners. Admins manage them under
// /admin/banners; every signed-in user reads the active set at
// /banners/active so the UI (and CLI) can surface announcements.
var bannerLevels = map[string]bool{"info": true, "success": true, "warning": true, "error": true}
// bannerJSON flattens sqlc nullable wrappers into plain API values.
type bannerJSON struct {
ID string `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Level string `json:"level"`
Active bool `json:"active"`
Dismissible bool `json:"dismissible"`
StartsAt *time.Time `json:"starts_at,omitempty"`
EndsAt *time.Time `json:"ends_at,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
}
func toBannerJSON(b sqlcdb.Banner) bannerJSON {
out := bannerJSON{
ID: b.ID.String(),
Title: b.Title,
Body: b.Body,
Level: b.Level,
Active: b.Active,
Dismissible: b.Dismissible,
}
if b.StartsAt.Valid {
out.StartsAt = &b.StartsAt.Time
}
if b.EndsAt.Valid {
out.EndsAt = &b.EndsAt.Time
}
if b.CreatedAt.Valid {
out.CreatedAt = &b.CreatedAt.Time
}
return out
}
func toBannerList(banners []sqlcdb.Banner) []bannerJSON {
out := make([]bannerJSON, 0, len(banners))
for _, b := range banners {
out = append(out, toBannerJSON(b))
}
return out
}
type bannerRequest struct {
Title *string `json:"title"`
Body *string `json:"body"`
Level *string `json:"level"`
Active *bool `json:"active"`
Dismissible *bool `json:"dismissible"`
StartsAt *time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at"`
}
func bannerQueries(c *gin.Context) *sqlcdb.Queries {
db := c.MustGet("db").(*database.DB)
return sqlcdb.New(db.DB)
}
func parseBannerID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid banner id")
return uuid.Nil, false
}
return id, true
}
func validBannerLevel(level string) bool { return bannerLevels[level] }
func handleListActiveBanners(c *gin.Context) {
banners, err := bannerQueries(c).ListActiveBanners(c.Request.Context())
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list banners")
return
}
c.JSON(http.StatusOK, gin.H{"banners": toBannerList(banners)})
}
func handleAdminListBanners(c *gin.Context) {
banners, err := bannerQueries(c).ListBanners(c.Request.Context())
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list banners")
return
}
c.JSON(http.StatusOK, gin.H{"banners": toBannerList(banners)})
}
func handleAdminCreateBanner(c *gin.Context) {
var req bannerRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "invalid request body")
return
}
if req.Title == nil || *req.Title == "" {
respondError(c, http.StatusBadRequest, "VALIDATION", "title is required")
return
}
level := "info"
if req.Level != nil {
if !validBannerLevel(*req.Level) {
respondError(c, http.StatusBadRequest, "VALIDATION", "level must be info, success, warning or error")
return
}
level = *req.Level
}
userID, _ := requireAuthenticatedUserUUID(c)
params := sqlcdb.CreateBannerParams{
Title: *req.Title,
Level: level,
Active: true,
Dismissible: true,
CreatedBy: uuid.NullUUID{UUID: userID, Valid: true},
}
if req.Body != nil {
params.Body = *req.Body
}
if req.Active != nil {
params.Active = *req.Active
}
if req.Dismissible != nil {
params.Dismissible = *req.Dismissible
}
if req.StartsAt != nil {
params.StartsAt = sql.NullTime{Time: *req.StartsAt, Valid: true}
}
if req.EndsAt != nil {
params.EndsAt = sql.NullTime{Time: *req.EndsAt, Valid: true}
}
banner, err := bannerQueries(c).CreateBanner(c.Request.Context(), params)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to create banner")
return
}
c.JSON(http.StatusCreated, toBannerJSON(banner))
}
func handleAdminUpdateBanner(c *gin.Context) {
id, ok := parseBannerID(c)
if !ok {
return
}
q := bannerQueries(c)
banner, err := q.GetBannerByID(c.Request.Context(), id)
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "banner not found")
return
}
var req bannerRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "invalid request body")
return
}
params := sqlcdb.UpdateBannerParams{
ID: id,
Title: banner.Title,
Body: banner.Body,
Level: banner.Level,
Active: banner.Active,
Dismissible: banner.Dismissible,
StartsAt: banner.StartsAt,
EndsAt: banner.EndsAt,
}
if req.Title != nil {
params.Title = *req.Title
}
if req.Body != nil {
params.Body = *req.Body
}
if req.Level != nil {
if !validBannerLevel(*req.Level) {
respondError(c, http.StatusBadRequest, "VALIDATION", "level must be info, success, warning or error")
return
}
params.Level = *req.Level
}
if req.Active != nil {
params.Active = *req.Active
}
if req.Dismissible != nil {
params.Dismissible = *req.Dismissible
}
if req.StartsAt != nil {
params.StartsAt = sql.NullTime{Time: *req.StartsAt, Valid: true}
}
if req.EndsAt != nil {
params.EndsAt = sql.NullTime{Time: *req.EndsAt, Valid: true}
}
updated, err := q.UpdateBanner(c.Request.Context(), params)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to update banner")
return
}
c.JSON(http.StatusOK, toBannerJSON(updated))
}
func handleAdminDeleteBanner(c *gin.Context) {
id, ok := parseBannerID(c)
if !ok {
return
}
if err := bannerQueries(c).DeleteBanner(c.Request.Context(), id); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to delete banner")
return
}
c.Status(http.StatusNoContent)
}
+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},
}) })
+5 -3
View File
@@ -133,7 +133,9 @@ func handleGetGitProviders(c *gin.Context) {
} }
func handleGetGitHubAppInstallURL(c *gin.Context) { func handleGetGitHubAppInstallURL(c *gin.Context) {
slug := strings.TrimSpace(os.Getenv("GITHUB_APP_SLUG")) db, _ := c.Get("db")
d, _ := db.(*database.DB)
slug := strings.TrimSpace(settingValue(d, settingGitHubAppSlug, "GITHUB_APP_SLUG", ""))
if slug == "" { if slug == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "GITHUB_APP_SLUG is not configured"}) c.JSON(http.StatusBadRequest, gin.H{"error": "GITHUB_APP_SLUG is not configured"})
return return
@@ -1073,8 +1075,8 @@ func fetchBitbucketRepositories(providerAPIURL, accessToken string) ([]remoteGit
} }
func getGitHubAppInstallationToken(baseURL string, installationID int64) (string, error) { func getGitHubAppInstallationToken(baseURL string, installationID int64) (string, error) {
appID := strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) appID := platformSetting(settingGitHubAppID, "GITHUB_APP_ID")
privateKeyPEM := strings.TrimSpace(os.Getenv("GITHUB_APP_PRIVATE_KEY")) privateKeyPEM := platformSetting(settingGitHubAppPrivateKey, "GITHUB_APP_PRIVATE_KEY")
if appID == "" || privateKeyPEM == "" { if appID == "" || privateKeyPEM == "" {
return "", fmt.Errorf("GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY must be configured") return "", fmt.Errorf("GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY must be configured")
} }
+23 -13
View File
@@ -113,6 +113,26 @@ func handleGitWebhookPush(c *gin.Context) {
return return
} }
engineValue, _ := c.Get("deployment_engine")
engine, _ := engineValue.(*deployment.DeploymentEngine)
enqueued, err := dispatchPushToServices(c, db, engine, cloneURL, fullName, branch, commit, repoUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"})
return
}
c.JSON(http.StatusAccepted, gin.H{
"received": true,
"branch": branch,
"deployments": enqueued,
})
}
// dispatchPushToServices enqueues a deployment for every service tracking
// the pushed repo+branch. Shared by the per-repo webhook receiver and the
// GitHub App webhook endpoint.
func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.DeploymentEngine, cloneURL, fullName, branch, commit, repoUserID string) (int, error) {
rows, err := db.Query(` rows, err := db.Query(`
SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command, SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory, s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
@@ -121,8 +141,7 @@ func handleGitWebhookPush(c *gin.Context) {
WHERE s.git_branch = $1 AND (s.git_repo = $2 OR s.git_repo = $3)`, WHERE s.git_branch = $1 AND (s.git_repo = $2 OR s.git_repo = $3)`,
branch, cloneURL, fullName) branch, cloneURL, fullName)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"}) return 0, err
return
} }
defer rows.Close() defer rows.Close()
@@ -134,15 +153,11 @@ func handleGitWebhookPush(c *gin.Context) {
&s.Environment, &s.GitRepo, &s.GitBranch, &s.BuildPath, &s.CPU, &s.Memory, &s.Environment, &s.GitRepo, &s.GitBranch, &s.BuildPath, &s.CPU, &s.Memory,
&s.CreatedAt, &s.UpdatedAt, &s.CreatedAt, &s.UpdatedAt,
); err != nil { ); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"}) return 0, err
return
} }
services = append(services, s) services = append(services, s)
} }
engineValue, _ := c.Get("deployment_engine")
engine, _ := engineValue.(*deployment.DeploymentEngine)
enqueued := 0 enqueued := 0
for _, service := range services { for _, service := range services {
now := time.Now() now := time.Now()
@@ -199,12 +214,7 @@ func handleGitWebhookPush(c *gin.Context) {
} }
enqueued++ enqueued++
} }
return enqueued, nil
c.JSON(http.StatusAccepted, gin.H{
"received": true,
"branch": branch,
"deployments": enqueued,
})
} }
// verifyGitWebhookSignature checks the push signature per provider: // verifyGitWebhookSignature checks the push signature per provider:
+310
View File
@@ -0,0 +1,310 @@
package api
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"containr/internal/database"
"containr/internal/deployment"
"github.com/gin-gonic/gin"
)
// GitHub App self-provisioning (manifest flow): an admin POSTs the manifest
// to github.com/settings/apps/new, GitHub redirects back with `code`, and the
// code is exchanged at /app-manifests/{code}/conversions for real credentials
// stored in app_settings. Values stored there take precedence over the
// GITHUB_APP_* env vars, so the flow removes the manual env-setup barrier.
const (
settingGitHubAppID = "github_app.id"
settingGitHubAppSlug = "github_app.slug"
settingGitHubAppName = "github_app.name"
settingGitHubAppClientID = "github_app.client_id"
settingGitHubAppClientSecret = "github_app.client_secret"
settingGitHubAppPrivateKey = "github_app.private_key"
settingGitHubAppWebhookSecret = "github_app.webhook_secret"
)
// platformSetting resolves a setting row first, then env, then empty.
func platformSetting(key, envKey string) string {
return strings.TrimSpace(settingValue(GetAuditDB(), key, envKey, ""))
}
func githubAppConfigured(db *database.DB) bool {
return strings.TrimSpace(settingValue(db, settingGitHubAppID, "GITHUB_APP_ID", "")) != "" &&
strings.TrimSpace(settingValue(db, settingGitHubAppPrivateKey, "GITHUB_APP_PRIVATE_KEY", "")) != ""
}
// POST /admin/git/github-app/manifest — builds the GitHub App manifest the
// browser POSTs to github.com. Response carries the manifest as a JSON string
// (the form submits it verbatim) plus the destination URL.
func handleAdminGitHubAppManifest(c *gin.Context) {
var req struct {
BaseURL string `json:"base_url" binding:"required"`
Name string `json:"name"`
Organization string `json:"organization"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "base_url is required")
return
}
base := strings.TrimSuffix(strings.TrimSpace(req.BaseURL), "/")
if !strings.HasPrefix(base, "https://") &&
!strings.HasPrefix(base, "http://localhost") &&
!strings.HasPrefix(base, "http://127.0.0.1") {
respondError(c, http.StatusBadRequest, "VALIDATION", "base_url must be an https URL (localhost http allowed)")
return
}
stateBuf := make([]byte, 16)
if _, err := rand.Read(stateBuf); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate state")
return
}
state := hex.EncodeToString(stateBuf)
name := strings.TrimSpace(req.Name)
if name == "" {
db, _ := c.Get("db")
if d, ok := db.(*database.DB); ok {
name = strings.TrimSpace(settingValue(d, settingBrandProductName, "BRAND_NAME", ""))
}
if name == "" {
name = "Containr"
}
name = fmt.Sprintf("%s-%s", name, time.Now().UTC().Format("2006-01-02"))
}
callbackURL := base + "/git/github-app/callback"
manifest, err := json.Marshal(map[string]interface{}{
"name": name,
"url": base,
"hook_attributes": map[string]interface{}{
"url": base + "/api/git/github-app/webhook",
"active": true,
},
"redirect_url": callbackURL,
"callback_urls": []string{callbackURL},
"public": false,
"default_permissions": map[string]string{
"contents": "read",
"metadata": "read",
"pull_requests": "write",
},
"default_events": []string{"push", "pull_request"},
})
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to build manifest")
return
}
org := strings.TrimSpace(req.Organization)
createURL := fmt.Sprintf("https://github.com/settings/apps/new?state=%s", state)
if org != "" {
createURL = fmt.Sprintf("https://github.com/organizations/%s/settings/apps/new?state=%s", org, state)
}
c.JSON(http.StatusOK, gin.H{
"url": createURL,
"manifest": string(manifest),
"state": state,
})
}
// POST /admin/git/github-app/convert — exchanges the `code` GitHub appends to
// the manifest redirect for the real app credentials, then stores them.
func handleAdminGitHubAppConvert(c *gin.Context) {
var req struct {
Code string `json:"code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "code is required")
return
}
apiBase := strings.TrimSuffix(strings.TrimSpace(os.Getenv("GITHUB_APP_BASE_URL")), "/")
if apiBase == "" {
apiBase = "https://api.github.com"
}
request, err := http.NewRequest(http.MethodPost,
fmt.Sprintf("%s/app-manifests/%s/conversions", apiBase, strings.TrimSpace(req.Code)), nil)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to build conversion request")
return
}
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("X-GitHub-Api-Version", "2022-11-28")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(request)
if err != nil {
respondError(c, http.StatusBadGateway, "UPSTREAM", "failed to reach GitHub")
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
respondError(c, http.StatusBadGateway, "UPSTREAM",
fmt.Sprintf("GitHub conversion failed (%d)", resp.StatusCode))
return
}
var app struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
PEM string `json:"pem"`
WebhookSecret string `json:"webhook_secret"`
}
if err := json.Unmarshal(body, &app); err != nil || app.ID == 0 {
respondError(c, http.StatusBadGateway, "UPSTREAM", "invalid conversion response")
return
}
db := c.MustGet("db").(*database.DB)
for _, kv := range []struct {
key string
value string
secret bool
}{
{settingGitHubAppID, fmt.Sprintf("%d", app.ID), false},
{settingGitHubAppSlug, app.Slug, false},
{settingGitHubAppName, app.Name, false},
{settingGitHubAppClientID, app.ClientID, false},
{settingGitHubAppClientSecret, app.ClientSecret, true},
{settingGitHubAppPrivateKey, app.PEM, true},
{settingGitHubAppWebhookSecret, app.WebhookSecret, true},
} {
if err := setSetting(db, kv.key, kv.value, kv.secret); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to store app credentials")
return
}
}
LogAuditWithRequest(c, "github_app", fmt.Sprintf("%d", app.ID), "provision",
map[string]interface{}{"name": app.Name, "slug": app.Slug})
c.JSON(http.StatusOK, gin.H{
"app_id": app.ID,
"slug": app.Slug,
"name": app.Name,
})
}
// GET /admin/git/github-app — provisioning status for the settings UI.
func handleAdminGetGitHubApp(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
var stored string
source := "none"
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingGitHubAppID).Scan(&stored); err == nil && strings.TrimSpace(stored) != "" {
source = "settings"
} else if strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" {
source = "env"
}
c.JSON(http.StatusOK, gin.H{
"configured": githubAppConfigured(db),
"source": source,
"app_id": settingValue(db, settingGitHubAppID, "GITHUB_APP_ID", ""),
"slug": settingValue(db, settingGitHubAppSlug, "GITHUB_APP_SLUG", ""),
"name": settingValue(db, settingGitHubAppName, "", ""),
})
}
// POST /api/git/github-app/webhook — the single App-level webhook all
// installed repos deliver to. Verified against the provisioned webhook
// secret, then dispatched to matching services.
func handleGitHubAppWebhook(c *gin.Context) {
dbValue, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
db := dbValue.(*database.DB)
secret := strings.TrimSpace(settingValue(db, settingGitHubAppWebhookSecret, "GITHUB_APP_WEBHOOK_SECRET", ""))
if secret == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "GitHub App webhook not configured"})
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 4<<20))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read payload"})
return
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := mac.Sum(nil)
sig := strings.TrimPrefix(c.GetHeader("X-Hub-Signature-256"), "sha256=")
decoded, err := hex.DecodeString(sig)
if err != nil || len(decoded) == 0 || subtle.ConstantTimeCompare(decoded, expected) != 1 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"})
return
}
event := c.GetHeader("X-GitHub-Event")
if event != "push" {
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "event " + event})
return
}
var payload struct {
Ref string `json:"ref"`
After string `json:"after"`
Repository struct {
FullName string `json:"full_name"`
CloneURL string `json:"clone_url"`
} `json:"repository"`
HeadCommit struct {
ID string `json:"id"`
} `json:"head_commit"`
}
if err := json.Unmarshal(body, &payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
if !strings.HasPrefix(payload.Ref, "refs/heads/") {
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "not a branch push"})
return
}
branch := strings.TrimPrefix(payload.Ref, "refs/heads/")
commit := payload.HeadCommit.ID
if commit == "" {
commit = payload.After
}
cloneURL := payload.Repository.CloneURL
fullName := payload.Repository.FullName
if cloneURL == "" {
cloneURL = fmt.Sprintf("https://github.com/%s.git", fullName)
}
// Services tracking an App-connected repo store either the clone URL or
// owner/name — match both like the per-repo receiver does.
engineValue, _ := c.Get("deployment_engine")
engine, _ := engineValue.(*deployment.DeploymentEngine)
enqueued, err := dispatchPushToServices(c, db, engine, cloneURL, fullName, branch, commit, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"})
return
}
c.JSON(http.StatusAccepted, gin.H{
"received": true,
"branch": branch,
"deployments": enqueued,
})
}
+245
View File
@@ -0,0 +1,245 @@
package api
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"net/http"
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// Team invites: an admin creates a single-use link; the invitee registers
// through it even while public signup is closed. Tokens are random 32-byte
// values shared once — only their sha256 hash is stored.
type inviteJSON struct {
ID string `json:"id"`
Email *string `json:"email,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
}
func toInviteJSON(i sqlcdb.UserInvite) inviteJSON {
out := inviteJSON{ID: i.ID.String(), ExpiresAt: i.ExpiresAt}
if i.Email.Valid && i.Email.String != "" {
out.Email = &i.Email.String
}
if i.UsedAt.Valid {
out.UsedAt = &i.UsedAt.Time
}
if i.CreatedAt.Valid {
out.CreatedAt = &i.CreatedAt.Time
}
return out
}
func inviteTokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func generateInviteToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// POST /admin/invites — {email?, expires_in_hours?} → {invite, token, url}
func handleAdminCreateInvite(c *gin.Context) {
var req struct {
Email string `json:"email"`
ExpiresInHours *int `json:"expires_in_hours"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "invalid request body")
return
}
ttl := 24 * 7
if req.ExpiresInHours != nil {
if *req.ExpiresInHours < 1 || *req.ExpiresInHours > 24*90 {
respondError(c, http.StatusBadRequest, "VALIDATION", "expires_in_hours must be 1–2160")
return
}
ttl = *req.ExpiresInHours
}
token, err := generateInviteToken()
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate token")
return
}
userID, _ := requireAuthenticatedUserUUID(c)
params := sqlcdb.CreateUserInviteParams{
TokenHash: inviteTokenHash(token),
CreatedBy: uuid.NullUUID{UUID: userID, Valid: true},
ExpiresAt: time.Now().UTC().Add(time.Duration(ttl) * time.Hour),
}
if email := strings.ToLower(strings.TrimSpace(req.Email)); email != "" {
params.Email = sql.NullString{String: email, Valid: true}
}
db := c.MustGet("db").(*database.DB)
invite, err := sqlcdb.New(db.DB).CreateUserInvite(c.Request.Context(), params)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to create invite")
return
}
c.JSON(http.StatusCreated, gin.H{
"invite": toInviteJSON(invite),
"token": token,
"url": "/auth/accept-invite?token=" + token,
})
}
// GET /admin/invites
func handleAdminListInvites(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
invites, err := sqlcdb.New(db.DB).ListUserInvites(c.Request.Context())
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list invites")
return
}
out := make([]inviteJSON, 0, len(invites))
for _, i := range invites {
out = append(out, toInviteJSON(i))
}
c.JSON(http.StatusOK, gin.H{"invites": out})
}
// DELETE /admin/invites/:id — revoke an unused invite.
func handleAdminDeleteInvite(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid invite id")
return
}
db := c.MustGet("db").(*database.DB)
if err := sqlcdb.New(db.DB).DeleteUserInvite(c.Request.Context(), id); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to delete invite")
return
}
c.Status(http.StatusNoContent)
}
// GET /auth/invites/:token — public. Lets the accept page validate the link
// and pre-fill a bound email before the user commits to registering.
func handleGetInvite(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
invite, err := sqlcdb.New(db.DB).GetUserInviteByTokenHash(c.Request.Context(), inviteTokenHash(c.Param("token")))
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "invite not found")
return
}
expired := time.Now().After(invite.ExpiresAt)
var email *string
if invite.Email.Valid && invite.Email.String != "" {
email = &invite.Email.String
}
c.JSON(http.StatusOK, gin.H{
"valid": !invite.UsedAt.Valid && !expired,
"used": invite.UsedAt.Valid,
"expired": expired,
"email": email,
"expires_at": invite.ExpiresAt,
})
}
// POST /auth/accept-invite — {token, name, email, password}
func handleAcceptInvite(c *gin.Context) {
var req struct {
Token string `json:"token" binding:"required"`
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "token, name, email and password are required")
return
}
if len(req.Password) < 8 {
respondError(c, http.StatusBadRequest, "VALIDATION", "password must be at least 8 characters")
return
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
db := c.MustGet("db").(*database.DB)
q := sqlcdb.New(db.DB)
invite, err := q.GetUserInviteByTokenHash(c.Request.Context(), inviteTokenHash(req.Token))
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "invite not found")
return
}
if invite.UsedAt.Valid {
respondError(c, http.StatusGone, "INVITE_USED", "invite already used")
return
}
if time.Now().After(invite.ExpiresAt) {
respondError(c, http.StatusGone, "INVITE_EXPIRED", "invite expired")
return
}
if invite.Email.Valid && invite.Email.String != "" && invite.Email.String != req.Email {
respondError(c, http.StatusForbidden, "INVITE_EMAIL_MISMATCH", "invite is bound to a different email")
return
}
var existing int
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE email = $1`, req.Email).Scan(&existing); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "database error")
return
}
if existing > 0 {
respondError(c, http.StatusConflict, "CONFLICT", "user already exists")
return
}
if err := createBetterAuthUser(c, strings.TrimSpace(req.Name), req.Email, req.Password); err != nil && !errors.Is(err, errBetterAuthUserExists) {
respondError(c, http.StatusInternalServerError, "INTERNAL", err.Error())
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to hash password")
return
}
var user User
err = db.QueryRow(`
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
`, req.Email, string(hashedPassword), strings.TrimSpace(req.Name)).
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err != nil {
respondError(c, http.StatusConflict, "CONFLICT", "user already exists")
return
}
userUUID, _ := uuid.Parse(user.ID)
if err := q.MarkUserInviteUsed(c.Request.Context(), sqlcdb.MarkUserInviteUsedParams{
ID: invite.ID,
UsedBy: uuid.NullUUID{UUID: userUUID, Valid: userUUID != uuid.Nil},
}); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to consume invite")
return
}
jwtSecret := c.MustGet("jwt_secret").(string)
token, err := generateJWT(user.ID, user.Email, jwtSecret)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate token")
return
}
c.JSON(http.StatusCreated, AuthResponse{Token: token, User: user})
}
+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,
})
}
+25
View File
@@ -7,6 +7,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"log"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -152,6 +153,7 @@ func handleCreateProject(c *gin.Context) {
return return
} }
LogAuditWithRequest(c, "project", projectRow.ID.String(), "project.create", map[string]interface{}{"name": req.Name})
c.JSON(http.StatusCreated, mapSQLCProject(projectRow)) c.JSON(http.StatusCreated, mapSQLCProject(projectRow))
} }
@@ -297,6 +299,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 {
@@ -317,6 +341,7 @@ func handleDeleteProject(c *gin.Context) {
return return
} }
LogAuditWithRequest(c, "project", projectID.String(), "project.delete", nil)
c.JSON(http.StatusOK, gin.H{"message": "Project deleted successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Project deleted successfully"})
} }
+55
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)
@@ -101,6 +106,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Sleep sweeper — scales idle services to zero and prunes wake containers. // Sleep sweeper — scales idle services to zero and prunes wake containers.
StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port) StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port)
// Audit retention — prunes rows past AUDIT_RETENTION_DAYS (default 90).
StartAuditRetention(context.Background(), db)
// Initialize database handler // Initialize database handler
databaseHandler := NewDatabaseHandler(db.DB, dockerClient) databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
@@ -244,6 +252,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// replaces session auth. Providers POST to /api/git/webhooks/:id. // replaces session auth. Providers POST to /api/git/webhooks/:id.
router.POST("/api/git/webhooks/:id", handleGitWebhookPush) router.POST("/api/git/webhooks/:id", handleGitWebhookPush)
// GitHub App-level webhook: installed repos deliver to this single
// endpoint, verified against the provisioned webhook secret.
router.POST("/api/git/github-app/webhook", handleGitHubAppWebhook)
v1 := router.Group("/api/v1") v1 := router.Group("/api/v1")
{ {
// Public routes (no authentication required) // Public routes (no authentication required)
@@ -253,6 +265,12 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
public.POST("/auth/login", handleLogin) public.POST("/auth/login", handleLogin)
public.POST("/auth/register", handleRegister) public.POST("/auth/register", handleRegister)
public.GET("/maintenance", handleMaintenancePage) public.GET("/maintenance", handleMaintenancePage)
public.GET("/branding", handleGetBranding)
// Invite accept flow — validates the link, then registers
// even while public signup is closed.
public.GET("/auth/invites/:token", handleGetInvite)
public.POST("/auth/accept-invite", handleAcceptInvite)
// Wake callback for sleep-mode placeholders. Unauthenticated but // Wake callback for sleep-mode placeholders. Unauthenticated but
// non-destructive: it only reconciles the service back to running. // non-destructive: it only reconciles the service back to running.
@@ -328,11 +346,28 @@ 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)
authed.GET("/activity", handleGetActivity)
authed.GET("/projects/:id/activity", handleGetProjectActivity)
// Instance announcements shown to all signed-in users
authed.GET("/banners/active", handleListActiveBanners)
// 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)
@@ -353,6 +388,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.POST("/deployments/:id/rollback", handleRollbackDeployment) authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
authed.POST("/deployments/:id/cancel", handleCancelDeployment) authed.POST("/deployments/:id/cancel", handleCancelDeployment)
// Interactive terminal — WebSocket → docker exec pty.
authed.GET("/services/:id/terminal", handleServiceTerminal)
// Environment variables — secrets, owner/admin only. // Environment variables — secrets, owner/admin only.
authed.GET("/services/:id/variables", handleGetVariables) authed.GET("/services/:id/variables", handleGetVariables)
authed.PUT("/services/:id/variables", handleUpdateVariables) authed.PUT("/services/:id/variables", handleUpdateVariables)
@@ -451,6 +489,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
admin.GET("/admin/overview", handleAdminOverview) admin.GET("/admin/overview", handleAdminOverview)
admin.GET("/admin/users", handleAdminListUsers) admin.GET("/admin/users", handleAdminListUsers)
admin.PATCH("/admin/users/:id", handleAdminSetUserAdmin) admin.PATCH("/admin/users/:id", handleAdminSetUserAdmin)
admin.POST("/admin/users/:id/impersonate", handleAdminImpersonateUser)
admin.PATCH("/admin/projects/:id", handleAdminSetProjectApproval) admin.PATCH("/admin/projects/:id", handleAdminSetProjectApproval)
admin.POST("/users", handleCreateUser) admin.POST("/users", handleCreateUser)
@@ -468,6 +507,22 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
admin.GET("/admin/volumes", handleAdminListVolumes) admin.GET("/admin/volumes", handleAdminListVolumes)
admin.DELETE("/admin/volumes/:name", handleAdminDeleteVolume) admin.DELETE("/admin/volumes/:name", handleAdminDeleteVolume)
// Announcement banners
admin.GET("/admin/banners", handleAdminListBanners)
admin.POST("/admin/banners", handleAdminCreateBanner)
admin.PATCH("/admin/banners/:id", handleAdminUpdateBanner)
admin.DELETE("/admin/banners/:id", handleAdminDeleteBanner)
// GitHub App self-provisioning (manifest flow)
admin.GET("/admin/git/github-app", handleAdminGetGitHubApp)
admin.POST("/admin/git/github-app/manifest", handleAdminGitHubAppManifest)
admin.POST("/admin/git/github-app/convert", handleAdminGitHubAppConvert)
// Team invites — single-use registration links
admin.GET("/admin/invites", handleAdminListInvites)
admin.POST("/admin/invites", handleAdminCreateInvite)
admin.DELETE("/admin/invites/:id", handleAdminDeleteInvite)
admin.GET("/audit-logs", handleGetAuditLogs) admin.GET("/audit-logs", handleGetAuditLogs)
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs) admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
+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"})
} }
+78
View File
@@ -23,10 +23,49 @@ const (
settingSignupEnabled = "signup_enabled" settingSignupEnabled = "signup_enabled"
settingCloudflareTunnelToken = "cloudflare_tunnel_token" settingCloudflareTunnelToken = "cloudflare_tunnel_token"
// White-label branding keys — unset fields fall back to Containr
// defaults. Self-hosters rebrand their instance via these.
settingBrandProductName = "branding.product_name"
settingBrandLogoURL = "branding.logo_url"
settingBrandFaviconURL = "branding.favicon_url"
settingBrandAccent = "branding.accent_color"
settingBrandDocsURL = "branding.docs_url"
settingBrandSupportURL = "branding.support_url"
cloudflaredContainerName = "containr-cloudflared" cloudflaredContainerName = "containr-cloudflared"
cloudflaredImage = "cloudflare/cloudflared:latest" cloudflaredImage = "cloudflare/cloudflared:latest"
) )
// brandingConfig is the public white-label payload. Fields are always
// populated — defaults keep the instance Containr-branded.
type brandingConfig struct {
ProductName string `json:"product_name"`
LogoURL string `json:"logo_url"`
FaviconURL string `json:"favicon_url"`
AccentColor string `json:"accent_color"`
DocsURL string `json:"docs_url"`
SupportURL string `json:"support_url"`
}
func resolveBranding(db *database.DB) brandingConfig {
return brandingConfig{
ProductName: settingValue(db, settingBrandProductName, "BRAND_NAME", "Containr"),
LogoURL: settingValue(db, settingBrandLogoURL, "BRAND_LOGO_URL", ""),
FaviconURL: settingValue(db, settingBrandFaviconURL, "BRAND_FAVICON_URL", ""),
AccentColor: settingValue(db, settingBrandAccent, "BRAND_ACCENT_COLOR", ""),
DocsURL: settingValue(db, settingBrandDocsURL, "BRAND_DOCS_URL", ""),
SupportURL: settingValue(db, settingBrandSupportURL, "BRAND_SUPPORT_URL", ""),
}
}
// GET /branding — public so the login page and shell can brand themselves
// before a session exists.
func handleGetBranding(c *gin.Context) {
db, _ := c.Get("db")
database, _ := db.(*database.DB)
c.JSON(http.StatusOK, resolveBranding(database))
}
// settingValue resolves a setting: app_settings row first, env fallback, // settingValue resolves a setting: app_settings row first, env fallback,
// then the caller's default. // then the caller's default.
func settingValue(db *database.DB, key, envKey, fallback string) string { func settingValue(db *database.DB, key, envKey, fallback string) string {
@@ -110,6 +149,7 @@ func handleGetSettings(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"signup_enabled": signupEnabled(db), "signup_enabled": signupEnabled(db),
"cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client), "cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client),
"branding": resolveBranding(db),
}) })
} }
@@ -122,6 +162,14 @@ func handleUpdateSettings(c *gin.Context) {
var req struct { var req struct {
SignupEnabled *bool `json:"signup_enabled"` SignupEnabled *bool `json:"signup_enabled"`
CloudflareTunnelToken *string `json:"cloudflare_tunnel_token"` CloudflareTunnelToken *string `json:"cloudflare_tunnel_token"`
Branding *struct {
ProductName *string `json:"product_name"`
LogoURL *string `json:"logo_url"`
FaviconURL *string `json:"favicon_url"`
AccentColor *string `json:"accent_color"`
DocsURL *string `json:"docs_url"`
SupportURL *string `json:"support_url"`
} `json:"branding"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -158,6 +206,36 @@ func handleUpdateSettings(c *gin.Context) {
} }
} }
if req.Branding != nil {
fields := []struct {
key string
value *string
}{
{settingBrandProductName, req.Branding.ProductName},
{settingBrandLogoURL, req.Branding.LogoURL},
{settingBrandFaviconURL, req.Branding.FaviconURL},
{settingBrandAccent, req.Branding.AccentColor},
{settingBrandDocsURL, req.Branding.DocsURL},
{settingBrandSupportURL, req.Branding.SupportURL},
}
for _, f := range fields {
if f.value == nil {
continue
}
v := strings.TrimSpace(*f.value)
var err error
if v == "" {
err = deleteSetting(db, f.key)
} else {
err = setSetting(db, f.key, v, false)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save branding"})
return
}
}
}
handleGetSettings(c) handleGetSettings(c)
} }
+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,18 +1157,16 @@ 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} func composeEnvironment(env interface{}, warnings *[]string, svcKey string) map[string]string {
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 {
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)
}
}
+212
View File
@@ -0,0 +1,212 @@
package api
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"sort"
"strings"
"sync"
"time"
"containr/internal/database"
"containr/internal/docker"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
// Interactive terminal: a WebSocket bridged to a `docker exec` pty in the
// service's container. Protocol is JSON text frames both ways:
// client → server: {"type":"stdin","data":"..."} | {"type":"resize","cols":N,"rows":N}
// server → client: {"type":"stdout","data":"..."} | {"type":"exit","code":N} | {"type":"error","message":"..."}
// Shell preference: $SHELL, then bash, then sh.
type terminalFrame struct {
Type string `json:"type"`
Data string `json:"data,omitempty"`
Cols uint `json:"cols,omitempty"`
Rows uint `json:"rows,omitempty"`
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
func handleServiceTerminal(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid service id")
return
}
projectID, found := serviceProjectID(db, serviceID)
if !found {
respondError(c, http.StatusNotFound, "NOT_FOUND", "service not found")
return
}
if _, allowed := projectManageAccess(c, db, projectID); !allowed {
respondError(c, http.StatusNotFound, "NOT_FOUND", "service not found")
return
}
dockerClient, _ := c.Get("docker_client")
client, ok := dockerClient.(*docker.Client)
if !ok || client == nil {
respondError(c, http.StatusServiceUnavailable, "DOCKER_UNAVAILABLE", "Docker is not configured")
return
}
ctx := c.Request.Context()
containers, err := client.ListContainersFiltered(ctx, filters.NewArgs(
filters.Arg("label", "containr.service="+serviceID.String()),
), false)
if err != nil || len(containers) == 0 {
respondError(c, http.StatusConflict, "NO_CONTAINER", "service has no running container")
return
}
// Prefer a running container. With several matches, deterministically
// pick the first `containr-<service>-<n>` replica by name — that is the
// actual workload naming; auxiliary containers get ignored.
target := ""
sort.Slice(containers, func(i, j int) bool { return containers[i].Names[0] < containers[j].Names[0] })
for _, ctr := range containers {
if ctr.State == "running" && strings.HasPrefix(ctr.Names[0], "/containr-") {
target = ctr.ID
break
}
}
if target == "" {
for _, ctr := range containers {
if ctr.State == "running" {
target = ctr.ID
break
}
}
}
if target == "" {
target = containers[0].ID
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
send := func(f terminalFrame) bool {
return conn.WriteJSON(f) == nil
}
// Pick a shell that actually exists — distroless images may have none.
// `command -v` can't probe without a shell, so start each candidate and
// watch for the OCI "no such file" failure.
var stream types.HijackedResponse
var execID string
attached := false
for _, candidate := range []string{"/bin/bash", "/bin/sh", "sh"} {
exec, err := client.ExecCreate(ctx, target, docker.ExecConfig{
Cmd: []string{candidate},
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
Env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"},
})
if err != nil {
continue
}
s, err := client.ExecAttach(ctx, exec.ID)
if err != nil {
continue
}
if execStartFailed(ctx, client, exec.ID, s) {
s.Close()
continue
}
stream, execID, attached = s, exec.ID, true
break
}
if !attached {
send(terminalFrame{Type: "error", Message: "no shell available in this container image"})
return
}
defer stream.Close()
// The client sends a resize frame with its real size right after
// connecting; start at a sane default meanwhile.
_ = client.ExecResize(ctx, execID, 80, 24)
var wsWrite sync.Mutex
writeJSON := func(f terminalFrame) {
wsWrite.Lock()
defer wsWrite.Unlock()
_ = conn.WriteJSON(f)
}
// Container → client.
go func() {
buf := make([]byte, 8192)
for {
n, err := stream.Reader.Read(buf)
if n > 0 {
writeJSON(terminalFrame{Type: "stdout", Data: string(buf[:n])})
}
if err != nil {
if err != io.EOF {
log.Printf("terminal: exec stream ended: %v", err)
}
inspect, ierr := client.ExecInspect(context.Background(), execID)
code := 0
if ierr == nil {
code = inspect.ExitCode
}
writeJSON(terminalFrame{Type: "exit", Code: code})
_ = conn.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "session ended"),
time.Now().Add(time.Second))
return
}
}
}()
// Client → container.
for {
_, raw, err := conn.ReadMessage()
if err != nil {
return
}
var f terminalFrame
if err := json.Unmarshal(raw, &f); err != nil {
continue
}
switch f.Type {
case "stdin":
if _, err := stream.Conn.Write([]byte(f.Data)); err != nil {
return
}
case "resize":
_ = client.ExecResize(ctx, execID, f.Cols, f.Rows)
case "ping":
writeJSON(terminalFrame{Type: "pong"})
}
}
}
// execStartFailed reports whether an attached exec failed to launch
// (missing binary → non-zero exit, not running). The OCI failure takes a
// moment to surface, so a short grace period elapses before judging.
func execStartFailed(ctx context.Context, client *docker.Client, execID string, _ types.HijackedResponse) bool {
select {
case <-ctx.Done():
return true
case <-time.After(500 * time.Millisecond):
}
inspect, err := client.ExecInspect(ctx, execID)
if err != nil {
return false
}
return !inspect.Running && inspect.ExitCode != 0
}
+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,67 @@
package commands
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
var (
activitySeverity string
activityCategory string
activityResource string
activityProject string
activityLimit int
)
// ActivityCmd shows the enriched activity feed (audit events with
// severity/category/label).
var ActivityCmd = &cobra.Command{
Use: "activity",
Short: "Activity feed — enriched audit events",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
params := []string{fmt.Sprintf("limit=%d", activityLimit)}
if activitySeverity != "" {
params = append(params, "severity="+activitySeverity)
}
if activityCategory != "" {
params = append(params, "category="+activityCategory)
}
if activityResource != "" {
params = append(params, "resource="+activityResource)
}
if activityProject != "" {
params = append(params, "project_id="+activityProject)
}
data, err := c.Do("GET", "/activity?"+strings.Join(params, "&"), nil)
if err != nil {
return err
}
items, err := unwrapList(data, "activity")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, e := range items {
rows = append(rows, []string{
str(e, "severity"), str(e, "category"), str(e, "label"),
str(e, "user_email"), relTime(e, "created_at"),
})
}
printRows(data, []string{"SEVERITY", "CATEGORY", "EVENT", "ACTOR", "WHEN"}, rows)
return nil
},
}
func init() {
ActivityCmd.Flags().StringVar(&activitySeverity, "severity", "", "filter: info|success|warning|error")
ActivityCmd.Flags().StringVar(&activityCategory, "category", "", "filter by category")
ActivityCmd.Flags().StringVar(&activityResource, "resource", "", "filter by resource type")
ActivityCmd.Flags().StringVar(&activityProject, "project", "", "filter by project id")
ActivityCmd.Flags().IntVar(&activityLimit, "limit", 50, "max events")
}
+85 -1
View File
@@ -56,6 +56,84 @@ var adminUsersCmd = &cobra.Command{
}, },
} }
var adminImpersonateCmd = &cobra.Command{
Use: "impersonate <user-id>",
Short: "Mint a 15-minute token acting as a user (audit-logged)",
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", "/admin/users/"+args[0]+"/impersonate", map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
fmt.Fprintln(cmd.ErrOrStderr(), "\nUse it with: CONTAINR_TOKEN=<token> containr <command>")
return nil
},
}
var adminGitHubAppCmd = &cobra.Command{
Use: "github-app",
Short: "GitHub App self-provisioning",
}
var adminGitHubAppStatusCmd = &cobra.Command{
Use: "status",
Short: "Show GitHub App provisioning status",
RunE: simpleGet("/admin/git/github-app"),
}
var adminGitHubAppManifestCmd = &cobra.Command{
Use: "manifest",
Short: "Build the manifest to create the instance GitHub App",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
req := map[string]interface{}{"base_url": ghAppBaseURL}
if ghAppOrg != "" {
req["organization"] = ghAppOrg
}
if ghAppName != "" {
req["name"] = ghAppName
}
data, err := c.Do("POST", "/admin/git/github-app/manifest", req)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var adminGitHubAppConvertCmd = &cobra.Command{
Use: "convert <code>",
Short: "Exchange the manifest callback code for app credentials",
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", "/admin/git/github-app/convert", map[string]interface{}{"code": args[0]})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var (
ghAppBaseURL string
ghAppOrg string
ghAppName string
)
var adminSettingsCmd = &cobra.Command{ var adminSettingsCmd = &cobra.Command{
Use: "settings", Use: "settings",
Short: "Show platform settings", Short: "Show platform settings",
@@ -353,7 +431,13 @@ func init() {
securityScanCmd.Flags().String("type", "configuration", "dependency|configuration|comprehensive") securityScanCmd.Flags().String("type", "configuration", "dependency|configuration|comprehensive")
securityScanCmd.Flags().String("service", "", "limit scan to one service") securityScanCmd.Flags().String("service", "", "limit scan to one service")
AdminCmd.AddCommand(adminOverviewCmd, adminUsersCmd, adminSettingsCmd, adminAuditCmd) adminGitHubAppManifestCmd.Flags().StringVar(&ghAppBaseURL, "base-url", "", "public base URL of this instance (required)")
adminGitHubAppManifestCmd.Flags().StringVar(&ghAppOrg, "org", "", "create the app under this organization")
adminGitHubAppManifestCmd.Flags().StringVar(&ghAppName, "name", "", "app name (defaults to product name + date)")
adminGitHubAppManifestCmd.MarkFlagRequired("base-url")
adminGitHubAppCmd.AddCommand(adminGitHubAppStatusCmd, adminGitHubAppManifestCmd, adminGitHubAppConvertCmd)
AdminCmd.AddCommand(adminOverviewCmd, adminUsersCmd, adminSettingsCmd, adminAuditCmd, adminImpersonateCmd, adminGitHubAppCmd)
GatewayCmd.AddCommand(gatewayServicesCmd, gatewayKeysCmd, gatewayAnalyticsCmd) GatewayCmd.AddCommand(gatewayServicesCmd, gatewayKeysCmd, gatewayAnalyticsCmd)
HACmd.AddCommand(haStatusCmd, haAlertsCmd, haHealthCmd, haPoliciesCmd, haEnableCmd, haDisableCmd, haFailoverCmd) HACmd.AddCommand(haStatusCmd, haAlertsCmd, haHealthCmd, haPoliciesCmd, haEnableCmd, haDisableCmd, haFailoverCmd)
ScalingCmd.AddCommand(scalingStatusCmd, scalingPoliciesCmd, scalingServicesCmd, scalingScaleCmd) ScalingCmd.AddCommand(scalingStatusCmd, scalingPoliciesCmd, scalingServicesCmd, scalingScaleCmd)
+16
View File
@@ -147,5 +147,21 @@ func runStatus(cmd *cobra.Command, args []string) error {
if p.IsAdmin { if p.IsAdmin {
fmt.Println("Role: platform admin") fmt.Println("Role: platform admin")
} }
var bannersResp struct {
Banners []struct {
Title string `json:"title"`
Body string `json:"body"`
Level string `json:"level"`
} `json:"banners"`
}
if err := client.DoJSON("GET", "/banners/active", nil, &bannersResp); err == nil {
for _, b := range bannersResp.Banners {
fmt.Printf("[%s] %s\n", b.Level, b.Title)
if b.Body != "" {
fmt.Printf(" %s\n", b.Body)
}
}
}
return nil return nil
} }
@@ -0,0 +1,133 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
var (
bannerTitle string
bannerLevel string
bannerBody string
bannerActive bool
)
// BannersCmd manages instance-wide announcement banners (admin).
var BannersCmd = &cobra.Command{
Use: "banners",
Short: "Manage announcement banners (admin)",
}
var bannerListCmd = &cobra.Command{
Use: "list",
Short: "List all banners",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/admin/banners", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "banners")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, b := range items {
rows = append(rows, []string{
str(b, "id"), str(b, "title"), str(b, "level"), str(b, "active"),
})
}
printRows(data, []string{"ID", "TITLE", "LEVEL", "ACTIVE"}, rows)
return nil
},
}
var bannerCreateCmd = &cobra.Command{
Use: "create <title>",
Short: "Create a banner (--body --level info|success|warning|error)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
req := map[string]interface{}{"title": args[0]}
if cmd.Flags().Changed("body") {
req["body"] = bannerBody
}
if cmd.Flags().Changed("level") {
req["level"] = bannerLevel
}
data, err := c.Do("POST", "/admin/banners", req)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var bannerUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a banner (--title --body --level --active/--inactive)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
req := map[string]interface{}{}
if cmd.Flags().Changed("title") {
req["title"] = bannerTitle
}
if cmd.Flags().Changed("body") {
req["body"] = bannerBody
}
if cmd.Flags().Changed("level") {
req["level"] = bannerLevel
}
if cmd.Flags().Changed("active") {
req["active"] = bannerActive
}
if len(req) == 0 {
return fmt.Errorf("nothing to update — pass --title/--body/--level/--active")
}
data, err := c.Do("PATCH", "/admin/banners/"+args[0], req)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var bannerDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a banner",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !Confirm("Delete banner " + args[0] + "?") {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
c, err := client()
if err != nil {
return err
}
_, err = c.Do("DELETE", "/admin/banners/"+args[0], nil)
return err
},
}
func init() {
bannerCreateCmd.Flags().StringVar(&bannerBody, "body", "", "banner body text")
bannerCreateCmd.Flags().StringVar(&bannerLevel, "level", "info", "info|success|warning|error")
bannerUpdateCmd.Flags().StringVar(&bannerTitle, "title", "", "new title")
bannerUpdateCmd.Flags().StringVar(&bannerBody, "body", "", "new body")
bannerUpdateCmd.Flags().StringVar(&bannerLevel, "level", "", "info|success|warning|error")
bannerUpdateCmd.Flags().BoolVar(&bannerActive, "active", true, "set banner active/inactive")
BannersCmd.AddCommand(bannerListCmd, bannerCreateCmd, bannerUpdateCmd, bannerDeleteCmd)
}
+113
View File
@@ -0,0 +1,113 @@
package commands
import (
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gorilla/websocket"
"github.com/spf13/cobra"
"golang.org/x/term"
)
// ShellCmd opens an interactive shell inside a service's running container.
var ShellCmd = &cobra.Command{
Use: "shell <service-id>",
Short: "Interactive shell inside the service container",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
serviceID := args[0]
cli, err := client()
if err != nil {
return err
}
base := strings.TrimRight(cli.BaseURL, "/")
wsURL := strings.Replace(base, "http", "ws", 1) + "/services/" + serviceID + "/terminal"
header := http.Header{}
header.Set("Authorization", "Bearer "+cli.Token)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, header)
if err != nil {
return fmt.Errorf("terminal: %w", err)
}
defer conn.Close()
fd := int(os.Stdin.Fd())
raw := term.IsTerminal(fd)
var oldState *term.State
if raw {
oldState, err = term.MakeRaw(fd)
if err == nil {
defer term.Restore(fd, oldState)
}
if w, h, err := term.GetSize(fd); err == nil {
_ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h})
}
// Forward SIGWINCH resizes.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGWINCH)
defer signal.Stop(sigCh)
go func() {
for range sigCh {
if w, h, err := term.GetSize(fd); err == nil {
_ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h})
}
}
}()
}
done := make(chan struct{})
// stdin → ws
go func() {
buf := make([]byte, 4096)
for {
n, err := os.Stdin.Read(buf)
if n > 0 {
if werr := conn.WriteJSON(map[string]interface{}{
"type": "stdin", "data": string(buf[:n]),
}); werr != nil {
return
}
}
if err != nil {
return
}
}
}()
// ws → stdout
go func() {
defer close(done)
for {
var frame struct {
Type string `json:"type"`
Data string `json:"data"`
Code int `json:"code"`
Message string `json:"message"`
}
if err := conn.ReadJSON(&frame); err != nil {
return
}
switch frame.Type {
case "stdout":
_, _ = os.Stdout.WriteString(frame.Data)
case "exit":
fmt.Fprintf(os.Stderr, "\r\n[session ended — exit %d]\r\n", frame.Code)
return
case "error":
fmt.Fprintf(os.Stderr, "\r\nerror: %s\r\n", frame.Message)
return
}
}
}()
select {
case <-done:
case <-time.After(24 * time.Hour):
}
return nil
},
}
@@ -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,92 @@
package commands
import (
"github.com/spf13/cobra"
)
var inviteEmail string
var inviteTTL int
// InvitesCmd manages team invite links (admin).
var InvitesCmd = &cobra.Command{
Use: "invites",
Short: "Manage team invite links (admin)",
}
var inviteListCmd = &cobra.Command{
Use: "list",
Short: "List invites",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/admin/invites", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "invites")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, i := range items {
state := "open"
if _, used := i["used_at"]; used && i["used_at"] != nil {
state = "used"
}
rows = append(rows, []string{
str(i, "id"), str(i, "email"), str(i, "expires_at"), state,
})
}
printRows(data, []string{"ID", "EMAIL", "EXPIRES", "STATE"}, rows)
return nil
},
}
var inviteCreateCmd = &cobra.Command{
Use: "create",
Short: "Create an invite link (--email to bind, --hours for TTL)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
req := map[string]interface{}{}
if cmd.Flags().Changed("email") {
req["email"] = inviteEmail
}
if cmd.Flags().Changed("hours") {
req["expires_in_hours"] = inviteTTL
}
data, err := c.Do("POST", "/admin/invites", req)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var inviteRevokeCmd = &cobra.Command{
Use: "revoke <id>",
Short: "Revoke an invite",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !Confirm("Revoke invite " + args[0] + "?") {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
c, err := client()
if err != nil {
return err
}
_, err = c.Do("DELETE", "/admin/invites/"+args[0], nil)
return err
},
}
func init() {
inviteCreateCmd.Flags().StringVar(&inviteEmail, "email", "", "bind invite to this email")
inviteCreateCmd.Flags().IntVar(&inviteTTL, "hours", 168, "invite lifetime in hours (1-2160)")
InvitesCmd.AddCommand(inviteListCmd, inviteCreateCmd, inviteRevokeCmd)
}
@@ -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)
}
+7
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,12 @@ 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.ActivityCmd)
rootCmd.AddCommand(commands.ShellCmd)
rootCmd.AddCommand(commands.BannersCmd)
rootCmd.AddCommand(commands.InvitesCmd)
rootCmd.AddCommand(commands.UpCmd) rootCmd.AddCommand(commands.UpCmd)
} }
@@ -0,0 +1,231 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: banners.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const createBanner = `-- name: CreateBanner :one
INSERT INTO banners (title, body, level, active, dismissible, created_by, starts_at, ends_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
`
type CreateBannerParams struct {
Title string `json:"title"`
Body string `json:"body"`
Level string `json:"level"`
Active bool `json:"active"`
Dismissible bool `json:"dismissible"`
CreatedBy uuid.NullUUID `json:"created_by"`
StartsAt sql.NullTime `json:"starts_at"`
EndsAt sql.NullTime `json:"ends_at"`
}
func (q *Queries) CreateBanner(ctx context.Context, arg CreateBannerParams) (Banner, error) {
row := q.db.QueryRowContext(ctx, createBanner,
arg.Title,
arg.Body,
arg.Level,
arg.Active,
arg.Dismissible,
arg.CreatedBy,
arg.StartsAt,
arg.EndsAt,
)
var i Banner
err := row.Scan(
&i.ID,
&i.Title,
&i.Body,
&i.Level,
&i.Active,
&i.Dismissible,
&i.CreatedBy,
&i.StartsAt,
&i.EndsAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteBanner = `-- name: DeleteBanner :exec
DELETE FROM banners
WHERE id = $1
`
func (q *Queries) DeleteBanner(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteBanner, id)
return err
}
const getBannerByID = `-- name: GetBannerByID :one
SELECT banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
FROM banners
WHERE id = $1
`
func (q *Queries) GetBannerByID(ctx context.Context, id uuid.UUID) (Banner, error) {
row := q.db.QueryRowContext(ctx, getBannerByID, id)
var i Banner
err := row.Scan(
&i.ID,
&i.Title,
&i.Body,
&i.Level,
&i.Active,
&i.Dismissible,
&i.CreatedBy,
&i.StartsAt,
&i.EndsAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listActiveBanners = `-- name: ListActiveBanners :many
SELECT banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
FROM banners
WHERE active
AND (starts_at IS NULL OR starts_at <= now())
AND (ends_at IS NULL OR ends_at > now())
ORDER BY created_at DESC
`
func (q *Queries) ListActiveBanners(ctx context.Context) ([]Banner, error) {
rows, err := q.db.QueryContext(ctx, listActiveBanners)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Banner{}
for rows.Next() {
var i Banner
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Body,
&i.Level,
&i.Active,
&i.Dismissible,
&i.CreatedBy,
&i.StartsAt,
&i.EndsAt,
&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 listBanners = `-- name: ListBanners :many
SELECT banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
FROM banners
ORDER BY created_at DESC
`
func (q *Queries) ListBanners(ctx context.Context) ([]Banner, error) {
rows, err := q.db.QueryContext(ctx, listBanners)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Banner{}
for rows.Next() {
var i Banner
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Body,
&i.Level,
&i.Active,
&i.Dismissible,
&i.CreatedBy,
&i.StartsAt,
&i.EndsAt,
&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 updateBanner = `-- name: UpdateBanner :one
UPDATE banners
SET title = $2,
body = $3,
level = $4,
active = $5,
dismissible = $6,
starts_at = $7,
ends_at = $8,
updated_at = now()
WHERE id = $1
RETURNING banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
`
type UpdateBannerParams struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Level string `json:"level"`
Active bool `json:"active"`
Dismissible bool `json:"dismissible"`
StartsAt sql.NullTime `json:"starts_at"`
EndsAt sql.NullTime `json:"ends_at"`
}
func (q *Queries) UpdateBanner(ctx context.Context, arg UpdateBannerParams) (Banner, error) {
row := q.db.QueryRowContext(ctx, updateBanner,
arg.ID,
arg.Title,
arg.Body,
arg.Level,
arg.Active,
arg.Dismissible,
arg.StartsAt,
arg.EndsAt,
)
var i Banner
err := row.Scan(
&i.ID,
&i.Title,
&i.Body,
&i.Level,
&i.Active,
&i.Dismissible,
&i.CreatedBy,
&i.StartsAt,
&i.EndsAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -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,8 +57,8 @@ 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 {
@@ -69,6 +71,7 @@ type CreateDatabaseServiceParams struct {
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"`
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"`
} }
@@ -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 {
@@ -0,0 +1,134 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: invites.sql
package sqlcdb
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
const createUserInvite = `-- name: CreateUserInvite :one
INSERT INTO user_invites (token_hash, email, created_by, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING user_invites.id, user_invites.token_hash, user_invites.email, user_invites.created_by, user_invites.expires_at, user_invites.used_by, user_invites.used_at, user_invites.created_at
`
type CreateUserInviteParams struct {
TokenHash string `json:"token_hash"`
Email sql.NullString `json:"email"`
CreatedBy uuid.NullUUID `json:"created_by"`
ExpiresAt time.Time `json:"expires_at"`
}
func (q *Queries) CreateUserInvite(ctx context.Context, arg CreateUserInviteParams) (UserInvite, error) {
row := q.db.QueryRowContext(ctx, createUserInvite,
arg.TokenHash,
arg.Email,
arg.CreatedBy,
arg.ExpiresAt,
)
var i UserInvite
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Email,
&i.CreatedBy,
&i.ExpiresAt,
&i.UsedBy,
&i.UsedAt,
&i.CreatedAt,
)
return i, err
}
const deleteUserInvite = `-- name: DeleteUserInvite :exec
DELETE FROM user_invites
WHERE id = $1
`
func (q *Queries) DeleteUserInvite(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteUserInvite, id)
return err
}
const getUserInviteByTokenHash = `-- name: GetUserInviteByTokenHash :one
SELECT user_invites.id, user_invites.token_hash, user_invites.email, user_invites.created_by, user_invites.expires_at, user_invites.used_by, user_invites.used_at, user_invites.created_at
FROM user_invites
WHERE token_hash = $1
`
func (q *Queries) GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error) {
row := q.db.QueryRowContext(ctx, getUserInviteByTokenHash, tokenHash)
var i UserInvite
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Email,
&i.CreatedBy,
&i.ExpiresAt,
&i.UsedBy,
&i.UsedAt,
&i.CreatedAt,
)
return i, err
}
const listUserInvites = `-- name: ListUserInvites :many
SELECT user_invites.id, user_invites.token_hash, user_invites.email, user_invites.created_by, user_invites.expires_at, user_invites.used_by, user_invites.used_at, user_invites.created_at
FROM user_invites
ORDER BY created_at DESC
`
func (q *Queries) ListUserInvites(ctx context.Context) ([]UserInvite, error) {
rows, err := q.db.QueryContext(ctx, listUserInvites)
if err != nil {
return nil, err
}
defer rows.Close()
items := []UserInvite{}
for rows.Next() {
var i UserInvite
if err := rows.Scan(
&i.ID,
&i.TokenHash,
&i.Email,
&i.CreatedBy,
&i.ExpiresAt,
&i.UsedBy,
&i.UsedAt,
&i.CreatedAt,
); 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 markUserInviteUsed = `-- name: MarkUserInviteUsed :exec
UPDATE user_invites
SET used_by = $2, used_at = now()
WHERE id = $1 AND used_at IS NULL
`
type MarkUserInviteUsedParams struct {
ID uuid.UUID `json:"id"`
UsedBy uuid.NullUUID `json:"used_by"`
}
func (q *Queries) MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error {
_, err := q.db.ExecContext(ctx, markUserInviteUsed, arg.ID, arg.UsedBy)
return err
}
@@ -64,6 +64,20 @@ type BackupTarget struct {
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
type Banner struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Level string `json:"level"`
Active bool `json:"active"`
Dismissible bool `json:"dismissible"`
CreatedBy uuid.NullUUID `json:"created_by"`
StartsAt sql.NullTime `json:"starts_at"`
EndsAt sql.NullTime `json:"ends_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type ContainerInstance struct { type ContainerInstance struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -116,6 +130,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 +189,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"`
@@ -273,6 +301,17 @@ type ServiceTemplate struct {
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
type UserInvite struct {
ID uuid.UUID `json:"id"`
TokenHash string `json:"token_hash"`
Email sql.NullString `json:"email"`
CreatedBy uuid.NullUUID `json:"created_by"`
ExpiresAt time.Time `json:"expires_at"`
UsedBy uuid.NullUUID `json:"used_by"`
UsedAt sql.NullTime `json:"used_at"`
CreatedAt sql.NullTime `json:"created_at"`
}
type UserToken struct { type UserToken struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"` UserID uuid.UUID `json:"user_id"`
@@ -285,3 +324,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"`
}
@@ -21,27 +21,36 @@ type Querier interface {
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error) CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error
CreateBanner(ctx context.Context, arg CreateBannerParams) (Banner, error)
CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error) CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error)
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error) CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
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
CreateUserInvite(ctx context.Context, arg CreateUserInviteParams) (UserInvite, 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
DeleteBanner(ctx context.Context, id uuid.UUID) 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)
DeleteUserInvite(ctx context.Context, id uuid.UUID) 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)
GetAgent(ctx context.Context, id string) (NodeAgent, error) GetAgent(ctx context.Context, id string) (NodeAgent, error)
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error) GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error)
GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error) GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error)
GetBannerByID(ctx context.Context, id uuid.UUID) (Banner, error)
GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error) GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error)
GetContainer(ctx context.Context, id string) (ContainerInstance, error) GetContainer(ctx context.Context, id string) (ContainerInstance, error)
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error) GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
@@ -49,25 +58,33 @@ 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)
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error) GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error)
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
ListActiveBanners(ctx context.Context) ([]Banner, error)
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error) ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
ListAgents(ctx context.Context) ([]NodeAgent, error) ListAgents(ctx context.Context) ([]NodeAgent, error)
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error) ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error) ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
ListBanners(ctx context.Context) ([]Banner, error)
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error) ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
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
@@ -76,9 +93,12 @@ type Querier interface {
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
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)
ListUserInvites(ctx context.Context) ([]UserInvite, 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
MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
@@ -96,12 +116,15 @@ type Querier interface {
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error
UpdateBanner(ctx context.Context, arg UpdateBannerParams) (Banner, error)
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
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()
+15
View File
@@ -516,6 +516,21 @@ func (c *Client) ExecRun(ctx context.Context, containerID string, cmd []string)
return buf.String(), inspect.ExitCode, nil return buf.String(), inspect.ExitCode, nil
} }
// ExecAttach attaches to a running exec instance, returning the hijacked
// bidirectional stream. With Tty enabled the stream carries raw pty data
// (no stdcopy multiplexing).
func (c *Client) ExecAttach(ctx context.Context, execID string) (types.HijackedResponse, error) {
return c.cli.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{})
}
// ExecResize resizes the pty of a running exec instance.
func (c *Client) ExecResize(ctx context.Context, execID string, cols, rows uint) error {
return c.cli.ContainerExecResize(ctx, execID, container.ResizeOptions{
Height: rows,
Width: cols,
})
}
// GetImageInfo returns information about a Docker image // GetImageInfo returns information about a Docker image
func (c *Client) GetImageInfo(ctx context.Context, imageName string) (*ImageInfo, error) { func (c *Client) GetImageInfo(ctx context.Context, imageName string) (*ImageInfo, error) {
images, err := c.cli.ImageList(ctx, image.ListOptions{}) images, err := c.cli.ImageList(ctx, image.ListOptions{})
+29
View File
@@ -134,11 +134,40 @@ 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})
register(spec{"containr_banners_active", "List active announcement banners shown to users.", "GET", "/banners/active", nil, nil, false})
register(spec{"containr_banners_list", "List all announcement banners (admin).", "GET", "/admin/banners", nil, nil, false})
register(spec{"containr_banners_create", "Create an announcement banner (admin). Args: title, body, level (info|success|warning|error), active, dismissible, starts_at, ends_at.", "POST", "/admin/banners", nil, []string{"title", "body", "level", "active", "dismissible", "starts_at", "ends_at"}, false})
register(spec{"containr_banners_update", "Update an announcement banner (admin). Args: id plus fields to change.", "PATCH", "/admin/banners/{id}", []string{"id"}, []string{"title", "body", "level", "active", "dismissible", "starts_at", "ends_at"}, false})
register(spec{"containr_banners_delete", "Delete an announcement banner (admin). Args: id. Requires confirm=true.", "DELETE", "/admin/banners/{id}", []string{"id"}, nil, true})
register(spec{"containr_branding_get", "Get instance white-label branding (product name, logo, accent, links).", "GET", "/branding", nil, nil, false})
register(spec{"containr_settings_update", "Update platform settings (admin). Args: signup_enabled (bool), cloudflare_tunnel_token (string, empty clears), branding (object: product_name, logo_url, favicon_url, accent_color, docs_url, support_url — empty string resets a field).", "PUT", "/settings", nil, []string{"signup_enabled", "cloudflare_tunnel_token", "branding"}, false})
register(spec{"containr_invites_list", "List team invite links (admin).", "GET", "/admin/invites", nil, nil, false})
register(spec{"containr_invites_create", "Create a team invite link (admin). Args: email (optional bind), expires_in_hours (default 168). Returns the one-time token + accept URL.", "POST", "/admin/invites", nil, []string{"email", "expires_in_hours"}, false})
register(spec{"containr_invites_revoke", "Revoke a team invite (admin). Args: id. Requires confirm=true.", "DELETE", "/admin/invites/{id}", []string{"id"}, nil, true})
register(spec{"containr_admin_impersonate", "Mint a 15-minute bearer token acting as a user (admin, audit-logged). Args: id (user id).", "POST", "/admin/users/{id}/impersonate", []string{"id"}, nil, false})
register(spec{"containr_github_app_status", "Show GitHub App provisioning status (admin).", "GET", "/admin/git/github-app", nil, nil, false})
register(spec{"containr_github_app_manifest", "Build a GitHub App manifest for self-provisioning (admin). Args: base_url (required, https public URL), name (optional), organization (optional).", "POST", "/admin/git/github-app/manifest", nil, []string{"base_url", "name", "organization"}, false})
register(spec{"containr_github_app_convert", "Exchange the GitHub manifest callback code for app credentials (admin). Args: code.", "POST", "/admin/git/github-app/convert", nil, []string{"code"}, false})
register(spec{"containr_activity_list", "Activity feed — enriched audit events (severity/category/label). Args: severity, category, resource, project_id, since, page, limit.", "GET", "/activity", nil, []string{"severity", "category", "resource", "project_id", "since", "page", "limit"}, false})
register(spec{"containr_project_activity", "Per-project activity timeline. Args: id (project id), page, limit.", "GET", "/projects/{id}/activity", []string{"id"}, []string{"page", "limit"}, 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;
@@ -0,0 +1,21 @@
-- +goose Up
-- Instance-wide announcement banners. Admins create them; active ones are
-- shown to every signed-in user in the web UI and surfaced in the CLI.
CREATE TABLE IF NOT EXISTS banners (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title varchar(255) NOT NULL,
body text NOT NULL DEFAULT '',
level varchar(20) NOT NULL DEFAULT 'info',
active boolean NOT NULL DEFAULT true,
dismissible boolean NOT NULL DEFAULT true,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
starts_at timestamptz,
ends_at timestamptz,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_banners_active ON banners(active);
-- +goose Down
DROP TABLE IF EXISTS banners;
@@ -0,0 +1,19 @@
-- +goose Up
-- Team invites: admin-generated single-use links that let a new user
-- register even while public signup is closed. Tokens are sha256-hashed
-- at rest like PATs.
CREATE TABLE IF NOT EXISTS user_invites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash varchar(64) NOT NULL UNIQUE,
email varchar(255),
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
expires_at timestamptz NOT NULL,
used_by uuid REFERENCES users(id) ON DELETE SET NULL,
used_at timestamptz,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_user_invites_expires ON user_invites(expires_at);
-- +goose Down
DROP TABLE IF EXISTS user_invites;
@@ -0,0 +1,15 @@
-- +goose Up
ALTER TABLE audit_logs
ADD COLUMN IF NOT EXISTS severity TEXT NOT NULL DEFAULT 'info',
ADD COLUMN IF NOT EXISTS category TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS label TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_audit_logs_feed
ON audit_logs (created_at DESC) INCLUDE (severity, category, resource, action);
-- +goose Down
DROP INDEX IF EXISTS idx_audit_logs_feed;
ALTER TABLE audit_logs
DROP COLUMN IF EXISTS severity,
DROP COLUMN IF EXISTS category,
DROP COLUMN IF EXISTS label;
+39
View File
@@ -0,0 +1,39 @@
-- name: ListBanners :many
SELECT banners.*
FROM banners
ORDER BY created_at DESC;
-- name: ListActiveBanners :many
SELECT banners.*
FROM banners
WHERE active
AND (starts_at IS NULL OR starts_at <= now())
AND (ends_at IS NULL OR ends_at > now())
ORDER BY created_at DESC;
-- name: GetBannerByID :one
SELECT banners.*
FROM banners
WHERE id = $1;
-- name: CreateBanner :one
INSERT INTO banners (title, body, level, active, dismissible, created_by, starts_at, ends_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING banners.*;
-- name: UpdateBanner :one
UPDATE banners
SET title = $2,
body = $3,
level = $4,
active = $5,
dismissible = $6,
starts_at = $7,
ends_at = $8,
updated_at = now()
WHERE id = $1
RETURNING banners.*;
-- name: DeleteBanner :exec
DELETE FROM banners
WHERE id = $1;
+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
+23
View File
@@ -0,0 +1,23 @@
-- name: CreateUserInvite :one
INSERT INTO user_invites (token_hash, email, created_by, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING user_invites.*;
-- name: GetUserInviteByTokenHash :one
SELECT user_invites.*
FROM user_invites
WHERE token_hash = $1;
-- name: ListUserInvites :many
SELECT user_invites.*
FROM user_invites
ORDER BY created_at DESC;
-- name: MarkUserInviteUsed :exec
UPDATE user_invites
SET used_by = $2, used_at = now()
WHERE id = $1 AND used_at IS NULL;
-- name: DeleteUserInvite :exec
DELETE FROM user_invites
WHERE id = $1;
+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;
+53
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,55 @@ 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
);
CREATE TABLE banners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL DEFAULT '',
level VARCHAR(20) NOT NULL DEFAULT 'info',
active BOOLEAN NOT NULL DEFAULT true,
dismissible BOOLEAN NOT NULL DEFAULT true,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
starts_at TIMESTAMP WITH TIME ZONE,
ends_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE user_invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash VARCHAR(64) NOT NULL UNIQUE,
email VARCHAR(255),
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_by UUID REFERENCES users(id) ON DELETE SET NULL,
used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
+17
View File
@@ -33,6 +33,8 @@
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"axios": "^1.7.9", "axios": "^1.7.9",
@@ -4262,6 +4264,21 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/@xyflow/react": { "node_modules/@xyflow/react": {
"version": "12.10.1", "version": "12.10.1",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz",
+2
View File
@@ -44,6 +44,8 @@
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"axios": "^1.7.9", "axios": "^1.7.9",
+12 -1
View File
@@ -9,8 +9,12 @@ 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 { ActivityPage } from '@/features/activity/pages/ActivityPage';
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 { useBranding } from '@/lib/use-branding';
import { import {
DocsPage, DocsPage,
PeoplePage, PeoplePage,
@@ -20,8 +24,9 @@ import {
HighAvailabilityPage, HighAvailabilityPage,
SecurityPage, SecurityPage,
ComponentShowcase, ComponentShowcase,
GitHubAppCallbackPage,
} from '@/features/secondary/pages'; } from '@/features/secondary/pages';
import { SignInPage, SignUpPage } from '@/features/auth/pages'; import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { getCurrentUserProfile } from '@/lib/api-client'; import { getCurrentUserProfile } from '@/lib/api-client';
@@ -124,12 +129,14 @@ function NotFoundPage() {
} }
export default function App() { export default function App() {
useBranding();
return ( return (
<ErrorBoundary> <ErrorBoundary>
<Routes> <Routes>
<Route path="/" element={<LandingPage />} /> <Route path="/" element={<LandingPage />} />
<Route path="/auth/sign-in" element={<SignInPage />} /> <Route path="/auth/sign-in" element={<SignInPage />} />
<Route path="/auth/sign-up" element={<SignUpPage />} /> <Route path="/auth/sign-up" element={<SignUpPage />} />
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} />
{/* Public browsing — approved projects, templates, docs. */} {/* Public browsing — approved projects, templates, docs. */}
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */} {/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
@@ -141,6 +148,8 @@ 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="/activity" element={<ActivityPage />} />
<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 />} />
@@ -150,7 +159,9 @@ export default function App() {
<Route path="/builds" element={<BuildsPage />} /> <Route path="/builds" element={<BuildsPage />} />
<Route path="/people" element={<PeoplePage />} /> <Route path="/people" element={<PeoplePage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/git/github-app/callback" element={<GitHubAppCallbackPage />} />
<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,11 +3,13 @@ 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,
CheckCircle2, CheckCircle2,
ChevronsLeft, ChevronsLeft,
Clock,
Container, Container,
Database, Database,
FileText, FileText,
@@ -27,6 +29,7 @@ import {
UploadCloud, UploadCloud,
User, User,
Users, Users,
Webhook,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getCurrentUserProfile, getCurrentUserProfile,
@@ -39,7 +42,7 @@ import {
import { signOutAuthSession } from '@/lib/auth-client'; import { signOutAuthSession } from '@/lib/auth-client';
import { isDemoSearch } from '@/lib/demo-mode'; import { isDemoSearch } from '@/lib/demo-mode';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useToast } from '@/shared/components'; import { BannerBar, BrandWordmark, useToast } from '@/shared/components';
type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string }; type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string };
type NavSection = { title: string; items: NavItem[] }; type NavSection = { title: string; items: NavItem[] };
@@ -56,6 +59,8 @@ const navSections: NavSection[] = [
{ {
title: 'Operate', title: 'Operate',
items: [ items: [
{ label: 'Operations', href: '/operations', icon: Activity, hint: 'Live jobs & failures' },
{ label: 'Activity', href: '/activity', icon: Clock, hint: 'Platform event feed' },
{ 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 +72,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' },
], ],
@@ -383,6 +389,7 @@ export function PlatformShell() {
</aside> </aside>
<div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden"> <div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden">
<BannerBar />
<header <header
className="hidden h-[46px] shrink-0 items-center border-b px-5 md:flex" className="hidden h-[46px] shrink-0 items-center border-b px-5 md:flex"
style={{ style={{
@@ -393,7 +400,7 @@ export function PlatformShell() {
}} }}
> >
<NavLink to={href('/projects')} className="mr-5 text-sm font-extrabold tracking-tight text-[var(--text-primary)]" style={{ fontFamily: 'Inter, sans-serif' }}> <NavLink to={href('/projects')} className="mr-5 text-sm font-extrabold tracking-tight text-[var(--text-primary)]" style={{ fontFamily: 'Inter, sans-serif' }}>
contain<span style={{ color: 'var(--accent-primary)' }}>r</span> <BrandWordmark />
</NavLink> </NavLink>
<div className="flex items-center gap-5"> <div className="flex items-center gap-5">
@@ -0,0 +1,121 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { listActivity, type ActivityEntry } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode';
import { formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components';
import {
Activity, AlertCircle, CheckCircle2, Database, GitBranch, Info, Key,
Loader2, Package, Projector, Rocket, Shield, User, Users, Wrench,
} from 'lucide-react';
const CATEGORY_ICONS: Record<string, typeof Activity> = {
service: Rocket, deployment: Package, project: Projector,
database: Database, user: User, team: Users, auth: Key,
security: Shield, git: GitBranch, webhook: GitBranch, system: Wrench,
};
const SEVERITY_STYLE: Record<string, { icon: typeof Info; cls: string; dot: string }> = {
error: { icon: AlertCircle, cls: 'text-[var(--error)]', dot: 'bg-[var(--error)]' },
warning: { icon: AlertCircle, cls: 'text-[var(--warning)]', dot: 'bg-[var(--warning)]' },
success: { icon: CheckCircle2, cls: 'text-[var(--success)]', dot: 'bg-[var(--success)]' },
info: { icon: Info, cls: 'text-[var(--accent-primary)]', dot: 'bg-[var(--accent-primary)]' },
};
const SEVERITIES = ['', 'error', 'warning', 'success', 'info'] as const;
export function ActivityPage() {
const isDemoMode = useDemoMode();
const [severity, setSeverity] = useState('');
const [page, setPage] = useState(1);
const query = useQuery({
queryKey: ['activity-feed', severity, page],
queryFn: () => listActivity({ severity: severity || undefined, page, limit: 50 }),
enabled: !isDemoMode,
refetchInterval: 15000,
});
if (isDemoMode) return <DemoRestricted feature="Activity" />;
const entries = query.data?.activity ?? [];
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">Activity<span className="v-cursor">_</span></h1>
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
platform events across your visible projects
</p>
</div>
</div>
<div className="px-8 py-6 space-y-4">
<div className="flex items-center gap-2">
{SEVERITIES.map((s) => (
<button
key={s || 'all'}
onClick={() => { setSeverity(s); setPage(1); }}
className={`v-mono text-[10px] uppercase px-3 py-1.5 rounded-md border transition-colors ${
severity === s
? 'border-[var(--accent-primary)] text-[var(--accent-primary)] bg-[var(--accent-primary)]/10'
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
}`}
>
{s || 'all'}
</button>
))}
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)]" />}
</div>
<div className="panel p-0 divide-y divide-[var(--border-subtle)]">
{query.isLoading && <p className="px-5 py-8 text-xs text-[var(--text-muted)]">Loading…</p>}
{!query.isLoading && entries.length === 0 && (
<p className="px-5 py-8 text-xs text-[var(--text-muted)]">No events yet — activity appears here as you deploy, configure, and manage services.</p>
)}
{entries.map((e) => <Entry key={e.id} entry={e} />)}
</div>
<div className="flex items-center gap-3">
<button
disabled={page <= 1}
onClick={() => setPage(page - 1)}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-muted)] disabled:opacity-40 hover:text-[var(--text-primary)]"
>
newer
</button>
<button
disabled={!query.data?.has_more}
onClick={() => setPage(page + 1)}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-muted)] disabled:opacity-40 hover:text-[var(--text-primary)]"
>
older
</button>
</div>
</div>
</div>
);
}
function Entry({ entry }: { entry: ActivityEntry }) {
const Cat = CATEGORY_ICONS[entry.category] ?? Activity;
const sev = SEVERITY_STYLE[entry.severity] ?? SEVERITY_STYLE.info;
return (
<div className="flex items-center gap-3 px-5 py-3">
<div className={`w-8 h-8 rounded-md bg-[var(--bg-elevated)] flex items-center justify-center ${sev.cls}`}>
<Cat size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[13px] text-[var(--text-primary)] truncate">{entry.label || entry.action}</span>
<span className={`v-mono text-[9px] uppercase px-1.5 py-0.5 rounded ${sev.cls} bg-[var(--bg-elevated)]`}>{entry.severity}</span>
</div>
<div className="v-mono text-[10px] text-[var(--text-muted)] mt-0.5">
{entry.category} · {entry.action}{entry.user_email ? ` · ${entry.user_email}` : ''}
</div>
</div>
<span className="v-mono text-[10px] text-[var(--text-muted)] whitespace-nowrap">{formatRelative(entry.created_at)}</span>
</div>
);
}
@@ -4,22 +4,35 @@ import { Link } from 'react-router-dom';
import { import {
CheckCircle2, CheckCircle2,
Clock, Clock,
Copy,
FolderKanban, FolderKanban,
Megaphone,
Pencil, Pencil,
ShieldCheck, ShieldCheck,
Trash2, Trash2,
UserPlus,
Users, Users,
XCircle, XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { import {
createBanner,
createInvite,
deleteBanner,
deleteProject, deleteProject,
getAdminOverview, getAdminOverview,
impersonateUser,
type ImpersonationResult,
listAdminBanners,
listAdminUsers, listAdminUsers,
listInvites,
listProjects, listProjects,
revokeInvite,
setProjectApproval, setProjectApproval,
setUserAdmin, setUserAdmin,
updateBanner,
updateProject, updateProject,
type AdminUser, type AdminUser,
type Banner,
type ProjectEntity, type ProjectEntity,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
@@ -50,16 +63,89 @@ export function AdminPage() {
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview, enabled: !isDemoMode }); const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview, enabled: !isDemoMode });
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode }); const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode });
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode }); const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode });
const bannersQuery = useQuery({ queryKey: ['admin-banners'], queryFn: listAdminBanners, enabled: !isDemoMode });
const invitesQuery = useQuery({
queryKey: ['admin-invites'],
queryFn: listInvites,
enabled: !isDemoMode,
select: (list) =>
list.map((invite) => ({
...invite,
expired: !invite.used_at && new Date(invite.expires_at).getTime() < Date.now(),
})),
});
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null); const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
const [editForm, setEditForm] = useState({ name: '', description: '' }); const [editForm, setEditForm] = useState({ name: '', description: '' });
const [bannerForm, setBannerForm] = useState({ title: '', body: '', level: 'info' as Banner['level'] });
const [inviteEmail, setInviteEmail] = useState('');
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [impersonation, setImpersonation] = useState<ImpersonationResult | null>(null);
const invalidate = () => { const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['admin-overview'] }); queryClient.invalidateQueries({ queryKey: ['admin-overview'] });
queryClient.invalidateQueries({ queryKey: ['admin-users'] }); queryClient.invalidateQueries({ queryKey: ['admin-users'] });
queryClient.invalidateQueries({ queryKey: ['admin-projects'] }); queryClient.invalidateQueries({ queryKey: ['admin-projects'] });
queryClient.invalidateQueries({ queryKey: ['admin-banners'] });
queryClient.invalidateQueries({ queryKey: ['active-banners'] });
queryClient.invalidateQueries({ queryKey: ['admin-invites'] });
queryClient.invalidateQueries({ queryKey: ['projects'] }); queryClient.invalidateQueries({ queryKey: ['projects'] });
}; };
const inviteCreateMutation = useMutation({
mutationFn: () => createInvite({ email: inviteEmail.trim() || undefined }),
onSuccess: (result) => {
setInviteLink(`${window.location.origin}${result.url}`);
setInviteEmail('');
invalidate();
},
onError: (error) => showToast('error', 'Invite failed', error instanceof Error ? error.message : undefined),
});
const inviteRevokeMutation = useMutation({
mutationFn: (id: string) => revokeInvite(id),
onSuccess: () => {
showToast('success', 'Invite revoked');
invalidate();
},
onError: (error) => showToast('error', 'Revoke failed', error instanceof Error ? error.message : undefined),
});
const impersonateMutation = useMutation({
mutationFn: (id: string) => impersonateUser(id),
onSuccess: (result) => setImpersonation(result),
onError: (error) => showToast('error', 'Impersonation failed', error instanceof Error ? error.message : undefined),
});
const bannerCreateMutation = useMutation({
mutationFn: () =>
createBanner({
title: bannerForm.title.trim(),
body: bannerForm.body.trim(),
level: bannerForm.level,
}),
onSuccess: () => {
showToast('success', 'Banner published');
setBannerForm({ title: '', body: '', level: 'info' });
invalidate();
},
onError: (error) => showToast('error', 'Create failed', error instanceof Error ? error.message : undefined),
});
const bannerToggleMutation = useMutation({
mutationFn: ({ id, active }: { id: string; active: boolean }) => updateBanner(id, { active }),
onSuccess: () => invalidate(),
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
});
const bannerDeleteMutation = useMutation({
mutationFn: (id: string) => deleteBanner(id),
onSuccess: () => {
showToast('success', 'Banner deleted');
invalidate();
},
onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined),
});
const approvalMutation = useMutation({ const approvalMutation = useMutation({
mutationFn: ({ id, approved }: { id: string; approved: boolean }) => setProjectApproval(id, approved), mutationFn: ({ id, approved }: { id: string; approved: boolean }) => setProjectApproval(id, approved),
onSuccess: (_, vars) => { onSuccess: (_, vars) => {
@@ -126,6 +212,8 @@ export function AdminPage() {
const pending = overview?.pending_projects ?? []; const pending = overview?.pending_projects ?? [];
const users = usersQuery.data ?? []; const users = usersQuery.data ?? [];
const allProjects = projectsQuery.data ?? []; const allProjects = projectsQuery.data ?? [];
const banners = bannersQuery.data ?? [];
const invites = invitesQuery.data ?? [];
return ( return (
<div className="mx-auto max-w-6xl px-5 py-8"> <div className="mx-auto max-w-6xl px-5 py-8">
@@ -258,6 +346,176 @@ export function AdminPage() {
</div> </div>
</section> </section>
<section className="mt-8">
<div className="mb-3 flex items-center gap-2">
<Megaphone size={15} className="text-[var(--accent-primary)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Announcements</h2>
</div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
<input
value={bannerForm.title}
onChange={(e) => setBannerForm((f) => ({ ...f, title: e.target.value }))}
placeholder="Title"
className="h-9 min-w-48 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/>
<input
value={bannerForm.body}
onChange={(e) => setBannerForm((f) => ({ ...f, body: e.target.value }))}
placeholder="Body (optional)"
className="h-9 min-w-48 flex-[2] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/>
<select
value={bannerForm.level}
onChange={(e) => setBannerForm((f) => ({ ...f, level: e.target.value as Banner['level'] }))}
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-sm"
>
<option value="info">info</option>
<option value="success">success</option>
<option value="warning">warning</option>
<option value="error">error</option>
</select>
<button
type="button"
onClick={() => bannerCreateMutation.mutate()}
disabled={bannerCreateMutation.isPending || bannerForm.title.trim().length === 0}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{bannerCreateMutation.isPending ? 'Publishing…' : 'Publish'}
</button>
</div>
{banners.map((banner) => (
<div
key={banner.id}
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
>
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-medium text-[var(--text-primary)]">{banner.title}</p>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">
{banner.level}
{banner.body ? ` · ${banner.body}` : ''}
</p>
</div>
<button
type="button"
onClick={() => bannerToggleMutation.mutate({ id: banner.id, active: !banner.active })}
disabled={bannerToggleMutation.isPending}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
>
{banner.active ? 'Deactivate' : 'Activate'}
</button>
<button
type="button"
onClick={() => {
if (window.confirm(`Delete banner "${banner.title}"?`)) {
bannerDeleteMutation.mutate(banner.id);
}
}}
disabled={bannerDeleteMutation.isPending}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
title="Delete banner"
>
<Trash2 size={12} />
</button>
</div>
))}
{banners.length === 0 ? (
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No announcements.</p>
) : null}
</div>
</section>
<section className="mt-8">
<div className="mb-3 flex items-center gap-2">
<UserPlus size={15} className="text-[var(--accent-primary)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Team invites</h2>
</div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
<input
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Bind to email (optional)"
type="email"
className="h-9 min-w-64 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/>
<button
type="button"
onClick={() => inviteCreateMutation.mutate()}
disabled={inviteCreateMutation.isPending}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{inviteCreateMutation.isPending ? 'Creating…' : 'Create invite link'}
</button>
</div>
{inviteLink ? (
<div className="flex items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--accent-primary-soft)] px-4 py-2.5">
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code>
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(inviteLink);
showToast('success', 'Link copied');
}}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
<Copy size={11} /> Copy
</button>
<button
type="button"
onClick={() => setInviteLink(null)}
className="text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
>
Dismiss
</button>
</div>
) : null}
{invites.map((invite) => {
const used = Boolean(invite.used_at);
const expired = !used && invite.expired;
return (
<div
key={invite.id}
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
>
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-medium text-[var(--text-primary)]">
{invite.email || 'Open invite'}
</p>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">
expires {new Date(invite.expires_at).toLocaleString()}
</p>
</div>
<span
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
used
? 'bg-[var(--surface-muted)] text-[var(--text-tertiary)]'
: expired
? 'bg-[var(--error-soft)] text-[var(--error)]'
: 'bg-[var(--success-soft)] text-[var(--success)]'
}`}
>
{used ? 'used' : expired ? 'expired' : 'open'}
</span>
{!used ? (
<button
type="button"
onClick={() => inviteRevokeMutation.mutate(invite.id)}
disabled={inviteRevokeMutation.isPending}
className="rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
>
Revoke
</button>
) : null}
</div>
);
})}
{invites.length === 0 ? (
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No invites yet.</p>
) : null}
</div>
</section>
<section className="mt-8 pb-10"> <section className="mt-8 pb-10">
<div className="mb-3 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
<Users size={15} className="text-[var(--accent-tertiary)]" /> <Users size={15} className="text-[var(--accent-tertiary)]" />
@@ -271,6 +529,7 @@ export function AdminPage() {
isSelf={user.id === myId} isSelf={user.id === myId}
pending={adminMutation.isPending} pending={adminMutation.isPending}
onToggle={(isAdmin) => adminMutation.mutate({ id: user.id, isAdmin })} onToggle={(isAdmin) => adminMutation.mutate({ id: user.id, isAdmin })}
onImpersonate={user.is_admin || user.id === myId ? undefined : () => impersonateMutation.mutate(user.id)}
/> />
))} ))}
{users.length === 0 ? ( {users.length === 0 ? (
@@ -279,6 +538,52 @@ export function AdminPage() {
</div> </div>
</section> </section>
{impersonation ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-lg overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
<div className="border-b border-[var(--border-subtle)] px-5 py-4">
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">
Impersonating {impersonation.user.email}
</h2>
<p className="text-xs text-[var(--text-tertiary)]">
Token valid until {new Date(impersonation.expires_at).toLocaleString()} — audit-logged.
</p>
</div>
<div className="space-y-3 px-5 py-4">
<div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5">
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--text-primary)]">
{impersonation.token}
</code>
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(impersonation.token);
showToast('success', 'Token copied');
}}
className="inline-flex shrink-0 items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
<Copy size={11} /> Copy
</button>
</div>
<p className="text-[12px] leading-relaxed text-[var(--text-tertiary)]">
Use it as a bearer token — e.g. <code className="text-[var(--text-secondary)]">CONTAINR_TOKEN=&lt;token&gt;
containr &lt;command&gt;</code> or <code className="text-[var(--text-secondary)]">Authorization: Bearer
&lt;token&gt;</code>. Requests run with that user's permissions for 15 minutes.
</p>
</div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
<button
type="button"
onClick={() => setImpersonation(null)}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)]"
>
Done
</button>
</div>
</div>
</div>
) : null}
{editingProject ? ( {editingProject ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl"> <div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
@@ -334,11 +639,13 @@ function UserRow({
isSelf, isSelf,
pending, pending,
onToggle, onToggle,
onImpersonate,
}: { }: {
user: AdminUser; user: AdminUser;
isSelf: boolean; isSelf: boolean;
pending: boolean; pending: boolean;
onToggle: (isAdmin: boolean) => void; onToggle: (isAdmin: boolean) => void;
onImpersonate?: () => void;
}) { }) {
return ( return (
<div className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"> <div className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
@@ -357,6 +664,15 @@ function UserRow({
<ShieldCheck size={11} /> admin <ShieldCheck size={11} /> admin
</span> </span>
) : null} ) : null}
{onImpersonate ? (
<button
type="button"
onClick={onImpersonate}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Impersonate
</button>
) : null}
<button <button
type="button" type="button"
onClick={() => onToggle(!user.is_admin)} onClick={() => onToggle(!user.is_admin)}
+134
View File
@@ -11,6 +11,7 @@ import {
signUpWithEmail, signUpWithEmail,
startGitHubSignIn, startGitHubSignIn,
} from '@/lib/auth-client'; } from '@/lib/auth-client';
import { acceptInvite, getInvite } from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
function sanitizeRedirect(raw: string | null): string { function sanitizeRedirect(raw: string | null): string {
@@ -258,6 +259,139 @@ export function SignInPage() {
); );
} }
export function AcceptInvitePage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const token = searchParams.get('token') ?? '';
const sessionQuery = useAuthSession();
const inviteQuery = useQuery({
queryKey: ['invite', token],
queryFn: () => getInvite(token),
enabled: token.length > 0,
retry: false,
});
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
if (sessionQuery.data) {
return <Navigate to="/projects" replace />;
}
const boundEmail = inviteQuery.data?.email ?? undefined;
const effectiveEmail = boundEmail ?? email;
const inviteValid = inviteQuery.data?.valid === true;
const submit = async (event: FormEvent) => {
event.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await acceptInvite({ token, name: name.trim(), email: effectiveEmail.trim(), password });
// Establish the cookie session — the accept endpoint creates the
// account, sign-in creates the browser session.
await signInWithEmail(effectiveEmail.trim(), password);
await queryClient.invalidateQueries({ queryKey: ['auth-session'] });
navigate('/projects', { replace: true });
} catch (exception) {
setError(exception instanceof Error ? exception.message : 'Failed to accept invite');
} finally {
setIsSubmitting(false);
}
};
if (!token) {
return (
<AuthLayout>
<AuthCard title="Invalid invite" subtitle="This link is missing its invite token.">
<AuthErrorNotice message="Ask your administrator for a fresh invite link." />
</AuthCard>
</AuthLayout>
);
}
return (
<AuthLayout>
<AuthCard
title="Join the team"
subtitle={boundEmail ? `Invited as ${boundEmail}` : 'You have been invited — create your account.'}
>
{error ? <AuthErrorNotice message={error} /> : null}
{inviteQuery.isLoading ? <AuthInfoNotice message="Validating invite…" /> : null}
{inviteQuery.isError ? <AuthErrorNotice message="This invite link is not valid." /> : null}
{inviteQuery.data && !inviteValid ? (
<AuthErrorNotice message={inviteQuery.data.used ? 'This invite was already used.' : 'This invite has expired.'} />
) : null}
{inviteValid ? (
<form className="space-y-3" onSubmit={submit}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Name
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
placeholder="Your name"
/>
</label>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Email
<input
type="email"
value={boundEmail ?? email}
onChange={(e) => setEmail(e.target.value)}
disabled={Boolean(boundEmail)}
required
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)] disabled:opacity-60"
placeholder="[email protected]"
/>
</label>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
required
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
placeholder="At least 8 characters"
/>
</label>
<button
type="submit"
disabled={isSubmitting}
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: 'var(--accent-primary)' }}
>
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
Accept invite
</button>
</form>
) : null}
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
<span>Already have an account?</span>
<Link to="/auth/sign-in" className="inline-flex items-center gap-1 text-[var(--accent-primary)] hover:underline">
Sign in <ArrowRight size={12} />
</Link>
</div>
</AuthCard>
</AuthLayout>
);
}
export function SignUpPage() { export function SignUpPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -15,6 +15,8 @@ import { listProjects, listTemplates } from '@/lib/api-client';
import { DocsBrowser } from '@/features/docs/DocsBrowser'; import { DocsBrowser } from '@/features/docs/DocsBrowser';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { useBranding } from '@/lib/use-branding';
import { BrandWordmark } from '@/shared/components';
import { demoProjects, demoTemplates } from '@/lib/demo-data'; import { demoProjects, demoTemplates } from '@/lib/demo-data';
const features = [ const features = [
@@ -28,6 +30,7 @@ const features = [
export function LandingPage() { export function LandingPage() {
const isDemoMode = useDemoMode(); const isDemoMode = useDemoMode();
const { logoUrl, productName } = useBranding();
const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = !isDemoMode && Boolean(sessionQuery.data); const signedIn = !isDemoMode && Boolean(sessionQuery.data);
const demo = isDemoMode ? '?demo=1' : ''; const demo = isDemoMode ? '?demo=1' : '';
@@ -45,9 +48,9 @@ export function LandingPage() {
<header className="sticky top-0 z-40 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 backdrop-blur-xl"> <header className="sticky top-0 z-40 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 backdrop-blur-xl">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5"> <div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
<Link to="/" className="flex items-center gap-2.5"> <Link to="/" className="flex items-center gap-2.5">
<img src="/containr.svg" alt="Containr" className="h-7 w-7 rounded-lg" /> <img src={logoUrl || '/containr.svg'} alt={productName} className="h-7 w-7 rounded-lg object-contain" />
<span className="text-[15px] font-extrabold tracking-tight text-[var(--text-primary)]"> <span className="text-[15px] font-extrabold tracking-tight text-[var(--text-primary)]">
contain<span style={{ color: 'var(--accent-primary)' }}>r</span> <BrandWordmark />
</span> </span>
</Link> </Link>
<nav className="flex items-center gap-1 text-[13px]"> <nav className="flex items-center gap-1 text-[13px]">
@@ -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>
);
}
@@ -44,10 +44,15 @@ import {
listServiceVariables, listServiceVariables,
updateServiceVariables, updateServiceVariables,
getGitHubAppInstallUrl, getGitHubAppInstallUrl,
getGitHubAppStatus,
createGitHubAppManifest,
convertGitHubAppCode,
connectGitHubApp, connectGitHubApp,
updateCurrentUserProfile, updateCurrentUserProfile,
getPlatformSettings, getPlatformSettings,
updatePlatformSettings, updatePlatformSettings,
type Branding,
type BrandingInput,
getHAStatus, getHAStatus,
setHAEnabled, setHAEnabled,
triggerFailover, triggerFailover,
@@ -948,6 +953,35 @@ function GitProvidersSection() {
} }
}; };
// Admins can self-provision the instance's GitHub App via the manifest
// flow when GITHUB_APP_* env vars were never configured.
const profileQuery = useQuery({ queryKey: ['user-profile'], queryFn: getCurrentUserProfile, retry: false });
const isAdmin = Boolean(profileQuery.data?.isAdmin);
const ghAppQuery = useQuery({
queryKey: ['github-app-status'],
queryFn: getGitHubAppStatus,
enabled: isAdmin,
retry: false,
});
const provisionMutation = useMutation({
mutationFn: async () => {
const { url, manifest } = await createGitHubAppManifest({ base_url: window.location.origin });
// GitHub requires a form POST carrying the manifest JSON.
const form = document.createElement('form');
form.method = 'POST';
form.action = url;
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'manifest';
input.value = manifest;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
},
});
const ghAppStatus = ghAppQuery.data;
const showProvision = isAdmin && ghAppStatus && !ghAppStatus.configured;
const [form, setForm] = useState({ const [form, setForm] = useState({
name: 'github' as (typeof gitProviderTypes)[number]['value'], name: 'github' as (typeof gitProviderTypes)[number]['value'],
displayName: '', displayName: '',
@@ -994,6 +1028,17 @@ function GitProvidersSection() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{showProvision ? (
<button
onClick={() => provisionMutation.mutate()}
disabled={provisionMutation.isPending}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
>
<GitBranch size={14} />
{provisionMutation.isPending ? 'Opening GitHub…' : 'Set up GitHub App'}
</button>
) : (
<button <button
onClick={() => void installApp()} onClick={() => void installApp()}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors" className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
@@ -1001,6 +1046,7 @@ function GitProvidersSection() {
<GitBranch size={14} /> <GitBranch size={14} />
Install GitHub App Install GitHub App
</button> </button>
)}
<button <button
onClick={() => setFormOpen((open) => !open)} onClick={() => setFormOpen((open) => !open)}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all" className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all"
@@ -1017,6 +1063,11 @@ function GitProvidersSection() {
{installUrlError} {installUrlError}
</div> </div>
)} )}
{provisionMutation.isError && (
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{provisionMutation.error instanceof Error ? provisionMutation.error.message : 'Failed to start GitHub App setup'}
</div>
)}
{connectAppMutation.isPending && ( {connectAppMutation.isPending && (
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--surface-muted)] text-sm text-[var(--text-secondary)]"> <div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--surface-muted)] text-sm text-[var(--text-secondary)]">
Completing GitHub App installation… Completing GitHub App installation…
@@ -1149,6 +1200,66 @@ function GitProvidersSection() {
); );
} }
// GitHub redirects here after the admin completes the manifest form —
// /git/github-app/callback?code=…&state=…
export function GitHubAppCallbackPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const code = searchParams.get('code');
const convertMutation = useMutation({
mutationFn: (appCode: string) => convertGitHubAppCode(appCode),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['github-app-status'] });
navigate('/settings', { replace: true });
},
});
const firedRef = useRef(false);
useEffect(() => {
if (code && !firedRef.current) {
firedRef.current = true;
convertMutation.mutate(code);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- fire once per code
}, [code]);
return (
<div className="mx-auto max-w-md px-5 py-16 text-center">
{!code ? (
<>
<AlertCircle size={28} className="mx-auto text-[var(--error)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Missing code</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]">
GitHub did not return an app code. Start the setup again from Git providers.
</p>
</>
) : convertMutation.isError ? (
<>
<AlertCircle size={28} className="mx-auto text-[var(--error)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Setup failed</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]">
{convertMutation.error instanceof Error ? convertMutation.error.message : 'Could not provision the GitHub App'}
</p>
<button
type="button"
onClick={() => navigate('/settings', { replace: true })}
className="mt-6 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-sm font-semibold text-[var(--accent-on)]"
>
Back to settings
</button>
</>
) : (
<>
<Loader2 size={28} className="mx-auto animate-spin text-[var(--accent-primary)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Setting up GitHub App</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]">Exchanging the app credentials…</p>
</>
)}
</div>
);
}
function PlatformSettingsSection() { function PlatformSettingsSection() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const settingsQuery = useQuery({ const settingsQuery = useQuery({
@@ -1158,6 +1269,7 @@ function PlatformSettingsSection() {
const [signupDraft, setSignupDraft] = useState<boolean | null>(null); const [signupDraft, setSignupDraft] = useState<boolean | null>(null);
const [tokenDraft, setTokenDraft] = useState(''); const [tokenDraft, setTokenDraft] = useState('');
const [brandDraft, setBrandDraft] = useState<BrandingInput>({});
const [feedback, setFeedback] = useState<string | null>(null); const [feedback, setFeedback] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -1168,8 +1280,10 @@ function PlatformSettingsSection() {
mutationFn: updatePlatformSettings, mutationFn: updatePlatformSettings,
onSuccess: (settings) => { onSuccess: (settings) => {
queryClient.setQueryData(['platform-settings'], settings); queryClient.setQueryData(['platform-settings'], settings);
queryClient.invalidateQueries({ queryKey: ['branding'] });
setSignupDraft(null); setSignupDraft(null);
setTokenDraft(''); setTokenDraft('');
setBrandDraft({});
setFeedback('Settings saved.'); setFeedback('Settings saved.');
setError(null); setError(null);
}, },
@@ -1279,6 +1393,59 @@ function PlatformSettingsSection() {
</a> </a>
</div> </div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Branding</span>
<span className="text-xs text-[var(--text-tertiary)]">leave blank to keep Containr defaults</span>
</div>
<p className="text-xs text-[var(--text-secondary)] mb-3">
White-label this instance — product name, logo, accent color and links apply
to the shell, login page and document title.
</p>
<div className="grid grid-cols-2 gap-2 max-sm:grid-cols-1">
{(
[
['productName', 'Product name'],
['logoUrl', 'Logo URL'],
['faviconUrl', 'Favicon URL'],
['accentColor', 'Accent color (#hex)'],
['docsUrl', 'Docs URL'],
['supportUrl', 'Support URL'],
] as const
).map(([field, label]) => (
<label key={field} className="block">
<span className="mb-1 block text-[11px] text-[var(--text-tertiary)]">{label}</span>
<input
value={brandDraft[field] ?? settingsQuery.data?.branding[field] ?? ''}
onChange={(e) => setBrandDraft((d) => ({ ...d, [field]: e.target.value }))}
className="h-9 w-full px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] text-sm focus:border-[var(--accent-primary)] transition-colors"
/>
</label>
))}
</div>
<div className="flex gap-2 mt-3">
<button
onClick={() => {
const b = { ...(settingsQuery.data?.branding ?? {}), ...brandDraft } as Branding;
saveMutation.mutate({ branding: b });
}}
disabled={saveMutation.isPending || Object.keys(brandDraft).length === 0}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ background: 'var(--accent-primary)' }}
>
Save branding
</button>
{Object.keys(brandDraft).length > 0 ? (
<button
onClick={() => setBrandDraft({})}
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)]"
>
Reset draft
</button>
) : null}
</div>
</div>
{feedback ? <p className="text-xs text-[var(--success)]">{feedback}</p> : null} {feedback ? <p className="text-xs text-[var(--success)]">{feedback}</p> : null}
{error ? <p className="text-xs text-[var(--error)]">{error}</p> : null} {error ? <p className="text-xs text-[var(--error)]">{error}</p> : null}
</div> </div>
@@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import { Loader2 } from 'lucide-react';
import { getApiBaseUrl } from '@/lib/api-client';
type Frame = {
type: 'stdin' | 'resize' | 'stdout' | 'exit' | 'error' | 'ping' | 'pong';
data?: string;
cols?: number;
rows?: number;
code?: number;
message?: string;
};
// Interactive shell: WebSocket → docker exec pty in the service container.
export function ServiceTerminal({ serviceId }: { serviceId: string }) {
const hostRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<'connecting' | 'live' | 'ended' | 'error'>('connecting');
useEffect(() => {
if (!hostRef.current) return;
const term = new Terminal({
fontFamily: 'var(--font-mono, ui-monospace, monospace)',
fontSize: 13,
cursorBlink: true,
convertEol: false,
theme: {
background: '#0a0a0c',
foreground: '#d4d4d8',
cursor: '#a1a1aa',
selectionBackground: '#3f3f46',
},
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(hostRef.current);
fit.fit();
const wsUrl = getApiBaseUrl().replace(/^http/, 'ws');
const ws = new WebSocket(`${wsUrl}/services/${serviceId}/terminal`);
ws.onopen = () => {
setStatus('live');
const { cols, rows } = term;
const frame: Frame = { type: 'resize', cols, rows };
ws.send(JSON.stringify(frame));
};
ws.onmessage = (ev) => {
let f: Frame;
try {
f = JSON.parse(ev.data);
} catch {
return;
}
if (f.type === 'stdout' && f.data) term.write(f.data);
else if (f.type === 'exit') {
setStatus('ended');
term.write(`\r\n\x1b[90m[session ended — exit ${f.code ?? 0}]\x1b[0m\r\n`);
} else if (f.type === 'error') {
setStatus('error');
term.write(`\r\n\x1b[31m${f.message ?? 'terminal error'}\x1b[0m\r\n`);
}
};
ws.onerror = () => setStatus((s) => (s === 'live' ? s : 'error'));
ws.onclose = () => setStatus((s) => (s === 'error' ? s : 'ended'));
const dataSub = term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'stdin', data } satisfies Frame));
}
});
const observer = new ResizeObserver(() => {
fit.fit();
if (ws.readyState === WebSocket.OPEN) {
const { cols, rows } = term;
ws.send(JSON.stringify({ type: 'resize', cols, rows } satisfies Frame));
}
});
observer.observe(hostRef.current);
return () => {
observer.disconnect();
dataSub.dispose();
ws.close();
term.dispose();
};
}, [serviceId]);
return (
<div className="relative">
{status === 'connecting' && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#0a0a0c]/80 rounded-[var(--radius-md)]">
<Loader2 size={18} className="animate-spin text-[var(--accent-primary)]" />
</div>
)}
<div
ref={hostRef}
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden p-2"
style={{ background: '#0a0a0c', height: 420 }}
/>
</div>
);
}
@@ -26,7 +26,6 @@ import {
deleteScalingPolicy, deleteScalingPolicy,
getServiceScalingState, getServiceScalingState,
manualScaleService, manualScaleService,
execInService,
rollbackDeployment, rollbackDeployment,
cancelDeployment, cancelDeployment,
updateServiceVariables, updateServiceVariables,
@@ -59,6 +58,7 @@ import { getCurrentUserProfile } from '@/lib/api-client';
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils'; import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time'; import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components'; import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components';
import { ServiceTerminal } from '@/features/service/components/ServiceTerminal';
import { import {
Activity, Activity,
FileText, FileText,
@@ -338,21 +338,6 @@ export function ServiceDetailPage() {
}, },
}); });
// --- Console ---
const [consoleInput, setConsoleInput] = useState('');
const [consoleHistory, setConsoleHistory] = useState<
Array<{ command: string; output: string; exitCode: number; error: string }>
>([]);
const execMutation = useMutation({
mutationFn: (command: string) => execInService(serviceId, command),
onSuccess: (result, command) => {
setConsoleHistory((prev) => [
{ command, output: result.output ?? '', exitCode: result.exit_code ?? 0, error: result.error ?? '' },
...prev,
]);
setConsoleInput('');
},
});
const triggerCronMutation = useMutation({ const triggerCronMutation = useMutation({
mutationFn: (id: string) => triggerCronJob(id), mutationFn: (id: string) => triggerCronJob(id),
onSuccess: () => { onSuccess: () => {
@@ -1927,64 +1912,10 @@ export function ServiceDetailPage() {
</div> </div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Console</h2> <h2 className="text-lg font-semibold text-[var(--text-primary)]">Console</h2>
<p className="text-sm text-[var(--text-secondary)]">One-off commands via docker exec — 30s limit, 64KB output cap</p> <p className="text-sm text-[var(--text-secondary)]">Interactive shell inside the service container</p>
</div> </div>
</div> </div>
<ServiceTerminal serviceId={serviceId} />
<form
className="flex items-center gap-2 mb-4"
onSubmit={(e) => {
e.preventDefault();
const cmd = consoleInput.trim();
if (cmd && !execMutation.isPending) execMutation.mutate(cmd);
}}
>
<span className="text-sm font-mono text-[var(--accent-primary)]">$</span>
<input
value={consoleInput}
onChange={(e) => setConsoleInput(e.target.value)}
placeholder="e.g. env | sort | head -20"
disabled={execMutation.isPending}
className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm font-mono text-[var(--text-primary)] disabled:opacity-50"
/>
<button
type="submit"
disabled={execMutation.isPending || !consoleInput.trim()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{execMutation.isPending ? 'Running…' : 'Run'}
</button>
</form>
{execMutation.isError && (
<p className="mb-4 text-xs text-[var(--error)]">
{(execMutation.error as Error)?.message ?? 'Command failed'}
</p>
)}
{consoleHistory.length === 0 ? (
<p className="text-xs text-[var(--text-tertiary)]">
Commands run inside the service's running container as its default user. No interactive shell — stdout/stderr is captured and returned.
</p>
) : (
<div className="space-y-3">
{consoleHistory.map((entry, i) => (
<div key={i} className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-3">
<div className="flex items-center justify-between mb-1">
<p className="text-xs font-mono text-[var(--text-primary)]">$ {entry.command}</p>
<span className={`text-xs font-mono ${entry.exitCode === 0 && !entry.error ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
{entry.error ? 'error' : `exit ${entry.exitCode}`}
</span>
</div>
{(entry.output || entry.error) && (
<pre className="text-xs font-mono text-[var(--text-secondary)] whitespace-pre-wrap break-all max-h-64 overflow-y-auto">
{entry.output}{entry.error ? `\n${entry.error}` : ''}
</pre>
)}
</div>
))}
</div>
)}
</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,10 +8,12 @@ import {
createManagedDatabase, createManagedDatabase,
createService, createService,
getProjectById, getProjectById,
importComposeIntoProject,
listConnectedGitRepositories, listConnectedGitRepositories,
listGitBranches, listGitBranches,
listGitProviders, listGitProviders,
listGitRepositories, listGitRepositories,
listProjectActivity,
listServiceLogs, listServiceLogs,
listServiceVariables, listServiceVariables,
listServicesByProject, listServicesByProject,
@@ -45,14 +47,16 @@ 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' | 'activity' | 'settings';
const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutGrid; authOnly?: boolean }> = [ const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutGrid; authOnly?: boolean }> = [
{ key: 'canvas', label: 'Canvas', icon: LayoutGrid }, { key: 'canvas', label: 'Canvas', icon: LayoutGrid },
{ key: 'observability', label: 'Observability', icon: Activity }, { key: 'observability', label: 'Observability', icon: Activity },
{ key: 'logs', label: 'Logs', icon: FileText, authOnly: true }, { key: 'logs', label: 'Logs', icon: FileText, authOnly: true },
{ key: 'activity', label: 'Activity', icon: Clock, authOnly: true },
{ key: 'settings', label: 'Settings', icon: Settings }, { key: 'settings', label: 'Settings', icon: Settings },
]; ];
@@ -473,6 +477,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 +562,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],
@@ -617,6 +642,13 @@ export function ProjectWorkspacePage() {
retry: false, retry: false,
}); });
const activityQuery = useQuery({
queryKey: ['project-activity', projectId],
queryFn: () => listProjectActivity(projectId!),
enabled: !isDemoMode && signedIn && activeView === 'activity' && !!projectId,
refetchInterval: 15000,
});
const runningServices = useMemo(() => services.filter((service) => service.status === 'running').length, [services]); const runningServices = useMemo(() => services.filter((service) => service.status === 'running').length, [services]);
const serviceHref = (serviceId: string) => const serviceHref = (serviceId: string) =>
isDemoMode isDemoMode
@@ -721,6 +753,15 @@ 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={() => setImportOpen(true)}
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"
>
<FileUp size={14} />
<span className="hidden sm:inline">Compose</span>
</button>
<button <button
onClick={openAddService} 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" 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"
@@ -729,6 +770,7 @@ export function ProjectWorkspacePage() {
<Plus size={14} /> <Plus size={14} />
<span className="hidden sm:inline">Add Service</span> <span className="hidden sm:inline">Add Service</span>
</button> </button>
</>
)} )}
</div> </div>
</div> </div>
@@ -909,6 +951,42 @@ export function ProjectWorkspacePage() {
</div> </div>
)} )}
{activeView === 'activity' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Clock size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Activity</h2>
<p className="text-sm text-[var(--text-secondary)]">Project timeline — deploys, config, membership</p>
</div>
</div>
<div className="divide-y divide-[var(--border-subtle)] border border-[var(--border-subtle)] rounded-[var(--radius-md)]">
{activityQuery.isLoading ? (
<p className="px-4 py-6 text-xs text-[var(--text-muted)]">Loading…</p>
) : (activityQuery.data?.activity?.length ?? 0) === 0 ? (
<p className="px-4 py-6 text-xs text-[var(--text-muted)]">No project events yet.</p>
) : (
activityQuery.data!.activity.map((e) => (
<div key={e.id} className="flex items-center gap-3 px-4 py-2.5">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${
e.severity === 'error' ? 'bg-[var(--error)]' :
e.severity === 'warning' ? 'bg-[var(--warning)]' :
e.severity === 'success' ? 'bg-[var(--success)]' : 'bg-[var(--accent-primary)]'
}`} />
<div className="flex-1 min-w-0">
<span className="text-[13px] text-[var(--text-primary)]">{e.label || e.action}</span>
<span className="v-mono text-[10px] text-[var(--text-muted)] ml-2">{e.user_email}</span>
</div>
<span className="v-mono text-[10px] text-[var(--text-muted)]">{formatRelative(e.created_at)}</span>
</div>
))
)}
</div>
</div>
)}
{activeView === 'settings' && ( {activeView === 'settings' && (
<div className="panel p-6"> <div className="panel p-6">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
@@ -973,6 +1051,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}
File diff suppressed because it is too large. Load diff
+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\nbanners list|creatLine truncated
}, },
{ {
"path": "guides/CLOUDFLARE_SETUP.md", "path": "guides/CLOUDFLARE_SETUP.md",
+353
View File
@@ -24,6 +24,24 @@ export type UserProfile = {
updatedAt?: string; updatedAt?: string;
}; };
export type Branding = {
productName: string;
logoUrl: string;
faviconUrl: string;
accentColor: string;
docsUrl: string;
supportUrl: string;
};
export type BrandingInput = Partial<{
productName: string;
logoUrl: string;
faviconUrl: string;
accentColor: string;
docsUrl: string;
supportUrl: string;
}>;
export type PlatformSettings = { export type PlatformSettings = {
signupEnabled: boolean; signupEnabled: boolean;
cloudflareTunnel: { cloudflareTunnel: {
@@ -31,6 +49,7 @@ export type PlatformSettings = {
source: 'app' | 'env' | 'none'; source: 'app' | 'env' | 'none';
container: string; container: string;
}; };
branding: Branding;
}; };
export type ServiceEntity = { export type ServiceEntity = {
@@ -542,6 +561,37 @@ function normalizeUserProfile(profile: RawUserProfile): UserProfile | null {
}; };
} }
function normalizeBranding(raw: {
product_name?: string;
logo_url?: string;
favicon_url?: string;
accent_color?: string;
docs_url?: string;
support_url?: string;
} | undefined | null): Branding {
return {
productName: raw?.product_name ?? 'Containr',
logoUrl: raw?.logo_url ?? '',
faviconUrl: raw?.favicon_url ?? '',
accentColor: raw?.accent_color ?? '',
docsUrl: raw?.docs_url ?? '',
supportUrl: raw?.support_url ?? '',
};
}
// GET /branding is public — used before login to theme the shell.
export async function getBranding(): Promise<Branding> {
const raw = await requestJson<{
product_name?: string;
logo_url?: string;
favicon_url?: string;
accent_color?: string;
docs_url?: string;
support_url?: string;
}>('/branding');
return normalizeBranding(raw);
}
function normalizePlatformSettings(raw: components['schemas']['PlatformSettings']): PlatformSettings { function normalizePlatformSettings(raw: components['schemas']['PlatformSettings']): PlatformSettings {
return { return {
signupEnabled: raw.signup_enabled ?? false, signupEnabled: raw.signup_enabled ?? false,
@@ -550,6 +600,7 @@ function normalizePlatformSettings(raw: components['schemas']['PlatformSettings'
source: raw.cloudflare_tunnel?.source ?? 'none', source: raw.cloudflare_tunnel?.source ?? 'none',
container: raw.cloudflare_tunnel?.container ?? 'missing', container: raw.cloudflare_tunnel?.container ?? 'missing',
}, },
branding: normalizeBranding(raw.branding),
}; };
} }
@@ -561,6 +612,7 @@ export async function getPlatformSettings(): Promise<PlatformSettings> {
export async function updatePlatformSettings(input: { export async function updatePlatformSettings(input: {
signupEnabled?: boolean; signupEnabled?: boolean;
cloudflareTunnelToken?: string; cloudflareTunnelToken?: string;
branding?: BrandingInput;
}): Promise<PlatformSettings> { }): Promise<PlatformSettings> {
const body: Record<string, unknown> = {}; const body: Record<string, unknown> = {};
if (input.signupEnabled !== undefined) { if (input.signupEnabled !== undefined) {
@@ -569,6 +621,16 @@ export async function updatePlatformSettings(input: {
if (input.cloudflareTunnelToken !== undefined) { if (input.cloudflareTunnelToken !== undefined) {
body.cloudflare_tunnel_token = input.cloudflareTunnelToken; body.cloudflare_tunnel_token = input.cloudflareTunnelToken;
} }
if (input.branding !== undefined) {
const b: Record<string, string> = {};
if (input.branding.productName !== undefined) b.product_name = input.branding.productName;
if (input.branding.logoUrl !== undefined) b.logo_url = input.branding.logoUrl;
if (input.branding.faviconUrl !== undefined) b.favicon_url = input.branding.faviconUrl;
if (input.branding.accentColor !== undefined) b.accent_color = input.branding.accentColor;
if (input.branding.docsUrl !== undefined) b.docs_url = input.branding.docsUrl;
if (input.branding.supportUrl !== undefined) b.support_url = input.branding.supportUrl;
body.branding = b;
}
const raw = await requestJson<components['schemas']['PlatformSettings']>('/settings', { const raw = await requestJson<components['schemas']['PlatformSettings']>('/settings', {
method: 'PUT', method: 'PUT',
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -1748,6 +1810,255 @@ 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 type ActivityEntry = {
id: string;
user_email: string;
resource: string;
resource_id: string;
action: string;
details?: string;
severity: 'info' | 'success' | 'warning' | 'error';
category: string;
label: string;
created_at: string;
};
export type ActivityPage = {
activity: ActivityEntry[];
page?: number;
has_more?: boolean;
};
export async function listActivity(filters: {
severity?: string; category?: string; resource?: string;
project_id?: string; page?: number; limit?: number;
} = {}): Promise<ActivityPage> {
const params = new URLSearchParams();
if (filters.severity) params.set('severity', filters.severity);
if (filters.category) params.set('category', filters.category);
if (filters.resource) params.set('resource', filters.resource);
if (filters.project_id) params.set('project_id', filters.project_id);
if (filters.page) params.set('page', String(filters.page));
if (filters.limit) params.set('limit', String(filters.limit));
const q = params.toString();
return requestJson<ActivityPage>(`/activity${q ? `?${q}` : ''}`);
}
export async function listProjectActivity(projectId: string, page = 1): Promise<ActivityPage> {
return requestJson<ActivityPage>(`/projects/${projectId}/activity?page=${page}&limit=50`);
}
export type Banner = {
id: string;
title: string;
body: string;
level: 'info' | 'success' | 'warning' | 'error';
active: boolean;
dismissible: boolean;
starts_at?: string | null;
ends_at?: string | null;
created_at: string;
};
export type BannerInput = {
title?: string;
body?: string;
level?: Banner['level'];
active?: boolean;
dismissible?: boolean;
starts_at?: string;
ends_at?: string;
};
export async function listActiveBanners(): Promise<Banner[]> {
const payload = await requestJson<{ banners?: Banner[] }>('/banners/active');
return payload.banners ?? [];
}
export async function listAdminBanners(): Promise<Banner[]> {
const payload = await requestJson<{ banners?: Banner[] }>('/admin/banners');
return payload.banners ?? [];
}
export async function createBanner(input: BannerInput): Promise<Banner> {
return requestJson<Banner>('/admin/banners', { method: 'POST', body: JSON.stringify(input) });
}
export async function updateBanner(id: string, input: BannerInput): Promise<Banner> {
return requestJson<Banner>(`/admin/banners/${id}`, { method: 'PATCH', body: JSON.stringify(input) });
}
export async function deleteBanner(id: string): Promise<void> {
await requestJson(`/admin/banners/${id}`, { method: 'DELETE' });
}
export type Invite = {
id: string;
email?: string;
expires_at: string;
used_at?: string;
created_at?: string;
};
export type InviteState = {
valid: boolean;
used: boolean;
expired: boolean;
email?: string | null;
expires_at: string;
};
export async function getInvite(token: string): Promise<InviteState> {
return requestJson<InviteState>(`/auth/invites/${token}`);
}
export async function acceptInvite(input: {
token: string;
name: string;
email: string;
password: string;
}): Promise<void> {
await requestJson('/auth/accept-invite', { method: 'POST', body: JSON.stringify(input) });
}
export async function listInvites(): Promise<Invite[]> {
const payload = await requestJson<{ invites?: Invite[] }>('/admin/invites');
return payload.invites ?? [];
}
export async function createInvite(input: { email?: string; expiresInHours?: number }): Promise<{
invite: Invite;
token: string;
url: string;
}> {
const body: Record<string, unknown> = {};
if (input.email) body.email = input.email;
if (input.expiresInHours) body.expires_in_hours = input.expiresInHours;
return requestJson('/admin/invites', { method: 'POST', body: JSON.stringify(body) });
}
export async function revokeInvite(id: string): Promise<void> {
await requestJson(`/admin/invites/${id}`, { method: 'DELETE' });
}
export function serviceStatusClass(status: ServiceStatus): string { export function serviceStatusClass(status: ServiceStatus): string {
switch (status) { switch (status) {
case 'running': case 'running':
@@ -1985,6 +2296,36 @@ export async function getGitHubAppInstallUrl(): Promise<string> {
return payload.install_url; return payload.install_url;
} }
export type GitHubAppStatus = {
configured: boolean;
source: 'env' | 'settings' | 'none';
app_id?: string;
slug?: string;
name?: string;
};
export async function getGitHubAppStatus(): Promise<GitHubAppStatus> {
return requestJson<GitHubAppStatus>('/admin/git/github-app');
}
export async function createGitHubAppManifest(input: {
base_url: string;
name?: string;
organization?: string;
}): Promise<{ url: string; manifest: string; state: string }> {
return requestJson('/admin/git/github-app/manifest', {
method: 'POST',
body: JSON.stringify(input),
});
}
export async function convertGitHubAppCode(code: string): Promise<{ app_id: number; slug: string; name: string }> {
return requestJson('/admin/git/github-app/convert', {
method: 'POST',
body: JSON.stringify({ code }),
});
}
export async function connectGitHubApp(installationId: number, displayName?: string): Promise<GitProviderEntity> { export async function connectGitHubApp(installationId: number, displayName?: string): Promise<GitProviderEntity> {
const payload = await requestJson<{ provider?: GitProviderEntity }>('/git/github-app/connect', { const payload = await requestJson<{ provider?: GitProviderEntity }>('/git/github-app/connect', {
method: 'POST', method: 'POST',
@@ -2349,6 +2690,18 @@ export async function listAdminUsers(): Promise<AdminUser[]> {
return payload.users ?? []; return payload.users ?? [];
} }
export type ImpersonationResult = {
token: string;
expires_at: string;
user: { id: string; email: string; name: string };
};
export async function impersonateUser(userId: string): Promise<ImpersonationResult> {
return requestJson<ImpersonationResult>(`/admin/users/${encodeURIComponent(userId)}/impersonate`, {
method: 'POST',
});
}
export async function setUserAdmin(userId: string, isAdmin: boolean): Promise<void> { export async function setUserAdmin(userId: string, isAdmin: boolean): Promise<void> {
await requestJson(`/admin/users/${encodeURIComponent(userId)}`, { await requestJson(`/admin/users/${encodeURIComponent(userId)}`, {
method: 'PATCH', method: 'PATCH',
+54
View File
@@ -0,0 +1,54 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getBranding, type Branding } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode';
const DEFAULT_TITLE = 'containr';
/**
* Loads the instance's white-label branding (public endpoint) and applies
* it: document title, favicon, and the accent CSS variable. Defaults keep
* the app Containr-branded.
*/
export function useBranding(): Branding {
const isDemoMode = useDemoMode();
const brandingQuery = useQuery({
queryKey: ['branding'],
queryFn: getBranding,
staleTime: 5 * 60_000,
enabled: !isDemoMode,
});
const branding = brandingQuery.data ?? {
productName: 'Containr',
logoUrl: '',
faviconUrl: '',
accentColor: '',
docsUrl: '',
supportUrl: '',
};
useEffect(() => {
const name = branding.productName;
document.title = name && name !== 'Containr' ? name : DEFAULT_TITLE;
const root = document.documentElement;
if (branding.accentColor && /^#[0-9a-fA-F]{3,8}$/.test(branding.accentColor)) {
root.style.setProperty('--accent-primary', branding.accentColor);
} else {
root.style.removeProperty('--accent-primary');
}
if (branding.faviconUrl) {
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = branding.faviconUrl;
}
}, [branding.productName, branding.accentColor, branding.faviconUrl]);
return branding;
}
@@ -0,0 +1,85 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle, CheckCircle2, Info, Megaphone, X, XCircle } from 'lucide-react';
import { listActiveBanners, type Banner } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode';
const DISMISSED_KEY = 'containr.dismissed-banners';
function dismissedIds(): Set<string> {
try {
return new Set(JSON.parse(localStorage.getItem(DISMISSED_KEY) ?? '[]'));
} catch {
return new Set();
}
}
const levelStyle: Record<Banner['level'], { bg: string; fg: string; icon: typeof Info }> = {
info: { bg: 'var(--accent-primary-soft)', fg: 'var(--accent-primary)', icon: Info },
success: { bg: 'var(--success-soft)', fg: 'var(--success)', icon: CheckCircle2 },
warning: { bg: 'var(--warning-soft)', fg: 'var(--warning)', icon: AlertTriangle },
error: { bg: 'var(--error-soft)', fg: 'var(--error)', icon: XCircle },
};
export function BannerBar() {
const isDemoMode = useDemoMode();
const [dismissed, setDismissed] = useState<Set<string>>(dismissedIds);
const bannersQuery = useQuery({
queryKey: ['active-banners'],
queryFn: listActiveBanners,
enabled: !isDemoMode,
refetchInterval: 60_000,
staleTime: 30_000,
});
const banners = (bannersQuery.data ?? []).filter((b) => !dismissed.has(b.id));
if (banners.length === 0) return null;
const dismiss = (id: string) => {
setDismissed((prev) => {
const next = new Set(prev);
next.add(id);
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...next]));
return next;
});
};
return (
<div className="shrink-0">
{banners.map((banner) => {
const style = levelStyle[banner.level] ?? levelStyle.info;
const Icon = banner.level === 'info' ? Megaphone : style.icon;
return (
<div
key={banner.id}
className="flex items-center gap-3 border-b border-[var(--border-subtle)] px-5 py-2"
style={{ background: style.bg }}
role="status"
>
<Icon size={14} className="shrink-0" style={{ color: style.fg }} />
<div className="min-w-0 flex-1">
<span className="text-[12.5px] font-semibold" style={{ color: style.fg }}>
{banner.title}
</span>
{banner.body ? (
<span className="ml-2 text-[12px] text-[var(--text-secondary)]">{banner.body}</span>
) : null}
</div>
{banner.dismissible ? (
<button
type="button"
onClick={() => dismiss(banner.id)}
className="shrink-0 rounded p-0.5 hover:bg-black/10"
style={{ color: style.fg }}
aria-label="Dismiss announcement"
>
<X size={14} />
</button>
) : null}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,28 @@
import { useBranding } from '@/lib/use-branding';
/**
* Instance wordmark — renders the configured logo when set, otherwise the
* product name with the signature accent on the last character
* ("contain[r]"). Falls back to Containr branding.
*/
export function BrandWordmark({ className = '' }: { className?: string }) {
const branding = useBranding();
if (branding.logoUrl) {
return (
<img
src={branding.logoUrl}
alt={branding.productName}
className={`h-6 w-auto object-contain ${className}`}
/>
);
}
const name = branding.productName;
return (
<span className={className}>
{name.slice(0, -1)}
<span style={{ color: 'var(--accent-primary)' }}>{name.slice(-1)}</span>
</span>
);
}
@@ -1,3 +1,4 @@
export * from './BannerBar';
export * from './DemoGate'; export * from './DemoGate';
export * from './ErrorBoundary'; export * from './ErrorBoundary';
export * from './LoadingSpinner'; export * from './LoadingSpinner';
@@ -9,3 +10,4 @@ export * from './CommandPalette';
export * from './StatusBadge'; export * from './StatusBadge';
export { useToast } from '../hooks/use-toast'; export { useToast } from '../hooks/use-toast';
export type { ToastType, ToastContextValue } from '../hooks/use-toast'; export type { ToastType, ToastContextValue } from '../hooks/use-toast';
export * from './BrandWordmark';
+6 -3
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.Screen name="login" options={{ headerShown: false }} /> <Stack.Protected guard={!!api}>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="project/[id]" options={{ title: 'Project' }} /> <Stack.Screen name="project/[id]" options={{ title: 'Project' }} />
<Stack.Screen name="service/[id]" options={{ title: 'Service' }} /> <Stack.Screen name="service/[id]" options={{ title: 'Service' }} />
<Stack.Screen name="service/logs" options={{ title: 'Logs' }} /> <Stack.Screen name="service/logs" options={{ title: 'Logs' }} />
</Stack.Protected>
<Stack.Screen name="login" options={{ headerShown: false }} />
</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'} />;
} }
+902
View File
@@ -720,6 +720,35 @@ paths:
items: items:
type: string type: string
/services/{id}/terminal:
get:
tags:
- Services
summary: Interactive terminal (WebSocket)
description: >-
Upgrades to a WebSocket bridged to a `docker exec` PTY in the
service's running container (bash if present, else sh).
Cookie/bearer auth as usual.
Client→server JSON frames: `{"type":"stdin","data":"..."}`,
`{"type":"resize","cols":N,"rows":N}`.
Server→client frames: `{"type":"stdout","data":"..."}`,
`{"type":"exit","code":N}`, `{"type":"error","message":"..."}`.
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'101':
description: Switching protocols — WebSocket session established
'404':
description: Service not found or not visible
'409':
description: Service has no running container
'503':
description: Docker unavailable
/internal/wake/{id}: /internal/wake/{id}:
get: get:
tags: tags:
@@ -1791,6 +1820,45 @@ paths:
message: message:
type: string type: string
/admin/users/{id}/impersonate:
post:
tags:
- Admin
summary: Impersonate a user
description: >-
Mint a 15-minute bearer token acting as the target user. Admin-only;
admin accounts cannot be impersonated and every mint is audit-logged.
The token carries an `impersonated_by` claim.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Impersonation token
content:
application/json:
schema:
type: object
properties:
token:
type: string
expires_at:
type: string
format: date-time
user:
type: object
properties:
id: {type: string}
email: {type: string}
name: {type: string}
'403':
description: Target is an admin account
/admin/projects/{id}: /admin/projects/{id}:
patch: patch:
tags: tags:
@@ -2149,6 +2217,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 +2418,543 @@ 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
/activity:
get:
tags:
- Operations
summary: Activity feed
description: >-
Enriched audit events (severity, category, label) scoped to the
caller's visible projects; admins see everything.
security:
- bearerAuth: []
parameters:
- {name: severity, in: query, schema: {type: string, enum: [info, success, warning, error]}}
- {name: category, in: query, schema: {type: string}}
- {name: resource, in: query, schema: {type: string}}
- {name: project_id, in: query, schema: {type: string, format: uuid}}
- {name: since, in: query, schema: {type: string, format: date-time}}
- {name: page, in: query, schema: {type: integer, default: 1}}
- {name: limit, in: query, schema: {type: integer, default: 50, maximum: 200}}
responses:
'200':
description: Feed entries
content:
application/json:
schema:
type: object
properties:
activity:
type: array
items:
$ref: '#/components/schemas/AuditLog'
page: {type: integer}
limit: {type: integer}
has_more: {type: boolean}
/projects/{id}/activity:
get:
tags:
- Operations
summary: Per-project activity timeline
description: Audit events correlated to this project's services, deployments, databases and the project record itself.
security:
- bearerAuth: []
parameters:
- {name: id, in: path, required: true, schema: {type: string}}
- {name: page, in: query, schema: {type: integer, default: 1}}
- {name: limit, in: query, schema: {type: integer, default: 50, maximum: 200}}
responses:
'200':
description: Feed entries
content:
application/json:
schema:
type: object
properties:
activity:
type: array
items:
$ref: '#/components/schemas/AuditLog'
'404':
description: Project not found or not visible
/banners/active:
get:
tags:
- Banners
summary: Active announcement banners
description: Banners currently active (enabled and within their optional schedule window).
security:
- bearerAuth: []
responses:
'200':
description: Active banners
content:
application/json:
schema:
type: object
properties:
banners:
type: array
items:
type: object
properties:
id: {type: string}
title: {type: string}
body: {type: string}
level: {type: string, enum: [info, success, warning, error]}
active: {type: boolean}
dismissible: {type: boolean}
starts_at: {type: string, format: date-time, nullable: true}
ends_at: {type: string, format: date-time, nullable: true}
created_at: {type: string, format: date-time}
/admin/banners:
get:
tags:
- Banners
summary: List all banners (admin)
security:
- bearerAuth: []
responses:
'200':
description: All banners
content:
application/json:
schema:
type: object
properties:
banners:
type: array
items:
type: object
post:
tags:
- Banners
summary: Create a banner (admin)
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [title]
properties:
title: {type: string}
body: {type: string}
level: {type: string, enum: [info, success, warning, error], default: info}
active: {type: boolean, default: true}
dismissible: {type: boolean, default: true}
starts_at: {type: string, format: date-time}
ends_at: {type: string, format: date-time}
responses:
'201':
description: Created banner
content:
application/json:
schema:
type: object
/admin/banners/{id}:
patch:
tags:
- Banners
summary: Update a banner (admin)
security:
- bearerAuth: []
parameters:
- {name: id, in: path, required: true, schema: {type: string}}
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title: {type: string}
body: {type: string}
level: {type: string, enum: [info, success, warning, error]}
active: {type: boolean}
dismissible: {type: boolean}
starts_at: {type: string, format: date-time}
ends_at: {type: string, format: date-time}
responses:
'200':
description: Updated banner
content:
application/json:
schema:
type: object
delete:
tags:
- Banners
summary: Delete a banner (admin)
security:
- bearerAuth: []
parameters:
- {name: id, in: path, required: true, schema: {type: string}}
responses:
'204':
description: Deleted
/auth/invites/{token}:
get:
tags:
- Invites
summary: Validate an invite token (public)
responses:
'200':
description: Invite state
content:
application/json:
schema:
type: object
properties:
valid: {type: boolean}
used: {type: boolean}
expired: {type: boolean}
email: {type: string, nullable: true}
expires_at: {type: string, format: date-time}
'404':
description: Invite not found
/auth/accept-invite:
post:
tags:
- Invites
summary: Register via an invite link (public)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [token, name, email, password]
properties:
token: {type: string}
name: {type: string}
email: {type: string}
password: {type: string, minLength: 8}
responses:
'201':
description: Created session
content:
application/json:
schema:
$ref: '#/components/schemas/LoginResponse'
'410':
description: Invite used or expired
/admin/invites:
get:
tags:
- Invites
summary: List invites (admin)
security:
- bearerAuth: []
responses:
'200':
description: All invites
content:
application/json:
schema:
type: object
properties:
invites:
type: array
items:
type: object
post:
tags:
- Invites
summary: Create an invite link (admin)
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
properties:
email: {type: string, description: Optionally bind the invite to this email}
expires_in_hours: {type: integer, default: 168, minimum: 1, maximum: 2160}
responses:
'201':
description: Created invite with one-time token
content:
application/json:
schema:
type: object
properties:
invite: {type: object}
token: {type: string, description: Shown once}
url: {type: string}
/admin/invites/{id}:
delete:
tags:
- Invites
summary: Revoke an invite (admin)
security:
- bearerAuth: []
parameters:
- {name: id, in: path, required: true, schema: {type: string}}
responses:
'204':
description: Revoked
/branding:
get:
tags:
- Branding
summary: White-label branding (public)
description: |
Instance branding payload — product name, logo/favicon URLs,
accent color, docs/support links. Unauthenticated so the login
page and shell can brand themselves before a session exists.
Unset fields return Containr defaults.
responses:
'200':
description: Branding configuration
content:
application/json:
schema:
$ref: '#/components/schemas/Branding'
# Audit Logs # Audit Logs
/audit-logs: /audit-logs:
get: get:
@@ -2907,6 +3578,16 @@ paths:
cloudflare_tunnel_token: cloudflare_tunnel_token:
type: string type: string
description: Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container. description: Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container.
branding:
type: object
description: White-label fields. Empty string clears a field back to the Containr default.
properties:
product_name: {type: string}
logo_url: {type: string}
favicon_url: {type: string}
accent_color: {type: string}
docs_url: {type: string}
support_url: {type: string}
responses: responses:
'200': '200':
description: Updated platform settings description: Updated platform settings
@@ -3178,6 +3859,93 @@ paths:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
/admin/git/github-app:
get:
tags:
- Admin
summary: GitHub App provisioning status
description: Shows whether the instance GitHub App is configured, and whether credentials come from app_settings or environment variables.
security:
- bearerAuth: []
responses:
'200':
description: Provisioning status
content:
application/json:
schema:
type: object
properties:
configured: {type: boolean}
source: {type: string, enum: [settings, env, none]}
app_id: {type: string}
slug: {type: string}
name: {type: string}
/admin/git/github-app/manifest:
post:
tags:
- Admin
summary: Build GitHub App manifest
description: >-
Returns a GitHub App manifest for self-provisioning. The client POSTs
`manifest` verbatim as a form field to `url`; GitHub then redirects to
the instance's /git/github-app/callback with a code.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [base_url]
properties:
base_url: {type: string, description: Public https base URL of this instance}
name: {type: string}
organization: {type: string, description: Create the app under this GitHub organization}
responses:
'200':
description: Manifest + submission URL
content:
application/json:
schema:
type: object
properties:
url: {type: string}
manifest: {type: string, description: JSON string — POST as form field `manifest`}
state: {type: string}
/admin/git/github-app/convert:
post:
tags:
- Admin
summary: Exchange manifest callback code
description: Exchanges the `code` from the manifest redirect for the app credentials and stores them in app_settings (overriding env vars).
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code]
properties:
code: {type: string}
responses:
'200':
description: App provisioned
content:
application/json:
schema:
type: object
properties:
app_id: {type: integer}
slug: {type: string}
name: {type: string}
'502':
description: GitHub conversion failed
/git/providers/{providerId}: /git/providers/{providerId}:
delete: delete:
tags: tags:
@@ -4773,6 +5541,45 @@ paths:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
/api/git/github-app/webhook:
servers:
- url: http://localhost:8080
post:
tags:
- Git
summary: GitHub App webhook receiver
description: >-
Single app-level webhook all installed repositories deliver to
(configured via the manifest's hook_attributes). Verified with
X-Hub-Signature-256 against the provisioned webhook secret. Push
events enqueue deployments for services tracking the repo+branch.
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'202':
description: Event accepted
content:
application/json:
schema:
type: object
properties:
received:
type: boolean
branch:
type: string
deployments:
type: integer
ignored:
type: string
'401':
description: Invalid signature
'503':
description: GitHub App webhook secret not configured
/api/agents/{id}/commands/{commandId}/result: /api/agents/{id}/commands/{commandId}/result:
servers: servers:
- url: http://localhost:8080 - url: http://localhost:8080
@@ -5672,6 +6479,25 @@ components:
container: container:
type: string type: string
description: cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable description: cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable
branding:
$ref: '#/components/schemas/Branding'
Branding:
type: object
description: White-label instance branding. Unset fields fall back to Containr defaults.
properties:
product_name:
type: string
logo_url:
type: string
favicon_url:
type: string
accent_color:
type: string
docs_url:
type: string
support_url:
type: string
User: User:
type: object type: object
@@ -6848,6 +7674,16 @@ components:
user_agent: user_agent:
type: string type: string
description: Source user agent description: Source user agent
severity:
type: string
enum: [info, success, warning, error]
description: Event severity for feed display
category:
type: string
description: Coarse category (service, deployment, project, user, security, system, …)
label:
type: string
description: Human-readable event label
created_at: created_at:
type: string type: string
format: date-time format: date-time
@@ -6887,6 +7723,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:
+22
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,23 @@ 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
activity enriched activity feed; --severity/--category/
--resource/--project/--limit filters
shell <service-id> interactive PTY shell inside the service
container (bash→sh fallback; resizes follow)
banners list|create|update|delete announcement banners (admin);
active banners print on `auth status`
invites list|create|revoke team invite links (admin); create
prints the one-time acceptance URL
admin impersonate <id> mint a 15-min token acting as a user
admin github-app status|manifest|convert
self-provision the instance GitHub App
up deploy the current directory up deploy the current directory
``` ```