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