diff --git a/src/web/frontend/src/components/BudgetAlertPanel.tsx b/src/web/frontend/src/components/BudgetAlertPanel.tsx new file mode 100644 index 0000000..326c80e --- /dev/null +++ b/src/web/frontend/src/components/BudgetAlertPanel.tsx @@ -0,0 +1,246 @@ +import React, { useState, useEffect, useCallback } from 'react'; + +interface BudgetStatus { + limit: number; + spent: number; + percentUsed: number; + isOverBudget: boolean; + warningLevel: 'none' | 'warning' | 'critical'; + remaining: number; +} + +interface BurnRate { + costPerMinute: number; + minutesToExhaustion: number | null; + timeToExhaustion: string | null; + projectedTotalCost: number; + windowMinutes: number; + isHighBurnRate: boolean; +} + +interface CostSummary { + totalCostUsd: number; + budget: BudgetStatus; + burnRate: BurnRate; + workerCount: number; +} + +interface WorkerCostEntry { + workerId: string; + costUsd: number; + totalTokens: number; + apiCalls: number; + currentBead?: string; +} + +function formatCost(usd: number): string { + if (usd < 0.01) return `$${(usd * 100).toFixed(2)}c`; + if (usd < 1) return `$${usd.toFixed(3)}`; + if (usd < 100) return `$${usd.toFixed(2)}`; + return `$${usd.toFixed(0)}`; +} + +function formatTokens(count: number): string { + if (count < 1000) return count.toString(); + if (count < 1_000_000) return `${(count / 1000).toFixed(1)}K`; + return `${(count / 1_000_000).toFixed(2)}M`; +} + +function formatBurnRate(rate: number): string { + if (rate < 0.01) return `$${(rate * 100).toFixed(2)}c/min`; + return `$${rate.toFixed(2)}/min`; +} + +// ── Banner: shown at top of page when budget >= 80% ── + +interface BudgetBannerProps { + budget: BudgetStatus; + burnRate: BurnRate; + onOpenPanel: () => void; + onDismiss: () => void; +} + +export const BudgetBanner: React.FC = ({ budget, burnRate, onOpenPanel, onDismiss }) => { + if (budget.warningLevel === 'none') return null; + + const isCritical = budget.warningLevel === 'critical' || budget.isOverBudget; + + return ( +
+
+ {isCritical ? '!!' : '!'} + + {budget.isOverBudget + ? `Budget exceeded: ${formatCost(budget.spent)} / ${formatCost(budget.limit)} (${Math.round(budget.percentUsed)}%)` + : `${isCritical ? 'Critical' : 'Warning'}: ${formatCost(budget.spent)} / ${formatCost(budget.limit)} (${Math.round(budget.percentUsed)}% used)`} + + + {formatBurnRate(burnRate.costPerMinute)} + {burnRate.timeToExhaustion && ` | ETA: ${burnRate.timeToExhaustion}`} + + + +
+
+
+
+
+ ); +}; + +// ── Full panel: detailed budget alert view ── + +interface BudgetAlertPanelProps { + visible: boolean; + onClose: () => void; +} + +const BudgetAlertPanel: React.FC = ({ visible, onClose }) => { + const [summary, setSummary] = useState(null); + const [workers, setWorkers] = useState([]); + + const fetchData = useCallback(async () => { + try { + const [summaryRes, workersRes] = await Promise.all([ + fetch('/api/cost/summary'), + fetch('/api/cost/workers'), + ]); + if (summaryRes.ok) setSummary(await summaryRes.json()); + if (workersRes.ok) { + const data = await workersRes.json(); + setWorkers((data.workers || []).slice(0, 10)); + } + } catch (err) { + console.error('Failed to fetch budget data:', err); + } + }, []); + + useEffect(() => { + if (visible) { + fetchData(); + const interval = setInterval(fetchData, 10000); + return () => clearInterval(interval); + } + }, [visible, fetchData]); + + if (!visible || !summary) return null; + + const { budget, burnRate, totalCostUsd, workerCount } = summary; + const maxWorkerCost = Math.max(...workers.map(w => w.costUsd), 0.001); + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+

Budget Alerts

+ +
+ +
+ {/* Budget Status */} +
+
Budget Status
+
+
+ Spent + + {formatCost(budget.spent)} + +
+
+ Limit + {formatCost(budget.limit)} +
+
+ Remaining + {formatCost(Math.max(0, budget.remaining))} +
+
+ Usage + + {Math.round(budget.percentUsed)}% + +
+
+
+
+
+
+
+ 80% + 95% +
+
+
+ + {/* Burn Rate & ETA */} +
+
Burn Rate & ETA
+
+
+ Rate + + {formatBurnRate(burnRate.costPerMinute)} + +
+
+ ETA to Exhaust + + {burnRate.timeToExhaustion || 'N/A'} + +
+
+ Projected Total + {formatCost(burnRate.projectedTotalCost)} +
+
+ {burnRate.isHighBurnRate && ( +
High burn rate detected
+ )} +
+ + {/* Top Consumers */} +
+
Top Consumers ({workerCount} workers)
+
+ {workers.length === 0 && ( +
No cost data yet
+ )} + {workers.map(w => ( +
+
+ {w.workerId} + {w.currentBead && {w.currentBead}} +
+
+
+
+ {formatCost(w.costUsd)} + {formatTokens(w.totalTokens)} tok | {w.apiCalls} calls +
+ ))} +
+
+
+
+
+ ); +}; + +export default BudgetAlertPanel; diff --git a/src/web/frontend/src/components/CostDashboard.tsx b/src/web/frontend/src/components/CostDashboard.tsx index 0040f10..9eb7ce1 100644 --- a/src/web/frontend/src/components/CostDashboard.tsx +++ b/src/web/frontend/src/components/CostDashboard.tsx @@ -131,6 +131,12 @@ const BudgetProgressBar: React.FC = ({ spent, limit, per backgroundColor: getColor(), }} /> +
+
+
+
+ 80% + 95%
); @@ -181,7 +187,7 @@ const CostDashboard: React.FC = ({ visible, onClose }) => { const [beads, setBeads] = useState([]); const [timeSeries, setTimeSeries] = useState([]); const [alerts, setAlerts] = useState([]); - const [activeTab, setActiveTab] = useState<'overview' | 'workers' | 'beads' | 'trends'>('overview'); + const [activeTab, setActiveTab] = useState<'overview' | 'workers' | 'beads' | 'trends' | 'alerts'>('overview'); const [loading, setLoading] = useState(false); const fetchCostData = useCallback(async () => { @@ -243,6 +249,7 @@ const CostDashboard: React.FC = ({ visible, onClose }) => { { id: 'workers' as const, label: 'Workers' }, { id: 'beads' as const, label: 'Tasks' }, { id: 'trends' as const, label: 'Trends' }, + { id: 'alerts' as const, label: 'Alerts', badge: alerts.filter(a => !a.acknowledged).length || undefined }, ]; return ( @@ -268,6 +275,9 @@ const CostDashboard: React.FC = ({ visible, onClose }) => { onClick={() => setActiveTab(tab.id)} > {tab.label} + {tab.badge && tab.badge > 0 && ( + {tab.badge} + )} ))}
@@ -279,6 +289,30 @@ const CostDashboard: React.FC = ({ visible, onClose }) => { {activeTab === 'overview' && summary && (
+ {/* Budget Alert Banner (when >= 80%) */} + {summary.budget.limit > 0 && summary.budget.warningLevel !== 'none' && ( +
+ + {summary.budget.isOverBudget ? '!!' : summary.budget.warningLevel === 'critical' ? '!!' : '!'} + + + {summary.budget.isOverBudget + ? `Budget exceeded: ${formatCost(summary.budget.spent)} / ${formatCost(summary.budget.limit)} (${Math.round(summary.budget.percentUsed)}%)` + : `${summary.budget.warningLevel === 'critical' ? 'Critical' : 'Warning'}: ${formatCost(summary.budget.spent)} / ${formatCost(summary.budget.limit)} (${Math.round(summary.budget.percentUsed)}% used)`} + + + {formatBurnRate(summary.burnRate.costPerMinute)} + {summary.burnRate.timeToExhaustion && ` | ETA: ${summary.burnRate.timeToExhaustion}`} + +
+
+
+
+ )} + {/* Session Cost */}
Session Cost
@@ -296,57 +330,56 @@ const CostDashboard: React.FC = ({ visible, onClose }) => { )}
- {/* Burn Rate */} + {/* Burn Rate & ETA */}
-
Burn Rate
-
- {formatBurnRate(summary.burnRate.costPerMinute)} -
-
- Window: {summary.burnRate.windowMinutes} min avg -
- {summary.burnRate.timeToExhaustion && ( -
- Time to exhaustion: {summary.burnRate.timeToExhaustion} +
Burn Rate & ETA
+
+
+ Rate + + {formatBurnRate(summary.burnRate.costPerMinute)} + +
+
+ ETA to Exhaust + + {summary.burnRate.timeToExhaustion || 'N/A'} + +
+
+ Projected Total + {formatCost(summary.burnRate.projectedTotalCost)}
- )} -
- Projected session total: {formatCost(summary.burnRate.projectedTotalCost)}
+ {summary.burnRate.isHighBurnRate && ( +
High burn rate detected
+ )}
- {/* Alerts */} - {alerts.filter(a => !a.acknowledged).length > 0 && ( -
-
Active Alerts
- {alerts.filter(a => !a.acknowledged).map(alert => ( -
-
- {alert.type.toUpperCase()} - {new Date(alert.timestamp).toLocaleTimeString()} + {/* Top Consumers */} +
+
Top Consumers ({summary.workerCount} workers)
+
+ {workers.slice(0, 10).length === 0 && ( +
No cost data yet
+ )} + {workers.slice(0, 10).map(w => ( +
+
+ {w.workerId} + {w.currentBead && {w.currentBead}}
-
- {formatCost(alert.spent)} / {formatCost(alert.limit)} at {formatBurnRate(alert.burnRate)} +
+
wc.costUsd)) || 0.001)) * 100}%` }} + />
- + {formatCost(w.costUsd)} + {formatTokens(w.totalTokens)} tok | {w.apiCalls} calls
))}
- )} - - {/* Quick Workers Summary */} -
-
Top Workers ({summary.workerCount} total)
- {workers.slice(0, 5).map(w => ( -
- {w.workerId} - {formatCost(w.costUsd)} - {formatTokens(w.totalTokens)} tok -
- ))} - {workers.length === 0 &&
No cost data yet
}
)} @@ -452,6 +485,99 @@ const CostDashboard: React.FC = ({ visible, onClose }) => {
)} + + {activeTab === 'alerts' && summary && ( +
+ {/* Budget Status */} + {summary.budget.limit > 0 && ( +
+
Budget Status
+
+
+ Spent + + {formatCost(summary.budget.spent)} + +
+
+ Limit + {formatCost(summary.budget.limit)} +
+
+ Remaining + {formatCost(Math.max(0, summary.budget.remaining))} +
+
+ Usage + + {Math.round(summary.budget.percentUsed)}% + +
+
+ +
+ )} + + {/* Burn Rate & ETA */} +
+
Burn Rate & ETA
+
+
+ Rate + + {formatBurnRate(summary.burnRate.costPerMinute)} + +
+
+ ETA to Exhaust + + {summary.burnRate.timeToExhaustion || 'N/A'} + +
+
+ Projected Total + {formatCost(summary.burnRate.projectedTotalCost)} +
+
+ {summary.burnRate.isHighBurnRate && ( +
High burn rate detected
+ )} +
+ + {/* Active Alerts */} + {alerts.length > 0 ? ( +
+
Alerts ({alerts.filter(a => !a.acknowledged).length} active)
+ {alerts.map(alert => ( +
+
+ {alert.type.toUpperCase()} + {new Date(alert.timestamp).toLocaleTimeString()} +
+
+ {formatCost(alert.spent)} / {formatCost(alert.limit)} at {formatBurnRate(alert.burnRate)} +
+ {!alert.acknowledged && ( + + )} +
+ ))} +
+ ) : ( +
+
Alerts
+
No budget alerts
+
+ )} +
+ )}
diff --git a/src/web/frontend/src/index.css b/src/web/frontend/src/index.css index cc88752..fee05e2 100644 --- a/src/web/frontend/src/index.css +++ b/src/web/frontend/src/index.css @@ -811,6 +811,326 @@ body { margin-top: 0.5rem; } +/* ============================================ + Worker Detail Tabs + ============================================ */ + +.worker-detail-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--bg-tertiary); + margin-bottom: 0.75rem; +} + +.worker-detail-tab { + flex: 1; + padding: 0.5rem 0.75rem; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-secondary); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 0.375rem; +} + +.worker-detail-tab:hover { + color: var(--text-primary); + background: var(--bg-tertiary); +} + +.worker-detail-tab.active { + color: var(--info); + border-bottom-color: var(--info); +} + +.worker-detail-tab-count { + background: var(--bg-tertiary); + padding: 0.0625rem 0.375rem; + border-radius: 10px; + font-size: 0.6875rem; + color: var(--text-secondary); +} + +.worker-detail-tab.active .worker-detail-tab-count { + background: rgba(33, 150, 243, 0.15); + color: var(--info); +} + +.worker-detail-tab-content { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +/* ============================================ + Conversation Transcript Styles + ============================================ */ + +.conversation-transcript { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.conversation-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.375rem 0; + margin-bottom: 0.5rem; +} + +.conversation-turn-count { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.conversation-toolbar-actions { + display: flex; + gap: 0.25rem; +} + +.conversation-toolbar-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-secondary); + padding: 0.25rem 0.5rem; + border-radius: 3px; + font-size: 0.6875rem; + cursor: pointer; + transition: all 0.15s; +} + +.conversation-toolbar-btn:hover { + background: var(--hover-bg); + color: var(--text-primary); +} + +.conversation-search-bar { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0; + margin-bottom: 0.5rem; + border-bottom: 1px solid var(--bg-tertiary); +} + +.conversation-search-input { + flex: 1; + background: var(--input-bg); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 0.25rem 0.5rem; + border-radius: 3px; + font-size: 0.8125rem; + font-family: inherit; + outline: none; +} + +.conversation-search-input:focus { + border-color: var(--info); +} + +.conversation-search-results { + font-size: 0.6875rem; + color: var(--text-secondary); + white-space: nowrap; +} + +.conversation-search-nav { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-secondary); + padding: 0.125rem 0.375rem; + border-radius: 2px; + cursor: pointer; + font-size: 0.75rem; + line-height: 1; +} + +.conversation-search-nav:hover { + color: var(--text-primary); +} + +.conversation-search-close { + background: none; + border: 1px solid var(--border-color); + color: var(--text-secondary); + padding: 0.125rem 0.375rem; + border-radius: 2px; + cursor: pointer; + font-size: 0.6875rem; +} + +.conversation-search-close:hover { + color: var(--text-primary); +} + +.conversation-turn-list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.conversation-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-secondary); + font-size: 0.875rem; + padding: 2rem 0; +} + +.conversation-turn { + border-left: 3px solid var(--border-color); + border-radius: 0 4px 4px 0; + background: var(--bg-primary); + transition: background 0.15s; +} + +.conversation-turn:hover { + background: var(--hover-bg); +} + +.conversation-turn-search-hit { + background: rgba(255, 193, 7, 0.05); +} + +.conversation-turn-current-hit { + background: rgba(33, 150, 243, 0.1); + outline: 1px solid rgba(33, 150, 243, 0.3); +} + +.conversation-turn-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.5rem; + font-size: 0.75rem; + user-select: none; +} + +.conversation-turn-role { + font-weight: 700; + font-size: 0.6875rem; + letter-spacing: 0.04em; + min-width: 56px; +} + +.conversation-turn-tool { + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.75rem; + color: #00bcd4; + background: rgba(0, 188, 212, 0.1); + padding: 0.0625rem 0.375rem; + border-radius: 3px; +} + +.conversation-turn-event { + font-size: 0.6875rem; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.conversation-turn-time { + font-size: 0.6875rem; + color: var(--text-secondary); + font-family: 'SF Mono', Monaco, monospace; + flex-shrink: 0; +} + +.conversation-turn-duration { + font-size: 0.6875rem; + color: var(--text-secondary); + font-family: 'SF Mono', Monaco, monospace; + flex-shrink: 0; +} + +.conversation-turn-error-badge { + background: rgba(244, 67, 54, 0.15); + color: var(--error); + font-size: 0.625rem; + font-weight: 700; + padding: 0.0625rem 0.25rem; + border-radius: 2px; +} + +.conversation-turn-collapse-icon { + color: var(--text-secondary); + font-size: 0.6875rem; + font-family: 'SF Mono', Monaco, monospace; +} + +.conversation-turn-content { + padding: 0 0.5rem 0.375rem 0.5rem; +} + +.conversation-turn-text { + font-size: 0.8125rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + color: var(--text-primary); + max-height: 200px; + overflow-y: auto; +} + +.conversation-turn-code { + font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + font-size: 0.75rem; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + color: var(--text-primary); + background: var(--bg-primary); + padding: 0.375rem 0.5rem; + border-radius: 3px; + border: 1px solid var(--bg-tertiary); + max-height: 300px; + overflow-y: auto; + margin: 0; +} + +.conversation-turn-error { + font-size: 0.75rem; + color: var(--error); + padding: 0.25rem 0.375rem; + margin-top: 0.25rem; + background: rgba(244, 67, 54, 0.08); + border-radius: 3px; + font-family: 'SF Mono', Monaco, monospace; +} + +.conversation-turn-meta { + display: flex; + gap: 0.75rem; + margin-top: 0.25rem; + font-size: 0.6875rem; + color: var(--text-secondary); + font-family: 'SF Mono', Monaco, monospace; +} + +.search-highlight { + background: rgba(255, 193, 7, 0.3); + color: inherit; + padding: 0.0625rem 0; + border-radius: 2px; +} + /* ============================================ Session Replay Component Styles ============================================ */ @@ -4107,6 +4427,107 @@ body { color: var(--text-secondary); } +/* Cost Dashboard Alert Banner (inside dashboard overlay) */ +.cost-dashboard-alert-banner { + grid-column: 1 / -1; + border-radius: 8px; + padding: 0.75rem 1rem; + margin-bottom: 0.5rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; + font-size: 0.85rem; + animation: slideDown 0.25s ease-out; +} + +.cost-dashboard-alert-warning { + background: linear-gradient(135deg, #332200, #443300); + border: 1px solid var(--warning); +} + +.cost-dashboard-alert-critical { + background: linear-gradient(135deg, #330a0a, #441111); + border: 1px solid var(--error); +} + +.cost-dashboard-alert-icon { + font-weight: 700; + font-size: 1rem; +} + +.cost-dashboard-alert-warning .cost-dashboard-alert-icon { + color: var(--warning); +} + +.cost-dashboard-alert-critical .cost-dashboard-alert-icon { + color: var(--error); +} + +.cost-dashboard-alert-text { + color: var(--text-primary); + font-weight: 500; +} + +.cost-dashboard-alert-burn { + color: var(--text-secondary); + font-size: 0.8rem; + margin-left: auto; +} + +.cost-dashboard-alert-bar { + flex-basis: 100%; + height: 4px; + background: var(--bg-tertiary); + border-radius: 2px; + overflow: hidden; +} + +.cost-dashboard-alert-bar-fill { + height: 100%; + border-radius: 2px; + transition: width 0.3s ease; +} + +.cost-dashboard-alert-warning .cost-dashboard-alert-bar-fill { + background: var(--warning); +} + +.cost-dashboard-alert-critical .cost-dashboard-alert-bar-fill { + background: var(--error); +} + +/* Budget threshold markers on progress bar */ +.budget-threshold-marker { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: var(--warning); + opacity: 0.7; + z-index: 1; +} + +.budget-threshold-critical { + background: var(--error); +} + +/* Tab badge */ +.cost-tab-badge { + background: var(--error); + color: white; + font-size: 0.6rem; + padding: 0.1rem 0.35rem; + border-radius: 8px; + font-weight: 600; + margin-left: 0.3rem; + line-height: 1; +} + +.cost-alert-acked { + opacity: 0.5; +} + .cost-alerts-card { grid-column: 1 / -1; } diff --git a/src/web/frontend/src/types.ts b/src/web/frontend/src/types.ts index 99a2e1c..918cb6b 100644 --- a/src/web/frontend/src/types.ts +++ b/src/web/frontend/src/types.ts @@ -15,9 +15,16 @@ export interface LogEvent { tool?: string; message: string; raw: string; - bead?: string; // Bead/task identifier for Focus Mode - sequence?: number; // Per-worker monotonic counter — authoritative for ordering - ts?: number; // Unix timestamp in ms (display only) + bead?: string; + sequence?: number; + ts?: number; + msg?: string; + error?: string; + path?: string; + provider?: string; + model?: string; + session?: string; + duration_ms?: number; } /** @@ -503,3 +510,25 @@ export interface SimilarError { resolution_successful: boolean | null; similarity: number; } + +// ============================================ +// Conversation Transcript Types +// ============================================ + +export type ConversationTurnRole = 'system' | 'user' | 'assistant' | 'tool'; + +export interface ConversationTurn { + id: string; + role: ConversationTurnRole; + eventType: string; + timestamp: number; + content: string; + isCollapsible: boolean; + isCollapsed: boolean; + tool?: string; + durationMs?: number; + error?: string; + success?: boolean; + sequence?: number; + meta?: Record; +}