Compare commits

...
Author SHA1 Message Date
Tomas DvorakandDevin 7cb5e10eb6 feat(nodes): mesh awareness + node detail page
- agent reports tailscale/netbird/zerotier/wireguard interface IPs at
  register (authoritative — absent interfaces clear stored state)
- backend stores under node_agents.metadata.mesh, merged on re-register
- node detail page shows mesh addresses under the primary IP

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 12:24:28 +02:00
Tomas DvorakandDevin 1361868946 feat(nodes): node detail page — telemetry, containers, commands, controls
/nodes/:id (admin): cpu/mem sparklines from heartbeat history
(1h/24h/7d), containers on node, command history, inline rename,
auto-prune toggle, prune-now with volume opt-in, remove. Agent rows
on the Usage page link into it.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 12:20:34 +02:00
Tomas DvorakandDevin 4848d1f6f0 feat(nodes): SSH bootstrap + housekeeping — install script, bundled binaries, auto-prune
- `containr nodes add <user@host>` mints an enroll token, SSHes once,
  runs the install script remotely; --print emits the one-liner
- Public /api/agents/install.sh + /api/agents/download/{platform};
  agent binaries built into the image under /agents
- Agent-side `prune` (bounded docker system prune, volumes opt-in)
  and `system_df` commands
- node_agents.auto_prune + health sweep enqueues daily prune;
  offline/online transitions notify admins
- CLI nodes update/prune/commands + MCP tools (prune confirm-gated)
- Usage page: install one-liner, per-node Prune + auto toggle

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 12:15:36 +02:00
Tomáš DvořákandDevin 7547375cc2 feat(notifications): producers — backups, agent health, scans, upgrades (#49)
* feat(filters): dynamic filter framework — typed schema, URL state, FilterBar

Port of dflow's filter.utils pattern, adapted to Containr:

- lib/dynamic-filter.ts: FilterConfig schema (search/select/multiselect/
  boolean/date-range/status), FilterEngine with client-side apply,
  derived options with counts, active-filter tracking, removal.
- lib/use-dynamic-filter.ts: filter state lives in ?f.<key> search
  params — filtered views are shareable links. useFilterState exposes
  URL state before data loads (server-backed filters); useDynamicFilter
  adds the engine over fetched data.
- shared/components/FilterBar.tsx: renders a schema as compact controls
  + active-filter chips, matching design tokens.
- Applied: audit logs (server-backed — state in URL, params to API),
  builds (server-backed + client date-range), activity (severity to the
  server query, category/search on the fetched page).
- 8 unit tests for the engine.

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

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

* fix(filters): erasableSyntaxOnly compat + BuildStatus cast

- Constructor parameter properties are banned under erasableSyntaxOnly —
  declare fields explicitly.
- listBuilds status param needs the BuildStatus type, not raw string.

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

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

* feat(notifications): producers round-out — backups, agent health, scans, upgrades

Four new emitters behind insertUserNotification:

- Backups: createBackupProcess notifies the database owner on
  completion (with size) and on failure (docker-down, snapshot error).
- Agent health: StartAgentHealthSweep marks agents offline after 3 min
  without heartbeat and back online on recovery — admins notified on
  each transition.
- Security scans: Scanner.OnScanComplete callback notifies the project
  owner with critical/high/total counts + score; scans with findings
  get a distinct title.
- Upgrades: daily check against the latest GitHub release; admins
  notified once per new tag (deduped via app_settings). Version stamped
  into the image via Dockerfile ARG + ldflags on tag builds; "dev"
  disables the check.

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 12:12:21 +02:00
Tomáš DvořákandDevin d4a4cde1fd feat(filters): dynamic filter framework — schema-driven filters with URL state (#48)
* feat(filters): dynamic filter framework — typed schema, URL state, FilterBar

Port of dflow's filter.utils pattern, adapted to Containr:

- lib/dynamic-filter.ts: FilterConfig schema (search/select/multiselect/
  boolean/date-range/status), FilterEngine with client-side apply,
  derived options with counts, active-filter tracking, removal.
- lib/use-dynamic-filter.ts: filter state lives in ?f.<key> search
  params — filtered views are shareable links. useFilterState exposes
  URL state before data loads (server-backed filters); useDynamicFilter
  adds the engine over fetched data.
- shared/components/FilterBar.tsx: renders a schema as compact controls
  + active-filter chips, matching design tokens.
- Applied: audit logs (server-backed — state in URL, params to API),
  builds (server-backed + client date-range), activity (severity to the
  server query, category/search on the fetched page).
- 8 unit tests for the engine.

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

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

* fix(filters): erasableSyntaxOnly compat + BuildStatus cast

- Constructor parameter properties are banned under erasableSyntaxOnly —
  declare fields explicitly.
- listBuilds status param needs the BuildStatus type, not raw string.

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 12:12:17 +02:00
Tomáš DvořákandDevin 8cfe2ca826 fix(git): webhook receiver — fork-sync events + missing deployment version (#47)
CI / Changed paths (push) Canceled after 0s
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Mobile (Expo) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
Two fixes in the push receiver:

- Accept repo-sync events (`sync`, `repository_dispatch`, `workflow_run`)
  that carry no ref — fork-sync bots and Actions dispatch these instead
  of pushes. Branch falls back to the webhook filter then the service's
  own git_branch; repo-only match when no branch is known.
- The deployment INSERT omitted the NOT NULL `version` column, so every
  webhook-triggered deploy failed the insert silently and enqueued
  nothing. Push-triggered deploys have been dead since the receiver
  shipped. Now set `v<unix>` like the manual deploy path.

Verified live: repository_dispatch → branch=main, 1 deployment enqueued;
push → 1; star → ignored.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 11:13:37 +02:00
Tomáš DvořákandDevin 612520bbff feat: interactive terminal — WS-bridged docker exec PTY (#46)
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:13:34 +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
78 changed files with 7902 additions and 296 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
+4
View File
@@ -248,6 +248,10 @@ jobs:
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta-backend.outputs.tags }} tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }} labels: ${{ steps.meta-backend.outputs.labels }}
# Stamp the release tag into the binary so the in-app upgrade
# check knows what it's running ("dev" disables it).
build-args: |
VERSION=${{ github.ref_type == 'tag' && github.ref_name || 'dev' }}
cache-from: type=gha,scope=backend cache-from: type=gha,scope=backend
cache-to: type=gha,mode=max,scope=backend cache-to: type=gha,mode=max,scope=backend
+90 -40
View File
@@ -89,12 +89,12 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
| Outbound webhooks (collection events, HMAC, custom headers) | ✅ `outbound_webhooks` + `webhook_deliveries`, HMAC `X-Containr-Signature`, wildcard events, 3-retry, UI page | — | | 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 token + `impersonated_by` claim (browser swap pending) | Done (CLI/API/MCP) |
| In-app docs (content-collections markdown) | ✅ `/docs` | — | | In-app docs (content-collections markdown) | ✅ `/docs` | — |
| Dynamic client-side filter framework (535-line `filter.utils`) | 🟡 audit page filters only | Reuse pattern for tables | | Dynamic client-side filter framework (535-line `filter.utils`) | ✅ `dynamic-filter.ts` + URL state; audit/builds/activity | Done |
| Ops "bubble" (queue monitor, terminal, sync) | ❌ | Operations page | | Ops "bubble" (queue monitor, terminal, sync) | ✅ `/operations` + interactive terminal | Done |
#### Agent surface #### Agent surface
| dflow capability | Containr status | Action | | dflow capability | Containr status | Action |
@@ -103,8 +103,8 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
| CLI | ❌ | none in dflow — **Containr CLI is a genuine leap past it** | | CLI | ❌ | none in dflow — **Containr CLI is a genuine leap past it** |
| MCP | ❌ | none — same leap | | MCP | ❌ | none — same leap |
| Railway migrator script | ❌ | `containr migrate railway` | | Railway migrator script | ❌ | `containr migrate railway` |
| GitHub App **manifest flow** (instance self-provisions the App) | 🟡 env-configured App | `POST /git/github-app/manifest` flow | | GitHub App **manifest flow** (instance self-provisions the App) | ✅ manifest + convert + App webhook | Done |
| fork-sync webhook deploy trigger | ❌ | ~20 lines on receiver | | fork-sync webhook deploy trigger | ✅ sync/repository_dispatch/workflow_run | Done |
| Azure DevOps provider | ❌ | Optional (dflow does token-clone only) | | Azure DevOps provider | ❌ | Optional (dflow does token-clone only) |
--- ---
@@ -252,18 +252,31 @@ and wired refs, one click or one CLI call.
## 7. Phase F — Nodes & infrastructure ## 7. Phase F — Nodes & infrastructure
- [ ] **SSH bootstrap install** — `containr nodes add --ssh user@host` + - [x] **SSH bootstrap install** — `containr nodes add <user@host>` mints an
UI flow: one SSH session runs agent install (binary + systemd + enroll token, SSHes once (agent/key/default keys), runs
enroll token), SSH closed forever. dflow onboarding UX, agent-model `GET /api/agents/install.sh | bash` remotely, closes the session.
security. `--print` emits the one-liner for manual runs; the Usage page shows the
- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/ same command. Agent binaries ship in the image under `/agents` and are
zt0 IPs at register; backend stores + displays. Optional served at `/api/agents/download/{linux-amd64,linux-arm64}` (install
`TAILSCALE_AUTH_KEY` enroll-at-provision later. script falls back to GH releases). `cli/commands/nodes_add.go`,
- [ ] **Node detail page** — placed services, telemetry history, agent `api/assets/install-agent.sh`, `agents.go:ServeInstallScript`/`ServeAgentBinary`.
version + self-upgrade, **drain/cordon**, remove. - [x] **Mesh awareness** — agent reports tailscale/netbird/wireguard/
- [ ] **Node housekeeping** — agent `prune` command (docker system zerotier interface IPs at register (`mesh` field, authoritative on
prune, bounded) + optional schedule (`setServerAutoCleanup` analog — re-register), stored in `node_agents.metadata.mesh`, shown on the node
disk pressure is the #1 small-VPS killer). detail page. `TAILSCALE_AUTH_KEY` enroll-at-provision still open.
- [x] **Node detail page** — `/nodes/:id` (admin): heartbeat telemetry
(cpu/mem sparklines over 1h/24h/7d via `GET /agents/:id/metrics`),
containers, command history, rename, auto-prune toggle, prune-now,
remove. Drain/cordon and self-upgrade deferred — they need
`services.node_id` routing which lands with multi-node scheduling.
- [x] **Node housekeeping** — agent `prune` + `system_df` commands
(`docker system prune -af`, volumes excluded unless requested, optional
`--until`). `node_agents.auto_prune` + the health sweep enqueue a prune
at most once per 24h per node. Surfaces: `POST /agents/:id/prune`,
`nodes update --auto-prune` / `nodes prune [--volumes --until]` /
`nodes commands`, MCP `containr_nodes_update|prune|commands`
(confirm-gated), Usage-page per-node Prune button + auto toggle.
`cmd/agent/main.go`, `notification_producers.go:scheduleAutoPrune`.
- [ ] **Multi-node scheduling** — wire `scheduling_rules` (table exists): - [ ] **Multi-node scheduling** — wire `scheduling_rules` (table exists):
pin/spread/affinity, prefer-lowest-usage; deploy engine routes to pin/spread/affinity, prefer-lowest-usage; deploy engine routes to
`services.node_id` via agent commands (agent already runs containers). `services.node_id` via agent commands (agent already runs containers).
@@ -282,34 +295,71 @@ and wired refs, one click or one CLI call.
headers, enabled)` + `webhook_deliveries` log; HMAC headers, enabled)` + `webhook_deliveries` log; HMAC
`X-Containr-Signature`; wildcard events on every audited action; `X-Containr-Signature`; wildcard events on every audited action;
CLI + MCP + `/settings/webhooks` page with delivery log + test ping. CLI + MCP + `/settings/webhooks` page with delivery log + test ping.
- [ ] **Activity feed** — enrich `audit_logs` with `severity category - [x] **Activity feed** — `audit_logs` enriched with `severity`
icon label metadata`; per-project timeline + global feed; TTL job. (`info|success|warning|error`), `category`, human `label`; derived at
- [ ] **Banners** — `banners(title, body, level, scope, active)`; top write time, classified on read for legacy rows, boot-time backfill.
bar in UI + surfaced to CLI on `auth status`. `GET /activity` (global, visibility-scoped, filters + `since`) and
- [ ] **White-label** — `app_settings.branding`: product name, logos, `GET /projects/:id/activity` (owner/member/approved/admin). Frontend:
favicon, accent override, docs/support links. `GET /api/v1/branding` `/activity` page with severity chips + project workspace `Activity`
public + cached. **Containr/Vertice remains default;** this is for view. `containr activity` + `containr_activity_list`/`containr_project_activity`
self-hosters who rebrand their instance. MCP tools. Retention worker prunes > `AUDIT_RETENTION_DAYS` (default 90).
- [x] **Banners** — `banners` table with title/body/level/active/
dismissible/schedule window. `GET /banners/active` for signed-in
users; admin CRUD at `/admin/banners`. `BannerBar` renders above the
header (per-banner dismiss via localStorage); admin console manages
them; `containr banners` CLI + `auth status` surfaces active ones.
- [x] **White-label** — `app_settings` `branding.*` keys: product name,
logo/favicon URL, accent color, docs/support links. Public
`GET /api/v1/branding` (defaults = Containr); admin writes via
`PUT /settings`. `useBranding()` applies title/accent/favicon;
`BrandWordmark` replaces hardcoded marks; admin Platform section
edits all fields. MCP `containr_branding_get` + `settings_update`.
- [x] **Operations page** — `GET /operations` aggregates active - [x] **Operations page** — `GET /operations` aggregates active
deployments, in-memory deploy-queue depth (`Queue.Snapshot()`), deployments, in-memory deploy-queue depth (`Queue.Snapshot()`),
24h failures, cron runs and backups scoped to the caller's projects. 24h failures, cron runs and backups scoped to the caller's projects.
UI at `/operations` (Operate nav) with live refetch; `containr UI at `/operations` (Operate nav) with live refetch; `containr
operations` CLI + `containr_operations_get` MCP. Remaining: agent operations` CLI + `containr_operations_get` MCP. Remaining: agent
commands once node fleet lands; flush/stuck-job tools. commands once node fleet lands; flush/stuck-job tools.
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`); - [x] **Interactive terminal** — `GET /services/:id/terminal` upgrades
`nodes/:id/terminal` via agent (dflow's wetty without the extra to a WebSocket bridged to a `docker exec` PTY (bash→sh fallback,
container). `TERM=xterm-256color`, resize frames). xterm.js Console section on the
- [ ] **Team invites** — `POST /invites` → signed link → join flow; service page; `containr shell <id>` bridges a local TTY; distroless
replaces manual-only user creation. images get a clean "no shell" error. Remaining: `nodes/:id/terminal`
- [ ] **Impersonate user** — admin support tool, audit-logged. via agent.
- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest` - [x] **Team invites** — `POST /admin/invites` returns a one-time
returns manifest + redirect; instance self-provisions its App link (sha256-hashed token at rest, optional email binding, TTL);
(dflow pattern — removes the env-setup barrier). public `GET /auth/invites/:token` validates and
- [ ] **fork-sync trigger** — accept repo-sync events on webhook `POST /auth/accept-invite` registers — works while public signup is
receiver. closed. `/auth/accept-invite` page, admin manage/revoke UI,
- [ ] **Dynamic filter framework** — port dflow's `filter.utils` `containr invites` CLI + MCP tools.
pattern (typed filter configs → URL state → client filtering) for - [x] **Impersonate user** — `POST /admin/users/:id/impersonate`
audit logs, deployments, notifications tables. 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.
- [x] **fork-sync trigger** — webhook receiver accepts `sync`,
`repository_dispatch`, and `workflow_run` events (no `ref`) alongside
pushes: branch falls back to the webhook filter, then the service's
own `git_branch`; repo-only matching when no branch is known.
Also fixed a latent bug where the webhook's deployment insert omitted
the NOT NULL `version` column — push-triggered deploys silently
failed for every service.
- [x] **Dynamic filter framework** — ported dflow's `filter.utils`
pattern as `lib/dynamic-filter.ts` + `use-dynamic-filter.ts`
(`FilterConfig` schema → `FilterEngine` → `FilterBar`; state lives in
`?f.<key>` search params so filtered views are shareable links).
`useFilterState` exposes URL state pre-fetch for server-backed
filters. Applied: audit logs (server-backed), builds (server + client
date-range), activity (severity server + client category/search).
Notifications table has no list surface yet — apply when one lands.
- [ ] **Notification producers round-out** — backups, agent offline, - [ ] **Notification producers round-out** — backups, agent offline,
upgrade available, security findings. upgrade available, security findings.
- [x] **Sleep-on-idle / wake-on-traffic** (serverless-lite) — - [x] **Sleep-on-idle / wake-on-traffic** (serverless-lite) —
+8 -1
View File
@@ -9,7 +9,13 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \
-ldflags "-X containr/internal/api.Version=${VERSION}" -o main cmd/server/main.go
# Node-agent binaries served by the API for SSH/bootstrap installs.
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /agents/containr-agent-linux-amd64 ./cmd/agent && \
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /agents/containr-agent-linux-arm64 ./cmd/agent
# Build embedded Better Auth runtime dependencies # Build embedded Better Auth runtime dependencies
FROM node:22-alpine AS auth-builder FROM node:22-alpine AS auth-builder
@@ -33,6 +39,7 @@ WORKDIR /app
COPY --from=go-builder /app/main ./main COPY --from=go-builder /app/main ./main
COPY --from=go-builder /app/migrations_goose ./migrations_goose COPY --from=go-builder /app/migrations_goose ./migrations_goose
COPY --from=go-builder /agents /agents
COPY --from=auth-builder /auth ./auth COPY --from=auth-builder /auth ./auth
RUN chown -R appuser:appgroup /app RUN chown -R appuser:appgroup /app
+56
View File
@@ -167,6 +167,8 @@ func register(ctx context.Context, client *http.Client, cfg agentConfig) (string
"ip_address": cfg.IP, "ip_address": cfg.IP,
"port": cfg.Port, "port": cfg.Port,
"capabilities": detectCapabilities(), "capabilities": detectCapabilities(),
// Always send mesh so a removed interface clears stored state.
"mesh": detectMeshInterfaces(),
} }
var response registerResponse var response registerResponse
if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil { if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil {
@@ -246,11 +248,29 @@ func executeCommand(item command) (string, error) {
return runDocker("restart", commandTarget(item.Payload)) return runDocker("restart", commandTarget(item.Payload))
case "remove_container": case "remove_container":
return runDocker("rm", "-f", commandTarget(item.Payload)) return runDocker("rm", "-f", commandTarget(item.Payload))
case "prune":
return dockerPrune(item.Payload)
case "system_df":
return runDocker("system", "df", "--format", "json")
default: default:
return "", fmt.Errorf("unsupported command type %q", item.Type) return "", fmt.Errorf("unsupported command type %q", item.Type)
} }
} }
// dockerPrune runs a bounded docker system prune. Volumes are excluded
// unless the payload explicitly opts in — a stray volume prune destroys
// database data.
func dockerPrune(payload map[string]interface{}) (string, error) {
args := []string{"system", "prune", "-af"}
if boolValue(payload["volumes"]) {
args = append(args, "--volumes")
}
if until := stringValue(payload["until"]); until != "" {
args = append(args, "--filter", fmt.Sprintf("until=%s", until))
}
return runDocker(args...)
}
func createContainer(payload map[string]interface{}) (string, error) { func createContainer(payload map[string]interface{}) (string, error) {
container, ok := payload["container"].(map[string]interface{}) container, ok := payload["container"].(map[string]interface{})
if !ok { if !ok {
@@ -354,6 +374,42 @@ func detectCapabilities() capabilities {
} }
} }
// detectMeshInterfaces reports overlay-network interface IPs (tailscale,
// netbird, zerotier, wireguard) so the backend can display reachable
// mesh addresses alongside the primary IP.
func detectMeshInterfaces() map[string]string {
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
mesh := map[string]string{}
for _, iface := range ifaces {
name := strings.ToLower(iface.Name)
isMesh := strings.HasPrefix(name, "tailscale") || strings.HasPrefix(name, "ts.") ||
strings.HasPrefix(name, "netbird") || strings.HasPrefix(name, "wt") ||
strings.HasPrefix(name, "zt") || strings.HasPrefix(name, "wg")
if !isMesh || iface.Flags&net.FlagUp == 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ip, _, err := net.ParseCIDR(addr.String())
if err != nil || ip.IsLoopback() {
continue
}
if v4 := ip.To4(); v4 != nil {
mesh[iface.Name] = v4.String()
} else if _, exists := mesh[iface.Name]; !exists {
mesh[iface.Name] = ip.String()
}
}
}
return mesh
}
func collectResources(ip string) nodeResources { func collectResources(ip string) nodeResources {
mem := readMemory() mem := readMemory()
storage := readStorage("/") storage := readStorage("/")
+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)
+89 -2
View File
@@ -6,6 +6,7 @@ import (
"crypto/sha256" "crypto/sha256"
"crypto/subtle" "crypto/subtle"
"database/sql" "database/sql"
_ "embed"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -13,6 +14,7 @@ import (
"math" "math"
"net/http" "net/http"
"os" "os"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -36,6 +38,7 @@ type NodeAgent struct {
Capabilities AgentCapabilities `json:"capabilities"` Capabilities AgentCapabilities `json:"capabilities"`
Resources NodeResources `json:"resources"` Resources NodeResources `json:"resources"`
LastHeartbeat time.Time `json:"last_heartbeat"` LastHeartbeat time.Time `json:"last_heartbeat"`
AutoPrune bool `json:"auto_prune"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Metadata map[string]interface{} `json:"metadata"` Metadata map[string]interface{} `json:"metadata"`
@@ -234,6 +237,7 @@ func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
Port: int(row.Port), Port: int(row.Port),
Status: row.Status.String, Status: row.Status.String,
Version: row.Version.String, Version: row.Version.String,
AutoPrune: row.AutoPrune,
Metadata: map[string]interface{}{}, Metadata: map[string]interface{}{},
} }
if row.LastHeartbeat.Valid { if row.LastHeartbeat.Valid {
@@ -327,6 +331,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
Port int `json:"port" binding:"required"` Port int `json:"port" binding:"required"`
Capabilities AgentCapabilities `json:"capabilities" binding:"required"` Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
AuthToken string `json:"auth_token"` AuthToken string `json:"auth_token"`
Mesh map[string]string `json:"mesh"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -341,12 +346,28 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
} }
ctx := c.Request.Context() ctx := c.Request.Context()
metadata := map[string]interface{}{}
// req.Mesh is authoritative when present (newer agents always send it,
// possibly empty); nil means an older agent — keep any stored value.
if req.Mesh != nil {
metadata["mesh"] = req.Mesh
}
// Re-registering an existing host updates it in place. // Re-registering an existing host updates it in place.
if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{ if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{
Hostname: req.Hostname, Hostname: req.Hostname,
IpAddress: req.IPAddress, IpAddress: req.IPAddress,
}); err == nil { }); err == nil {
if existing.Metadata.Valid && len(existing.Metadata.RawMessage) > 0 {
var prev map[string]interface{}
if err := json.Unmarshal(existing.Metadata.RawMessage, &prev); err == nil {
for k, v := range prev {
if _, ok := metadata[k]; !ok {
metadata[k] = v
}
}
}
}
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{ updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
ID: existing.ID, ID: existing.ID,
Name: req.Name, Name: req.Name,
@@ -358,7 +379,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
Capabilities: rawJSON(req.Capabilities), Capabilities: rawJSON(req.Capabilities),
Resources: existing.Resources, Resources: existing.Resources,
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true}, LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
Metadata: existing.Metadata, Metadata: rawJSON(metadata),
}) })
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
@@ -408,7 +429,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
Capabilities: rawJSON(req.Capabilities), Capabilities: rawJSON(req.Capabilities),
Resources: rawJSON(resources), Resources: rawJSON(resources),
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true}, LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
Metadata: rawJSON(map[string]interface{}{}), Metadata: rawJSON(metadata),
}) })
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
@@ -489,6 +510,7 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
Resources: rawJSON(agent.Resources), Resources: rawJSON(agent.Resources),
LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()}, LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()},
Metadata: rawJSON(agent.Metadata), Metadata: rawJSON(agent.Metadata),
AutoPrune: agent.AutoPrune,
}) })
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
@@ -516,6 +538,7 @@ func applyAgentUpdates(agent *NodeAgent, updates map[string]interface{}) {
remarshal("capabilities", &agent.Capabilities) remarshal("capabilities", &agent.Capabilities)
remarshal("resources", &agent.Resources) remarshal("resources", &agent.Resources)
remarshal("metadata", &agent.Metadata) remarshal("metadata", &agent.Metadata)
remarshal("auto_prune", &agent.AutoPrune)
} }
// DeleteAgent removes an agent // DeleteAgent removes an agent
@@ -803,6 +826,33 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"container": container}) c.JSON(http.StatusCreated, gin.H{"container": container})
} }
// PruneAgent enqueues a bounded `docker system prune -af` command. Volumes
// stay unless the caller explicitly opts in.
func (h *NodeAgentHandler) PruneAgent(c *gin.Context) {
agentID := c.Param("id")
if _, err := h.q.GetAgent(c.Request.Context(), agentID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
var req struct {
Until string `json:"until"` // e.g. "168h" — only prune objects older than this
Volumes bool `json:"volumes"` // destructive — off by default
}
_ = c.ShouldBindJSON(&req)
command, err := h.q.CreateCommand(c.Request.Context(), sqlcdb.CreateCommandParams{
ID: uuid.New().String(),
Type: "prune",
NodeAgentID: agentID,
Payload: rawJSON(map[string]interface{}{"until": req.Until, "volumes": req.Volumes}),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enqueue prune"})
return
}
c.JSON(http.StatusAccepted, gin.H{"command": command})
}
// ExecuteCommand executes a command on an agent // ExecuteCommand executes a command on an agent
func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) { func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
agentID := c.Param("id") agentID := c.Param("id")
@@ -1214,9 +1264,45 @@ func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
agents.POST("/heartbeat", h.SendHeartbeat) agents.POST("/heartbeat", h.SendHeartbeat)
agents.GET("/:id/commands", h.GetPendingCommandsForAgent) agents.GET("/:id/commands", h.GetPendingCommandsForAgent)
agents.POST("/:id/commands/:commandId/result", h.CompleteCommand) agents.POST("/:id/commands/:commandId/result", h.CompleteCommand)
// Bootstrap surface — the install script and agent binaries are
// public; the enroll token is the actual secret.
agents.GET("/install.sh", h.ServeInstallScript)
agents.GET("/download/:platform", h.ServeAgentBinary)
} }
} }
//go:embed assets/install-agent.sh
var agentInstallScript string
// ServeInstallScript returns the agent bootstrap script. The script
// downloads the binary from this instance first, GH releases second.
func (h *NodeAgentHandler) ServeInstallScript(c *gin.Context) {
c.Data(http.StatusOK, "text/x-sh", []byte(agentInstallScript))
}
// ServeAgentBinary streams a bundled agent binary. Images ship the
// linux binaries under CONTAINR_AGENT_BIN_DIR (default /agents).
func (h *NodeAgentHandler) ServeAgentBinary(c *gin.Context) {
platform := c.Param("platform")
if platform != "linux-amd64" && platform != "linux-arm64" {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported platform"})
return
}
dir := os.Getenv("CONTAINR_AGENT_BIN_DIR")
if dir == "" {
dir = "/agents"
}
path := filepath.Join(dir, "containr-agent-"+platform)
info, err := os.Stat(path)
if err != nil || info.IsDir() {
c.JSON(http.StatusNotFound, gin.H{"error": "no bundled binary for " + platform})
return
}
c.Header("Content-Disposition", `attachment; filename="containr-agent"`)
c.File(path)
}
// SetupAdminRoutes registers dashboard/control agent routes. Command // SetupAdminRoutes registers dashboard/control agent routes. Command
// history and container details can leak sensitive output, so these stay // history and container details can leak sensitive output, so these stay
// admin-only rather than joining the public read group. // admin-only rather than joining the public read group.
@@ -1243,6 +1329,7 @@ func (h *NodeAgentHandler) SetupAdminRoutes(router *gin.RouterGroup) {
agents.DELETE("/:id/containers/:containerId", h.ContainerAction) agents.DELETE("/:id/containers/:containerId", h.ContainerAction)
agents.GET("/:id/metrics", h.GetAgentMetrics) agents.GET("/:id/metrics", h.GetAgentMetrics)
agents.POST("/:id/prune", h.PruneAgent)
agents.POST("/:id/commands", h.ExecuteCommand) agents.POST("/:id/commands", h.ExecuteCommand)
agents.GET("/:id/commands", h.GetAgentCommands) agents.GET("/:id/commands", h.GetAgentCommands)
agents.GET("/:id/commands/:commandId", h.GetCommandStatus) agents.GET("/:id/commands/:commandId", h.GetCommandStatus)
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Containr node agent installer.
# curl -fsSL <api>/api/agents/install.sh | bash -s -- --url <api> --token <enroll-token>
#
# One-shot: downloads the agent binary from the Containr instance (or GH
# releases as fallback), writes a systemd unit, starts it. SSH is never
# needed again — the agent heartbeats and polls commands outbound.
set -euo pipefail
API_URL=""
TOKEN=""
AGENT_NAME=""
VERSION=""
while [ $# -gt 0 ]; do
case "$1" in
--url) API_URL="$2"; shift 2 ;;
--token) TOKEN="$2"; shift 2 ;;
--name) AGENT_NAME="$2"; shift 2 ;;
--version) VERSION="$2"; shift 2 ;;
*) echo "unknown flag: $1" >&2; exit 2 ;;
esac
done
[ -n "$API_URL" ] || { echo "--url is required" >&2; exit 2; }
[ -n "$TOKEN" ] || { echo "--token is required (create one: containr nodes tokens create)" >&2; exit 2; }
API_URL="${API_URL%/}"
API_URL="${API_URL%/api/v1}"
API_URL="${API_URL%/api}"
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
err() { printf '\033[1;31mError:\033[0m %s\n' "$*" >&2; }
[ "$(id -u)" = "0" ] || { err "run as root (sudo bash -s -- --url … --token …)"; exit 1; }
command -v docker >/dev/null 2>&1 || { err "docker not found"; exit 1; }
command -v curl >/dev/null 2>&1 || { err "curl not found"; exit 1; }
ARCH="$(uname -m)"
case "$ARCH" in
x86_64|amd64) ARCH=amd64 ;;
aarch64|arm64) ARCH=arm64 ;;
*) err "unsupported architecture: $ARCH"; exit 1 ;;
esac
BIN=/usr/local/bin/containr-agent
info "downloading containr-agent (linux/$ARCH)"
if ! curl -fsSL "$API_URL/api/agents/download/linux-$ARCH" -o "$BIN" 2>/dev/null; then
info "instance has no bundled binary — trying GitHub releases"
REF="${VERSION:-latest}"
if [ "$REF" = "latest" ]; then
DL="https://github.com/Dvorinka/Containr/releases/latest/download/containr-agent-linux-$ARCH"
else
DL="https://github.com/Dvorinka/Containr/releases/download/$REF/containr-agent-linux-$ARCH"
fi
curl -fsSL "$DL" -o "$BIN" || { err "agent download failed"; exit 1; }
fi
chmod +x "$BIN"
UNIT=/etc/systemd/system/containr-agent.service
info "writing systemd unit"
cat > "$UNIT" <<UNIT
[Unit]
Description=Containr node agent
After=docker.service network-online.target
Wants=network-online.target
Requires=docker.service
[Service]
Environment=CONTAINR_API_URL=$API_URL
Environment=CONTAINR_AGENT_AUTH_TOKEN=$TOKEN
Environment=CONTAINR_AGENT_NAME=${AGENT_NAME:-$(hostname)}
Restart=always
RestartSec=5
ExecStart=$BIN
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now containr-agent
info "agent installed and started — it should appear in Containr within ~15s"
+73 -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,12 +100,13 @@ 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 {
@@ -59,6 +118,9 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in
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")
@@ -71,12 +133,13 @@ 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 {
@@ -155,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
@@ -173,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
@@ -2,6 +2,7 @@ package api
import ( import (
"archive/tar" "archive/tar"
"containr/internal/database"
"containr/internal/database/sqlcdb" "containr/internal/database/sqlcdb"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/docker" "containr/internal/docker"
@@ -1868,14 +1869,22 @@ func (h *DatabaseHandler) createBackupProcess(databaseID, backupID, backupPath s
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel() defer cancel()
notify := func(title, body string) {
if row, err := h.queries.GetDatabaseServiceByID(context.Background(), databaseID); err == nil && row.UserID != "" {
insertUserNotification(&database.DB{DB: h.db}, row.UserID, "backup", title, body, "database", databaseID)
}
}
if h.dockerClient == nil { if h.dockerClient == nil {
_ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false) _ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false)
notify("Database backup failed", "Backup could not start: docker unavailable.")
return return
} }
sizeLabel, err := h.snapshotDatabaseVolume(ctx, databaseID, backupPath) sizeLabel, err := h.snapshotDatabaseVolume(ctx, databaseID, backupPath)
if err != nil { if err != nil {
_ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false) _ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false)
notify("Database backup failed", fmt.Sprintf("Snapshot failed: %v", err))
return return
} }
if dbRow, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil { if dbRow, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil {
@@ -1886,6 +1895,7 @@ func (h *DatabaseHandler) createBackupProcess(databaseID, backupID, backupPath s
} }
} }
_ = h.setDatabaseBackupStatus(backupID, "completed", sizeLabel, true) _ = h.setDatabaseBackupStatus(backupID, "completed", sizeLabel, true)
notify("Database backup completed", fmt.Sprintf("Backup archived (%s).", sizeLabel))
} }
func (h *DatabaseHandler) restoreBackupProcess(databaseID, backupID string, backupPath string) { func (h *DatabaseHandler) restoreBackupProcess(databaseID, backupID string, backupPath string) {
+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")
} }
+53 -20
View File
@@ -7,6 +7,7 @@ import (
"crypto/subtle" "crypto/subtle"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"strings" "strings"
@@ -90,11 +91,24 @@ func handleGitWebhookPush(c *gin.Context) {
return return
} }
if !strings.HasPrefix(payload.Ref, "refs/heads/") { // Non-push events that still mean "the repo changed": fork-sync bots
// (sync), repository_dispatch, workflow_run completions. They carry no
// `ref`, so fall back to the webhook's branch filter (or the service's
// own git_branch via the repo match below).
event := c.Request.Header.Get("X-GitHub-Event")
if event == "" {
event = c.Request.Header.Get("X-Gitea-Event")
}
syncEvent := event == "sync" || event == "repository_dispatch" || event == "workflow_run"
if !strings.HasPrefix(payload.Ref, "refs/heads/") && !syncEvent {
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "not a branch push"}) c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "not a branch push"})
return return
} }
branch := strings.TrimPrefix(payload.Ref, "refs/heads/") branch := strings.TrimPrefix(payload.Ref, "refs/heads/")
if branch == "" {
branch = branchFilter
}
if branchFilter != "" && branchFilter != branch { if branchFilter != "" && branchFilter != branch {
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "branch not watched"}) c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "branch not watched"})
return return
@@ -113,16 +127,40 @@ func handleGitWebhookPush(c *gin.Context) {
return return
} }
rows, err := db.Query(` 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) {
// Sync-style events may carry no branch — match services on repo only.
branchClause := "s.git_branch = $1"
if branch == "" {
branchClause = "TRUE"
}
rows, err := db.Query(fmt.Sprintf(`
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,
s.created_at, s.updated_at s.created_at, s.updated_at
FROM services s FROM services s
WHERE s.git_branch = $1 AND (s.git_repo = $2 OR s.git_repo = $3)`, WHERE %s AND (s.git_repo = $2 OR s.git_repo = $3)`, branchClause),
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 +172,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()
@@ -160,9 +194,9 @@ func handleGitWebhookPush(c *gin.Context) {
} }
if _, err := db.Exec( if _, err := db.Exec(
`INSERT INTO deployments `INSERT INTO deployments
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at) (id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt, d.ID, d.ServiceID, fmt.Sprintf("v%d", now.Unix()), d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
); err != nil { ); err != nil {
continue continue
} }
@@ -181,7 +215,11 @@ func handleGitWebhookPush(c *gin.Context) {
) )
// Webhook pushes default to a clean build — stale layers are the // Webhook pushes default to a clean build — stale layers are the
// classic silent-rollback failure (dflow convention). // classic silent-rollback failure (dflow convention).
webhookReq := CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook", NoCache: true} branchForBuild := branch
if branchForBuild == "" {
branchForBuild = service.GitBranch
}
webhookReq := CreateDeploymentRequest{CommitHash: commit, Branch: branchForBuild, Trigger: "webhook", NoCache: true}
if pos := getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{ if pos := getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{
DeploymentID: d.ID, DeploymentID: d.ID,
Run: func(jctx context.Context) { Run: func(jctx context.Context) {
@@ -199,12 +237,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})
}
@@ -0,0 +1,238 @@
package api
// Notification producers — the emitters behind `insertUserNotification`.
// Deployment success/failure lives in deployments.go; this file covers
// the remaining producers from the roadmap: admin fan-out helper, the
// agent offline/online sweep, and the upgrade-available check.
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
// Version is stamped at image build time via -ldflags "-X
// containr/internal/api.Version=…". "dev" disables the upgrade check.
var Version = "dev"
// notifyAdmins inserts a notification for every admin user. Best-effort.
func notifyAdmins(db *database.DB, kind, title, body, resourceType, resourceID string) {
if db == nil {
return
}
rows, err := db.Query(`SELECT id FROM users WHERE is_admin`)
if err != nil {
log.Printf("containr: notify admins lookup failed: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err == nil {
insertUserNotification(db, id.String(), kind, title, body, resourceType, resourceID)
}
}
}
// StartAgentHealthSweep marks agents offline when their heartbeat goes
// stale (and back online when it resumes), notifying admins on each
// transition. Heartbeats keep the row fresh via UpdateAgentHeartbeat.
func StartAgentHealthSweep(ctx context.Context, db *database.DB) {
const staleAfter = 3 * time.Minute
go func() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
reconcileAgentHealth(db, staleAfter)
}
}
}()
}
type agentHealthRow struct {
id string
name string
status string
stale bool
autoPrune bool
}
func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) {
cutoff := time.Now().Add(-staleAfter)
var agents []agentHealthRow
rows, err := db.Query(
`SELECT id, COALESCE(name, hostname, id::text), COALESCE(status, ''),
COALESCE(last_heartbeat < $1, TRUE), auto_prune
FROM node_agents`, cutoff)
if err != nil {
log.Printf("containr: agent health sweep: %v", err)
return
}
for rows.Next() {
var a agentHealthRow
if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale, &a.autoPrune); err == nil {
agents = append(agents, a)
}
}
rows.Close()
for _, a := range agents {
switch {
case a.stale && a.status != "offline":
if _, err := db.Exec(
`UPDATE node_agents SET status = 'offline', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
continue
}
notifyAdmins(db, "agent", "Node agent offline",
fmt.Sprintf("Agent %s missed its heartbeat for over %s.", a.name, staleAfter), "agent", a.id)
case !a.stale && a.status == "offline":
if _, err := db.Exec(
`UPDATE node_agents SET status = 'online', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
continue
}
notifyAdmins(db, "agent", "Node agent back online",
fmt.Sprintf("Agent %s resumed heartbeating.", a.name), "agent", a.id)
}
}
scheduleAutoPrune(db, agents)
}
// scheduleAutoPrune enqueues a `prune` command once per day for agents
// with auto_prune enabled. Disk pressure is the most common small-VPS
// failure — this is the dflow setServerAutoCleanup analog.
func scheduleAutoPrune(db *database.DB, agents []agentHealthRow) {
q := sqlcdb.New(db.DB)
for _, a := range agents {
if !a.autoPrune || a.stale {
continue
}
last, err := q.GetLastAgentCommandByType(context.Background(),
sqlcdb.GetLastAgentCommandByTypeParams{NodeAgentID: a.id, Type: "prune"})
if err == nil && last.CreatedAt.Valid && time.Since(last.CreatedAt.Time) < 24*time.Hour {
continue
}
if _, err := q.CreateCommand(context.Background(), sqlcdb.CreateCommandParams{
ID: uuid.New().String(),
Type: "prune",
NodeAgentID: a.id,
}); err != nil {
log.Printf("containr: auto-prune enqueue failed for agent %s: %v", a.id, err)
}
}
}
// StartUpgradeCheck polls GitHub for the latest release once a day and
// notifies admins when it is newer than the running version. The last
// notified tag is persisted in app_settings so restarts don't re-fire.
func StartUpgradeCheck(ctx context.Context, db *database.DB, currentVersion string) {
if currentVersion == "" || currentVersion == "dev" {
return
}
go func() {
checkForUpgrade(db, currentVersion)
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
checkForUpgrade(db, currentVersion)
}
}
}()
}
func checkForUpgrade(db *database.DB, currentVersion string) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.github.com/repos/Dvorinka/Containr/releases/latest", nil)
if err != nil {
return
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "containr-upgrade-check")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return
}
var release struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
}
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil || release.TagName == "" {
return
}
if !semverNewer(release.TagName, currentVersion) {
return
}
var lastNotified string
_ = db.QueryRow(`SELECT value FROM app_settings WHERE key = 'upgrade_notified_tag'`).Scan(&lastNotified)
if lastNotified == release.TagName {
return
}
notifyAdmins(db, "upgrade", "Containr update available",
fmt.Sprintf("Release %s is available (running %s). %s", release.TagName, currentVersion, release.HTMLURL),
"release", release.TagName)
_, _ = db.Exec(
`INSERT INTO app_settings (key, value, updated_at) VALUES ('upgrade_notified_tag', $1, NOW())
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`, release.TagName)
}
// semverNewer reports whether candidate is a higher semver than current.
// Non-semver input (e.g. "dev", commit hashes) compares false.
func semverNewer(candidate, current string) bool {
parse := func(s string) ([]int, bool) {
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
// Prerelease/build metadata sorts after the same base version
// upstream — for "is a newer release available" purposes the base
// comparison is what matters.
if j := strings.IndexAny(s, "-+"); j >= 0 {
s = s[:j]
}
parts := strings.Split(s, ".")
if len(parts) == 0 || len(parts) > 3 {
return nil, false
}
out := make([]int, 3)
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
return nil, false
}
out[i] = n
}
return out, true
}
c, ok1 := parse(candidate)
cur, ok2 := parse(current)
if !ok1 || !ok2 {
return false
}
for i := 0; i < 3; i++ {
if c[i] != cur[i] {
return c[i] > cur[i]
}
}
return false
}
@@ -0,0 +1,26 @@
package api
import "testing"
func TestSemverNewer(t *testing.T) {
cases := []struct {
candidate, current string
want bool
}{
{"v1.2.3", "v1.2.2", true},
{"1.3.0", "v1.2.9", true},
{"v2.0.0", "v1.9.9", true},
{"v1.2.3", "v1.2.3", false},
{"v1.2.0", "v1.2.3", false},
{"v1.10.0", "v1.9.9", true}, // numeric, not lexicographic
{"dev", "v1.0.0", false},
{"v1.0.0", "main", false},
{"v1.2.3-rc.1", "v1.2.2", true}, // prerelease suffix tolerated
{"", "v1.0.0", false},
}
for _, tc := range cases {
if got := semverNewer(tc.candidate, tc.current); got != tc.want {
t.Errorf("semverNewer(%q, %q) = %v, want %v", tc.candidate, tc.current, got, tc.want)
}
}
}
+2
View File
@@ -153,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))
} }
@@ -340,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"})
} }
+45
View File
@@ -106,6 +106,16 @@ 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)
// Agent health — marks stale heartbeats offline, notifies admins on
// offline/recovery transitions.
StartAgentHealthSweep(context.Background(), db)
// Daily upgrade check — notifies admins when a newer release exists.
StartUpgradeCheck(context.Background(), db, Version)
// Initialize database handler // Initialize database handler
databaseHandler := NewDatabaseHandler(db.DB, dockerClient) databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
@@ -249,6 +259,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)
@@ -258,6 +272,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.
@@ -335,6 +355,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Cross-cutting ops view — active/queued/failed work // Cross-cutting ops view — active/queued/failed work
authed.GET("/operations", handleGetOperations) 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 // Outbound webhooks — signed event delivery to user HTTP endpoints
authed.GET("/webhooks", handleListOutboundWebhooks) authed.GET("/webhooks", handleListOutboundWebhooks)
@@ -370,6 +395,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)
@@ -468,6 +496,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)
@@ -485,6 +514,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)
+19 -1
View File
@@ -29,9 +29,27 @@ type SecurityHandler struct {
func NewSecurityHandler(db *database.DB, encryptionKey string) *SecurityHandler { func NewSecurityHandler(db *database.DB, encryptionKey string) *SecurityHandler {
encryptionManager, _ := security.NewEncryptionManager(encryptionKey) encryptionManager, _ := security.NewEncryptionManager(encryptionKey)
scanner := security.NewScanner(db)
// Notify the project owner when a background scan completes — criticals
// earn a distinct title so they stand out in the notification list.
scanner.OnScanComplete = func(scan *security.SecurityScan) {
var ownerID string
if err := db.QueryRow(`SELECT owner_id::text FROM projects WHERE id = $1`, scan.ProjectID).Scan(&ownerID); err != nil || ownerID == "" {
return
}
title := "Security scan completed"
if scan.Summary.Critical > 0 || scan.Summary.High > 0 {
title = "Security scan found issues"
}
insertUserNotification(db, ownerID, "security", title,
fmt.Sprintf("%s scan finished: %d critical, %d high, %d total findings (score %d/100).",
scan.ScanType, scan.Summary.Critical, scan.Summary.High, scan.Summary.Total, scan.Summary.Score),
"project", scan.ProjectID)
}
return &SecurityHandler{ return &SecurityHandler{
db: db, db: db,
scanner: security.NewScanner(db), scanner: scanner,
complianceManager: security.NewComplianceManager(db), complianceManager: security.NewComplianceManager(db),
encryptionManager: encryptionManager, encryptionManager: encryptionManager,
dataRetentionManager: security.NewDataRetentionManager(encryptionManager), dataRetentionManager: security.NewDataRetentionManager(encryptionManager),
+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)
} }
+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
}
@@ -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,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)
}
+75 -1
View File
@@ -81,6 +81,76 @@ var nodesDeleteCmd = &cobra.Command{
}, },
} }
var nodesUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a node agent (--name, --auto-prune)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
if cmd.Flags().Changed("name") {
body["name"], _ = cmd.Flags().GetString("name")
}
if cmd.Flags().Changed("auto-prune") {
body["auto_prune"], _ = cmd.Flags().GetBool("auto-prune")
}
if len(body) == 0 {
return &APIError{Message: "nothing to update — pass --name or --auto-prune", ExitCode: ExitError}
}
data, err := c.Do("PUT", "/agents/"+args[0], body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodesPruneCmd = &cobra.Command{
Use: "prune <id>",
Short: "Enqueue a bounded docker system prune on a node",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
volumes, _ := cmd.Flags().GetBool("volumes")
if volumes && !Confirm("Prune INCLUDING volumes? This can delete database data.") {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
until, _ := cmd.Flags().GetString("until")
data, err := c.Do("POST", "/agents/"+args[0]+"/prune",
map[string]interface{}{"until": until, "volumes": volumes})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodesCommandsCmd = &cobra.Command{
Use: "commands <id>",
Short: "List recent commands sent to a node agent",
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", "/agents/"+args[0]+"/commands", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// Agent tokens (cagt_) onboard new nodes. // Agent tokens (cagt_) onboard new nodes.
var nodeTokensCmd = &cobra.Command{ var nodeTokensCmd = &cobra.Command{
Use: "tokens", Use: "tokens",
@@ -145,6 +215,10 @@ var nodeTokensRevokeCmd = &cobra.Command{
} }
func init() { func init() {
nodesUpdateCmd.Flags().String("name", "", "node display name")
nodesUpdateCmd.Flags().Bool("auto-prune", false, "enqueue a daily bounded docker prune")
nodesPruneCmd.Flags().String("until", "", "only prune objects older than this duration (e.g. 168h)")
nodesPruneCmd.Flags().Bool("volumes", false, "also prune volumes (destructive — asks twice)")
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd) nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesDeleteCmd, nodeTokensCmd) NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesUpdateCmd, nodesPruneCmd, nodesCommandsCmd, nodesDeleteCmd, nodeTokensCmd)
} }
@@ -0,0 +1,214 @@
package commands
// SSH node bootstrap — one SSH session installs the agent (binary +
// systemd + enroll token), then SSH is never needed again. SSH keys
// stay client-side; the server only sees the resulting agent.
import (
"errors"
"fmt"
"net"
"os"
"os/user"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/term"
)
var (
nodeSSHKey string
nodeSSHName string
nodeSSHPrint bool
)
var nodesAddCmd = &cobra.Command{
Use: "add <[user@]host[:port]>",
Short: "Bootstrap a node agent over SSH (one session, then closed)",
Long: `Installs the containr agent on a remote host:
1. mints a single-use enroll token via the API
2. SSHes in (agent socket, --key, or default key files)
3. runs the instance's install script remotely
4. closes the session — the agent heartbeats outbound from then on
Use --print to skip SSH and emit the remote one-liner for manual runs.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
// Mint the enroll token first — SSH failures then don't leave
// partial state on the remote.
body := map[string]interface{}{"label": "bootstrap"}
if nodeSSHName != "" {
body["label"] = "bootstrap:" + nodeSSHName
}
tokenData, err := c.Do("POST", "/agent-tokens", body)
if err != nil {
return err
}
tokenObj, err := unwrapObject(tokenData, "token")
if err != nil {
return err
}
token := str(tokenObj, "token")
if token == "" {
return &APIError{Message: "enroll token missing from response", ExitCode: ExitError}
}
// Agent bootstrap endpoints live at /api/agents (no /v1) — strip
// the API version suffix off the client base.
apiRoot := strings.TrimSuffix(c.BaseURL, "/api/v1")
installCmd := fmt.Sprintf(
`curl -fsSL %q | bash -s -- --url %q --token %q --name %q`,
apiRoot+"/api/agents/install.sh", apiRoot, token, firstNonEmptyStr(nodeSSHName, nodeSSHTarget(args[0]).host),
)
if nodeSSHPrint {
fmt.Println(installCmd)
return nil
}
target := nodeSSHTarget(args[0])
if err := sshBootstrap(target, installCmd); err != nil {
return err
}
fmt.Printf("agent installing on %s — it should appear in `containr nodes list` within ~15s\n", target.host)
return nil
},
}
type sshTarget struct {
user string
host string
port string
}
func nodeSSHTarget(raw string) sshTarget {
t := sshTarget{port: "22"}
rest := raw
if at := strings.LastIndex(rest, "@"); at >= 0 {
t.user = rest[:at]
rest = rest[at+1:]
}
if colon := strings.LastIndex(rest, ":"); colon >= 0 && !strings.Contains(rest, "]") {
t.host, t.port = rest[:colon], rest[colon+1:]
} else {
t.host = rest
}
if t.user == "" {
if u, err := user.Current(); err == nil {
t.user = u.Username
}
}
return t
}
func sshAuthMethods(keyPath string) []ssh.AuthMethod {
var methods []ssh.AuthMethod
// ssh-agent first — matches how people actually manage keys.
if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" {
if conn, err := net.Dial("unix", sock); err == nil {
defer conn.Close()
methods = append(methods, ssh.PublicKeysCallback(agent.NewClient(conn).Signers))
}
}
var keyFiles []string
if keyPath != "" {
keyFiles = []string{keyPath}
} else if home, err := os.UserHomeDir(); err == nil {
for _, name := range []string{"id_ed25519", "id_rsa", "id_ecdsa"} {
p := filepath.Join(home, ".ssh", name)
if _, err := os.Stat(p); err == nil {
keyFiles = append(keyFiles, p)
}
}
}
for _, p := range keyFiles {
if signer, err := sshPrivateKey(p); err == nil {
methods = append(methods, ssh.PublicKeys(signer))
}
}
return methods
}
func sshPrivateKey(path string) (ssh.Signer, error) {
pem, err := os.ReadFile(path)
if err != nil {
return nil, err
}
signer, err := ssh.ParsePrivateKey(pem)
if err != nil {
var missing *ssh.PassphraseMissingError
if errors.As(err, &missing) {
fmt.Fprintf(os.Stderr, "passphrase for %s: ", path)
pass, readErr := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if readErr != nil {
return nil, readErr
}
return ssh.ParsePrivateKeyWithPassphrase(pem, pass)
}
return nil, err
}
return signer, nil
}
func sshBootstrap(t sshTarget, remoteCmd string) error {
config := &ssh.ClientConfig{
User: t.user,
Auth: sshAuthMethods(nodeSSHKey),
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // jarvis: TOFU would be better; ssh_known_hosts parsing is the upgrade path
Timeout: 15 * time.Second,
}
if len(config.Auth) == 0 {
return &APIError{Message: "no SSH auth methods (ssh-agent empty, no usable keys)", ExitCode: ExitError}
}
conn, err := ssh.Dial("tcp", net.JoinHostPort(t.host, t.port), config)
if err != nil {
return &APIError{Message: fmt.Sprintf("ssh connect: %v", err), ExitCode: ExitError}
}
defer conn.Close()
session, err := conn.NewSession()
if err != nil {
return err
}
defer session.Close()
session.Stdout = os.Stdout
session.Stderr = os.Stderr
// Root shells run directly; otherwise escalate via sudo. NOPASSWD
// sudo is assumed — a password prompt here means the operator should
// use --print and run the command themselves.
if t.user == "root" {
return session.Run(remoteCmd)
}
return session.Run("sudo -n " + remoteCmd)
}
func firstNonEmptyStr(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
func init() {
nodesAddCmd.Flags().StringVar(&nodeSSHKey, "key", "", "SSH private key path (defaults: agent, ~/.ssh/id_*)")
nodesAddCmd.Flags().StringVar(&nodeSSHName, "name", "", "node display name (defaults to remote hostname)")
nodesAddCmd.Flags().BoolVar(&nodeSSHPrint, "print", false, "print the remote install command instead of running SSH")
NodesCmd.AddCommand(nodesAddCmd)
}
+4
View File
@@ -89,6 +89,10 @@ func init() {
rootCmd.AddCommand(commands.NotificationsCmd) rootCmd.AddCommand(commands.NotificationsCmd)
rootCmd.AddCommand(commands.WebhooksCmd) rootCmd.AddCommand(commands.WebhooksCmd)
rootCmd.AddCommand(commands.OperationsCmd) 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)
} }
@@ -63,7 +63,7 @@ INSERT INTO node_agents (
id, name, hostname, ip_address, port, status, version, id, name, hostname, ip_address, port, status, version,
capabilities, resources, last_heartbeat, metadata capabilities, resources, last_heartbeat, metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune
` `
type CreateAgentParams struct { type CreateAgentParams struct {
@@ -109,6 +109,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeA
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Metadata, &i.Metadata,
&i.AutoPrune,
) )
return i, err return i, err
} }
@@ -229,7 +230,7 @@ func (q *Queries) DeleteAgent(ctx context.Context, id string) error {
} }
const getAgent = `-- name: GetAgent :one const getAgent = `-- name: GetAgent :one
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata FROM node_agents WHERE id = $1 SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune FROM node_agents WHERE id = $1
` `
func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) { func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
@@ -249,12 +250,13 @@ func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Metadata, &i.Metadata,
&i.AutoPrune,
) )
return i, err return i, err
} }
const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata FROM node_agents SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune FROM node_agents
WHERE hostname = $1 AND ip_address = $2 WHERE hostname = $1 AND ip_address = $2
LIMIT 1 LIMIT 1
` `
@@ -281,6 +283,7 @@ func (q *Queries) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAnd
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Metadata, &i.Metadata,
&i.AutoPrune,
) )
return i, err return i, err
} }
@@ -378,6 +381,36 @@ func (q *Queries) GetContainerForAgent(ctx context.Context, arg GetContainerForA
return i, err return i, err
} }
const getLastAgentCommandByType = `-- name: GetLastAgentCommandByType :one
SELECT id, type, node_agent_id, container_id, payload, status, result, error, created_at, updated_at, completed_at FROM agent_commands
WHERE node_agent_id = $1 AND type = $2
ORDER BY created_at DESC LIMIT 1
`
type GetLastAgentCommandByTypeParams struct {
NodeAgentID string `json:"node_agent_id"`
Type string `json:"type"`
}
func (q *Queries) GetLastAgentCommandByType(ctx context.Context, arg GetLastAgentCommandByTypeParams) (AgentCommand, error) {
row := q.db.QueryRowContext(ctx, getLastAgentCommandByType, arg.NodeAgentID, arg.Type)
var i AgentCommand
err := row.Scan(
&i.ID,
&i.Type,
&i.NodeAgentID,
&i.ContainerID,
&i.Payload,
&i.Status,
&i.Result,
&i.Error,
&i.CreatedAt,
&i.UpdatedAt,
&i.CompletedAt,
)
return i, err
}
const insertAgentHeartbeat = `-- name: InsertAgentHeartbeat :exec const insertAgentHeartbeat = `-- name: InsertAgentHeartbeat :exec
INSERT INTO agent_heartbeats ( INSERT INTO agent_heartbeats (
id, node_agent_id, timestamp, status, resources, id, node_agent_id, timestamp, status, resources,
@@ -458,7 +491,7 @@ func (q *Queries) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHea
} }
const listAgents = `-- name: ListAgents :many const listAgents = `-- name: ListAgents :many
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata FROM node_agents ORDER BY created_at ASC SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune FROM node_agents ORDER BY created_at ASC
` `
func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) { func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
@@ -484,6 +517,7 @@ func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Metadata, &i.Metadata,
&i.AutoPrune,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -639,9 +673,10 @@ UPDATE node_agents SET
resources = $9, resources = $9,
last_heartbeat = $10, last_heartbeat = $10,
metadata = $11, metadata = $11,
auto_prune = $12,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune
` `
type UpdateAgentParams struct { type UpdateAgentParams struct {
@@ -656,6 +691,7 @@ type UpdateAgentParams struct {
Resources pqtype.NullRawMessage `json:"resources"` Resources pqtype.NullRawMessage `json:"resources"`
LastHeartbeat sql.NullTime `json:"last_heartbeat"` LastHeartbeat sql.NullTime `json:"last_heartbeat"`
Metadata pqtype.NullRawMessage `json:"metadata"` Metadata pqtype.NullRawMessage `json:"metadata"`
AutoPrune bool `json:"auto_prune"`
} }
func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) { func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) {
@@ -671,6 +707,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
arg.Resources, arg.Resources,
arg.LastHeartbeat, arg.LastHeartbeat,
arg.Metadata, arg.Metadata,
arg.AutoPrune,
) )
var i NodeAgent var i NodeAgent
err := row.Scan( err := row.Scan(
@@ -687,6 +724,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Metadata, &i.Metadata,
&i.AutoPrune,
) )
return i, err return i, err
} }
@@ -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
}
@@ -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"`
@@ -161,6 +175,7 @@ type NodeAgent struct {
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
Metadata pqtype.NullRawMessage `json:"metadata"` Metadata pqtype.NullRawMessage `json:"metadata"`
AutoPrune bool `json:"auto_prune"`
} }
type Notification struct { type Notification struct {
@@ -287,6 +302,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"`
@@ -21,6 +21,7 @@ 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
@@ -30,22 +31,26 @@ type Querier interface {
CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) 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 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 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 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)
@@ -53,6 +58,7 @@ 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)
GetLastAgentCommandByType(ctx context.Context, arg GetLastAgentCommandByTypeParams) (AgentCommand, error)
GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error) GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (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)
@@ -60,14 +66,17 @@ type Querier interface {
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)
@@ -85,10 +94,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) 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
@@ -106,6 +117,7 @@ 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
+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{})
+22
View File
@@ -149,11 +149,33 @@ func init() {
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_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})
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false}) register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false}) register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
register(spec{"containr_nodes_update", "Update a node agent (admin). Args: id; optional auto_prune, name.", "PUT", "/agents/{id}", []string{"id"}, []string{"auto_prune", "name"}, false})
register(spec{"containr_nodes_prune", "Enqueue a bounded docker system prune on a node (admin). Args: id; optional until (e.g. 168h), volumes (danger, off by default).", "POST", "/agents/{id}/prune", []string{"id"}, []string{"until", "volumes"}, true})
register(spec{"containr_nodes_commands", "List recent commands sent to a node agent (admin). Args: id.", "GET", "/agents/{id}/commands", []string{"id"}, nil, false})
// Scaling / HA / security // Scaling / HA / security
register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false}) register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false})
+10
View File
@@ -54,6 +54,9 @@ type ScanSummary struct {
// Scanner handles security scanning operations // Scanner handles security scanning operations
type Scanner struct { type Scanner struct {
db *database.DB db *database.DB
// OnScanComplete fires after a scan finishes (goroutine context) —
// wired by the api layer for notifications. Optional.
OnScanComplete func(scan *SecurityScan)
} }
// NewScanner creates a new security scanner // NewScanner creates a new security scanner
@@ -115,6 +118,9 @@ func (s *Scanner) performScan(scan *SecurityScan) {
// Update scan with results // Update scan with results
completedAt := time.Now() completedAt := time.Now()
scan.CompletedAt = &completedAt
scan.Summary = summary
scan.Status = "completed"
_, err := s.db.Exec(` _, err := s.db.Exec(`
UPDATE security_scans UPDATE security_scans
SET status = $1, completed_at = $2, summary = $3 SET status = $1, completed_at = $2, summary = $3
@@ -137,6 +143,10 @@ func (s *Scanner) performScan(scan *SecurityScan) {
log.Printf("Failed to store vulnerability %s: %v", vuln.ID, err) log.Printf("Failed to store vulnerability %s: %v", vuln.ID, err)
} }
} }
if s.OnScanComplete != nil {
s.OnScanComplete(scan)
}
} }
// scanDependencies scans for known vulnerable dependencies // scanDependencies scans for known vulnerable dependencies
@@ -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;
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE node_agents ADD COLUMN IF NOT EXISTS auto_prune BOOLEAN NOT NULL DEFAULT false;
-- +goose Down
ALTER TABLE node_agents DROP COLUMN IF EXISTS auto_prune;
+6
View File
@@ -28,6 +28,7 @@ UPDATE node_agents SET
resources = $9, resources = $9,
last_heartbeat = $10, last_heartbeat = $10,
metadata = $11, metadata = $11,
auto_prune = $12,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING *; RETURNING *;
@@ -107,3 +108,8 @@ RETURNING *;
-- name: UpdateContainerStatus :exec -- name: UpdateContainerStatus :exec
UPDATE container_instances SET status = $2, updated_at = NOW() UPDATE container_instances SET status = $2, updated_at = NOW()
WHERE id = $1; WHERE id = $1;
-- name: GetLastAgentCommandByType :one
SELECT * FROM agent_commands
WHERE node_agent_id = $1 AND type = $2
ORDER BY created_at DESC LIMIT 1;
+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;
+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;
+27 -1
View File
@@ -192,7 +192,8 @@ CREATE TABLE node_agents (
last_heartbeat TIMESTAMP WITH TIME ZONE, last_heartbeat TIMESTAMP WITH TIME ZONE,
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(),
metadata JSONB metadata JSONB,
auto_prune BOOLEAN NOT NULL DEFAULT false
); );
CREATE TABLE container_instances ( CREATE TABLE container_instances (
@@ -302,3 +303,28 @@ CREATE TABLE webhook_deliveries (
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
delivered_at TIMESTAMP WITH TIME ZONE 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",
+10 -1
View File
@@ -11,8 +11,10 @@ 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 { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage';
import { OperationsPage } from '@/features/operations/pages/OperationsPage'; 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,
@@ -22,8 +24,10 @@ import {
HighAvailabilityPage, HighAvailabilityPage,
SecurityPage, SecurityPage,
ComponentShowcase, ComponentShowcase,
GitHubAppCallbackPage,
} from '@/features/secondary/pages'; } from '@/features/secondary/pages';
import { SignInPage, SignUpPage } from '@/features/auth/pages'; import { NodeDetailPage } from '@/features/nodes/NodeDetailPage';
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';
@@ -126,12 +130,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 */}
@@ -144,6 +150,7 @@ export default function App() {
<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="/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 />} />
@@ -153,6 +160,7 @@ 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="/settings/webhooks" element={<WebhooksPage />} />
<Route path="/showcase" element={<ComponentShowcase />} /> <Route path="/showcase" element={<ComponentShowcase />} />
@@ -161,6 +169,7 @@ export default function App() {
{/* Platform owner only */} {/* Platform owner only */}
<Route element={<AdminRequired />}> <Route element={<AdminRequired />}>
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<AdminPage />} />
<Route path="/nodes/:id" element={<NodeDetailPage />} />
</Route> </Route>
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
@@ -9,6 +9,7 @@ import {
ChartBar, ChartBar,
CheckCircle2, CheckCircle2,
ChevronsLeft, ChevronsLeft,
Clock,
Container, Container,
Database, Database,
FileText, FileText,
@@ -41,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[] };
@@ -59,6 +60,7 @@ const navSections: NavSection[] = [
title: 'Operate', title: 'Operate',
items: [ items: [
{ label: 'Operations', href: '/operations', icon: Activity, hint: 'Live jobs & failures' }, { 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' },
@@ -387,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={{
@@ -397,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,127 @@
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 { type FilterConfig } from '@/lib/dynamic-filter';
import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter';
import { formatRelative } from '@/lib/time';
import { DemoRestricted, FilterBar } 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 SCHEMA: FilterConfig<ActivityEntry>[] = [
{ key: 'q', label: 'Search', type: 'search', searchFields: ['label', 'action', 'user_email'] },
{
key: 'severity', label: 'Severity', type: 'select',
options: [
{ value: 'error', label: 'Error' },
{ value: 'warning', label: 'Warning' },
{ value: 'success', label: 'Success' },
{ value: 'info', label: 'Info' },
],
},
{ key: 'category', label: 'Category', type: 'select' },
];
export function ActivityPage() {
const isDemoMode = useDemoMode();
const [page, setPage] = useState(1);
const { filters } = useFilterState(SCHEMA);
const severityParam = filters.severity === 'all' ? '' : String(filters.severity ?? '');
const query = useQuery({
queryKey: ['activity-feed', severityParam, page],
queryFn: () => listActivity({ severity: severityParam || undefined, page, limit: 50 }),
enabled: !isDemoMode,
refetchInterval: 15000,
});
const filter = useDynamicFilter<ActivityEntry>({ data: query.data?.activity ?? [], schema: SCHEMA });
if (isDemoMode) return <DemoRestricted feature="Activity" />;
const entries = filter.filteredData;
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-start gap-3">
<div className="flex-1">
<FilterBar {...filter} />
</div>
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)] mt-3" />}
</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)}
@@ -2,8 +2,10 @@ import { useDeferredValue, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { listAuditLogs } from '@/lib/api-client'; import { listAuditLogs } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { type FilterConfig } from '@/lib/dynamic-filter';
import { useDynamicFilter } from '@/lib/use-dynamic-filter';
import { formatDate, formatRelative } from '@/lib/time'; import { formatDate, formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components'; import { DemoRestricted, FilterBar } from '@/shared/components';
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -15,20 +17,34 @@ const rangeOptions = [
{ value: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 }, { value: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 },
] as const; ] as const;
const inputClass = // Server-backed filters — the engine runs over empty data; values map to
'h-9 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'; // API params. URL state makes filtered views shareable.
const AUDIT_SCHEMA: FilterConfig<unknown>[] = [
{ key: 'resource', label: 'Resource', type: 'search' },
{ key: 'action', label: 'Action', type: 'search' },
{ key: 'actor', label: 'Actor', type: 'search' },
{
key: 'range', label: 'Time', type: 'select',
options: rangeOptions.filter((o) => o.value !== '').map((o) => ({ value: o.value, label: o.label })),
},
];
export function AuditLogsPage() { export function AuditLogsPage() {
const isDemoMode = useDemoMode(); const isDemoMode = useDemoMode();
const [resource, setResource] = useState(''); const filter = useDynamicFilter<unknown>({ data: [], schema: AUDIT_SCHEMA });
const [action, setAction] = useState('');
const [actor, setActor] = useState('');
const [range, setRange] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Back to page 1 whenever the filter set changes.
const filtersJson = JSON.stringify(filter.filters);
const [prevFiltersJson, setPrevFiltersJson] = useState(filtersJson);
if (prevFiltersJson !== filtersJson) {
setPrevFiltersJson(filtersJson);
setPage(1);
}
const deferredResource = useDeferredValue(resource); const deferredResource = useDeferredValue(String(filter.filters.resource ?? ''));
const deferredAction = useDeferredValue(action); const deferredAction = useDeferredValue(String(filter.filters.action ?? ''));
const deferredActor = useDeferredValue(actor); const deferredActor = useDeferredValue(String(filter.filters.actor ?? ''));
const range = filter.filters.range === 'all' ? '' : String(filter.filters.range ?? '');
const filters = { const filters = {
resource: deferredResource.trim() || undefined, resource: deferredResource.trim() || undefined,
@@ -54,15 +70,7 @@ export function AuditLogsPage() {
const logs = logsQuery.data ?? []; const logs = logsQuery.data ?? [];
const hasNext = logs.length === PAGE_SIZE; const hasNext = logs.length === PAGE_SIZE;
const hasFilters = Boolean(resource || action || actor || range); const hasFilters = filter.activeCount > 0;
const resetFilters = () => {
setResource('');
setAction('');
setActor('');
setRange('');
setPage(1);
};
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
@@ -79,63 +87,7 @@ export function AuditLogsPage() {
<div className="w-full px-8 py-6"> <div className="w-full px-8 py-6">
<section className="panel p-6"> <section className="panel p-6">
<div className="flex flex-wrap items-end gap-3 mb-5"> <FilterBar {...filter} />
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
Resource
</label>
<input
value={resource}
onChange={(e) => { setResource(e.target.value); setPage(1); }}
placeholder="service, project…"
className={`${inputClass} w-44`}
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
Action
</label>
<input
value={action}
onChange={(e) => { setAction(e.target.value); setPage(1); }}
placeholder="create, deploy…"
className={`${inputClass} w-44`}
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
Actor
</label>
<input
value={actor}
onChange={(e) => { setActor(e.target.value); setPage(1); }}
placeholder="user email"
className={`${inputClass} w-52`}
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
Time
</label>
<select
value={range}
onChange={(e) => { setRange(e.target.value); setPage(1); }}
className={`${inputClass} w-40`}
>
{rangeOptions.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
{hasFilters && (
<button
onClick={resetFilters}
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
Clear
</button>
)}
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
+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();
@@ -9,6 +9,9 @@ import {
} from '@/lib/api-client'; } from '@/lib/api-client';
import { useBuildUpdates } from '@/lib/use-build-updates'; import { useBuildUpdates } from '@/lib/use-build-updates';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { type FilterConfig } from '@/lib/dynamic-filter';
import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter';
import { FilterBar } from '@/shared/components';
import { demoBuilds } from '@/lib/demo-data'; import { demoBuilds } from '@/lib/demo-data';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import { import {
@@ -34,6 +37,19 @@ const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
{ value: 'cancelled', label: 'Cancelled' }, { value: 'cancelled', label: 'Cancelled' },
]; ];
const BUILD_SCHEMA: FilterConfig<BuildEntity>[] = [
{
key: 'status', label: 'Status', type: 'select',
options: statusOptions.filter((o) => o.value !== '').map((o) => ({ value: o.value, label: o.label })),
},
{ key: 'project', label: 'Project ID', type: 'search', searchFields: ['projectId'] },
{ key: 'service', label: 'Service ID', type: 'search', searchFields: ['serviceId'] },
{
key: 'created', label: 'Time', type: 'date-range',
accessor: (b) => b.startedAt ?? b.completedAt,
},
];
function StatusBadge({ status }: { status: BuildStatus }) { function StatusBadge({ status }: { status: BuildStatus }) {
const config = { const config = {
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false }, success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
@@ -72,9 +88,10 @@ export function BuildsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isDemoMode = useDemoMode(); const isDemoMode = useDemoMode();
const [projectFilter, setProjectFilter] = useState(''); const { filters } = useFilterState(BUILD_SCHEMA);
const [serviceFilter, setServiceFilter] = useState(''); const projectFilter = String(filters.project ?? '');
const [statusFilter, setStatusFilter] = useState<'' | BuildStatus>(''); const serviceFilter = String(filters.service ?? '');
const statusFilter = filters.status === 'all' ? '' : String(filters.status ?? '');
const [limit, setLimit] = useState(50); const [limit, setLimit] = useState(50);
const [selectedBuild, setSelectedBuild] = useState<BuildEntity | null>(null); const [selectedBuild, setSelectedBuild] = useState<BuildEntity | null>(null);
@@ -85,20 +102,22 @@ export function BuildsPage() {
listBuilds({ listBuilds({
projectId: projectFilter || undefined, projectId: projectFilter || undefined,
serviceId: serviceFilter || undefined, serviceId: serviceFilter || undefined,
status: statusFilter || undefined, status: (statusFilter || undefined) as BuildStatus | undefined,
page: 1, page: 1,
limit, limit,
}), }),
}); });
const builds = useMemo( const rawBuilds = useMemo(
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []), () => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
[isDemoMode, buildsQuery.data?.builds], [isDemoMode, buildsQuery.data?.builds],
); );
const filter = useDynamicFilter<BuildEntity>({ data: rawBuilds, schema: BUILD_SCHEMA });
const builds = filter.filteredData;
const subscribedBuildIds = useMemo( const subscribedBuildIds = useMemo(
() => (isDemoMode ? [] : builds.map((build) => build.id)), () => (isDemoMode ? [] : rawBuilds.map((build) => build.id)),
[isDemoMode, builds], [isDemoMode, rawBuilds],
); );
const liveStatus = useBuildUpdates(subscribedBuildIds, ({ channel }) => { const liveStatus = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
queryClient.invalidateQueries({ queryKey: ['builds-page'] }); queryClient.invalidateQueries({ queryKey: ['builds-page'] });
@@ -217,46 +236,13 @@ export function BuildsPage() {
{/* Filters */} {/* Filters */}
<div className="w-full px-8"> <div className="w-full px-8">
<div className="panel p-4"> <div className="panel p-4">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2">
<Filter size={16} className="text-[var(--text-tertiary)]" /> <Filter size={16} className="text-[var(--text-tertiary)]" />
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span> <span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
</div> </div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="flex flex-wrap items-end gap-3 mt-3">
<div> <div className="flex-1">
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2"> <FilterBar {...filter} />
Status
</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as '' | BuildStatus)}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
>
{statusOptions.map((option) => (
<option key={option.label} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Project ID
</label>
<input
value={projectFilter}
onChange={(e) => setProjectFilter(e.target.value)}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
placeholder="project-123"
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Service ID
</label>
<input
value={serviceFilter}
onChange={(e) => setServiceFilter(e.target.value)}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
placeholder="service-abc"
/>
</div> </div>
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
@@ -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,393 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
deleteAgent,
getAgent,
getAgentMetrics,
listAgentCommands,
listAgentContainers,
pruneAgent,
updateAgent,
type AgentMetricPoint,
} from '@/lib/api-client';
import { getCurrentUserProfile } from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { formatBytes, formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components';
import {
ArrowLeft, CheckCircle2, ChevronRight, Loader2, Pencil, XCircle,
} from 'lucide-react';
const TIME_RANGES = [
{ label: '1h', value: '1h' },
{ label: '24h', value: '24h' },
{ label: '7d', value: '168h' },
];
export function NodeDetailPage() {
const { id = '' } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: getCurrentUserProfile,
enabled: !isDemoMode && Boolean(sessionQuery.data),
retry: false,
});
const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
const [timeRange, setTimeRange] = useState('24h');
const [rename, setRename] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmVolumes, setConfirmVolumes] = useState(false);
const agentQuery = useQuery({
queryKey: ['node-agent', id],
queryFn: () => getAgent(id),
enabled: !isDemoMode && id !== '',
refetchInterval: 15000,
});
const metricsQuery = useQuery({
queryKey: ['node-metrics', id, timeRange],
queryFn: () => getAgentMetrics(id, timeRange),
enabled: !isDemoMode && id !== '',
refetchInterval: 30000,
});
const containersQuery = useQuery({
queryKey: ['node-containers', id],
queryFn: () => listAgentContainers(id),
enabled: !isDemoMode && id !== '',
refetchInterval: 15000,
});
const commandsQuery = useQuery({
queryKey: ['node-commands', id],
queryFn: () => listAgentCommands(id),
enabled: !isDemoMode && id !== '',
refetchInterval: 15000,
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['node-agent', id] });
queryClient.invalidateQueries({ queryKey: ['node-commands', id] });
queryClient.invalidateQueries({ queryKey: ['usage-agents'] });
};
const updateMutation = useMutation({
mutationFn: (body: { name?: string; auto_prune?: boolean }) => updateAgent(id, body),
onSuccess: () => { setRename(null); invalidate(); },
});
const pruneMutation = useMutation({
mutationFn: (volumes: boolean) => pruneAgent(id, { volumes }),
onSuccess: () => { setConfirmVolumes(false); invalidate(); },
});
const deleteMutation = useMutation({
mutationFn: () => deleteAgent(id),
onSuccess: () => navigate('/usage'),
});
const agent = agentQuery.data;
const metrics = useMemo(() => metricsQuery.data ?? [], [metricsQuery.data]);
const latest = metrics.length > 0 ? metrics[metrics.length - 1] : null;
const containers = containersQuery.data ?? [];
const commands = commandsQuery.data ?? [];
if (isDemoMode) return <DemoRestricted feature="Nodes" />;
if (agentQuery.isLoading) {
return <div className="px-8 py-10 text-xs text-[var(--text-muted)]">Loading node…</div>;
}
if (!agent) {
return (
<div className="px-8 py-10">
<p className="text-sm text-[var(--text-secondary)]">Node not found.</p>
<Link to="/usage" className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline">← back to usage</Link>
</div>
);
}
const online = agent.status === 'online' || agent.status === 'connecting';
return (
<div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]">
<div className="w-full px-8 py-5">
<div className="flex items-center gap-2 v-mono text-[11px] text-[var(--text-tertiary)] mb-2">
<Link to="/usage" className="hover:text-[var(--text-primary)] flex items-center gap-1">
<ArrowLeft size={12} /> usage
</Link>
<ChevronRight size={10} />
<span className="text-[var(--text-primary)]">{agent.name}</span>
</div>
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="v-title">
{rename === null ? agent.name : rename}
{isAdmin && rename === null && (
<button
onClick={() => setRename(agent.name)}
className="ml-2 inline-block align-middle text-[var(--text-muted)] hover:text-[var(--text-primary)]"
title="Rename node"
>
<Pencil size={14} />
</button>
)}
<span className="v-cursor">_</span>
</h1>
{rename !== null && (
<div className="mt-2 flex items-center gap-2">
<input
value={rename}
onChange={(e) => setRename(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') updateMutation.mutate({ name: rename });
if (e.key === 'Escape') setRename(null);
}}
autoFocus
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-1 text-sm text-[var(--text-primary)]"
/>
<button onClick={() => updateMutation.mutate({ name: rename })} className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline">save</button>
<button onClick={() => setRename(null)} className="v-mono text-[11px] text-[var(--text-muted)] hover:underline">cancel</button>
</div>
)}
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
{agent.hostname} · {agent.ipAddress}:{agent.port}
{agent.version ? ` · v${agent.version}` : ''}
{agent.lastHeartbeat ? ` · heartbeat ${formatRelative(agent.lastHeartbeat)}` : ''}
</p>
{meshAddresses(agent).length > 0 && (
<p className="v-mono mt-1 text-[11px] text-[var(--text-tertiary)]">
mesh: {meshAddresses(agent).map(([iface, ip]) => `${iface}=${ip}`).join(' · ')}
</p>
)}
</div>
<div className="flex items-center gap-3">
<span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online
? 'border-[var(--success)]/40 text-[var(--success)]'
: 'border-[var(--error)]/40 text-[var(--error)]'}`}>
{agent.status}
</span>
{isAdmin && (
<>
<label className="flex items-center gap-1.5 v-mono text-[11px] text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune">
<input
type="checkbox"
checked={agent.autoPrune}
onChange={(e) => updateMutation.mutate({ auto_prune: e.target.checked })}
className="accent-[var(--accent-primary)]"
/>
auto-prune
</label>
<button
onClick={() => setConfirmVolumes(true)}
disabled={pruneMutation.isPending}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
>
prune now
</button>
<button
onClick={() => setConfirmDelete(true)}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--error)]/40 text-[var(--error)] hover:bg-[var(--error)]/10"
>
remove
</button>
</>
)}
</div>
</div>
</div>
</div>
<div className="px-8 py-6 space-y-6">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="cpu" value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} />
<Stat label="memory" value={latest ? `${latest.memory.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${formatBytes(latest.memory.available)} free of ${formatBytes(latest.memory.limit)}` : ''} />
<Stat label="load 1m" value={latest ? latest.system_load.load_1m.toFixed(2) : '—'} sub={latest ? `5m ${latest.system_load.load_5m.toFixed(2)} · 15m ${latest.system_load.load_15m.toFixed(2)}` : ''} />
<Stat label="containers" value={latest ? String(latest.container_count) : String(containers.length)} sub="reported by agent" />
</div>
<section className="panel p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">telemetry</h2>
<div className="flex gap-1">
{TIME_RANGES.map((r) => (
<button
key={r.value}
onClick={() => setTimeRange(r.value)}
className={`v-mono text-[11px] px-2.5 py-1 rounded-md border ${timeRange === r.value
? 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'}`}
>
{r.label}
</button>
))}
</div>
</div>
{metricsQuery.isLoading ? (
<p className="text-xs text-[var(--text-muted)]">Loading metrics…</p>
) : metrics.length < 2 ? (
<p className="text-xs text-[var(--text-muted)]">Not enough heartbeat history yet — the agent reports every few seconds once connected.</p>
) : (
<div className="grid gap-6 md:grid-cols-2">
<Sparkline label="cpu %" points={metrics} value={(m) => m.cpu.usage_percent} />
<Sparkline label="memory %" points={metrics} value={(m) => m.memory.usage_percent} />
</div>
)}
</section>
<div className="grid gap-6 lg:grid-cols-2">
<section className="panel p-0">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">containers</h2>
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
{containers.length === 0 && (
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No containers reported on this node.</p>
)}
{containers.map((c) => (
<div key={c.id} className="flex items-center justify-between gap-3 px-5 py-3">
<div className="min-w-0">
<p className="truncate text-sm text-[var(--text-primary)]">{c.name ?? c.id}</p>
<p className="truncate v-mono text-[11px] text-[var(--text-tertiary)]">{c.image}</p>
</div>
<span className="v-mono text-[11px] text-[var(--text-secondary)]">{c.status ?? '—'}</span>
</div>
))}
</div>
</section>
<section className="panel p-0">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">command history</h2>
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
{commands.length === 0 && (
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No commands sent to this node yet.</p>
)}
{commands.slice(0, 15).map((cmd) => (
<div key={cmd.id} className="flex items-center justify-between gap-3 px-5 py-3">
<div className="flex items-center gap-2 min-w-0">
{cmd.status === 'completed'
? <CheckCircle2 size={13} className="text-[var(--success)] shrink-0" />
: cmd.status === 'failed'
? <XCircle size={13} className="text-[var(--error)] shrink-0" />
: <Loader2 size={13} className="text-[var(--text-muted)] animate-spin shrink-0" />}
<span className="v-mono text-[11px] text-[var(--text-primary)]">{cmd.type}</span>
{cmd.error && <span className="truncate text-[11px] text-[var(--error)]">{cmd.error}</span>}
</div>
<span className="v-mono text-[11px] text-[var(--text-muted)] whitespace-nowrap">
{cmd.created_at ? formatRelative(cmd.created_at) : ''}
</span>
</div>
))}
</div>
</section>
</div>
</div>
{confirmVolumes && (
<ConfirmModal
title="Prune this node?"
body={`Runs a bounded docker system prune on ${agent.name}. Unused images, stopped containers, and networks are removed.`}
confirmLabel="prune"
danger={false}
extra={
<label className="flex items-center gap-2 text-xs text-[var(--text-secondary)] cursor-pointer">
<input type="checkbox" id="prune-volumes" className="accent-[var(--error)]" />
also prune volumes (can delete database data)
</label>
}
onCancel={() => setConfirmVolumes(false)}
onConfirm={() => {
const volumes = (document.getElementById('prune-volumes') as HTMLInputElement | null)?.checked ?? false;
pruneMutation.mutate(volumes);
}}
pending={pruneMutation.isPending}
/>
)}
{confirmDelete && (
<ConfirmModal
title={`Remove ${agent.name}?`}
body="The agent record and its history are deleted. Containers running on the host are not stopped — uninstall the agent there separately."
confirmLabel="remove node"
danger
onCancel={() => setConfirmDelete(false)}
onConfirm={() => deleteMutation.mutate()}
pending={deleteMutation.isPending}
/>
)}
</div>
);
}
function meshAddresses(agent: { metadata: Record<string, unknown> }): [string, string][] {
const mesh = agent.metadata?.mesh;
if (!mesh || typeof mesh !== 'object' || Array.isArray(mesh)) return [];
return Object.entries(mesh as Record<string, unknown>)
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '');
}
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="panel p-4">
<p className="v-mono text-[10px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
<p className="mt-1 text-xl font-semibold text-[var(--text-primary)]">{value}</p>
{sub && <p className="mt-0.5 v-mono text-[11px] text-[var(--text-muted)]">{sub}</p>}
</div>
);
}
function Sparkline({ label, points, value }: {
label: string;
points: AgentMetricPoint[];
value: (m: AgentMetricPoint) => number;
}) {
const w = 560;
const h = 90;
const vals = points.map(value);
const max = Math.max(100, ...vals);
const step = w / Math.max(points.length - 1, 1);
const polyline = vals.map((v, i) => `${(i * step).toFixed(1)},${(h - (v / max) * (h - 6)).toFixed(1)}`).join(' ');
return (
<div>
<p className="v-mono text-[10px] uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
{label} — now {vals[vals.length - 1].toFixed(0)}% / peak {Math.max(...vals).toFixed(0)}%
</p>
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-24 rounded-md border border-[var(--border-subtle)] bg-[var(--bg-secondary)]">
<polyline points={polyline} fill="none" stroke="var(--accent-primary)" strokeWidth="1.5" />
</svg>
</div>
);
}
function ConfirmModal({ title, body, confirmLabel, danger, extra, onCancel, onConfirm, pending }: {
title: string;
body: string;
confirmLabel: string;
danger: boolean;
extra?: React.ReactNode;
onCancel: () => void;
onConfirm: () => void;
pending: boolean;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div className="panel w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
<p className="mt-2 text-xs text-[var(--text-secondary)]">{body}</p>
{extra && <div className="mt-3">{extra}</div>}
<div className="mt-5 flex justify-end gap-2">
<button onClick={onCancel} className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)]">
cancel
</button>
<button
onClick={onConfirm}
disabled={pending}
className={`v-mono text-[11px] px-3 py-1.5 rounded-md border disabled:opacity-50 ${danger
? 'border-[var(--error)]/50 text-[var(--error)] hover:bg-[var(--error)]/10'
: 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)] hover:bg-[var(--accent-primary)]/10'}`}
>
{pending ? 'working…' : confirmLabel}
</button>
</div>
</div>
</div>
);
}
+216 -15
View File
@@ -11,6 +11,8 @@ import {
getCurrentUserProfile, getCurrentUserProfile,
listAuditLogs, listAuditLogs,
listAgents, listAgents,
pruneAgent,
updateAgent,
listBuilds, listBuilds,
listGitProviders, listGitProviders,
listProjects, listProjects,
@@ -44,10 +46,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,
@@ -288,6 +295,14 @@ export function UsagePage() {
mutationFn: (id: string) => revokeAgentToken(id), mutationFn: (id: string) => revokeAgentToken(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }),
}); });
const pruneMutation = useMutation({
mutationFn: (id: string) => pruneAgent(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['usage-agents'] }),
});
const autoPruneMutation = useMutation({
mutationFn: ({ id, on }: { id: string; on: boolean }) => updateAgent(id, { auto_prune: on }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['usage-agents'] }),
});
const builds = useMemo( const builds = useMemo(
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []), () => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
@@ -372,9 +387,9 @@ export function UsagePage() {
const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0); const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0); const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
const agentEndpoint = getAgentPublicBaseUrl(); const agentEndpoint = getAgentPublicBaseUrl();
const connectCommand = `CONTAINR_API_URL=${getApiBaseUrl().replace(/\/api\/v1$/, '')} \\ const apiBase = getApiBaseUrl().replace(/\/api\/v1$/, '');
CONTAINR_AGENT_AUTH_TOKEN=${issuedToken?.token ?? '<token>'} \\ const installCommand = `curl -fsSL ${apiBase}/api/agents/install.sh | bash -s -- --url ${apiBase} --token ${issuedToken?.token ?? '<token>'}`;
containr-agent`; const sshCommand = `containr nodes add --ssh <user@host>${issuedToken?.token ? '' : ' # run on a host with the CLI'}`;
let buildActivityBody = 'Track deployment frequency and failed rollouts over time.'; let buildActivityBody = 'Track deployment frequency and failed rollouts over time.';
let runtimeBody = 'Build duration telemetry will appear after completed builds are available.'; let runtimeBody = 'Build duration telemetry will appear after completed builds are available.';
@@ -598,12 +613,35 @@ containr-agent`;
agents.slice(0, 5).map((agent) => ( agents.slice(0, 5).map((agent) => (
<div key={agent.id} className="flex items-center justify-between gap-3 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"> <div key={agent.id} className="flex items-center justify-between gap-3 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium text-[var(--text-primary)]">{agent.name}</p> <Link to={`/nodes/${agent.id}`} className="truncate text-sm font-medium text-[var(--text-primary)] hover:text-[var(--accent-primary)]">{agent.name}</Link>
<p className="truncate text-xs text-[var(--text-tertiary)]">{agent.hostname} · {agent.ipAddress}:{agent.port}</p> <p className="truncate text-xs text-[var(--text-tertiary)]">{agent.hostname} · {agent.ipAddress}:{agent.port}</p>
</div> </div>
<div className="text-right"> <div className="flex items-center gap-3">
<p className="text-xs font-semibold text-[var(--text-primary)]">{agent.status}</p> {isAdmin && (
<p className="text-xs text-[var(--text-tertiary)]">{formatBytes(agent.resources.memory.available)} free</p> <>
<button
onClick={() => pruneMutation.mutate(agent.id)}
disabled={pruneMutation.isPending}
title="Enqueue a bounded docker prune on this node"
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:underline disabled:opacity-50"
>
Prune
</button>
<label className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune">
<input
type="checkbox"
checked={agent.autoPrune}
onChange={(e) => autoPruneMutation.mutate({ id: agent.id, on: e.target.checked })}
className="accent-[var(--accent-primary)]"
/>
auto
</label>
</>
)}
<div className="text-right">
<p className="text-xs font-semibold text-[var(--text-primary)]">{agent.status}</p>
<p className="text-xs text-[var(--text-tertiary)]">{formatBytes(agent.resources.memory.available)} free</p>
</div>
</div> </div>
</div> </div>
)) ))
@@ -661,7 +699,8 @@ containr-agent`;
</p> </p>
)} )}
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{connectCommand}</pre> <pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{installCommand}</pre>
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap mt-2">{sshCommand}</pre>
<div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]"> <div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]">
<p>Agent endpoint: <span className="mono text-[var(--text-primary)]">{agentEndpoint}</span></p> <p>Agent endpoint: <span className="mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
<p>Use an issued token above, or set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p> <p>Use an issued token above, or set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p>
@@ -948,6 +987,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,13 +1062,25 @@ function GitProvidersSection() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button {showProvision ? (
onClick={() => void installApp()} <button
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" onClick={() => provisionMutation.mutate()}
> disabled={provisionMutation.isPending}
<GitBranch size={14} /> 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"
Install GitHub App style={{ background: 'var(--accent-primary)' }}
</button> >
<GitBranch size={14} />
{provisionMutation.isPending ? 'Opening GitHub…' : 'Set up GitHub App'}
</button>
) : (
<button
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"
>
<GitBranch size={14} />
Install GitHub App
</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 +1097,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 +1234,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 +1303,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 +1314,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 +1427,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>
)} )}
@@ -13,6 +13,7 @@ import {
listGitBranches, listGitBranches,
listGitProviders, listGitProviders,
listGitRepositories, listGitRepositories,
listProjectActivity,
listServiceLogs, listServiceLogs,
listServiceVariables, listServiceVariables,
listServicesByProject, listServicesByProject,
@@ -49,12 +50,13 @@ import {
FileUp, 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 },
]; ];
@@ -640,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
@@ -942,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">
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)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nup Line truncated "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)\nnodes add <user@host> SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs)\nnodes update|prune|commands rename/auto-prune, bounded docker prune, command history\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 Line truncated
}, },
{ {
"path": "guides/CLOUDFLARE_SETUP.md", "path": "guides/CLOUDFLARE_SETUP.md",
+307
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 = {
@@ -322,6 +341,8 @@ export type NodeAgentEntity = {
version: string; version: string;
capabilities: AgentCapabilities; capabilities: AgentCapabilities;
resources: NodeResources; resources: NodeResources;
autoPrune: boolean;
metadata: Record<string, unknown>;
lastHeartbeat?: string; lastHeartbeat?: string;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
@@ -542,6 +563,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 +602,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 +614,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 +623,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),
@@ -1014,6 +1078,8 @@ function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
}, },
}, },
lastHeartbeat: agent.last_heartbeat, lastHeartbeat: agent.last_heartbeat,
autoPrune: agent.auto_prune ?? false,
metadata: (agent.metadata ?? {}) as Record<string, unknown>,
createdAt: agent.created_at, createdAt: agent.created_at,
updatedAt: agent.updated_at, updatedAt: agent.updated_at,
}; };
@@ -1052,6 +1118,74 @@ export async function listAgents(): Promise<NodeAgentEntity[]> {
return normalizeAgentArray(payload.agents); return normalizeAgentArray(payload.agents);
} }
export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean }): Promise<NodeAgentEntity | null> {
const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
});
return payload.agent ? normalizeAgent(payload.agent) : null;
}
export async function pruneAgent(id: string, body?: { until?: string; volumes?: boolean }): Promise<void> {
await requestJson(`/agents/${id}/prune`, {
method: 'POST',
body: JSON.stringify(body ?? {}),
});
}
export async function getAgent(id: string): Promise<NodeAgentEntity | null> {
const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`);
return payload.agent ? normalizeAgent(payload.agent) : null;
}
export async function deleteAgent(id: string): Promise<void> {
await requestJson(`/agents/${id}`, { method: 'DELETE' });
}
export type AgentMetricPoint = {
timestamp: string;
cpu: { usage: number; usage_percent: number; cores: number };
memory: { usage: number; usage_percent: number; limit: number; available: number };
system_load: { load_1m: number; load_5m: number; load_15m: number };
container_count: number;
};
export async function getAgentMetrics(id: string, timeRange = '24h'): Promise<AgentMetricPoint[]> {
const payload = await requestJson<{ metrics?: AgentMetricPoint[] }>(
`/agents/${id}/metrics?time_range=${encodeURIComponent(timeRange)}`,
);
return payload.metrics ?? [];
}
export type AgentContainer = {
id?: string;
name?: string;
image?: string;
status?: string;
service_id?: string;
created_at?: string;
};
export async function listAgentContainers(id: string): Promise<AgentContainer[]> {
const payload = await requestJson<{ containers?: AgentContainer[] }>(`/agents/${id}/containers`);
return payload.containers ?? [];
}
export type AgentCommand = {
id?: string;
type?: string;
status?: string;
result?: string;
error?: string;
created_at?: string;
completed_at?: string;
};
export async function listAgentCommands(id: string): Promise<AgentCommand[]> {
const payload = await requestJson<{ commands?: AgentCommand[] }>(`/agents/${id}/commands`);
return payload.commands ?? [];
}
export type AgentAuthToken = components['schemas']['AgentAuthToken']; export type AgentAuthToken = components['schemas']['AgentAuthToken'];
export type AgentAuthTokenCreated = components['schemas']['AgentAuthTokenCreated']; export type AgentAuthTokenCreated = components['schemas']['AgentAuthTokenCreated'];
@@ -1866,6 +2000,137 @@ export async function getOperations(): Promise<OperationsView> {
return requestJson<OperationsView>('/operations'); 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':
@@ -2103,6 +2368,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',
@@ -2467,6 +2762,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',
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import {
emptyFilterState,
FilterEngine,
type FilterConfig,
} from '@/lib/dynamic-filter';
interface Row {
status: string;
name: string;
labels: string[];
createdAt: string;
}
const ROWS: Row[] = [
{ status: 'success', name: 'web-api', labels: ['prod'], createdAt: new Date().toISOString() },
{ status: 'failed', name: 'worker', labels: [], createdAt: '2020-01-01T00:00:00Z' },
{ status: 'running', name: 'web-admin', labels: ['prod', 'canary'], createdAt: '2020-06-01T00:00:00Z' },
];
const SCHEMA: FilterConfig<Row>[] = [
{ key: 'q', label: 'Search', type: 'search', searchFields: ['name'] },
{ key: 'status', label: 'Status', type: 'select' },
{ key: 'labels', label: 'Labels', type: 'multiselect' },
{ key: 'createdAt', label: 'Time', type: 'date-range' },
];
const f = (over: Partial<Record<string, unknown>>) => ({
...emptyFilterState(SCHEMA),
...over,
});
describe('FilterEngine', () => {
it('passes everything on an empty filter state', () => {
expect(new FilterEngine(ROWS, SCHEMA).apply(f({}))).toHaveLength(3);
});
it('searches across configured fields case-insensitively', () => {
const engine = new FilterEngine(ROWS, SCHEMA);
expect(engine.apply(f({ q: 'WEB' })).map((r) => r.name)).toEqual(['web-api', 'web-admin']);
expect(engine.apply(f({ q: 'worker' }))).toHaveLength(1);
});
it('select matches exactly and "all" is a no-op', () => {
const engine = new FilterEngine(ROWS, SCHEMA);
expect(engine.apply(f({ status: 'failed' }))).toHaveLength(1);
expect(engine.apply(f({ status: 'all' }))).toHaveLength(3);
});
it('multiselect intersects array values', () => {
const engine = new FilterEngine(ROWS, SCHEMA);
expect(engine.apply(f({ labels: ['canary'] })).map((r) => r.name)).toEqual(['web-admin']);
});
it('date-range bounds by preset', () => {
const engine = new FilterEngine(ROWS, SCHEMA);
expect(engine.apply(f({ createdAt: 'today' })).map((r) => r.name)).toEqual(['web-api']);
expect(engine.apply(f({ createdAt: 'all' }))).toHaveLength(3);
});
it('combines filters conjunctively', () => {
const engine = new FilterEngine(ROWS, SCHEMA);
expect(engine.apply(f({ q: 'web', status: 'running' })).map((r) => r.name)).toEqual(['web-admin']);
});
it('derives options with counts from data', () => {
const opts = new FilterEngine(ROWS, SCHEMA).options().find((c) => c.key === 'status');
expect(opts?.options?.find((o) => o.value === 'success')?.count).toBe(1);
expect(opts?.options?.map((o) => o.value).sort()).toEqual(['failed', 'running', 'success']);
});
it('counts active filters and lists them with raw + display values', () => {
const engine = new FilterEngine(ROWS, SCHEMA);
const state = f({ status: 'failed', q: 'w' });
expect(engine.activeCount(state)).toBe(2);
const active = engine.active(state);
expect(active.find((a) => a.key === 'status')).toMatchObject({ value: 'failed', displayValue: 'Failed' });
expect(active.find((a) => a.key === 'q')).toMatchObject({ displayValue: 'w' });
});
});
+208
View File
@@ -0,0 +1,208 @@
// Dynamic filter framework — typed filter configs → client-side filtering
// with optional URL-state sync. Port of dflow's filter.utils pattern,
// adapted to Containr (no external date lib, searchParams-driven).
//
// A page declares a FilterConfig<T>[] schema, passes fetched data +
// schema to useDynamicFilter(), and renders <FilterBar/>. Filter state
// lives in the URL when `urlSync` is on, so filtered views are
// shareable links.
export type FilterType =
| 'search'
| 'select'
| 'multiselect'
| 'boolean'
| 'date-range'
| 'status';
export interface FilterOption {
value: string;
label: string;
count?: number;
}
export interface FilterConfig<T> {
key: string;
label: string;
type: FilterType;
options?: FilterOption[];
/** fields searched by type 'search' (defaults to [key]) */
searchFields?: (keyof T & string)[];
/** custom value accessor (defaults to item[key]) */
accessor?: (item: T) => unknown;
}
export type FilterState = Record<string, unknown>;
const EMPTY: Record<FilterType, unknown> = {
search: '',
select: 'all',
multiselect: [],
boolean: [],
'date-range': 'all',
status: [],
};
export function emptyValueFor(type: FilterType): unknown {
return structuredClone(EMPTY[type]);
}
export function emptyFilterState<T>(schema: FilterConfig<T>[]): FilterState {
const state: FilterState = {};
for (const c of schema) state[c.key] = structuredClone(EMPTY[c.type]);
return state;
}
function accessorOf<T>(config: FilterConfig<T>): (item: T) => unknown {
if (config.accessor) return config.accessor;
return (item) => (item as Record<string, unknown>)[config.key];
}
export class FilterEngine<T> {
private data: T[];
private schema: FilterConfig<T>[];
constructor(data: T[], schema: FilterConfig<T>[]) {
this.data = data;
this.schema = schema;
}
/** Fill in option lists (with counts) for configs that didn't declare them. */
options(): FilterConfig<T>[] {
return this.schema.map((config) => {
if (config.options?.length) return config;
const counts = new Map<string, number>();
for (const item of this.data) {
const v = accessorOf(config)(item);
for (const each of Array.isArray(v) ? v : [v]) {
if (each != null && each !== '') {
const key = String(each);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
}
}
const options = [...counts.entries()]
.map(([value, count]) => ({ value, label: formatLabel(value), count }))
.sort((a, b) => b.count! - a.count!);
return { ...config, options };
});
}
apply(filters: FilterState): T[] {
return this.data.filter((item) =>
this.schema.every((config) => {
const value = filters[config.key];
if (value == null || value === '' || value === 'all') return true;
if (Array.isArray(value) && value.length === 0) return true;
return this.evaluate(item, config, value);
}),
);
}
private evaluate(item: T, config: FilterConfig<T>, value: unknown): boolean {
const itemValue = accessorOf(config)(item);
switch (config.type) {
case 'search': {
const term = String(value).toLowerCase();
const fields = config.searchFields ?? [config.key as keyof T & string];
return fields.some((f) =>
String((item as Record<string, unknown>)[f] ?? '').toLowerCase().includes(term),
);
}
case 'select':
return String(itemValue) === String(value);
case 'multiselect':
case 'status': {
const values = value as string[];
if (Array.isArray(itemValue)) return itemValue.some((v) => values.includes(String(v)));
return values.includes(String(itemValue));
}
case 'boolean': {
const values = value as string[];
return values.includes(String(Boolean(itemValue)));
}
case 'date-range':
return dateInRange(itemValue, String(value));
default:
return true;
}
}
activeCount(filters: FilterState): number {
let n = 0;
for (const c of this.schema) {
const v = filters[c.key];
if (v == null || v === '' || v === 'all') continue;
n += Array.isArray(v) ? v.length : 1;
}
return n;
}
active(filters: FilterState): { key: string; label: string; value: string; displayValue: string }[] {
const out: { key: string; label: string; value: string; displayValue: string }[] = [];
const opts = this.options();
for (const c of this.schema) {
const v = filters[c.key];
if (v == null || v === '' || v === 'all') continue;
const optFor = (raw: unknown) => {
if (c.type === 'search') return String(raw);
if (c.type === 'date-range') {
return DATE_RANGE_OPTIONS.find((o) => o.value === raw)?.label ?? String(raw);
}
return (
opts.find((o) => o.key === c.key)?.options?.find((o) => o.value === raw)?.label ??
formatLabel(String(raw))
);
};
if (Array.isArray(v)) {
for (const each of v) {
out.push({ key: c.key, label: c.label, value: String(each), displayValue: optFor(each) });
}
} else {
out.push({ key: c.key, label: c.label, value: String(v), displayValue: optFor(v) });
}
}
return out;
}
}
function formatLabel(value: string): string {
if (value === 'true') return 'Yes';
if (value === 'false') return 'No';
return value.charAt(0).toUpperCase() + value.slice(1).replace(/[_-]/g, ' ');
}
function dateInRange(itemValue: unknown, range: string): boolean {
if (!itemValue || range === 'all') return true;
const date = new Date(String(itemValue));
if (Number.isNaN(date.getTime())) return false;
const now = new Date();
let boundary: Date;
switch (range) {
case 'today':
boundary = new Date(now.getFullYear(), now.getMonth(), now.getDate());
break;
case 'week': {
const day = (now.getDay() + 6) % 7; // monday start
boundary = new Date(now.getFullYear(), now.getMonth(), now.getDate() - day);
break;
}
case 'month':
boundary = new Date(now.getFullYear(), now.getMonth(), 1);
break;
case '30d':
boundary = new Date(now.getTime() - 30 * 86400000);
break;
default:
return true;
}
return date >= boundary;
}
export const DATE_RANGE_OPTIONS: FilterOption[] = [
{ value: 'all', label: 'All time' },
{ value: 'today', label: 'Today' },
{ value: 'week', label: 'This week' },
{ value: 'month', label: 'This month' },
{ value: '30d', label: 'Last 30 days' },
];
+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;
}
+104
View File
@@ -0,0 +1,104 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
emptyFilterState,
emptyValueFor,
FilterEngine,
type FilterConfig,
type FilterState,
} from '@/lib/dynamic-filter';
// Filter state lives in `?f.<key>=...` search params — filtered views are
// shareable links. Arrays join with commas.
function stateFromParams<T>(schema: FilterConfig<T>[], params: URLSearchParams): FilterState {
const state = emptyFilterState(schema);
for (const c of schema) {
const raw = params.get(`f.${c.key}`);
if (raw == null) continue;
switch (c.type) {
case 'multiselect':
case 'boolean':
case 'status':
state[c.key] = raw.split(',').filter(Boolean);
break;
default:
state[c.key] = raw;
}
}
return state;
}
function stateToParams<T>(schema: FilterConfig<T>[], state: FilterState): URLSearchParams {
const params = new URLSearchParams();
const empty = emptyFilterState(schema);
for (const c of schema) {
const v = state[c.key];
if (v == null || v === '' || v === 'all') continue;
if (Array.isArray(v) && v.length === 0) continue;
if (JSON.stringify(v) === JSON.stringify(empty[c.key])) continue;
params.set(`f.${c.key}`, Array.isArray(v) ? v.join(',') : String(v));
}
return params;
}
/** URL-backed filter state — usable before any data is fetched. */
export function useFilterState<T>(schema: FilterConfig<T>[]) {
const [searchParams, setSearchParams] = useSearchParams();
const filters = useMemo(() => stateFromParams(schema, searchParams), [schema, searchParams]);
const setFilters = useCallback(
(updater: (prev: FilterState) => FilterState) => {
const next = updater(filters);
setSearchParams((prev) => {
const merged = new URLSearchParams(prev);
for (const key of [...merged.keys()]) {
if (key.startsWith('f.')) merged.delete(key);
}
stateToParams(schema, next).forEach((v, k) => merged.set(k, v));
return merged;
}, { replace: true });
},
[filters, schema, setSearchParams],
);
const updateFilter = useCallback(
(key: string, value: unknown) => setFilters((prev) => ({ ...prev, [key]: value })),
[setFilters],
);
const clearAll = useCallback(
() => setFilters(() => emptyFilterState(schema)),
[setFilters, schema],
);
const removeFilter = useCallback(
(key: string, rawValue?: string) =>
setFilters((prev) => {
const v = prev[key];
if (Array.isArray(v) && rawValue !== undefined) {
return { ...prev, [key]: v.filter((x) => String(x) !== rawValue) };
}
const config = schema.find((c) => c.key === key);
return config ? { ...prev, [key]: emptyValueFor(config.type) } : prev;
}),
[setFilters, schema],
);
return { filters, updateFilter, removeFilter, clearAll };
}
/** Full client-side filter: URL state + engine over `data`. */
export function useDynamicFilter<T>({ data, schema }: { data: T[]; schema: FilterConfig<T>[] }) {
const state = useFilterState(schema);
const engine = useMemo(() => new FilterEngine(data, schema), [data, schema]);
return {
filteredData: useMemo(() => engine.apply(state.filters), [engine, state.filters]),
filters: state.filters,
schema: useMemo(() => engine.options(), [engine]),
activeCount: useMemo(() => engine.activeCount(state.filters), [engine, state.filters]),
active: useMemo(() => engine.active(state.filters), [engine, state.filters]),
updateFilter: state.updateFilter,
removeFilter: state.removeFilter,
clearAll: state.clearAll,
};
}
@@ -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>
);
}
@@ -0,0 +1,147 @@
import { Search, X } from 'lucide-react';
import {
DATE_RANGE_OPTIONS,
type FilterConfig,
type FilterState,
} from '@/lib/dynamic-filter';
const inputClass =
'h-9 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';
// Dynamic filter bar — renders a FilterConfig schema as compact controls.
// State is owned by useDynamicFilter (URL-synced); this is a pure view.
export function FilterBar<T>({
schema,
filters,
activeCount,
active,
updateFilter,
removeFilter,
clearAll,
}: {
schema: FilterConfig<T>[];
filters: FilterState;
activeCount: number;
active: { key: string; label: string; value: string; displayValue: string }[];
updateFilter: (key: string, value: unknown) => void;
removeFilter: (key: string, rawValue?: string) => void;
clearAll: () => void;
}) {
return (
<div className="mb-5">
<div className="flex flex-wrap items-end gap-3">
{schema.map((config) => {
const value = filters[config.key];
switch (config.type) {
case 'search':
return (
<div key={config.key} className="relative">
<Search
size={13}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)]"
/>
<input
value={String(value ?? '')}
onChange={(e) => updateFilter(config.key, e.target.value)}
placeholder={`Search ${config.label.toLowerCase()}…`}
className={`${inputClass} w-56 pl-8`}
aria-label={config.label}
/>
</div>
);
case 'select':
case 'date-range': {
const options =
config.type === 'date-range'
? DATE_RANGE_OPTIONS
: [{ value: 'all', label: `All ${config.label.toLowerCase()}` }, ...(config.options ?? [])];
return (
<select
key={config.key}
value={String(value ?? 'all')}
onChange={(e) => updateFilter(config.key, e.target.value)}
className={`${inputClass} w-40`}
aria-label={config.label}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.count != null ? `${o.label} (${o.count})` : o.label}
</option>
))}
</select>
);
}
case 'multiselect':
case 'boolean':
case 'status': {
const selected = Array.isArray(value) ? (value as string[]) : [];
const options =
config.type === 'boolean'
? [
{ value: 'true', label: 'Yes' },
{ value: 'false', label: 'No' },
]
: (config.options ?? []);
return (
<div key={config.key} className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--text-muted)]">
{config.label}:
</span>
{options.map((o) => {
const on = selected.includes(o.value);
return (
<button
key={o.value}
onClick={() =>
updateFilter(
config.key,
on ? selected.filter((s) => s !== o.value) : [...selected, o.value],
)
}
className={`h-7 px-2.5 rounded-[var(--radius-sm)] text-xs font-medium border transition-colors ${
on
? 'bg-[var(--accent-primary-soft)] border-[var(--accent-primary)]/40 text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] text-[var(--text-secondary)] hover:border-[var(--border-default)]'
}`}
>
{o.label}
{o.count != null && <span className="ml-1 opacity-60">{o.count}</span>}
</button>
);
})}
</div>
);
}
default:
return null;
}
})}
{activeCount > 0 && (
<button
onClick={clearAll}
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
Clear ({activeCount})
</button>
)}
</div>
{active.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 mt-3">
{active.map((a, i) => (
<button
key={`${a.key}-${a.value}-${i}`}
onClick={() => removeFilter(a.key, a.value)}
className="group inline-flex items-center gap-1.5 h-6 px-2 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] text-[11px] font-medium text-[var(--accent-primary)]"
>
<span className="opacity-70">{a.label}</span>
{a.displayValue}
<X size={11} className="opacity-50 group-hover:opacity-100" />
</button>
))}
</div>
)}
</div>
);
}
@@ -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,5 @@ 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';
export * from './FilterBar';
+634
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:
@@ -2581,6 +2649,312 @@ paths:
items: items:
type: object 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:
@@ -3204,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
@@ -3475,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:
@@ -4820,6 +5291,93 @@ paths:
schema: schema:
$ref: '#/components/schemas/MessageResponse' $ref: '#/components/schemas/MessageResponse'
/api/agents/install.sh:
servers:
- url: http://localhost:8080
get:
tags:
- Agents
summary: Agent install script
description: Public bootstrap script — `curl | bash -s -- --url <api> --token <enroll-token>`.
security: []
responses:
'200':
description: Bourne shell installer
content:
text/x-sh:
schema:
type: string
/api/agents/download/{platform}:
servers:
- url: http://localhost:8080
get:
tags:
- Agents
summary: Download a bundled agent binary
description: "platform: linux-amd64 | linux-arm64. Bundled in release images; 404 when absent (install script falls back to GH releases)."
security: []
parameters:
- name: platform
in: path
required: true
schema:
type: string
enum: [linux-amd64, linux-arm64]
responses:
'200':
description: Agent binary
content:
application/octet-stream:
schema:
type: string
format: binary
'404':
description: No bundled binary for platform
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/agents/{id}/prune:
post:
tags:
- Agents
summary: Enqueue a bounded docker prune on a node
description: Queues a `prune` command — `docker system prune -af`, volumes excluded unless requested.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/AgentIdParam'
requestBody:
content:
application/json:
schema:
type: object
properties:
until:
type: string
description: Only prune objects older than this duration (e.g. "168h").
volumes:
type: boolean
description: Also prune volumes — destructive, off by default.
responses:
'202':
description: Prune command queued
content:
application/json:
schema:
type: object
properties:
command:
$ref: '#/components/schemas/AgentCommand'
'404':
description: Agent not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/agents/{id}/metrics: /agents/{id}/metrics:
get: get:
tags: tags:
@@ -5070,6 +5628,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
@@ -5969,6 +6566,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
@@ -7145,6 +7761,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
@@ -8268,6 +8894,9 @@ components:
metadata: metadata:
type: object type: object
additionalProperties: true additionalProperties: true
auto_prune:
type: boolean
description: Enqueue a bounded docker prune on this node once per day.
last_heartbeat: last_heartbeat:
type: string type: string
format: date-time format: date-time
@@ -8501,6 +9130,11 @@ components:
auth_token: auth_token:
type: string type: string
description: May also be sent via the X-Agent-Token header description: May also be sent via the X-Agent-Token header
mesh:
type: object
additionalProperties:
type: string
description: Overlay-network interface IPs (tailscale/netbird/zerotier/wireguard). Stored under agent metadata.mesh; authoritative — send an empty object when no mesh interfaces exist.
AgentHeartbeat: AgentHeartbeat:
type: object type: object
+13
View File
@@ -72,6 +72,8 @@ import compose <project-id> <compose.yml|->
attached under their service name; --var K=V overrides attached under their service name; --var K=V overrides
template variables template variables
nodes list|get|delete|tokens node agents + onboarding tokens (admin) nodes list|get|delete|tokens node agents + onboarding tokens (admin)
nodes add <user@host> SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs)
nodes update|prune|commands rename/auto-prune, bounded docker prune, command history
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
security scan|vulnerabilities|metrics security scan|vulnerabilities|metrics
@@ -84,6 +86,17 @@ webhooks list|create|update|delete|deliveries|test
deliveries are HMAC-signed (X-Containr-Signature) deliveries are HMAC-signed (X-Containr-Signature)
operations live ops view: active deploys, queue depth, operations live ops view: active deploys, queue depth,
24h failures, cron runs, backups 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
``` ```