| import { useEffect, useMemo, useState } from 'react'; |
| import { |
| Activity, |
| AlertTriangle, |
| Bot, |
| CheckCircle2, |
| CirclePause, |
| CirclePlay, |
| ClipboardCheck, |
| ExternalLink, |
| RefreshCw, |
| ShieldCheck, |
| Sparkles, |
| TerminalSquare, |
| } from 'lucide-react'; |
|
|
| const API_BASE = import.meta.env.VITE_SESSION_AMPLIFIER_BASE_URL || '/session-amplifier'; |
| const POLL_MS = 15000; |
|
|
| async function fetchJson(path) { |
| const response = await fetch(API_BASE + path); |
| if (!response.ok) { |
| throw new Error(response.status + ' ' + response.statusText); |
| } |
| return response.json(); |
| } |
|
|
| function formatAge(value) { |
| if (!value) return 'unknown'; |
| const at = new Date(value); |
| if (Number.isNaN(at.getTime())) return 'unknown'; |
| const seconds = Math.max(0, Math.floor((Date.now() - at.getTime()) / 1000)); |
| if (seconds < 60) return seconds + 's'; |
| const minutes = Math.floor(seconds / 60); |
| if (minutes < 60) return minutes + 'm'; |
| const hours = Math.floor(minutes / 60); |
| if (hours < 36) return hours + 'h'; |
| return Math.floor(hours / 24) + 'd'; |
| } |
|
|
| function compactText(value, limit = 110) { |
| const text = String(value || '').replace(/\s+/g, ' ').trim(); |
| if (!text) return ''; |
| return text.length > limit ? text.slice(0, limit - 1).trimEnd() + '…' : text; |
| } |
|
|
| function looksLikeOpaqueId(value) { |
| return /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(String(value || '').trim()); |
| } |
|
|
| function firstMeaningfulEvent(events = []) { |
| return events.find((event) => { |
| const text = event?.details || event?.summary || event?.clean_text || event?.preview || ''; |
| return text && text !== 'assistant' && (event.role === 'user' || event.event_type === 'user_message'); |
| }) || events.find((event) => { |
| const text = event?.details || event?.summary || event?.clean_text || event?.preview || ''; |
| return text && text !== 'assistant'; |
| }); |
| } |
|
|
| function sessionTextLabel(event) { |
| if (!event) return ''; |
| const raw = event.details || event.summary || event.clean_text || event.preview || ''; |
| const firstLine = String(raw).split('\n').find((line) => line.trim()) || ''; |
| const cronMatch = firstLine.match(/^\[cron:[^\s\]]+\s+([^\]]+)\]\s*(.*)$/); |
| if (cronMatch) { |
| return compactText(cronMatch[1] + ': ' + cronMatch[2], 96); |
| } |
| return compactText(firstLine.replace(/^#+\s*/, ''), 96); |
| } |
|
|
| function quotedHint(event) { |
| if (!event) return ''; |
| const raw = event.details || event.summary || event.clean_text || event.preview || ''; |
| const quote = String(raw).match(/["“']([^"”']{8,130})["”']/); |
| if (quote?.[1]) return compactText('"' + quote[1] + '"', 120); |
| const label = sessionTextLabel(event); |
| return label ? compactText('"' + label + '"', 120) : ''; |
| } |
|
|
| function shortSessionId(session) { |
| return String(session.session_id || '').slice(0, 8) || 'unknown'; |
| } |
|
|
| function pickTitle(session, events = []) { |
| const configuredTitle = ( |
| session.display_title || |
| session.displayTitle || |
| session.display_name || |
| session.displayName || |
| session.origin_label |
| ); |
| if (configuredTitle && !looksLikeOpaqueId(configuredTitle)) { |
| return { title: configuredTitle, hint: quotedHint(firstMeaningfulEvent(events)) }; |
| } |
| const firstEvent = firstMeaningfulEvent(events); |
| const derivedTitle = sessionTextLabel(firstEvent); |
| return { |
| title: derivedTitle || 'Session ' + shortSessionId(session), |
| hint: quotedHint(firstEvent), |
| }; |
| } |
|
|
| function deriveState(session, events = []) { |
| if (session.derived_state) return session.derived_state; |
| if (session.health === 'error') return 'error'; |
| const latest = [...events].reverse().find(Boolean); |
| if (!latest) return 'idle'; |
| if (latest.is_error) return 'error'; |
| if (latest.tool_name) return 'active'; |
| if ((latest.preview || latest.clean_text || '').toLowerCase().includes('permission')) return 'waiting'; |
| return latest.role === 'assistant' || latest.role === 'user' ? 'active' : 'idle'; |
| } |
|
|
| function stateMeta(state, needsPermission) { |
| if (needsPermission || state === 'waiting') { |
| return { label: 'Waiting', tone: 'waiting', Icon: CirclePause }; |
| } |
| if (state === 'active') return { label: 'Active', tone: 'active', Icon: Activity }; |
| if (state === 'error') return { label: 'Error', tone: 'error', Icon: AlertTriangle }; |
| return { label: 'Idle', tone: 'idle', Icon: CheckCircle2 }; |
| } |
|
|
| function MetricCard({ icon: Icon, label, value, sublabel, tone = 'neutral' }) { |
| return ( |
| <section className={'metric metric--' + tone}> |
| <div className="metric__icon"><Icon size={18} /></div> |
| <div> |
| <div className="metric__value">{value}</div> |
| <div className="metric__label">{label}</div> |
| {sublabel ? <div className="metric__sublabel">{sublabel}</div> : null} |
| </div> |
| </section> |
| ); |
| } |
|
|
| function SessionCard({ session, events }) { |
| const state = deriveState(session, events); |
| const meta = stateMeta(state, session.needs_permission); |
| const latest = [...events].reverse().find(Boolean); |
| const currentTool = session.current_tool_name || latest?.tool_name || 'none'; |
| const { title, hint } = pickTitle(session, events); |
|
|
| return ( |
| <article className={'session-card session-card--' + meta.tone}> |
| <header className="session-card__head"> |
| <div className="session-card__identity"> |
| <span className={'status-pill status-pill--' + meta.tone}> |
| <meta.Icon size={14} /> |
| {meta.label} |
| </span> |
| <h2 title={title}>{title}</h2> |
| {hint ? <p className="session-card__quote">{hint}</p> : null} |
| </div> |
| <span className="session-card__age">{formatAge(session.last_activity_at || session.updated_at || latest?.timestamp)}</span> |
| </header> |
| |
| <div className="session-card__meta"> |
| <span><Bot size={14} />{session.agent_id || 'unknown'}</span> |
| <span><TerminalSquare size={14} />{currentTool}</span> |
| <span><Activity size={14} />{shortSessionId(session)} · {events.length} events</span> |
| </div> |
| |
| <p className="session-card__preview"> |
| {latest?.preview || latest?.clean_text || session.active_reason || 'No recent transcript preview'} |
| </p> |
| |
| <footer className="session-card__actions"> |
| <button type="button" title="Open transcript when a route is wired" disabled> |
| <ExternalLink size={15} /> |
| Open |
| </button> |
| <button type="button" title="Run control requires an action endpoint" disabled> |
| <CirclePlay size={15} /> |
| Run |
| </button> |
| <button type="button" title="Pause/resume requires an action endpoint" disabled> |
| <CirclePause size={15} /> |
| Pause |
| </button> |
| </footer> |
| </article> |
| ); |
| } |
|
|
| function App() { |
| const [health, setHealth] = useState(null); |
| const [bulk, setBulk] = useState({ sessions: [], activity: {} }); |
| const [skills, setSkills] = useState(null); |
| const [error, setError] = useState(''); |
| const [loading, setLoading] = useState(true); |
|
|
| async function refresh() { |
| setError(''); |
| try { |
| const results = await Promise.allSettled([ |
| fetchJson('/health'), |
| fetchJson('/sessions/active-bulk?limit=30&activity_limit=80'), |
| fetchJson('/review/skills'), |
| ]); |
| const [healthResult, bulkResult, skillsResult] = results; |
| if (healthResult.status === 'fulfilled') setHealth(healthResult.value); |
| if (bulkResult.status === 'fulfilled') setBulk(bulkResult.value); |
| if (skillsResult.status === 'fulfilled') setSkills(skillsResult.value); |
| const rejected = [healthResult, bulkResult].find((result) => result.status === 'rejected'); |
| if (rejected) throw rejected.reason; |
| } catch (err) { |
| setError(err.message || String(err)); |
| } finally { |
| setLoading(false); |
| } |
| } |
|
|
| useEffect(() => { |
| refresh(); |
| const timer = window.setInterval(refresh, POLL_MS); |
| return () => window.clearInterval(timer); |
| }, []); |
|
|
| const sessions = bulk.sessions || []; |
| const activity = bulk.activity || {}; |
| const metrics = useMemo(() => { |
| const states = sessions.map((session) => deriveState(session, activity[session.session_id] || [])); |
| const active = states.filter((state) => state === 'active').length; |
| const waiting = sessions.filter((session, index) => session.needs_permission || states[index] === 'waiting').length; |
| const errors = states.filter((state) => state === 'error').length; |
| const toolHeavy = sessions.filter((session) => (activity[session.session_id] || []).filter((row) => row.tool_name).length >= 5).length; |
| const candidateCount = Array.isArray(skills?.candidates) ? skills.candidates.length : Array.isArray(skills?.recommendations) ? skills.recommendations.length : 0; |
| return { active, waiting, errors, toolHeavy, candidateCount }; |
| }, [sessions, activity, skills]); |
|
|
| return ( |
| <main className="app-shell"> |
| <header className="topbar"> |
| <div> |
| <p className="eyebrow">OpenClaw Ops</p> |
| <h1>Action Dashboard</h1> |
| </div> |
| <button className="refresh-button" type="button" onClick={refresh}> |
| <RefreshCw size={16} className={loading ? 'spin' : ''} /> |
| Refresh |
| </button> |
| </header> |
| |
| {error ? ( |
| <section className="alert"> |
| <AlertTriangle size={16} /> |
| <span>{error}</span> |
| </section> |
| ) : null} |
| |
| <section className="metrics-grid"> |
| <MetricCard icon={ShieldCheck} label="Amplifier" value={health?.status || 'unknown'} sublabel={(health?.sessions ?? 0) + ' indexed sessions'} tone={health?.status === 'ok' ? 'good' : 'warn'} /> |
| <MetricCard icon={Activity} label="Active" value={metrics.active} sublabel={sessions.length + ' recent sessions'} tone="info" /> |
| <MetricCard icon={CirclePause} label="Waiting" value={metrics.waiting} sublabel="permission or stalled state" tone={metrics.waiting ? 'warn' : 'neutral'} /> |
| <MetricCard icon={AlertTriangle} label="Errors" value={metrics.errors} sublabel="recent session state" tone={metrics.errors ? 'bad' : 'neutral'} /> |
| <MetricCard icon={TerminalSquare} label="Tool Heavy" value={metrics.toolHeavy} sublabel="5+ tool events" tone="neutral" /> |
| <MetricCard icon={ClipboardCheck} label="Skill Signals" value={metrics.candidateCount || 'review'} sublabel="from skill review API" tone="neutral" /> |
| </section> |
| |
| <section className="workspace-grid"> |
| <section className="panel panel--wide"> |
| <div className="panel__head"> |
| <h2>Live Sessions</h2> |
| <span>{bulk.generated_at ? 'Updated ' + formatAge(bulk.generated_at) + ' ago' : 'Not loaded'}</span> |
| </div> |
| <div className="sessions-grid"> |
| {sessions.length ? ( |
| sessions.map((session) => ( |
| <SessionCard |
| key={session.session_id} |
| session={session} |
| events={activity[session.session_id] || []} |
| /> |
| )) |
| ) : ( |
| <div className="empty-state"> |
| <Sparkles size={18} /> |
| <span>No recent sessions returned.</span> |
| </div> |
| )} |
| </div> |
| </section> |
| |
| <aside className="panel"> |
| <div className="panel__head"> |
| <h2>Promotion Lane</h2> |
| <span>advisory</span> |
| </div> |
| <div className="lane-list"> |
| <div className="lane-row"> |
| <ShieldCheck size={16} /> |
| <span>Installed skills may be used automatically when the match is safe.</span> |
| </div> |
| <div className="lane-row"> |
| <ClipboardCheck size={16} /> |
| <span>New installs, agent allowlists, and config changes stay approval-gated.</span> |
| </div> |
| <div className="lane-row"> |
| <ExternalLink size={16} /> |
| <span>Approval packets are summarized by scripts/openclaw_skill_approval_status.py.</span> |
| </div> |
| </div> |
| </aside> |
| </section> |
| </main> |
| ); |
| } |
|
|
| export default App; |
|
|