Add floating Live Stream widget with expandable log rows

Replace the Activity page's Feed tab with a global floating widget
that persists across page navigation. Clean top-level view with
level pills (INF/WRN/ERR/DBG) and truncated messages; click any
row to expand the raw JSON payload. Widget states: hidden, collapsed
pill, and expanded panel with play/pause, copy, and auto-scroll.

- Create components/LiveStreamWidget.tsx (global floating widget)
- Mount in app/layout.tsx alongside OnboardingWizard
- Simplify Activity page: remove tabs, render LogBrowser directly,
  add "Open Live Stream" button dispatching custom DOM event
- Delete components/activity/ActivityFeed.tsx (replaced by widget)
- Add raw field to LiveLogLine type for expandable detail view
- Add lib/sse.ts (SSE buffer parser, client-safe)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JohnRiceML
2026-03-06 12:56:14 -06:00
co-authored by Claude Opus 4.6
parent 02a0b0f49b
commit bfd5e64ec4
6 changed files with 665 additions and 387 deletions
+30 -60
View File
@@ -3,10 +3,9 @@
import { useCallback, useEffect, useState } from 'react'
import type { LogEntry, LogFilter, LogSummary } from '@/lib/types'
import { Skeleton } from '@/components/ui/skeleton'
import { RefreshCw, Radio, Search } from 'lucide-react'
import { RefreshCw, Radio } from 'lucide-react'
import { ErrorState } from '@/components/ErrorState'
import { LogBrowser } from '@/components/activity/LogBrowser'
import { ActivityFeed } from '@/components/activity/ActivityFeed'
/* ── Time helpers ──────────────────────────────────────────────── */
@@ -23,15 +22,6 @@ function timeAgo(dateStr: string): string {
return `${days}d ago`
}
/* ── Types ─────────────────────────────────────────────────────── */
type Tab = 'feed' | 'browser'
const TABS: { key: Tab; label: string; icon: React.ComponentType<{ size: number }> }[] = [
{ key: 'feed', label: 'Feed', icon: Radio },
{ key: 'browser', label: 'Browser', icon: Search },
]
/* ── Summary Cards ─────────────────────────────────────────────── */
function TotalCard({ count }: { count: number }) {
@@ -111,7 +101,6 @@ export default function ActivityPage() {
const [entries, setEntries] = useState<LogEntry[]>([])
const [summary, setSummary] = useState<LogSummary | null>(null)
const [filter, setFilter] = useState<LogFilter>('all')
const [tab, setTab] = useState<Tab>('browser')
const [lastRefresh, setLastRefresh] = useState<Date>(new Date())
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
@@ -194,6 +183,27 @@ export default function ActivityPage() {
)}
</div>
<div className="flex items-center" style={{ gap: 'var(--space-3)' }}>
{/* Open Live Stream */}
<button
onClick={() => window.dispatchEvent(new CustomEvent('clawport:open-stream-widget'))}
className="focus-ring flex items-center"
style={{
padding: '6px 14px',
borderRadius: 'var(--radius-sm)',
border: 'none',
cursor: 'pointer',
fontSize: 'var(--text-footnote)',
fontWeight: 'var(--weight-semibold)',
gap: 6,
background: 'var(--accent-fill)',
color: 'var(--accent)',
transition: 'all 200ms var(--ease-smooth)',
}}
>
<Radio size={14} />
Open Live Stream
</button>
<span style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)' }}>
Updated {updatedAgo}
</span>
@@ -219,38 +229,6 @@ export default function ActivityPage() {
</button>
</div>
</div>
{/* ── Tab navigation ─────────────────────────────────── */}
<div className="flex items-center" style={{ padding: '0 var(--space-6) var(--space-3)', gap: 'var(--space-1)' }}>
{TABS.map(t => {
const isActive = tab === t.key
const TabIcon = t.icon
return (
<button
key={t.key}
onClick={() => setTab(t.key)}
className="focus-ring"
style={{
padding: '6px 16px',
fontSize: 'var(--text-footnote)',
fontWeight: isActive ? 'var(--weight-semibold)' : 'var(--weight-medium)',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
transition: 'all 200ms var(--ease-smooth)',
background: isActive ? 'var(--accent-fill)' : 'transparent',
color: isActive ? 'var(--accent)' : 'var(--text-secondary)',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
}}
>
<TabIcon size={14} />
{t.label}
</button>
)
})}
</div>
</header>
{/* ── Scrollable content ─────────────────────────────────── */}
@@ -285,22 +263,14 @@ export default function ActivityPage() {
<SourcesCard cron={summary?.sources.cron ?? 0} config={summary?.sources.config ?? 0} />
</div>
{/* Tab content */}
{tab === 'feed' && (
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<ActivityFeed active={tab === 'feed'} />
</div>
)}
{tab === 'browser' && (
<LogBrowser
entries={entries}
summary={summary}
loading={false}
filter={filter}
onFilterChange={setFilter}
/>
)}
{/* Log browser */}
<LogBrowser
entries={entries}
summary={summary}
loading={false}
filter={filter}
onFilterChange={setFilter}
/>
</>
)}
</div>
+2
View File
@@ -5,6 +5,7 @@ import { SettingsProvider } from './settings-provider';
import { Sidebar } from '@/components/Sidebar';
import { DynamicFavicon } from '@/components/DynamicFavicon';
import { OnboardingWizard } from '@/components/OnboardingWizard';
import { LiveStreamWidget } from '@/components/LiveStreamWidget';
export const metadata: Metadata = {
title: 'ClawPort -- Command Centre',
@@ -23,6 +24,7 @@ export default function RootLayout({
<SettingsProvider>
<DynamicFavicon />
<OnboardingWizard />
<LiveStreamWidget />
<div
className="flex h-screen overflow-hidden"
style={{ background: 'var(--bg)' }}
+497
View File
@@ -0,0 +1,497 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { LiveLogLine } from '@/lib/types'
import { parseSSEBuffer } from '@/lib/sse'
import { Play, Pause, Copy, Minimize2, X, ChevronRight } from 'lucide-react'
/* ── Constants ────────────────────────────────────────────────── */
const MAX_LINES = 500
const WIDGET_EVENT = 'clawport:open-stream-widget'
const LEVEL_STYLE: Record<string, { bg: string; color: string; label: string }> = {
info: { bg: 'rgba(48,209,88,0.12)', color: 'var(--system-green)', label: 'INF' },
warn: { bg: 'rgba(255,159,10,0.12)', color: 'var(--system-orange)', label: 'WRN' },
error: { bg: 'rgba(255,69,58,0.12)', color: 'var(--system-red)', label: 'ERR' },
debug: { bg: 'var(--fill-secondary)', color: 'var(--text-tertiary)', label: 'DBG' },
}
function formatTime(ts: string): string {
const d = new Date(ts)
if (isNaN(d.getTime())) return ts.slice(0, 8)
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function formatCopyLine(line: LiveLogLine): string {
return `[${formatTime(line.time)}] [${line.level}] ${line.message}`
}
function prettyRaw(raw: string): string {
try { return JSON.stringify(JSON.parse(raw), null, 2) } catch { return raw }
}
/* ── Visual states ────────────────────────────────────────────── */
type WidgetState = 'hidden' | 'collapsed' | 'expanded'
/* ── LogRow ───────────────────────────────────────────────────── */
function LogRow({ line }: { line: LiveLogLine }) {
const [open, setOpen] = useState(false)
const lvl = LEVEL_STYLE[line.level] ?? LEVEL_STYLE.debug
return (
<div style={{
borderBottom: '1px solid var(--separator)',
background: line.level === 'error' ? 'rgba(255,69,58,0.03)' : undefined,
}}>
{/* Summary row */}
<button
onClick={() => line.raw && setOpen(o => !o)}
style={{
display: 'flex',
alignItems: 'center',
width: '100%',
padding: '5px 12px',
gap: 8,
border: 'none',
background: 'transparent',
cursor: line.raw ? 'pointer' : 'default',
textAlign: 'left',
}}
>
{/* Expand chevron */}
{line.raw ? (
<ChevronRight size={10} style={{
color: 'var(--text-tertiary)',
flexShrink: 0,
transition: 'transform 150ms var(--ease-smooth)',
transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
}} />
) : (
<span style={{ width: 10, flexShrink: 0 }} />
)}
{/* Time */}
<span className="font-mono" style={{
color: 'var(--text-tertiary)',
fontSize: 10,
flexShrink: 0,
minWidth: 58,
}}>
{formatTime(line.time)}
</span>
{/* Level pill */}
<span style={{
fontSize: 9,
fontWeight: 700,
letterSpacing: '0.5px',
padding: '1px 5px',
borderRadius: 3,
background: lvl.bg,
color: lvl.color,
flexShrink: 0,
lineHeight: '14px',
}}>
{lvl.label}
</span>
{/* Message (truncated) */}
<span className="font-mono" style={{
color: line.level === 'error' ? 'var(--system-red)' : 'var(--text-secondary)',
fontSize: 10,
lineHeight: 1.4,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}>
{line.message}
</span>
</button>
{/* Raw JSON detail */}
{open && line.raw && (
<div style={{
padding: '6px 12px 8px 30px',
borderTop: '1px solid var(--separator)',
background: 'var(--fill-secondary)',
}}>
<pre className="font-mono" style={{
fontSize: 9,
lineHeight: 1.5,
color: 'var(--text-secondary)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
}}>
{prettyRaw(line.raw)}
</pre>
</div>
)}
</div>
)
}
/* ── Component ────────────────────────────────────────────────── */
export function LiveStreamWidget() {
const [state, setState] = useState<WidgetState>('hidden')
const [lines, setLines] = useState<LiveLogLine[]>([])
const [streaming, setStreaming] = useState(false)
const [error, setError] = useState<string | null>(null)
const [autoScroll, setAutoScroll] = useState(true)
const [copied, setCopied] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
/* ── Auto-scroll ──────────────────────────────────────────── */
useEffect(() => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [lines, autoScroll])
const handleScroll = useCallback(() => {
if (!scrollRef.current) return
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current
const atBottom = scrollHeight - scrollTop - clientHeight < 40
if (!atBottom) setAutoScroll(false)
else setAutoScroll(true)
}, [])
/* ── Stream lifecycle ─────────────────────────────────────── */
const startStream = useCallback(() => {
if (abortRef.current) abortRef.current.abort()
const controller = new AbortController()
abortRef.current = controller
setStreaming(true)
setError(null)
fetch('/api/logs/stream', { signal: controller.signal })
.then(res => {
if (!res.ok || !res.body) throw new Error(`Stream failed: HTTP ${res.status}`)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
function pump(): Promise<void> {
return reader.read().then(({ done, value }) => {
if (done) { setStreaming(false); return }
buffer += decoder.decode(value, { stream: true })
const result = parseSSEBuffer(buffer)
buffer = result.remainder
if (result.errors.length > 0) setError(result.errors[0])
if (result.lines.length > 0) {
setLines(prev => [...prev, ...result.lines].slice(-MAX_LINES))
}
return pump()
})
}
return pump()
})
.catch(err => {
if (err instanceof DOMException && err.name === 'AbortError') return
setError(err instanceof Error ? err.message : 'Stream connection failed')
setStreaming(false)
})
}, [])
const stopStream = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort()
abortRef.current = null
}
setStreaming(false)
}, [])
/* ── Actions ──────────────────────────────────────────────── */
const handleClose = useCallback(() => {
stopStream()
setState('hidden')
}, [stopStream])
const handleCopy = useCallback(async () => {
const text = lines.map(formatCopyLine).join('\n')
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}, [lines])
/* ── DOM event listener ───────────────────────────────────── */
useEffect(() => {
function onOpen() {
setState('expanded')
}
window.addEventListener(WIDGET_EVENT, onOpen)
return () => window.removeEventListener(WIDGET_EVENT, onOpen)
}, [])
/* ── Cleanup on unmount ───────────────────────────────────── */
useEffect(() => {
return () => {
if (abortRef.current) {
abortRef.current.abort()
abortRef.current = null
}
}
}, [])
/* ── Hidden ───────────────────────────────────────────────── */
if (state === 'hidden') return null
/* ── Collapsed pill ───────────────────────────────────────── */
if (state === 'collapsed') {
return (
<button
onClick={() => setState('expanded')}
className="focus-ring flex items-center"
style={{
position: 'fixed',
bottom: 20,
right: 20,
zIndex: 50,
padding: '8px 14px',
borderRadius: 'var(--radius-pill)',
border: '1px solid var(--separator)',
background: 'var(--material-regular)',
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
cursor: 'pointer',
gap: 8,
boxShadow: '0 4px 24px rgba(0,0,0,0.25)',
}}
>
<span style={{
width: 8,
height: 8,
borderRadius: '50%',
background: streaming ? 'var(--system-green)' : 'var(--text-tertiary)',
animation: streaming ? 'lsw-pulse 2s ease-in-out infinite' : undefined,
flexShrink: 0,
}} />
<span style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-secondary)', fontWeight: 'var(--weight-medium)' }}>
Live Stream
</span>
{lines.length > 0 && (
<span style={{
fontSize: 'var(--text-caption2)',
color: 'var(--text-tertiary)',
background: 'var(--fill-secondary)',
padding: '1px 6px',
borderRadius: 'var(--radius-sm)',
}}>
{lines.length}
</span>
)}
<style>{`@keyframes lsw-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }`}</style>
</button>
)
}
/* ── Expanded panel ───────────────────────────────────────── */
return (
<div style={{
position: 'fixed',
bottom: 20,
right: 20,
zIndex: 50,
width: 440,
height: 400,
borderRadius: 'var(--radius-lg)',
border: '1px solid var(--separator)',
background: 'var(--material-regular)',
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
boxShadow: '0 8px 40px rgba(0,0,0,0.35)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
{/* ── Header ────────────────────────────────────────────── */}
<div className="flex items-center flex-shrink-0" style={{
padding: '10px 14px',
borderBottom: '1px solid var(--separator)',
gap: 8,
}}>
<span style={{
width: 8,
height: 8,
borderRadius: '50%',
background: streaming ? 'var(--system-green)' : 'var(--text-tertiary)',
animation: streaming ? 'lsw-pulse 2s ease-in-out infinite' : undefined,
flexShrink: 0,
}} />
<span style={{
fontSize: 'var(--text-footnote)',
fontWeight: 'var(--weight-semibold)',
color: 'var(--text-primary)',
}}>
Live Stream
</span>
{lines.length > 0 && (
<span style={{ fontSize: 'var(--text-caption2)', color: 'var(--text-tertiary)' }}>
{lines.length} line{lines.length !== 1 ? 's' : ''}
</span>
)}
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
<button
onClick={handleCopy}
className="focus-ring"
title="Copy all logs"
disabled={lines.length === 0}
style={{
width: 28, height: 28,
display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: 'var(--radius-sm)',
border: 'none',
background: copied ? 'var(--accent-fill)' : 'transparent',
color: copied ? 'var(--accent)' : 'var(--text-tertiary)',
cursor: lines.length === 0 ? 'default' : 'pointer',
opacity: lines.length === 0 ? 0.3 : 1,
transition: 'all 150ms var(--ease-smooth)',
}}
>
<Copy size={14} />
</button>
<button
onClick={() => setState('collapsed')}
className="focus-ring"
title="Minimize"
style={{
width: 28, height: 28,
display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: 'var(--radius-sm)',
border: 'none',
background: 'transparent',
color: 'var(--text-tertiary)',
cursor: 'pointer',
transition: 'color 150ms var(--ease-smooth)',
}}
>
<Minimize2 size={14} />
</button>
<button
onClick={handleClose}
className="focus-ring"
title="Close"
style={{
width: 28, height: 28,
display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: 'var(--radius-sm)',
border: 'none',
background: 'transparent',
color: 'var(--text-tertiary)',
cursor: 'pointer',
transition: 'color 150ms var(--ease-smooth)',
}}
>
<X size={14} />
</button>
</div>
</div>
{/* ── Error banner ──────────────────────────────────────── */}
{error && (
<div style={{
padding: '6px 14px',
background: 'rgba(255,69,58,0.06)',
borderBottom: '1px solid rgba(255,69,58,0.15)',
fontSize: 'var(--text-caption2)',
color: 'var(--system-red)',
flexShrink: 0,
}}>
{error}
</div>
)}
{/* ── Log area ──────────────────────────────────────────── */}
<div
ref={scrollRef}
onScroll={handleScroll}
style={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}
>
{lines.length === 0 ? (
<div className="flex flex-col items-center justify-center" style={{
height: '100%',
color: 'var(--text-secondary)',
gap: 'var(--space-2)',
padding: 'var(--space-4)',
}}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--text-tertiary)' }}>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
<span style={{ fontSize: 'var(--text-caption1)', fontWeight: 'var(--weight-medium)' }}>
{streaming ? 'Waiting for log data...' : 'Click Play to start streaming'}
</span>
</div>
) : (
<div>
{lines.map((line, i) => <LogRow key={i} line={line} />)}
</div>
)}
</div>
{/* ── Footer toolbar ────────────────────────────────────── */}
<div className="flex items-center flex-shrink-0" style={{
padding: '8px 14px',
borderTop: '1px solid var(--separator)',
gap: 8,
}}>
<button
onClick={streaming ? stopStream : startStream}
className="focus-ring flex items-center"
style={{
padding: '4px 12px',
borderRadius: 'var(--radius-sm)',
border: 'none',
cursor: 'pointer',
fontSize: 'var(--text-caption1)',
fontWeight: 'var(--weight-semibold)',
gap: 5,
background: streaming ? 'rgba(255,69,58,0.1)' : 'var(--accent-fill)',
color: streaming ? 'var(--system-red)' : 'var(--accent)',
transition: 'all 200ms var(--ease-smooth)',
}}
>
{streaming ? <Pause size={12} /> : <Play size={12} />}
{streaming ? 'Pause' : 'Play'}
</button>
{!autoScroll && lines.length > 0 && (
<button
onClick={() => setAutoScroll(true)}
className="focus-ring"
style={{
padding: '4px 10px',
borderRadius: 'var(--radius-sm)',
border: 'none',
cursor: 'pointer',
fontSize: 'var(--text-caption2)',
fontWeight: 'var(--weight-medium)',
background: 'var(--fill-secondary)',
color: 'var(--text-secondary)',
}}
>
Scroll to bottom
</button>
)}
</div>
<style>{`@keyframes lsw-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }`}</style>
</div>
)
}
-327
View File
@@ -1,327 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { LiveLogLine } from '@/lib/types'
import { Play, Pause } from 'lucide-react'
/* ── Helpers ───────────────────────────────────────────────────── */
const MAX_LINES = 500
const LEVEL_DOT: Record<string, string> = {
info: 'var(--system-green)',
warn: 'var(--system-orange)',
error: 'var(--system-red)',
debug: 'var(--text-tertiary)',
}
function parseSSELine(data: string): LiveLogLine | null {
try {
const obj = JSON.parse(data)
return {
type: obj.type ?? 'log',
time: obj.time ?? obj.ts ?? new Date().toISOString(),
level: obj.level ?? 'info',
message: obj.message ?? obj.msg ?? JSON.stringify(obj),
}
} catch {
// Plain text line
return {
type: 'log',
time: new Date().toISOString(),
level: 'info',
message: data,
}
}
}
function formatTime(ts: string): string {
const d = new Date(ts)
if (isNaN(d.getTime())) return ts.slice(0, 8)
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
/* ── Component ─────────────────────────────────────────────────── */
interface ActivityFeedProps {
active: boolean
}
export function ActivityFeed({ active }: ActivityFeedProps) {
const [lines, setLines] = useState<LiveLogLine[]>([])
const [streaming, setStreaming] = useState(false)
const [error, setError] = useState<string | null>(null)
const [autoScroll, setAutoScroll] = useState(true)
const abortRef = useRef<AbortController | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const userScrolledRef = useRef(false)
// Auto-scroll to bottom
useEffect(() => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [lines, autoScroll])
// Detect manual scroll
const handleScroll = useCallback(() => {
if (!scrollRef.current) return
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current
const atBottom = scrollHeight - scrollTop - clientHeight < 40
if (!atBottom) {
userScrolledRef.current = true
setAutoScroll(false)
}
}, [])
const reanchor = useCallback(() => {
userScrolledRef.current = false
setAutoScroll(true)
}, [])
const startStream = useCallback(() => {
if (abortRef.current) abortRef.current.abort()
const controller = new AbortController()
abortRef.current = controller
setStreaming(true)
setError(null)
fetch('/api/logs/stream', { signal: controller.signal })
.then(res => {
if (!res.ok || !res.body) {
throw new Error(`Stream failed: HTTP ${res.status}`)
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
function pump(): Promise<void> {
return reader.read().then(({ done, value }) => {
if (done) {
setStreaming(false)
return
}
buffer += decoder.decode(value, { stream: true })
const chunks = buffer.split('\n\n')
buffer = chunks.pop() || ''
const newLines: LiveLogLine[] = []
for (const chunk of chunks) {
for (const line of chunk.split('\n')) {
if (line.startsWith('data: ')) {
const parsed = parseSSELine(line.slice(6))
if (parsed) newLines.push(parsed)
} else if (line.startsWith('event: error')) {
// Next data line will be the error
} else if (line.startsWith('data: ') && chunk.includes('event: error')) {
try {
const errData = JSON.parse(line.slice(6))
setError(errData.error || 'Stream error')
} catch {
setError(line.slice(6))
}
}
}
}
if (newLines.length > 0) {
setLines(prev => [...prev, ...newLines].slice(-MAX_LINES))
}
return pump()
})
}
return pump()
})
.catch(err => {
if (err instanceof DOMException && err.name === 'AbortError') return
setError(err instanceof Error ? err.message : 'Stream connection failed')
setStreaming(false)
})
}, [])
const stopStream = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort()
abortRef.current = null
}
setStreaming(false)
}, [])
// Cleanup on unmount or when tab becomes inactive
useEffect(() => {
if (!active && streaming) {
stopStream()
}
return () => {
if (abortRef.current) {
abortRef.current.abort()
abortRef.current = null
}
}
}, [active, streaming, stopStream])
return (
<div className="flex flex-col" style={{ height: '100%', minHeight: 0 }}>
{/* Toolbar */}
<div className="flex items-center flex-shrink-0" style={{ gap: 'var(--space-3)', marginBottom: 'var(--space-3)' }}>
{/* Play/Pause */}
<button
onClick={streaming ? stopStream : startStream}
className="focus-ring flex items-center"
style={{
padding: '6px 14px',
borderRadius: 'var(--radius-sm)',
border: 'none',
cursor: 'pointer',
fontSize: 'var(--text-footnote)',
fontWeight: 'var(--weight-semibold)',
gap: 6,
background: streaming ? 'rgba(255,69,58,0.1)' : 'var(--accent-fill)',
color: streaming ? 'var(--system-red)' : 'var(--accent)',
transition: 'all 200ms var(--ease-smooth)',
}}
>
{streaming ? <Pause size={14} /> : <Play size={14} />}
{streaming ? 'Pause' : 'Stream'}
</button>
{/* Connection status */}
<div className="flex items-center" style={{ gap: 6 }}>
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: streaming ? 'var(--system-green)' : 'var(--text-tertiary)',
animation: streaming ? 'pulse-green 2s ease-in-out infinite' : undefined,
flexShrink: 0,
}}
/>
<span style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)' }}>
{streaming ? 'Connected' : 'Disconnected'}
</span>
</div>
{/* Line count */}
{lines.length > 0 && (
<span style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)', marginLeft: 'auto' }}>
{lines.length} line{lines.length !== 1 ? 's' : ''}
</span>
)}
{/* Re-anchor button */}
{!autoScroll && (
<button
onClick={reanchor}
className="focus-ring"
style={{
padding: '4px 10px',
borderRadius: 'var(--radius-sm)',
border: 'none',
cursor: 'pointer',
fontSize: 'var(--text-caption1)',
fontWeight: 'var(--weight-medium)',
background: 'var(--fill-secondary)',
color: 'var(--text-secondary)',
}}
>
Scroll to bottom
</button>
)}
</div>
{/* Error banner */}
{error && (
<div style={{
padding: 'var(--space-2) var(--space-3)',
marginBottom: 'var(--space-2)',
borderRadius: 'var(--radius-sm)',
background: 'rgba(255,69,58,0.06)',
borderLeft: '3px solid var(--system-red)',
fontSize: 'var(--text-caption1)',
color: 'var(--system-red)',
}}>
{error}
</div>
)}
{/* Feed area */}
<div
ref={scrollRef}
onScroll={handleScroll}
style={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
borderRadius: 'var(--radius-md)',
background: 'var(--material-regular)',
border: '1px solid var(--separator)',
}}
>
{lines.length === 0 ? (
<div className="flex flex-col items-center justify-center" style={{ height: '100%', minHeight: 200, color: 'var(--text-secondary)', gap: 'var(--space-2)' }}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--text-tertiary)', marginBottom: 'var(--space-2)' }}>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
<span style={{ fontSize: 'var(--text-subheadline)', fontWeight: 'var(--weight-medium)' }}>
{streaming ? 'Waiting for log data...' : 'Click Stream to start'}
</span>
<span style={{ fontSize: 'var(--text-footnote)', color: 'var(--text-tertiary)', textAlign: 'center', maxWidth: 300 }}>
Streams live output from the OpenClaw gateway via <code style={{ fontSize: 'var(--text-caption1)' }}>openclaw logs --follow</code>
</span>
</div>
) : (
<div style={{ padding: 'var(--space-2) 0' }}>
{lines.map((line, i) => (
<div
key={i}
className="flex items-start hover-bg"
style={{
padding: '3px var(--space-3)',
gap: 'var(--space-2)',
fontSize: 'var(--text-caption1)',
minHeight: 24,
background: line.level === 'error' ? 'rgba(255,69,58,0.04)' : undefined,
}}
>
{/* Time */}
<span className="font-mono flex-shrink-0" style={{ color: 'var(--text-tertiary)', minWidth: 70, fontSize: 'var(--text-caption2)' }}>
{formatTime(line.time)}
</span>
{/* Level dot */}
<span className="flex-shrink-0" style={{
width: 6,
height: 6,
borderRadius: '50%',
background: LEVEL_DOT[line.level] ?? 'var(--text-tertiary)',
marginTop: 5,
}} />
{/* Message */}
<span className="font-mono" style={{
color: line.level === 'error' ? 'var(--system-red)' : 'var(--text-secondary)',
wordBreak: 'break-word',
lineHeight: 'var(--leading-relaxed)',
fontSize: 'var(--text-caption2)',
}}>
{line.message}
</span>
</div>
))}
</div>
)}
</div>
<style>{`
@keyframes pulse-green {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
`}</style>
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
import type { LiveLogLine } from '@/lib/types'
/** Parse a single SSE data payload into a LiveLogLine */
export function parseSSELine(data: string): LiveLogLine {
try {
const obj = JSON.parse(data)
return {
type: obj.type ?? 'log',
time: obj.time ?? obj.ts ?? new Date().toISOString(),
level: obj.level ?? 'info',
message: obj.message ?? obj.msg ?? JSON.stringify(obj),
raw: data,
}
} catch {
return {
type: 'log',
time: new Date().toISOString(),
level: 'info',
message: data,
}
}
}
/**
* Parse an SSE buffer into log lines and errors.
* Returns { lines, errors, remainder } where remainder is the
* incomplete trailing chunk to carry forward.
*/
export function parseSSEBuffer(buffer: string): {
lines: LiveLogLine[]
errors: string[]
remainder: string
} {
const chunks = buffer.split('\n\n')
const remainder = chunks.pop() || ''
const lines: LiveLogLine[] = []
const errors: string[] = []
for (const chunk of chunks) {
const isError = chunk.includes('event: error')
for (const line of chunk.split('\n')) {
if (!line.startsWith('data: ')) continue
const payload = line.slice(6)
if (isError) {
try {
const errData = JSON.parse(payload)
errors.push(errData.error || payload)
} catch {
errors.push(payload)
}
} else {
lines.push(parseSSELine(payload))
}
}
}
return { lines, errors, remainder }
}
+76
View File
@@ -31,6 +31,81 @@ export interface CronRun {
error: string | null
durationMs: number
deliveryStatus: string | null
model: string | null
provider: string | null
usage: { input_tokens: number; output_tokens: number; total_tokens: number } | null
}
// ── Cost Dashboard Types ──────────────────────────────────────
export interface ModelPricing {
inputPer1M: number
outputPer1M: number
}
export interface RunCost {
ts: number
jobId: string
model: string
provider: string
inputTokens: number
outputTokens: number
totalTokens: number
cacheTokens: number
minCost: number
}
export interface JobCostSummary {
jobId: string
runs: number
totalInputTokens: number
totalOutputTokens: number
totalCacheTokens: number
totalCost: number
medianCost: number
}
export interface DailyCost {
date: string
cost: number
runs: number
}
export interface ModelBreakdown {
model: string
tokens: number
pct: number
}
export interface TokenAnomaly {
ts: number
jobId: string
totalTokens: number
medianTokens: number
ratio: number
}
export interface WeekOverWeek {
thisWeek: number
lastWeek: number
changePct: number | null
}
export interface CacheSavings {
cacheTokens: number
estimatedSavings: number
}
export interface CostSummary {
totalCost: number
topSpender: { jobId: string; cost: number } | null
anomalies: TokenAnomaly[]
jobCosts: JobCostSummary[]
dailyCosts: DailyCost[]
modelBreakdown: ModelBreakdown[]
runCosts: RunCost[]
weekOverWeek: WeekOverWeek
cacheSavings: CacheSavings
}
export interface CronJob {
@@ -161,4 +236,5 @@ export interface LiveLogLine {
time: string
level: string
message: string
raw?: string
}