Found during a browser-driven audit of the whole app:
- connectivity banner showed permanently: pingServer() resolved
undefined, which React Query treats as an error; returns boolean now
- Vite dev proxy now forwards /health so the probe actually measures
the backend, and the API target is env-overridable (VITE_API_PROXY)
- auth-session removed from the persisted query cache — stale sessions
no longer rehydrate into a false signed-in flash
- workspace Observability tab no longer shows fabricated random metrics
("87K Active User", fake performance/speed-test cards); the dashboard
now aggregates real per-service metrics (CPU, memory, network, instances)
- service detail tabs write ?section= so tab state is shareable/deep-linked
- Security page auto-selects the first project instead of rendering blank
- People page user-creation form no longer overflows its card
(minmax(0,1fr) grid tracks; same fix on env-var rows)
- page padding is responsive (px-4 sm:px-8) so mobile keeps content width;
Projects header wraps instead of clipping the "+ new project" button
- template deploy modal shows a loading state while the session resolves
instead of flashing the sign-in prompt
- audit log table strips the CIDR suffix from displayed IPs
- docs list excerpts no longer repeat the document title
- activity filter placeholder "Search search…" → "Search events…"
- workspace view tabs get more room: ⌘K button now waits for lg:
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(db): port operations, settings, audit, activity, and misc service metadata to sqlc
Covers the cross-cutting surface: operations board reads, app settings,
notification producers, activity/audit inserts, builds upsert/cancel,
git clone credential resolution, invites, environments, registry auth,
project/service variables, HA resolver, boot sweeps, and admin user ops.
Dynamic filtered lists and Better Auth table probes stay raw by design.
Authored By: TDvorak <[email protected]>
* refactor(db): port apwhy gateway handlers and proxy data plane to sqlc
Final tranche — every static call site now uses generated queries. Raw SQL
remains only where the statement is assembled at runtime (filtered lists,
sparse updates, admin count closure) or probes Better Auth's auth_users
table, which lives outside the local schema.
Authored By: TDvorak <[email protected]>
- auth.sql: user lookups, counts, register/mirror inserts, profile update
- security.sql: access EXISTS, vulnerability list/metrics/status,
framework list, latest scan/report, project-id guards
- NULL description/service_id rows stay filtered, matching the original
scan-error-and-skip behavior
- auth_users probe and filtered audit-log queries stay raw: one is a
Better Auth table outside the schema, the other is dynamically built
- TestSecurityLiveInvariants covers the access predicate, severity
ordering, upsert conflict path, and latest-row lookups against live
Postgres
Authored By: TDvorak <[email protected]>
- git.sql: provider/repo/webhook CRUD, push-dispatch service matcher
- ListServicesForPush folds the dynamic branch clause into a static
predicate; COALESCE fallbacks match other service loaders
- git_receiver dispatch writes reuse deployment queries
- TestGitLiveInvariants: live coverage for upsert, search, webhook,
and branch matching semantics
Authored By: TDvorak <[email protected]>
Tranche 7 — all 11 raw sites in sleeper.go. Adds sleep_enabled and
sleep_idle_minutes to schema.sql and three runtime.sql queries:
ListSleepCandidates, GetServiceDomainText, GetServiceStatusText. Status
writes reuse SetServiceStatus; the wake loader reuses
GetServiceRuntimeWithOwner (the projects join is FK-safe).
Also drops a dead port read in handleServiceSleep that was loaded but
never used.
TestRuntimeLiveInvariants now covers the sweep filter and both text
lookups on live Postgres.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Tranche 6 — 15 of 17 raw sites in cron.go. Adds cron_jobs and
cron_executions to schema.sql and a full query set: job CRUD, owner
joins (via project and via service), execution list/insert/finish,
due-job scan for the scheduler, run bookkeeping, and retention trim.
The filtered list query and the sparse UPDATE builder stay raw — both
are genuinely dynamic. Go models keep string IDs; cronJobFrom and
cronExecutionFrom map uuid/null types at the boundary.
TestCronLiveInvariants covers insert/read, both owner joins, the
trigger read, execution lifecycle, retention trim, and delete on live
Postgres.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Tranche 5 — all 24 raw sites in runtime.go. Adds runtime.sql with the
legacy-column COALESCE loader (type/image/command/git_repo fall back to
service_type/image_name/start_command/source_url), env-var reads,
sibling refs, deployed-image concat, and conditional published-port /
replica writes. Status transitions reuse SetServiceStatus.
loadReadableService now shares GetServiceRuntimeWithOwner — the projects
join is safe (FK) and drops a duplicated 24-column select.
TestRuntimeLiveInvariants verifies the COALESCE chain against a service
seeded with only legacy columns, plus refs, port, variables, agent
listing, image concat, and conditional updates on live Postgres.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Tranche 4: all 32 raw sites in deployments.go — paginated service list,
the accessible-feed joins (owner/member/admin/approved), service+owner
load, deployment insert, status transitions (fail/complete/set), the
10-field progress sync, cancel access check, rollback reads/inserts, and
the published_port reuse read. The shared serviceForDeployFromRow mapper
now feeds both the preview and deployments loaders. Live test covers the
full lifecycle: insert, list, access join, feed predicate, queued →
deployed sync, failed transition.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Tranche 3: all 30 raw sites in preview_environments.go — list/detail
joins, access-check reads, create/update/delete, promote, user cleanup,
TTL sweep, and the status-sync UPDATE...FROM CASE. Deployments and
preview_environments schema.sql definitions completed to match runtime.
Shared mapper converts generated rows to the PreviewEnvironment DTO;
DeleteServiceByID from tranche 2 is reused for clone teardown.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Tranche 2: cloneServiceRow's INSERT...SELECT (volumes and traefik_labels
now read straight from the source row — traefik_labels was silently
dropped before, and the jsonb param round-trip goes away), domain and
variable copies, name-conflict counts, move UPDATE, project owner check.
Also fixes serviceWriteAccess: user_id is a context string, not a
uuid.UUID — the tranche-1 cast silently failed owner checks for
non-admins. Live tests cover the domain invariants and a full clone
round-trip (config, domains, variables).
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
First tranche of the ~300-site raw SQL port. Metrics: node/service/
instance rows via generated queries; aggregation moved off TimescaleDB's
time_bucket onto portable date_bin (PG14+); interval uptime returns epoch
seconds; per-container writes dropped (container_metrics FKs a different
model — collector shape never matched). Domains: all reads/writes through
sqlcdb including the create transaction via WithTx; default-domain and
services.domain invariants unchanged. Both paths covered by live
Postgres tests gated on METRICS_TEST_DSN.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Canvas mode in the template editor (lazy-loaded React Flow): service
nodes for web/worker/cron/database, connect handles to set depends_on,
per-node inspector (image/repo, engine, port, health check, env,
volumes), live validation (dup keys, missing deps, cycles, missing
source), and an Apply step that serializes back to the version-2 graph
config JSON.
- builder-model.ts is pure: config↔canvas round-trip, layered
auto-layout, flat-legacy→single-node migration, validation —
16 vitest cases cover all of it
- node positions persist under config.canvas (backend ignores unknown
config keys, so no schema change)
- flat legacy templates open as a single node so nothing is lost on
edit
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(setup): first-run wizard — admin, GitHub App, tunnel, done
- GET /api/v1/setup/status (public): has_users, needs_setup,
has_github_app, has_tunnel. app_settings.setup_completed flag; installs
with existing projects auto-complete so upgrades never see the wizard.
- POST /api/v1/setup/complete records the flag.
- /setup page: progress rail + four steps — create admin (links into the
bootstrap sign-up with redirect back), GitHub App manifest flow,
optional Cloudflare tunnel token, finish. Every optional step is
skippable; nothing blocks.
- PlatformShell redirects signed-in users to /setup while needs_setup.
- The GitHub App callback honors a sessionStorage return marker set by
the wizard so provisioning resumes at /setup instead of /settings.
- OpenAPI documents both endpoints.
Verified live: status reports auto-completed on this install,
complete persists the flag, response shape matches the client.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(setup): wizard step-state coverage
Five cases across the state machine: fresh-install account prompt,
sign-in resume, git-provider step, tunnel step, and finish.
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>
splitImageReference checked for '@' AFTER the last colon — but digests
put '@' before it, so `nginx@sha256:abc` split into
name=nginx@sha256/tag=abc. The deploy sync loop wrote those mangled
pairs into the deployments table for any digest-pinned image.
Any ref containing '@' now returns whole with the fallback tag. Table
tests cover registry ports, digests, and tag+digest combos.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(offline): persisted query cache + unreachable banner (web + mobile)
- Web: PersistQueryClientProvider on localStorage (24h maxAge); mobile:
AsyncStorage persister (7d). Both exclude high-churn keys (logs,
notifications, metrics) — stale tails are worse than none.
- networkMode 'offlineFirst' so cached data serves while the refetch
attempt fails.
- Web: ConnectivityBanner in PlatformShell pings public /health every
15s + listens to online/offline events — slim "last-known data" bar
so persisted state can't masquerade as live.
- Mobile: same banner in the tabs layout — /health ping plus
expo-network's local signal distinguishes "no connection" from
"instance down".
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(mobile): cron jobs screen — schedules, run-now, execution history
- /service/cron lists the service's jobs with schedule, timezone,
last/next run, and last status; "Run now" triggers via the existing
POST /cron-jobs/:id/trigger and "History" expands recent executions
with status + output/error.
- Manage row on the service detail + Stack registration.
Verified live: created */5 job on a running service, triggered it —
execution recorded with status success and captured stdout.
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>
Per-user notification_channels (ntfy + Gotify): every
insertUserNotification now fans out best-effort to enabled channels —
deployment/backup/security/agent alerts reach the user's phone via the
ntfy app with zero extra infrastructure.
- POST/GET/DELETE /notifications/channels + POST .../:id/test which
pushes a real probe so misconfiguration surfaces before alerts depend
on it (502 DELIVERY_FAILED on endpoint errors).
- ntfy: POST to endpoint (topic in path), Title/Tags headers, optional
Bearer token. Gotify: POST {endpoint}/message?token=...
- Delivery is best-effort by design — a dead relay never breaks the
producing flow; one shared 5s-timeout client.
- CLI `notifications channels list|add|test|remove`, MCP tools,
OpenAPI paths + NotificationChannel schema.
- Web settings "Push notifications" section and a Push channels card on
the mobile notifications tab (add/test/remove).
Verified live: channel CRUD, test-ping hit a recording endpoint with
correct headers/body, and a real deployment fanned out "Deployment
succeeded" through the channel end-to-end.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Environment lanes existed as seeded rows (production/preview/development)
with a per-service environment label, but there was no way to list,
create, or delete them. Adds the management surface and the switcher:
- GET /projects/:id/environments with live service counts (sqlc)
- POST /projects/:id/environments — lowercase dns-label names, 1-50
chars, unique per project (409 on dupe)
- DELETE /environments/:id — refuses while it holds services (409)
- Workspace service list gains env filter chips with counts plus an
inline "+ env" creator
- CLI: containr environments list|create|delete (--yes on delete);
MCP: containr_environments_list/create/delete (delete needs confirm)
- OpenAPI: new Environments tag + ProjectEnvironment schema
Live-verified: list counts, name validation, dupe 409, empty delete,
non-empty 409, CLI list.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Preview environments were bookkeeping only: a fabricated deployment row
marked "running" and a fake *.preview.containr.local URL. Now a preview
is a real service clone — full config copy (source, env, volumes, access),
git_branch pinned to the requested branch, its own preview hostname under
the clone's node default domain, and a real deployment through the queue.
- cloneServiceRow extracted from handleCloneService for reuse
- preview_environments.preview_service_id links to the clone
- minute sweeper: expires past-TTL previews (removes clone runtime + row)
and syncs preview status from the clone's live service status
- delete/cleanup paths tear the clone down, not just the row
- promote redeploys the SOURCE service at the preview branch and retires
the clone — a real deployment, not a pending stub
Verified live: create → clone → deploy → running container; TTL expiry →
clone + container gone; promote → real deploy on the source, preview
stopped. Unit test covers the DNS-safe clone naming.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The admin surface under /admin/gateway managed api_services and api_keys,
but no public route ever consumed them. Mounts /g/:slug and /g/:slug/* on
the root router (outside /api/v1 so route_prefix stays clean) and runs the
full pipeline: slug lookup -> X-API-Key/Bearer extraction -> prefix-indexed
bcrypt key auth -> allowed_service_ids scope -> per-minute buckets (key and
service caps) -> monthly usage_counters quotas -> gateway.ProxyRequest with
per-service timeout and upstream auth headers -> usage/metrics/incident
recording.
Live-verified: proxied GET/POST, 401/404/429 boundaries, counter upserts,
UPSTREAM_5XX incidents. Unit tests cover URL building, the rpm bucket, key
extraction, and the service allowlist.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
node_agents.default_domain gives each agent a base domain. Services
placed on it (explicit pin or spread) with no explicit service_domains
get <name>.<default_domain> — resolved in serviceDomainNames so edge
routing, wake placeholders, and public_url all see the same names.
Spread services union every eligible node's base domain; explicit
domains always win.
Surfaces: PUT /agents/:id {default_domain} (hostname-validated), CLI
nodes update --domain, MCP arg docs, OpenAPI + regenerated types,
node-detail inline editor.
Also marks the already-shipped backup-import roadmap item.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
services.traefik_labels (jsonb) holds operator-defined Traefik
middlewares keyed 'middlewares.<name>.<type>[.<field>]', validated
against an allowlist at write time. edgeLabels (extracted from
createReplica) scopes each definition to the service router as
<router>-<name> and auto-attaches it to the router's middleware chain
after builtins. Router/service/provider-level keys are rejected.
Surfaces: create/update API fields, services create/update
--traefik-label (repeatable, key=value), MCP arg docs, OpenAPI +
regenerated types, service-page Access editor (Routing overrides
card + textarea).
Also marks three stale ROADMAP2 items shipped: notification producers
round-out, build-on-node (PR #60), mobile env/domains/scaling (PR #61).
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(templates): git compose import, six stack seeds, empty-default vars
- POST /templates/import/git fetches a compose file from any git remote
(clone URL, ssh, owner/repo via connected providers — private repos
included), ref selects branch or commit. Path defaults through
compose.yaml/yml then docker-compose.yml/yaml. Reuses the build-on-node
checkout machinery; repo-relative paths are containment-checked.
- CLI `templates import-git <repo> [path] [--ref]`, MCP
containr_templates_import_git, OpenAPI entry, and a paste/git toggle
in the template import dialog.
- {{VAR:-}} with an empty default now resolves instead of reporting the
var missing — matches shell :- semantics.
- SeedTemplates gains six official graph stacks — n8n (pg+redis+worker),
Gitea, Nextcloud, Vaultwarden, Umami, Immich — wired with
{{service.*}} refs and {{secret}} credentials; no hardcoded passwords.
- TestSeedTemplatesResolveCleanly parses, topo-sorts, validates, and
resolves every seeded graph — catalog drift can't merge silently.
- templates/n8n.md compose block had invalid YAML (mixed depends_on
list/map) — fixed so the documented stack actually parses.
Verified live: import/git on a dumb-HTTP repo returns the converted
graph (db image → managed postgresql), path/ref/traversal/unauth edges
return clean errors, tpl-n8n plan resolves with zero unresolved vars.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* ci: pre-pull compose-smoke images via gcr mirror
Anonymous Docker Hub pulls rate-limit shared CI egress IPs — the compose
boot smoke flaked on traefik:pull. postgres/traefik are library/* images
served by mirror.gcr.io without auth; compose reuses the retagged local
images, falling back to a normal pull when the mirror misses.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* ci: route docker.io through gcr mirror for compose smoke
Pull-time pre-pulls don't cover BuildKit's base-image metadata lookups
(node:22-alpine, nginx:alpine, golang:1.26-alpine all 429). Daemon-level
registry-mirrors covers pulls and resolution in one move.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* ci: mirror docker.io in buildx for image publish job
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* ci: route smoke pulls/builds through mirror.gcr.io via compose overlay
daemon.json registry-mirrors isn't honored by the runner's containerd
image store. Qualify refs directly: ARG-parameterized Dockerfile FROMs
(defaults unchanged) plus a CI-only compose overlay that points image
pulls and build args at mirror.gcr.io.
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>
- Wake placeholder fired its wake callback unconditionally at boot, so
sleeping services flapped sleep→wake on every idle timeout. The wake
call now lives in a CGI script the busybox httpd runs on inbound hits;
E404 covers non-root paths. URLs/page travel via env, no quoting.
- Wake endpoint tries ordered candidate addresses (self-name on shared
net, host.docker.internal, edge gateway, probe) — Docker Desktop's VM
can't reach the bridge gateway, so single-URL wake was dead there.
- CreateServiceRequest accepts sleep_enabled/sleep_idle_minutes and the
INSERT persists them — previously update-only, silently dropped at
create. CLI `services create` gains --sleep/--sleep-idle; MCP doc lists
both; OpenAPI node_id description updated for build-on-node.
- Wake goroutine logs spec/reconcile failures instead of a bare return,
and marks the service failed when spec build errors.
- scripts/build-cli.sh ships containr-agent-linux-{amd64,arm64} so the
install-agent.sh GH-releases fallback actually resolves.
Verified live: create-time flags persist and validate (1-1440), service
sleeps on idle and stays asleep, an inbound hit on the placeholder wakes
it (~15s to running), and the placeholder is pruned after reconcile.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Companion app was read-mostly — quick fixes still needed a workstation.
- service/variables: full env editor — add/edit/delete rows, secret
toggle, masked-secret preservation (untouched secrets resubmit the
"********" placeholder so stored ciphertext survives the PUT).
- service/domains: list with default/TLS status, add, remove,
set-default.
- service/settings: replicas stepper (0–16), container port, scale-to-
zero toggle + idle minutes — saves via PUT /services/:id.
- (tabs)/notifications: alerts tab with unread badge on the tab bar,
mark-read per item and mark-all.
- Service detail gains a Manage section linking all three screens.
State initializes at mount inside a child component rather than an
effect — post-save refetches no longer clobber in-flight edits, and
the react-hooks/set-state-in-effect lint stays clean.
Verified: tsc --noEmit, expo lint, expo export (android bundle,
1378 modules — router graph compiles).
Authored By: TDvorak <[email protected]>
Git-sourced deploys never materialized a checkout: sourcePath was
build_path resolved against the server cwd, so builds consumed the
backend's own directory. Remote placement could only run
registry-pulled images.
- internal/source: per-deployment checkout (init/fetch/checkout) at
branch or commit; GIT_ASKPASS keeps tokens out of argv, .git/config,
and the build context; dumb-transport and SHA-refusal fallbacks;
build_path validated inside the checkout.
- BuildManager.PackageContext: produces the same tar context Build
ships to the daemon — generated Dockerfile at tar root, .git
excluded. Fixes nixpacks passing the Dockerfile path as content.
- Agent artifact endpoint: GET /api/agents/artifacts/:id streams
build contexts and docker-save tarballs behind agent-token auth;
hex-validated ids, per-deployment cleanup, boot-time purge.
- Agent commands: build_image (docker build -, --build-arg values via
process env, not argv) and load_image (docker load for containr-*
rollback tags no registry serves).
- Deployment flow: remote git builds package context → artifact →
node docker-builds the deployment tag; remote rollbacks of local
tags export via SaveImage → artifact → node docker-loads.
Verified end-to-end: agent-pinned git service built on the node
(build_image → create_container → deployed) and rollback shipped a
docker-save tarball (load_image → deployed). Artifact auth returns
401/404 correctly; workspaces and blobs are removed after deploy.
Authored By: TDvorak <[email protected]>
Agents report per-container net IO at heartbeat; the sleeper measures
idleness from those counters for remote services, removes replicas via
the agent queue, and repoints the domain route at the backend's new
/internal/wake-page endpoint (replacePath + priority-100 route in the
file provider). First hit serves a reloading page and retriggers the
wake reconcile, which recreates replicas and restores real upstreams.
Manual POST /services/:id/sleep also routes remote; local sleep/wake
flow is unchanged.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The agent now lists every containr-managed container's docker state at
each heartbeat; the server reconciles container_instances for rows that
node owns, so exits, OOMs, and crash loops surface in runtime state
between reconciles. Tombstoned rows are never resurrected.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Remote replicas publish an ephemeral host port; the agent reports the
assigned binding back in the create_container result and the backend
renders a per-service file-provider config into TRAEFIK_DYNAMIC_DIR
pointing Traefik at the node's mesh IP + port. Routes are rewritten on
every remote reconcile and removed on teardown or local placement.
Also: RetireService retires remote leftovers when a service returns to
local placement; remove_container is idempotent agent-side; tombstoned
replicas skip remove dispatch; the domains-stay-local ceiling is gone.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Operator-set node labels (node_agents.tags) gate which nodes a service
may schedule onto: services.placement_tags requires every listed tag.
auto and spread filter eligible agents by tags; tags are re-evaluated
each reconcile so retagging a node re-spreads replicas. Pin + tags and
local + tags rejected; empty array clears the requirement.
Surfaces: --placement-tags on services create/update, nodes update
--tags, MCP args, OpenAPI, node-detail tag editor, service-detail
placement-tags input.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
POST /agents/:id/upgrade enqueues a self_upgrade command carrying the
target platform, the sha256 the server computed over its bundled binary,
and the server version. The agent downloads the replacement from the
existing /api/agents/download/:platform endpoint, verifies the checksum,
sanity-checks the new binary (--version), atomically swaps its own
executable, reports the command result, then re-execs via syscall.Exec —
no supervisor or restart plumbing required.
Agents stamp main.version via ldflags at image build, expose --version,
and now send version at register so node_agents.version refreshes on
re-register (older agents without the field keep their stored value).
The server rejects upgrades for offline nodes and for platforms with no
bundled binary (linux-amd64/linux-arm64 ship in the image).
Surfaces: nodes upgrade CLI, containr_nodes_upgrade MCP (confirm-gated),
node-detail page button, OpenAPI. Verified live: v0.0.3 agent upgraded
to v0.0.4 in place, re-registered with the new version; offline node
returns 409 NODE_OFFLINE.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
services.spread distributes replicas across every online, schedulable
agent (replica i → agents[i mod n], deterministic). auto placement now
orders by real headroom — memory utilisation, cpu usage, then container
count — instead of count alone.
Lifecycle (start/stop/restart/delete) and GET /runtime are now driven
by container_instances inventory, so remote replicas are visible and
tear down wherever they run — covers spread, pins, and stale pin
leftovers. Spread and explicit pins are mutually exclusive.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(scheduling): multi-node service placement + remote container lifecycle
services.node_id pins a service to a node agent: explicit id, "auto"
(least-loaded online node), or "local"/empty for the local Docker host.
- DeploymentEngine gains a NodeRunner seam; ReconcileService routes
pinned services to the agent's command queue (create_container with
image/env/command/ports/volumes/labels/resource limits), tracked in
container_instances with deterministic per-replica ids.
- Stop/start/restart/delete fan out to the pinned node — no more leaked
remote containers on service delete.
- Agent create_container is idempotent (rm -f first) and honors restart
policy, labels, memory/cpus, published+ephemeral ports, volumes, env.
- GET /nodes/options exposes id/name/status to authed users; full agent
management stays admin-only.
- CLI services create/update --node, MCP node_id docs, OpenAPI
node_id/node_name + /nodes/options, service-detail Placement section.
Ceilings: remote nodes run registry-pulled images only — git builds,
Traefik domains, and remote sleep detection stay local until
build-on-node and per-node ingress land.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(scheduling): node cordon/drain + schedulable gate
- node_agents.schedulable column; "auto" placement and explicit pins skip
cordoned nodes; already-pinned services keep running (reconcile is
unaffected — cordon is not eviction).
- POST /agents/:id/cordon|uncordon|drain (admin). Drain cordons, removes
every service container via the agent queue, and clears the services'
pins so they redeploy on the local host. Requires the node online —
an offline node can't acknowledge removals.
- CLI nodes cordon|uncordon|drain (drain confirm-gated), MCP
containr_nodes_cordon|uncordon|drain, OpenAPI paths + schedulable
field, node-detail page cordon badge + drain/cordon controls.
- Fix: re-registering an agent no longer resets auto_prune.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(scheduling): remote registry pulls + post-run health check
- Dispatch payload carries the project owner's registry credentials for
the image host; the agent docker-logins for the pull and logs out
after, so private images deploy to remote nodes.
- Completed command payloads get their registry password scrubbed —
secrets don't linger in agent_commands rows.
- Agent verifies the container is still running ~1.5s after docker run;
crash-loops now fail the deployment with the container's last log
lines instead of reporting "running" optimistically.
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>
syscall.SIGWINCH does not exist on Windows, breaking the release
cross-compile for windows/amd64.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
dflow importer pages the Payload REST collections (users API-Key
auth) and recreates projects/services: app→git web services, docker
→image, database→typed image with DATABASE_URL for external
providers; variables (empty encrypted values flagged), bind volumes,
and domains carried over; soft-deleted docs excluded.
Also fixes the Railway migrator posting type "image"/"git" — the
create API only accepts web|worker|database|cron and requires an
environment, so every service create 400'd.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
--token (or RAILWAY_TOKEN) reads the Railway project via backboard
GraphQL: services resolve to git-repo or image services with command +
replicas, variables and service/custom domains follow. --dry-run prints
the plan; --into targets an existing project. Both account (Bearer) and
project tokens (Project-Access-Token header) work.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- 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>
* 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>
* 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>
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>
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>
* feat(branding): white-label — public /branding, admin write, shell theming
app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.
useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(invites): team invite links — admin create/revoke, public accept
Admins mint single-use invite links (sha256-hashed token at rest,
optional email binding, configurable TTL). Public endpoints let the
accept page validate the link and register — working even while
public signup is disabled.
- migration + sqlc queries/model for user_invites
- POST /admin/invites (returns one-time token+url), GET list, DELETE revoke
- GET /auth/invites/:token public validation, POST /auth/accept-invite
- CLI containr invites list|create|revoke; MCP invites tools
- /auth/accept-invite page (expired/used/invalid states, bound-email lock)
- Admin page Team invites section: create, copy link, revoke
- OpenAPI + generated types + CLI guide
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
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>
banners table (title/body/level/active/dismissible/schedule window).
GET /banners/active for signed-in users; admin CRUD under /admin/banners.
BannerBar renders above the header with per-banner dismissal
(localStorage); admin console gains an Announcements section;
`containr banners` manages them and `auth status` prints active ones.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
GET /api/v1/operations aggregates active deployments, per-service
deploy-queue depth via Queue.Snapshot(), 24h deployment failures,
cron runs and backups scoped to the caller's projects (admins see all).
Frontend /operations page (Operate nav) shows in-flight count, queued
depth, failures and backup stats with live refetch while deploys are
active. CLI `containr operations` + MCP containr_operations_get.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
POST /projects/:id/import-compose deploys a compose file straight into a
project — no template row required. Well-known DB images become managed
databases attached to the project network under their service name as DNS
alias; other services deploy in dependency order via deployTemplateGraph.
- api: extract composeToSpecs converter (shared by the template
import-compose preview and the new project endpoint); fix dropped env
warnings (pointer receiver); replace insertion sort with sort.Strings
- cascade: database_services.project_id links graph-provisioned DBs to
their project so project delete tears down runtime + row (previously
leaked container + network)
- cli: new `containr import` group, `import compose <project> <file|->`
- mcp: containr_projects_import_compose tool
- openapi + regenerated frontend types; docs snapshot + CLI.md
- frontend: compose import modal on the project workspace toolbar
- tests: composeToSpecs conversion, ordering, db-image map, warnings