Add Docker-over-SSH management and push-agent monitoring (#31)

Expands the Containers feature with two new ways to see and manage Docker
containers without exposing the Docker Engine TCP socket, plus the docs and
roadmap entries that frame them.

Docker over SSH (management):
- Runs the `docker` CLI on a remote SSH host instead of talking to the Engine
  TCP API, reusing the existing SSH transport (jump-host chaining, host-key
  verification, key/password auth) via connectTarget + execCommand. No dockerd
  socket has to be exposed — the mesh + SSH auth are the gate.
- backend/src/ssh/docker.ts: list/logs/start/stop/restart/pause/unpause/remove
  and an interactive `docker exec` shell builder. Container refs are validated
  against a strict allowlist and single-quoted to prevent command injection;
  action verbs are whitelisted.
- backend/src/routes/dockerSsh.ts: REST routes mirroring the TCP Docker API
  shape (mutating actions gated by adminOnly) + a /api/docker-ssh/exec
  WebSocket modeled on the terminal PTY plumbing.
- Note: the SSH path uses the ssh2 key/password auth; it does not implement the
  OpenSSH-certificate (OPKSSH) fallback that the terminal route has.

Docker push-agent monitoring (self-hosted, read-only):
- A small bash agent (agent/archnest-docker-agent.sh) runs on each Docker VM,
  collects a rich snapshot (docker ps + inspect + a stats snapshot), masks
  secret-looking env values locally, and POSTs it to ArchNest. VMs need
  outbound-only mesh access — no exposed port, no SSH for monitoring.
- backend/src/routes/agents.ts: token-gated ingest
  (POST /api/agents/docker/report, ARCHNEST_AGENT_TOKEN, constant-time compare;
  503 when unset, so it is disabled by default) plus user-auth read endpoints
  (hosts list with staleness flag, per-host containers, single-container
  detail). New docker_agent_reports table (latest report per host).
- Ingest stores data only; it never executes anything from the agent.

Containers page:
- Host selector now spans Docker API, SSH, and Agent sources.
- Intra-page tabs: a Containers list plus dynamic, closeable per-container
  detail tabs opened by clicking a container name. Agent detail shows
  overview/state/stats/ports/networks/mounts/env(masked)/labels; docker/ssh
  degrade gracefully. Agent rows are read-only; docker/ssh keep management.

Docs/roadmap:
- docs/docker-agent-monitoring.md (design doc, written before implementation).
- ROADMAP.md: LXC management (paid), Docker monitoring agent tiering
  (push self-hosted now / pull-agent paid), terminal grid tiering.

Deferred (documented, not built here): the mesh-prerequisite setup gate, the
paid pull-agent (Option 2), per-host tokens, time-series metrics.

Requires ARCHNEST_AGENT_TOKEN in the backend env to enable agent ingest.
Verified: backend `tsc --noEmit` and frontend `tsc -b && vite build` both pass;
agent jq filters, byte conversion, and `bash -n` checked locally.

Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
This commit is contained in:
Samuel James 2026-06-20 16:24:57 -04:00 committed by GitHub
parent b836ac1a02
commit 35fd7fc703
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1726 additions and 157 deletions

View file

@ -70,6 +70,72 @@ arbitrary pane count.
---
## LXC container management (Proxmox) — PAID ADD-ON
**Status:** not built; planned as a paid-tier feature.
ArchNest currently has full **Docker** container management (the Containers
page: list/start/stop/restart/pause/remove, logs, interactive exec — backed
by `backend/src/routes/docker.ts` + `backend/src/docker/`). There is **no LXC
equivalent**.
The only place LXC could surface today is the Proxmox integration's
`listResources()` (`backend/src/integrations/proxmox.ts`), and it currently
queries `/api2/json/cluster/resources?type=vm` — i.e. **QEMU VMs only**, so
Proxmox LXC containers (`type=lxc`) are not even listed.
Planned scope (paid tier):
- **List** LXC guests alongside VMs (drop/relax the `type=vm` filter, or also
fetch `type=lxc`, and label them in the resource grid).
- **Lifecycle** management via Proxmox's per-node LXC API
(`POST /api2/json/nodes/{node}/lxc/{vmid}/status/{start|stop|shutdown}`) —
a new route group + `api.ts` entries + UI, mirroring the Docker Containers
page.
- **Console/shell** into an LXC guest via the Proxmox console/ticket API
(more involved than Docker exec — separate auth/ticket flow).
Note: the read-only "list LXC in the resource grid" piece is small and
arguably a bug fix (the Proxmox integration silently hides half a cluster's
guests today); if the user later wants just that part in the free tier, it
can be split out from this paid add-on.
---
## Docker monitoring agent — tiered (push self-hosted / pull paid)
ArchNest can manage Docker containers two ways today: the Docker Engine TCP
integration (`backend/src/docker/`) and "Docker over SSH" (runs the `docker`
CLI on a remote SSH host — `backend/src/ssh/docker.ts`,
`backend/src/routes/dockerSsh.ts`). Both are **pull** models where ArchNest
reaches into the host.
A complementary **agent** model is planned, split across tiers:
### Self-hosted — Option 1: push agent (monitoring) — IN PROGRESS
- A lightweight script dropped on each Docker VM (bash + `docker` CLI + curl)
collects `docker ps` (+ optional per-container stats) and **POSTs** a JSON
report to an ArchNest ingest endpoint on a timer (cron/systemd).
- VMs need **outbound-only** access to ArchNest over the mesh — no exposed
port, no SSH, no dockerd socket. Cleanest security story for the free tier.
- ArchNest stores the latest report per host and surfaces it as a read-only
monitoring view / Infrastructure resource source.
- **Monitoring only** — a one-way push cannot perform actions. Management on
self-hosted continues to use the existing **Docker-over-SSH** path on
demand, so nothing is removed: push = constant monitoring (zero exposure),
SSH = occasional management action.
### Paid — Option 2: pull agent with local API (monitor + manage)
- A small **authenticated HTTP service** runs on each VM, bound to its mesh
IP, exposing a thin, locked-down wrapper over the Docker socket
(`/containers`, `/logs`, lifecycle actions, exec).
- ArchNest **pulls** on demand — supports both monitoring and management
through one uniform mechanism, with real per-agent auth (which the raw
dockerd TCP socket lacks).
- Tradeoff: exposes a (locked-down, authenticated) port on each VM, and is a
service to run/secure — hence gated to the paid tier.
---
## Known non-blocking stubs (cosmetic, not scheduled)
Not flagged as work to do unless explicitly asked:

125
agent/README.md Normal file
View file

@ -0,0 +1,125 @@
# ArchNest Docker monitoring agent
A small push agent that reports this host's Docker containers to ArchNest. See
the design in [`docs/docker-agent-monitoring.md`](../docs/docker-agent-monitoring.md).
It is **monitoring only** — it pushes data outbound to ArchNest and never
receives or runs commands. Container management stays on ArchNest's
Docker-over-SSH / Docker API paths.
## Requirements
`bash`, `docker`, `curl`, `jq`. Install `jq` if missing:
```bash
# Debian/Ubuntu
sudo apt-get install -y jq
# RHEL/Alma/Rocky
sudo dnf install -y jq
# Alpine
sudo apk add jq
```
The user running the agent must be able to run `docker` (in the `docker` group
or via root).
## Install
1. Copy the script onto the VM and make it executable:
```bash
sudo install -m 0755 archnest-docker-agent.sh /usr/local/bin/archnest-docker-agent
```
2. Create the config file (keep it root-only — it holds the token):
```bash
sudo mkdir -p /etc/archnest
sudo tee /etc/archnest/agent.env >/dev/null <<'EOF'
ARCHNEST_URL=http://<archnest-mesh-ip>:4000
ARCHNEST_AGENT_TOKEN=<the shared token, same as the backend>
ARCHNEST_HOST_ID=proxmox-vm-1
# ARCHNEST_HOSTNAME=docker01 # optional; defaults to `hostname`
EOF
sudo chmod 600 /etc/archnest/agent.env
```
`ARCHNEST_URL` must point at the ArchNest backend over your **mesh / private
network**, never a public address — the ingest endpoint is protected only by
the shared token at the application layer.
3. Run it once to verify:
```bash
sudo archnest-docker-agent
# -> "reported N container(s) as 'proxmox-vm-1' (HTTP 200)"
```
## Schedule it (pick one)
Report interval should be **shorter than the backend's stale window**
(`ARCHNEST_AGENT_STALE_MS`, default 90s). 30s is a good default.
### Option A — cron (every minute; simplest)
```cron
* * * * * root /usr/local/bin/archnest-docker-agent >/dev/null 2>&1
```
(cron's finest granularity is 1 minute; raise `ARCHNEST_AGENT_STALE_MS` to e.g.
150000 on the backend if you use a 1-minute cron.)
### Option B — systemd service + timer (recommended; supports 30s)
`/etc/systemd/system/archnest-docker-agent.service`:
```ini
[Unit]
Description=ArchNest Docker monitoring agent
After=docker.service
Wants=docker.service
[Service]
Type=oneshot
EnvironmentFile=/etc/archnest/agent.env
ExecStart=/usr/local/bin/archnest-docker-agent
```
`/etc/systemd/system/archnest-docker-agent.timer`:
```ini
[Unit]
Description=Run ArchNest Docker monitoring agent every 30s
[Timer]
OnBootSec=30
OnUnitActiveSec=30
AccuracySec=5s
[Install]
WantedBy=timers.target
```
Enable:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now archnest-docker-agent.timer
sudo systemctl list-timers archnest-docker-agent.timer # confirm scheduling
journalctl -u archnest-docker-agent.service -n 20 # see last run output
```
## Backend configuration
The backend must have `ARCHNEST_AGENT_TOKEN` set (the same value as the agent).
If it is unset, the ingest endpoint is disabled and returns HTTP 503. Optional:
`ARCHNEST_AGENT_STALE_MS` (default 90000) controls when a host is shown stale.
## Security notes
- The token is a credential — treat `/etc/archnest/agent.env` as sensitive
(`chmod 600`, root-owned).
- The agent masks env var values whose key matches
`PASS|SECRET|TOKEN|KEY|PRIVATE|CREDENTIAL` before sending; the full values
never leave the VM.
- Expose the ArchNest ingest endpoint on the mesh only, not the public internet.

View file

@ -0,0 +1,175 @@
#!/usr/bin/env bash
#
# ArchNest Docker monitoring agent (self-hosted, push model).
#
# Collects a rich snapshot of this host's Docker containers (docker ps +
# docker inspect + a docker stats snapshot) and POSTs it to ArchNest. ArchNest
# stores the latest report per host and shows it read-only on the Containers
# page. This is MONITORING ONLY — it never receives or runs commands.
#
# Requirements: bash, docker, curl, jq.
#
# Configuration (env vars; may live in /etc/archnest/agent.env):
# ARCHNEST_URL Base URL of the ArchNest backend, reachable over your
# mesh / private network, e.g. http://100.64.0.5:4000
# ARCHNEST_AGENT_TOKEN Shared token; must match the backend's ARCHNEST_AGENT_TOKEN.
# ARCHNEST_HOST_ID Stable id for this host, e.g. "proxmox-vm-1"
# (allowed: letters, digits, . _ - ; max 128 chars).
# ARCHNEST_HOSTNAME Optional display hostname (defaults to `hostname`).
#
# Exit codes: 0 ok, 1 misconfig/missing deps, 2 report POST failed.
set -euo pipefail
AGENT_VERSION="1"
# Load config file if present (does not override already-exported env).
if [ -f /etc/archnest/agent.env ]; then
# shellcheck disable=SC1091
. /etc/archnest/agent.env
fi
err() { echo "archnest-docker-agent: $*" >&2; }
# --- Dependency + config checks -------------------------------------------
for bin in docker curl jq; do
if ! command -v "$bin" >/dev/null 2>&1; then
err "missing required dependency: $bin"
exit 1
fi
done
: "${ARCHNEST_URL:?ARCHNEST_URL is required}"
: "${ARCHNEST_AGENT_TOKEN:?ARCHNEST_AGENT_TOKEN is required}"
: "${ARCHNEST_HOST_ID:?ARCHNEST_HOST_ID is required}"
HOSTNAME_VALUE="${ARCHNEST_HOSTNAME:-$(hostname)}"
if ! printf '%s' "$ARCHNEST_HOST_ID" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$'; then
err "ARCHNEST_HOST_ID '$ARCHNEST_HOST_ID' is invalid (allowed: A-Z a-z 0-9 . _ - , max 128)"
exit 1
fi
REPORT_URL="${ARCHNEST_URL%/}/api/agents/docker/report"
# --- Collect container ids -------------------------------------------------
mapfile -t IDS < <(docker ps --all --no-trunc --format '{{.ID}}')
# --- Stats snapshot (one shot) keyed by full id ----------------------------
# `docker stats` reports a short id; we map short->full via the ids list.
# Build a jq object: { "<shortid>": {cpu,mem,...} }.
STATS_JSON="$(docker stats --no-stream --no-trunc \
--format '{{.ID}}|{{.CPUPerc}}|{{.MemUsage}}|{{.NetIO}}|{{.BlockIO}}' 2>/dev/null \
| jq -R -s '
def bytes:
# converts "12.3MiB" / "1.2GB" etc to a number of bytes
capture("(?<n>[0-9.]+)\\s*(?<u>[A-Za-z]*)") as $m
| ($m.n | tonumber) as $n
| ($m.u | ascii_downcase) as $u
| $n * (
if $u|startswith("ki") then 1024
elif $u|startswith("mi") then 1048576
elif $u|startswith("gi") then 1073741824
elif $u|startswith("ti") then 1099511627776
elif $u|startswith("kb") or $u=="k" then 1000
elif $u|startswith("mb") or $u=="m" then 1000000
elif $u|startswith("gb") or $u=="g" then 1000000000
elif $u|startswith("tb") or $u=="t" then 1000000000000
elif $u|startswith("b") or $u=="" then 1
else 1 end
) | floor;
split("\n") | map(select(length > 0)) | map(split("|")) | map({
key: .[0],
value: {
cpuPercent: (.[1] | gsub("%";"") | tonumber? // 0),
memUsage: (.[2] | split("/")[0] | gsub(" ";"") | (try bytes catch 0)),
memLimit: (.[2] | split("/")[1] | gsub(" ";"") | (try bytes catch 0)),
netRxBytes: (.[3] | split("/")[0] | gsub(" ";"") | (try bytes catch 0)),
netTxBytes: (.[3] | split("/")[1] | gsub(" ";"") | (try bytes catch 0)),
blockReadBytes: (.[4] | split("/")[0] | gsub(" ";"") | (try bytes catch 0)),
blockWriteBytes: (.[4] | split("/")[1] | gsub(" ";"") | (try bytes catch 0))
}
}) | from_entries
')"
[ -z "$STATS_JSON" ] && STATS_JSON='{}'
# --- Per-container detail from docker inspect ------------------------------
# jq transform turning one inspect object into our report schema, masking
# secret-looking env values.
INSPECT_FILTER='
def mask($k): ($k | ascii_upcase) as $u
| ($u | test("PASS|SECRET|TOKEN|KEY|PRIVATE|CREDENTIAL"));
.[0] as $c
| {
id: $c.Id,
name: ($c.Name // "" | ltrimstr("/")),
image: ($c.Config.Image // ""),
imageId: ($c.Image // ""),
state: ($c.State.Status // "unknown"),
status: ($c.State.Status // ""),
createdAt: ($c.Created // null),
startedAt: ($c.State.StartedAt // null),
restartCount: ($c.RestartCount // 0),
restartPolicy: ($c.HostConfig.RestartPolicy.Name // ""),
health: ($c.State.Health.Status // "none"),
ports: (
($c.NetworkSettings.Ports // {}) | to_entries | map(
(.key | split("/")) as $p
| (.value // [])[]? as $b
| { hostIp: ($b.HostIp // ""), hostPort: ($b.HostPort | tonumber? // null),
containerPort: ($p[0] | tonumber? // 0), proto: ($p[1] // "tcp") }
)
),
networks: (
($c.NetworkSettings.Networks // {}) | to_entries
| map({ name: .key, ip: (.value.IPAddress // "") })
),
mounts: (
($c.Mounts // []) | map({
type: (.Type // ""), source: (.Source // .Name // ""),
destination: (.Destination // ""), rw: (.RW // true)
})
),
env: (
($c.Config.Env // []) | map(
(. | split("=")) as $kv
| { key: $kv[0], value: (if mask($kv[0]) then "********" else ($kv[1:] | join("=")) end) }
)
),
command: (($c.Config.Entrypoint // []) + ($c.Config.Cmd // []) | join(" ")),
labels: ($c.Config.Labels // {})
}
'
CONTAINERS='[]'
for id in "${IDS[@]}"; do
[ -z "$id" ] && continue
detail="$(docker inspect "$id" 2>/dev/null | jq -c "$INSPECT_FILTER" 2>/dev/null || true)"
[ -z "$detail" ] && continue
short="${id:0:12}"
# Attach the matching stats snapshot (match by full or short id).
detail="$(jq -c --argjson stats "$STATS_JSON" --arg id "$id" --arg short "$short" \
'. + { stats: ($stats[$id] // $stats[$short] // null) }' <<<"$detail")"
CONTAINERS="$(jq -c --argjson c "$detail" '. + [$c]' <<<"$CONTAINERS")"
done
# --- Assemble + POST -------------------------------------------------------
PAYLOAD="$(jq -n \
--arg hostId "$ARCHNEST_HOST_ID" \
--arg hostname "$HOSTNAME_VALUE" \
--arg agentVersion "$AGENT_VERSION" \
--arg reportedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--argjson containers "$CONTAINERS" \
'{ hostId: $hostId, hostname: $hostname, agentVersion: $agentVersion, reportedAt: $reportedAt, containers: $containers }')"
HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' \
-X POST "$REPORT_URL" \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer ${ARCHNEST_AGENT_TOKEN}" \
--data-binary "$PAYLOAD" || echo "000")"
if [ "$HTTP_CODE" != "200" ]; then
err "report POST to $REPORT_URL failed (HTTP $HTTP_CODE)"
exit 2
fi
echo "archnest-docker-agent: reported ${#IDS[@]} container(s) as '$ARCHNEST_HOST_ID' (HTTP $HTTP_CODE)"

View file

@ -104,6 +104,14 @@ db.exec(`
success INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS docker_agent_reports (
host_id TEXT PRIMARY KEY,
hostname TEXT,
report_json TEXT NOT NULL,
reported_at TEXT,
received_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`)
export function logEvent(type: string, title: string, source?: string | null) {

View file

@ -0,0 +1,200 @@
import type { FastifyInstance, FastifyRequest } from 'fastify'
import { timingSafeEqual } from 'node:crypto'
import { z } from 'zod'
import { db } from '../db/index.js'
/**
* Docker monitoring agents (self-hosted, push model). Agents on each Docker VM
* POST a rich container report here; ArchNest stores the latest per host and
* serves it read-only to the UI. See docs/docker-agent-monitoring.md.
*
* Ingest is gated by a shared ARCHNEST_AGENT_TOKEN (NOT the user JWT). Read
* endpoints are behind the normal user authenticate hook.
*/
// Reports older than this (by server receive time) are flagged stale.
const STALE_AFTER_MS = Number(process.env.ARCHNEST_AGENT_STALE_MS) || 90_000
const HOST_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
const portSchema = z.object({
hostIp: z.string().optional(),
hostPort: z.number().int().nullable().optional(),
containerPort: z.number().int(),
proto: z.string(),
})
const containerSchema = z.object({
id: z.string().min(1).max(128),
name: z.string().max(256),
image: z.string().max(512).default(''),
imageId: z.string().max(256).optional(),
state: z.string().max(32).default('unknown'),
status: z.string().max(256).default(''),
createdAt: z.string().max(64).optional(),
startedAt: z.string().max(64).optional(),
restartCount: z.number().int().optional(),
restartPolicy: z.string().max(64).optional(),
health: z.string().max(32).optional(),
ports: z.array(portSchema).max(200).default([]),
networks: z.array(z.object({ name: z.string(), ip: z.string().optional() })).max(50).default([]),
mounts: z
.array(
z.object({
type: z.string().optional(),
source: z.string().optional(),
destination: z.string().optional(),
rw: z.boolean().optional(),
}),
)
.max(100)
.default([]),
env: z.array(z.object({ key: z.string(), value: z.string() })).max(500).default([]),
command: z.string().max(2048).optional(),
labels: z.record(z.string(), z.string()).optional(),
stats: z
.object({
cpuPercent: z.number().optional(),
memUsage: z.number().optional(),
memLimit: z.number().optional(),
netRxBytes: z.number().optional(),
netTxBytes: z.number().optional(),
blockReadBytes: z.number().optional(),
blockWriteBytes: z.number().optional(),
})
.optional(),
})
const reportSchema = z.object({
hostId: z.string().regex(HOST_ID_RE, 'Invalid hostId'),
hostname: z.string().max(256).optional(),
agentVersion: z.string().max(32).optional(),
reportedAt: z.string().max(64).optional(),
containers: z.array(containerSchema).max(1000),
})
export type AgentReport = z.infer<typeof reportSchema>
export type AgentContainer = z.infer<typeof containerSchema>
interface ReportRow {
host_id: string
hostname: string | null
report_json: string
reported_at: string | null
received_at: string
}
/** Constant-time bearer-token check against ARCHNEST_AGENT_TOKEN. */
function agentTokenValid(req: FastifyRequest): { ok: boolean; configured: boolean } {
const expected = process.env.ARCHNEST_AGENT_TOKEN
if (!expected) return { ok: false, configured: false }
const header = req.headers.authorization ?? ''
const presented = header.startsWith('Bearer ') ? header.slice(7) : ''
const a = Buffer.from(presented)
const b = Buffer.from(expected)
if (a.length !== b.length) return { ok: false, configured: true }
return { ok: timingSafeEqual(a, b), configured: true }
}
function isStale(receivedAt: string): boolean {
const t = Date.parse(receivedAt.replace(' ', 'T') + 'Z')
if (Number.isNaN(t)) return false
return Date.now() - t > STALE_AFTER_MS
}
/** Token-gated ingest. Registered separately so it is NOT behind the user-auth hook. */
export async function agentIngestRoutes(app: FastifyInstance) {
app.post('/api/agents/docker/report', async (req, reply) => {
const auth = agentTokenValid(req)
if (!auth.configured) {
return reply.code(503).send({ error: 'Agent ingest is disabled (ARCHNEST_AGENT_TOKEN not configured)' })
}
if (!auth.ok) {
return reply.code(401).send({ error: 'Unauthorized' })
}
const parsed = reportSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid report' })
}
const { hostId, hostname, reportedAt, containers } = parsed.data
db.prepare(
`INSERT INTO docker_agent_reports (host_id, hostname, report_json, reported_at, received_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(host_id) DO UPDATE SET
hostname = excluded.hostname,
report_json = excluded.report_json,
reported_at = excluded.reported_at,
received_at = datetime('now')`,
).run(hostId, hostname ?? null, JSON.stringify(containers), reportedAt ?? null)
return { ok: true }
})
}
/** Read-only query endpoints, behind the user authenticate hook. */
export async function agentRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate)
app.get('/api/agents/docker/hosts', async () => {
const rows = db
.prepare('SELECT host_id, hostname, report_json, reported_at, received_at FROM docker_agent_reports ORDER BY host_id')
.all() as ReportRow[]
return {
hosts: rows.map((r) => {
let count = 0
try {
count = (JSON.parse(r.report_json) as unknown[]).length
} catch {
count = 0
}
return {
hostId: r.host_id,
hostname: r.hostname,
reportedAt: r.reported_at,
receivedAt: r.received_at,
containerCount: count,
stale: isStale(r.received_at),
}
}),
}
})
app.get('/api/agents/docker/hosts/:hostId/containers', async (req, reply) => {
const hostId = (req.params as { hostId: string }).hostId
const row = db
.prepare('SELECT host_id, hostname, report_json, reported_at, received_at FROM docker_agent_reports WHERE host_id = ?')
.get(hostId) as ReportRow | undefined
if (!row) return reply.code(404).send({ error: 'Host not reported' })
let containers: AgentContainer[] = []
try {
containers = JSON.parse(row.report_json) as AgentContainer[]
} catch {
containers = []
}
return {
hostId: row.host_id,
hostname: row.hostname,
reportedAt: row.reported_at,
receivedAt: row.received_at,
stale: isStale(row.received_at),
containers,
}
})
app.get('/api/agents/docker/hosts/:hostId/containers/:containerId', async (req, reply) => {
const { hostId, containerId } = req.params as { hostId: string; containerId: string }
const row = db
.prepare('SELECT report_json FROM docker_agent_reports WHERE host_id = ?')
.get(hostId) as { report_json: string } | undefined
if (!row) return reply.code(404).send({ error: 'Host not reported' })
let containers: AgentContainer[] = []
try {
containers = JSON.parse(row.report_json) as AgentContainer[]
} catch {
containers = []
}
// Match by full id or a unique short-id prefix.
const container = containers.find((c) => c.id === containerId || c.id.startsWith(containerId))
if (!container) return reply.code(404).send({ error: 'Container not found in latest report' })
return { container }
})
}

View file

@ -0,0 +1,175 @@
import type { FastifyInstance } from 'fastify'
import { Client, type ClientChannel } from 'ssh2'
import { z } from 'zod'
import { loadSshHost, connectTarget } from '../ssh/connect.js'
import {
withSshClient,
listContainers,
containerLogs,
containerAction,
removeContainer,
buildExecShellCommand,
isDockerAction,
isValidContainerRef,
} from '../ssh/docker.js'
function sendJson(socket: { send: (data: string) => void }, payload: Record<string, unknown>) {
socket.send(JSON.stringify(payload))
}
interface ExecMessage {
type: 'connect' | 'input' | 'resize' | 'disconnect'
integrationId?: number
containerId?: string
cols?: number
rows?: number
data?: string
}
/**
* "Docker over SSH" REST routes. These target an SSH integration (not the
* Docker TCP integration) and shell out to the `docker` CLI on the remote host.
* Mutating actions are admin-only, matching the policy for the TCP Docker routes
* and the rest of the shared-config surface.
*/
export async function dockerSshRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate)
app.get('/api/docker-ssh/:integrationId/containers', async (req, reply) => {
const integrationId = Number((req.params as { integrationId: string }).integrationId)
const result = await withSshClient(integrationId, (client) => listContainers(client))
if (!result.ok) return reply.code(502).send({ error: result.error })
return { containers: result.value }
})
app.get('/api/docker-ssh/:integrationId/containers/:id/logs', async (req, reply) => {
const { integrationId, id } = req.params as { integrationId: string; id: string }
if (!isValidContainerRef(id)) return reply.code(400).send({ error: 'Invalid container reference' })
const tail = Number((req.query as { tail?: string }).tail ?? '200')
const result = await withSshClient(Number(integrationId), (client) => containerLogs(client, id, tail))
if (!result.ok) return reply.code(502).send({ error: result.error })
return { logs: result.value }
})
app.post('/api/docker-ssh/:integrationId/containers/:id/:action', { onRequest: [app.adminOnly] }, async (req, reply) => {
const { integrationId, id, action } = req.params as { integrationId: string; id: string; action: string }
if (!isValidContainerRef(id)) return reply.code(400).send({ error: 'Invalid container reference' })
if (!isDockerAction(action)) return reply.code(400).send({ error: 'Invalid action' })
const result = await withSshClient(Number(integrationId), (client) => containerAction(client, id, action))
if (!result.ok) return reply.code(502).send({ error: result.error })
return { ok: true }
})
app.post('/api/docker-ssh/:integrationId/containers/:id/remove', { onRequest: [app.adminOnly] }, async (req, reply) => {
const { integrationId, id } = req.params as { integrationId: string; id: string }
if (!isValidContainerRef(id)) return reply.code(400).send({ error: 'Invalid container reference' })
const parsed = z.object({ force: z.boolean().default(false) }).safeParse(req.body ?? {})
if (!parsed.success) return reply.code(400).send({ error: 'Invalid input' })
const result = await withSshClient(Number(integrationId), (client) => removeContainer(client, id, parsed.data.force))
if (!result.ok) return reply.code(502).send({ error: result.error })
return { ok: true }
})
}
/**
* Interactive `docker exec` shell over a PTY, wired to a WebSocket. Models the
* terminal route's plumbing but runs the exec command on the SSH host instead
* of opening a login shell.
*/
export async function dockerSshExecRoutes(app: FastifyInstance) {
app.get('/api/docker-ssh/exec', { websocket: true }, (socket, req) => {
let conn: Client | null = null
let jumpConn: Client | null = null
let stream: ClientChannel | null = null
const cleanup = () => {
stream?.end()
conn?.end()
jumpConn?.end()
stream = null
conn = null
jumpConn = null
}
socket.on('close', cleanup)
socket.on('message', async (raw: Buffer) => {
let msg: ExecMessage
try {
msg = JSON.parse(raw.toString())
} catch {
sendJson(socket, { type: 'error', message: 'Invalid JSON' })
return
}
if (msg.type === 'connect') {
const query = req.query as { token?: string }
try {
await app.jwt.verify(query.token ?? '')
} catch {
sendJson(socket, { type: 'error', message: 'Unauthorized' })
socket.close()
return
}
const target = msg.integrationId !== undefined ? loadSshHost(msg.integrationId) : null
if (!target) {
sendJson(socket, { type: 'error', message: 'SSH integration not found' })
return
}
if (!msg.containerId || !isValidContainerRef(msg.containerId)) {
sendJson(socket, { type: 'error', message: 'Invalid container reference' })
return
}
let command: string
try {
command = buildExecShellCommand(msg.containerId)
} catch (err) {
sendJson(socket, { type: 'error', message: err instanceof Error ? err.message : 'Invalid container reference' })
return
}
const cols = msg.cols ?? 80
const rows = msg.rows ?? 24
const startSession = (client: Client) => {
conn = client
client.exec(command, { pty: { cols, rows, term: 'xterm-256color' } }, (err, ch) => {
if (err) {
sendJson(socket, { type: 'error', message: err.message })
return
}
stream = ch
sendJson(socket, { type: 'ready' })
ch.on('data', (chunk: Buffer) => sendJson(socket, { type: 'data', data: chunk.toString('utf8') }))
ch.stderr.on('data', (chunk: Buffer) => sendJson(socket, { type: 'data', data: chunk.toString('utf8') }))
ch.on('close', () => {
sendJson(socket, { type: 'exit' })
cleanup()
})
})
}
const result = connectTarget(target, startSession, (message) => sendJson(socket, { type: 'error', message }))
conn = result.conn
jumpConn = result.jumpConn
return
}
if (msg.type === 'input') {
stream?.write(msg.data ?? '')
return
}
if (msg.type === 'resize') {
stream?.setWindow(msg.rows ?? 24, msg.cols ?? 80, 0, 0)
return
}
if (msg.type === 'disconnect') {
cleanup()
socket.close()
}
})
})
}

View file

@ -12,6 +12,8 @@ import { terminalRoutes } from './routes/terminal.js'
import { tunnelRoutes } from './routes/tunnels.js'
import { fileRoutes } from './routes/files.js'
import { dockerRoutes, dockerExecRoutes } from './routes/docker.js'
import { dockerSshRoutes, dockerSshExecRoutes } from './routes/dockerSsh.js'
import { agentIngestRoutes, agentRoutes } from './routes/agents.js'
import { guacamoleRoutes } from './routes/guacamole.js'
import { metricsRoutes } from './routes/metrics.js'
import { transferRoutes } from './routes/transfer.js'
@ -89,6 +91,10 @@ await app.register(tunnelRoutes)
await app.register(fileRoutes)
await app.register(dockerRoutes)
await app.register(dockerExecRoutes)
await app.register(dockerSshRoutes)
await app.register(dockerSshExecRoutes)
await app.register(agentIngestRoutes)
await app.register(agentRoutes)
await app.register(guacamoleRoutes)
await app.register(metricsRoutes)
await app.register(transferRoutes)

152
backend/src/ssh/docker.ts Normal file
View file

@ -0,0 +1,152 @@
import { Client } from 'ssh2'
import { connectTarget, loadSshHost, type SshHost } from './connect.js'
import { execCommand } from './metrics/common.js'
/**
* "Docker over SSH": instead of talking to the Docker Engine TCP API, run the
* `docker` CLI on a remote SSH host. This reuses the existing SSH transport
* (jump-host chaining, host-key verification, cert/key/password auth) so no
* dockerd TCP socket has to be exposed the mesh + SSH auth are the gate.
*
* Container ids/names come from the client and are interpolated into shell
* commands, so every one is validated against this strict allowlist and passed
* single-quoted. Anything outside this set is rejected before a command runs.
*/
const CONTAINER_REF_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/
export function isValidContainerRef(ref: string): boolean {
return CONTAINER_REF_RE.test(ref)
}
/** Single-quote a value for safe use as one shell argument. */
function shQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`
}
export const DOCKER_ACTIONS = ['start', 'stop', 'restart', 'pause', 'unpause'] as const
export type DockerAction = (typeof DOCKER_ACTIONS)[number]
export function isDockerAction(value: string): value is DockerAction {
return (DOCKER_ACTIONS as readonly string[]).includes(value)
}
export interface SshContainer {
id: string
name: string
image: string
state: string
status: string
ports: string
}
/**
* Connects to the SSH host for `integrationId`, runs `fn` with a ready ssh2
* Client, and tears the connection (and any jump host) down afterwards.
* Mirrors the connect-once pattern used by the metrics route.
*/
export async function withSshClient<T>(
integrationId: number,
fn: (client: Client) => Promise<T>,
): Promise<{ ok: true; value: T } | { ok: false; error: string }> {
const target: SshHost | null = loadSshHost(integrationId)
if (!target) return { ok: false, error: 'SSH integration not found' }
const jumpRef: { current: Client | null } = { current: null }
const client = await new Promise<Client | null>((resolve) => {
const { jumpConn } = connectTarget(
target,
(c) => resolve(c),
() => {
jumpConn?.end()
resolve(null)
},
)
jumpRef.current = jumpConn
})
if (!client) return { ok: false, error: 'Failed to connect to host' }
try {
const value = await fn(client)
return { ok: true, value }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Command failed' }
} finally {
client.end()
jumpRef.current?.end()
}
}
/**
* Lists containers via `docker ps`. Uses `--format '{{json .}}'` which emits one
* JSON object per line (the documented stable CLI format), avoiding fragile
* column parsing.
*/
export async function listContainers(client: Client): Promise<SshContainer[]> {
const { stdout, stderr, code } = await execCommand(
client,
"docker ps --all --no-trunc --format '{{json .}}'",
)
if (code !== 0) {
throw new Error(stderr.trim() || `docker ps exited with code ${code}`)
}
const containers: SshContainer[] = []
for (const line of stdout.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
try {
const row = JSON.parse(trimmed) as Record<string, string>
containers.push({
id: row.ID ?? '',
name: row.Names ?? '',
image: row.Image ?? '',
state: row.State ?? '',
status: row.Status ?? '',
ports: row.Ports ?? '',
})
} catch {
// Skip any line that isn't valid JSON rather than failing the whole list.
}
}
return containers
}
export async function containerLogs(client: Client, ref: string, tail: number): Promise<string> {
if (!isValidContainerRef(ref)) throw new Error('Invalid container reference')
const safeTail = Number.isFinite(tail) && tail > 0 ? Math.min(Math.floor(tail), 5000) : 200
const { stdout, stderr, code } = await execCommand(
client,
`docker logs --tail ${safeTail} ${shQuote(ref)} 2>&1`,
)
if (code !== 0 && !stdout) {
throw new Error(stderr.trim() || `docker logs exited with code ${code}`)
}
// `2>&1` folds stderr into stdout so interleaved container logs are preserved.
return stdout
}
export async function containerAction(client: Client, ref: string, action: DockerAction): Promise<void> {
if (!isValidContainerRef(ref)) throw new Error('Invalid container reference')
const { stderr, code } = await execCommand(client, `docker ${action} ${shQuote(ref)}`)
if (code !== 0) {
throw new Error(stderr.trim() || `docker ${action} exited with code ${code}`)
}
}
export async function removeContainer(client: Client, ref: string, force: boolean): Promise<void> {
if (!isValidContainerRef(ref)) throw new Error('Invalid container reference')
const flag = force ? '--force ' : ''
const { stderr, code } = await execCommand(client, `docker rm ${flag}${shQuote(ref)}`)
if (code !== 0) {
throw new Error(stderr.trim() || `docker rm exited with code ${code}`)
}
}
/** Builds the remote command for an interactive `docker exec` shell (used over a PTY). */
export function buildExecShellCommand(ref: string): string {
if (!isValidContainerRef(ref)) throw new Error('Invalid container reference')
// Try bash first, fall back to sh, so it works on minimal images too. The
// ref is validated + single-quoted; the trailing snippet is a fixed string.
const quoted = shQuote(ref)
return `docker exec -it ${quoted} bash 2>/dev/null || docker exec -it ${quoted} sh`
}

View file

@ -0,0 +1,215 @@
# Docker Agent Monitoring (self-hosted, push model)
Design doc for the self-hosted **Docker push-agent monitoring** feature
(Option 1 in `ROADMAP.md` → "Docker monitoring agent"). Written before
implementation; this is the contract the code should match.
## Goal
Let ArchNest **monitor** Docker containers across multiple VMs without ArchNest
reaching into those VMs. A small agent script runs on each Docker host, gathers
rich container data, and **pushes** it to ArchNest. ArchNest stores the latest
report per host and renders it read-only on the Containers page.
This is monitoring only. **Management (start/stop/restart/exec) is unchanged**
and continues to use the existing Docker-over-SSH path
(`backend/src/ssh/docker.ts`, `backend/src/routes/dockerSsh.ts`) and the Docker
Engine TCP integration (`backend/src/docker/`). A one-way push cannot perform
actions, by design — so nothing about management is removed.
## Why push (for self-hosted)
- VMs need **outbound-only** reachability to ArchNest. No exposed port, no
dockerd TCP socket, no inbound SSH required for monitoring.
- Decoupled from SSH auth entirely (sidesteps the cert/OPKSSH auth gap that
affects the Docker-over-SSH path).
- Simplest thing to "drop on any VM": a bash script + cron/systemd timer.
The richer **pull agent** (on-demand monitor + manage via a local authenticated
HTTP API on each VM) is the **paid** tier — see `ROADMAP.md`, not built here.
## Architecture
```
Docker VM (agent.sh, every N s) ArchNest backend Browser
docker ps --format json ─┐
docker inspect <id>... ├─> JSON report ──POST /api/agents/docker/report──> upsert latest
docker stats --no-stream ─┘ (Bearer: ARCHNEST_AGENT_TOKEN) per host_id in SQLite
GET /api/agents/docker/... <────────┘ (user JWT)
Containers page (read-only)
```
## Security
- **Ingest is token-gated, not user-gated.** `POST /api/agents/docker/report`
is authenticated by a single shared secret `ARCHNEST_AGENT_TOKEN` (env var on
the backend, same value in each agent script), compared in **constant time**.
If the env var is unset, the ingest endpoint is **disabled** (returns 503) —
the server never accepts unauthenticated reports.
- **Ingest must be reachable on the mesh / non-public IP only.** The token is
the application-layer guard; network-layer the endpoint should not be exposed
publicly. (A separate, later initiative — the "mesh prerequisite gate" — will
enforce mesh setup app-wide; this doc does not implement that gate. Until it
exists, mesh-only reachability is an operational/deployment responsibility.)
- **Ingest only stores data — it never executes anything from the agent.** The
payload is validated with zod and persisted as-is; there is no command path,
so there is no injection surface from agent input.
- **Read endpoints are behind the normal user `authenticate` hook**, so any
logged-in user can view monitoring data (consistent with the Phase 3 model:
members can view everything). They are read-only.
- Single shared token now; **per-host revocable tokens** are a noted future
improvement, not in this iteration.
## Report schema (rich)
The agent posts one report per host. `host_id` is a stable, user-chosen
identifier; `hostname` is informational.
```jsonc
{
"hostId": "proxmox-vm-1", // stable id, [A-Za-z0-9._-], required
"hostname": "docker01", // informational
"agentVersion": "1",
"reportedAt": "2026-06-20T19:30:00Z", // agent clock; server also records its own receivedAt
"containers": [
{
"id": "<full container id>",
"name": "myapp",
"image": "nginx:1.27",
"imageId": "sha256:...",
"state": "running", // running|exited|paused|created|restarting|dead
"status": "Up 3 hours", // human string from docker ps
"createdAt": "2026-06-20T16:00:00Z",
"startedAt": "2026-06-20T16:00:01Z",
"restartCount": 0,
"restartPolicy": "unless-stopped",
"health": "healthy", // healthy|unhealthy|starting|none
"ports": [ // normalized from inspect
{ "hostIp": "0.0.0.0", "hostPort": 8080, "containerPort": 80, "proto": "tcp" }
],
"networks": [
{ "name": "bridge", "ip": "172.17.0.2" }
],
"mounts": [
{ "type": "volume", "source": "myapp_data", "destination": "/data", "rw": true }
],
"env": [ // SECRETS MASKED (see below)
{ "key": "NODE_ENV", "value": "production" },
{ "key": "DB_PASSWORD", "value": "********" }
],
"command": "nginx -g 'daemon off;'",
"labels": { "com.docker.compose.project": "myapp" },
"stats": { // snapshot from docker stats --no-stream
"cpuPercent": 1.4,
"memUsage": 20971520,
"memLimit": 536870912,
"netRxBytes": 12345,
"netTxBytes": 67890,
"blockReadBytes": 0,
"blockWriteBytes": 0
}
}
]
}
```
### Env masking
The agent masks values whose key matches a secret-ish pattern
(`/(PASS|SECRET|TOKEN|KEY|PRIVATE|CREDENTIAL)/i`) before sending, replacing the
value with `********`. The full value never leaves the VM. (Defense in depth;
the backend also will not display unmasked secrets.)
### Source capability note
The Containers page already aggregates three sources (Docker TCP API, Docker
over SSH, and now agent). Not every field exists for every source — the UI must
**degrade gracefully** and show "—" / "not available from this source" rather
than erroring. The agent is the richest source (it runs `docker inspect`).
## Backend
### DB
New table, latest-report-per-host (idempotent migration in
`backend/src/db/index.ts`):
```sql
CREATE TABLE IF NOT EXISTS docker_agent_reports (
host_id TEXT PRIMARY KEY,
hostname TEXT,
report_json TEXT NOT NULL, -- the full containers array as JSON
reported_at TEXT, -- agent-supplied timestamp
received_at TEXT NOT NULL DEFAULT (datetime('now')) -- server receive time (source of truth for staleness)
);
```
We keep only the latest report per `host_id` (upsert). Historical
time-series is out of scope for this iteration.
### Endpoints
- `POST /api/agents/docker/report`**token-gated** (Bearer
`ARCHNEST_AGENT_TOKEN`, constant-time). 503 if token unconfigured, 401 on
mismatch, 400 on invalid payload. Upserts the row for `hostId`.
- `GET /api/agents/docker/hosts` — user-auth. Returns each reported host with
`hostId`, `hostname`, `receivedAt`, `containerCount`, and a `stale` flag
(`true` if `received_at` older than `STALE_AFTER_MS`, default ~90s / tunable).
- `GET /api/agents/docker/hosts/:hostId/containers` — user-auth. Returns the
parsed container list for that host (the spreadsheet rows + enough for detail).
- `GET /api/agents/docker/hosts/:hostId/containers/:containerId` — user-auth.
Returns the single container's full detail object.
`api.ts` gets matching functions + TS interfaces (`AgentHost`,
`AgentContainer`, etc.).
## Agent script
`agent/archnest-docker-agent.sh` — portable bash, dependencies: `docker`,
`curl`, and a JSON tool. To avoid forcing `jq`, the script builds the report by
combining `docker ps --format '{{json .}}'`, `docker inspect`, and
`docker stats --no-stream --format '{{json .}}'`; if `jq` is present it is used
to assemble/mask robustly, otherwise a documented `jq`-required note is shown.
(Decision: require `jq` — it is the only sane way to assemble + mask nested
JSON in bash reliably; `jq` is a one-line install on every distro. The script
checks for it and exits with a clear message if missing.)
Configuration via env (script header or `/etc/archnest/agent.env`):
- `ARCHNEST_URL` — e.g. `http://<archnest-mesh-ip>:4000` (mesh address).
- `ARCHNEST_AGENT_TOKEN` — shared token.
- `ARCHNEST_HOST_ID` — stable id for this VM.
Scheduling: provide both a **cron** line and a **systemd service + timer**
example. Recommended interval 30s (must be < backend `STALE_AFTER_MS`).
## Frontend — Containers page
The Containers page becomes **tabbed**:
- **Tab 1 "Containers"** — the existing spreadsheet view (Name, Image, State,
CPU, Memory, Ports, Actions), now also including agent-reported hosts. The
host selector lists Docker-API, SSH, and agent hosts.
- **Clicking a container Name** opens a **new tab** in the Containers page
showing that container's detail (tabs are dynamic; closeable).
### Detail tab contents (graceful per-source degradation)
- **Overview:** name, image + tag, image id, short/full id, created, started,
uptime, restart count, restart policy.
- **State & health:** state, exit code (if stopped), healthcheck status.
- **Stats:** CPU %, mem usage/limit, net RX/TX, block I/O (snapshot; agent &
Docker-API have it, SSH list does not).
- **Ports / Networks / Mounts:** tables.
- **Environment & labels:** env vars with secret values masked; labels.
- **Command/entrypoint.**
- **Logs:** recent tail (reuse existing logs path where the source supports it).
Fields unavailable from the active source render as "—" / a small "not
reported by this source" note.
## Explicitly deferred (not in this work)
- **Mesh prerequisite gate** (require mesh detected/tested/verified in Settings
before anything else can be configured) — its own initiative, needs its own
design (lockout-safety is the hard part). This doc assumes mesh-only ingest is
handled operationally for now.
- **Option 2 paid pull-agent** (local authenticated HTTP API per VM, on-demand
monitor + manage) — `ROADMAP.md`.
- **Per-host tokens**, **historical/time-series metrics**, **live log tailing
for agent hosts**.

View file

@ -159,6 +159,30 @@ export const api = {
body: JSON.stringify({ force }),
}),
// Docker over SSH: runs the `docker` CLI on a remote SSH host instead of the
// Docker Engine TCP API. `integrationId` here is an SSH integration.
listSshContainers: (integrationId: number) =>
apiFetch<{ containers: SshContainer[] }>(`/docker-ssh/${integrationId}/containers`),
sshContainerLogs: (integrationId: number, id: string, tail = 200) =>
apiFetch<{ logs: string }>(`/docker-ssh/${integrationId}/containers/${encodeURIComponent(id)}/logs?tail=${tail}`),
sshContainerAction: (integrationId: number, id: string, action: 'start' | 'stop' | 'restart' | 'pause' | 'unpause') =>
apiFetch<{ ok: boolean }>(`/docker-ssh/${integrationId}/containers/${encodeURIComponent(id)}/${action}`, { method: 'POST' }),
removeSshContainer: (integrationId: number, id: string, force = false) =>
apiFetch<{ ok: boolean }>(`/docker-ssh/${integrationId}/containers/${encodeURIComponent(id)}/remove`, {
method: 'POST',
body: JSON.stringify({ force }),
}),
// Docker monitoring agents (push model). Read-only; agents POST reports to a
// token-gated ingest endpoint that the UI never calls.
listAgentHosts: () => apiFetch<{ hosts: AgentHost[] }>('/agents/docker/hosts'),
listAgentContainers: (hostId: string) =>
apiFetch<AgentHostContainers>(`/agents/docker/hosts/${encodeURIComponent(hostId)}/containers`),
getAgentContainer: (hostId: string, containerId: string) =>
apiFetch<{ container: AgentContainer }>(
`/agents/docker/hosts/${encodeURIComponent(hostId)}/containers/${encodeURIComponent(containerId)}`,
),
getHostMetrics: (integrationId: number) => apiFetch<HostMetrics>(`/integrations/${integrationId}/metrics`),
startTransfer: (data: { sourceIntegrationId: number; destIntegrationId: number; sourcePaths: string[]; destPath: string; move?: boolean }) =>
@ -296,6 +320,72 @@ export interface Container {
ports: { privatePort: number; publicPort?: number; type: string }[]
}
export interface SshContainer {
id: string
name: string
image: string
state: string
status: string
/** Raw `docker ps` ports string (e.g. "0.0.0.0:8080->80/tcp"). */
ports: string
}
export interface AgentHost {
hostId: string
hostname: string | null
reportedAt: string | null
receivedAt: string
containerCount: number
stale: boolean
}
export interface AgentContainerPort {
hostIp?: string
hostPort?: number | null
containerPort: number
proto: string
}
export interface AgentContainerStats {
cpuPercent?: number
memUsage?: number
memLimit?: number
netRxBytes?: number
netTxBytes?: number
blockReadBytes?: number
blockWriteBytes?: number
}
export interface AgentContainer {
id: string
name: string
image: string
imageId?: string
state: string
status: string
createdAt?: string
startedAt?: string
restartCount?: number
restartPolicy?: string
health?: string
ports: AgentContainerPort[]
networks: { name: string; ip?: string }[]
mounts: { type?: string; source?: string; destination?: string; rw?: boolean }[]
env: { key: string; value: string }[]
command?: string
labels?: Record<string, string>
stats?: AgentContainerStats
}
export interface AgentHostContainers {
hostId: string
hostname: string | null
reportedAt: string | null
receivedAt: string
stale: boolean
containers: AgentContainer[]
}
export interface ContainerStats {
cpuPercent: number
memUsage: number

View file

@ -14,7 +14,14 @@ import {
ScrollText,
X,
} from 'lucide-react'
import { api, getToken, type Container, type ContainerStats, type Integration } from '../lib/api'
import {
api,
getToken,
type Container,
type SshContainer,
type AgentContainer,
type ContainerStats,
} from '../lib/api'
const TEXT_PRIMARY = '#E8E6E0'
const TEXT_SECONDARY = '#7A7D85'
@ -27,6 +34,66 @@ const cardBase: React.CSSProperties = {
boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)',
}
// docker = Engine TCP API; ssh = `docker` CLI over SSH; agent = pushed report.
// docker/ssh support management; agent is read-only monitoring.
type Source = 'docker' | 'ssh' | 'agent'
/** A selectable container host. For docker/ssh it wraps an integration id; for
* agent it wraps the string hostId of a reporting agent. */
interface HostOption {
source: Source
/** integration id (docker/ssh) or agent hostId (agent), as a string key. */
key: string
label: string
/** numeric integration id for docker/ssh sources. */
integrationId?: number
/** agent hostId for agent sources. */
agentHostId?: string
}
/** Unified table row across all three sources. */
interface Row {
id: string
name: string
image: string
state: string
status: string
ports: string
/** Stats embedded in agent reports (docker/ssh fetch stats separately/none). */
embeddedStats?: ContainerStats
}
function toRowFromDocker(c: Container): Row {
return {
id: c.id,
name: c.name,
image: c.image,
state: c.state,
status: c.status,
ports: c.ports.length === 0 ? '' : c.ports.map((p) => `${p.publicPort ?? ''}${p.publicPort ? ':' : ''}${p.privatePort}/${p.type}`).join(', '),
}
}
function toRowFromSsh(c: SshContainer): Row {
return { id: c.id, name: c.name, image: c.image, state: c.state.toLowerCase(), status: c.status, ports: c.ports }
}
function toRowFromAgent(c: AgentContainer): Row {
const ports = c.ports
.map((p) => `${p.hostPort ? `${p.hostPort}:` : ''}${p.containerPort}/${p.proto}`)
.join(', ')
const embeddedStats: ContainerStats | undefined = c.stats
? {
cpuPercent: c.stats.cpuPercent ?? 0,
memUsage: c.stats.memUsage ?? 0,
memLimit: c.stats.memLimit ?? 0,
netRx: c.stats.netRxBytes ?? 0,
netTx: c.stats.netTxBytes ?? 0,
}
: undefined
return { id: c.id, name: c.name, image: c.image, state: c.state.toLowerCase(), status: c.status, ports, embeddedStats }
}
function stateColor(state: string): string {
if (state === 'running') return '#2ECC71'
if (state === 'paused') return '#E0A82E'
@ -46,56 +113,113 @@ function formatBytes(bytes: number): string {
return `${v.toFixed(1)} ${units[i]}`
}
/** A dynamic detail tab opened by clicking a container name. */
interface DetailTab {
tabId: string
source: Source
integrationId?: number
agentHostId?: string
containerId: string
containerName: string
}
export default function Containers() {
const [hosts, setHosts] = useState<Integration[]>([])
const [integrationId, setIntegrationId] = useState<number | ''>('')
const [containers, setContainers] = useState<Container[]>([])
const [hostOptions, setHostOptions] = useState<HostOption[]>([])
const [selectedKey, setSelectedKey] = useState<string>('')
const [rows, setRows] = useState<Row[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [busyId, setBusyId] = useState<string | null>(null)
const [statsById, setStatsById] = useState<Record<string, ContainerStats>>({})
const [logsContainer, setLogsContainer] = useState<Container | null>(null)
const [execContainer, setExecContainer] = useState<Container | null>(null)
const [logsRow, setLogsRow] = useState<Row | null>(null)
const [execRow, setExecRow] = useState<Row | null>(null)
// Intra-page tabs: the containers list plus any opened container-detail tabs.
const [detailTabs, setDetailTabs] = useState<DetailTab[]>([])
const [activeTab, setActiveTab] = useState<string>('list')
const selected = hostOptions.find((h) => h.key === selectedKey)
const source: Source | null = selected?.source ?? null
const canManage = source === 'docker' || source === 'ssh'
async function loadHosts() {
const [{ integrations }, agentRes] = await Promise.all([
api.listIntegrations(),
api.listAgentHosts().catch(() => ({ hosts: [] })),
])
const opts: HostOption[] = []
for (const i of integrations) {
if (i.type === 'docker') opts.push({ source: 'docker', key: `docker:${i.id}`, label: `${i.name} (Docker API)`, integrationId: i.id })
if (i.type === 'ssh') opts.push({ source: 'ssh', key: `ssh:${i.id}`, label: `${i.name} (SSH)`, integrationId: i.id })
}
for (const h of agentRes.hosts) {
const label = `${h.hostname || h.hostId} (Agent${h.stale ? ' — stale' : ''})`
opts.push({ source: 'agent', key: `agent:${h.hostId}`, label, agentHostId: h.hostId })
}
setHostOptions(opts)
if (opts.length > 0 && !opts.some((o) => o.key === selectedKey)) setSelectedKey(opts[0].key)
}
useEffect(() => {
api.listIntegrations().then(({ integrations }) => {
const dockerHosts = integrations.filter((i) => i.type === 'docker')
setHosts(dockerHosts)
if (dockerHosts.length > 0) setIntegrationId(dockerHosts[0].id)
})
loadHosts()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function refresh() {
if (!integrationId) return
if (!selected) return
setLoading(true)
setError(null)
api
.listContainers(integrationId)
.then(({ containers }) => {
setContainers(containers)
containers.forEach((c) => {
if (c.state !== 'running') return
api
.containerStats(integrationId, c.id)
.then((stats) => setStatsById((prev) => ({ ...prev, [c.id]: stats })))
.catch(() => {})
setStatsById({})
if (selected.source === 'agent' && selected.agentHostId) {
api
.listAgentContainers(selected.agentHostId)
.then(({ containers }) => setRows(containers.map(toRowFromAgent)))
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load agent report'))
.finally(() => setLoading(false))
return
}
if (selected.source === 'ssh' && selected.integrationId) {
api
.listSshContainers(selected.integrationId)
.then(({ containers }) => setRows(containers.map(toRowFromSsh)))
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to list containers'))
.finally(() => setLoading(false))
return
}
if (selected.source === 'docker' && selected.integrationId) {
const integrationId = selected.integrationId
api
.listContainers(integrationId)
.then(({ containers }) => {
setRows(containers.map(toRowFromDocker))
containers.forEach((c) => {
if (c.state !== 'running') return
api
.containerStats(integrationId, c.id)
.then((stats) => setStatsById((prev) => ({ ...prev, [c.id]: stats })))
.catch(() => {})
})
})
})
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to list containers'))
.finally(() => setLoading(false))
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to list containers'))
.finally(() => setLoading(false))
}
}
useEffect(() => {
refresh()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [integrationId])
}, [selectedKey])
async function runAction(c: Container, action: 'start' | 'stop' | 'restart' | 'pause' | 'unpause') {
if (!integrationId) return
async function runAction(c: Row, action: 'start' | 'stop' | 'restart' | 'pause' | 'unpause') {
if (!selected?.integrationId) return
setBusyId(c.id)
setError(null)
try {
await api.containerAction(integrationId, c.id, action)
if (selected.source === 'ssh') await api.sshContainerAction(selected.integrationId, c.id, action)
else await api.containerAction(selected.integrationId, c.id, action)
refresh()
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to ${action} container`)
@ -104,13 +228,14 @@ export default function Containers() {
}
}
async function removeContainer(c: Container) {
if (!integrationId) return
async function removeRow(c: Row) {
if (!selected?.integrationId) return
if (!confirm(`Remove container "${c.name}"? This cannot be undone.`)) return
setBusyId(c.id)
setError(null)
try {
await api.removeContainer(integrationId, c.id, c.state === 'running')
if (selected.source === 'ssh') await api.removeSshContainer(selected.integrationId, c.id, c.state === 'running')
else await api.removeContainer(selected.integrationId, c.id, c.state === 'running')
refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove container')
@ -119,6 +244,28 @@ export default function Containers() {
}
}
function openDetail(c: Row) {
if (!selected) return
const tabId = `${selected.key}::${c.id}`
setDetailTabs((prev) => (prev.some((t) => t.tabId === tabId) ? prev : [
...prev,
{
tabId,
source: selected.source,
integrationId: selected.integrationId,
agentHostId: selected.agentHostId,
containerId: c.id,
containerName: c.name,
},
]))
setActiveTab(tabId)
}
function closeDetail(tabId: string) {
setDetailTabs((prev) => prev.filter((t) => t.tabId !== tabId))
setActiveTab((cur) => (cur === tabId ? 'list' : cur))
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@ -127,34 +274,51 @@ export default function Containers() {
Containers
</h1>
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>
Manage Docker containers across your configured hosts.
Manage and monitor Docker containers via the Docker Engine API, the
<code> docker</code> CLI over SSH, or a reporting agent.
</p>
</div>
<div className="flex items-center gap-2">
<select
value={integrationId}
onChange={(e) => setIntegrationId(Number(e.target.value))}
className="rounded-md border border-white/10 bg-transparent px-2 py-1.5 text-sm"
style={{ color: TEXT_PRIMARY }}
>
{hosts.length === 0 && <option value="">No Docker integrations</option>}
{hosts.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
<button
onClick={refresh}
className="flex items-center gap-1 rounded-md px-3.5 py-1.5 text-xs"
style={{ border: '1px solid rgba(200,164,52,0.2)', color: TEXT_PRIMARY }}
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</div>
{activeTab === 'list' && (
<div className="flex items-center gap-2">
<select
value={selectedKey}
onChange={(e) => setSelectedKey(e.target.value)}
className="rounded-md border border-white/10 bg-transparent px-2 py-1.5 text-sm"
style={{ color: TEXT_PRIMARY }}
>
{hostOptions.length === 0 && <option value="">No container hosts</option>}
{hostOptions.map((h) => (
<option key={h.key} value={h.key}>
{h.label}
</option>
))}
</select>
<button
onClick={refresh}
className="flex items-center gap-1 rounded-md px-3.5 py-1.5 text-xs"
style={{ border: '1px solid rgba(200,164,52,0.2)', color: TEXT_PRIMARY }}
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</div>
)}
</div>
{error && (
{/* Intra-page tab bar */}
<div className="flex items-center gap-1 border-b border-white/10">
<TabButton label="Containers" active={activeTab === 'list'} onClick={() => setActiveTab('list')} />
{detailTabs.map((t) => (
<TabButton
key={t.tabId}
label={t.containerName}
active={activeTab === t.tabId}
onClick={() => setActiveTab(t.tabId)}
onClose={() => closeDetail(t.tabId)}
/>
))}
</div>
{error && activeTab === 'list' && (
<div className="flex items-center justify-between rounded-md px-3 py-2 text-sm" style={{ backgroundColor: 'rgba(231,76,60,0.1)', color: '#E74C3C' }}>
<span>{error}</span>
<button onClick={() => setError(null)} style={{ color: TEXT_SECONDARY }}>
@ -163,105 +327,296 @@ export default function Containers() {
</div>
)}
<div style={cardBase} className="overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr style={{ color: TEXT_SECONDARY, borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<th className="px-3 py-2 text-left font-medium">Name</th>
<th className="px-3 py-2 text-left font-medium">Image</th>
<th className="px-3 py-2 text-left font-medium">State</th>
<th className="px-3 py-2 text-left font-medium">CPU</th>
<th className="px-3 py-2 text-left font-medium">Memory</th>
<th className="px-3 py-2 text-left font-medium">Ports</th>
<th className="px-3 py-2 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{containers.length === 0 && (
<tr>
<td colSpan={7} className="px-3 py-6 text-center" style={{ color: TEXT_SECONDARY }}>
{integrationId ? 'No containers found.' : 'Select a Docker integration to view containers.'}
</td>
{activeTab === 'list' ? (
<div style={cardBase} className="overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr style={{ color: TEXT_SECONDARY, borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<th className="px-3 py-2 text-left font-medium">Name</th>
<th className="px-3 py-2 text-left font-medium">Image</th>
<th className="px-3 py-2 text-left font-medium">State</th>
<th className="px-3 py-2 text-left font-medium">CPU</th>
<th className="px-3 py-2 text-left font-medium">Memory</th>
<th className="px-3 py-2 text-left font-medium">Ports</th>
<th className="px-3 py-2 text-right font-medium">Actions</th>
</tr>
)}
{containers.map((c) => {
const stats = statsById[c.id]
const busy = busyId === c.id
return (
<tr key={c.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<td className="px-3 py-2" style={{ color: TEXT_PRIMARY }}>
{c.name}
</td>
<td className="px-3 py-2" style={{ color: TEXT_SECONDARY }}>
{c.image}
</td>
<td className="px-3 py-2">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full" style={{ background: stateColor(c.state) }} />
<span style={{ color: TEXT_PRIMARY }}>{c.status}</span>
</span>
</td>
<td className="px-3 py-2" style={{ color: TEXT_SECONDARY }}>
{stats ? `${stats.cpuPercent.toFixed(1)}%` : '—'}
</td>
<td className="px-3 py-2" style={{ color: TEXT_SECONDARY }}>
{stats ? `${formatBytes(stats.memUsage)} / ${formatBytes(stats.memLimit)}` : '—'}
</td>
<td className="px-3 py-2" style={{ color: TEXT_SECONDARY }}>
{c.ports.length === 0 ? '—' : c.ports.map((p) => `${p.publicPort ?? ''}${p.publicPort ? ':' : ''}${p.privatePort}/${p.type}`).join(', ')}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1.5">
{c.state === 'running' ? (
<>
<button disabled={busy} onClick={() => runAction(c, 'pause')} title="Pause" style={{ color: TEXT_SECONDARY }}>
<Pause size={14} />
</button>
<button disabled={busy} onClick={() => runAction(c, 'restart')} title="Restart" style={{ color: TEXT_SECONDARY }}>
<RotateCw size={14} />
</button>
<button disabled={busy} onClick={() => runAction(c, 'stop')} title="Stop" style={{ color: TEXT_SECONDARY }}>
<Square size={14} />
</button>
<button disabled={busy} onClick={() => setExecContainer(c)} title="Exec terminal" style={{ color: GOLD }}>
<TerminalSquare size={14} />
</button>
</>
) : c.state === 'paused' ? (
<button disabled={busy} onClick={() => runAction(c, 'unpause')} title="Unpause" style={{ color: TEXT_SECONDARY }}>
<PlayCircle size={14} />
</button>
) : (
<button disabled={busy} onClick={() => runAction(c, 'start')} title="Start" style={{ color: TEXT_SECONDARY }}>
<Play size={14} />
</button>
)}
<button disabled={busy} onClick={() => setLogsContainer(c)} title="Logs" style={{ color: TEXT_SECONDARY }}>
<ScrollText size={14} />
</button>
<button disabled={busy} onClick={() => removeContainer(c)} title="Remove" style={{ color: '#E74C3C' }}>
<Trash2 size={14} />
</button>
</div>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={7} className="px-3 py-6 text-center" style={{ color: TEXT_SECONDARY }}>
{selected ? 'No containers found.' : 'Select a container host.'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{logsContainer && integrationId && (
<LogsModal integrationId={integrationId} container={logsContainer} onClose={() => setLogsContainer(null)} />
)}
{rows.map((c) => {
const stats = statsById[c.id] ?? c.embeddedStats
const busy = busyId === c.id
return (
<tr key={c.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<td className="px-3 py-2">
<button
onClick={() => openDetail(c)}
className="text-left hover:underline"
style={{ color: GOLD }}
title="View details"
>
{c.name}
</button>
</td>
<td className="px-3 py-2" style={{ color: TEXT_SECONDARY }}>
{c.image}
</td>
<td className="px-3 py-2">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full" style={{ background: stateColor(c.state) }} />
<span style={{ color: TEXT_PRIMARY }}>{c.status}</span>
</span>
</td>
<td className="px-3 py-2" style={{ color: TEXT_SECONDARY }}>
{stats ? `${stats.cpuPercent.toFixed(1)}%` : '—'}
</td>
<td className="px-3 py-2" style={{ color: TEXT_SECONDARY }}>
{stats ? `${formatBytes(stats.memUsage)} / ${formatBytes(stats.memLimit)}` : '—'}
</td>
<td className="px-3 py-2" style={{ color: TEXT_SECONDARY }}>
{c.ports || '—'}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1.5">
{canManage ? (
<>
{c.state === 'running' ? (
<>
<button disabled={busy} onClick={() => runAction(c, 'pause')} title="Pause" style={{ color: TEXT_SECONDARY }}>
<Pause size={14} />
</button>
<button disabled={busy} onClick={() => runAction(c, 'restart')} title="Restart" style={{ color: TEXT_SECONDARY }}>
<RotateCw size={14} />
</button>
<button disabled={busy} onClick={() => runAction(c, 'stop')} title="Stop" style={{ color: TEXT_SECONDARY }}>
<Square size={14} />
</button>
<button disabled={busy} onClick={() => setExecRow(c)} title="Exec terminal" style={{ color: GOLD }}>
<TerminalSquare size={14} />
</button>
</>
) : c.state === 'paused' ? (
<button disabled={busy} onClick={() => runAction(c, 'unpause')} title="Unpause" style={{ color: TEXT_SECONDARY }}>
<PlayCircle size={14} />
</button>
) : (
<button disabled={busy} onClick={() => runAction(c, 'start')} title="Start" style={{ color: TEXT_SECONDARY }}>
<Play size={14} />
</button>
)}
<button disabled={busy} onClick={() => setLogsRow(c)} title="Logs" style={{ color: TEXT_SECONDARY }}>
<ScrollText size={14} />
</button>
<button disabled={busy} onClick={() => removeRow(c)} title="Remove" style={{ color: '#E74C3C' }}>
<Trash2 size={14} />
</button>
</>
) : (
<span className="text-xs" style={{ color: TEXT_SECONDARY }}>read-only</span>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : (
(() => {
const tab = detailTabs.find((t) => t.tabId === activeTab)
if (!tab) return null
return <ContainerDetail key={tab.tabId} tab={tab} />
})()
)}
{execContainer && integrationId && (
<ExecModal integrationId={integrationId} container={execContainer} onClose={() => setExecContainer(null)} />
{logsRow && selected?.integrationId && (source === 'docker' || source === 'ssh') && (
<LogsModal source={source} integrationId={selected.integrationId} row={logsRow} onClose={() => setLogsRow(null)} />
)}
{execRow && selected?.integrationId && (source === 'docker' || source === 'ssh') && (
<ExecModal source={source} integrationId={selected.integrationId} row={execRow} onClose={() => setExecRow(null)} />
)}
</div>
)
}
function LogsModal({ integrationId, container, onClose }: { integrationId: number; container: Container; onClose: () => void }) {
function TabButton({ label, active, onClick, onClose }: { label: string; active: boolean; onClick: () => void; onClose?: () => void }) {
return (
<div
onClick={onClick}
className="flex cursor-pointer items-center gap-2 rounded-t-md px-3 py-1.5 text-xs"
style={{
color: active ? GOLD : TEXT_SECONDARY,
borderBottom: active ? `2px solid ${GOLD}` : '2px solid transparent',
maxWidth: '200px',
}}
>
<span className="truncate">{label}</span>
{onClose && (
<X
size={12}
onClick={(e) => {
e.stopPropagation()
onClose()
}}
/>
)}
</div>
)
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex gap-3 py-1 text-sm">
<span className="w-40 shrink-0" style={{ color: TEXT_SECONDARY }}>{label}</span>
<span className="min-w-0 break-words" style={{ color: TEXT_PRIMARY }}>{value}</span>
</div>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={cardBase} className="p-4">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide" style={{ color: TEXT_SECONDARY }}>{title}</h3>
{children}
</div>
)
}
/**
* Container detail tab. Agent reports carry the full inspect+stats payload, so
* for agent hosts we render everything. For docker/ssh sources we currently
* only have the list row data, so we show what we have and note the rest is
* available from an agent graceful degradation per the design.
*/
function ContainerDetail({ tab }: { tab: DetailTab }) {
const [container, setContainer] = useState<AgentContainer | null>(null)
const [loading, setLoading] = useState(tab.source === 'agent')
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (tab.source !== 'agent' || !tab.agentHostId) return
setLoading(true)
api
.getAgentContainer(tab.agentHostId, tab.containerId)
.then(({ container }) => setContainer(container))
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load container detail'))
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab.tabId])
if (tab.source !== 'agent') {
return (
<div style={cardBase} className="p-4">
<p className="text-sm" style={{ color: TEXT_PRIMARY }}>{tab.containerName}</p>
<p className="mt-2 text-sm" style={{ color: TEXT_SECONDARY }}>
Rich container detail (inspect data, mounts, networks, environment) is
provided by the monitoring agent. This host is a{' '}
{tab.source === 'ssh' ? 'Docker-over-SSH' : 'Docker API'} source use the
list view actions for management, or install the ArchNest agent on this
host for full detail.
</p>
</div>
)
}
if (loading) return <p className="text-sm" style={{ color: TEXT_SECONDARY }}>Loading</p>
if (error) return <p className="text-sm" style={{ color: '#E74C3C' }}>{error}</p>
if (!container) return <p className="text-sm" style={{ color: TEXT_SECONDARY }}>No data.</p>
const c = container
const masked = (v: string) => v
return (
<div className="grid gap-4" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))' }}>
<Section title="Overview">
<DetailRow label="Name" value={c.name} />
<DetailRow label="Image" value={c.image} />
{c.imageId && <DetailRow label="Image ID" value={<code className="text-xs">{c.imageId.slice(0, 19)}</code>} />}
<DetailRow label="Container ID" value={<code className="text-xs">{c.id.slice(0, 12)}</code>} />
{c.command && <DetailRow label="Command" value={<code className="text-xs">{c.command}</code>} />}
{c.createdAt && <DetailRow label="Created" value={new Date(c.createdAt).toLocaleString()} />}
{c.startedAt && <DetailRow label="Started" value={new Date(c.startedAt).toLocaleString()} />}
</Section>
<Section title="State & Health">
<DetailRow
label="State"
value={
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full" style={{ background: stateColor(c.state.toLowerCase()) }} />
{c.status || c.state}
</span>
}
/>
{c.health && c.health !== 'none' && <DetailRow label="Health" value={c.health} />}
<DetailRow label="Restart count" value={String(c.restartCount ?? 0)} />
{c.restartPolicy && <DetailRow label="Restart policy" value={c.restartPolicy} />}
</Section>
{c.stats && (
<Section title="Stats (snapshot)">
<DetailRow label="CPU" value={`${(c.stats.cpuPercent ?? 0).toFixed(1)}%`} />
<DetailRow label="Memory" value={`${formatBytes(c.stats.memUsage ?? 0)} / ${formatBytes(c.stats.memLimit ?? 0)}`} />
<DetailRow label="Network" value={`${formatBytes(c.stats.netRxBytes ?? 0)}${formatBytes(c.stats.netTxBytes ?? 0)}`} />
<DetailRow label="Block I/O" value={`R ${formatBytes(c.stats.blockReadBytes ?? 0)} W ${formatBytes(c.stats.blockWriteBytes ?? 0)}`} />
</Section>
)}
<Section title="Ports">
{c.ports.length === 0 ? (
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>None published.</p>
) : (
c.ports.map((p, i) => (
<DetailRow key={i} label={`${p.containerPort}/${p.proto}`} value={p.hostPort ? `${p.hostIp || '0.0.0.0'}:${p.hostPort}` : 'not published'} />
))
)}
</Section>
<Section title="Networks">
{c.networks.length === 0 ? (
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>None.</p>
) : (
c.networks.map((n, i) => <DetailRow key={i} label={n.name} value={n.ip || '—'} />)
)}
</Section>
<Section title="Mounts">
{c.mounts.length === 0 ? (
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>None.</p>
) : (
c.mounts.map((m, i) => (
<DetailRow key={i} label={m.destination || '—'} value={`${m.source || ''}${m.rw === false ? ' (ro)' : ''}`} />
))
)}
</Section>
<Section title="Environment">
{c.env.length === 0 ? (
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>None.</p>
) : (
c.env.map((e, i) => <DetailRow key={i} label={e.key} value={<code className="text-xs">{masked(e.value)}</code>} />)
)}
</Section>
{c.labels && Object.keys(c.labels).length > 0 && (
<Section title="Labels">
{Object.entries(c.labels).map(([k, v]) => (
<DetailRow key={k} label={k} value={<span className="text-xs">{v}</span>} />
))}
</Section>
)}
</div>
)
}
function LogsModal({ source, integrationId, row, onClose }: { source: 'docker' | 'ssh'; integrationId: number; row: Row; onClose: () => void }) {
const [logs, setLogs] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -269,8 +624,8 @@ function LogsModal({ integrationId, container, onClose }: { integrationId: numbe
function load() {
setLoading(true)
setError(null)
api
.containerLogs(integrationId, container.id)
const req = source === 'ssh' ? api.sshContainerLogs(integrationId, row.id) : api.containerLogs(integrationId, row.id)
req
.then(({ logs }) => setLogs(logs))
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to fetch logs'))
.finally(() => setLoading(false))
@ -283,7 +638,7 @@ function LogsModal({ integrationId, container, onClose }: { integrationId: numbe
<div style={cardBase} className="flex h-[70vh] w-full max-w-3xl flex-col p-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-medium" style={{ color: TEXT_PRIMARY }}>
Logs {container.name}
Logs {row.name}
</h2>
<div className="flex items-center gap-2">
<button onClick={load} style={{ color: TEXT_SECONDARY }} title="Refresh">
@ -303,7 +658,7 @@ function LogsModal({ integrationId, container, onClose }: { integrationId: numbe
)
}
function ExecModal({ integrationId, container, onClose }: { integrationId: number; container: Container; onClose: () => void }) {
function ExecModal({ source, integrationId, row, onClose }: { source: 'docker' | 'ssh'; integrationId: number; row: Row; onClose: () => void }) {
const containerRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WebSocket | null>(null)
const [connected, setConnected] = useState(false)
@ -321,20 +676,23 @@ function ExecModal({ integrationId, container, onClose }: { integrationId: numbe
term.open(containerRef.current)
fit.fit()
const isSsh = source === 'ssh'
const token = getToken()
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${proto}://${window.location.host}/api/docker/exec?token=${encodeURIComponent(token ?? '')}`)
const path = isSsh ? '/api/docker-ssh/exec' : '/api/docker/exec'
const ws = new WebSocket(`${proto}://${window.location.host}${path}?token=${encodeURIComponent(token ?? '')}`)
wsRef.current = ws
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'connect', integrationId, containerId: container.id, cols: term.cols, rows: term.rows }))
ws.send(JSON.stringify({ type: 'connect', integrationId, containerId: row.id, cols: term.cols, rows: term.rows }))
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'ready') {
setConnected(true)
} else if (msg.type === 'data') {
term.write(Uint8Array.from(atob(msg.data), (c) => c.charCodeAt(0)))
if (isSsh) term.write(msg.data as string)
else term.write(Uint8Array.from(atob(msg.data), (c) => c.charCodeAt(0)))
} else if (msg.type === 'error') {
term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`)
setConnected(false)
@ -346,9 +704,8 @@ function ExecModal({ integrationId, container, onClose }: { integrationId: numbe
ws.onclose = () => setConnected(false)
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data: btoa(data) }))
}
if (ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'input', data: isSsh ? data : btoa(data) }))
})
term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows }))
@ -371,7 +728,7 @@ function ExecModal({ integrationId, container, onClose }: { integrationId: numbe
<div className="mb-2 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-sm font-medium" style={{ color: TEXT_PRIMARY }}>
<span className="inline-block h-2 w-2 rounded-full" style={{ background: connected ? '#2ECC71' : '#7A7D85' }} />
Exec {container.name}
Exec {row.name}
</h2>
<button onClick={onClose} style={{ color: TEXT_SECONDARY }}>
<X size={16} />