clawport-ui 0.5.4 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/app/activity/page.tsx +287 -0
- package/app/api/costs/route.ts +14 -0
- package/app/api/logs/route.ts +18 -0
- package/app/api/logs/stream/route.ts +103 -0
- package/app/costs/page.tsx +7 -0
- package/app/layout.tsx +2 -0
- package/app/memory/page.tsx +117 -57
- package/components/AgentNode.tsx +6 -6
- package/components/LiveStreamWidget.tsx +497 -0
- package/components/MobileSidebar.tsx +7 -2
- package/components/NavLinks.tsx +3 -1
- package/components/Sidebar.tsx +3 -1
- package/components/activity/LogBrowser.tsx +315 -0
- package/components/costs/CostsPage.tsx +757 -0
- package/docs/COMPONENTS.md +57 -0
- package/lib/costs.test.ts +265 -0
- package/lib/costs.ts +215 -0
- package/lib/cron-runs.test.ts +30 -0
- package/lib/cron-runs.ts +11 -0
- package/lib/logs.test.ts +381 -0
- package/lib/logs.ts +173 -0
- package/lib/sse.ts +60 -0
- package/lib/types.ts +108 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/clawport-ui)
|
|
8
8
|
[](LICENSE)
|
|
9
|
-
[](#testing)
|
|
10
10
|
|
|
11
11
|
[Website](https://clawport.dev) | [Setup Guide](SETUP.md) | [API Docs](docs/API.md) | [npm](https://www.npmjs.com/package/clawport-ui)
|
|
12
12
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
17
|
-
ClawPort is an open-source dashboard for managing, monitoring, and talking directly to your [OpenClaw](https://openclaw.ai) AI agents. It connects to your local OpenClaw gateway and gives you an org chart, direct agent chat with vision and voice, a kanban board, cron monitoring, and a memory browser -- all in one place.
|
|
17
|
+
ClawPort is an open-source dashboard for managing, monitoring, and talking directly to your [OpenClaw](https://openclaw.ai) AI agents. It connects to your local OpenClaw gateway and gives you an org chart, direct agent chat with vision and voice, a kanban board, cron monitoring, an activity console with live log streaming, and a memory browser -- all in one place.
|
|
18
18
|
|
|
19
19
|
No separate AI API keys needed. Everything routes through your OpenClaw gateway.
|
|
20
20
|
|
|
@@ -84,7 +84,8 @@ npm run dev
|
|
|
84
84
|
- **Chat** -- Streaming text chat, image attachments with vision, voice messages with waveform playback, file attachments, clipboard paste and drag-and-drop. Conversations persist locally.
|
|
85
85
|
- **Kanban** -- Task board for managing work across agents. Drag-and-drop cards with agent assignment and chat context.
|
|
86
86
|
- **Cron Monitor** -- Live status of all scheduled jobs. Filter by status, sort errors to top, expand for details. Auto-refreshes every 60 seconds.
|
|
87
|
-
- **
|
|
87
|
+
- **Activity Console** -- Log browser for historical events plus a floating live stream widget. Click any log row to expand the raw JSON. The live stream widget persists across page navigation.
|
|
88
|
+
- **Memory Browser** -- Read team memory, long-term memory, and daily logs. Markdown rendering, JSON syntax highlighting, search, and download. Guide tab with categorized best practices.
|
|
88
89
|
- **Agent Detail** -- Full profile per agent: SOUL.md viewer, tools, hierarchy, crons, voice ID, and direct chat link.
|
|
89
90
|
- **Five Themes** -- Dark, Glass, Color, Light, and System. All CSS custom properties -- switch instantly.
|
|
90
91
|
- **Auto-Discovery** -- Automatically finds agents from your OpenClaw workspace. No config file needed.
|
|
@@ -167,7 +168,7 @@ clawport help # Show usage
|
|
|
167
168
|
## Testing
|
|
168
169
|
|
|
169
170
|
```bash
|
|
170
|
-
npm test #
|
|
171
|
+
npm test # 501 tests across 23 suites (Vitest)
|
|
171
172
|
npx tsc --noEmit # Type-check (zero errors)
|
|
172
173
|
npx next build # Production build
|
|
173
174
|
```
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
+
import type { LogEntry, LogFilter, LogSummary } from '@/lib/types'
|
|
5
|
+
import { Skeleton } from '@/components/ui/skeleton'
|
|
6
|
+
import { RefreshCw, Radio } from 'lucide-react'
|
|
7
|
+
import { ErrorState } from '@/components/ErrorState'
|
|
8
|
+
import { LogBrowser } from '@/components/activity/LogBrowser'
|
|
9
|
+
|
|
10
|
+
/* ── Time helpers ──────────────────────────────────────────────── */
|
|
11
|
+
|
|
12
|
+
function timeAgo(dateStr: string): string {
|
|
13
|
+
const d = new Date(dateStr)
|
|
14
|
+
if (isNaN(d.getTime())) return '--'
|
|
15
|
+
const diff = Date.now() - d.getTime()
|
|
16
|
+
const mins = Math.floor(diff / 60000)
|
|
17
|
+
const hrs = Math.floor(diff / 3600000)
|
|
18
|
+
const days = Math.floor(diff / 86400000)
|
|
19
|
+
if (mins < 1) return 'just now'
|
|
20
|
+
if (mins < 60) return `${mins}m ago`
|
|
21
|
+
if (hrs < 24) return `${hrs}h ago`
|
|
22
|
+
return `${days}d ago`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* ── Summary Cards ─────────────────────────────────────────────── */
|
|
26
|
+
|
|
27
|
+
function TotalCard({ count }: { count: number }) {
|
|
28
|
+
return (
|
|
29
|
+
<div style={{
|
|
30
|
+
background: 'var(--material-regular)',
|
|
31
|
+
border: '1px solid var(--separator)',
|
|
32
|
+
borderRadius: 'var(--radius-md)',
|
|
33
|
+
padding: 'var(--space-4)',
|
|
34
|
+
}}>
|
|
35
|
+
<div style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)', fontWeight: 'var(--weight-medium)', marginBottom: 'var(--space-1)' }}>
|
|
36
|
+
Total Events
|
|
37
|
+
</div>
|
|
38
|
+
<div style={{ fontSize: 'var(--text-title2)', color: 'var(--text-primary)', fontWeight: 'var(--weight-bold)' }}>
|
|
39
|
+
{count}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ErrorCard({ count }: { count: number }) {
|
|
46
|
+
const hasErrors = count > 0
|
|
47
|
+
return (
|
|
48
|
+
<div style={{
|
|
49
|
+
background: 'var(--material-regular)',
|
|
50
|
+
border: '1px solid var(--separator)',
|
|
51
|
+
borderRadius: 'var(--radius-md)',
|
|
52
|
+
padding: 'var(--space-4)',
|
|
53
|
+
}}>
|
|
54
|
+
<div style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)', fontWeight: 'var(--weight-medium)', marginBottom: 'var(--space-1)' }}>
|
|
55
|
+
Errors
|
|
56
|
+
</div>
|
|
57
|
+
<div className="flex items-center" style={{ gap: 'var(--space-2)' }}>
|
|
58
|
+
{hasErrors && (
|
|
59
|
+
<span className="animate-error-pulse" style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--system-red)', flexShrink: 0 }} />
|
|
60
|
+
)}
|
|
61
|
+
<span style={{
|
|
62
|
+
fontSize: 'var(--text-title2)',
|
|
63
|
+
fontWeight: 'var(--weight-bold)',
|
|
64
|
+
color: hasErrors ? 'var(--system-red)' : 'var(--system-green)',
|
|
65
|
+
}}>
|
|
66
|
+
{count}
|
|
67
|
+
</span>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function SourcesCard({ cron, config }: { cron: number; config: number }) {
|
|
74
|
+
return (
|
|
75
|
+
<div style={{
|
|
76
|
+
background: 'var(--material-regular)',
|
|
77
|
+
border: '1px solid var(--separator)',
|
|
78
|
+
borderRadius: 'var(--radius-md)',
|
|
79
|
+
padding: 'var(--space-4)',
|
|
80
|
+
}}>
|
|
81
|
+
<div style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)', fontWeight: 'var(--weight-medium)', marginBottom: 'var(--space-1)' }}>
|
|
82
|
+
Sources
|
|
83
|
+
</div>
|
|
84
|
+
<div className="flex items-center" style={{ gap: 'var(--space-3)' }}>
|
|
85
|
+
<div>
|
|
86
|
+
<span style={{ fontSize: 'var(--text-footnote)', fontWeight: 'var(--weight-semibold)', color: 'var(--system-blue)' }}>{cron}</span>
|
|
87
|
+
<span style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)', marginLeft: 4 }}>cron</span>
|
|
88
|
+
</div>
|
|
89
|
+
<div>
|
|
90
|
+
<span style={{ fontSize: 'var(--text-footnote)', fontWeight: 'var(--weight-semibold)', color: 'var(--system-purple)' }}>{config}</span>
|
|
91
|
+
<span style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)', marginLeft: 4 }}>config</span>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* ── Page ──────────────────────────────────────────────────────── */
|
|
99
|
+
|
|
100
|
+
export default function ActivityPage() {
|
|
101
|
+
const [entries, setEntries] = useState<LogEntry[]>([])
|
|
102
|
+
const [summary, setSummary] = useState<LogSummary | null>(null)
|
|
103
|
+
const [filter, setFilter] = useState<LogFilter>('all')
|
|
104
|
+
const [lastRefresh, setLastRefresh] = useState<Date>(new Date())
|
|
105
|
+
const [loading, setLoading] = useState(true)
|
|
106
|
+
const [refreshing, setRefreshing] = useState(false)
|
|
107
|
+
const [error, setError] = useState<string | null>(null)
|
|
108
|
+
const [updatedAgo, setUpdatedAgo] = useState('just now')
|
|
109
|
+
|
|
110
|
+
const refresh = useCallback(() => {
|
|
111
|
+
setRefreshing(true)
|
|
112
|
+
setError(null)
|
|
113
|
+
fetch('/api/logs')
|
|
114
|
+
.then(r => {
|
|
115
|
+
if (!r.ok) throw new Error('Failed to load logs')
|
|
116
|
+
return r.json()
|
|
117
|
+
})
|
|
118
|
+
.then((data: { entries: LogEntry[]; summary: LogSummary }) => {
|
|
119
|
+
setEntries(data.entries)
|
|
120
|
+
setSummary(data.summary)
|
|
121
|
+
setLastRefresh(new Date())
|
|
122
|
+
setLoading(false)
|
|
123
|
+
setRefreshing(false)
|
|
124
|
+
})
|
|
125
|
+
.catch(err => {
|
|
126
|
+
setError(err instanceof Error ? err.message : 'Unknown error')
|
|
127
|
+
setLoading(false)
|
|
128
|
+
setRefreshing(false)
|
|
129
|
+
})
|
|
130
|
+
}, [])
|
|
131
|
+
|
|
132
|
+
// Initial load + polling
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
refresh()
|
|
135
|
+
const interval = setInterval(refresh, 60000)
|
|
136
|
+
return () => clearInterval(interval)
|
|
137
|
+
}, [refresh])
|
|
138
|
+
|
|
139
|
+
// Updated ago ticker
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
const tick = () => setUpdatedAgo(timeAgo(lastRefresh.toISOString()))
|
|
142
|
+
tick()
|
|
143
|
+
const interval = setInterval(tick, 30000)
|
|
144
|
+
return () => clearInterval(interval)
|
|
145
|
+
}, [lastRefresh])
|
|
146
|
+
|
|
147
|
+
if (error && entries.length === 0) {
|
|
148
|
+
return <ErrorState message={error} onRetry={refresh} />
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div className="h-full flex flex-col overflow-hidden animate-fade-in" style={{ background: 'var(--bg)' }}>
|
|
153
|
+
{/* ── Sticky header ──────────────────────────────────────── */}
|
|
154
|
+
<header
|
|
155
|
+
className="sticky top-0 z-10 flex-shrink-0"
|
|
156
|
+
style={{
|
|
157
|
+
background: 'var(--material-regular)',
|
|
158
|
+
backdropFilter: 'blur(40px) saturate(180%)',
|
|
159
|
+
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
|
|
160
|
+
borderBottom: '1px solid var(--separator)',
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
<div className="flex items-center justify-between" style={{ padding: 'var(--space-4) var(--space-6)' }}>
|
|
164
|
+
<div>
|
|
165
|
+
<h1 style={{
|
|
166
|
+
fontSize: 'var(--text-title1)',
|
|
167
|
+
fontWeight: 'var(--weight-bold)',
|
|
168
|
+
color: 'var(--text-primary)',
|
|
169
|
+
letterSpacing: '-0.5px',
|
|
170
|
+
lineHeight: 'var(--leading-tight)',
|
|
171
|
+
}}>
|
|
172
|
+
Activity Console
|
|
173
|
+
</h1>
|
|
174
|
+
{!loading && summary && (
|
|
175
|
+
<p style={{ fontSize: 'var(--text-footnote)', color: 'var(--text-secondary)', marginTop: 'var(--space-1)' }}>
|
|
176
|
+
{summary.totalEntries} event{summary.totalEntries !== 1 ? 's' : ''}
|
|
177
|
+
{summary.errorCount > 0 && (
|
|
178
|
+
<span style={{ color: 'var(--system-red)' }}>
|
|
179
|
+
{' \u00b7 '}{summary.errorCount} error{summary.errorCount !== 1 ? 's' : ''}
|
|
180
|
+
</span>
|
|
181
|
+
)}
|
|
182
|
+
</p>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
<div className="flex items-center" style={{ gap: 'var(--space-3)' }}>
|
|
186
|
+
{/* Open Live Stream */}
|
|
187
|
+
<button
|
|
188
|
+
onClick={() => window.dispatchEvent(new CustomEvent('clawport:open-stream-widget'))}
|
|
189
|
+
className="focus-ring flex items-center"
|
|
190
|
+
style={{
|
|
191
|
+
padding: '6px 14px',
|
|
192
|
+
borderRadius: 'var(--radius-sm)',
|
|
193
|
+
border: 'none',
|
|
194
|
+
cursor: 'pointer',
|
|
195
|
+
fontSize: 'var(--text-footnote)',
|
|
196
|
+
fontWeight: 'var(--weight-semibold)',
|
|
197
|
+
gap: 6,
|
|
198
|
+
background: 'var(--accent-fill)',
|
|
199
|
+
color: 'var(--accent)',
|
|
200
|
+
transition: 'all 200ms var(--ease-smooth)',
|
|
201
|
+
}}
|
|
202
|
+
>
|
|
203
|
+
<Radio size={14} />
|
|
204
|
+
Open Live Stream
|
|
205
|
+
</button>
|
|
206
|
+
|
|
207
|
+
<span style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)' }}>
|
|
208
|
+
Updated {updatedAgo}
|
|
209
|
+
</span>
|
|
210
|
+
<button
|
|
211
|
+
onClick={refresh}
|
|
212
|
+
className="focus-ring"
|
|
213
|
+
aria-label="Refresh activity data"
|
|
214
|
+
style={{
|
|
215
|
+
width: 32,
|
|
216
|
+
height: 32,
|
|
217
|
+
display: 'flex',
|
|
218
|
+
alignItems: 'center',
|
|
219
|
+
justifyContent: 'center',
|
|
220
|
+
borderRadius: 'var(--radius-sm)',
|
|
221
|
+
border: 'none',
|
|
222
|
+
background: 'transparent',
|
|
223
|
+
color: 'var(--text-tertiary)',
|
|
224
|
+
cursor: 'pointer',
|
|
225
|
+
transition: 'color 150ms var(--ease-smooth)',
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
<RefreshCw size={16} className={refreshing ? 'animate-spin' : ''} />
|
|
229
|
+
</button>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</header>
|
|
233
|
+
|
|
234
|
+
{/* ── Scrollable content ─────────────────────────────────── */}
|
|
235
|
+
<div className="flex-1 overflow-y-auto flex flex-col" style={{ padding: 'var(--space-4) var(--space-6) var(--space-6)', minHeight: 0 }}>
|
|
236
|
+
{loading ? (
|
|
237
|
+
<>
|
|
238
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--space-3)', marginBottom: 'var(--space-4)' }} className="summary-cards-grid">
|
|
239
|
+
{[1, 2, 3].map(i => (
|
|
240
|
+
<div key={i} style={{ background: 'var(--material-regular)', border: '1px solid var(--separator)', borderRadius: 'var(--radius-md)', padding: 'var(--space-4)' }}>
|
|
241
|
+
<Skeleton style={{ width: 80, height: 10, marginBottom: 8 }} />
|
|
242
|
+
<Skeleton style={{ width: 48, height: 20 }} />
|
|
243
|
+
</div>
|
|
244
|
+
))}
|
|
245
|
+
</div>
|
|
246
|
+
<div style={{ borderRadius: 'var(--radius-md)', overflow: 'hidden', background: 'var(--material-regular)' }}>
|
|
247
|
+
{[1, 2, 3, 4, 5].map(i => (
|
|
248
|
+
<div key={i} className="flex items-center" style={{ padding: 'var(--space-3) var(--space-4)', borderBottom: i < 5 ? '1px solid var(--separator)' : undefined, gap: 'var(--space-3)' }}>
|
|
249
|
+
<Skeleton className="flex-shrink-0" style={{ width: 8, height: 8, borderRadius: '50%' }} />
|
|
250
|
+
<Skeleton style={{ width: 120, height: 12 }} />
|
|
251
|
+
<Skeleton style={{ width: 50, height: 18, borderRadius: 4 }} />
|
|
252
|
+
<Skeleton style={{ width: 200, height: 14, flex: 1 }} />
|
|
253
|
+
</div>
|
|
254
|
+
))}
|
|
255
|
+
</div>
|
|
256
|
+
</>
|
|
257
|
+
) : (
|
|
258
|
+
<>
|
|
259
|
+
{/* Summary cards */}
|
|
260
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 'var(--space-3)', marginBottom: 'var(--space-4)' }} className="summary-cards-grid">
|
|
261
|
+
<TotalCard count={summary?.totalEntries ?? 0} />
|
|
262
|
+
<ErrorCard count={summary?.errorCount ?? 0} />
|
|
263
|
+
<SourcesCard cron={summary?.sources.cron ?? 0} config={summary?.sources.config ?? 0} />
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
{/* Log browser */}
|
|
267
|
+
<LogBrowser
|
|
268
|
+
entries={entries}
|
|
269
|
+
summary={summary}
|
|
270
|
+
loading={false}
|
|
271
|
+
filter={filter}
|
|
272
|
+
onFilterChange={setFilter}
|
|
273
|
+
/>
|
|
274
|
+
</>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
<style>{`
|
|
279
|
+
@media (max-width: 640px) {
|
|
280
|
+
.summary-cards-grid {
|
|
281
|
+
grid-template-columns: 1fr !important;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
`}</style>
|
|
285
|
+
</div>
|
|
286
|
+
)
|
|
287
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { getCronRuns } from '@/lib/cron-runs'
|
|
2
|
+
import { computeCostSummary } from '@/lib/costs'
|
|
3
|
+
import { apiErrorResponse } from '@/lib/api-error'
|
|
4
|
+
import { NextResponse } from 'next/server'
|
|
5
|
+
|
|
6
|
+
export async function GET() {
|
|
7
|
+
try {
|
|
8
|
+
const runs = getCronRuns()
|
|
9
|
+
const summary = computeCostSummary(runs)
|
|
10
|
+
return NextResponse.json(summary)
|
|
11
|
+
} catch (err) {
|
|
12
|
+
return apiErrorResponse(err, 'Failed to compute costs')
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { getLogEntries, computeLogSummary } from '@/lib/logs'
|
|
2
|
+
import { apiErrorResponse } from '@/lib/api-error'
|
|
3
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
4
|
+
|
|
5
|
+
export async function GET(request: NextRequest) {
|
|
6
|
+
try {
|
|
7
|
+
const source = request.nextUrl.searchParams.get('source') ?? undefined
|
|
8
|
+
const limitParam = request.nextUrl.searchParams.get('limit')
|
|
9
|
+
const limit = limitParam ? parseInt(limitParam, 10) : undefined
|
|
10
|
+
|
|
11
|
+
const entries = getLogEntries({ source, limit })
|
|
12
|
+
const summary = computeLogSummary(entries)
|
|
13
|
+
|
|
14
|
+
return NextResponse.json({ entries, summary })
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return apiErrorResponse(err, 'Failed to load logs')
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { spawn } from 'child_process'
|
|
2
|
+
import { requireEnv } from '@/lib/env'
|
|
3
|
+
|
|
4
|
+
const MAX_LIFETIME_MS = 10 * 60 * 1000 // 10 minutes
|
|
5
|
+
const HEARTBEAT_INTERVAL_MS = 15 * 1000 // 15 seconds
|
|
6
|
+
|
|
7
|
+
export async function GET(request: Request) {
|
|
8
|
+
const encoder = new TextEncoder()
|
|
9
|
+
let child: ReturnType<typeof spawn> | null = null
|
|
10
|
+
let heartbeat: ReturnType<typeof setInterval> | null = null
|
|
11
|
+
let lifetime: ReturnType<typeof setTimeout> | null = null
|
|
12
|
+
|
|
13
|
+
const stream = new ReadableStream({
|
|
14
|
+
start(controller) {
|
|
15
|
+
const openclawBin = requireEnv('OPENCLAW_BIN')
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
child = spawn(openclawBin, ['logs', '--follow', '--json'], {
|
|
19
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
20
|
+
})
|
|
21
|
+
} catch (err) {
|
|
22
|
+
const msg = err instanceof Error ? err.message : 'Failed to spawn openclaw'
|
|
23
|
+
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: msg })}\n\n`))
|
|
24
|
+
controller.close()
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let buffer = ''
|
|
29
|
+
|
|
30
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
31
|
+
buffer += chunk.toString()
|
|
32
|
+
const lines = buffer.split('\n')
|
|
33
|
+
buffer = lines.pop() || ''
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
if (!line.trim()) continue
|
|
36
|
+
controller.enqueue(encoder.encode(`data: ${line}\n\n`))
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
41
|
+
const msg = chunk.toString().trim()
|
|
42
|
+
if (msg) {
|
|
43
|
+
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: msg })}\n\n`))
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
child.on('error', (err) => {
|
|
48
|
+
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`))
|
|
49
|
+
cleanup()
|
|
50
|
+
controller.close()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
child.on('close', (code) => {
|
|
54
|
+
if (code !== null && code !== 0) {
|
|
55
|
+
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: `Process exited with code ${code}` })}\n\n`))
|
|
56
|
+
}
|
|
57
|
+
cleanup()
|
|
58
|
+
controller.close()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// Heartbeat to prevent proxy timeouts
|
|
62
|
+
heartbeat = setInterval(() => {
|
|
63
|
+
try {
|
|
64
|
+
controller.enqueue(encoder.encode(`: heartbeat\n\n`))
|
|
65
|
+
} catch {
|
|
66
|
+
// Controller may be closed
|
|
67
|
+
}
|
|
68
|
+
}, HEARTBEAT_INTERVAL_MS)
|
|
69
|
+
|
|
70
|
+
// Max lifetime safety valve
|
|
71
|
+
lifetime = setTimeout(() => {
|
|
72
|
+
cleanup()
|
|
73
|
+
try {
|
|
74
|
+
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: 'Stream max lifetime reached' })}\n\n`))
|
|
75
|
+
controller.close()
|
|
76
|
+
} catch {
|
|
77
|
+
// Already closed
|
|
78
|
+
}
|
|
79
|
+
}, MAX_LIFETIME_MS)
|
|
80
|
+
|
|
81
|
+
// Cleanup on client disconnect
|
|
82
|
+
request.signal.addEventListener('abort', () => {
|
|
83
|
+
cleanup()
|
|
84
|
+
try { controller.close() } catch { /* already closed */ }
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
function cleanup() {
|
|
88
|
+
if (heartbeat) { clearInterval(heartbeat); heartbeat = null }
|
|
89
|
+
if (lifetime) { clearTimeout(lifetime); lifetime = null }
|
|
90
|
+
if (child) { child.kill('SIGTERM'); child = null }
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
return new Response(stream, {
|
|
96
|
+
headers: {
|
|
97
|
+
'Content-Type': 'text/event-stream',
|
|
98
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
99
|
+
'Connection': 'keep-alive',
|
|
100
|
+
'X-Accel-Buffering': 'no',
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
}
|
package/app/layout.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { SettingsProvider } from './settings-provider';
|
|
|
5
5
|
import { Sidebar } from '@/components/Sidebar';
|
|
6
6
|
import { DynamicFavicon } from '@/components/DynamicFavicon';
|
|
7
7
|
import { OnboardingWizard } from '@/components/OnboardingWizard';
|
|
8
|
+
import { LiveStreamWidget } from '@/components/LiveStreamWidget';
|
|
8
9
|
|
|
9
10
|
export const metadata: Metadata = {
|
|
10
11
|
title: 'ClawPort -- Command Centre',
|
|
@@ -23,6 +24,7 @@ export default function RootLayout({
|
|
|
23
24
|
<SettingsProvider>
|
|
24
25
|
<DynamicFavicon />
|
|
25
26
|
<OnboardingWizard />
|
|
27
|
+
<LiveStreamWidget />
|
|
26
28
|
<div
|
|
27
29
|
className="flex h-screen overflow-hidden"
|
|
28
30
|
style={{ background: 'var(--bg)' }}
|