Compare commits

..
Author SHA1 Message Date
Tomas DvorakandDevin cc2f2ace47 feat(templates): remote template registry via CONTAINR_TEMPLATE_REGISTRY_URL
- GET /templates merges a remote Containr catalog as source:"registry"
  (5s timeout, 60s cache, offline-safe)
- GET /templates/:id falls through to the registry
- Deploy of a registry template materializes a deterministic private
  copy for the deployer — repeat deploys reuse it
- Templates page badges registry entries; openapi + env documented

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 12:36:17 +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
52 changed files with 3856 additions and 273 deletions

No files matched your search

+9
View File
@@ -47,6 +47,12 @@ SECRETS_KEY=
CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
# Optional rotation list (comma-separated). When set, this takes precedence. # Optional rotation list (comma-separated). When set, this takes precedence.
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret # CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
# Bundled agent binaries dir served at /api/agents/download/:platform.
# CONTAINR_AGENT_BIN_DIR=/agents
# Optional remote template catalog (another Containr instance). Its
# templates merge into GET /templates as source:"registry" and
# materialize locally on deploy. The bundled catalog works without it.
# CONTAINR_TEMPLATE_REGISTRY_URL=https://templates.example.com
CORS_ORIGINS=http://localhost:3000,http://localhost:8080 CORS_ORIGINS=http://localhost:3000,http://localhost:8080
# Optional build-time API URL overrides. Leave empty for the default # Optional build-time API URL overrides. Leave empty for the default
# same-origin setup - the frontend nginx proxies /api to the backend, which # same-origin setup - the frontend nginx proxies /api to the backend, which
@@ -159,3 +165,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
+54 -23
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) |
--- ---
@@ -282,8 +282,14 @@ 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
write time, classified on read for legacy rows, boot-time backfill.
`GET /activity` (global, visibility-scoped, filters + `since`) and
`GET /projects/:id/activity` (owner/member/approved/admin). Frontend:
`/activity` page with severity chips + project workspace `Activity`
view. `containr activity` + `containr_activity_list`/`containr_project_activity`
MCP tools. Retention worker prunes > `AUDIT_RETENTION_DAYS` (default 90).
- [x] **Banners** — `banners` table with title/body/level/active/ - [x] **Banners** — `banners` table with title/body/level/active/
dismissible/schedule window. `GET /banners/active` for signed-in dismissible/schedule window. `GET /banners/active` for signed-in
users; admin CRUD at `/admin/banners`. `BannerBar` renders above the users; admin CRUD at `/admin/banners`. `BannerBar` renders above the
@@ -301,24 +307,46 @@ and wired refs, one click or one CLI call.
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
service page; `containr shell <id>` bridges a local TTY; distroless
images get a clean "no shell" error. Remaining: `nodes/:id/terminal`
via agent.
- [x] **Team invites** — `POST /admin/invites` returns a one-time - [x] **Team invites** — `POST /admin/invites` returns a one-time
link (sha256-hashed token at rest, optional email binding, TTL); link (sha256-hashed token at rest, optional email binding, TTL);
public `GET /auth/invites/:token` validates and public `GET /auth/invites/:token` validates and
`POST /auth/accept-invite` registers — works while public signup is `POST /auth/accept-invite` registers — works while public signup is
closed. `/auth/accept-invite` page, admin manage/revoke UI, closed. `/auth/accept-invite` page, admin manage/revoke UI,
`containr invites` CLI + MCP tools. `containr invites` CLI + MCP tools.
- [ ] **Impersonate user** — admin support tool, audit-logged. - [x] **Impersonate user** — `POST /admin/users/:id/impersonate`
- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest` mints a 15-minute bearer token (`impersonated_by` claim),
returns manifest + redirect; instance self-provisions its App audit-logged; admin accounts are not impersonatable. CLI
(dflow pattern — removes the env-setup barrier). `admin impersonate`, MCP tool, admin UI token modal. Remaining:
- [ ] **fork-sync trigger** — accept repo-sync events on webhook in-browser session swap needs a session bridge.
receiver. - [x] **GitHub App manifest flow** — `POST /admin/git/github-app/manifest`
- [ ] **Dynamic filter framework** — port dflow's `filter.utils` returns manifest + submission URL; the callback page exchanges
pattern (typed filter configs → URL state → client filtering) for `code` via `POST /admin/git/github-app/convert` and credentials
audit logs, deployments, notifications tables. 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) —
@@ -362,9 +390,12 @@ and wired refs, one click or one CLI call.
deploys a compose file straight into a project; CLI `import compose`, deploys a compose file straight into a project; CLI `import compose`,
MCP tool, workspace import modal; project delete cascades to MCP tool, workspace import modal; project delete cascades to
graph-provisioned managed databases (`database_services.project_id`). graph-provisioned managed databases (`database_services.project_id`).
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL` - [x] **Template registry** — `CONTAINR_TEMPLATE_REGISTRY_URL` merges a
fetch/publish; bundled catalog works offline (dflow's self-contained remote Containr catalog into `GET /templates` as `source:"registry"`
principle — nothing phones home). (60s cache, dead registry never blocks local). `GET /templates/:id`
falls through to the registry; deploy materializes a deterministic
`reg-<hash>` private copy for the deployer so re-deploys don't dup.
`api/templateregistry.go`, `.env.example`.
## 9b. Phase I — Companion surfaces ## 9b. Phase I — Companion surfaces
+3 -1
View File
@@ -9,7 +9,9 @@ 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
# 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
+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)
+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) {
+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,
})
}
@@ -0,0 +1,209 @@
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"
"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)
}
}
}()
}
func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) {
cutoff := time.Now().Add(-staleAfter)
type agentRow struct {
id string
name string
status string
stale bool
}
var agents []agentRow
rows, err := db.Query(
`SELECT id, COALESCE(name, hostname, id::text), COALESCE(status, ''),
COALESCE(last_heartbeat < $1, TRUE)
FROM node_agents`, cutoff)
if err != nil {
log.Printf("containr: agent health sweep: %v", err)
return
}
for rows.Next() {
var a agentRow
if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale); 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)
}
}
}
// 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"})
} }
+25
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)
@@ -341,6 +355,8 @@ 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 // Instance announcements shown to all signed-in users
authed.GET("/banners/active", handleListActiveBanners) authed.GET("/banners/active", handleListActiveBanners)
@@ -379,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)
@@ -477,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)
@@ -501,6 +521,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
admin.PATCH("/admin/banners/:id", handleAdminUpdateBanner) admin.PATCH("/admin/banners/:id", handleAdminUpdateBanner)
admin.DELETE("/admin/banners/:id", handleAdminDeleteBanner) 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 // Team invites — single-use registration links
admin.GET("/admin/invites", handleAdminListInvites) admin.GET("/admin/invites", handleAdminListInvites)
admin.POST("/admin/invites", handleAdminCreateInvite) admin.POST("/admin/invites", handleAdminCreateInvite)
+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),
@@ -0,0 +1,186 @@
package api
// Template registry — CONTAINR_TEMPLATE_REGISTRY_URL points at another
// Containr instance (or any server answering the same template shape):
// GET {base}/api/v1/templates → {"templates": [ServiceTemplate…]}
// GET {base}/api/v1/templates/:id → {"template": …, "config": …, "variables": …}
// Registry entries merge into the catalog as source:"registry"; deploying
// one materializes it as a user template for the deployer first. The
// bundled catalog always works offline — a dead registry never blocks it.
import (
"containr/internal/database/sqlcdb"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
const registryCacheTTL = 60 * time.Second
var registryCache struct {
mu sync.Mutex
fetchedAt time.Time
templates []ServiceTemplate
}
func registryBaseURL() string {
base := strings.TrimRight(strings.TrimSpace(os.Getenv("CONTAINR_TEMPLATE_REGISTRY_URL")), "/")
base = strings.TrimSuffix(base, "/api/v1")
return base
}
// fetchRegistryTemplates lists the remote catalog, cached for
// registryCacheTTL. Failures degrade to an empty list — never fatal.
func fetchRegistryTemplates(ctx context.Context) []ServiceTemplate {
if registryBaseURL() == "" {
return nil
}
registryCache.mu.Lock()
fresh := time.Since(registryCache.fetchedAt) < registryCacheTTL && registryCache.templates != nil
cached := registryCache.templates
registryCache.mu.Unlock()
if fresh {
return cached
}
var templates []ServiceTemplate
var payload struct {
Templates []ServiceTemplate `json:"templates"`
}
if err := registryGet(ctx, "/api/v1/templates", &payload); err == nil {
templates = payload.Templates
}
for i := range templates {
templates[i].Source = "registry"
}
registryCache.mu.Lock()
registryCache.fetchedAt = time.Now()
registryCache.templates = templates
registryCache.mu.Unlock()
return templates
}
// fetchRegistryTemplate fetches one remote template's full document
// (template + config + variables). nil when the registry lacks it.
func fetchRegistryTemplate(ctx context.Context, id string) *ServiceTemplate {
base := registryBaseURL()
if base == "" {
return nil
}
var payload struct {
Template ServiceTemplate `json:"template"`
Config json.RawMessage `json:"config"`
Variables []TemplateVariable `json:"variables"`
}
if err := registryGet(ctx, "/api/v1/templates/"+id, &payload); err != nil {
return nil
}
t := payload.Template
if t.ID == "" {
t.ID = id
}
t.Source = "registry"
// The detail endpoint returns config/variables decoded; store them back
// as raw JSON so they can persist unmodified into a local copy.
if len(payload.Config) > 0 {
t.Config = string(payload.Config)
}
if payload.Variables != nil {
if raw, err := json.Marshal(payload.Variables); err == nil {
t.Variables = string(raw)
}
}
return &t
}
func registryGet(ctx context.Context, path string, out interface{}) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, registryBaseURL()+path, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errRegistry{resp.StatusCode}
}
return json.NewDecoder(resp.Body).Decode(out)
}
type errRegistry struct{ status int }
func (e errRegistry) Error() string { return "template registry status " + http.StatusText(e.status) }
// respondRegistryTemplate answers GET /templates/:id for a registry-only
// template with the same envelope shape as local templates.
func respondRegistryTemplate(c *gin.Context, t *ServiceTemplate) {
config := json.RawMessage(t.Config)
if len(config) == 0 {
config = json.RawMessage(`{}`)
}
variables := json.RawMessage(t.Variables)
if len(variables) == 0 {
variables = json.RawMessage(`[]`)
}
c.JSON(http.StatusOK, gin.H{
"template": t,
"config": config,
"variables": variables,
})
}
// materializeRegistryTemplate copies a registry template into the local
// catalog as a private template owned by the deployer, so the normal
// deploy path (and future re-deploys) work without the registry. The
// local id is deterministic per (registry, template, user) so repeat
// deploys reuse the same copy instead of accumulating duplicates.
func materializeRegistryTemplate(c *gin.Context, q *sqlcdb.Queries, userID, templateID string) (sqlcdb.ServiceTemplate, error) {
ownerUUID, err := uuid.Parse(userID)
if err != nil {
return sqlcdb.ServiceTemplate{}, err
}
sum := sha256.Sum256([]byte(registryBaseURL() + "|" + templateID + "|" + userID))
localID := "reg-" + hex.EncodeToString(sum[:8])
if existing, err := q.GetServiceTemplateByID(c.Request.Context(), localID); err == nil {
return existing, nil
}
rt := fetchRegistryTemplate(c.Request.Context(), templateID)
if rt == nil {
return sqlcdb.ServiceTemplate{}, sql.ErrNoRows
}
variables := pqtype.NullRawMessage{}
if rt.Variables != "" {
variables = pqtype.NullRawMessage{RawMessage: json.RawMessage(rt.Variables), Valid: true}
}
if err := q.CreateUserTemplate(c.Request.Context(), sqlcdb.CreateUserTemplateParams{
ID: localID,
Name: rt.Name,
Description: nullableText(rt.Description),
Category: rt.Category,
Logo: nullableText(rt.Logo),
Config: json.RawMessage(rt.Config),
Variables: variables,
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
IsPublic: false,
}); err != nil {
return sqlcdb.ServiceTemplate{}, err
}
return q.GetServiceTemplateByID(c.Request.Context(), localID)
}
+43 -15
View File
@@ -21,18 +21,20 @@ import (
) )
type ServiceTemplate struct { type ServiceTemplate struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"` Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"` Description string `json:"description" db:"description"`
Category string `json:"category" db:"category"` Category string `json:"category" db:"category"`
Logo string `json:"logo" db:"logo"` Logo string `json:"logo" db:"logo"`
Config string `json:"config" db:"config"` Config string `json:"config" db:"config"`
Variables string `json:"variables" db:"variables"` Variables string `json:"variables" db:"variables"`
IsOfficial bool `json:"is_official" db:"is_official"` IsOfficial bool `json:"is_official" db:"is_official"`
OwnerID string `json:"owner_id,omitempty" db:"owner_id"` OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
IsPublic bool `json:"is_public" db:"is_public"` IsPublic bool `json:"is_public" db:"is_public"`
CreatedAt time.Time `json:"created_at" db:"created_at"` // Source marks catalog origin: "" (local/official) or "registry".
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` Source string `json:"source,omitempty" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }
type TemplateConfig struct { type TemplateConfig struct {
@@ -97,10 +99,24 @@ func handleGetTemplates(c *gin.Context) {
} }
templates := make([]ServiceTemplate, 0, len(templateRows)) templates := make([]ServiceTemplate, 0, len(templateRows))
localIDs := make(map[string]bool, len(templateRows))
for _, row := range templateRows { for _, row := range templateRows {
localIDs[row.ID] = true
templates = append(templates, mapSQLCTemplate(row)) templates = append(templates, mapSQLCTemplate(row))
} }
// Merge the configured remote registry (CONTAINR_TEMPLATE_REGISTRY_URL).
// Registry ids never shadow local templates.
for _, t := range fetchRegistryTemplates(ctx) {
if localIDs[t.ID] {
continue
}
if category != "" && t.Category != category {
continue
}
templates = append(templates, t)
}
c.JSON(http.StatusOK, gin.H{"templates": templates}) c.JSON(http.StatusOK, gin.H{"templates": templates})
} }
@@ -112,6 +128,11 @@ func handleGetTemplate(c *gin.Context) {
row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID) row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
// Registry fallback — remote templates materialize read-only.
if rt := fetchRegistryTemplate(c.Request.Context(), templateID); rt != nil {
respondRegistryTemplate(c, rt)
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return return
} }
@@ -190,11 +211,18 @@ func handleCreateFromTemplate(c *gin.Context) {
templateRow, err := queries.GetServiceTemplateByID(ctx, templateID) templateRow, err := queries.GetServiceTemplateByID(ctx, templateID)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"}) // Registry template — materialize a local copy for the
// deployer, then continue down the normal deploy path.
row, mErr := materializeRegistryTemplate(c, queries, userID, templateID)
if mErr != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
templateRow = row
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
return return
} }
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
return
} }
template := mapSQLCTemplate(templateRow) template := mapSQLCTemplate(templateRow)
+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)
+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
},
}
+2
View File
@@ -89,6 +89,8 @@ 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.BannersCmd)
rootCmd.AddCommand(commands.InvitesCmd) rootCmd.AddCommand(commands.InvitesCmd)
rootCmd.AddCommand(commands.UpCmd) rootCmd.AddCommand(commands.UpCmd)
+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{})
+6
View File
@@ -161,6 +161,12 @@ func init() {
register(spec{"containr_invites_list", "List team invite links (admin).", "GET", "/admin/invites", nil, nil, 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_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_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})
+11 -1
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,8 +118,11 @@ 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
WHERE id = $4 WHERE id = $4
`, "completed", completedAt, summaryToJSON(summary), scan.ID) `, "completed", completedAt, summaryToJSON(summary), scan.ID)
@@ -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,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;
+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",
+4
View File
@@ -11,6 +11,7 @@ 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 { useBranding } from '@/lib/use-branding';
@@ -23,6 +24,7 @@ import {
HighAvailabilityPage, HighAvailabilityPage,
SecurityPage, SecurityPage,
ComponentShowcase, ComponentShowcase,
GitHubAppCallbackPage,
} from '@/features/secondary/pages'; } from '@/features/secondary/pages';
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages'; import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
@@ -147,6 +149,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 />} />
@@ -156,6 +159,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 />} />
@@ -9,6 +9,7 @@ import {
ChartBar, ChartBar,
CheckCircle2, CheckCircle2,
ChevronsLeft, ChevronsLeft,
Clock,
Container, Container,
Database, Database,
FileText, FileText,
@@ -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' },
@@ -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>
);
}
@@ -20,6 +20,8 @@ import {
deleteBanner, deleteBanner,
deleteProject, deleteProject,
getAdminOverview, getAdminOverview,
impersonateUser,
type ImpersonationResult,
listAdminBanners, listAdminBanners,
listAdminUsers, listAdminUsers,
listInvites, listInvites,
@@ -77,6 +79,7 @@ export function AdminPage() {
const [bannerForm, setBannerForm] = useState({ title: '', body: '', level: 'info' as Banner['level'] }); const [bannerForm, setBannerForm] = useState({ title: '', body: '', level: 'info' as Banner['level'] });
const [inviteEmail, setInviteEmail] = useState(''); const [inviteEmail, setInviteEmail] = useState('');
const [inviteLink, setInviteLink] = useState<string | null>(null); 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'] });
@@ -107,6 +110,12 @@ export function AdminPage() {
onError: (error) => showToast('error', 'Revoke failed', error instanceof Error ? error.message : undefined), 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({ const bannerCreateMutation = useMutation({
mutationFn: () => mutationFn: () =>
createBanner({ createBanner({
@@ -520,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 ? (
@@ -528,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">
@@ -583,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">
@@ -606,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">
@@ -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">
+116 -7
View File
@@ -44,6 +44,9 @@ import {
listServiceVariables, listServiceVariables,
updateServiceVariables, updateServiceVariables,
getGitHubAppInstallUrl, getGitHubAppInstallUrl,
getGitHubAppStatus,
createGitHubAppManifest,
convertGitHubAppCode,
connectGitHubApp, connectGitHubApp,
updateCurrentUserProfile, updateCurrentUserProfile,
getPlatformSettings, getPlatformSettings,
@@ -950,6 +953,35 @@ function GitProvidersSection() {
} }
}; };
// Admins can self-provision the instance's GitHub App via the manifest
// flow when GITHUB_APP_* env vars were never configured.
const profileQuery = useQuery({ queryKey: ['user-profile'], queryFn: getCurrentUserProfile, retry: false });
const isAdmin = Boolean(profileQuery.data?.isAdmin);
const ghAppQuery = useQuery({
queryKey: ['github-app-status'],
queryFn: getGitHubAppStatus,
enabled: isAdmin,
retry: false,
});
const provisionMutation = useMutation({
mutationFn: async () => {
const { url, manifest } = await createGitHubAppManifest({ base_url: window.location.origin });
// GitHub requires a form POST carrying the manifest JSON.
const form = document.createElement('form');
form.method = 'POST';
form.action = url;
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'manifest';
input.value = manifest;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
},
});
const ghAppStatus = ghAppQuery.data;
const showProvision = isAdmin && ghAppStatus && !ghAppStatus.configured;
const [form, setForm] = useState({ const [form, setForm] = useState({
name: 'github' as (typeof gitProviderTypes)[number]['value'], name: 'github' as (typeof gitProviderTypes)[number]['value'],
displayName: '', displayName: '',
@@ -996,13 +1028,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"
@@ -1019,6 +1063,11 @@ function GitProvidersSection() {
{installUrlError} {installUrlError}
</div> </div>
)} )}
{provisionMutation.isError && (
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{provisionMutation.error instanceof Error ? provisionMutation.error.message : 'Failed to start GitHub App setup'}
</div>
)}
{connectAppMutation.isPending && ( {connectAppMutation.isPending && (
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--surface-muted)] text-sm text-[var(--text-secondary)]"> <div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--surface-muted)] text-sm text-[var(--text-secondary)]">
Completing GitHub App installation… Completing GitHub App installation…
@@ -1151,6 +1200,66 @@ function GitProvidersSection() {
); );
} }
// GitHub redirects here after the admin completes the manifest form —
// /git/github-app/callback?code=…&state=…
export function GitHubAppCallbackPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const code = searchParams.get('code');
const convertMutation = useMutation({
mutationFn: (appCode: string) => convertGitHubAppCode(appCode),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['github-app-status'] });
navigate('/settings', { replace: true });
},
});
const firedRef = useRef(false);
useEffect(() => {
if (code && !firedRef.current) {
firedRef.current = true;
convertMutation.mutate(code);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- fire once per code
}, [code]);
return (
<div className="mx-auto max-w-md px-5 py-16 text-center">
{!code ? (
<>
<AlertCircle size={28} className="mx-auto text-[var(--error)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Missing code</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]">
GitHub did not return an app code. Start the setup again from Git providers.
</p>
</>
) : convertMutation.isError ? (
<>
<AlertCircle size={28} className="mx-auto text-[var(--error)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Setup failed</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]">
{convertMutation.error instanceof Error ? convertMutation.error.message : 'Could not provision the GitHub App'}
</p>
<button
type="button"
onClick={() => navigate('/settings', { replace: true })}
className="mt-6 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-sm font-semibold text-[var(--accent-on)]"
>
Back to settings
</button>
</>
) : (
<>
<Loader2 size={28} className="mx-auto animate-spin text-[var(--accent-primary)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Setting up GitHub App</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]">Exchanging the app credentials…</p>
</>
)}
</div>
);
}
function PlatformSettingsSection() { function PlatformSettingsSection() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const settingsQuery = useQuery({ const settingsQuery = useQuery({
@@ -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>
)} )}
@@ -644,6 +644,11 @@ export function TemplatesPage() {
Public Public
</span> </span>
) : null} ) : null}
{template.source === 'registry' ? (
<span className="px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] text-[10px] font-semibold">
Registry
</span>
) : null}
</div> </div>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span <span
@@ -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">
+451
View File
@@ -1056,6 +1056,66 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/services/{id}/terminal": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* 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":"..."}`.
*/
get: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Switching protocols — WebSocket session established */
101: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Service not found or not visible */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Service has no running container */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Docker unavailable */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/internal/wake/{id}": { "/internal/wake/{id}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2717,6 +2777,63 @@ export interface paths {
}; };
trace?: never; trace?: never;
}; };
"/admin/users/{id}/impersonate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* 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.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Impersonation token */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
token?: string;
/** Format: date-time */
expires_at?: string;
user?: {
id?: string;
email?: string;
name?: string;
};
};
};
};
/** @description Target is an admin account */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/projects/{id}": { "/admin/projects/{id}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -3809,6 +3926,111 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/activity": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Activity feed
* @description Enriched audit events (severity, category, label) scoped to the caller's visible projects; admins see everything.
*/
get: {
parameters: {
query?: {
severity?: "info" | "success" | "warning" | "error";
category?: string;
resource?: string;
project_id?: string;
since?: string;
page?: number;
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Feed entries */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
activity?: components["schemas"]["AuditLog"][];
page?: number;
limit?: number;
has_more?: boolean;
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{id}/activity": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Per-project activity timeline
* @description Audit events correlated to this project's services, deployments, databases and the project record itself.
*/
get: {
parameters: {
query?: {
page?: number;
limit?: number;
};
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Feed entries */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
activity?: components["schemas"]["AuditLog"][];
};
};
};
/** @description Project not found or not visible */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/banners/active": { "/banners/active": {
parameters: { parameters: {
query?: never; query?: never;
@@ -5661,6 +5883,162 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/admin/git/github-app": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* GitHub App provisioning status
* @description Shows whether the instance GitHub App is configured, and whether credentials come from app_settings or environment variables.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Provisioning status */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
configured?: boolean;
/** @enum {string} */
source?: "settings" | "env" | "none";
app_id?: string;
slug?: string;
name?: string;
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/git/github-app/manifest": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* 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.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description Public https base URL of this instance */
base_url: string;
name?: string;
/** @description Create the app under this GitHub organization */
organization?: string;
};
};
};
responses: {
/** @description Manifest + submission URL */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
url?: string;
/** @description JSON string — POST as form field `manifest` */
manifest?: string;
state?: string;
};
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/git/github-app/convert": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* 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).
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
code: string;
};
};
};
responses: {
/** @description App provisioned */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
app_id?: number;
slug?: string;
name?: string;
};
};
};
/** @description GitHub conversion failed */
502: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/git/providers/{providerId}": { "/git/providers/{providerId}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -8386,6 +8764,68 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/git/github-app/webhook": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* 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.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": Record<string, never>;
};
};
responses: {
/** @description Event accepted */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
received?: boolean;
branch?: string;
deployments?: number;
ignored?: string;
};
};
};
/** @description Invalid signature */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description GitHub App webhook secret not configured */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/agents/{id}/commands/{commandId}/result": { "/api/agents/{id}/commands/{commandId}/result": {
parameters: { parameters: {
query?: never; query?: never;
@@ -10503,6 +10943,8 @@ export interface components {
owner_id?: string | null; owner_id?: string | null;
/** @description Whether a user-created template is published to the public catalog */ /** @description Whether a user-created template is published to the public catalog */
is_public?: boolean; is_public?: boolean;
/** @description Catalog origin: absent for local/official templates; "registry" for entries merged from CONTAINR_TEMPLATE_REGISTRY_URL. Deploying a registry template materializes a local copy first. */
source?: string;
/** /**
* Format: date-time * Format: date-time
* @description Creation timestamp * @description Creation timestamp
@@ -10679,6 +11121,15 @@ export interface components {
ip_address?: string; ip_address?: string;
/** @description Source user agent */ /** @description Source user agent */
user_agent?: string; user_agent?: string;
/**
* @description Event severity for feed display
* @enum {string}
*/
severity?: "info" | "success" | "warning" | "error";
/** @description Coarse category (service, deployment, project, user, security, system, …) */
category?: string;
/** @description Human-readable event label */
label?: string;
/** /**
* Format: date-time * Format: date-time
* @description Timestamp when the audit log entry was created * @description Timestamp when the audit log entry was created
+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\nbanners list|creatLine truncated "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nactivity Line truncated
}, },
{ {
"path": "guides/CLOUDFLARE_SETUP.md", "path": "guides/CLOUDFLARE_SETUP.md",
+82
View File
@@ -181,6 +181,7 @@ export type TemplateEntity = {
isOfficial: boolean; isOfficial: boolean;
ownerId: string | null; ownerId: string | null;
isPublic: boolean; isPublic: boolean;
source?: string;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
}; };
@@ -749,6 +750,7 @@ function normalizeTemplate(template: RawServiceTemplate): TemplateEntity | null
isOfficial: Boolean(template.is_official), isOfficial: Boolean(template.is_official),
ownerId: template.owner_id ?? null, ownerId: template.owner_id ?? null,
isPublic: Boolean(template.is_public), isPublic: Boolean(template.is_public),
source: template.source,
createdAt: template.created_at, createdAt: template.created_at,
updatedAt: template.updated_at, updatedAt: template.updated_at,
}; };
@@ -1928,6 +1930,44 @@ 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 = { export type Banner = {
id: string; id: string;
title: string; title: string;
@@ -2258,6 +2298,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',
@@ -2622,6 +2692,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' },
];
+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,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>
);
}
@@ -11,3 +11,4 @@ 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 './BrandWordmark';
export * from './FilterBar';
+268
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,67 @@ 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: /banners/active:
get: get:
tags: tags:
@@ -3730,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:
@@ -5325,6 +5541,45 @@ paths:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
/api/git/github-app/webhook:
servers:
- url: http://localhost:8080
post:
tags:
- Git
summary: GitHub App webhook receiver
description: >-
Single app-level webhook all installed repositories deliver to
(configured via the manifest's hook_attributes). Verified with
X-Hub-Signature-256 against the provisioned webhook secret. Push
events enqueue deployments for services tracking the repo+branch.
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'202':
description: Event accepted
content:
application/json:
schema:
type: object
properties:
received:
type: boolean
branch:
type: string
deployments:
type: integer
ignored:
type: string
'401':
description: Invalid signature
'503':
description: GitHub App webhook secret not configured
/api/agents/{id}/commands/{commandId}/result: /api/agents/{id}/commands/{commandId}/result:
servers: servers:
- url: http://localhost:8080 - url: http://localhost:8080
@@ -7138,6 +7393,9 @@ components:
is_public: is_public:
type: boolean type: boolean
description: Whether a user-created template is published to the public catalog description: Whether a user-created template is published to the public catalog
source:
type: string
description: 'Catalog origin: absent for local/official templates; "registry" for entries merged from CONTAINR_TEMPLATE_REGISTRY_URL. Deploying a registry template materializes a local copy first.'
created_at: created_at:
type: string type: string
format: date-time format: date-time
@@ -7419,6 +7677,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
+7
View File
@@ -84,10 +84,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); banners list|create|update|delete announcement banners (admin);
active banners print on `auth status` active banners print on `auth status`
invites list|create|revoke team invite links (admin); create invites list|create|revoke team invite links (admin); create
prints the one-time acceptance URL 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
``` ```