Project root: /home/zk/projects/ContainerYard
Goal: Add first-class Logs functionality to ContainerYard with minimal risk, using our existing stack (Node/Express + React/Vite + TS + Tailwind + shadcn/ui). Keep security tight (session cookies & roles) and avoid exposing Docker sockets over the network.
-
Per-container logs (Pi host)
- API
GET /api/hosts/:hostId/containers/:id/logswith query params:follow(bool) → Server-Sent Events (SSE) stream when truetail(number, default 500; max 5000)since(RFC3339 or seconds; optional)stdout(0/1; default 1),stderr(0/1; default 1)timestamps(0/1; default 0)grep(string; optional, simple substring or JS regex literal/pattern/iparsed server-side with safe fallback)
- Host handling
hostId=piapps: use Docker UNIX socket/var/run/docker.sockviadockerodehostId=synology: do not access its Docker socket; instead return a small JSON with a Dozzle URL to open (we’ll render a button in the UI).
- API
-
Host file logs (read-only)
- API
GET /api/hostlogs/:name(stream tail or SSE follow) reading only from a strict allowlist:nginx_containerYard_access→/var/log/nginx/container.piapps.dev.access.lognginx_containerYard_error→/var/log/nginx/container.piapps.dev.error.logpm2_containeryard_out→$HOME/.pm2/logs/containeryard-out.logpm2_containeryard_err→$HOME/.pm2/logs/containeryard-error.loggrafana_server→/var/log/grafana/grafana.log(if present)prometheus_server→/var/log/prometheus/prometheus.logor journalctl fallbackcryptoagent_freqtrade→~/bots/crypto-agent/user_data/logs/freqtrade.log(if exists)
- Query params:
follow,tail,since,grep,timestamps(same semantics as above). - No arbitrary path access. Reject unknown names with 404.
- API
-
Client UI
- New Logs tab in Container Details page:
- Controls: Tail size, Since, Stdout/Stderr toggles, Timestamps, Grep filter, Follow toggle.
- When
hostId=synology, show “Open in Dozzle” button (URL from API) and a short help text.
- New Host Logs page (
/host-logs):- Card list for each allowlisted log. Each card has a View action opening the live log viewer with the same controls.
- Common
<LogsViewer />component that can consume:- SSE streams (follow=true)
- One-shot fetch (follow=false)
- Client-side filter box (additional fast filter after server grep)
- Pause/Resume and Copy last N lines
- Download (server route below)
- New Logs tab in Container Details page:
-
Download endpoint (admin-only)
GET /api/logs/download?source=container|hostfile&hostId=..&id=..&name=..&tail=..&since=..- Streams last N MB or last N lines (pick lines for simplicity) with
Content-Disposition: attachment. - Enforce sane limits and role check.
-
Security & Ops
- Use existing session cookie auth and roles:
viewercan view;admincan download. - Rate limit log routes, return text/event-stream for SSE.
- Never expose Docker socket over TCP; only UNIX socket on Pi machine.
- Add basic input validation (zod or handcrafted): numeric bounds, regex parsing safe-guard, since parsing with fallback.
- Add CORS and cache headers consistent with the rest of the API (no cache for SSE).
- Use existing session cookie auth and roles:
-
(Optional, Phase 2) Loki
- Provide a
docker-compose.loki.ymland promtail configs to ship PM2/Nginx/container logs to Loki, searchable in Grafana. Keep ContainerYard real-time tails as-is.
- Provide a
Adjust names/paths to match the current repo. The following assumes a common structure:
server/(Express app in TypeScript; built intodist/index.js)client/(React/Vite TypeScript)- Env loaded via
dotenv/config- PM2 runs
dist/index.js
-
Dependencies
cd server npm i dockerode event-stream npm i -D @types/dockerode -
Config: hosts map
- Add
config/hosts.ts:export const HOSTS = { piapps: { type: 'docker', // docker unix socket on the Pi host socketPath: '/var/run/docker.sock', }, synology: { type: 'dozzle', dozzleBase: 'https://synology.piapps.dev:9816', // adjust actual URL/port }, } as const; export type HostId = keyof typeof HOSTS;
- Add
-
Lib: docker client factory
-
lib/dockerClient.tsto return a dockerode instance forpiappsonly.
-
-
Routes: container logs
-
routes/containerLogs.ts:- Validate params/query.
- If
hostId=piapps:- Use
dockerode.getContainer(id).logs({follow, stdout, stderr, tail, since, timestamps}). - When
follow=true, set headers for SSE and stream lines; otherwise return the last chunk. - If
grepprovided, filter server-side with a safe regex (try/catch; if invalid, treat as substring).
- Use
- If
hostId=synology:- Return
{ dozzleUrl: \${HOSTS.synology.dozzleBase}/containers/${id}` }` and do not attempt socket access.
- Return
- Mount at
/api/hosts/:hostId/containers/:id/logs.
-
-
Routes: host file logs
-
routes/hostLogs.tswith an allowlist map:const ALLOWLIST = { nginx_containerYard_access: '/var/log/nginx/container.piapps.dev.access.log', nginx_containerYard_error: '/var/log/nginx/container.piapps.dev.error.log', pm2_containeryard_out: process.env.HOME + '/.pm2/logs/containeryard-out.log', pm2_containeryard_err: process.env.HOME + '/.pm2/logs/containeryard-error.log', grafana_server: '/var/log/grafana/grafana.log', prometheus_server: '/var/log/prometheus/prometheus.log', cryptoagent_freqtrade: process.env.HOME + '/bots/crypto-agent/user_data/logs/freqtrade.log' } as const;
- Verify file exists & readable; otherwise 404.
- Implement tail reading (use
fs.createReadStreamoffset seek or a small tail utility) and SSE follow usingfs.watch+ read append. - Apply
grep/regex filter server-side (same as container logs).
-
-
Route: download
-
routes/logDownload.ts: admin-only; enforces limits; setsContent-Type: text/plainandContent-Dispositionfor attachment.
-
-
Wiring
- Register routes in
server/index.tswith auth + rate limits. - Add
/api/healthquick check for new modules (if not present).
- Register routes in
-
Add a Logs viewer component
client/src/components/LogsViewer.tsxsupporting both:- SSE (EventSource) for follow mode
- Fetch for snapshot
- Props:
interface LogsViewerProps { endpoint: string; // full API URL follow?: boolean; tail?: number; since?: string | number; stdout?: boolean; stderr?: boolean; timestamps?: boolean; grep?: string; title?: string; allowDownload?: boolean; }
- Features:
- Show latest N lines in a virtualized list.
- UI controls for grep/tail/since/stdout/stderr/timestamps/follow.
- Buttons: Pause/Resume, Copy, Download (if allowed).
-
Container Details → Logs tab
- On container item page, add a tab that:
- Calls
/api/hosts/:hostId/containers/:id/logswith current controls. - When response has
{ dozzleUrl }, render a card with an “Open in Dozzle” button instead of live tail.
- Calls
- On container item page, add a tab that:
-
Host Logs page
- Simple grid of allowlisted sources with “Open” actions that route to a full-screen
<LogsViewer />with proper endpoint.
- Simple grid of allowlisted sources with “Open” actions that route to a full-screen
-
Styling
- Tailwind + shadcn/ui cards/buttons/inputs; keep our existing dark theme.
- Use a monospaced font for the log body; auto-scroll when follow is on.
- Auth: reuse existing session middleware; require
req.user.rolein {viewer, admin}. - Rate-limit: 10 req / 10s per IP for non-SSE routes; for SSE keep connection limits reasonable (2 per user).
- Validation:
tail: 1–5000 (default 500)since: parse ISO or number seconds; clamp to 7 days max look-backgrep: 0–200 chars; compile regex in try/catch; fallback to case-insensitive substring
- Deny path traversal and unknown allowlist names.
- Send
X-Content-Type-Options: nosniff; setCache-Control: no-storefor SSE.
Start the app (
pm2 restart containeryard --update-env) then:
# Pick an actual container id on the Pi (e.g., crypto-agent-freqtrade-trade-1)
CID=$(docker ps --format '{{.Names}}' | head -n1)
# Snapshot last 200 lines
curl -sS "http://127.0.0.1:5001/api/hosts/piapps/containers/$CID/logs?tail=200" | head
# Follow (press Ctrl+C after seeing lines)
curl -N "http://127.0.0.1:5001/api/hosts/piapps/containers/$CID/logs?follow=1×tamps=1"
# Host file log snapshot
curl -sS "http://127.0.0.1:5001/api/hostlogs/nginx_containerYard_error?tail=100&grep=error" | tail- Container → Logs tab shows live tail and controls.
- Synology containers show Open in Dozzle button.
/host-logslists allowlisted logs; each opens a live viewer.- Pause/Resume works; Copy copies visible lines; Download available for admin only.
- SSE stays connected for 10+ minutes.
- Rate limits kick in when spamming requests.
- Invalid
grepdoesn’t crash server; falls back to substring.
- dockerode example for logs:
const stream = await container.logs({ follow, stdout, stderr, tail, since, timestamps }); // demux if needed: Docker can multiplex stdout/stderr in a single stream
- For tailing files efficiently, consider a small helper that seeks from end and watches file growth. Keep memory bounded.
- Wrap all streams with
on('error')handlers; ensure properres.flushHeaders()for SSE.
docker-compose.loki.ymlwith Loki + Promtail- promtail scrape configs for:
/var/log/nginx/*.log$HOME/.pm2/logs/*.log- Docker containers (
/var/lib/docker/containers/*/*-json.log)
- Grafana data source provisioning and a basic “Logs Explorer” dashboard.
- ContainerYard left nav: add “Open Grafana Logs” outbound link.
- All routes implemented with tests above passing.
- UI works on desktop and mobile, follows our theme.
- No open file descriptors after client disconnect (SSE cleanup).
- Role checks in place; log downloads admin-only.
- No network exposure of Docker socket.
- PR includes brief
HARDENING_SUMMARY.mdsection about the new endpoints.
Please implement now. If something is unclear, assume sensible defaults and proceed — we’ll iterate after first pass.