mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a634e6960c |
No files matched your search
@@ -47,12 +47,6 @@ 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,10 +248,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
+31
-62
@@ -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 token + `impersonated_by` claim (browser swap pending) | Done (CLI/API/MCP) |
|
| **Impersonate user** | ❌ | Admin support tool |
|
||||||
| In-app docs (content-collections markdown) | ✅ `/docs` | — |
|
| In-app docs (content-collections markdown) | ✅ `/docs` | — |
|
||||||
| Dynamic client-side filter framework (535-line `filter.utils`) | ✅ `dynamic-filter.ts` + URL state; audit/builds/activity | Done |
|
| Dynamic client-side filter framework (535-line `filter.utils`) | 🟡 audit page filters only | Reuse pattern for tables |
|
||||||
| Ops "bubble" (queue monitor, terminal, sync) | ✅ `/operations` + interactive terminal | Done |
|
| Ops "bubble" (queue monitor, terminal, sync) | ❌ | Operations page |
|
||||||
|
|
||||||
#### 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) | ✅ manifest + convert + App webhook | Done |
|
| GitHub App **manifest flow** (instance self-provisions the App) | 🟡 env-configured App | `POST /git/github-app/manifest` flow |
|
||||||
| fork-sync webhook deploy trigger | ✅ sync/repository_dispatch/workflow_run | Done |
|
| fork-sync webhook deploy trigger | ❌ | ~20 lines on receiver |
|
||||||
| Azure DevOps provider | ❌ | Optional (dflow does token-clone only) |
|
| Azure DevOps provider | ❌ | Optional (dflow does token-clone only) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -252,31 +252,18 @@ and wired refs, one click or one CLI call.
|
|||||||
|
|
||||||
## 7. Phase F — Nodes & infrastructure
|
## 7. Phase F — Nodes & infrastructure
|
||||||
|
|
||||||
- [x] **SSH bootstrap install** — `containr nodes add <user@host>` mints an
|
- [ ] **SSH bootstrap install** — `containr nodes add --ssh user@host` +
|
||||||
enroll token, SSHes once (agent/key/default keys), runs
|
UI flow: one SSH session runs agent install (binary + systemd +
|
||||||
`GET /api/agents/install.sh | bash` remotely, closes the session.
|
enroll token), SSH closed forever. dflow onboarding UX, agent-model
|
||||||
`--print` emits the one-liner for manual runs; the Usage page shows the
|
security.
|
||||||
same command. Agent binaries ship in the image under `/agents` and are
|
- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/
|
||||||
served at `/api/agents/download/{linux-amd64,linux-arm64}` (install
|
zt0 IPs at register; backend stores + displays. Optional
|
||||||
script falls back to GH releases). `cli/commands/nodes_add.go`,
|
`TAILSCALE_AUTH_KEY` enroll-at-provision later.
|
||||||
`api/assets/install-agent.sh`, `agents.go:ServeInstallScript`/`ServeAgentBinary`.
|
- [ ] **Node detail page** — placed services, telemetry history, agent
|
||||||
- [x] **Mesh awareness** — agent reports tailscale/netbird/wireguard/
|
version + self-upgrade, **drain/cordon**, remove.
|
||||||
zerotier interface IPs at register (`mesh` field, authoritative on
|
- [ ] **Node housekeeping** — agent `prune` command (docker system
|
||||||
re-register), stored in `node_agents.metadata.mesh`, shown on the node
|
prune, bounded) + optional schedule (`setServerAutoCleanup` analog —
|
||||||
detail page. `TAILSCALE_AUTH_KEY` enroll-at-provision still open.
|
disk pressure is the #1 small-VPS killer).
|
||||||
- [x] **Node detail page** — `/nodes/:id` (admin): heartbeat telemetry
|
|
||||||
(cpu/mem sparklines over 1h/24h/7d via `GET /agents/:id/metrics`),
|
|
||||||
containers, command history, rename, auto-prune toggle, prune-now,
|
|
||||||
remove. Drain/cordon and self-upgrade deferred — they need
|
|
||||||
`services.node_id` routing which lands with multi-node scheduling.
|
|
||||||
- [x] **Node housekeeping** — agent `prune` + `system_df` commands
|
|
||||||
(`docker system prune -af`, volumes excluded unless requested, optional
|
|
||||||
`--until`). `node_agents.auto_prune` + the health sweep enqueue a prune
|
|
||||||
at most once per 24h per node. Surfaces: `POST /agents/:id/prune`,
|
|
||||||
`nodes update --auto-prune` / `nodes prune [--volumes --until]` /
|
|
||||||
`nodes commands`, MCP `containr_nodes_update|prune|commands`
|
|
||||||
(confirm-gated), Usage-page per-node Prune button + auto toggle.
|
|
||||||
`cmd/agent/main.go`, `notification_producers.go:scheduleAutoPrune`.
|
|
||||||
- [ ] **Multi-node scheduling** — wire `scheduling_rules` (table exists):
|
- [ ] **Multi-node scheduling** — wire `scheduling_rules` (table exists):
|
||||||
pin/spread/affinity, prefer-lowest-usage; deploy engine routes to
|
pin/spread/affinity, prefer-lowest-usage; deploy engine routes to
|
||||||
`services.node_id` via agent commands (agent already runs containers).
|
`services.node_id` via agent commands (agent already runs containers).
|
||||||
@@ -345,21 +332,11 @@ 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.
|
||||||
- [x] **fork-sync trigger** — webhook receiver accepts `sync`,
|
- [ ] **fork-sync trigger** — accept repo-sync events on webhook
|
||||||
`repository_dispatch`, and `workflow_run` events (no `ref`) alongside
|
receiver.
|
||||||
pushes: branch falls back to the webhook filter, then the service's
|
- [ ] **Dynamic filter framework** — port dflow's `filter.utils`
|
||||||
own `git_branch`; repo-only matching when no branch is known.
|
pattern (typed filter configs → URL state → client filtering) for
|
||||||
Also fixed a latent bug where the webhook's deployment insert omitted
|
audit logs, deployments, notifications tables.
|
||||||
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) —
|
||||||
@@ -393,27 +370,19 @@ and wired refs, one click or one CLI call.
|
|||||||
|
|
||||||
## 9. Phase H — Migration & ecosystem
|
## 9. Phase H — Migration & ecosystem
|
||||||
|
|
||||||
- [x] **Railway migrator** — `containr migrate railway --token --project
|
- [ ] **Railway migrator** — `containr migrate railway --token --project
|
||||||
--environment --into --dry-run`: Railway GraphQL (backboard) →
|
--dry-run`: Railway GraphQL → services/vars/domains → recreate via
|
||||||
services (git-repo/image + command + replicas), variables, custom +
|
PAT. Landing-page pitch: "off Railway in one command."
|
||||||
service domains → recreates via the Containr API. Project tokens and
|
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST
|
||||||
account tokens both supported. `cli/commands/migrate.go`.
|
export or Mongo dump: servers→nodes, projects, services(+vars/
|
||||||
- [x] **dflow importer** — `containr migrate dflow --url --api-key
|
domains/volumes), databases, templates. Onboards dflow refugees.
|
||||||
--project --into --dry-run`: pages the Payload REST collections
|
|
||||||
(users API-Key auth) → services (app→git/web, docker→image,
|
|
||||||
database→typed image + DATABASE_URL for external providers),
|
|
||||||
variables (empty encrypted values flagged), bind volumes, domains.
|
|
||||||
Soft-deleted services excluded. `cli/commands/migrate_dflow.go`.
|
|
||||||
- [x] **`containr import compose`** — `POST /projects/:id/import-compose`
|
- [x] **`containr import compose`** — `POST /projects/:id/import-compose`
|
||||||
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`).
|
||||||
- [x] **Template registry** — `CONTAINR_TEMPLATE_REGISTRY_URL` merges a
|
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL`
|
||||||
remote Containr catalog into `GET /templates` as `source:"registry"`
|
fetch/publish; bundled catalog works offline (dflow's self-contained
|
||||||
(60s cache, dead registry never blocks local). `GET /templates/:id`
|
principle — nothing phones home).
|
||||||
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,13 +9,7 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG VERSION=dev
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \
|
|
||||||
-ldflags "-X containr/internal/api.Version=${VERSION}" -o main cmd/server/main.go
|
|
||||||
|
|
||||||
# Node-agent binaries served by the API for SSH/bootstrap installs.
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /agents/containr-agent-linux-amd64 ./cmd/agent && \
|
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /agents/containr-agent-linux-arm64 ./cmd/agent
|
|
||||||
|
|
||||||
# Build embedded Better Auth runtime dependencies
|
# Build embedded Better Auth runtime dependencies
|
||||||
FROM node:22-alpine AS auth-builder
|
FROM node:22-alpine AS auth-builder
|
||||||
@@ -39,7 +33,6 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY --from=go-builder /app/main ./main
|
COPY --from=go-builder /app/main ./main
|
||||||
COPY --from=go-builder /app/migrations_goose ./migrations_goose
|
COPY --from=go-builder /app/migrations_goose ./migrations_goose
|
||||||
COPY --from=go-builder /agents /agents
|
|
||||||
COPY --from=auth-builder /auth ./auth
|
COPY --from=auth-builder /auth ./auth
|
||||||
|
|
||||||
RUN chown -R appuser:appgroup /app
|
RUN chown -R appuser:appgroup /app
|
||||||
|
|||||||
@@ -167,8 +167,6 @@ func register(ctx context.Context, client *http.Client, cfg agentConfig) (string
|
|||||||
"ip_address": cfg.IP,
|
"ip_address": cfg.IP,
|
||||||
"port": cfg.Port,
|
"port": cfg.Port,
|
||||||
"capabilities": detectCapabilities(),
|
"capabilities": detectCapabilities(),
|
||||||
// Always send mesh so a removed interface clears stored state.
|
|
||||||
"mesh": detectMeshInterfaces(),
|
|
||||||
}
|
}
|
||||||
var response registerResponse
|
var response registerResponse
|
||||||
if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil {
|
if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil {
|
||||||
@@ -248,29 +246,11 @@ func executeCommand(item command) (string, error) {
|
|||||||
return runDocker("restart", commandTarget(item.Payload))
|
return runDocker("restart", commandTarget(item.Payload))
|
||||||
case "remove_container":
|
case "remove_container":
|
||||||
return runDocker("rm", "-f", commandTarget(item.Payload))
|
return runDocker("rm", "-f", commandTarget(item.Payload))
|
||||||
case "prune":
|
|
||||||
return dockerPrune(item.Payload)
|
|
||||||
case "system_df":
|
|
||||||
return runDocker("system", "df", "--format", "json")
|
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unsupported command type %q", item.Type)
|
return "", fmt.Errorf("unsupported command type %q", item.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// dockerPrune runs a bounded docker system prune. Volumes are excluded
|
|
||||||
// unless the payload explicitly opts in — a stray volume prune destroys
|
|
||||||
// database data.
|
|
||||||
func dockerPrune(payload map[string]interface{}) (string, error) {
|
|
||||||
args := []string{"system", "prune", "-af"}
|
|
||||||
if boolValue(payload["volumes"]) {
|
|
||||||
args = append(args, "--volumes")
|
|
||||||
}
|
|
||||||
if until := stringValue(payload["until"]); until != "" {
|
|
||||||
args = append(args, "--filter", fmt.Sprintf("until=%s", until))
|
|
||||||
}
|
|
||||||
return runDocker(args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createContainer(payload map[string]interface{}) (string, error) {
|
func createContainer(payload map[string]interface{}) (string, error) {
|
||||||
container, ok := payload["container"].(map[string]interface{})
|
container, ok := payload["container"].(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -374,42 +354,6 @@ func detectCapabilities() capabilities {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectMeshInterfaces reports overlay-network interface IPs (tailscale,
|
|
||||||
// netbird, zerotier, wireguard) so the backend can display reachable
|
|
||||||
// mesh addresses alongside the primary IP.
|
|
||||||
func detectMeshInterfaces() map[string]string {
|
|
||||||
ifaces, err := net.Interfaces()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
mesh := map[string]string{}
|
|
||||||
for _, iface := range ifaces {
|
|
||||||
name := strings.ToLower(iface.Name)
|
|
||||||
isMesh := strings.HasPrefix(name, "tailscale") || strings.HasPrefix(name, "ts.") ||
|
|
||||||
strings.HasPrefix(name, "netbird") || strings.HasPrefix(name, "wt") ||
|
|
||||||
strings.HasPrefix(name, "zt") || strings.HasPrefix(name, "wg")
|
|
||||||
if !isMesh || iface.Flags&net.FlagUp == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
addrs, err := iface.Addrs()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, addr := range addrs {
|
|
||||||
ip, _, err := net.ParseCIDR(addr.String())
|
|
||||||
if err != nil || ip.IsLoopback() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if v4 := ip.To4(); v4 != nil {
|
|
||||||
mesh[iface.Name] = v4.String()
|
|
||||||
} else if _, exists := mesh[iface.Name]; !exists {
|
|
||||||
mesh[iface.Name] = ip.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mesh
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectResources(ip string) nodeResources {
|
func collectResources(ip string) nodeResources {
|
||||||
mem := readMemory()
|
mem := readMemory()
|
||||||
storage := readStorage("/")
|
storage := readStorage("/")
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
_ "embed"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -14,7 +13,6 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -38,7 +36,6 @@ type NodeAgent struct {
|
|||||||
Capabilities AgentCapabilities `json:"capabilities"`
|
Capabilities AgentCapabilities `json:"capabilities"`
|
||||||
Resources NodeResources `json:"resources"`
|
Resources NodeResources `json:"resources"`
|
||||||
LastHeartbeat time.Time `json:"last_heartbeat"`
|
LastHeartbeat time.Time `json:"last_heartbeat"`
|
||||||
AutoPrune bool `json:"auto_prune"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
Metadata map[string]interface{} `json:"metadata"`
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
@@ -237,7 +234,6 @@ func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
|
|||||||
Port: int(row.Port),
|
Port: int(row.Port),
|
||||||
Status: row.Status.String,
|
Status: row.Status.String,
|
||||||
Version: row.Version.String,
|
Version: row.Version.String,
|
||||||
AutoPrune: row.AutoPrune,
|
|
||||||
Metadata: map[string]interface{}{},
|
Metadata: map[string]interface{}{},
|
||||||
}
|
}
|
||||||
if row.LastHeartbeat.Valid {
|
if row.LastHeartbeat.Valid {
|
||||||
@@ -331,7 +327,6 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
|||||||
Port int `json:"port" binding:"required"`
|
Port int `json:"port" binding:"required"`
|
||||||
Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
|
Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
|
||||||
AuthToken string `json:"auth_token"`
|
AuthToken string `json:"auth_token"`
|
||||||
Mesh map[string]string `json:"mesh"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -346,28 +341,12 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
metadata := map[string]interface{}{}
|
|
||||||
// req.Mesh is authoritative when present (newer agents always send it,
|
|
||||||
// possibly empty); nil means an older agent — keep any stored value.
|
|
||||||
if req.Mesh != nil {
|
|
||||||
metadata["mesh"] = req.Mesh
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-registering an existing host updates it in place.
|
// Re-registering an existing host updates it in place.
|
||||||
if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{
|
if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{
|
||||||
Hostname: req.Hostname,
|
Hostname: req.Hostname,
|
||||||
IpAddress: req.IPAddress,
|
IpAddress: req.IPAddress,
|
||||||
}); err == nil {
|
}); err == nil {
|
||||||
if existing.Metadata.Valid && len(existing.Metadata.RawMessage) > 0 {
|
|
||||||
var prev map[string]interface{}
|
|
||||||
if err := json.Unmarshal(existing.Metadata.RawMessage, &prev); err == nil {
|
|
||||||
for k, v := range prev {
|
|
||||||
if _, ok := metadata[k]; !ok {
|
|
||||||
metadata[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
|
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
|
||||||
ID: existing.ID,
|
ID: existing.ID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
@@ -379,7 +358,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
|||||||
Capabilities: rawJSON(req.Capabilities),
|
Capabilities: rawJSON(req.Capabilities),
|
||||||
Resources: existing.Resources,
|
Resources: existing.Resources,
|
||||||
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
Metadata: rawJSON(metadata),
|
Metadata: existing.Metadata,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
||||||
@@ -429,7 +408,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
|||||||
Capabilities: rawJSON(req.Capabilities),
|
Capabilities: rawJSON(req.Capabilities),
|
||||||
Resources: rawJSON(resources),
|
Resources: rawJSON(resources),
|
||||||
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
Metadata: rawJSON(metadata),
|
Metadata: rawJSON(map[string]interface{}{}),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
|
||||||
@@ -510,7 +489,6 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
|
|||||||
Resources: rawJSON(agent.Resources),
|
Resources: rawJSON(agent.Resources),
|
||||||
LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()},
|
LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()},
|
||||||
Metadata: rawJSON(agent.Metadata),
|
Metadata: rawJSON(agent.Metadata),
|
||||||
AutoPrune: agent.AutoPrune,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
||||||
@@ -538,7 +516,6 @@ func applyAgentUpdates(agent *NodeAgent, updates map[string]interface{}) {
|
|||||||
remarshal("capabilities", &agent.Capabilities)
|
remarshal("capabilities", &agent.Capabilities)
|
||||||
remarshal("resources", &agent.Resources)
|
remarshal("resources", &agent.Resources)
|
||||||
remarshal("metadata", &agent.Metadata)
|
remarshal("metadata", &agent.Metadata)
|
||||||
remarshal("auto_prune", &agent.AutoPrune)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteAgent removes an agent
|
// DeleteAgent removes an agent
|
||||||
@@ -826,33 +803,6 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, gin.H{"container": container})
|
c.JSON(http.StatusCreated, gin.H{"container": container})
|
||||||
}
|
}
|
||||||
|
|
||||||
// PruneAgent enqueues a bounded `docker system prune -af` command. Volumes
|
|
||||||
// stay unless the caller explicitly opts in.
|
|
||||||
func (h *NodeAgentHandler) PruneAgent(c *gin.Context) {
|
|
||||||
agentID := c.Param("id")
|
|
||||||
if _, err := h.q.GetAgent(c.Request.Context(), agentID); err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req struct {
|
|
||||||
Until string `json:"until"` // e.g. "168h" — only prune objects older than this
|
|
||||||
Volumes bool `json:"volumes"` // destructive — off by default
|
|
||||||
}
|
|
||||||
_ = c.ShouldBindJSON(&req)
|
|
||||||
|
|
||||||
command, err := h.q.CreateCommand(c.Request.Context(), sqlcdb.CreateCommandParams{
|
|
||||||
ID: uuid.New().String(),
|
|
||||||
Type: "prune",
|
|
||||||
NodeAgentID: agentID,
|
|
||||||
Payload: rawJSON(map[string]interface{}{"until": req.Until, "volumes": req.Volumes}),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enqueue prune"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusAccepted, gin.H{"command": command})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecuteCommand executes a command on an agent
|
// ExecuteCommand executes a command on an agent
|
||||||
func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
|
func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
|
||||||
agentID := c.Param("id")
|
agentID := c.Param("id")
|
||||||
@@ -1264,45 +1214,9 @@ func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
|
|||||||
agents.POST("/heartbeat", h.SendHeartbeat)
|
agents.POST("/heartbeat", h.SendHeartbeat)
|
||||||
agents.GET("/:id/commands", h.GetPendingCommandsForAgent)
|
agents.GET("/:id/commands", h.GetPendingCommandsForAgent)
|
||||||
agents.POST("/:id/commands/:commandId/result", h.CompleteCommand)
|
agents.POST("/:id/commands/:commandId/result", h.CompleteCommand)
|
||||||
|
|
||||||
// Bootstrap surface — the install script and agent binaries are
|
|
||||||
// public; the enroll token is the actual secret.
|
|
||||||
agents.GET("/install.sh", h.ServeInstallScript)
|
|
||||||
agents.GET("/download/:platform", h.ServeAgentBinary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:embed assets/install-agent.sh
|
|
||||||
var agentInstallScript string
|
|
||||||
|
|
||||||
// ServeInstallScript returns the agent bootstrap script. The script
|
|
||||||
// downloads the binary from this instance first, GH releases second.
|
|
||||||
func (h *NodeAgentHandler) ServeInstallScript(c *gin.Context) {
|
|
||||||
c.Data(http.StatusOK, "text/x-sh", []byte(agentInstallScript))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeAgentBinary streams a bundled agent binary. Images ship the
|
|
||||||
// linux binaries under CONTAINR_AGENT_BIN_DIR (default /agents).
|
|
||||||
func (h *NodeAgentHandler) ServeAgentBinary(c *gin.Context) {
|
|
||||||
platform := c.Param("platform")
|
|
||||||
if platform != "linux-amd64" && platform != "linux-arm64" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported platform"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dir := os.Getenv("CONTAINR_AGENT_BIN_DIR")
|
|
||||||
if dir == "" {
|
|
||||||
dir = "/agents"
|
|
||||||
}
|
|
||||||
path := filepath.Join(dir, "containr-agent-"+platform)
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil || info.IsDir() {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "no bundled binary for " + platform})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Header("Content-Disposition", `attachment; filename="containr-agent"`)
|
|
||||||
c.File(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetupAdminRoutes registers dashboard/control agent routes. Command
|
// SetupAdminRoutes registers dashboard/control agent routes. Command
|
||||||
// history and container details can leak sensitive output, so these stay
|
// history and container details can leak sensitive output, so these stay
|
||||||
// admin-only rather than joining the public read group.
|
// admin-only rather than joining the public read group.
|
||||||
@@ -1329,7 +1243,6 @@ func (h *NodeAgentHandler) SetupAdminRoutes(router *gin.RouterGroup) {
|
|||||||
agents.DELETE("/:id/containers/:containerId", h.ContainerAction)
|
agents.DELETE("/:id/containers/:containerId", h.ContainerAction)
|
||||||
|
|
||||||
agents.GET("/:id/metrics", h.GetAgentMetrics)
|
agents.GET("/:id/metrics", h.GetAgentMetrics)
|
||||||
agents.POST("/:id/prune", h.PruneAgent)
|
|
||||||
agents.POST("/:id/commands", h.ExecuteCommand)
|
agents.POST("/:id/commands", h.ExecuteCommand)
|
||||||
agents.GET("/:id/commands", h.GetAgentCommands)
|
agents.GET("/:id/commands", h.GetAgentCommands)
|
||||||
agents.GET("/:id/commands/:commandId", h.GetCommandStatus)
|
agents.GET("/:id/commands/:commandId", h.GetCommandStatus)
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Containr node agent installer.
|
|
||||||
# curl -fsSL <api>/api/agents/install.sh | bash -s -- --url <api> --token <enroll-token>
|
|
||||||
#
|
|
||||||
# One-shot: downloads the agent binary from the Containr instance (or GH
|
|
||||||
# releases as fallback), writes a systemd unit, starts it. SSH is never
|
|
||||||
# needed again — the agent heartbeats and polls commands outbound.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
API_URL=""
|
|
||||||
TOKEN=""
|
|
||||||
AGENT_NAME=""
|
|
||||||
VERSION=""
|
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--url) API_URL="$2"; shift 2 ;;
|
|
||||||
--token) TOKEN="$2"; shift 2 ;;
|
|
||||||
--name) AGENT_NAME="$2"; shift 2 ;;
|
|
||||||
--version) VERSION="$2"; shift 2 ;;
|
|
||||||
*) echo "unknown flag: $1" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
[ -n "$API_URL" ] || { echo "--url is required" >&2; exit 2; }
|
|
||||||
[ -n "$TOKEN" ] || { echo "--token is required (create one: containr nodes tokens create)" >&2; exit 2; }
|
|
||||||
API_URL="${API_URL%/}"
|
|
||||||
API_URL="${API_URL%/api/v1}"
|
|
||||||
API_URL="${API_URL%/api}"
|
|
||||||
|
|
||||||
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
|
||||||
err() { printf '\033[1;31mError:\033[0m %s\n' "$*" >&2; }
|
|
||||||
|
|
||||||
[ "$(id -u)" = "0" ] || { err "run as root (sudo bash -s -- --url … --token …)"; exit 1; }
|
|
||||||
command -v docker >/dev/null 2>&1 || { err "docker not found"; exit 1; }
|
|
||||||
command -v curl >/dev/null 2>&1 || { err "curl not found"; exit 1; }
|
|
||||||
|
|
||||||
ARCH="$(uname -m)"
|
|
||||||
case "$ARCH" in
|
|
||||||
x86_64|amd64) ARCH=amd64 ;;
|
|
||||||
aarch64|arm64) ARCH=arm64 ;;
|
|
||||||
*) err "unsupported architecture: $ARCH"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
BIN=/usr/local/bin/containr-agent
|
|
||||||
info "downloading containr-agent (linux/$ARCH)"
|
|
||||||
if ! curl -fsSL "$API_URL/api/agents/download/linux-$ARCH" -o "$BIN" 2>/dev/null; then
|
|
||||||
info "instance has no bundled binary — trying GitHub releases"
|
|
||||||
REF="${VERSION:-latest}"
|
|
||||||
if [ "$REF" = "latest" ]; then
|
|
||||||
DL="https://github.com/Dvorinka/Containr/releases/latest/download/containr-agent-linux-$ARCH"
|
|
||||||
else
|
|
||||||
DL="https://github.com/Dvorinka/Containr/releases/download/$REF/containr-agent-linux-$ARCH"
|
|
||||||
fi
|
|
||||||
curl -fsSL "$DL" -o "$BIN" || { err "agent download failed"; exit 1; }
|
|
||||||
fi
|
|
||||||
chmod +x "$BIN"
|
|
||||||
|
|
||||||
UNIT=/etc/systemd/system/containr-agent.service
|
|
||||||
info "writing systemd unit"
|
|
||||||
cat > "$UNIT" <<UNIT
|
|
||||||
[Unit]
|
|
||||||
Description=Containr node agent
|
|
||||||
After=docker.service network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
Requires=docker.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Environment=CONTAINR_API_URL=$API_URL
|
|
||||||
Environment=CONTAINR_AGENT_AUTH_TOKEN=$TOKEN
|
|
||||||
Environment=CONTAINR_AGENT_NAME=${AGENT_NAME:-$(hostname)}
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
ExecStart=$BIN
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
UNIT
|
|
||||||
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable --now containr-agent
|
|
||||||
info "agent installed and started — it should appear in Containr within ~15s"
|
|
||||||
@@ -2,7 +2,6 @@ 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"
|
||||||
@@ -1869,22 +1868,14 @@ 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 {
|
||||||
@@ -1895,7 +1886,6 @@ 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,7 +7,6 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -91,24 +90,11 @@ func handleGitWebhookPush(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-push events that still mean "the repo changed": fork-sync bots
|
if !strings.HasPrefix(payload.Ref, "refs/heads/") {
|
||||||
// (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
|
||||||
@@ -147,17 +133,12 @@ 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) {
|
||||||
// Sync-style events may carry no branch — match services on repo only.
|
rows, err := db.Query(`
|
||||||
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 AND (s.git_repo = $2 OR s.git_repo = $3)`, branchClause),
|
WHERE s.git_branch = $1 AND (s.git_repo = $2 OR s.git_repo = $3)`,
|
||||||
branch, cloneURL, fullName)
|
branch, cloneURL, fullName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -194,9 +175,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, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
d.ID, d.ServiceID, fmt.Sprintf("v%d", now.Unix()), d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
|
d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -215,11 +196,7 @@ 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).
|
||||||
branchForBuild := branch
|
webhookReq := CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook", NoCache: true}
|
||||||
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) {
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
// Notification producers — the emitters behind `insertUserNotification`.
|
|
||||||
// Deployment success/failure lives in deployments.go; this file covers
|
|
||||||
// the remaining producers from the roadmap: admin fan-out helper, the
|
|
||||||
// agent offline/online sweep, and the upgrade-available check.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"containr/internal/database"
|
|
||||||
"containr/internal/database/sqlcdb"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Version is stamped at image build time via -ldflags "-X
|
|
||||||
// containr/internal/api.Version=…". "dev" disables the upgrade check.
|
|
||||||
var Version = "dev"
|
|
||||||
|
|
||||||
// notifyAdmins inserts a notification for every admin user. Best-effort.
|
|
||||||
func notifyAdmins(db *database.DB, kind, title, body, resourceType, resourceID string) {
|
|
||||||
if db == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rows, err := db.Query(`SELECT id FROM users WHERE is_admin`)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("containr: notify admins lookup failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var id uuid.UUID
|
|
||||||
if err := rows.Scan(&id); err == nil {
|
|
||||||
insertUserNotification(db, id.String(), kind, title, body, resourceType, resourceID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartAgentHealthSweep marks agents offline when their heartbeat goes
|
|
||||||
// stale (and back online when it resumes), notifying admins on each
|
|
||||||
// transition. Heartbeats keep the row fresh via UpdateAgentHeartbeat.
|
|
||||||
func StartAgentHealthSweep(ctx context.Context, db *database.DB) {
|
|
||||||
const staleAfter = 3 * time.Minute
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(time.Minute)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
reconcileAgentHealth(db, staleAfter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
type agentHealthRow struct {
|
|
||||||
id string
|
|
||||||
name string
|
|
||||||
status string
|
|
||||||
stale bool
|
|
||||||
autoPrune bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) {
|
|
||||||
cutoff := time.Now().Add(-staleAfter)
|
|
||||||
var agents []agentHealthRow
|
|
||||||
rows, err := db.Query(
|
|
||||||
`SELECT id, COALESCE(name, hostname, id::text), COALESCE(status, ''),
|
|
||||||
COALESCE(last_heartbeat < $1, TRUE), auto_prune
|
|
||||||
FROM node_agents`, cutoff)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("containr: agent health sweep: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
|
||||||
var a agentHealthRow
|
|
||||||
if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale, &a.autoPrune); err == nil {
|
|
||||||
agents = append(agents, a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
|
|
||||||
for _, a := range agents {
|
|
||||||
switch {
|
|
||||||
case a.stale && a.status != "offline":
|
|
||||||
if _, err := db.Exec(
|
|
||||||
`UPDATE node_agents SET status = 'offline', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
notifyAdmins(db, "agent", "Node agent offline",
|
|
||||||
fmt.Sprintf("Agent %s missed its heartbeat for over %s.", a.name, staleAfter), "agent", a.id)
|
|
||||||
case !a.stale && a.status == "offline":
|
|
||||||
if _, err := db.Exec(
|
|
||||||
`UPDATE node_agents SET status = 'online', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
notifyAdmins(db, "agent", "Node agent back online",
|
|
||||||
fmt.Sprintf("Agent %s resumed heartbeating.", a.name), "agent", a.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleAutoPrune(db, agents)
|
|
||||||
}
|
|
||||||
|
|
||||||
// scheduleAutoPrune enqueues a `prune` command once per day for agents
|
|
||||||
// with auto_prune enabled. Disk pressure is the most common small-VPS
|
|
||||||
// failure — this is the dflow setServerAutoCleanup analog.
|
|
||||||
func scheduleAutoPrune(db *database.DB, agents []agentHealthRow) {
|
|
||||||
q := sqlcdb.New(db.DB)
|
|
||||||
for _, a := range agents {
|
|
||||||
if !a.autoPrune || a.stale {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
last, err := q.GetLastAgentCommandByType(context.Background(),
|
|
||||||
sqlcdb.GetLastAgentCommandByTypeParams{NodeAgentID: a.id, Type: "prune"})
|
|
||||||
if err == nil && last.CreatedAt.Valid && time.Since(last.CreatedAt.Time) < 24*time.Hour {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, err := q.CreateCommand(context.Background(), sqlcdb.CreateCommandParams{
|
|
||||||
ID: uuid.New().String(),
|
|
||||||
Type: "prune",
|
|
||||||
NodeAgentID: a.id,
|
|
||||||
}); err != nil {
|
|
||||||
log.Printf("containr: auto-prune enqueue failed for agent %s: %v", a.id, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartUpgradeCheck polls GitHub for the latest release once a day and
|
|
||||||
// notifies admins when it is newer than the running version. The last
|
|
||||||
// notified tag is persisted in app_settings so restarts don't re-fire.
|
|
||||||
func StartUpgradeCheck(ctx context.Context, db *database.DB, currentVersion string) {
|
|
||||||
if currentVersion == "" || currentVersion == "dev" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
checkForUpgrade(db, currentVersion)
|
|
||||||
ticker := time.NewTicker(24 * time.Hour)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
checkForUpgrade(db, currentVersion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkForUpgrade(db *database.DB, currentVersion string) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
|
||||||
"https://api.github.com/repos/Dvorinka/Containr/releases/latest", nil)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Header.Set("Accept", "application/vnd.github+json")
|
|
||||||
req.Header.Set("User-Agent", "containr-upgrade-check")
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var release struct {
|
|
||||||
TagName string `json:"tag_name"`
|
|
||||||
HTMLURL string `json:"html_url"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil || release.TagName == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !semverNewer(release.TagName, currentVersion) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastNotified string
|
|
||||||
_ = db.QueryRow(`SELECT value FROM app_settings WHERE key = 'upgrade_notified_tag'`).Scan(&lastNotified)
|
|
||||||
if lastNotified == release.TagName {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
notifyAdmins(db, "upgrade", "Containr update available",
|
|
||||||
fmt.Sprintf("Release %s is available (running %s). %s", release.TagName, currentVersion, release.HTMLURL),
|
|
||||||
"release", release.TagName)
|
|
||||||
_, _ = db.Exec(
|
|
||||||
`INSERT INTO app_settings (key, value, updated_at) VALUES ('upgrade_notified_tag', $1, NOW())
|
|
||||||
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`, release.TagName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// semverNewer reports whether candidate is a higher semver than current.
|
|
||||||
// Non-semver input (e.g. "dev", commit hashes) compares false.
|
|
||||||
func semverNewer(candidate, current string) bool {
|
|
||||||
parse := func(s string) ([]int, bool) {
|
|
||||||
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
|
|
||||||
// Prerelease/build metadata sorts after the same base version
|
|
||||||
// upstream — for "is a newer release available" purposes the base
|
|
||||||
// comparison is what matters.
|
|
||||||
if j := strings.IndexAny(s, "-+"); j >= 0 {
|
|
||||||
s = s[:j]
|
|
||||||
}
|
|
||||||
parts := strings.Split(s, ".")
|
|
||||||
if len(parts) == 0 || len(parts) > 3 {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
out := make([]int, 3)
|
|
||||||
for i, p := range parts {
|
|
||||||
n, err := strconv.Atoi(p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
out[i] = n
|
|
||||||
}
|
|
||||||
return out, true
|
|
||||||
}
|
|
||||||
c, ok1 := parse(candidate)
|
|
||||||
cur, ok2 := parse(current)
|
|
||||||
if !ok1 || !ok2 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
if c[i] != cur[i] {
|
|
||||||
return c[i] > cur[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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,13 +109,6 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@@ -29,27 +29,9 @@ 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: scanner,
|
scanner: security.NewScanner(db),
|
||||||
complianceManager: security.NewComplianceManager(db),
|
complianceManager: security.NewComplianceManager(db),
|
||||||
encryptionManager: encryptionManager,
|
encryptionManager: encryptionManager,
|
||||||
dataRetentionManager: security.NewDataRetentionManager(encryptionManager),
|
dataRetentionManager: security.NewDataRetentionManager(encryptionManager),
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -31,8 +31,6 @@ type ServiceTemplate struct {
|
|||||||
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"`
|
||||||
// Source marks catalog origin: "" (local/official) or "registry".
|
|
||||||
Source string `json:"source,omitempty" db:"-"`
|
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -99,24 +97,10 @@ 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})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,11 +112,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -211,19 +190,12 @@ 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) {
|
||||||
// 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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
templateRow = row
|
|
||||||
} else {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
template := mapSQLCTemplate(templateRow)
|
template := mapSQLCTemplate(templateRow)
|
||||||
if template.ID == "" || !templateVisibleTo(c, template) {
|
if template.ID == "" || !templateVisibleTo(c, template) {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
@@ -42,13 +44,20 @@ var ShellCmd = &cobra.Command{
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
defer term.Restore(fd, oldState)
|
defer term.Restore(fd, oldState)
|
||||||
}
|
}
|
||||||
sendSize := func() {
|
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 {
|
if w, h, err := term.GetSize(fd); err == nil {
|
||||||
_ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h})
|
_ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sendSize()
|
}()
|
||||||
defer watchResize(sendSize)()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package commands
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
)
|
|
||||||
|
|
||||||
// watchResize forwards SIGWINCH events to send. Returns a stop func.
|
|
||||||
func watchResize(send func()) func() {
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigCh, syscall.SIGWINCH)
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-sigCh:
|
|
||||||
send()
|
|
||||||
case <-done:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return func() {
|
|
||||||
signal.Stop(sigCh)
|
|
||||||
close(done)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package commands
|
|
||||||
|
|
||||||
// watchResize is a no-op on Windows — console resize events don't map to a
|
|
||||||
// signal. The initial size is still sent, and Windows Terminal resize is rare
|
|
||||||
// enough to accept stale dimensions for now.
|
|
||||||
func watchResize(send func()) func() { return func() {} }
|
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
package commands
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
const railwayAPI = "https://backboard.railway.com/graphql/v2"
|
|
||||||
|
|
||||||
// railwaySource mirrors the Railway serviceInstance.source union.
|
|
||||||
type railwaySource struct {
|
|
||||||
Repo string `json:"repo"`
|
|
||||||
Image string `json:"image"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type railwayServicePlan struct {
|
|
||||||
ID string
|
|
||||||
Name string
|
|
||||||
Source railwaySource
|
|
||||||
Command string
|
|
||||||
Replicas int
|
|
||||||
Domains []string
|
|
||||||
Variables map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// MigrateCmd imports workloads from other platforms.
|
|
||||||
var MigrateCmd = &cobra.Command{
|
|
||||||
Use: "migrate",
|
|
||||||
Short: "Import workloads from other platforms",
|
|
||||||
}
|
|
||||||
|
|
||||||
var migrateRailwayCmd = &cobra.Command{
|
|
||||||
Use: "railway",
|
|
||||||
Short: "Migrate a Railway project into Containr",
|
|
||||||
Long: `Pulls a Railway project's services, variables, and domains via the
|
|
||||||
public GraphQL API and recreates them in Containr.
|
|
||||||
|
|
||||||
containr migrate railway --token <railway-token> --project <id> --dry-run
|
|
||||||
containr migrate railway --token <railway-token> --project <id> --into <project-id>
|
|
||||||
|
|
||||||
Token: account/workspace token (Authorization: Bearer) or a project token.
|
|
||||||
--into defaults to creating a project named after the Railway project.`,
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
token, _ := cmd.Flags().GetString("token")
|
|
||||||
if token == "" {
|
|
||||||
token = os.Getenv("RAILWAY_TOKEN")
|
|
||||||
}
|
|
||||||
if token == "" {
|
|
||||||
return &APIError{Message: "pass --token or set RAILWAY_TOKEN", ExitCode: ExitError}
|
|
||||||
}
|
|
||||||
projectID, _ := cmd.Flags().GetString("project")
|
|
||||||
envName, _ := cmd.Flags().GetString("environment")
|
|
||||||
into, _ := cmd.Flags().GetString("into")
|
|
||||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
|
||||||
|
|
||||||
projectName, envID, services, err := railwayProject(projectID, envName, token)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(services) == 0 {
|
|
||||||
return &APIError{Message: "no services found in that Railway project/environment", ExitCode: ExitError}
|
|
||||||
}
|
|
||||||
|
|
||||||
plans := make([]railwayServicePlan, 0, len(services))
|
|
||||||
for _, svc := range services {
|
|
||||||
plan, err := railwayServiceDetail(svc, envID, token)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "warning: %s: %v — skipping\n", svc["name"], err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
plans = append(plans, plan)
|
|
||||||
}
|
|
||||||
if len(plans) == 0 {
|
|
||||||
return &APIError{Message: "no migratable services", ExitCode: ExitError}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Railway project %q → %d service(s)\n", projectName, len(plans))
|
|
||||||
for _, p := range plans {
|
|
||||||
src := p.Source.Image
|
|
||||||
if src == "" {
|
|
||||||
src = railwayRepoURL(p.Source.Repo)
|
|
||||||
}
|
|
||||||
fmt.Printf(" %s src=%s replicas=%d vars=%d domains=%d\n",
|
|
||||||
p.Name, src, max(p.Replicas, 1), len(p.Variables), len(p.Domains))
|
|
||||||
}
|
|
||||||
|
|
||||||
if dryRun {
|
|
||||||
fmt.Println("\nDry run — pass without --dry-run to create these resources.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
target := into
|
|
||||||
if target == "" {
|
|
||||||
data, err := c.Do("POST", "/projects", map[string]interface{}{
|
|
||||||
"name": projectName,
|
|
||||||
"description": "Imported from Railway",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
p, _ := unwrapObject(data, "project")
|
|
||||||
target = str(p, "id")
|
|
||||||
if target == "" || target == "-" {
|
|
||||||
return &APIError{Message: "project created but id missing from response", ExitCode: ExitError}
|
|
||||||
}
|
|
||||||
fmt.Printf("Created project %s (%s)\n", projectName, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range plans {
|
|
||||||
body := map[string]interface{}{
|
|
||||||
"name": p.Name,
|
|
||||||
"type": "web",
|
|
||||||
"environment": "production",
|
|
||||||
}
|
|
||||||
if p.Source.Image != "" {
|
|
||||||
body["image"] = p.Source.Image
|
|
||||||
} else if repo := railwayRepoURL(p.Source.Repo); repo != "" {
|
|
||||||
body["git_repo"] = repo
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(os.Stderr, "warning: %s has no source — skipping\n", p.Name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if p.Command != "" {
|
|
||||||
body["command"] = p.Command
|
|
||||||
}
|
|
||||||
if p.Replicas > 1 {
|
|
||||||
body["replicas"] = p.Replicas
|
|
||||||
}
|
|
||||||
data, err := c.Do("POST", "/projects/"+target+"/services", body)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "error: create %s: %v\n", p.Name, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
svc, _ := unwrapObject(data, "service")
|
|
||||||
svcID := str(svc, "id")
|
|
||||||
fmt.Printf(" created service %s (%s)\n", p.Name, svcID)
|
|
||||||
|
|
||||||
if len(p.Variables) > 0 && svcID != "" && svcID != "-" {
|
|
||||||
vars := make([]map[string]interface{}, 0, len(p.Variables))
|
|
||||||
for k, v := range p.Variables {
|
|
||||||
vars = append(vars, map[string]interface{}{"key": k, "value": v, "is_secret": false})
|
|
||||||
}
|
|
||||||
if _, err := c.Do("PUT", "/services/"+svcID+"/variables",
|
|
||||||
map[string]interface{}{"variables": vars}); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, " warning: variables for %s: %v\n", p.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, domain := range p.Domains {
|
|
||||||
if svcID == "" || svcID == "-" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if _, err := c.Do("POST", "/services/"+svcID+"/domains",
|
|
||||||
map[string]interface{}{"domain": domain}); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, " warning: domain %s: %v\n", domain, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Println("Done. Railway deploy triggers are not migrated — redeploy each service when ready.")
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// railwayGraphQL posts a query; Railway returns 200 with an errors array on
|
|
||||||
// partial failures — treat errors as fatal, the migration needs clean data.
|
|
||||||
func railwayGraphQL(token, query string, variables map[string]interface{}) (map[string]interface{}, error) {
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{"query": query, "variables": variables})
|
|
||||||
req, err := http.NewRequest(http.MethodPost, railwayAPI, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
// Project tokens use a dedicated header; account/workspace tokens use Bearer.
|
|
||||||
if strings.HasPrefix(token, "rw_pat") || strings.HasPrefix(token, "rw_pt") {
|
|
||||||
req.Header.Set("Project-Access-Token", token)
|
|
||||||
} else {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
}
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
|
||||||
var out struct {
|
|
||||||
Data map[string]interface{} `json:"data"`
|
|
||||||
Errors []struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
} `json:"errors"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
|
||||||
return nil, fmt.Errorf("railway response not JSON (status %d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if len(out.Errors) > 0 {
|
|
||||||
return nil, fmt.Errorf("railway: %s", out.Errors[0].Message)
|
|
||||||
}
|
|
||||||
if resp.StatusCode >= 300 {
|
|
||||||
return nil, fmt.Errorf("railway status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return out.Data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// railwayProject resolves the project (by id, or sole accessible project) and
|
|
||||||
// returns its name, the target environment id, and its services.
|
|
||||||
func railwayProject(projectID, envName, token string) (string, string, []map[string]interface{}, error) {
|
|
||||||
if projectID == "" {
|
|
||||||
data, err := railwayGraphQL(token,
|
|
||||||
`query { projects(first: 100) { edges { node { id name } } } }`, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", nil, err
|
|
||||||
}
|
|
||||||
projects := edgesOf(data, "projects")
|
|
||||||
if len(projects) == 1 {
|
|
||||||
projectID = str(projects[0], "id")
|
|
||||||
} else {
|
|
||||||
for _, p := range projects {
|
|
||||||
fmt.Fprintf(os.Stderr, " %s %s\n", str(p, "id"), str(p, "name"))
|
|
||||||
}
|
|
||||||
return "", "", nil, &APIError{
|
|
||||||
Message: "multiple projects — pass --project <id> (listed above)",
|
|
||||||
ExitCode: ExitError,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := railwayGraphQL(token, `query ($id: String!) {
|
|
||||||
project(id: $id) {
|
|
||||||
name
|
|
||||||
environments { edges { node { id name } } }
|
|
||||||
services { edges { node { id name } } }
|
|
||||||
}
|
|
||||||
}`, map[string]interface{}{"id": projectID})
|
|
||||||
if err != nil {
|
|
||||||
return "", "", nil, err
|
|
||||||
}
|
|
||||||
project, _ := data["project"].(map[string]interface{})
|
|
||||||
if project == nil {
|
|
||||||
return "", "", nil, &APIError{Message: "railway project not found", ExitCode: ExitNotFound}
|
|
||||||
}
|
|
||||||
envs := edgesOf(project, "environments")
|
|
||||||
envID := ""
|
|
||||||
for _, e := range envs {
|
|
||||||
if str(e, "name") == envName {
|
|
||||||
envID = str(e, "id")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if envID == "" && len(envs) == 1 {
|
|
||||||
envID = str(envs[0], "id")
|
|
||||||
}
|
|
||||||
if envID == "" {
|
|
||||||
names := make([]string, 0, len(envs))
|
|
||||||
for _, e := range envs {
|
|
||||||
names = append(names, str(e, "name"))
|
|
||||||
}
|
|
||||||
return "", "", nil, &APIError{
|
|
||||||
Message: fmt.Sprintf("environment %q not found (have: %s)", envName, strings.Join(names, ", ")),
|
|
||||||
ExitCode: ExitError,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return str(project, "name"), envID, edgesOf(project, "services"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// railwayServiceDetail pulls source, command, replicas, domains, and
|
|
||||||
// variables for one service in the target environment.
|
|
||||||
func railwayServiceDetail(svc map[string]interface{}, envID, token string) (railwayServicePlan, error) {
|
|
||||||
plan := railwayServicePlan{
|
|
||||||
ID: str(svc, "id"),
|
|
||||||
Name: str(svc, "name"),
|
|
||||||
}
|
|
||||||
data, err := railwayGraphQL(token, `query ($sid: String!, $eid: String!) {
|
|
||||||
serviceInstance(serviceId: $sid, environmentId: $eid) {
|
|
||||||
startCommand
|
|
||||||
numReplicas
|
|
||||||
source { repo image }
|
|
||||||
domains { serviceDomains { domain } customDomains { domain } }
|
|
||||||
}
|
|
||||||
}`, map[string]interface{}{"sid": plan.ID, "eid": envID})
|
|
||||||
if err != nil {
|
|
||||||
return plan, err
|
|
||||||
}
|
|
||||||
inst, _ := data["serviceInstance"].(map[string]interface{})
|
|
||||||
if inst == nil {
|
|
||||||
return plan, fmt.Errorf("no service instance in this environment")
|
|
||||||
}
|
|
||||||
if src, ok := inst["source"].(map[string]interface{}); ok {
|
|
||||||
plan.Source = railwaySource{Repo: str(src, "repo"), Image: str(src, "image")}
|
|
||||||
}
|
|
||||||
if cmd, ok := inst["startCommand"].(string); ok {
|
|
||||||
plan.Command = cmd
|
|
||||||
}
|
|
||||||
if n, ok := inst["numReplicas"].(float64); ok {
|
|
||||||
plan.Replicas = int(n)
|
|
||||||
}
|
|
||||||
if domains, ok := inst["domains"].(map[string]interface{}); ok {
|
|
||||||
for _, group := range []string{"serviceDomains", "customDomains"} {
|
|
||||||
if list, ok := domains[group].([]interface{}); ok {
|
|
||||||
for _, d := range list {
|
|
||||||
if m, ok := d.(map[string]interface{}); ok {
|
|
||||||
if dom := str(m, "domain"); dom != "" && dom != "-" {
|
|
||||||
plan.Domains = append(plan.Domains, dom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
vdata, err := railwayGraphQL(token, `query ($sid: String!, $eid: String!) {
|
|
||||||
variables(serviceId: $sid, environmentId: $eid)
|
|
||||||
}`, map[string]interface{}{"sid": plan.ID, "eid": envID})
|
|
||||||
if err == nil {
|
|
||||||
if vars, ok := vdata["variables"].(map[string]interface{}); ok {
|
|
||||||
plan.Variables = map[string]string{}
|
|
||||||
for k, v := range vars {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
// Railway reference variables (${{…}}) resolve at deploy
|
|
||||||
// time — carry them literally so they stay visible.
|
|
||||||
plan.Variables[k] = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return plan, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// railwayRepoURL normalizes Railway's "owner/repo" to a cloneable https URL.
|
|
||||||
func railwayRepoURL(repo string) string {
|
|
||||||
repo = strings.TrimSpace(repo)
|
|
||||||
if repo == "" || repo == "-" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if strings.Contains(repo, "://") || strings.HasPrefix(repo, "git@") {
|
|
||||||
return repo
|
|
||||||
}
|
|
||||||
return "https://github.com/" + repo
|
|
||||||
}
|
|
||||||
|
|
||||||
func edgesOf(obj map[string]interface{}, key string) []map[string]interface{} {
|
|
||||||
conn, _ := obj[key].(map[string]interface{})
|
|
||||||
edges, _ := conn["edges"].([]interface{})
|
|
||||||
out := make([]map[string]interface{}, 0, len(edges))
|
|
||||||
for _, e := range edges {
|
|
||||||
if m, ok := e.(map[string]interface{}); ok {
|
|
||||||
if node, ok := m["node"].(map[string]interface{}); ok {
|
|
||||||
out = append(out, node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
migrateRailwayCmd.Flags().String("token", "", "Railway API token (or RAILWAY_TOKEN env)")
|
|
||||||
migrateRailwayCmd.Flags().String("project", "", "Railway project id (lists yours when omitted and >1 exists)")
|
|
||||||
migrateRailwayCmd.Flags().String("environment", "production", "Railway environment name")
|
|
||||||
migrateRailwayCmd.Flags().String("into", "", "existing Containr project id (default: create one)")
|
|
||||||
migrateRailwayCmd.Flags().Bool("dry-run", false, "print the plan without creating anything")
|
|
||||||
MigrateCmd.AddCommand(migrateRailwayCmd)
|
|
||||||
}
|
|
||||||
@@ -1,441 +0,0 @@
|
|||||||
package commands
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// dflow runs on Payload CMS — every collection is a plain REST resource under
|
|
||||||
// /api/<slug> returning {docs, page, totalPages, hasNextPage}. Auth is a
|
|
||||||
// Payload API key on the users collection: "Authorization: users API-Key <k>".
|
|
||||||
|
|
||||||
type dflowServicePlan struct {
|
|
||||||
ID string
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
Type string // web | database
|
|
||||||
Image string
|
|
||||||
GitRepo string
|
|
||||||
GitBranch string
|
|
||||||
BuildPath string
|
|
||||||
Port int
|
|
||||||
Variables map[string]string
|
|
||||||
Domains []string
|
|
||||||
Volumes []map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
var migrateDflowCmd = &cobra.Command{
|
|
||||||
Use: "dflow",
|
|
||||||
Short: "Migrate a dflow (Payload CMS) project into Containr",
|
|
||||||
Long: `Pulls a dflow project's services, variables, volumes, and domains via the
|
|
||||||
Payload REST API and recreates them in Containr.
|
|
||||||
|
|
||||||
containr migrate dflow --url https://dflow.example.com --api-key <key> --dry-run
|
|
||||||
containr migrate dflow --url https://dflow.example.com --api-key <key> --project <id>
|
|
||||||
|
|
||||||
Auth: a Payload API key from a dflow admin user (users API-Key). Encrypted
|
|
||||||
fields (variables, git tokens) only come back decrypted if the API key belongs
|
|
||||||
to a user with access — empty values are flagged so nothing is silently lost.`,
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
base, _ := cmd.Flags().GetString("url")
|
|
||||||
if base == "" {
|
|
||||||
base = os.Getenv("DFLOW_URL")
|
|
||||||
}
|
|
||||||
if base == "" {
|
|
||||||
return &APIError{Message: "pass --url or set DFLOW_URL", ExitCode: ExitError}
|
|
||||||
}
|
|
||||||
base = strings.TrimSuffix(base, "/")
|
|
||||||
key, _ := cmd.Flags().GetString("api-key")
|
|
||||||
if key == "" {
|
|
||||||
key = os.Getenv("DFLOW_API_KEY")
|
|
||||||
}
|
|
||||||
if key == "" {
|
|
||||||
return &APIError{Message: "pass --api-key or set DFLOW_API_KEY", ExitCode: ExitError}
|
|
||||||
}
|
|
||||||
projectID, _ := cmd.Flags().GetString("project")
|
|
||||||
into, _ := cmd.Flags().GetString("into")
|
|
||||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
|
||||||
|
|
||||||
projectName, services, err := dflowProject(base, key, projectID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(services) == 0 {
|
|
||||||
return &APIError{Message: "no services found in that dflow project", ExitCode: ExitError}
|
|
||||||
}
|
|
||||||
|
|
||||||
plans := make([]dflowServicePlan, 0, len(services))
|
|
||||||
for _, svc := range services {
|
|
||||||
plan, warn := dflowServicePlanOf(svc)
|
|
||||||
for _, w := range warn {
|
|
||||||
fmt.Fprintf(os.Stderr, " warning: %s: %s\n", plan.Name, w)
|
|
||||||
}
|
|
||||||
plans = append(plans, plan)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("dflow project %q → %d service(s)\n", projectName, len(plans))
|
|
||||||
for _, p := range plans {
|
|
||||||
src := p.Image
|
|
||||||
if src == "" {
|
|
||||||
src = p.GitRepo
|
|
||||||
}
|
|
||||||
fmt.Printf(" %s type=%s src=%s vars=%d domains=%d volumes=%d\n",
|
|
||||||
p.Name, p.Type, src, len(p.Variables), len(p.Domains), len(p.Volumes))
|
|
||||||
}
|
|
||||||
|
|
||||||
if dryRun {
|
|
||||||
fmt.Println("\nDry run — pass without --dry-run to create these resources.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
target := into
|
|
||||||
if target == "" {
|
|
||||||
data, err := c.Do("POST", "/projects", map[string]interface{}{
|
|
||||||
"name": projectName,
|
|
||||||
"description": "Imported from dflow",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
p, _ := unwrapObject(data, "project")
|
|
||||||
target = str(p, "id")
|
|
||||||
if target == "" || target == "-" {
|
|
||||||
return &APIError{Message: "project created but id missing from response", ExitCode: ExitError}
|
|
||||||
}
|
|
||||||
fmt.Printf("Created project %s (%s)\n", projectName, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range plans {
|
|
||||||
body := map[string]interface{}{
|
|
||||||
"name": p.Name,
|
|
||||||
"type": p.Type,
|
|
||||||
"environment": "production",
|
|
||||||
}
|
|
||||||
if p.Image != "" {
|
|
||||||
body["image"] = p.Image
|
|
||||||
}
|
|
||||||
if p.GitRepo != "" {
|
|
||||||
body["git_repo"] = p.GitRepo
|
|
||||||
}
|
|
||||||
if p.GitBranch != "" {
|
|
||||||
body["git_branch"] = p.GitBranch
|
|
||||||
}
|
|
||||||
if p.BuildPath != "" {
|
|
||||||
body["build_path"] = p.BuildPath
|
|
||||||
}
|
|
||||||
if p.Port > 0 {
|
|
||||||
body["port"] = p.Port
|
|
||||||
}
|
|
||||||
if len(p.Volumes) > 0 {
|
|
||||||
vols := make([]map[string]interface{}, 0, len(p.Volumes))
|
|
||||||
for _, v := range p.Volumes {
|
|
||||||
vols = append(vols, map[string]interface{}{
|
|
||||||
"type": "bind", "source": v["source"], "target": v["target"],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
body["volumes"] = vols
|
|
||||||
}
|
|
||||||
data, err := c.Do("POST", "/projects/"+target+"/services", body)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "error: create %s: %v\n", p.Name, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
svc, _ := unwrapObject(data, "service")
|
|
||||||
svcID := str(svc, "id")
|
|
||||||
fmt.Printf(" created service %s (%s)\n", p.Name, svcID)
|
|
||||||
|
|
||||||
if len(p.Variables) > 0 && svcID != "" && svcID != "-" {
|
|
||||||
vars := make([]map[string]interface{}, 0, len(p.Variables))
|
|
||||||
for k, v := range p.Variables {
|
|
||||||
vars = append(vars, map[string]interface{}{"key": k, "value": v, "is_secret": false})
|
|
||||||
}
|
|
||||||
if _, err := c.Do("PUT", "/services/"+svcID+"/variables",
|
|
||||||
map[string]interface{}{"variables": vars}); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, " warning: variables for %s: %v\n", p.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, domain := range p.Domains {
|
|
||||||
if svcID == "" || svcID == "-" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if _, err := c.Do("POST", "/services/"+svcID+"/domains",
|
|
||||||
map[string]interface{}{"domain": domain}); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, " warning: domain %s: %v\n", domain, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Println("Done. Git credentials, deployment history, and server topology are not migrated — reconnect repos and redeploy each service when ready.")
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// dflowGet performs an authenticated Payload REST call.
|
|
||||||
func dflowGet(base, apiKey, path string, q url.Values) (map[string]interface{}, error) {
|
|
||||||
u := base + "/api/" + path
|
|
||||||
if len(q) > 0 {
|
|
||||||
u += "?" + q.Encode()
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "users API-Key "+apiKey)
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
|
||||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
|
||||||
return nil, &APIError{Message: "dflow rejected the API key — needs an admin user's users API-Key", ExitCode: ExitAuth}
|
|
||||||
}
|
|
||||||
var out map[string]interface{}
|
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
|
||||||
return nil, fmt.Errorf("dflow response not JSON (status %d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if resp.StatusCode >= 300 {
|
|
||||||
return nil, fmt.Errorf("dflow status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// dflowCollection pages through a Payload collection.
|
|
||||||
func dflowCollection(base, apiKey, collection string, q url.Values) ([]map[string]interface{}, error) {
|
|
||||||
var docs []map[string]interface{}
|
|
||||||
for page := 1; ; page++ {
|
|
||||||
params := url.Values{}
|
|
||||||
for k, v := range q {
|
|
||||||
params[k] = v
|
|
||||||
}
|
|
||||||
params.Set("limit", "100")
|
|
||||||
params.Set("depth", "0")
|
|
||||||
params.Set("page", fmt.Sprint(page))
|
|
||||||
data, err := dflowGet(base, apiKey, collection, params)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
list, _ := data["docs"].([]interface{})
|
|
||||||
for _, d := range list {
|
|
||||||
if m, ok := d.(map[string]interface{}); ok {
|
|
||||||
docs = append(docs, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
next, _ := data["hasNextPage"].(bool)
|
|
||||||
if !next {
|
|
||||||
return docs, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dflowProject resolves the project (by id, or sole project) and returns its
|
|
||||||
// name plus its services.
|
|
||||||
func dflowProject(base, apiKey, projectID string) (string, []map[string]interface{}, error) {
|
|
||||||
if projectID == "" {
|
|
||||||
projects, err := dflowCollection(base, apiKey, "projects", nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
if len(projects) == 1 {
|
|
||||||
projectID = str(projects[0], "id")
|
|
||||||
} else {
|
|
||||||
for _, p := range projects {
|
|
||||||
fmt.Fprintf(os.Stderr, " %s %s\n", str(p, "id"), str(p, "name"))
|
|
||||||
}
|
|
||||||
return "", nil, &APIError{
|
|
||||||
Message: "multiple projects — pass --project <id> (listed above)",
|
|
||||||
ExitCode: ExitError,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
projects, err := dflowCollection(base, apiKey, "projects",
|
|
||||||
url.Values{"where[id][equals]": {projectID}})
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
if len(projects) == 0 {
|
|
||||||
return "", nil, &APIError{Message: "dflow project not found", ExitCode: ExitNotFound}
|
|
||||||
}
|
|
||||||
name := str(projects[0], "name")
|
|
||||||
|
|
||||||
q := url.Values{
|
|
||||||
"where[project][equals]": {projectID},
|
|
||||||
"where[deletedAt][exists]": {"false"},
|
|
||||||
"where[_status][not_equals]": {"draft"},
|
|
||||||
}
|
|
||||||
services, err := dflowCollection(base, apiKey, "services", q)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
return name, services, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// dflowServicePlanOf maps a Payload service doc onto a Containr create plan.
|
|
||||||
func dflowServicePlanOf(svc map[string]interface{}) (dflowServicePlan, []string) {
|
|
||||||
var warn []string
|
|
||||||
plan := dflowServicePlan{
|
|
||||||
ID: str(svc, "id"),
|
|
||||||
Name: str(svc, "name"),
|
|
||||||
Description: str(svc, "description"),
|
|
||||||
Variables: map[string]string{},
|
|
||||||
}
|
|
||||||
switch str(svc, "type") {
|
|
||||||
case "app":
|
|
||||||
plan.Type = "web"
|
|
||||||
settings := dflowProviderSettings(svc)
|
|
||||||
plan.GitRepo = dflowRepoURL(str(svc, "providerType"), str(settings, "owner"), str(settings, "repository"))
|
|
||||||
plan.GitBranch = str(settings, "branch")
|
|
||||||
plan.BuildPath = str(settings, "buildPath")
|
|
||||||
plan.Port = int(num(settings, "port"))
|
|
||||||
if plan.GitRepo == "" {
|
|
||||||
warn = append(warn, "no repository settings — importing with no source")
|
|
||||||
}
|
|
||||||
case "docker":
|
|
||||||
plan.Type = "web"
|
|
||||||
d, _ := svc["dockerDetails"].(map[string]interface{})
|
|
||||||
plan.Image = normalizeDockerImage(str(d, "url"))
|
|
||||||
for _, p := range listOf(d, "ports") {
|
|
||||||
if plan.Port == 0 {
|
|
||||||
plan.Port = int(num(p, "containerPort"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if plan.Image == "" {
|
|
||||||
warn = append(warn, "no docker image url — importing with no source")
|
|
||||||
}
|
|
||||||
case "database":
|
|
||||||
plan.Type = "database"
|
|
||||||
d, _ := svc["databaseDetails"].(map[string]interface{})
|
|
||||||
dbType := str(d, "type")
|
|
||||||
version := str(d, "version")
|
|
||||||
if dbType != "" {
|
|
||||||
plan.Image = dbType + ":" + firstOf(version, "latest")
|
|
||||||
}
|
|
||||||
if str(d, "provider") == "external" {
|
|
||||||
if conn := str(d, "connectionUrl"); conn != "" {
|
|
||||||
plan.Variables["DATABASE_URL"] = conn
|
|
||||||
} else if str(d, "host") != "" {
|
|
||||||
warn = append(warn, "external database has host but no connectionUrl — set DATABASE_URL manually")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
plan.Type = "web"
|
|
||||||
warn = append(warn, fmt.Sprintf("unknown type %q — imported as web", str(svc, "type")))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, v := range listOf(svc, "variables") {
|
|
||||||
k, val := str(v, "key"), str(v, "value")
|
|
||||||
if k == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if val == "" {
|
|
||||||
warn = append(warn, fmt.Sprintf("variable %s came back empty (encrypted field?)", k))
|
|
||||||
}
|
|
||||||
plan.Variables[k] = val
|
|
||||||
}
|
|
||||||
for _, d := range listOf(svc, "domains") {
|
|
||||||
if dom := str(d, "domain"); dom != "" && dom != "-" {
|
|
||||||
plan.Domains = append(plan.Domains, dom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, v := range listOf(svc, "volumes") {
|
|
||||||
host, container := str(v, "hostPath"), str(v, "containerPath")
|
|
||||||
if host != "" && container != "" {
|
|
||||||
plan.Volumes = append(plan.Volumes, map[string]string{"source": host, "target": container})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return plan, warn
|
|
||||||
}
|
|
||||||
|
|
||||||
// dflowProviderSettings picks the populated <provider>Settings group.
|
|
||||||
func dflowProviderSettings(svc map[string]interface{}) map[string]interface{} {
|
|
||||||
for _, key := range []string{"githubSettings", "gitlabSettings", "bitbucketSettings", "giteaSettings", "azureSettings"} {
|
|
||||||
if g, ok := svc[key].(map[string]interface{}); ok && str(g, "repository") != "" {
|
|
||||||
return g
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// dflowRepoURL builds a cloneable https URL for the known git providers.
|
|
||||||
func dflowRepoURL(providerType, owner, repo string) string {
|
|
||||||
repo = strings.TrimSuffix(strings.TrimSpace(repo), ".git")
|
|
||||||
owner = strings.TrimSpace(owner)
|
|
||||||
if repo == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if strings.Contains(repo, "://") || strings.HasPrefix(repo, "git@") {
|
|
||||||
return repo
|
|
||||||
}
|
|
||||||
switch providerType {
|
|
||||||
case "gitlab":
|
|
||||||
return "https://gitlab.com/" + owner + "/" + repo
|
|
||||||
case "bitbucket":
|
|
||||||
return "https://bitbucket.org/" + owner + "/" + repo
|
|
||||||
case "azureDevOps":
|
|
||||||
return "https://dev.azure.com/" + owner + "/_git/" + repo
|
|
||||||
case "gitea":
|
|
||||||
return "" // host unknown — user must reconnect the repo
|
|
||||||
default:
|
|
||||||
if owner == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return "https://github.com/" + owner + "/" + repo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeDockerImage fixes common dflow registry URL typos (ghrc://).
|
|
||||||
func normalizeDockerImage(image string) string {
|
|
||||||
image = strings.TrimSpace(image)
|
|
||||||
if strings.HasPrefix(image, "ghrc://") {
|
|
||||||
return "ghcr.io/" + strings.TrimPrefix(image, "ghrc://")
|
|
||||||
}
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
|
|
||||||
func listOf(m map[string]interface{}, key string) []map[string]interface{} {
|
|
||||||
raw, _ := m[key].([]interface{})
|
|
||||||
out := make([]map[string]interface{}, 0, len(raw))
|
|
||||||
for _, item := range raw {
|
|
||||||
if v, ok := item.(map[string]interface{}); ok {
|
|
||||||
out = append(out, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func num(m map[string]interface{}, key string) float64 {
|
|
||||||
v, _ := m[key].(float64)
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstOf(v, fallback string) string {
|
|
||||||
if v == "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
migrateDflowCmd.Flags().String("url", "", "dflow base URL (or DFLOW_URL env)")
|
|
||||||
migrateDflowCmd.Flags().String("api-key", "", "dflow admin API key (or DFLOW_API_KEY env)")
|
|
||||||
migrateDflowCmd.Flags().String("project", "", "dflow project id (lists yours when omitted and >1 exists)")
|
|
||||||
migrateDflowCmd.Flags().String("into", "", "existing Containr project id (default: create one)")
|
|
||||||
migrateDflowCmd.Flags().Bool("dry-run", false, "print the plan without creating anything")
|
|
||||||
MigrateCmd.AddCommand(migrateDflowCmd)
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
package commands
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestRailwayRepoURL(t *testing.T) {
|
|
||||||
cases := map[string]string{
|
|
||||||
"owner/repo": "https://github.com/owner/repo",
|
|
||||||
"https://gitlab.com/a/b": "https://gitlab.com/a/b",
|
|
||||||
"[email protected]:a/b.git": "[email protected]:a/b.git",
|
|
||||||
"": "",
|
|
||||||
"-": "",
|
|
||||||
" spaced/x ": "https://github.com/spaced/x",
|
|
||||||
}
|
|
||||||
for in, want := range cases {
|
|
||||||
if got := railwayRepoURL(in); got != want {
|
|
||||||
t.Errorf("railwayRepoURL(%q) = %q, want %q", in, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEdgesOf(t *testing.T) {
|
|
||||||
obj := map[string]interface{}{
|
|
||||||
"services": map[string]interface{}{
|
|
||||||
"edges": []interface{}{
|
|
||||||
map[string]interface{}{"node": map[string]interface{}{"id": "a", "name": "web"}},
|
|
||||||
map[string]interface{}{"node": map[string]interface{}{"id": "b", "name": "api"}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
nodes := edgesOf(obj, "services")
|
|
||||||
if len(nodes) != 2 || str(nodes[0], "name") != "web" {
|
|
||||||
t.Fatalf("edgesOf returned %+v", nodes)
|
|
||||||
}
|
|
||||||
if got := edgesOf(obj, "missing"); len(got) != 0 {
|
|
||||||
t.Fatalf("edgesOf(missing) = %+v, want empty", got)
|
|
||||||
}
|
|
||||||
if got := edgesOf(map[string]interface{}{"services": nil}, "services"); len(got) != 0 {
|
|
||||||
t.Fatalf("edgesOf(nil conn) = %+v, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDflowServicePlanOf(t *testing.T) {
|
|
||||||
appSvc := map[string]interface{}{
|
|
||||||
"id": "s1",
|
|
||||||
"name": "api",
|
|
||||||
"type": "app",
|
|
||||||
"providerType": "github",
|
|
||||||
"githubSettings": map[string]interface{}{
|
|
||||||
"owner": "acme", "repository": "api", "branch": "main",
|
|
||||||
"buildPath": "/", "port": float64(8080),
|
|
||||||
},
|
|
||||||
"variables": []interface{}{
|
|
||||||
map[string]interface{}{"key": "FOO", "value": "bar"},
|
|
||||||
map[string]interface{}{"key": "EMPTY", "value": ""},
|
|
||||||
},
|
|
||||||
"domains": []interface{}{
|
|
||||||
map[string]interface{}{"domain": "api.example.com"},
|
|
||||||
},
|
|
||||||
"volumes": []interface{}{
|
|
||||||
map[string]interface{}{"hostPath": "/data", "containerPath": "/app/data"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
plan, warn := dflowServicePlanOf(appSvc)
|
|
||||||
if plan.Type != "web" || plan.GitRepo != "https://github.com/acme/api" || plan.Port != 8080 {
|
|
||||||
t.Fatalf("app plan = %+v", plan)
|
|
||||||
}
|
|
||||||
if plan.Variables["FOO"] != "bar" || len(plan.Domains) != 1 || len(plan.Volumes) != 1 {
|
|
||||||
t.Fatalf("plan detail = %+v", plan)
|
|
||||||
}
|
|
||||||
if len(warn) != 1 { // EMPTY value flagged
|
|
||||||
t.Fatalf("warnings = %v", warn)
|
|
||||||
}
|
|
||||||
|
|
||||||
dbSvc := map[string]interface{}{
|
|
||||||
"id": "s2", "name": "pg", "type": "database",
|
|
||||||
"databaseDetails": map[string]interface{}{
|
|
||||||
"type": "postgres", "version": "16", "provider": "external",
|
|
||||||
"connectionUrl": "postgres://u:p@h:5432/db",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
dplan, _ := dflowServicePlanOf(dbSvc)
|
|
||||||
if dplan.Type != "database" || dplan.Image != "postgres:16" {
|
|
||||||
t.Fatalf("db plan = %+v", dplan)
|
|
||||||
}
|
|
||||||
if dplan.Variables["DATABASE_URL"] != "postgres://u:p@h:5432/db" {
|
|
||||||
t.Fatalf("db vars = %+v", dplan.Variables)
|
|
||||||
}
|
|
||||||
|
|
||||||
dockerSvc := map[string]interface{}{
|
|
||||||
"id": "s3", "name": "img", "type": "docker",
|
|
||||||
"dockerDetails": map[string]interface{}{
|
|
||||||
"url": "ghrc://acme/web:v1",
|
|
||||||
"ports": []interface{}{
|
|
||||||
map[string]interface{}{"hostPort": float64(80), "containerPort": float64(3000), "scheme": "http"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
iplan, _ := dflowServicePlanOf(dockerSvc)
|
|
||||||
if iplan.Image != "ghcr.io/acme/web:v1" || iplan.Port != 3000 {
|
|
||||||
t.Fatalf("docker plan = %+v", iplan)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDflowRepoURL(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
provider, owner, repo, want string
|
|
||||||
}{
|
|
||||||
{"github", "acme", "api", "https://github.com/acme/api"},
|
|
||||||
{"gitlab", "acme", "api", "https://gitlab.com/acme/api"},
|
|
||||||
{"bitbucket", "acme", "api", "https://bitbucket.org/acme/api"},
|
|
||||||
{"gitea", "acme", "api", ""},
|
|
||||||
{"github", "acme", "api.git", "https://github.com/acme/api"},
|
|
||||||
{"github", "", "api", ""},
|
|
||||||
{"github", "acme", "", ""},
|
|
||||||
{"github", "acme", "https://git.example.com/a/b", "https://git.example.com/a/b"},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
if got := dflowRepoURL(c.provider, c.owner, c.repo); got != c.want {
|
|
||||||
t.Errorf("dflowRepoURL(%q,%q,%q) = %q, want %q", c.provider, c.owner, c.repo, got, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -81,76 +81,6 @@ var nodesDeleteCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var nodesUpdateCmd = &cobra.Command{
|
|
||||||
Use: "update <id>",
|
|
||||||
Short: "Update a node agent (--name, --auto-prune)",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
body := map[string]interface{}{}
|
|
||||||
if cmd.Flags().Changed("name") {
|
|
||||||
body["name"], _ = cmd.Flags().GetString("name")
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("auto-prune") {
|
|
||||||
body["auto_prune"], _ = cmd.Flags().GetBool("auto-prune")
|
|
||||||
}
|
|
||||||
if len(body) == 0 {
|
|
||||||
return &APIError{Message: "nothing to update — pass --name or --auto-prune", ExitCode: ExitError}
|
|
||||||
}
|
|
||||||
data, err := c.Do("PUT", "/agents/"+args[0], body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
PrintRaw(data)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var nodesPruneCmd = &cobra.Command{
|
|
||||||
Use: "prune <id>",
|
|
||||||
Short: "Enqueue a bounded docker system prune on a node",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
volumes, _ := cmd.Flags().GetBool("volumes")
|
|
||||||
if volumes && !Confirm("Prune INCLUDING volumes? This can delete database data.") {
|
|
||||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
|
||||||
}
|
|
||||||
until, _ := cmd.Flags().GetString("until")
|
|
||||||
data, err := c.Do("POST", "/agents/"+args[0]+"/prune",
|
|
||||||
map[string]interface{}{"until": until, "volumes": volumes})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
PrintRaw(data)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var nodesCommandsCmd = &cobra.Command{
|
|
||||||
Use: "commands <id>",
|
|
||||||
Short: "List recent commands sent to a node agent",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := c.Do("GET", "/agents/"+args[0]+"/commands", nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
PrintRaw(data)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agent tokens (cagt_) onboard new nodes.
|
// Agent tokens (cagt_) onboard new nodes.
|
||||||
var nodeTokensCmd = &cobra.Command{
|
var nodeTokensCmd = &cobra.Command{
|
||||||
Use: "tokens",
|
Use: "tokens",
|
||||||
@@ -215,10 +145,6 @@ var nodeTokensRevokeCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
nodesUpdateCmd.Flags().String("name", "", "node display name")
|
|
||||||
nodesUpdateCmd.Flags().Bool("auto-prune", false, "enqueue a daily bounded docker prune")
|
|
||||||
nodesPruneCmd.Flags().String("until", "", "only prune objects older than this duration (e.g. 168h)")
|
|
||||||
nodesPruneCmd.Flags().Bool("volumes", false, "also prune volumes (destructive — asks twice)")
|
|
||||||
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
|
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
|
||||||
NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesUpdateCmd, nodesPruneCmd, nodesCommandsCmd, nodesDeleteCmd, nodeTokensCmd)
|
NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesDeleteCmd, nodeTokensCmd)
|
||||||
}
|
}
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
package commands
|
|
||||||
|
|
||||||
// SSH node bootstrap — one SSH session installs the agent (binary +
|
|
||||||
// systemd + enroll token), then SSH is never needed again. SSH keys
|
|
||||||
// stay client-side; the server only sees the resulting agent.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"os/user"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
"golang.org/x/crypto/ssh/agent"
|
|
||||||
"golang.org/x/term"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
nodeSSHKey string
|
|
||||||
nodeSSHName string
|
|
||||||
nodeSSHPrint bool
|
|
||||||
)
|
|
||||||
|
|
||||||
var nodesAddCmd = &cobra.Command{
|
|
||||||
Use: "add <[user@]host[:port]>",
|
|
||||||
Short: "Bootstrap a node agent over SSH (one session, then closed)",
|
|
||||||
Long: `Installs the containr agent on a remote host:
|
|
||||||
|
|
||||||
1. mints a single-use enroll token via the API
|
|
||||||
2. SSHes in (agent socket, --key, or default key files)
|
|
||||||
3. runs the instance's install script remotely
|
|
||||||
4. closes the session — the agent heartbeats outbound from then on
|
|
||||||
|
|
||||||
Use --print to skip SSH and emit the remote one-liner for manual runs.`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mint the enroll token first — SSH failures then don't leave
|
|
||||||
// partial state on the remote.
|
|
||||||
body := map[string]interface{}{"label": "bootstrap"}
|
|
||||||
if nodeSSHName != "" {
|
|
||||||
body["label"] = "bootstrap:" + nodeSSHName
|
|
||||||
}
|
|
||||||
tokenData, err := c.Do("POST", "/agent-tokens", body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tokenObj, err := unwrapObject(tokenData, "token")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
token := str(tokenObj, "token")
|
|
||||||
if token == "" {
|
|
||||||
return &APIError{Message: "enroll token missing from response", ExitCode: ExitError}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agent bootstrap endpoints live at /api/agents (no /v1) — strip
|
|
||||||
// the API version suffix off the client base.
|
|
||||||
apiRoot := strings.TrimSuffix(c.BaseURL, "/api/v1")
|
|
||||||
installCmd := fmt.Sprintf(
|
|
||||||
`curl -fsSL %q | bash -s -- --url %q --token %q --name %q`,
|
|
||||||
apiRoot+"/api/agents/install.sh", apiRoot, token, firstNonEmptyStr(nodeSSHName, nodeSSHTarget(args[0]).host),
|
|
||||||
)
|
|
||||||
|
|
||||||
if nodeSSHPrint {
|
|
||||||
fmt.Println(installCmd)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
target := nodeSSHTarget(args[0])
|
|
||||||
if err := sshBootstrap(target, installCmd); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("agent installing on %s — it should appear in `containr nodes list` within ~15s\n", target.host)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
type sshTarget struct {
|
|
||||||
user string
|
|
||||||
host string
|
|
||||||
port string
|
|
||||||
}
|
|
||||||
|
|
||||||
func nodeSSHTarget(raw string) sshTarget {
|
|
||||||
t := sshTarget{port: "22"}
|
|
||||||
rest := raw
|
|
||||||
if at := strings.LastIndex(rest, "@"); at >= 0 {
|
|
||||||
t.user = rest[:at]
|
|
||||||
rest = rest[at+1:]
|
|
||||||
}
|
|
||||||
if colon := strings.LastIndex(rest, ":"); colon >= 0 && !strings.Contains(rest, "]") {
|
|
||||||
t.host, t.port = rest[:colon], rest[colon+1:]
|
|
||||||
} else {
|
|
||||||
t.host = rest
|
|
||||||
}
|
|
||||||
if t.user == "" {
|
|
||||||
if u, err := user.Current(); err == nil {
|
|
||||||
t.user = u.Username
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
func sshAuthMethods(keyPath string) []ssh.AuthMethod {
|
|
||||||
var methods []ssh.AuthMethod
|
|
||||||
|
|
||||||
// ssh-agent first — matches how people actually manage keys.
|
|
||||||
if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" {
|
|
||||||
if conn, err := net.Dial("unix", sock); err == nil {
|
|
||||||
defer conn.Close()
|
|
||||||
methods = append(methods, ssh.PublicKeysCallback(agent.NewClient(conn).Signers))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var keyFiles []string
|
|
||||||
if keyPath != "" {
|
|
||||||
keyFiles = []string{keyPath}
|
|
||||||
} else if home, err := os.UserHomeDir(); err == nil {
|
|
||||||
for _, name := range []string{"id_ed25519", "id_rsa", "id_ecdsa"} {
|
|
||||||
p := filepath.Join(home, ".ssh", name)
|
|
||||||
if _, err := os.Stat(p); err == nil {
|
|
||||||
keyFiles = append(keyFiles, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, p := range keyFiles {
|
|
||||||
if signer, err := sshPrivateKey(p); err == nil {
|
|
||||||
methods = append(methods, ssh.PublicKeys(signer))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return methods
|
|
||||||
}
|
|
||||||
|
|
||||||
func sshPrivateKey(path string) (ssh.Signer, error) {
|
|
||||||
pem, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
signer, err := ssh.ParsePrivateKey(pem)
|
|
||||||
if err != nil {
|
|
||||||
var missing *ssh.PassphraseMissingError
|
|
||||||
if errors.As(err, &missing) {
|
|
||||||
fmt.Fprintf(os.Stderr, "passphrase for %s: ", path)
|
|
||||||
pass, readErr := term.ReadPassword(int(os.Stdin.Fd()))
|
|
||||||
fmt.Fprintln(os.Stderr)
|
|
||||||
if readErr != nil {
|
|
||||||
return nil, readErr
|
|
||||||
}
|
|
||||||
return ssh.ParsePrivateKeyWithPassphrase(pem, pass)
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return signer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sshBootstrap(t sshTarget, remoteCmd string) error {
|
|
||||||
config := &ssh.ClientConfig{
|
|
||||||
User: t.user,
|
|
||||||
Auth: sshAuthMethods(nodeSSHKey),
|
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // jarvis: TOFU would be better; ssh_known_hosts parsing is the upgrade path
|
|
||||||
Timeout: 15 * time.Second,
|
|
||||||
}
|
|
||||||
if len(config.Auth) == 0 {
|
|
||||||
return &APIError{Message: "no SSH auth methods (ssh-agent empty, no usable keys)", ExitCode: ExitError}
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err := ssh.Dial("tcp", net.JoinHostPort(t.host, t.port), config)
|
|
||||||
if err != nil {
|
|
||||||
return &APIError{Message: fmt.Sprintf("ssh connect: %v", err), ExitCode: ExitError}
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
session, err := conn.NewSession()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer session.Close()
|
|
||||||
session.Stdout = os.Stdout
|
|
||||||
session.Stderr = os.Stderr
|
|
||||||
|
|
||||||
// Root shells run directly; otherwise escalate via sudo. NOPASSWD
|
|
||||||
// sudo is assumed — a password prompt here means the operator should
|
|
||||||
// use --print and run the command themselves.
|
|
||||||
if t.user == "root" {
|
|
||||||
return session.Run(remoteCmd)
|
|
||||||
}
|
|
||||||
return session.Run("sudo -n " + remoteCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstNonEmptyStr(values ...string) string {
|
|
||||||
for _, v := range values {
|
|
||||||
if v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
nodesAddCmd.Flags().StringVar(&nodeSSHKey, "key", "", "SSH private key path (defaults: agent, ~/.ssh/id_*)")
|
|
||||||
nodesAddCmd.Flags().StringVar(&nodeSSHName, "name", "", "node display name (defaults to remote hostname)")
|
|
||||||
nodesAddCmd.Flags().BoolVar(&nodeSSHPrint, "print", false, "print the remote install command instead of running SSH")
|
|
||||||
NodesCmd.AddCommand(nodesAddCmd)
|
|
||||||
}
|
|
||||||
@@ -94,7 +94,6 @@ func init() {
|
|||||||
rootCmd.AddCommand(commands.BannersCmd)
|
rootCmd.AddCommand(commands.BannersCmd)
|
||||||
rootCmd.AddCommand(commands.InvitesCmd)
|
rootCmd.AddCommand(commands.InvitesCmd)
|
||||||
rootCmd.AddCommand(commands.UpCmd)
|
rootCmd.AddCommand(commands.UpCmd)
|
||||||
rootCmd.AddCommand(commands.MigrateCmd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func initConfig() {
|
func initConfig() {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ INSERT INTO node_agents (
|
|||||||
id, name, hostname, ip_address, port, status, version,
|
id, name, hostname, ip_address, port, status, version,
|
||||||
capabilities, resources, last_heartbeat, metadata
|
capabilities, resources, last_heartbeat, metadata
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune
|
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateAgentParams struct {
|
type CreateAgentParams struct {
|
||||||
@@ -109,7 +109,6 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeA
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Metadata,
|
&i.Metadata,
|
||||||
&i.AutoPrune,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -230,7 +229,7 @@ func (q *Queries) DeleteAgent(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getAgent = `-- name: GetAgent :one
|
const getAgent = `-- name: GetAgent :one
|
||||||
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune FROM node_agents WHERE id = $1
|
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata FROM node_agents WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
|
func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
|
||||||
@@ -250,13 +249,12 @@ func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Metadata,
|
&i.Metadata,
|
||||||
&i.AutoPrune,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one
|
const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one
|
||||||
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune FROM node_agents
|
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata FROM node_agents
|
||||||
WHERE hostname = $1 AND ip_address = $2
|
WHERE hostname = $1 AND ip_address = $2
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
@@ -283,7 +281,6 @@ func (q *Queries) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAnd
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Metadata,
|
&i.Metadata,
|
||||||
&i.AutoPrune,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -381,36 +378,6 @@ func (q *Queries) GetContainerForAgent(ctx context.Context, arg GetContainerForA
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLastAgentCommandByType = `-- name: GetLastAgentCommandByType :one
|
|
||||||
SELECT id, type, node_agent_id, container_id, payload, status, result, error, created_at, updated_at, completed_at FROM agent_commands
|
|
||||||
WHERE node_agent_id = $1 AND type = $2
|
|
||||||
ORDER BY created_at DESC LIMIT 1
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetLastAgentCommandByTypeParams struct {
|
|
||||||
NodeAgentID string `json:"node_agent_id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetLastAgentCommandByType(ctx context.Context, arg GetLastAgentCommandByTypeParams) (AgentCommand, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, getLastAgentCommandByType, arg.NodeAgentID, arg.Type)
|
|
||||||
var i AgentCommand
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Type,
|
|
||||||
&i.NodeAgentID,
|
|
||||||
&i.ContainerID,
|
|
||||||
&i.Payload,
|
|
||||||
&i.Status,
|
|
||||||
&i.Result,
|
|
||||||
&i.Error,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
&i.CompletedAt,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertAgentHeartbeat = `-- name: InsertAgentHeartbeat :exec
|
const insertAgentHeartbeat = `-- name: InsertAgentHeartbeat :exec
|
||||||
INSERT INTO agent_heartbeats (
|
INSERT INTO agent_heartbeats (
|
||||||
id, node_agent_id, timestamp, status, resources,
|
id, node_agent_id, timestamp, status, resources,
|
||||||
@@ -491,7 +458,7 @@ func (q *Queries) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHea
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listAgents = `-- name: ListAgents :many
|
const listAgents = `-- name: ListAgents :many
|
||||||
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune FROM node_agents ORDER BY created_at ASC
|
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata FROM node_agents ORDER BY created_at ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
|
func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
|
||||||
@@ -517,7 +484,6 @@ func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Metadata,
|
&i.Metadata,
|
||||||
&i.AutoPrune,
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -673,10 +639,9 @@ UPDATE node_agents SET
|
|||||||
resources = $9,
|
resources = $9,
|
||||||
last_heartbeat = $10,
|
last_heartbeat = $10,
|
||||||
metadata = $11,
|
metadata = $11,
|
||||||
auto_prune = $12,
|
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune
|
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateAgentParams struct {
|
type UpdateAgentParams struct {
|
||||||
@@ -691,7 +656,6 @@ type UpdateAgentParams struct {
|
|||||||
Resources pqtype.NullRawMessage `json:"resources"`
|
Resources pqtype.NullRawMessage `json:"resources"`
|
||||||
LastHeartbeat sql.NullTime `json:"last_heartbeat"`
|
LastHeartbeat sql.NullTime `json:"last_heartbeat"`
|
||||||
Metadata pqtype.NullRawMessage `json:"metadata"`
|
Metadata pqtype.NullRawMessage `json:"metadata"`
|
||||||
AutoPrune bool `json:"auto_prune"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) {
|
func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) {
|
||||||
@@ -707,7 +671,6 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
|
|||||||
arg.Resources,
|
arg.Resources,
|
||||||
arg.LastHeartbeat,
|
arg.LastHeartbeat,
|
||||||
arg.Metadata,
|
arg.Metadata,
|
||||||
arg.AutoPrune,
|
|
||||||
)
|
)
|
||||||
var i NodeAgent
|
var i NodeAgent
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -724,7 +687,6 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Metadata,
|
&i.Metadata,
|
||||||
&i.AutoPrune,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,6 @@ type NodeAgent struct {
|
|||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
Metadata pqtype.NullRawMessage `json:"metadata"`
|
Metadata pqtype.NullRawMessage `json:"metadata"`
|
||||||
AutoPrune bool `json:"auto_prune"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Notification struct {
|
type Notification struct {
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ type Querier interface {
|
|||||||
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
|
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
|
||||||
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
|
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
|
||||||
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error)
|
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error)
|
||||||
GetLastAgentCommandByType(ctx context.Context, arg GetLastAgentCommandByTypeParams) (AgentCommand, error)
|
|
||||||
GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
|
GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
|
||||||
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
|
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
|
||||||
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
|
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
|
||||||
|
|||||||
@@ -173,9 +173,6 @@ func init() {
|
|||||||
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
|
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
|
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
|
||||||
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
|
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
|
||||||
register(spec{"containr_nodes_update", "Update a node agent (admin). Args: id; optional auto_prune, name.", "PUT", "/agents/{id}", []string{"id"}, []string{"auto_prune", "name"}, false})
|
|
||||||
register(spec{"containr_nodes_prune", "Enqueue a bounded docker system prune on a node (admin). Args: id; optional until (e.g. 168h), volumes (danger, off by default).", "POST", "/agents/{id}/prune", []string{"id"}, []string{"until", "volumes"}, true})
|
|
||||||
register(spec{"containr_nodes_commands", "List recent commands sent to a node agent (admin). Args: id.", "GET", "/agents/{id}/commands", []string{"id"}, nil, false})
|
|
||||||
|
|
||||||
// Scaling / HA / security
|
// Scaling / HA / security
|
||||||
register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false})
|
register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false})
|
||||||
|
|||||||
@@ -54,9 +54,6 @@ 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
|
||||||
@@ -118,9 +115,6 @@ 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
|
||||||
@@ -143,10 +137,6 @@ 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
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
ALTER TABLE node_agents ADD COLUMN IF NOT EXISTS auto_prune BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
ALTER TABLE node_agents DROP COLUMN IF EXISTS auto_prune;
|
|
||||||
@@ -28,7 +28,6 @@ UPDATE node_agents SET
|
|||||||
resources = $9,
|
resources = $9,
|
||||||
last_heartbeat = $10,
|
last_heartbeat = $10,
|
||||||
metadata = $11,
|
metadata = $11,
|
||||||
auto_prune = $12,
|
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
@@ -108,8 +107,3 @@ RETURNING *;
|
|||||||
-- name: UpdateContainerStatus :exec
|
-- name: UpdateContainerStatus :exec
|
||||||
UPDATE container_instances SET status = $2, updated_at = NOW()
|
UPDATE container_instances SET status = $2, updated_at = NOW()
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
-- name: GetLastAgentCommandByType :one
|
|
||||||
SELECT * FROM agent_commands
|
|
||||||
WHERE node_agent_id = $1 AND type = $2
|
|
||||||
ORDER BY created_at DESC LIMIT 1;
|
|
||||||
@@ -192,8 +192,7 @@ CREATE TABLE node_agents (
|
|||||||
last_heartbeat TIMESTAMP WITH TIME ZONE,
|
last_heartbeat TIMESTAMP WITH TIME ZONE,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
metadata JSONB,
|
metadata JSONB
|
||||||
auto_prune BOOLEAN NOT NULL DEFAULT false
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE container_instances (
|
CREATE TABLE container_instances (
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
ComponentShowcase,
|
ComponentShowcase,
|
||||||
GitHubAppCallbackPage,
|
GitHubAppCallbackPage,
|
||||||
} from '@/features/secondary/pages';
|
} from '@/features/secondary/pages';
|
||||||
import { NodeDetailPage } from '@/features/nodes/NodeDetailPage';
|
|
||||||
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
|
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
import { useDemoMode } from '@/lib/demo-mode';
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
@@ -169,7 +168,6 @@ export default function App() {
|
|||||||
{/* Platform owner only */}
|
{/* Platform owner only */}
|
||||||
<Route element={<AdminRequired />}>
|
<Route element={<AdminRequired />}>
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
<Route path="/nodes/:id" element={<NodeDetailPage />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ 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, FilterBar } from '@/shared/components';
|
import { DemoRestricted } 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,
|
||||||
@@ -24,37 +22,23 @@ 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 SCHEMA: FilterConfig<ActivityEntry>[] = [
|
const SEVERITIES = ['', 'error', 'warning', 'success', 'info'] as const;
|
||||||
{ 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', severityParam, page],
|
queryKey: ['activity-feed', severity, page],
|
||||||
queryFn: () => listActivity({ severity: severityParam || undefined, page, limit: 50 }),
|
queryFn: () => listActivity({ severity: severity || 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 = filter.filteredData;
|
const entries = query.data?.activity ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
@@ -68,11 +52,21 @@ 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-start gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1">
|
{SEVERITIES.map((s) => (
|
||||||
<FilterBar {...filter} />
|
<button
|
||||||
</div>
|
key={s || 'all'}
|
||||||
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)] mt-3" />}
|
onClick={() => { setSeverity(s); setPage(1); }}
|
||||||
|
className={`v-mono text-[10px] uppercase px-3 py-1.5 rounded-md border transition-colors ${
|
||||||
|
severity === s
|
||||||
|
? 'border-[var(--accent-primary)] text-[var(--accent-primary)] bg-[var(--accent-primary)]/10'
|
||||||
|
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s || 'all'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)]" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel p-0 divide-y divide-[var(--border-subtle)]">
|
<div className="panel p-0 divide-y divide-[var(--border-subtle)]">
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ 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, FilterBar } from '@/shared/components';
|
import { DemoRestricted } 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;
|
||||||
@@ -17,34 +15,20 @@ 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;
|
||||||
|
|
||||||
// Server-backed filters — the engine runs over empty data; values map to
|
const inputClass =
|
||||||
// API params. URL state makes filtered views shareable.
|
'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';
|
||||||
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 filter = useDynamicFilter<unknown>({ data: [], schema: AUDIT_SCHEMA });
|
const [resource, setResource] = useState('');
|
||||||
|
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(String(filter.filters.resource ?? ''));
|
const deferredResource = useDeferredValue(resource);
|
||||||
const deferredAction = useDeferredValue(String(filter.filters.action ?? ''));
|
const deferredAction = useDeferredValue(action);
|
||||||
const deferredActor = useDeferredValue(String(filter.filters.actor ?? ''));
|
const deferredActor = useDeferredValue(actor);
|
||||||
const range = filter.filters.range === 'all' ? '' : String(filter.filters.range ?? '');
|
|
||||||
|
|
||||||
const filters = {
|
const filters = {
|
||||||
resource: deferredResource.trim() || undefined,
|
resource: deferredResource.trim() || undefined,
|
||||||
@@ -70,7 +54,15 @@ 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 = filter.activeCount > 0;
|
const hasFilters = Boolean(resource || action || actor || range);
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setResource('');
|
||||||
|
setAction('');
|
||||||
|
setActor('');
|
||||||
|
setRange('');
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
@@ -87,7 +79,63 @@ 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">
|
||||||
<FilterBar {...filter} />
|
<div className="flex flex-wrap items-end gap-3 mb-5">
|
||||||
|
<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,9 +9,6 @@ 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 {
|
||||||
@@ -37,19 +34,6 @@ 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 },
|
||||||
@@ -88,10 +72,9 @@ export function BuildsPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isDemoMode = useDemoMode();
|
const isDemoMode = useDemoMode();
|
||||||
|
|
||||||
const { filters } = useFilterState(BUILD_SCHEMA);
|
const [projectFilter, setProjectFilter] = useState('');
|
||||||
const projectFilter = String(filters.project ?? '');
|
const [serviceFilter, setServiceFilter] = useState('');
|
||||||
const serviceFilter = String(filters.service ?? '');
|
const [statusFilter, setStatusFilter] = useState<'' | BuildStatus>('');
|
||||||
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);
|
||||||
|
|
||||||
@@ -102,22 +85,20 @@ export function BuildsPage() {
|
|||||||
listBuilds({
|
listBuilds({
|
||||||
projectId: projectFilter || undefined,
|
projectId: projectFilter || undefined,
|
||||||
serviceId: serviceFilter || undefined,
|
serviceId: serviceFilter || undefined,
|
||||||
status: (statusFilter || undefined) as BuildStatus | undefined,
|
status: statusFilter || undefined,
|
||||||
page: 1,
|
page: 1,
|
||||||
limit,
|
limit,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawBuilds = useMemo(
|
const builds = 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 ? [] : rawBuilds.map((build) => build.id)),
|
() => (isDemoMode ? [] : builds.map((build) => build.id)),
|
||||||
[isDemoMode, rawBuilds],
|
[isDemoMode, builds],
|
||||||
);
|
);
|
||||||
const liveStatus = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
|
const liveStatus = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['builds-page'] });
|
queryClient.invalidateQueries({ queryKey: ['builds-page'] });
|
||||||
@@ -236,13 +217,46 @@ 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">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<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="flex flex-wrap items-end gap-3 mt-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div className="flex-1">
|
<div>
|
||||||
<FilterBar {...filter} />
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||||
|
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">
|
||||||
|
|||||||
@@ -1,393 +0,0 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
deleteAgent,
|
|
||||||
getAgent,
|
|
||||||
getAgentMetrics,
|
|
||||||
listAgentCommands,
|
|
||||||
listAgentContainers,
|
|
||||||
pruneAgent,
|
|
||||||
updateAgent,
|
|
||||||
type AgentMetricPoint,
|
|
||||||
} from '@/lib/api-client';
|
|
||||||
import { getCurrentUserProfile } from '@/lib/api-client';
|
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
|
||||||
import { useDemoMode } from '@/lib/demo-mode';
|
|
||||||
import { formatBytes, formatRelative } from '@/lib/time';
|
|
||||||
import { DemoRestricted } from '@/shared/components';
|
|
||||||
import {
|
|
||||||
ArrowLeft, CheckCircle2, ChevronRight, Loader2, Pencil, XCircle,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
const TIME_RANGES = [
|
|
||||||
{ label: '1h', value: '1h' },
|
|
||||||
{ label: '24h', value: '24h' },
|
|
||||||
{ label: '7d', value: '168h' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function NodeDetailPage() {
|
|
||||||
const { id = '' } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const isDemoMode = useDemoMode();
|
|
||||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
|
||||||
const profileQuery = useQuery({
|
|
||||||
queryKey: ['user-profile'],
|
|
||||||
queryFn: getCurrentUserProfile,
|
|
||||||
enabled: !isDemoMode && Boolean(sessionQuery.data),
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
|
|
||||||
const [timeRange, setTimeRange] = useState('24h');
|
|
||||||
const [rename, setRename] = useState<string | null>(null);
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
||||||
const [confirmVolumes, setConfirmVolumes] = useState(false);
|
|
||||||
|
|
||||||
const agentQuery = useQuery({
|
|
||||||
queryKey: ['node-agent', id],
|
|
||||||
queryFn: () => getAgent(id),
|
|
||||||
enabled: !isDemoMode && id !== '',
|
|
||||||
refetchInterval: 15000,
|
|
||||||
});
|
|
||||||
const metricsQuery = useQuery({
|
|
||||||
queryKey: ['node-metrics', id, timeRange],
|
|
||||||
queryFn: () => getAgentMetrics(id, timeRange),
|
|
||||||
enabled: !isDemoMode && id !== '',
|
|
||||||
refetchInterval: 30000,
|
|
||||||
});
|
|
||||||
const containersQuery = useQuery({
|
|
||||||
queryKey: ['node-containers', id],
|
|
||||||
queryFn: () => listAgentContainers(id),
|
|
||||||
enabled: !isDemoMode && id !== '',
|
|
||||||
refetchInterval: 15000,
|
|
||||||
});
|
|
||||||
const commandsQuery = useQuery({
|
|
||||||
queryKey: ['node-commands', id],
|
|
||||||
queryFn: () => listAgentCommands(id),
|
|
||||||
enabled: !isDemoMode && id !== '',
|
|
||||||
refetchInterval: 15000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const invalidate = () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['node-agent', id] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['node-commands', id] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['usage-agents'] });
|
|
||||||
};
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: (body: { name?: string; auto_prune?: boolean }) => updateAgent(id, body),
|
|
||||||
onSuccess: () => { setRename(null); invalidate(); },
|
|
||||||
});
|
|
||||||
const pruneMutation = useMutation({
|
|
||||||
mutationFn: (volumes: boolean) => pruneAgent(id, { volumes }),
|
|
||||||
onSuccess: () => { setConfirmVolumes(false); invalidate(); },
|
|
||||||
});
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: () => deleteAgent(id),
|
|
||||||
onSuccess: () => navigate('/usage'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const agent = agentQuery.data;
|
|
||||||
const metrics = useMemo(() => metricsQuery.data ?? [], [metricsQuery.data]);
|
|
||||||
const latest = metrics.length > 0 ? metrics[metrics.length - 1] : null;
|
|
||||||
const containers = containersQuery.data ?? [];
|
|
||||||
const commands = commandsQuery.data ?? [];
|
|
||||||
|
|
||||||
if (isDemoMode) return <DemoRestricted feature="Nodes" />;
|
|
||||||
|
|
||||||
if (agentQuery.isLoading) {
|
|
||||||
return <div className="px-8 py-10 text-xs text-[var(--text-muted)]">Loading node…</div>;
|
|
||||||
}
|
|
||||||
if (!agent) {
|
|
||||||
return (
|
|
||||||
<div className="px-8 py-10">
|
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Node not found.</p>
|
|
||||||
<Link to="/usage" className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline">← back to usage</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const online = agent.status === 'online' || agent.status === 'connecting';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
<div className="border-b border-[var(--border-subtle)]">
|
|
||||||
<div className="w-full px-8 py-5">
|
|
||||||
<div className="flex items-center gap-2 v-mono text-[11px] text-[var(--text-tertiary)] mb-2">
|
|
||||||
<Link to="/usage" className="hover:text-[var(--text-primary)] flex items-center gap-1">
|
|
||||||
<ArrowLeft size={12} /> usage
|
|
||||||
</Link>
|
|
||||||
<ChevronRight size={10} />
|
|
||||||
<span className="text-[var(--text-primary)]">{agent.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
|
||||||
<div>
|
|
||||||
<h1 className="v-title">
|
|
||||||
{rename === null ? agent.name : rename}
|
|
||||||
{isAdmin && rename === null && (
|
|
||||||
<button
|
|
||||||
onClick={() => setRename(agent.name)}
|
|
||||||
className="ml-2 inline-block align-middle text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
|
||||||
title="Rename node"
|
|
||||||
>
|
|
||||||
<Pencil size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<span className="v-cursor">_</span>
|
|
||||||
</h1>
|
|
||||||
{rename !== null && (
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
value={rename}
|
|
||||||
onChange={(e) => setRename(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') updateMutation.mutate({ name: rename });
|
|
||||||
if (e.key === 'Escape') setRename(null);
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-1 text-sm text-[var(--text-primary)]"
|
|
||||||
/>
|
|
||||||
<button onClick={() => updateMutation.mutate({ name: rename })} className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline">save</button>
|
|
||||||
<button onClick={() => setRename(null)} className="v-mono text-[11px] text-[var(--text-muted)] hover:underline">cancel</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
|
|
||||||
{agent.hostname} · {agent.ipAddress}:{agent.port}
|
|
||||||
{agent.version ? ` · v${agent.version}` : ''}
|
|
||||||
{agent.lastHeartbeat ? ` · heartbeat ${formatRelative(agent.lastHeartbeat)}` : ''}
|
|
||||||
</p>
|
|
||||||
{meshAddresses(agent).length > 0 && (
|
|
||||||
<p className="v-mono mt-1 text-[11px] text-[var(--text-tertiary)]">
|
|
||||||
mesh: {meshAddresses(agent).map(([iface, ip]) => `${iface}=${ip}`).join(' · ')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online
|
|
||||||
? 'border-[var(--success)]/40 text-[var(--success)]'
|
|
||||||
: 'border-[var(--error)]/40 text-[var(--error)]'}`}>
|
|
||||||
{agent.status}
|
|
||||||
</span>
|
|
||||||
{isAdmin && (
|
|
||||||
<>
|
|
||||||
<label className="flex items-center gap-1.5 v-mono text-[11px] text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={agent.autoPrune}
|
|
||||||
onChange={(e) => updateMutation.mutate({ auto_prune: e.target.checked })}
|
|
||||||
className="accent-[var(--accent-primary)]"
|
|
||||||
/>
|
|
||||||
auto-prune
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmVolumes(true)}
|
|
||||||
disabled={pruneMutation.isPending}
|
|
||||||
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
|
||||||
>
|
|
||||||
prune now
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDelete(true)}
|
|
||||||
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--error)]/40 text-[var(--error)] hover:bg-[var(--error)]/10"
|
|
||||||
>
|
|
||||||
remove
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-8 py-6 space-y-6">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<Stat label="cpu" value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} />
|
|
||||||
<Stat label="memory" value={latest ? `${latest.memory.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${formatBytes(latest.memory.available)} free of ${formatBytes(latest.memory.limit)}` : ''} />
|
|
||||||
<Stat label="load 1m" value={latest ? latest.system_load.load_1m.toFixed(2) : '—'} sub={latest ? `5m ${latest.system_load.load_5m.toFixed(2)} · 15m ${latest.system_load.load_15m.toFixed(2)}` : ''} />
|
|
||||||
<Stat label="containers" value={latest ? String(latest.container_count) : String(containers.length)} sub="reported by agent" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="panel p-5">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">telemetry</h2>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{TIME_RANGES.map((r) => (
|
|
||||||
<button
|
|
||||||
key={r.value}
|
|
||||||
onClick={() => setTimeRange(r.value)}
|
|
||||||
className={`v-mono text-[11px] px-2.5 py-1 rounded-md border ${timeRange === r.value
|
|
||||||
? 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)]'
|
|
||||||
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'}`}
|
|
||||||
>
|
|
||||||
{r.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{metricsQuery.isLoading ? (
|
|
||||||
<p className="text-xs text-[var(--text-muted)]">Loading metrics…</p>
|
|
||||||
) : metrics.length < 2 ? (
|
|
||||||
<p className="text-xs text-[var(--text-muted)]">Not enough heartbeat history yet — the agent reports every few seconds once connected.</p>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
|
||||||
<Sparkline label="cpu %" points={metrics} value={(m) => m.cpu.usage_percent} />
|
|
||||||
<Sparkline label="memory %" points={metrics} value={(m) => m.memory.usage_percent} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
<section className="panel p-0">
|
|
||||||
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">containers</h2>
|
|
||||||
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
|
|
||||||
{containers.length === 0 && (
|
|
||||||
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No containers reported on this node.</p>
|
|
||||||
)}
|
|
||||||
{containers.map((c) => (
|
|
||||||
<div key={c.id} className="flex items-center justify-between gap-3 px-5 py-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm text-[var(--text-primary)]">{c.name ?? c.id}</p>
|
|
||||||
<p className="truncate v-mono text-[11px] text-[var(--text-tertiary)]">{c.image}</p>
|
|
||||||
</div>
|
|
||||||
<span className="v-mono text-[11px] text-[var(--text-secondary)]">{c.status ?? '—'}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="panel p-0">
|
|
||||||
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">command history</h2>
|
|
||||||
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
|
|
||||||
{commands.length === 0 && (
|
|
||||||
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No commands sent to this node yet.</p>
|
|
||||||
)}
|
|
||||||
{commands.slice(0, 15).map((cmd) => (
|
|
||||||
<div key={cmd.id} className="flex items-center justify-between gap-3 px-5 py-3">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
{cmd.status === 'completed'
|
|
||||||
? <CheckCircle2 size={13} className="text-[var(--success)] shrink-0" />
|
|
||||||
: cmd.status === 'failed'
|
|
||||||
? <XCircle size={13} className="text-[var(--error)] shrink-0" />
|
|
||||||
: <Loader2 size={13} className="text-[var(--text-muted)] animate-spin shrink-0" />}
|
|
||||||
<span className="v-mono text-[11px] text-[var(--text-primary)]">{cmd.type}</span>
|
|
||||||
{cmd.error && <span className="truncate text-[11px] text-[var(--error)]">{cmd.error}</span>}
|
|
||||||
</div>
|
|
||||||
<span className="v-mono text-[11px] text-[var(--text-muted)] whitespace-nowrap">
|
|
||||||
{cmd.created_at ? formatRelative(cmd.created_at) : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{confirmVolumes && (
|
|
||||||
<ConfirmModal
|
|
||||||
title="Prune this node?"
|
|
||||||
body={`Runs a bounded docker system prune on ${agent.name}. Unused images, stopped containers, and networks are removed.`}
|
|
||||||
confirmLabel="prune"
|
|
||||||
danger={false}
|
|
||||||
extra={
|
|
||||||
<label className="flex items-center gap-2 text-xs text-[var(--text-secondary)] cursor-pointer">
|
|
||||||
<input type="checkbox" id="prune-volumes" className="accent-[var(--error)]" />
|
|
||||||
also prune volumes (can delete database data)
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
onCancel={() => setConfirmVolumes(false)}
|
|
||||||
onConfirm={() => {
|
|
||||||
const volumes = (document.getElementById('prune-volumes') as HTMLInputElement | null)?.checked ?? false;
|
|
||||||
pruneMutation.mutate(volumes);
|
|
||||||
}}
|
|
||||||
pending={pruneMutation.isPending}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{confirmDelete && (
|
|
||||||
<ConfirmModal
|
|
||||||
title={`Remove ${agent.name}?`}
|
|
||||||
body="The agent record and its history are deleted. Containers running on the host are not stopped — uninstall the agent there separately."
|
|
||||||
confirmLabel="remove node"
|
|
||||||
danger
|
|
||||||
onCancel={() => setConfirmDelete(false)}
|
|
||||||
onConfirm={() => deleteMutation.mutate()}
|
|
||||||
pending={deleteMutation.isPending}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function meshAddresses(agent: { metadata: Record<string, unknown> }): [string, string][] {
|
|
||||||
const mesh = agent.metadata?.mesh;
|
|
||||||
if (!mesh || typeof mesh !== 'object' || Array.isArray(mesh)) return [];
|
|
||||||
return Object.entries(mesh as Record<string, unknown>)
|
|
||||||
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
|
||||||
return (
|
|
||||||
<div className="panel p-4">
|
|
||||||
<p className="v-mono text-[10px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
|
|
||||||
<p className="mt-1 text-xl font-semibold text-[var(--text-primary)]">{value}</p>
|
|
||||||
{sub && <p className="mt-0.5 v-mono text-[11px] text-[var(--text-muted)]">{sub}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Sparkline({ label, points, value }: {
|
|
||||||
label: string;
|
|
||||||
points: AgentMetricPoint[];
|
|
||||||
value: (m: AgentMetricPoint) => number;
|
|
||||||
}) {
|
|
||||||
const w = 560;
|
|
||||||
const h = 90;
|
|
||||||
const vals = points.map(value);
|
|
||||||
const max = Math.max(100, ...vals);
|
|
||||||
const step = w / Math.max(points.length - 1, 1);
|
|
||||||
const polyline = vals.map((v, i) => `${(i * step).toFixed(1)},${(h - (v / max) * (h - 6)).toFixed(1)}`).join(' ');
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className="v-mono text-[10px] uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
|
||||||
{label} — now {vals[vals.length - 1].toFixed(0)}% / peak {Math.max(...vals).toFixed(0)}%
|
|
||||||
</p>
|
|
||||||
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-24 rounded-md border border-[var(--border-subtle)] bg-[var(--bg-secondary)]">
|
|
||||||
<polyline points={polyline} fill="none" stroke="var(--accent-primary)" strokeWidth="1.5" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfirmModal({ title, body, confirmLabel, danger, extra, onCancel, onConfirm, pending }: {
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
confirmLabel: string;
|
|
||||||
danger: boolean;
|
|
||||||
extra?: React.ReactNode;
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
pending: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
|
||||||
<div className="panel w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
|
|
||||||
<p className="mt-2 text-xs text-[var(--text-secondary)]">{body}</p>
|
|
||||||
{extra && <div className="mt-3">{extra}</div>}
|
|
||||||
<div className="mt-5 flex justify-end gap-2">
|
|
||||||
<button onClick={onCancel} className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)]">
|
|
||||||
cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onConfirm}
|
|
||||||
disabled={pending}
|
|
||||||
className={`v-mono text-[11px] px-3 py-1.5 rounded-md border disabled:opacity-50 ${danger
|
|
||||||
? 'border-[var(--error)]/50 text-[var(--error)] hover:bg-[var(--error)]/10'
|
|
||||||
: 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)] hover:bg-[var(--accent-primary)]/10'}`}
|
|
||||||
>
|
|
||||||
{pending ? 'working…' : confirmLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,6 @@ import {
|
|||||||
getCurrentUserProfile,
|
getCurrentUserProfile,
|
||||||
listAuditLogs,
|
listAuditLogs,
|
||||||
listAgents,
|
listAgents,
|
||||||
pruneAgent,
|
|
||||||
updateAgent,
|
|
||||||
listBuilds,
|
listBuilds,
|
||||||
listGitProviders,
|
listGitProviders,
|
||||||
listProjects,
|
listProjects,
|
||||||
@@ -295,14 +293,6 @@ export function UsagePage() {
|
|||||||
mutationFn: (id: string) => revokeAgentToken(id),
|
mutationFn: (id: string) => revokeAgentToken(id),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }),
|
||||||
});
|
});
|
||||||
const pruneMutation = useMutation({
|
|
||||||
mutationFn: (id: string) => pruneAgent(id),
|
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['usage-agents'] }),
|
|
||||||
});
|
|
||||||
const autoPruneMutation = useMutation({
|
|
||||||
mutationFn: ({ id, on }: { id: string; on: boolean }) => updateAgent(id, { auto_prune: on }),
|
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['usage-agents'] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const builds = useMemo(
|
const builds = useMemo(
|
||||||
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
|
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
|
||||||
@@ -387,9 +377,9 @@ export function UsagePage() {
|
|||||||
const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
|
const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
|
||||||
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
|
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
|
||||||
const agentEndpoint = getAgentPublicBaseUrl();
|
const agentEndpoint = getAgentPublicBaseUrl();
|
||||||
const apiBase = getApiBaseUrl().replace(/\/api\/v1$/, '');
|
const connectCommand = `CONTAINR_API_URL=${getApiBaseUrl().replace(/\/api\/v1$/, '')} \\
|
||||||
const installCommand = `curl -fsSL ${apiBase}/api/agents/install.sh | bash -s -- --url ${apiBase} --token ${issuedToken?.token ?? '<token>'}`;
|
CONTAINR_AGENT_AUTH_TOKEN=${issuedToken?.token ?? '<token>'} \\
|
||||||
const sshCommand = `containr nodes add --ssh <user@host>${issuedToken?.token ? '' : ' # run on a host with the CLI'}`;
|
containr-agent`;
|
||||||
|
|
||||||
let buildActivityBody = 'Track deployment frequency and failed rollouts over time.';
|
let buildActivityBody = 'Track deployment frequency and failed rollouts over time.';
|
||||||
let runtimeBody = 'Build duration telemetry will appear after completed builds are available.';
|
let runtimeBody = 'Build duration telemetry will appear after completed builds are available.';
|
||||||
@@ -613,37 +603,14 @@ export function UsagePage() {
|
|||||||
agents.slice(0, 5).map((agent) => (
|
agents.slice(0, 5).map((agent) => (
|
||||||
<div key={agent.id} className="flex items-center justify-between gap-3 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
|
<div key={agent.id} className="flex items-center justify-between gap-3 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<Link to={`/nodes/${agent.id}`} className="truncate text-sm font-medium text-[var(--text-primary)] hover:text-[var(--accent-primary)]">{agent.name}</Link>
|
<p className="truncate text-sm font-medium text-[var(--text-primary)]">{agent.name}</p>
|
||||||
<p className="truncate text-xs text-[var(--text-tertiary)]">{agent.hostname} · {agent.ipAddress}:{agent.port}</p>
|
<p className="truncate text-xs text-[var(--text-tertiary)]">{agent.hostname} · {agent.ipAddress}:{agent.port}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{isAdmin && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => pruneMutation.mutate(agent.id)}
|
|
||||||
disabled={pruneMutation.isPending}
|
|
||||||
title="Enqueue a bounded docker prune on this node"
|
|
||||||
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:underline disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Prune
|
|
||||||
</button>
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={agent.autoPrune}
|
|
||||||
onChange={(e) => autoPruneMutation.mutate({ id: agent.id, on: e.target.checked })}
|
|
||||||
className="accent-[var(--accent-primary)]"
|
|
||||||
/>
|
|
||||||
auto
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-xs font-semibold text-[var(--text-primary)]">{agent.status}</p>
|
<p className="text-xs font-semibold text-[var(--text-primary)]">{agent.status}</p>
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">{formatBytes(agent.resources.memory.available)} free</p>
|
<p className="text-xs text-[var(--text-tertiary)]">{formatBytes(agent.resources.memory.available)} free</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="px-4 py-6 text-sm text-[var(--text-tertiary)]">
|
<div className="px-4 py-6 text-sm text-[var(--text-tertiary)]">
|
||||||
@@ -699,8 +666,7 @@ export function UsagePage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{installCommand}</pre>
|
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{connectCommand}</pre>
|
||||||
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap mt-2">{sshCommand}</pre>
|
|
||||||
<div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]">
|
<div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]">
|
||||||
<p>Agent endpoint: <span className="mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
|
<p>Agent endpoint: <span className="mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
|
||||||
<p>Use an issued token above, or set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p>
|
<p>Use an issued token above, or set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p>
|
||||||
|
|||||||
@@ -644,11 +644,6 @@ 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
|
||||||
|
|||||||
@@ -8363,157 +8363,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/agents/install.sh": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Agent install script
|
|
||||||
* @description Public bootstrap script — `curl | bash -s -- --url <api> --token <enroll-token>`.
|
|
||||||
*/
|
|
||||||
get: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description Bourne shell installer */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"text/x-sh": string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/agents/download/{platform}": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Download a bundled agent binary
|
|
||||||
* @description platform: linux-amd64 | linux-arm64. Bundled in release images; 404 when absent (install script falls back to GH releases).
|
|
||||||
*/
|
|
||||||
get: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
platform: "linux-amd64" | "linux-arm64";
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description Agent binary */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/octet-stream": string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description No bundled binary for platform */
|
|
||||||
404: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["Error"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/agents/{id}/prune": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
/**
|
|
||||||
* Enqueue a bounded docker prune on a node
|
|
||||||
* @description Queues a `prune` command — `docker system prune -af`, volumes excluded unless requested.
|
|
||||||
*/
|
|
||||||
post: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
/** @description Node agent ID */
|
|
||||||
id: components["parameters"]["AgentIdParam"];
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
/** @description Only prune objects older than this duration (e.g. "168h"). */
|
|
||||||
until?: string;
|
|
||||||
/** @description Also prune volumes — destructive, off by default. */
|
|
||||||
volumes?: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Prune command queued */
|
|
||||||
202: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
command?: components["schemas"]["AgentCommand"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Agent not found */
|
|
||||||
404: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["Error"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/agents/{id}/metrics": {
|
"/agents/{id}/metrics": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -11094,8 +10943,6 @@ 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
|
||||||
@@ -11902,8 +11749,6 @@ export interface components {
|
|||||||
metadata?: {
|
metadata?: {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
/** @description Enqueue a bounded docker prune on this node once per day. */
|
|
||||||
auto_prune?: boolean;
|
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
last_heartbeat?: string;
|
last_heartbeat?: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -12028,10 +11873,6 @@ export interface components {
|
|||||||
capabilities: components["schemas"]["AgentCapabilities"];
|
capabilities: components["schemas"]["AgentCapabilities"];
|
||||||
/** @description May also be sent via the X-Agent-Token header */
|
/** @description May also be sent via the X-Agent-Token header */
|
||||||
auth_token?: string;
|
auth_token?: string;
|
||||||
/** @description Overlay-network interface IPs (tailscale/netbird/zerotier/wireguard). Stored under agent metadata.mesh; authoritative — send an empty object when no mesh interfaces exist. */
|
|
||||||
mesh?: {
|
|
||||||
[key: string]: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
AgentHeartbeat: {
|
AgentHeartbeat: {
|
||||||
agent_id?: string;
|
agent_id?: string;
|
||||||
|
|||||||
@@ -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)\nnodes add <user@host> SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs)\nnodes update|prune|commands rename/auto-prune, bounded docker prune, command history\nmigrate railway pull a Railway project (services, vars, domains) via GraphQL and recreate it — --dry-run prints the plan\nmigrate dflow import a dflow project (services, vars, volumes, domains) via its Payload REST API — needs --url + --api-key, --dry-run prints the plan\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|anLine truncated
|
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nbanners list|creatLine truncated
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||||
|
|||||||
@@ -181,7 +181,6 @@ 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;
|
||||||
};
|
};
|
||||||
@@ -342,8 +341,6 @@ export type NodeAgentEntity = {
|
|||||||
version: string;
|
version: string;
|
||||||
capabilities: AgentCapabilities;
|
capabilities: AgentCapabilities;
|
||||||
resources: NodeResources;
|
resources: NodeResources;
|
||||||
autoPrune: boolean;
|
|
||||||
metadata: Record<string, unknown>;
|
|
||||||
lastHeartbeat?: string;
|
lastHeartbeat?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
@@ -752,7 +749,6 @@ 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,
|
||||||
};
|
};
|
||||||
@@ -1080,8 +1076,6 @@ function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
lastHeartbeat: agent.last_heartbeat,
|
lastHeartbeat: agent.last_heartbeat,
|
||||||
autoPrune: agent.auto_prune ?? false,
|
|
||||||
metadata: (agent.metadata ?? {}) as Record<string, unknown>,
|
|
||||||
createdAt: agent.created_at,
|
createdAt: agent.created_at,
|
||||||
updatedAt: agent.updated_at,
|
updatedAt: agent.updated_at,
|
||||||
};
|
};
|
||||||
@@ -1120,74 +1114,6 @@ export async function listAgents(): Promise<NodeAgentEntity[]> {
|
|||||||
return normalizeAgentArray(payload.agents);
|
return normalizeAgentArray(payload.agents);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean }): Promise<NodeAgentEntity | null> {
|
|
||||||
const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
return payload.agent ? normalizeAgent(payload.agent) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pruneAgent(id: string, body?: { until?: string; volumes?: boolean }): Promise<void> {
|
|
||||||
await requestJson(`/agents/${id}/prune`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(body ?? {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAgent(id: string): Promise<NodeAgentEntity | null> {
|
|
||||||
const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`);
|
|
||||||
return payload.agent ? normalizeAgent(payload.agent) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteAgent(id: string): Promise<void> {
|
|
||||||
await requestJson(`/agents/${id}`, { method: 'DELETE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentMetricPoint = {
|
|
||||||
timestamp: string;
|
|
||||||
cpu: { usage: number; usage_percent: number; cores: number };
|
|
||||||
memory: { usage: number; usage_percent: number; limit: number; available: number };
|
|
||||||
system_load: { load_1m: number; load_5m: number; load_15m: number };
|
|
||||||
container_count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getAgentMetrics(id: string, timeRange = '24h'): Promise<AgentMetricPoint[]> {
|
|
||||||
const payload = await requestJson<{ metrics?: AgentMetricPoint[] }>(
|
|
||||||
`/agents/${id}/metrics?time_range=${encodeURIComponent(timeRange)}`,
|
|
||||||
);
|
|
||||||
return payload.metrics ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentContainer = {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
image?: string;
|
|
||||||
status?: string;
|
|
||||||
service_id?: string;
|
|
||||||
created_at?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function listAgentContainers(id: string): Promise<AgentContainer[]> {
|
|
||||||
const payload = await requestJson<{ containers?: AgentContainer[] }>(`/agents/${id}/containers`);
|
|
||||||
return payload.containers ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentCommand = {
|
|
||||||
id?: string;
|
|
||||||
type?: string;
|
|
||||||
status?: string;
|
|
||||||
result?: string;
|
|
||||||
error?: string;
|
|
||||||
created_at?: string;
|
|
||||||
completed_at?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function listAgentCommands(id: string): Promise<AgentCommand[]> {
|
|
||||||
const payload = await requestJson<{ commands?: AgentCommand[] }>(`/agents/${id}/commands`);
|
|
||||||
return payload.commands ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentAuthToken = components['schemas']['AgentAuthToken'];
|
export type AgentAuthToken = components['schemas']['AgentAuthToken'];
|
||||||
export type AgentAuthTokenCreated = components['schemas']['AgentAuthTokenCreated'];
|
export type AgentAuthTokenCreated = components['schemas']['AgentAuthTokenCreated'];
|
||||||
|
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
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' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
// 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' },
|
|
||||||
];
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
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,4 +11,3 @@ 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';
|
|
||||||
@@ -5291,93 +5291,6 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/MessageResponse'
|
$ref: '#/components/schemas/MessageResponse'
|
||||||
|
|
||||||
/api/agents/install.sh:
|
|
||||||
servers:
|
|
||||||
- url: http://localhost:8080
|
|
||||||
get:
|
|
||||||
tags:
|
|
||||||
- Agents
|
|
||||||
summary: Agent install script
|
|
||||||
description: Public bootstrap script — `curl | bash -s -- --url <api> --token <enroll-token>`.
|
|
||||||
security: []
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Bourne shell installer
|
|
||||||
content:
|
|
||||||
text/x-sh:
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
|
|
||||||
/api/agents/download/{platform}:
|
|
||||||
servers:
|
|
||||||
- url: http://localhost:8080
|
|
||||||
get:
|
|
||||||
tags:
|
|
||||||
- Agents
|
|
||||||
summary: Download a bundled agent binary
|
|
||||||
description: "platform: linux-amd64 | linux-arm64. Bundled in release images; 404 when absent (install script falls back to GH releases)."
|
|
||||||
security: []
|
|
||||||
parameters:
|
|
||||||
- name: platform
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
enum: [linux-amd64, linux-arm64]
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Agent binary
|
|
||||||
content:
|
|
||||||
application/octet-stream:
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
format: binary
|
|
||||||
'404':
|
|
||||||
description: No bundled binary for platform
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/Error'
|
|
||||||
|
|
||||||
/agents/{id}/prune:
|
|
||||||
post:
|
|
||||||
tags:
|
|
||||||
- Agents
|
|
||||||
summary: Enqueue a bounded docker prune on a node
|
|
||||||
description: Queues a `prune` command — `docker system prune -af`, volumes excluded unless requested.
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/AgentIdParam'
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
until:
|
|
||||||
type: string
|
|
||||||
description: Only prune objects older than this duration (e.g. "168h").
|
|
||||||
volumes:
|
|
||||||
type: boolean
|
|
||||||
description: Also prune volumes — destructive, off by default.
|
|
||||||
responses:
|
|
||||||
'202':
|
|
||||||
description: Prune command queued
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
command:
|
|
||||||
$ref: '#/components/schemas/AgentCommand'
|
|
||||||
'404':
|
|
||||||
description: Agent not found
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/Error'
|
|
||||||
|
|
||||||
/agents/{id}/metrics:
|
/agents/{id}/metrics:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -7480,9 +7393,6 @@ 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
|
||||||
@@ -8897,9 +8807,6 @@ components:
|
|||||||
metadata:
|
metadata:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
auto_prune:
|
|
||||||
type: boolean
|
|
||||||
description: Enqueue a bounded docker prune on this node once per day.
|
|
||||||
last_heartbeat:
|
last_heartbeat:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -9133,11 +9040,6 @@ components:
|
|||||||
auth_token:
|
auth_token:
|
||||||
type: string
|
type: string
|
||||||
description: May also be sent via the X-Agent-Token header
|
description: May also be sent via the X-Agent-Token header
|
||||||
mesh:
|
|
||||||
type: object
|
|
||||||
additionalProperties:
|
|
||||||
type: string
|
|
||||||
description: Overlay-network interface IPs (tailscale/netbird/zerotier/wireguard). Stored under agent metadata.mesh; authoritative — send an empty object when no mesh interfaces exist.
|
|
||||||
|
|
||||||
AgentHeartbeat:
|
AgentHeartbeat:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -72,10 +72,6 @@ import compose <project-id> <compose.yml|->
|
|||||||
attached under their service name; --var K=V overrides
|
attached under their service name; --var K=V overrides
|
||||||
template variables
|
template variables
|
||||||
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
|
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
|
||||||
nodes add <user@host> SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs)
|
|
||||||
nodes update|prune|commands rename/auto-prune, bounded docker prune, command history
|
|
||||||
migrate railway pull a Railway project (services, vars, domains) via GraphQL and recreate it — --dry-run prints the plan
|
|
||||||
migrate dflow import a dflow project (services, vars, volumes, domains) via its Payload REST API — needs --url + --api-key, --dry-run prints the plan
|
|
||||||
scaling status|policies|services|scale
|
scaling status|policies|services|scale
|
||||||
ha status|alerts|health|policies|enable|disable|failover
|
ha status|alerts|health|policies|enable|disable|failover
|
||||||
security scan|vulnerabilities|metrics
|
security scan|vulnerabilities|metrics
|
||||||
|
|||||||
Reference in new issue
Block a user