feat(web): add budget alert banner with 80%/95% thresholds
Port BudgetAlertPanel behaviour to CostDashboard: warning at 80% and critical at 95% of configured daily budget. Adds BudgetBanner (sticky top-of-page alert when budget >= 80%), burn rate with ETA-to-exhaust, and top consumers breakdown. Sources data from /api/cost/summary polled every 15 seconds. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
9938630bdd
commit
0c1a4eebeb
4 changed files with 867 additions and 45 deletions
246
src/web/frontend/src/components/BudgetAlertPanel.tsx
Normal file
246
src/web/frontend/src/components/BudgetAlertPanel.tsx
Normal file
|
|
@ -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<BudgetBannerProps> = ({ budget, burnRate, onOpenPanel, onDismiss }) => {
|
||||
if (budget.warningLevel === 'none') return null;
|
||||
|
||||
const isCritical = budget.warningLevel === 'critical' || budget.isOverBudget;
|
||||
|
||||
return (
|
||||
<div className={`budget-banner ${isCritical ? 'budget-banner--critical' : 'budget-banner--warning'}`}>
|
||||
<div className="budget-banner-content">
|
||||
<span className="budget-banner-icon">{isCritical ? '!!' : '!'}</span>
|
||||
<span className="budget-banner-text">
|
||||
{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)`}
|
||||
</span>
|
||||
<span className="budget-banner-burn">
|
||||
{formatBurnRate(burnRate.costPerMinute)}
|
||||
{burnRate.timeToExhaustion && ` | ETA: ${burnRate.timeToExhaustion}`}
|
||||
</span>
|
||||
<button className="budget-banner-action" onClick={onOpenPanel}>Details</button>
|
||||
<button className="budget-banner-dismiss" onClick={onDismiss} title="Dismiss for this session">×</button>
|
||||
</div>
|
||||
<div className="budget-banner-bar">
|
||||
<div
|
||||
className="budget-banner-bar-fill"
|
||||
style={{ width: `${Math.min(100, budget.percentUsed)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Full panel: detailed budget alert view ──
|
||||
|
||||
interface BudgetAlertPanelProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const BudgetAlertPanel: React.FC<BudgetAlertPanelProps> = ({ visible, onClose }) => {
|
||||
const [summary, setSummary] = useState<CostSummary | null>(null);
|
||||
const [workers, setWorkers] = useState<WorkerCostEntry[]>([]);
|
||||
|
||||
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 (
|
||||
<div className="cost-dashboard-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="cost-dashboard budget-alert-panel">
|
||||
<div className="cost-dashboard-header">
|
||||
<h3>Budget Alerts</h3>
|
||||
<button className="close-button" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="cost-dashboard-content">
|
||||
{/* Budget Status */}
|
||||
<div className="cost-card">
|
||||
<div className="cost-card-title">Budget Status</div>
|
||||
<div className="budget-alert-summary-row">
|
||||
<div className="budget-alert-stat">
|
||||
<span className="budget-alert-stat-label">Spent</span>
|
||||
<span className={`budget-alert-stat-value ${budget.warningLevel === 'critical' || budget.isOverBudget ? 'cost-high' : budget.warningLevel === 'warning' ? 'cost-warn' : ''}`}>
|
||||
{formatCost(budget.spent)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="budget-alert-stat">
|
||||
<span className="budget-alert-stat-label">Limit</span>
|
||||
<span className="budget-alert-stat-value">{formatCost(budget.limit)}</span>
|
||||
</div>
|
||||
<div className="budget-alert-stat">
|
||||
<span className="budget-alert-stat-label">Remaining</span>
|
||||
<span className="budget-alert-stat-value">{formatCost(Math.max(0, budget.remaining))}</span>
|
||||
</div>
|
||||
<div className="budget-alert-stat">
|
||||
<span className="budget-alert-stat-label">Usage</span>
|
||||
<span className={`budget-alert-stat-value ${budget.warningLevel === 'critical' || budget.isOverBudget ? 'cost-high' : budget.warningLevel === 'warning' ? 'cost-warn' : ''}`}>
|
||||
{Math.round(budget.percentUsed)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="budget-progress-container">
|
||||
<div className="budget-progress-bar">
|
||||
<div
|
||||
className="budget-progress-fill"
|
||||
style={{
|
||||
width: `${Math.min(100, budget.percentUsed)}%`,
|
||||
backgroundColor: budget.warningLevel === 'critical' || budget.isOverBudget
|
||||
? 'var(--error)'
|
||||
: budget.warningLevel === 'warning'
|
||||
? 'var(--warning)'
|
||||
: 'var(--success)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="budget-progress-markers">
|
||||
<span className="budget-marker" style={{ left: '80%' }}>80%</span>
|
||||
<span className="budget-marker" style={{ left: '95%' }}>95%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Burn Rate & ETA */}
|
||||
<div className="cost-card">
|
||||
<div className="cost-card-title">Burn Rate & ETA</div>
|
||||
<div className="budget-alert-summary-row">
|
||||
<div className="budget-alert-stat">
|
||||
<span className="budget-alert-stat-label">Rate</span>
|
||||
<span className={`budget-alert-stat-value ${burnRate.isHighBurnRate ? 'cost-high' : ''}`}>
|
||||
{formatBurnRate(burnRate.costPerMinute)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="budget-alert-stat">
|
||||
<span className="budget-alert-stat-label">ETA to Exhaust</span>
|
||||
<span className="budget-alert-stat-value">
|
||||
{burnRate.timeToExhaustion || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="budget-alert-stat">
|
||||
<span className="budget-alert-stat-label">Projected Total</span>
|
||||
<span className="budget-alert-stat-value">{formatCost(burnRate.projectedTotalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{burnRate.isHighBurnRate && (
|
||||
<div className="budget-burn-warning">High burn rate detected</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Consumers */}
|
||||
<div className="cost-card">
|
||||
<div className="cost-card-title">Top Consumers ({workerCount} workers)</div>
|
||||
<div className="budget-consumers-list">
|
||||
{workers.length === 0 && (
|
||||
<div className="cost-empty">No cost data yet</div>
|
||||
)}
|
||||
{workers.map(w => (
|
||||
<div key={w.workerId} className="budget-consumer-row">
|
||||
<div className="budget-consumer-info">
|
||||
<span className="budget-consumer-id">{w.workerId}</span>
|
||||
{w.currentBead && <span className="budget-consumer-bead">{w.currentBead}</span>}
|
||||
</div>
|
||||
<div className="budget-consumer-bar-container">
|
||||
<div
|
||||
className="budget-consumer-bar"
|
||||
style={{ width: `${(w.costUsd / maxWorkerCost) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="budget-consumer-cost">{formatCost(w.costUsd)}</span>
|
||||
<span className="budget-consumer-tokens">{formatTokens(w.totalTokens)} tok | {w.apiCalls} calls</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BudgetAlertPanel;
|
||||
|
|
@ -131,6 +131,12 @@ const BudgetProgressBar: React.FC<BudgetProgressBarProps> = ({ spent, limit, per
|
|||
backgroundColor: getColor(),
|
||||
}}
|
||||
/>
|
||||
<div className="budget-threshold-marker" style={{ left: '80%' }} title="Warning (80%)" />
|
||||
<div className="budget-threshold-marker budget-threshold-critical" style={{ left: '95%' }} title="Critical (95%)" />
|
||||
</div>
|
||||
<div className="budget-progress-markers">
|
||||
<span className="budget-marker" style={{ left: '80%' }}>80%</span>
|
||||
<span className="budget-marker" style={{ left: '95%' }}>95%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -181,7 +187,7 @@ const CostDashboard: React.FC<CostDashboardProps> = ({ visible, onClose }) => {
|
|||
const [beads, setBeads] = useState<BeadCostEntry[]>([]);
|
||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||
const [alerts, setAlerts] = useState<BudgetAlert[]>([]);
|
||||
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<CostDashboardProps> = ({ 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<CostDashboardProps> = ({ visible, onClose }) => {
|
|||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.badge && tab.badge > 0 && (
|
||||
<span className="cost-tab-badge">{tab.badge}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -279,6 +289,30 @@ const CostDashboard: React.FC<CostDashboardProps> = ({ visible, onClose }) => {
|
|||
|
||||
{activeTab === 'overview' && summary && (
|
||||
<div className="cost-overview">
|
||||
{/* Budget Alert Banner (when >= 80%) */}
|
||||
{summary.budget.limit > 0 && summary.budget.warningLevel !== 'none' && (
|
||||
<div className={`cost-dashboard-alert-banner ${summary.budget.warningLevel === 'critical' || summary.budget.isOverBudget ? 'cost-dashboard-alert-critical' : 'cost-dashboard-alert-warning'}`}>
|
||||
<span className="cost-dashboard-alert-icon">
|
||||
{summary.budget.isOverBudget ? '!!' : summary.budget.warningLevel === 'critical' ? '!!' : '!'}
|
||||
</span>
|
||||
<span className="cost-dashboard-alert-text">
|
||||
{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)`}
|
||||
</span>
|
||||
<span className="cost-dashboard-alert-burn">
|
||||
{formatBurnRate(summary.burnRate.costPerMinute)}
|
||||
{summary.burnRate.timeToExhaustion && ` | ETA: ${summary.burnRate.timeToExhaustion}`}
|
||||
</span>
|
||||
<div className="cost-dashboard-alert-bar">
|
||||
<div
|
||||
className="cost-dashboard-alert-bar-fill"
|
||||
style={{ width: `${Math.min(100, summary.budget.percentUsed)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session Cost */}
|
||||
<div className="cost-card">
|
||||
<div className="cost-card-title">Session Cost</div>
|
||||
|
|
@ -296,57 +330,56 @@ const CostDashboard: React.FC<CostDashboardProps> = ({ visible, onClose }) => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Burn Rate */}
|
||||
{/* Burn Rate & ETA */}
|
||||
<div className="cost-card">
|
||||
<div className="cost-card-title">Burn Rate</div>
|
||||
<div className={`cost-card-value ${summary.burnRate.isHighBurnRate ? 'cost-high' : ''}`}>
|
||||
{formatBurnRate(summary.burnRate.costPerMinute)}
|
||||
</div>
|
||||
<div className="cost-card-subtitle">
|
||||
Window: {summary.burnRate.windowMinutes} min avg
|
||||
</div>
|
||||
{summary.burnRate.timeToExhaustion && (
|
||||
<div className="cost-exhaustion">
|
||||
Time to exhaustion: <strong>{summary.burnRate.timeToExhaustion}</strong>
|
||||
<div className="cost-card-title">Burn Rate & ETA</div>
|
||||
<div className="budget-alert-summary-row">
|
||||
<div className="budget-alert-stat">
|
||||
<span className="budget-alert-stat-label">Rate</span>
|
||||
<span className={`budget-alert-stat-value ${summary.burnRate.isHighBurnRate ? 'cost-high' : ''}`}>
|
||||
{formatBurnRate(summary.burnRate.costPerMinute)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="budget-alert-stat">
|
||||
<span className="budget-alert-stat-label">ETA to Exhaust</span>
|
||||
<span className="budget-alert-stat-value">
|
||||
{summary.burnRate.timeToExhaustion || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="budget-alert-stat">
|
||||
<span className="budget-alert-stat-label">Projected Total</span>
|
||||
<span className="budget-alert-stat-value">{formatCost(summary.burnRate.projectedTotalCost)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="cost-projected">
|
||||
Projected session total: {formatCost(summary.burnRate.projectedTotalCost)}
|
||||
</div>
|
||||
{summary.burnRate.isHighBurnRate && (
|
||||
<div className="budget-burn-warning">High burn rate detected</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{alerts.filter(a => !a.acknowledged).length > 0 && (
|
||||
<div className="cost-card cost-alerts-card">
|
||||
<div className="cost-card-title">Active Alerts</div>
|
||||
{alerts.filter(a => !a.acknowledged).map(alert => (
|
||||
<div key={alert.id} className={`cost-alert-item cost-alert-${alert.type}`}>
|
||||
<div className="cost-alert-header">
|
||||
<span className="cost-alert-type">{alert.type.toUpperCase()}</span>
|
||||
<span className="cost-alert-time">{new Date(alert.timestamp).toLocaleTimeString()}</span>
|
||||
{/* Top Consumers */}
|
||||
<div className="cost-card">
|
||||
<div className="cost-card-title">Top Consumers ({summary.workerCount} workers)</div>
|
||||
<div className="budget-consumers-list">
|
||||
{workers.slice(0, 10).length === 0 && (
|
||||
<div className="cost-empty">No cost data yet</div>
|
||||
)}
|
||||
{workers.slice(0, 10).map(w => (
|
||||
<div key={w.workerId} className="budget-consumer-row">
|
||||
<div className="budget-consumer-info">
|
||||
<span className="budget-consumer-id">{w.workerId}</span>
|
||||
{w.currentBead && <span className="budget-consumer-bead">{w.currentBead}</span>}
|
||||
</div>
|
||||
<div className="cost-alert-details">
|
||||
{formatCost(alert.spent)} / {formatCost(alert.limit)} at {formatBurnRate(alert.burnRate)}
|
||||
<div className="budget-consumer-bar-container">
|
||||
<div
|
||||
className="budget-consumer-bar"
|
||||
style={{ width: `${(w.costUsd / (Math.max(...workers.slice(0, 10).map(wc => wc.costUsd)) || 0.001)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<button className="cost-alert-ack" onClick={() => handleAcknowledge(alert.id)}>
|
||||
Acknowledge
|
||||
</button>
|
||||
<span className="budget-consumer-cost">{formatCost(w.costUsd)}</span>
|
||||
<span className="budget-consumer-tokens">{formatTokens(w.totalTokens)} tok | {w.apiCalls} calls</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Workers Summary */}
|
||||
<div className="cost-card">
|
||||
<div className="cost-card-title">Top Workers ({summary.workerCount} total)</div>
|
||||
{workers.slice(0, 5).map(w => (
|
||||
<div key={w.workerId} className="cost-worker-row">
|
||||
<span className="cost-worker-id">{w.workerId}</span>
|
||||
<span className="cost-worker-cost">{formatCost(w.costUsd)}</span>
|
||||
<span className="cost-worker-tokens">{formatTokens(w.totalTokens)} tok</span>
|
||||
</div>
|
||||
))}
|
||||
{workers.length === 0 && <div className="cost-empty">No cost data yet</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -452,6 +485,99 @@ const CostDashboard: React.FC<CostDashboardProps> = ({ visible, onClose }) => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'alerts' && summary && (
|
||||
<div className="cost-alerts-view">
|
||||
{/* Budget Status */}
|
||||
{summary.budget.limit > 0 && (
|
||||
<div className="cost-card">
|
||||
<div className="cost-card-title">Budget Status</div>
|
||||
<div className="budget-alert-summary-row">
|
||||
<div className="budget-alert-stat">
|
||||
<span className="budget-alert-stat-label">Spent</span>
|
||||
<span className={`budget-alert-stat-value ${summary.budget.warningLevel === 'critical' || summary.budget.isOverBudget ? 'cost-high' : summary.budget.warningLevel === 'warning' ? 'cost-warn' : ''}`}>
|
||||
{formatCost(summary.budget.spent)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="budget-alert-stat">
|
||||
<span className="budget-alert-stat-label">Limit</span>
|
||||
<span className="budget-alert-stat-value">{formatCost(summary.budget.limit)}</span>
|
||||
</div>
|
||||
<div className="budget-alert-stat">
|
||||
<span className="budget-alert-stat-label">Remaining</span>
|
||||
<span className="budget-alert-stat-value">{formatCost(Math.max(0, summary.budget.remaining))}</span>
|
||||
</div>
|
||||
<div className="budget-alert-stat">
|
||||
<span className="budget-alert-stat-label">Usage</span>
|
||||
<span className={`budget-alert-stat-value ${summary.budget.warningLevel === 'critical' || summary.budget.isOverBudget ? 'cost-high' : summary.budget.warningLevel === 'warning' ? 'cost-warn' : ''}`}>
|
||||
{Math.round(summary.budget.percentUsed)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<BudgetProgressBar
|
||||
spent={summary.budget.spent}
|
||||
limit={summary.budget.limit}
|
||||
percentUsed={summary.budget.percentUsed}
|
||||
warningLevel={summary.budget.warningLevel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Burn Rate & ETA */}
|
||||
<div className="cost-card">
|
||||
<div className="cost-card-title">Burn Rate & ETA</div>
|
||||
<div className="budget-alert-summary-row">
|
||||
<div className="budget-alert-stat">
|
||||
<span className="budget-alert-stat-label">Rate</span>
|
||||
<span className={`budget-alert-stat-value ${summary.burnRate.isHighBurnRate ? 'cost-high' : ''}`}>
|
||||
{formatBurnRate(summary.burnRate.costPerMinute)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="budget-alert-stat">
|
||||
<span className="budget-alert-stat-label">ETA to Exhaust</span>
|
||||
<span className="budget-alert-stat-value">
|
||||
{summary.burnRate.timeToExhaustion || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="budget-alert-stat">
|
||||
<span className="budget-alert-stat-label">Projected Total</span>
|
||||
<span className="budget-alert-stat-value">{formatCost(summary.burnRate.projectedTotalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{summary.burnRate.isHighBurnRate && (
|
||||
<div className="budget-burn-warning">High burn rate detected</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Alerts */}
|
||||
{alerts.length > 0 ? (
|
||||
<div className="cost-card cost-alerts-card">
|
||||
<div className="cost-card-title">Alerts ({alerts.filter(a => !a.acknowledged).length} active)</div>
|
||||
{alerts.map(alert => (
|
||||
<div key={alert.id} className={`cost-alert-item cost-alert-${alert.type}${alert.acknowledged ? ' cost-alert-acked' : ''}`}>
|
||||
<div className="cost-alert-header">
|
||||
<span className="cost-alert-type">{alert.type.toUpperCase()}</span>
|
||||
<span className="cost-alert-time">{new Date(alert.timestamp).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
<div className="cost-alert-details">
|
||||
{formatCost(alert.spent)} / {formatCost(alert.limit)} at {formatBurnRate(alert.burnRate)}
|
||||
</div>
|
||||
{!alert.acknowledged && (
|
||||
<button className="cost-alert-ack" onClick={() => handleAcknowledge(alert.id)}>
|
||||
Acknowledge
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="cost-card">
|
||||
<div className="cost-card-title">Alerts</div>
|
||||
<div className="cost-empty">No budget alerts</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue