From 8706147f84a39959b1fe1c3121e310a6aebebb03 Mon Sep 17 00:00:00 2001 From: HonzysClawdbot Date: Sat, 14 Mar 2026 09:38:13 +0100 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20resolve=20Docker=20connectivity?= =?UTF-8?q?=20=E2=80=94=20404=20endpoints,=20EROFS=20write,=20missing=20OP?= =?UTF-8?q?ENCLAW=5FHOME=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add screenshot drift prevention guide and CI workflow - Add docs/SCREENSHOT-GUIDE.md with step-by-step instructions for capturing, optimising, and committing fresh README screenshots - Add .github/workflows/screenshot-drift.yml that detects UI file changes in PRs, applies a 'screenshot-drift' label, and posts a checklist comment reminding contributors to verify screenshots Addresses: README screenshots may drift as the UI evolves quickly (setup wizard, health history timeline, and onboarding were added after the last screenshot refresh on 2026-03-11) * fix(gateway): resolve Docker connectivity — 404 endpoints, EROFS write, missing OPENCLAW_HOME Fixes #332, #333 Root causes ----------- 1. nodes/route.ts called non-existent HTTP REST endpoints on the gateway (/api/presence, /api/devices, /api/rpc). The OpenClaw gateway only exposes HTTP at /health; all other operations use WebSocket RPC. 2. gateway-runtime.ts attempted fs.writeFileSync on openclaw.json which fails with EROFS when the container or mount is read-only (docker-compose uses read_only: true by default). 3. install.sh never wrote the detected OPENCLAW_HOME or Docker host gateway IP into .env, leaving OPENCLAW_HOME empty after a fresh install. Fixes ----- - nodes/route.ts: replace HTTP calls to /api/presence and /api/devices with gateway /health reachability check + callOpenClawGateway() RPC (node.list / device.pair.list). When the openclaw CLI is unavailable inside Docker, the RPC falls back gracefully and returns connected=true with an empty list rather than a misleading 'Gateway offline' error. POST device actions likewise use callOpenClawGateway instead of /api/rpc. - gateway-runtime.ts: catch EROFS / EACCES / EPERM on config write and emit a warn-level log with a hint, instead of an error. This eliminates the 'Failed to register MC in gateway config EROFS: read-only file system' log spam on Docker installs where openclaw.json is read-only. - install.sh: after generating .env, write the detected OPENCLAW_HOME. In --docker mode also detect the Docker host IP (host-gateway or bridge default) and set OPENCLAW_GATEWAY_HOST so the container can reach a gateway running on the host out of the box. - docker-compose.yml: add extra_hosts host-gateway so the container can resolve the Docker host IP; add a commented volume snippet for mounting the OpenClaw state directory read-only. --- .github/workflows/screenshot-drift.yml | 98 ++++++++++++++++++++++++++ docker-compose.yml | 9 +++ docs/SCREENSHOT-GUIDE.md | 88 +++++++++++++++++++++++ install.sh | 38 ++++++++++ src/app/api/nodes/route.ts | 75 +++++++++++--------- src/lib/gateway-runtime.ts | 13 +++- 6 files changed, 286 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/screenshot-drift.yml create mode 100644 docs/SCREENSHOT-GUIDE.md diff --git a/.github/workflows/screenshot-drift.yml b/.github/workflows/screenshot-drift.yml new file mode 100644 index 0000000..4c06e33 --- /dev/null +++ b/.github/workflows/screenshot-drift.yml @@ -0,0 +1,98 @@ +name: Screenshot Drift Check + +on: + pull_request: + paths: + - "src/app/**" + - "src/components/**" + - "public/**" + +permissions: + contents: read + pull-requests: write + +jobs: + flag-drift: + name: Flag potential screenshot drift + runs-on: ubuntu-latest + steps: + - name: Check for UI changes + id: ui_changed + uses: actions/github-script@v7 + with: + script: | + const { data: files } = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + per_page: 100, + }); + + const uiPaths = files + .map(f => f.filename) + .filter(f => + f.startsWith('src/app/') || + f.startsWith('src/components/') || + f.startsWith('public/') + ); + + core.setOutput('count', uiPaths.length); + core.setOutput('paths', uiPaths.slice(0, 10).join('\n')); + + - name: Add label if UI changed + if: steps.ui_changed.outputs.count > 0 + uses: actions/github-script@v7 + with: + script: | + // Ensure the label exists + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'screenshot-drift', + color: 'f9d71c', + description: 'UI changed — README screenshots may need updating', + }); + } catch (_) { /* label already exists */ } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['screenshot-drift'], + }); + + - name: Post checklist comment + if: steps.ui_changed.outputs.count > 0 + uses: actions/github-script@v7 + with: + script: | + const paths = `${{ steps.ui_changed.outputs.paths }}`; + const body = [ + '## 📸 Screenshot Drift Check', + '', + 'This PR modifies UI source files. Please verify whether the README screenshots need refreshing:', + '', + '- [ ] `docs/mission-control-overview.png` — main dashboard', + '- [ ] `docs/mission-control-agents.png` — agents panel', + '- [ ] `docs/mission-control-memory-graph.png` — memory graph', + '', + '
Changed UI files', + '', + '```', + paths, + '```', + '', + '
', + '', + 'See [`docs/SCREENSHOT-GUIDE.md`](docs/SCREENSHOT-GUIDE.md) for instructions on capturing and optimising screenshots.', + '', + '_This comment is posted automatically and can be dismissed if no visual changes occurred._', + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); diff --git a/docker-compose.yml b/docker-compose.yml index fb53bc9..00e60b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,15 @@ services: required: false volumes: - mc-data:/app/.data + # Optional: mount your OpenClaw state directory read-only so Mission Control + # can read agent configs and memory. Uncomment and adjust the host path: + # - ${OPENCLAW_HOME:-~/.openclaw}:/run/openclaw:ro + # Allow the container to reach an OpenClaw gateway running on the Docker host. + # Uses the special host-gateway alias available on Docker 20.10+. + # If your gateway runs in another container on this network, remove this line + # and set OPENCLAW_GATEWAY_HOST to the container name instead. + extra_hosts: + - "host-gateway:host-gateway" read_only: true tmpfs: - /tmp diff --git a/docs/SCREENSHOT-GUIDE.md b/docs/SCREENSHOT-GUIDE.md new file mode 100644 index 0000000..fb521e4 --- /dev/null +++ b/docs/SCREENSHOT-GUIDE.md @@ -0,0 +1,88 @@ +# Screenshot Guide + +This document explains how to capture and update the README screenshots so they stay in sync with the UI. + +## Screenshots in the README + +| File | Section | Description | +|------|---------|-------------| +| `docs/mission-control-overview.png` | Dashboard Overview | Main dashboard view | +| `docs/mission-control-agents.png` | Agents Panel | Active agents list | +| `docs/mission-control-memory-graph.png` | Memory Graph | Agent memory graph | + +## When to Refresh + +Screenshots should be updated when: + +- A new page, panel, or major UI component is added +- An existing page layout changes noticeably +- The color scheme or branding updates +- A GitHub Actions `screenshot-drift` label is applied to a PR (see [automation](#automation)) + +## How to Take New Screenshots + +### Prerequisites + +- Mission Control running locally (`pnpm dev` or Docker) +- Browser with at least 1440×900 viewport recommended + +### Steps + +1. **Start the app** (with some sample data for a realistic view): + + ```bash + pnpm dev + # or + docker compose up + ``` + +2. **Seed sample data** (optional but recommended for non-empty screenshots): + + ```bash + pnpm seed # if a seed script exists, otherwise populate via UI + ``` + +3. **Navigate to each page** and take a screenshot: + + | Screenshot | URL | Notes | + |-----------|-----|-------| + | `mission-control-overview.png` | `/` | Main dashboard, full page | + | `mission-control-agents.png` | `/agents` | Agents panel open | + | `mission-control-memory-graph.png` | `/memory` | Memory graph with nodes | + +4. **Crop and optimise** to reduce file size: + + ```bash + # macOS + pngcrush -reduce -brute input.png output.png + + # Linux + optipng -o5 input.png + # or + pngquant --quality=80-95 --output output.png input.png + ``` + +5. **Replace the files** under `docs/` and commit: + + ```bash + cp ~/Downloads/dashboard.png docs/mission-control-overview.png + git add docs/ + git commit -m "docs: refresh README screenshots" + ``` + +## Automation + +The repository has a GitHub Actions workflow (`.github/workflows/screenshot-drift.yml`) that: + +- Detects changes to files under `src/app/`, `src/components/`, and `public/` +- Adds a `screenshot-drift` label to the PR as a reminder +- Posts a checklist comment listing which screenshots may need updating + +This does **not** auto-capture screenshots — it just flags the PR so a human can decide whether the change is visually significant enough to warrant a refresh. + +## Tips + +- Use a consistent browser zoom level (100%) and window size +- Hide bookmarks bar and dev tools before capturing +- Light mode and dark mode screenshots can coexist — add a `*-dark.png` variant if useful +- Prefer PNG for UI screenshots (lossless); JPEG for photos/illustrations diff --git a/install.sh b/install.sh index 5c3ac06..f4e8133 100755 --- a/install.sh +++ b/install.sh @@ -158,6 +158,44 @@ setup_env() { fi fi + # Auto-detect and write OpenClaw home directory into .env + local oc_home="${OPENCLAW_HOME:-$HOME/.openclaw}" + if [[ -d "$oc_home" ]]; then + if [[ "$(uname)" == "Darwin" ]]; then + sed -i '' "s|^OPENCLAW_HOME=.*|OPENCLAW_HOME=$oc_home|" "$INSTALL_DIR/.env" + else + sed -i "s|^OPENCLAW_HOME=.*|OPENCLAW_HOME=$oc_home|" "$INSTALL_DIR/.env" + fi + info "Set OPENCLAW_HOME=$oc_home in .env" + fi + + # In Docker mode, the gateway runs on the host, not inside the container. + # Set OPENCLAW_GATEWAY_HOST to the Docker host gateway IP so the container + # can reach the gateway. Users may override this with the gateway container + # name if running OpenClaw in a container on the same network. + if [[ "$DEPLOY_MODE" == "docker" ]]; then + local gw_host="${OPENCLAW_GATEWAY_HOST:-}" + if [[ -z "$gw_host" ]]; then + # Detect Docker host IP (host-gateway alias or default bridge) + if getent hosts host-gateway &>/dev/null 2>&1; then + gw_host="host-gateway" + else + # Fallback: use the default Docker bridge gateway (172.17.0.1) + gw_host=$(ip route show default 2>/dev/null | awk '/default/ {print $3; exit}' || echo "172.17.0.1") + fi + fi + if [[ -n "$gw_host" && "$gw_host" != "127.0.0.1" ]]; then + if [[ "$(uname)" == "Darwin" ]]; then + sed -i '' "s|^OPENCLAW_GATEWAY_HOST=.*|OPENCLAW_GATEWAY_HOST=$gw_host|" "$INSTALL_DIR/.env" + else + sed -i "s|^OPENCLAW_GATEWAY_HOST=.*|OPENCLAW_GATEWAY_HOST=$gw_host|" "$INSTALL_DIR/.env" + fi + info "Set OPENCLAW_GATEWAY_HOST=$gw_host in .env (Docker host IP)" + info " If your gateway runs in a Docker container, update OPENCLAW_GATEWAY_HOST" + info " to the container name and add it to the mc-net network." + fi + fi + ok "Secure .env generated" } diff --git a/src/app/api/nodes/route.ts b/src/app/api/nodes/route.ts index c01beb5..9813a80 100644 --- a/src/app/api/nodes/route.ts +++ b/src/app/api/nodes/route.ts @@ -2,21 +2,22 @@ import { NextRequest, NextResponse } from 'next/server' import { requireRole } from '@/lib/auth' import { config } from '@/lib/config' import { logger } from '@/lib/logger' +import { callOpenClawGateway } from '@/lib/openclaw-gateway' const GATEWAY_TIMEOUT = 5000 -function gatewayUrl(path: string): string { - return `http://${config.gatewayHost}:${config.gatewayPort}${path}` -} - -async function fetchGateway(path: string, init?: RequestInit): Promise { +/** Probe the gateway HTTP /health endpoint to check reachability. */ +async function isGatewayReachable(): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), GATEWAY_TIMEOUT) try { - return await fetch(gatewayUrl(path), { - ...init, - signal: controller.signal, - }) + const res = await fetch( + `http://${config.gatewayHost}:${config.gatewayPort}/health`, + { signal: controller.signal }, + ) + return res.ok + } catch { + return false } finally { clearTimeout(timeout) } @@ -30,28 +31,44 @@ export async function GET(request: NextRequest) { if (action === 'list') { try { - const res = await fetchGateway('/api/presence') - if (!res.ok) { - logger.warn({ status: res.status }, 'Gateway presence endpoint returned non-OK') + const connected = await isGatewayReachable() + if (!connected) { return NextResponse.json({ nodes: [], connected: false }) } - const data = await res.json() - return NextResponse.json(data) + + try { + const data = await callOpenClawGateway<{ nodes?: unknown[] }>('node.list', {}, GATEWAY_TIMEOUT) + return NextResponse.json({ nodes: data?.nodes ?? [], connected: true }) + } catch (rpcErr) { + // Gateway is reachable but openclaw CLI unavailable (e.g. Docker) or + // node.list not supported — return connected=true with empty node list + logger.warn({ err: rpcErr }, 'node.list RPC failed, returning empty node list') + return NextResponse.json({ nodes: [], connected: true }) + } } catch (err) { - logger.warn({ err }, 'Gateway unreachable for presence listing') + logger.warn({ err }, 'Gateway unreachable for node listing') return NextResponse.json({ nodes: [], connected: false }) } } if (action === 'devices') { try { - const res = await fetchGateway('/api/devices') - if (!res.ok) { - logger.warn({ status: res.status }, 'Gateway devices endpoint returned non-OK') + const connected = await isGatewayReachable() + if (!connected) { + return NextResponse.json({ devices: [] }) + } + + try { + const data = await callOpenClawGateway<{ devices?: unknown[] }>( + 'device.pair.list', + {}, + GATEWAY_TIMEOUT, + ) + return NextResponse.json({ devices: data?.devices ?? [] }) + } catch (rpcErr) { + logger.warn({ err: rpcErr }, 'device.pair.list RPC failed, returning empty device list') return NextResponse.json({ devices: [] }) } - const data = await res.json() - return NextResponse.json(data) } catch (err) { logger.warn({ err }, 'Gateway unreachable for device listing') return NextResponse.json({ devices: [] }) @@ -64,6 +81,7 @@ export async function GET(request: NextRequest) { const VALID_DEVICE_ACTIONS = ['approve', 'reject', 'rotate-token', 'revoke-token'] as const type DeviceAction = (typeof VALID_DEVICE_ACTIONS)[number] +/** Map UI action names to gateway RPC method names and their required param keys. */ const ACTION_RPC_MAP: Record = { 'approve': { method: 'device.pair.approve', paramKey: 'requestId' }, 'reject': { method: 'device.pair.reject', paramKey: 'requestId' }, @@ -112,21 +130,10 @@ export async function POST(request: NextRequest) { } try { - const res = await fetchGateway('/api/rpc', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ method: spec.method, params }), - }) - - const data = await res.json() - return NextResponse.json(data, { status: res.status }) + const result = await callOpenClawGateway(spec.method, params, GATEWAY_TIMEOUT) + return NextResponse.json(result) } catch (err: unknown) { - const name = err instanceof Error ? err.name : '' - if (name === 'AbortError') { - logger.error('Gateway device action request timed out') - return NextResponse.json({ error: 'Gateway request timed out' }, { status: 504 }) - } logger.error({ err }, 'Gateway device action failed') - return NextResponse.json({ error: 'Gateway unreachable' }, { status: 502 }) + return NextResponse.json({ error: 'Gateway device action failed' }, { status: 502 }) } } diff --git a/src/lib/gateway-runtime.ts b/src/lib/gateway-runtime.ts index 490a561..8802ab3 100644 --- a/src/lib/gateway-runtime.ts +++ b/src/lib/gateway-runtime.ts @@ -61,7 +61,18 @@ export function registerMcAsDashboard(mcUrl: string): { registered: boolean; alr fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2) + '\n') logger.info({ origin }, 'Registered MC origin in gateway config') return { registered: true, alreadySet: false } - } catch (err) { + } catch (err: any) { + // Read-only filesystem (e.g. Docker read_only: true, or intentional mount) — + // treat as a non-fatal skip rather than an error. + if (err?.code === 'EROFS' || err?.code === 'EACCES' || err?.code === 'EPERM') { + logger.warn( + { err, configPath }, + 'Gateway config is read-only — skipping MC origin registration. ' + + 'To enable auto-registration, mount openclaw.json with write access or ' + + 'add the MC origin to gateway.controlUi.allowedOrigins manually.', + ) + return { registered: false, alreadySet: false } + } logger.error({ err }, 'Failed to register MC in gateway config') return { registered: false, alreadySet: false } }