feat(web): add Historical Session Index API and browser UI
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:
parent
5b350b9326
commit
55611370bb
3 changed files with 353 additions and 0 deletions
|
|
@ -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">🏆</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">📅</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">
|
||||
|
|
|
|||
254
src/web/frontend/src/components/HistoricalSessionsPanel.tsx
Normal file
254
src/web/frontend/src/components/HistoricalSessionsPanel.tsx
Normal 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;
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue