Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 80 additions & 7 deletions packages/client/src/features/system/DevicesTab.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,90 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi, afterEach } from 'vitest'

afterEach(() => {
vi.restoreAllMocks()
})

describe('DevicesTab', () => {
describe('§6.6 — Devices Tab', () => {
it('§6.6 — shows connected devices and pending pairing requests', async () => {
it('exports the component and restart helpers', async () => {
const mod = await import('./DevicesTab.js')
expect(typeof mod.default).toBe('function')
expect(typeof mod.fetchDevices).toBe('function')
expect(typeof mod.requestGatewayRestart).toBe('function')
expect(typeof mod.waitForGatewayReconnect).toBe('function')
})

it('requestGatewayRestart posts to the gateway restart endpoint and returns the success payload', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ status: 'accepted', message: 'gateway restarted', command: 'openclaw gateway restart' })
})
vi.stubGlobal('fetch', fetchMock)

const { requestGatewayRestart } = await import('./DevicesTab.js')
const result = await requestGatewayRestart()

expect(fetchMock).toHaveBeenCalledWith('/api/system/gateway/restart', {
method: 'POST',
headers: { Accept: 'application/json' }
})
expect(result).toEqual({
status: 'accepted',
message: 'gateway restarted',
command: 'openclaw gateway restart'
})
})

it('requestGatewayRestart surfaces server errors', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 409,
json: async () => ({ error: 'Gateway restart already in progress' })
})
)

const { requestGatewayRestart } = await import('./DevicesTab.js')

await expect(requestGatewayRestart()).rejects.toThrow('Gateway restart already in progress')
})

it('waitForGatewayReconnect returns connected once the current device reconnects', async () => {
const { waitForGatewayReconnect } = await import('./DevicesTab.js')
const fetchDevicesFn = vi
.fn()
.mockResolvedValueOnce({
devices: [{ isCurrent: true, connectionStatus: 'connecting' }]
})
.mockResolvedValueOnce({
devices: [{ isCurrent: true, connectionStatus: 'connected' }]
})

const result = await waitForGatewayReconnect({
pollMs: 1,
timeoutMs: 50,
fetchDevicesFn: fetchDevicesFn as any
})

expect(result).toBe('connected')
expect(fetchDevicesFn).toHaveBeenCalledTimes(2)
})

it('§6.6 — exports status helpers', async () => {
const { statusColor, statusLabel } = (await import('./DevicesTab.js')) as any
// These are module-scoped functions, not exported — component renders them inline
// Just verify the component is importable
expect(true).toBe(true)
it('waitForGatewayReconnect returns timeout when reconnect is not observed in time', async () => {
const { waitForGatewayReconnect } = await import('./DevicesTab.js')
const fetchDevicesFn = vi.fn().mockResolvedValue({
devices: [{ isCurrent: true, connectionStatus: 'connecting' }]
})

const result = await waitForGatewayReconnect({
pollMs: 1,
timeoutMs: 5,
fetchDevicesFn: fetchDevicesFn as any
})

expect(result).toBe('timeout')
expect(fetchDevicesFn).toHaveBeenCalled()
})
})
})
123 changes: 118 additions & 5 deletions packages/client/src/features/system/DevicesTab.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// §6.6 Devices Tab — Device identity, gateway connection, and pairing
import { createSignal, onMount, Show, For, type Component } from 'solid-js'
import { createSignal, onMount, onCleanup, Show, For, type Component } from 'solid-js'

export interface DeviceInfo {
deviceId: string
Expand All @@ -22,6 +22,56 @@ export interface DevicesData {
pendingRequests?: PairingRequest[]
}

export async function fetchDevices(): Promise<DevicesData> {
const res = await fetch('/api/system/devices')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}

export async function requestGatewayRestart(): Promise<{ status: string; message?: string; command?: string }> {
const res = await fetch('/api/system/gateway/restart', {
method: 'POST',
headers: { Accept: 'application/json' }
})
const body = (await res.json().catch(() => ({}))) as {
error?: string
status?: string
message?: string
command?: string
}
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`)
return {
status: body.status || 'accepted',
message: body.message,
command: body.command
}
}

export async function waitForGatewayReconnect(options?: {
pollMs?: number
timeoutMs?: number
fetchDevicesFn?: typeof fetchDevices
}): Promise<'connected' | 'timeout'> {
const pollMs = options?.pollMs ?? 1000
const timeoutMs = options?.timeoutMs ?? 20_000
const fetchDevicesFn = options?.fetchDevicesFn ?? fetchDevices
const deadline = Date.now() + timeoutMs

while (Date.now() < deadline) {
try {
const data = await fetchDevicesFn()
if (data.devices.some((device) => device.isCurrent && device.connectionStatus === 'connected')) {
return 'connected'
}
} catch {
// Ignore transient fetch failures while the gateway is bouncing.
}
await new Promise((resolve) => setTimeout(resolve, pollMs))
}

return 'timeout'
}

function statusColor(status: string): string {
if (status === 'connected') return 'bg-green-500'
if (status === 'connecting') return 'bg-amber-500'
Expand All @@ -35,31 +85,94 @@ function statusLabel(status: string): string {
const DevicesTab: Component = () => {
const [data, setData] = createSignal<DevicesData | null>(null)
const [error, setError] = createSignal<string | null>(null)
const [restartState, setRestartState] = createSignal<'idle' | 'restarting' | 'recovering'>('idle')
const [restartMessage, setRestartMessage] = createSignal<string | null>(null)
let disposed = false

const load = async () => {
try {
const res = await fetch('/api/system/devices')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setData(await res.json())
setData(await fetchDevices())
setError(null)
} catch (e: any) {
setError(e.message ?? 'Failed to load')
}
}

const handleRestartGateway = async () => {
if (restartState() !== 'idle') return

setRestartState('restarting')
setRestartMessage('Restarting OpenClaw gateway…')

try {
const result = await requestGatewayRestart()
if (disposed) return
await load()
setRestartState('recovering')
setRestartMessage(result.message || 'Gateway restart requested. Waiting for reconnect…')
const reconnectResult = await waitForGatewayReconnect()
if (disposed) return
await load()
if (reconnectResult === 'connected') {
setRestartMessage('OpenClaw gateway restarted and reconnected.')
} else {
setRestartMessage(
'Gateway restart completed, but reconnect was not observed yet. Check the device status above.'
)
}
} catch (e: any) {
if (disposed) return
setRestartMessage(e?.message ?? 'Failed to restart OpenClaw gateway')
} finally {
if (!disposed) setRestartState('idle')
}
}

onMount(load)
onCleanup(() => {
disposed = true
})

return (
<div class="space-y-6">
{error() && <div class="rounded border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">{error()}</div>}

{restartMessage() && (
<div
class="rounded border p-3 text-sm"
style={{
'border-color': restartMessage()?.toLowerCase().includes('failed')
? 'rgb(239 68 68 / 0.3)'
: 'var(--c-border)',
background: restartState() === 'idle' ? 'var(--c-bg-raised)' : 'rgb(59 130 246 / 0.08)',
color: restartMessage()?.toLowerCase().includes('failed') ? 'rgb(248 113 113)' : 'var(--c-text)'
}}
>
{restartMessage()}
</div>
)}

{!data() && !error() && <div class="text-sm opacity-60">Loading devices…</div>}

<Show when={data()}>
{(d) => (
<>
<div>
<h3 class="mb-3 text-sm font-semibold opacity-80">Devices ({d().devices.length})</h3>
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
<h3 class="text-sm font-semibold opacity-80">Devices ({d().devices.length})</h3>
<button
class="cursor-pointer rounded border px-3 py-1.5 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-60"
style={{ 'border-color': 'var(--c-border)', background: 'transparent', color: 'var(--c-accent)' }}
disabled={restartState() !== 'idle'}
onClick={() => void handleRestartGateway()}
>
{restartState() === 'restarting'
? 'Restarting Gateway…'
: restartState() === 'recovering'
? 'Waiting for Reconnect…'
: 'Restart OpenClaw Gateway'}
</button>
</div>
<div class="space-y-2">
<For each={d().devices}>
{(device) => (
Expand Down
53 changes: 53 additions & 0 deletions packages/server/src/system/routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// System REST endpoints: GET /api/system/architecture, GET /api/system/health, GET /api/system/logs

import { Router } from 'express'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import type { SystemModule } from './system.js'
import type { LogsChannel } from './ws.js'
import { readPersistedLogs } from './ws.js'
Expand All @@ -16,12 +18,17 @@ export interface DeviceInfoProvider {
}
}

export interface GatewayRestartService {
restart(): Promise<{ message: string; command: string }>
}

export interface SystemRoutesOptions {
system: SystemModule
logsChannel: LogsChannel
dataDir: string
healthHistory?: HealthHistory
deviceInfoProvider?: DeviceInfoProvider
gatewayRestart?: GatewayRestartService
}

async function fetchContextBudgetFromGateway(): Promise<Record<string, unknown> | null> {
Expand All @@ -43,6 +50,25 @@ async function fetchContextBudgetFromGateway(): Promise<Record<string, unknown>
return null
}

const execFileAsync = promisify(execFile)

export function createGatewayRestartService(): GatewayRestartService {
return {
async restart() {
const command = 'openclaw gateway restart'
const { stdout, stderr } = await execFileAsync('openclaw', ['gateway', 'restart'], {
timeout: 30_000,
maxBuffer: 1024 * 1024
})
const output = [stdout, stderr].filter(Boolean).join('\n').trim()
return {
command,
message: output || 'OpenClaw gateway restart completed'
}
}
}
}

function mockContextBudget(): Record<string, unknown> {
return {
report: {
Expand Down Expand Up @@ -73,6 +99,9 @@ export function createSystemRoutes(opts: SystemRoutesOptions | SystemModule): Ro
const dataDir = 'dataDir' in opts ? (opts as SystemRoutesOptions).dataDir : null
const healthHistory = 'healthHistory' in opts ? (opts as SystemRoutesOptions).healthHistory : null
const deviceInfoProvider = 'deviceInfoProvider' in opts ? (opts as SystemRoutesOptions).deviceInfoProvider : null
const gatewayRestart =
'gatewayRestart' in opts ? (opts as SystemRoutesOptions).gatewayRestart : createGatewayRestartService()
let restartInFlight: Promise<{ message: string; command: string }> | null = null

router.get('/api/system/identity', (_req, res) => {
res.json({
Expand Down Expand Up @@ -154,6 +183,30 @@ export function createSystemRoutes(opts: SystemRoutesOptions | SystemModule): Ro
})
})

router.post('/api/system/gateway/restart', async (_req, res) => {
if (!gatewayRestart) {
res.status(500).json({ error: 'Gateway restart service unavailable' })
return
}

if (restartInFlight) {
res.status(409).json({ error: 'Gateway restart already in progress' })
return
}

restartInFlight = gatewayRestart.restart()
try {
const result = await restartInFlight
res.status(202).json({ status: 'accepted', ...result })
} catch (error) {
const message =
error instanceof Error && error.message.trim() ? error.message : 'Failed to restart OpenClaw gateway'
res.status(500).json({ error: message })
} finally {
restartInFlight = null
}
})

// Watchdog endpoint
router.get('/api/system/watchdog', async (_req, res) => {
const checks: Array<{ name: string; status: 'ok' | 'warning' | 'error'; message: string }> = []
Expand Down
Loading
Loading