mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc2f2ace47 | ||
|
|
7547375cc2 | ||
|
|
d4a4cde1fd | ||
|
|
8cfe2ca826 | ||
|
|
612520bbff |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+32
-16
@@ -91,10 +91,10 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
|
|||||||
| 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) | ✅ `/activity` + project view | — |
|
| 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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -307,9 +307,12 @@ 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
|
||||||
@@ -329,11 +332,21 @@ and wired refs, one click or one CLI call.
|
|||||||
events (HMAC-verified) and enqueues matching services. Settings→env
|
events (HMAC-verified) and enqueues matching services. Settings→env
|
||||||
fallback wired into install-URL + installation-token paths.
|
fallback wired into install-URL + installation-token paths.
|
||||||
`containr admin github-app` CLI + MCP + OpenAPI.
|
`containr admin github-app` CLI + MCP + OpenAPI.
|
||||||
- [ ] **fork-sync trigger** — accept repo-sync events on webhook
|
- [x] **fork-sync trigger** — webhook receiver accepts `sync`,
|
||||||
receiver.
|
`repository_dispatch`, and `workflow_run` events (no `ref`) alongside
|
||||||
- [ ] **Dynamic filter framework** — port dflow's `filter.utils`
|
pushes: branch falls back to the webhook filter, then the service's
|
||||||
pattern (typed filter configs → URL state → client filtering) for
|
own `git_branch`; repo-only matching when no branch is known.
|
||||||
audit logs, deployments, notifications tables.
|
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) —
|
||||||
@@ -377,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -133,12 +147,17 @@ func handleGitWebhookPush(c *gin.Context) {
|
|||||||
// the pushed repo+branch. Shared by the per-repo webhook receiver and the
|
// the pushed repo+branch. Shared by the per-repo webhook receiver and the
|
||||||
// GitHub App webhook endpoint.
|
// GitHub App webhook endpoint.
|
||||||
func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.DeploymentEngine, cloneURL, fullName, branch, commit, repoUserID string) (int, error) {
|
func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.DeploymentEngine, cloneURL, fullName, branch, commit, repoUserID string) (int, error) {
|
||||||
rows, err := db.Query(`
|
// 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 {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -175,9 +194,9 @@ func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.
|
|||||||
}
|
}
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -196,7 +215,11 @@ func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.
|
|||||||
)
|
)
|
||||||
// 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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,6 +109,13 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// Audit retention — prunes rows past AUDIT_RETENTION_DAYS (default 90).
|
// Audit retention — prunes rows past AUDIT_RETENTION_DAYS (default 90).
|
||||||
StartAuditRetention(context.Background(), db)
|
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)
|
||||||
|
|
||||||
@@ -388,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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -90,6 +90,7 @@ func init() {
|
|||||||
rootCmd.AddCommand(commands.WebhooksCmd)
|
rootCmd.AddCommand(commands.WebhooksCmd)
|
||||||
rootCmd.AddCommand(commands.OperationsCmd)
|
rootCmd.AddCommand(commands.OperationsCmd)
|
||||||
rootCmd.AddCommand(commands.ActivityCmd)
|
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)
|
||||||
|
|||||||
@@ -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{})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+17
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { listActivity, type ActivityEntry } from '@/lib/api-client';
|
import { listActivity, type ActivityEntry } 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, useFilterState } from '@/lib/use-dynamic-filter';
|
||||||
import { formatRelative } from '@/lib/time';
|
import { formatRelative } from '@/lib/time';
|
||||||
import { DemoRestricted } from '@/shared/components';
|
import { DemoRestricted, FilterBar } from '@/shared/components';
|
||||||
import {
|
import {
|
||||||
Activity, AlertCircle, CheckCircle2, Database, GitBranch, Info, Key,
|
Activity, AlertCircle, CheckCircle2, Database, GitBranch, Info, Key,
|
||||||
Loader2, Package, Projector, Rocket, Shield, User, Users, Wrench,
|
Loader2, Package, Projector, Rocket, Shield, User, Users, Wrench,
|
||||||
@@ -22,23 +24,37 @@ const SEVERITY_STYLE: Record<string, { icon: typeof Info; cls: string; dot: stri
|
|||||||
info: { icon: Info, cls: 'text-[var(--accent-primary)]', dot: 'bg-[var(--accent-primary)]' },
|
info: { icon: Info, cls: 'text-[var(--accent-primary)]', dot: 'bg-[var(--accent-primary)]' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const SEVERITIES = ['', 'error', 'warning', 'success', 'info'] as const;
|
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() {
|
export function ActivityPage() {
|
||||||
const isDemoMode = useDemoMode();
|
const isDemoMode = useDemoMode();
|
||||||
const [severity, setSeverity] = useState('');
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const { filters } = useFilterState(SCHEMA);
|
||||||
|
const severityParam = filters.severity === 'all' ? '' : String(filters.severity ?? '');
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['activity-feed', severity, page],
|
queryKey: ['activity-feed', severityParam, page],
|
||||||
queryFn: () => listActivity({ severity: severity || undefined, page, limit: 50 }),
|
queryFn: () => listActivity({ severity: severityParam || undefined, page, limit: 50 }),
|
||||||
enabled: !isDemoMode,
|
enabled: !isDemoMode,
|
||||||
refetchInterval: 15000,
|
refetchInterval: 15000,
|
||||||
});
|
});
|
||||||
|
const filter = useDynamicFilter<ActivityEntry>({ data: query.data?.activity ?? [], schema: SCHEMA });
|
||||||
|
|
||||||
if (isDemoMode) return <DemoRestricted feature="Activity" />;
|
if (isDemoMode) return <DemoRestricted feature="Activity" />;
|
||||||
|
|
||||||
const entries = query.data?.activity ?? [];
|
const entries = filter.filteredData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
@@ -52,21 +68,11 @@ export function ActivityPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-8 py-6 space-y-4">
|
<div className="px-8 py-6 space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start gap-3">
|
||||||
{SEVERITIES.map((s) => (
|
<div className="flex-1">
|
||||||
<button
|
<FilterBar {...filter} />
|
||||||
key={s || 'all'}
|
</div>
|
||||||
onClick={() => { setSeverity(s); setPage(1); }}
|
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)] mt-3" />}
|
||||||
className={`v-mono text-[10px] uppercase px-3 py-1.5 rounded-md border transition-colors ${
|
|
||||||
severity === s
|
|
||||||
? 'border-[var(--accent-primary)] text-[var(--accent-primary)] bg-[var(--accent-primary)]/10'
|
|
||||||
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{s || 'all'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)]" />}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel p-0 divide-y divide-[var(--border-subtle)]">
|
<div className="panel p-0 divide-y divide-[var(--border-subtle)]">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -10883,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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' },
|
||||||
|
];
|
||||||
@@ -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';
|
||||||
@@ -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:
|
||||||
@@ -7364,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
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ operations live ops view: active deploys, queue depth,
|
|||||||
24h failures, cron runs, backups
|
24h failures, cron runs, backups
|
||||||
activity enriched activity feed; --severity/--category/
|
activity enriched activity feed; --severity/--category/
|
||||||
--resource/--project/--limit filters
|
--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
|
||||||
|
|||||||
Reference in new issue
Block a user