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 CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/clawport-ui.svg)](https://www.npmjs.com/package/clawport-ui)
8
8
  [![license](https://img.shields.io/npm/l/clawport-ui.svg)](LICENSE)
9
- [![tests](https://img.shields.io/badge/tests-442%20passed-brightgreen)](#testing)
9
+ [![tests](https://img.shields.io/badge/tests-501%20passed-brightgreen)](#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
- - **Memory Browser** -- Read team memory, long-term memory, and daily logs. Markdown rendering, JSON syntax highlighting, search, and download.
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 # 442 tests across 21 suites (Vitest)
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
+ }
@@ -0,0 +1,7 @@
1
+ 'use client'
2
+
3
+ import { CostsPage } from '@/components/costs/CostsPage'
4
+
5
+ export default function CostsRoute() {
6
+ return <CostsPage />
7
+ }
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)' }}