fix(gateway): resolve Docker connectivity — 404 endpoints, EROFS write, missing OPENCLAW_HOME (#343)
* 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.
This commit is contained in:
parent
2b4438b63a
commit
8706147f84
|
|
@ -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',
|
||||
'',
|
||||
'<details><summary>Changed UI files</summary>',
|
||||
'',
|
||||
'```',
|
||||
paths,
|
||||
'```',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'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,
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
38
install.sh
38
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"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Response> {
|
||||
/** Probe the gateway HTTP /health endpoint to check reachability. */
|
||||
async function isGatewayReachable(): Promise<boolean> {
|
||||
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<DeviceAction, { method: string; paramKey: 'requestId' | 'deviceId' }> = {
|
||||
'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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue