feat(web): add Historical Session Index API and browser UI
Some checks are pending
CI / test (18.x) (push) Waiting to run
CI / test (20.x) (push) Waiting to run
CI / test (22.x) (push) Waiting to run

Implements Phase 6 Historical session index for comparisons.

Backend (src/web/server.ts):
- GET /api/sessions — list sessions (paginated, with start/end filter)
- GET /api/sessions/:id — get single session detail with per-worker summaries
- GET /api/sessions/:id/workers — get worker summaries for a session
All endpoints use the existing HistoricalStore infrastructure.

Frontend (src/web/frontend/src/components/HistoricalSessionsPanel.tsx):
- Sessions list table with duration, workers, tasks, cost, tokens, time range
- Click-to-expand session detail with worker performance breakdown
- Metrics source badge (otlp-metric, otlp-span, log-derived)
- Empty state with helpful hint when no sessions exist
- Refresh button for manual reload

Integration:
- Added to App.tsx with Sessions toggle button in header
- Command palette action: show:sessions
- Follows existing panel patterns (ProductivityPanel, AnalyticsDashboard)

Closes: bf-5xch

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-26 17:19:58 -04:00
parent 5b350b9326
commit 55611370bb
3 changed files with 353 additions and 0 deletions

View file

@ -21,6 +21,7 @@ import SessionDigestPanel from './components/SessionDigestPanel';
import GitIntegrationPanel from './components/GitIntegrationPanel';
import ProductivityPanel from './components/ProductivityPanel';
import FleetSummaryBar from './components/FleetSummaryBar';
import HistoricalSessionsPanel from './components/HistoricalSessionsPanel';
import CommandPalette from './components/CommandPalette';
import { extractReplayFromUrl, ReplayExport } from './utils/replayExport';
import { FocusPresetManager, createWebPresetManager, FocusPreset } from './utils/focusPresets';
@ -260,6 +261,7 @@ const App: React.FC = () => {
const [showGitIntegration, setShowGitIntegration] = useState(false);
const [showNarrative, setShowNarrative] = useState(false);
const [showProductivity, setShowProductivity] = useState(false);
const [showHistoricalSessions, setShowHistoricalSessions] = useState(false);
const [budgetBannerDismissed, setBudgetBannerDismissed] = useState(false);
const [hideTestWorkers, setHideTestWorkers] = useState(true);
@ -543,6 +545,8 @@ const App: React.FC = () => {
setShowGitIntegration(true);
} else if (action === 'show:narrative') {
setShowNarrative(true);
} else if (action === 'show:sessions') {
setShowHistoricalSessions(true);
} else if (action.startsWith('worker:')) {
const workerId = action.slice('worker:'.length);
setSelectedWorker(workerId);
@ -838,6 +842,14 @@ const App: React.FC = () => {
<span className="productivity-toggle-icon">&#x1F3C6;</span>
<span className="productivity-toggle-label">Productivity</span>
</button>
<button
className={`sessions-toggle ${showHistoricalSessions ? 'active' : ''}`}
onClick={() => setShowHistoricalSessions(!showHistoricalSessions)}
title="Historical Sessions — browse past sessions and worker performance"
>
<span className="sessions-toggle-icon">&#x1F4C5;</span>
<span className="sessions-toggle-label">Sessions</span>
</button>
<button
className={`hide-test-workers-toggle ${hideTestWorkers ? 'active' : ''}`}
onClick={() => setHideTestWorkers(prev => !prev)}
@ -1027,6 +1039,13 @@ const App: React.FC = () => {
/>
)}
{showHistoricalSessions && (
<HistoricalSessionsPanel
visible={showHistoricalSessions}
onClose={() => setShowHistoricalSessions(false)}
/>
)}
{showSessionReplay && (
<div className="session-replay-panel">
<div className="session-replay-header">

View file

@ -0,0 +1,254 @@
import React, { useState, useEffect, useCallback } from 'react';
interface SessionRecord {
id: string;
started_at: number;
ended_at: number;
worker_count: number;
task_count: number;
total_cost: number;
total_tokens: number;
metrics_source?: string;
}
interface WorkerSummary {
sessionId: string;
workerId: string;
tokensIn: number;
tokensOut: number;
costUsd: number;
beadsCompleted: number;
beadsFailed: number;
errors: number;
metricsSource: string;
}
interface SessionDetail extends SessionRecord {
workerSummaries: WorkerSummary[];
}
interface SessionsResponse {
sessions: SessionRecord[];
count: number;
}
interface SessionDetailResponse {
session: SessionRecord;
workerSummaries: WorkerSummary[];
}
interface HistoricalSessionsPanelProps {
visible: boolean;
onClose: () => void;
}
const HistoricalSessionsPanel: React.FC<HistoricalSessionsPanelProps> = ({ visible, onClose }) => {
const [sessions, setSessions] = useState<SessionRecord[]>([]);
const [selectedSession, setSelectedSession] = useState<SessionDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const fetchSessions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/sessions?limit=50');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: SessionsResponse = await res.json();
setSessions(data.sessions);
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
}
}, []);
const fetchSessionDetail = useCallback(async (sessionId: string) => {
setDetailLoading(true);
setDetailError(null);
try {
const res = await fetch(`/api/sessions/${sessionId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: SessionDetailResponse = await res.json();
setSelectedSession({
...data.session,
workerSummaries: data.workerSummaries,
});
} catch (err) {
setDetailError(String(err));
} finally {
setDetailLoading(false);
}
}, []);
useEffect(() => {
if (visible) fetchSessions();
}, [visible, fetchSessions]);
if (!visible) return null;
const formatDate = (ts: number): string => {
return new Date(ts).toLocaleString();
};
const formatDuration = (startMs: number, endMs: number): string => {
const durationMs = endMs - startMs;
const minutes = Math.floor(durationMs / 60000);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
};
const formatCost = (cost: number): string => {
if (cost < 0.01) return '<$0.01';
return `$${cost.toFixed(2)}`;
};
const formatTokens = (tokens: number): string => {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`;
return tokens.toString();
};
return (
<div className="analytics-panel sessions-panel">
<div className="analytics-header">
<h3>
Historical Sessions
{sessions.length > 0 && (
<span className="analytics-subtitle">{sessions.length} sessions</span>
)}
</h3>
<div className="analytics-header-actions">
<button className="analytics-refresh" onClick={fetchSessions} disabled={loading}>
{loading ? 'Loading...' : 'Refresh'}
</button>
<button className="close-button" onClick={onClose}>×</button>
</div>
</div>
{error && <div className="analytics-error">{error}</div>}
<div className="analytics-content">
{sessions.length === 0 && !loading && !error && (
<div className="analytics-empty">
<p>No historical sessions found.</p>
<p className="analytics-empty-hint">Sessions are recorded when workers complete tasks and metrics are finalized.</p>
</div>
)}
{sessions.length > 0 && (
<div className="sessions-list">
<table className="sessions-table">
<thead>
<tr>
<th>Session ID</th>
<th>Duration</th>
<th>Workers</th>
<th>Tasks</th>
<th>Cost</th>
<th>Tokens</th>
<th>Time Range</th>
</tr>
</thead>
<tbody>
{sessions.map((session) => (
<tr
key={session.id}
className={selectedSession?.id === session.id ? 'selected' : ''}
onClick={() => fetchSessionDetail(session.id)}
style={{ cursor: 'pointer' }}
>
<td className="sessions-id">
<code>{session.id.slice(-12)}</code>
</td>
<td className="sessions-duration">
{formatDuration(session.started_at, session.ended_at)}
</td>
<td className="sessions-workers">{session.worker_count}</td>
<td className="sessions-tasks">{session.task_count}</td>
<td className="sessions-cost">{formatCost(session.total_cost)}</td>
<td className="sessions-tokens">{formatTokens(session.total_tokens)}</td>
<td className="sessions-time">
<div className="sessions-time-start">{formatDate(session.started_at)}</div>
<div className="sessions-time-end">{formatDate(session.ended_at)}</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{selectedSession && (
<div className="sessions-detail">
<div className="sessions-detail-header">
<h4>Session Details: <code>{selectedSession.id.slice(-12)}</code></h4>
<button
className="close-button"
onClick={() => setSelectedSession(null)}
>
×
</button>
</div>
{detailError && <div className="analytics-error">{detailError}</div>}
{detailLoading && <div className="analytics-loading">Loading session details...</div>}
{!detailLoading && selectedSession.workerSummaries.length > 0 && (
<div className="sessions-workers-section">
<h5>Worker Performance</h5>
<table className="sessions-workers-table">
<thead>
<tr>
<th>Worker</th>
<th>Beads Completed</th>
<th>Beads Failed</th>
<th>Errors</th>
<th>Cost</th>
<th>Tokens (In/Out)</th>
<th>Source</th>
</tr>
</thead>
<tbody>
{selectedSession.workerSummaries.map((summary) => (
<tr key={summary.workerId}>
<td className="sessions-worker-id">
<code>{summary.workerId}</code>
</td>
<td className="sessions-beads-completed">{summary.beadsCompleted}</td>
<td className="sessions-beads-failed">{summary.beadsFailed}</td>
<td className="sessions-errors">{summary.errors}</td>
<td className="sessions-cost">{formatCost(summary.costUsd)}</td>
<td className="sessions-tokens">
{formatTokens(summary.tokensIn)} / {formatTokens(summary.tokensOut)}
</td>
<td className="sessions-source">
<span className={`sessions-source-badge ${summary.metricsSource}`}>
{summary.metricsSource}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!detailLoading && selectedSession.workerSummaries.length === 0 && (
<div className="analytics-empty">
<p>No worker summaries available for this session.</p>
</div>
)}
</div>
)}
</div>
</div>
);
};
export default HistoricalSessionsPanel;

View file

@ -1307,6 +1307,86 @@ export function createWebServer(options: WebServerOptions): WebServer {
}
});
// ============================================
// Historical Sessions API Endpoints
// ============================================
// Get historical sessions list (paginated, with time range filter)
app.get('/api/sessions', (req: Request, res: Response) => {
try {
const historical = store.getHistoricalStore();
const startTime = req.query.start ? parseInt(req.query.start as string) : undefined;
const endTime = req.query.end ? parseInt(req.query.end as string) : undefined;
const limit = req.query.limit ? parseInt(req.query.limit as string) : 100;
const sessions = historical.getSessions({
startTime,
endTime,
limit,
});
res.json({
sessions,
count: sessions.length,
});
} catch (error) {
console.error('Error fetching sessions:', error);
res.status(500).json({
error: 'Failed to fetch sessions',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
// Get a specific session by ID
app.get('/api/sessions/:id', (req: Request, res: Response) => {
try {
const sessionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const historical = store.getHistoricalStore();
const session = historical.getSession(sessionId);
if (!session) {
res.status(404).json({ error: 'Session not found' });
return;
}
// Get worker summaries for this session
const workerSummaries = historical.getSessionWorkerSummaries({ sessionId });
res.json({
session,
workerSummaries,
});
} catch (error) {
console.error('Error fetching session:', error);
res.status(500).json({
error: 'Failed to fetch session',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
// Get worker summaries for a specific session
app.get('/api/sessions/:id/workers', (req: Request, res: Response) => {
try {
const sessionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const historical = store.getHistoricalStore();
const workerSummaries = historical.getSessionWorkerSummaries({ sessionId });
res.json({
sessionId,
workers: workerSummaries,
count: workerSummaries.length,
});
} catch (error) {
console.error('Error fetching session workers:', error);
res.status(500).json({
error: 'Failed to fetch session workers',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
// Serve static frontend files
const staticPath = join(__dirname, 'public');
app.use(express.static(staticPath));