mirror of
https://github.com/JohnRiceML/clawport-ui.git
synced 2026-08-14 08:51:58 +00:00
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:
co-authored by
Claude Opus 4.6
parent
02a0b0f49b
commit
bfd5e64ec4
+30
-60
@@ -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>
|
||||
|
||||
@@ -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)' }}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user