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 }
}