Merge pull request #1 from adenhq/oss-clean

moved honeycomb to oss
This commit is contained in:
Aden HQ
2026-01-14 19:55:49 -08:00
committed by GitHub
29 changed files with 1882 additions and 295 deletions
+58 -5
View File
@@ -17,6 +17,16 @@ const router = express.Router();
const AUTH_MIDDLEWARE = passport.authenticate("jwt", { session: false });
const isRecord = (v: unknown): v is Record<string, unknown> => {
return typeof v === "object" && v !== null && !Array.isArray(v);
};
const utcDateKey = (d: Date): string => {
const x = new Date(d);
x.setUTCHours(0, 0, 0, 0);
return x.toISOString().slice(0, 10);
};
interface TokenContext {
team_id: string;
user_id?: string;
@@ -453,19 +463,62 @@ router.get("/logs", AUTH_MIDDLEWARE, async (req: Request, res: Response) => {
});
}
// Default: return raw rows
// Default: return raw rows with derived type and success fields
const { type: typeFilter, success: successFilter } = req.query as { type?: string; success?: string };
// Build WHERE conditions for optional filters
const whereConditions = [
'"timestamp" >= $1',
'"timestamp" <= $2',
'team_id = $3',
];
const params: (string | number | boolean)[] = [startDate.toISOString(), endDate.toISOString(), String(ctx.team_id)];
// Add type filter if specified
if (typeFilter && typeFilter !== 'all') {
if (typeFilter === 'tool_call') {
whereConditions.push('COALESCE(tool_call_count, 0) > 0');
} else if (typeFilter === 'error') {
whereConditions.push('(finish_reason IS NULL OR finish_reason IN (\'error\', \'content_filter\'))');
} else if (typeFilter === 'llm_request') {
whereConditions.push('COALESCE(tool_call_count, 0) = 0');
whereConditions.push('(finish_reason IS NOT NULL AND finish_reason NOT IN (\'error\', \'content_filter\'))');
}
}
// Add success filter if specified
if (successFilter !== undefined && successFilter !== '') {
const isSuccess = successFilter === 'true';
if (isSuccess) {
whereConditions.push('finish_reason IN (\'stop\', \'end_turn\', \'tool_calls\', \'length\')');
} else {
whereConditions.push('(finish_reason IS NULL OR finish_reason NOT IN (\'stop\', \'end_turn\', \'tool_calls\', \'length\'))');
}
}
params.push(limit, offset);
const sql = `
SELECT *
SELECT *,
CASE
WHEN COALESCE(tool_call_count, 0) > 0 THEN 'tool_call'
WHEN finish_reason IS NULL OR finish_reason IN ('error', 'content_filter') THEN 'error'
ELSE 'llm_request'
END as derived_type,
CASE
WHEN finish_reason IN ('stop', 'end_turn', 'tool_calls', 'length') THEN true
ELSE false
END as derived_success
FROM llm_events
WHERE "timestamp" >= $1 AND "timestamp" <= $2 AND team_id = $3
WHERE ${whereConditions.join(' AND ')}
ORDER BY "timestamp" DESC
LIMIT $4 OFFSET $5
LIMIT $${params.length - 1} OFFSET $${params.length}
`;
const params = [startDate.toISOString(), endDate.toISOString(), String(ctx.team_id), limit, offset];
const { rows } = await poolClient.query(sql, params);
return res.json({
window: { start: startDate.toISOString(), end: endDate.toISOString() },
count: rows.length,
filters: { type: typeFilter || 'all', success: successFilter },
rows,
});
} catch (err) {
+138
View File
@@ -463,4 +463,142 @@ router.post("/generate-dev-token", async (req: Request, res: Response) => {
}
});
// =============================================================================
// UI Settings Endpoints
// =============================================================================
/**
* Default UI settings for new users
*/
const DEFAULT_UI_SETTINGS = {
sidebarCollapsed: false,
performanceDashboardTimeRange: 'today',
};
/**
* GET /user/settings
*
* Get user UI settings from preferences column.
* Returns defaults if no settings exist.
*/
router.get("/settings", async (req: Request, res: Response) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({
success: false,
msg: "No token provided",
});
}
const userDbService = req.app.locals.userDbService;
const user = await userDbService.findByToken(extractToken(authHeader));
if (!user) {
return res.status(401).json({
success: false,
msg: "Invalid token",
});
}
// Extract UI settings from preferences, merge with defaults
const preferences = user.preferences || {};
const uiSettings = {
sidebarCollapsed: preferences.sidebarCollapsed ?? DEFAULT_UI_SETTINGS.sidebarCollapsed,
performanceDashboardTimeRange: preferences.performanceDashboardTimeRange ?? DEFAULT_UI_SETTINGS.performanceDashboardTimeRange,
};
res.json({
success: true,
data: uiSettings,
});
} catch (err: any) {
console.error("[UserController] GET /settings error:", err.message);
res.status(500).json({
success: false,
msg: "Failed to get settings",
});
}
});
/**
* PUT /user/settings
*
* Update user UI settings in preferences column.
* Supports partial updates - merges with existing preferences.
*/
router.put("/settings", async (req: Request, res: Response) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({
success: false,
msg: "No token provided",
});
}
const userDbService = req.app.locals.userDbService;
const user = await userDbService.findByToken(extractToken(authHeader));
if (!user) {
return res.status(401).json({
success: false,
msg: "Invalid token",
});
}
const { sidebarCollapsed, performanceDashboardTimeRange } = req.body;
// Build update object with only provided fields
const updates: Record<string, any> = {};
if (typeof sidebarCollapsed === 'boolean') {
updates.sidebarCollapsed = sidebarCollapsed;
}
if (performanceDashboardTimeRange !== undefined) {
updates.performanceDashboardTimeRange = performanceDashboardTimeRange;
}
// Merge with existing preferences
const currentPreferences = user.preferences || {};
const newPreferences = { ...currentPreferences, ...updates };
// Update in database - use pgPool for Postgres, mysqlPool for MySQL
const pgPool = req.app.locals.pgPool;
const mysqlPool = req.app.locals.mysqlPool;
if (pgPool) {
// PostgreSQL - use JSONB
await pgPool.query(
'UPDATE users SET preferences = $1, updated_at = NOW() WHERE id = $2',
[JSON.stringify(newPreferences), user.id]
);
} else if (mysqlPool) {
// MySQL - use JSON column
await mysqlPool.query(
'UPDATE user SET preferences = ?, updated_at = NOW() WHERE id = ?',
[JSON.stringify(newPreferences), user.id]
);
} else {
console.warn("[UserController] PUT /settings: No database pool available, settings not persisted");
}
// Return updated settings
const uiSettings = {
sidebarCollapsed: newPreferences.sidebarCollapsed ?? DEFAULT_UI_SETTINGS.sidebarCollapsed,
performanceDashboardTimeRange: newPreferences.performanceDashboardTimeRange ?? DEFAULT_UI_SETTINGS.performanceDashboardTimeRange,
};
res.json({
success: true,
data: uiSettings,
});
} catch (err: any) {
console.error("[UserController] PUT /settings error:", err.message);
res.status(500).json({
success: false,
msg: "Failed to update settings",
});
}
});
export default router;
@@ -63,6 +63,22 @@ const httpInstances = new Map<string, Map<string, HttpInstanceInfo>>();
// TTL for HTTP agents (remove if no heartbeat for this duration)
const HTTP_AGENT_TTL_MS = 60000; // 60 seconds
// Store the control emitter globally for agent status broadcasts
let globalControlEmitter: ControlEmitterInner | null = null;
// Track which teams have active subscriptions for agent status (team -> subscriber count)
const teamSubscriberCounts = new Map<string, number>();
// Helper to get teams with active subscribers
function getTeamsWithSubscribers(): string[] {
return Array.from(teamSubscriberCounts.entries())
.filter(([, count]) => count > 0)
.map(([teamId]) => teamId);
}
// Interval for periodic agent status broadcasts
let agentStatusInterval: ReturnType<typeof setInterval> | null = null;
/**
* Register or update an HTTP-only agent from heartbeat
* Called from control_service when processing heartbeat events
@@ -113,6 +129,9 @@ function registerHttpAgent(
console.log(
`[Aden Control] HTTP agent registered: ${agentName || instanceId.slice(0, 8)}... (team: ${teamKey})`
);
// Broadcast updated agent status to subscribers
broadcastAgentStatus(teamKey);
}
}
@@ -121,27 +140,131 @@ function registerHttpAgent(
*/
function cleanupStaleHttpAgents(): void {
const now = Date.now();
const teamsWithRemovedAgents: string[] = [];
for (const [teamId, instances] of httpInstances) {
let removed = false;
for (const [instanceId, info] of instances) {
if (now - info.lastHeartbeat.getTime() > HTTP_AGENT_TTL_MS) {
instances.delete(instanceId);
removed = true;
console.log(
`[Aden Control] HTTP agent expired: ${instanceId.slice(0, 8)}... (team: ${teamId})`
);
}
}
if (removed) {
teamsWithRemovedAgents.push(teamId);
}
// Clean up empty team maps
if (instances.size === 0) {
httpInstances.delete(teamId);
}
}
// Broadcast updated status to teams that had agents removed
for (const teamId of teamsWithRemovedAgents) {
broadcastAgentStatus(teamId);
}
}
// Run cleanup every 30 seconds
setInterval(cleanupStaleHttpAgents, 30000);
/**
* Get agent status for a team
*/
function getAgentStatusForTeam(teamId: string): {
type: string;
active: boolean;
count: number;
instances: Array<{
instance_id: string;
policy_id: string | null;
agent_name: string | null;
connected_at: string;
last_heartbeat: string;
connection_type: "websocket" | "http";
status?: string;
}>;
timestamp: string;
} {
const wsInstances = connectedInstances.get(teamId);
const httpInsts = httpInstances.get(teamId);
const instances: Array<{
instance_id: string;
policy_id: string | null;
agent_name: string | null;
connected_at: string;
last_heartbeat: string;
connection_type: "websocket" | "http";
status?: string;
}> = [];
// Add WebSocket-connected instances
if (wsInstances) {
for (const info of wsInstances.values()) {
instances.push({
instance_id: info.instanceId,
policy_id: info.policyId,
agent_name: null,
connected_at: info.connectedAt.toISOString(),
last_heartbeat: info.lastHeartbeat.toISOString(),
connection_type: "websocket",
});
}
}
// Add HTTP-only instances
if (httpInsts) {
for (const info of httpInsts.values()) {
instances.push({
instance_id: info.instanceId,
policy_id: info.policyId,
agent_name: info.agentName,
connected_at: info.firstSeen.toISOString(),
last_heartbeat: info.lastHeartbeat.toISOString(),
connection_type: "http",
status: info.status,
});
}
}
const count = instances.length;
return {
type: "agent-status",
active: count > 0,
count,
instances,
timestamp: new Date().toISOString(),
};
}
/**
* Broadcast agent status to all subscribed clients for a team
*/
function broadcastAgentStatus(teamId: string): void {
if (!globalControlEmitter) return;
const status = getAgentStatusForTeam(teamId);
const room = `team:${teamId}:llm-events`;
globalControlEmitter.to(room).emit("message", status);
}
/**
* Broadcast agent status to all teams with subscribers
*/
function broadcastAgentStatusToAllTeams(): void {
const teams = getTeamsWithSubscribers();
for (const teamId of teams) {
broadcastAgentStatus(teamId);
}
}
interface AdenSocket extends Socket {
user?: Record<string, unknown>;
teamId?: string;
@@ -194,6 +317,15 @@ function initAdenControlSockets(io: Server, rootEmitter: RedisEmitter): ControlE
// Create emitter for this namespace
const controlEmitter: ControlEmitterInner = rootEmitter.of("/v1/control/ws");
// Store globally for agent status broadcasts
globalControlEmitter = controlEmitter;
// Start periodic agent status broadcast (every 2 seconds)
if (agentStatusInterval) {
clearInterval(agentStatusInterval);
}
agentStatusInterval = setInterval(broadcastAgentStatusToAllTeams, 2000);
// Initialize LLM event batcher with emitter for real-time streaming
llmEventBatcher.setEmitter(controlEmitter as unknown as { to: (room: string) => { emit: (event: string, payload: unknown) => void } });
@@ -352,17 +484,33 @@ function initAdenControlSockets(io: Server, rootEmitter: RedisEmitter): ControlE
const room = `team:${teamId}:llm-events`;
socket.join(room);
console.log(`[Aden Control WS] Socket ${socket.id} subscribed to ${room}`);
// Track subscriber count for this team
const currentCount = teamSubscriberCounts.get(teamId!) || 0;
teamSubscriberCounts.set(teamId!, currentCount + 1);
socket.emit("message", {
type: "subscribed",
stream: "llm-events",
teamId: teamId,
});
// Send initial agent status
const status = getAgentStatusForTeam(teamId!);
socket.emit("message", status);
});
socket.on("unsubscribe-llm-events", () => {
const room = `team:${teamId}:llm-events`;
socket.leave(room);
console.log(`[Aden Control WS] Socket ${socket.id} unsubscribed from ${room}`);
// Decrement subscriber count
const currentCount = teamSubscriberCounts.get(teamId!) || 0;
if (currentCount > 0) {
teamSubscriberCounts.set(teamId!, currentCount - 1);
}
socket.emit("message", {
type: "unsubscribed",
stream: "llm-events",
+1
View File
@@ -31,6 +31,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"react-day-picker": "^9.13.0",
"lucide-react": "^0.562.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+1 -1
View File
@@ -30,7 +30,7 @@ export function App() {
>
<Route path="agents" element={<WorkersPanel />} />
<Route path="data" element={<DataPanel />} />
<Route path="analytics" element={<AnalyticsPanel />} />
<Route path="performance-dashboard" element={<AnalyticsPanel />} />
<Route path="cost-control" element={<CostControls />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
import { useControlSocket } from '@/hooks/useControlSocket'
import { useAgentControlStore } from '@/stores/agentControlStore'
import { useUserStore } from '@/stores/userStore'
import { useSidebarCollapsed } from '@/hooks/usePersistedSettings'
import { NotificationBell } from './shared/NotificationBell'
import {
DropdownMenu,
@@ -31,13 +32,18 @@ import {
PanelLeft,
Settings,
Sparkles,
HelpCircle,
ExternalLink,
FileText,
MessageCircle,
} from 'lucide-react'
import { SettingsModal } from '@/components/settings/SettingsModal'
import { HelpDialog } from './shared/HelpDialog'
const navItems = [
{ value: 'agents', label: 'Agents', path: '/agents', icon: Users },
{ value: 'data', label: 'Logs', path: '/data', icon: Database },
{ value: 'analytics', label: 'Performance Dashboard', path: '/analytics', icon: BarChart3 },
{ value: 'analytics', label: 'Performance Dashboard', path: '/performance-dashboard', icon: BarChart3 },
{ value: 'cost-control', label: 'Cost Control', path: '/cost-control', icon: DollarSign },
]
@@ -51,7 +57,7 @@ export function AgentControlLayout() {
const fullName = useUserStore((state) => state.fullName())
const navigate = useNavigate()
const location = useLocation()
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const { sidebarCollapsed, toggleSidebar } = useSidebarCollapsed()
// Settings modal controlled by URL hash
const settingsOpen = location.hash === '#settings'
@@ -61,14 +67,20 @@ export function AgentControlLayout() {
}
}
// Help dialog controlled by URL hash
const helpOpen = location.hash === '#help'
const handleHelpClose = (open: boolean) => {
if (!open) {
navigate(location.pathname, { replace: true })
}
}
// Connect socket on mount
useEffect(() => {
connect()
return () => disconnect()
}, [connect, disconnect])
const toggleSidebar = () => setSidebarCollapsed((prev) => !prev)
return (
<div className="h-screen flex bg-background overflow-hidden">
{/* Sidebar - full height */}
@@ -238,6 +250,31 @@ export function AgentControlLayout() {
</div>
<NotificationBell />
{/* Help dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<HelpCircle className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`${location.pathname}#help`)}>
<HelpCircle className="mr-2 h-4 w-4" />
Guide
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.open('https://docs.adenhq.com/', '_blank')}>
<FileText className="mr-2 h-4 w-4" />
Documentation
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.open('https://discord.gg/MXE49hrKDk', '_blank')}>
<MessageCircle className="mr-2 h-4 w-4" />
Discord
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
{/* Content area */}
@@ -249,6 +286,7 @@ export function AgentControlLayout() {
</div>
<SettingsModal open={settingsOpen} onOpenChange={handleSettingsClose} />
<HelpDialog open={helpOpen} onOpenChange={handleHelpClose} />
</div>
)
}
@@ -12,7 +12,9 @@ import { KpiCard } from './shared/KpiCard'
import { LiveIndicator } from './shared/LiveIndicator'
import { VegaLiteChart } from './charts/VegaLiteChart'
import { useAnalytics } from '@/hooks/queries/useAnalytics'
import { useAgentControlStore, type TimeRange } from '@/stores/agentControlStore'
import { useAgentControlStore } from '@/stores/agentControlStore'
import { usePersistedTimeRange } from '@/hooks/usePersistedSettings'
import type { TimeRange } from '@/types/settings'
import { transformAnalyticsData, type CostByModelData } from './charts/transformers'
import {
createCostTrendSpec,
@@ -23,11 +25,11 @@ import {
import type { RawJsonData, KPIValues } from '@/types/agentControl'
const timeRangeOptions: { value: TimeRange; label: string }[] = [
{ value: 'today', label: 'Today' },
{ value: 'week', label: 'Last Week' },
{ value: 'twoWeeks', label: 'Last 2 Weeks' },
{ value: 'month', label: 'Last Month' },
{ value: 'all', label: 'All Time' },
{ value: 'month', label: 'Last Month' },
{ value: 'twoWeeks', label: 'Last 2 Weeks' },
{ value: 'week', label: 'Last Week' },
{ value: 'today', label: 'Today' },
]
// Helper to safely extract KPI values from raw API response
@@ -77,8 +79,7 @@ function extractKpis(data: RawJsonData | undefined): KPIValues {
* Main analytics dashboard with KPIs and VegaLite charts.
*/
export function AnalyticsPanel() {
const timeRange = useAgentControlStore((state) => state.timeRange)
const setTimeRange = useAgentControlStore((state) => state.setTimeRange)
const { timeRange, setTimeRange } = usePersistedTimeRange()
const hasActiveAgents = useAgentControlStore((state) => state.eventsBuffer.length > 0)
const { data: analytics, isLoading } = useAnalytics()
@@ -41,6 +41,15 @@ function extractBudgets(data: RawJsonData | undefined): BudgetConfig[] {
return []
}
// Extract policyId from API response (uses first policy or 'default')
function extractPolicyId(data: RawJsonData | undefined): string | null {
if (!data) return null
if (data.policies && Array.isArray(data.policies) && data.policies.length > 0) {
return (data.policies[0] as { id?: string }).id || 'default'
}
return 'default'
}
/**
* Budget management panel with summary cards and budget list.
*/
@@ -57,12 +66,17 @@ export function CostControls() {
const { data: rawData, isLoading, error } = useBudgets()
// Parse budgets from API response
// Parse budgets and policyId from API response
const budgets = useMemo(
() => extractBudgets(rawData as RawJsonData | undefined),
[rawData]
)
const policyId = useMemo(
() => extractPolicyId(rawData as RawJsonData | undefined),
[rawData]
)
// Compute summary stats
const summary = useMemo(() => {
if (!budgets.length) return null
@@ -232,13 +246,6 @@ export function CostControls() {
) : filteredBudgets.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>No budgets found</p>
<Button
variant="link"
className="mt-2"
onClick={() => setAddDialogOpen(true)}
>
Create your first budget
</Button>
</div>
) : (
<div className="space-y-3">
@@ -253,13 +260,19 @@ export function CostControls() {
)}
{/* Add Budget Dialog */}
<AddBudgetDialog open={addDialogOpen} onOpenChange={setAddDialogOpen} />
<AddBudgetDialog
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
policyId={policyId}
/>
{/* Budget Detail Panel */}
<BudgetDetailPanel
budget={selectedBudget}
open={detailPanelOpen}
onOpenChange={setDetailPanelOpen}
policyId={policyId}
existingBudgets={budgets}
/>
</div>
)
@@ -8,7 +8,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import {
Table,
TableBody,
@@ -20,57 +19,104 @@ import {
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { LiveIndicator } from './shared/LiveIndicator'
import { useLogs } from '@/hooks/queries/useLogs'
import { DateRangePicker } from '@/components/ui/date-range-picker'
import { useLogs, useLogsAggregated } from '@/hooks/queries/useLogs'
import { useAgentControlStore } from '@/stores/agentControlStore'
import { cn } from '@/lib/utils'
import type { DateRange } from 'react-day-picker'
type ViewMode = 'metrics' | 'requests'
type ViewType = 'raw' | 'metrics' | 'model' | 'agent'
type LogType = 'llm_request' | 'tool_call' | 'error'
const dataTypeOptions = [
{ value: 'all', label: 'All Types' },
{ value: 'llm_request', label: 'LLM Requests' },
{ value: 'tool_call', label: 'Tool Calls' },
{ value: 'error', label: 'Errors' },
const viewOptions = [
{ value: 'raw', label: 'Raw Data' },
{ value: 'metrics', label: 'Metrics Summary' },
{ value: 'model', label: 'Model Usage' },
{ value: 'agent', label: 'Agent Activity' },
]
// Define log entry type
interface LogEntry {
id?: string
timestamp: string
type?: string
derived_type: LogType
derived_success: boolean
agent?: string
model?: string
success?: boolean
cost?: number
latency?: number
provider?: string
cost_total?: number
latency_ms?: number
finish_reason?: string
tool_call_count?: number
usage_input_tokens?: number
usage_output_tokens?: number
usage_total_tokens?: number
[key: string]: unknown
}
/**
* Logs viewer with filtering and export capabilities.
*/
interface AggregatedEntry {
model?: string
agent?: string
request_count: number
total_input_tokens: number
total_output_tokens: number
total_tokens: number
total_cost: number
avg_latency_ms: number
first_seen?: string
last_seen?: string
}
export function DataPanel() {
const [viewMode, setViewMode] = useState<ViewMode>('requests')
const [dataType, setDataType] = useState('all')
const [searchQuery, setSearchQuery] = useState('')
const [viewType, setViewType] = useState<ViewType>('raw')
const [expandedRow, setExpandedRow] = useState<string | null>(null)
// Default date range: last 7 days
const [dateRange, setDateRange] = useState<DateRange | undefined>(() => {
const end = new Date()
end.setHours(23, 59, 59, 999)
const start = new Date()
start.setDate(start.getDate() - 7)
start.setHours(0, 0, 0, 0)
return { from: start, to: end }
})
const hasActiveAgents = useAgentControlStore((state) => state.eventsBuffer.length > 0)
// Get date range (last 24 hours)
const endDate = useMemo(() => new Date().toISOString(), [])
// Convert date range to ISO strings for API
const startDate = useMemo(() => {
const d = new Date()
d.setHours(d.getHours() - 24)
return d.toISOString()
}, [])
return dateRange?.from?.toISOString() ?? new Date().toISOString()
}, [dateRange?.from])
const { data: logsData, isLoading, error, refetch } = useLogs(startDate, endDate, 500)
const endDate = useMemo(() => {
return dateRange?.to?.toISOString() ?? new Date().toISOString()
}, [dateRange?.to])
// Fetch raw logs for 'raw' and 'metrics' views
const {
data: logsData,
isLoading: logsLoading,
error: logsError,
refetch: refetchLogs,
} = useLogs(startDate, endDate, 500, viewType === 'raw' || viewType === 'metrics')
// Fetch aggregated data for 'model' view
const {
data: modelData,
isLoading: modelLoading,
error: modelError,
refetch: refetchModel,
} = useLogsAggregated(startDate, endDate, 'model', 100, viewType === 'model')
// Fetch aggregated data for 'agent' view
const {
data: agentData,
isLoading: agentLoading,
error: agentError,
refetch: refetchAgent,
} = useLogsAggregated(startDate, endDate, 'agent', 100, viewType === 'agent')
// Parse logs from API response
const logs = useMemo((): LogEntry[] => {
if (!logsData) return []
// Handle different response shapes
const rawLogs = (logsData as { rows?: unknown[] }).rows ||
(logsData as { logs?: unknown[] }).logs ||
(Array.isArray(logsData) ? logsData : [])
@@ -80,42 +126,103 @@ export function DataPanel() {
}))
}, [logsData])
// Filter logs
const filteredLogs = useMemo(() => {
return logs.filter((log) => {
if (dataType !== 'all' && log.type !== dataType) return false
if (searchQuery) {
const query = searchQuery.toLowerCase()
const searchable = JSON.stringify(log).toLowerCase()
return searchable.includes(query)
}
return true
})
}, [logs, dataType, searchQuery])
// Parse aggregated data
const modelAggregations = useMemo((): AggregatedEntry[] => {
if (!modelData) return []
return (modelData as { aggregations?: AggregatedEntry[] }).aggregations || []
}, [modelData])
const agentAggregations = useMemo((): AggregatedEntry[] => {
if (!agentData) return []
return (agentData as { aggregations?: AggregatedEntry[] }).aggregations || []
}, [agentData])
// Determine loading/error state based on current view
const isLoading = viewType === 'raw' || viewType === 'metrics'
? logsLoading
: viewType === 'model'
? modelLoading
: agentLoading
const error = viewType === 'raw' || viewType === 'metrics'
? logsError
: viewType === 'model'
? modelError
: agentError
const refetch = viewType === 'raw' || viewType === 'metrics'
? refetchLogs
: viewType === 'model'
? refetchModel
: refetchAgent
const handleExport = () => {
if (!filteredLogs.length) return
let csv = ''
const csv = [
['Timestamp', 'Type', 'Agent', 'Model', 'Status', 'Cost', 'Latency'].join(','),
...filteredLogs.map((log) =>
[
log.timestamp,
log.type || '-',
log.agent || '-',
log.model || '-',
log.success !== undefined ? (log.success ? 'Success' : 'Failed') : '-',
log.cost?.toFixed(4) || '-',
log.latency ? `${log.latency}ms` : '-',
].join(',')
),
].join('\n')
if (viewType === 'raw') {
if (!logs.length) return
csv = [
['Timestamp', 'Provider', 'Model', 'Agent', 'Tokens', 'Cost', 'Latency'].join(','),
...logs.map((log) =>
[
log.timestamp,
log.provider || '-',
log.model || '-',
log.agent || '-',
log.usage_total_tokens ?? '-',
log.cost_total ? Number(log.cost_total).toFixed(6) : '-',
log.latency_ms ? `${Math.round(Number(log.latency_ms))}ms` : '-',
].join(',')
),
].join('\n')
} else if (viewType === 'metrics') {
const successCount = logs.filter((l) => l.derived_success).length
const totalCost = logs.reduce((sum, l) => sum + (Number(l.cost_total) || 0), 0)
csv = [
['Metric', 'Value'].join(','),
['Total Requests', logs.length].join(','),
['Success Rate', `${((successCount / Math.max(logs.length, 1)) * 100).toFixed(1)}%`].join(','),
['Total Cost', `$${totalCost.toFixed(2)}`].join(','),
].join('\n')
} else if (viewType === 'model') {
if (!modelAggregations.length) return
csv = [
['Model', 'Requests', 'Input Tokens', 'Output Tokens', 'Total Cost', 'Avg Latency'].join(','),
...modelAggregations.map((row) =>
[
row.model || '-',
row.request_count,
row.total_input_tokens,
row.total_output_tokens,
`$${row.total_cost.toFixed(4)}`,
`${Math.round(row.avg_latency_ms)}ms`,
].join(',')
),
].join('\n')
} else if (viewType === 'agent') {
if (!agentAggregations.length) return
csv = [
['Agent', 'Requests', 'Input Tokens', 'Output Tokens', 'Total Cost', 'Avg Latency'].join(','),
...agentAggregations.map((row) =>
[
row.agent || '-',
row.request_count,
row.total_input_tokens,
row.total_output_tokens,
`$${row.total_cost.toFixed(4)}`,
`${Math.round(row.avg_latency_ms)}ms`,
].join(',')
),
].join('\n')
}
if (!csv) return
const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `agent-logs-${new Date().toISOString().split('T')[0]}.csv`
a.download = `logs-${viewType}-${new Date().toISOString().split('T')[0]}.csv`
a.click()
URL.revokeObjectURL(url)
}
@@ -130,10 +237,39 @@ export function DataPanel() {
})
}
const getCardTitle = () => {
switch (viewType) {
case 'raw':
return 'Raw Data'
case 'metrics':
return 'Metrics Summary'
case 'model':
return 'Model Usage'
case 'agent':
return 'Agent Activity'
default:
return 'Data'
}
}
const hasData = () => {
switch (viewType) {
case 'raw':
case 'metrics':
return logs.length > 0
case 'model':
return modelAggregations.length > 0
case 'agent':
return agentAggregations.length > 0
default:
return false
}
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-red-500 mb-4">Failed to load logs</p>
<p className="text-red-500 mb-4">Failed to load data</p>
<Button variant="outline" onClick={() => refetch()}>
Retry
</Button>
@@ -146,48 +282,25 @@ export function DataPanel() {
{/* Controls */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* View Mode Toggle */}
<div className="flex rounded-lg border p-1">
<Button
variant={viewMode === 'requests' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('requests')}
>
Requests
</Button>
<Button
variant={viewMode === 'metrics' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('metrics')}
>
Metrics
</Button>
</div>
<DateRangePicker value={dateRange} onChange={setDateRange} />
<Select value={dataType} onValueChange={setDataType}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Data type" />
<Select value={viewType} onValueChange={(v) => setViewType(v as ViewType)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select view" />
</SelectTrigger>
<SelectContent>
{dataTypeOptions.map((option) => (
{viewOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="Search logs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-[200px]"
/>
</div>
<div className="flex items-center gap-3">
<LiveIndicator isLive={hasActiveAgents} />
<Button variant="outline" onClick={handleExport} disabled={!filteredLogs.length}>
<Button variant="outline" onClick={handleExport} disabled={!hasData()}>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 mr-2"
@@ -208,9 +321,7 @@ export function DataPanel() {
{/* Data Table */}
<Card>
<CardHeader className="pb-3">
<CardTitle>
{viewMode === 'requests' ? 'Request Logs' : 'Metrics Summary'}
</CardTitle>
<CardTitle>{getCardTitle()}</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
@@ -219,25 +330,25 @@ export function DataPanel() {
<Skeleton key={i} className="h-12" />
))}
</div>
) : filteredLogs.length === 0 ? (
) : !hasData() ? (
<div className="text-center py-12 text-muted-foreground">
No logs found
No data found for the selected date range
</div>
) : viewMode === 'requests' ? (
) : viewType === 'raw' ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Timestamp</TableHead>
<TableHead>Type</TableHead>
<TableHead>Agent</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Model</TableHead>
<TableHead>Status</TableHead>
<TableHead>Agent</TableHead>
<TableHead className="text-right">Tokens</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Latency</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLogs.map((log) => (
{logs.map((log) => (
<>
<TableRow
key={log.id}
@@ -251,37 +362,23 @@ export function DataPanel() {
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{log.type || 'request'}
{log.provider || '-'}
</Badge>
</TableCell>
<TableCell className="truncate max-w-[150px]">
{log.agent || '-'}
</TableCell>
<TableCell className="truncate max-w-[150px]">
{log.model || '-'}
</TableCell>
<TableCell>
{log.success !== undefined ? (
<Badge
variant="secondary"
className={cn(
'text-xs',
log.success
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
)}
>
{log.success ? 'Success' : 'Failed'}
</Badge>
) : (
'-'
)}
<TableCell className="truncate max-w-[150px]">
{log.agent || '-'}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{log.cost ? `$${log.cost.toFixed(4)}` : '-'}
{log.usage_total_tokens ?? '-'}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{log.latency ? `${log.latency}ms` : '-'}
{log.cost_total ? `$${Number(log.cost_total).toFixed(6)}` : '-'}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{log.latency_ms ? `${Math.round(Number(log.latency_ms))}ms` : '-'}
</TableCell>
</TableRow>
{expandedRow === log.id && (
@@ -297,47 +394,126 @@ export function DataPanel() {
))}
</TableBody>
</Table>
) : (
// Metrics view - simplified
) : viewType === 'metrics' ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Metric</TableHead>
<TableHead>Value</TableHead>
<TableHead>Period</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">Total Requests</TableCell>
<TableCell>{filteredLogs.length}</TableCell>
<TableCell>Last 24h</TableCell>
<TableCell>{logs.length}</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Success Rate</TableCell>
<TableCell>
{(
(filteredLogs.filter((l) => l.success !== false).length /
Math.max(filteredLogs.length, 1)) *
(logs.filter((l) => l.derived_success).length /
Math.max(logs.length, 1)) *
100
).toFixed(1)}
%
</TableCell>
<TableCell>Last 24h</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Total Cost</TableCell>
<TableCell>
$
{filteredLogs
.reduce((sum, l) => sum + (l.cost || 0), 0)
{logs
.reduce((sum, l) => sum + (Number(l.cost_total) || 0), 0)
.toFixed(2)}
</TableCell>
<TableCell>Last 24h</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Total Tokens</TableCell>
<TableCell>
{logs
.reduce((sum, l) => sum + (Number(l.usage_total_tokens) || 0), 0)
.toLocaleString()}
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Avg Latency</TableCell>
<TableCell>
{Math.round(
logs.reduce((sum, l) => sum + (Number(l.latency_ms) || 0), 0) /
Math.max(logs.length, 1)
)}
ms
</TableCell>
</TableRow>
</TableBody>
</Table>
)}
) : viewType === 'model' ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead className="text-right">Input Tokens</TableHead>
<TableHead className="text-right">Output Tokens</TableHead>
<TableHead className="text-right">Total Cost</TableHead>
<TableHead className="text-right">Avg Latency</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{modelAggregations.map((row, idx) => (
<TableRow key={row.model || idx}>
<TableCell className="font-medium">{row.model || '-'}</TableCell>
<TableCell className="text-right">{row.request_count}</TableCell>
<TableCell className="text-right font-mono text-xs">
{row.total_input_tokens.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{row.total_output_tokens.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono text-xs">
${row.total_cost.toFixed(4)}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{Math.round(row.avg_latency_ms)}ms
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : viewType === 'agent' ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Agent</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead className="text-right">Input Tokens</TableHead>
<TableHead className="text-right">Output Tokens</TableHead>
<TableHead className="text-right">Total Cost</TableHead>
<TableHead className="text-right">Avg Latency</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{agentAggregations.map((row, idx) => (
<TableRow key={row.agent || idx}>
<TableCell className="font-medium">{row.agent || '(no agent)'}</TableCell>
<TableCell className="text-right">{row.request_count}</TableCell>
<TableCell className="text-right font-mono text-xs">
{row.total_input_tokens.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{row.total_output_tokens.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono text-xs">
${row.total_cost.toFixed(4)}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{Math.round(row.avg_latency_ms)}ms
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : null}
</CardContent>
</Card>
</div>
@@ -16,11 +16,14 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { BudgetType, LimitAction } from '@/types/agentControl'
import { useCreateBudget } from '@/hooks/queries/useBudgets'
import { useNotificationStore } from '@/stores/notificationStore'
import type { BudgetType } from '@/types/agentControl'
interface AddBudgetDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
policyId: string | null
}
const budgetTypes: { value: BudgetType; label: string }[] = [
@@ -31,29 +34,27 @@ const budgetTypes: { value: BudgetType; label: string }[] = [
{ value: 'tag', label: 'Tag' },
]
const limitActions: { value: LimitAction; label: string; description: string }[] = [
{ value: 'notify', label: 'Notify Only', description: 'Send alerts but continue' },
{ value: 'throttle', label: 'Throttle', description: 'Reduce request rate' },
{ value: 'degrade', label: 'Degrade', description: 'Switch to cheaper model' },
{ value: 'kill', label: 'Kill', description: 'Stop all requests' },
]
/**
* Dialog for creating a new budget configuration.
*/
export function AddBudgetDialog({ open, onOpenChange }: AddBudgetDialogProps) {
export function AddBudgetDialog({ open, onOpenChange, policyId }: AddBudgetDialogProps) {
const [name, setName] = useState('')
const [type, setType] = useState<BudgetType>('agent')
const [limit, setLimit] = useState('100')
const [limitAction, setLimitAction] = useState<LimitAction>('notify')
const [alertThreshold, setAlertThreshold] = useState('80')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const createBudget = useCreateBudget()
const addNotification = useNotificationStore((state) => state.addNotification)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!policyId) {
setError('No policy available. Please try again later.')
return
}
if (!name.trim()) {
setError('Name is required')
return
@@ -65,24 +66,41 @@ export function AddBudgetDialog({ open, onOpenChange }: AddBudgetDialogProps) {
return
}
setIsSubmitting(true)
try {
// TODO: Integrate with actual API when policyId is available
// For now, just close the dialog
console.log('Creating budget:', {
name,
type,
limit: limitValue,
limitAction,
alertThreshold: parseFloat(alertThreshold) || undefined,
await createBudget.mutateAsync({
policyId,
budget: {
id: name.trim().toLowerCase().replace(/\s+/g, '-'),
name: name.trim(),
type,
limit: limitValue,
spent: 0,
limitAction: 'throttle',
throttleRate: 1.0,
alerts: [
{ threshold: 80, enabled: true },
{ threshold: 100, enabled: true },
],
notifications: {
inApp: true,
email: false,
emailRecipients: [],
webhook: false,
},
},
})
addNotification({
type: 'success',
title: 'Budget created',
message: `"${name.trim()}" has been created successfully.`,
})
handleClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create budget')
} finally {
setIsSubmitting(false)
addNotification({
type: 'error',
title: 'Creation failed',
message: err instanceof Error ? err.message : 'Failed to create budget',
})
}
}
@@ -90,8 +108,6 @@ export function AddBudgetDialog({ open, onOpenChange }: AddBudgetDialogProps) {
setName('')
setType('agent')
setLimit('100')
setLimitAction('notify')
setAlertThreshold('80')
setError(null)
onOpenChange(false)
}
@@ -153,53 +169,12 @@ export function AddBudgetDialog({ open, onOpenChange }: AddBudgetDialogProps) {
/>
</div>
{/* Limit Action */}
<div className="space-y-2">
<label className="text-sm font-medium">Action at Limit</label>
<Select
value={limitAction}
onValueChange={(value) => setLimitAction(value as LimitAction)}
>
<SelectTrigger>
<SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent>
{limitActions.map((action) => (
<SelectItem key={action.value} value={action.value}>
<div className="flex flex-col">
<span>{action.label}</span>
<span className="text-xs text-muted-foreground">
{action.description}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Alert Threshold */}
<div className="space-y-2">
<label className="text-sm font-medium">Alert Threshold (%)</label>
<Input
type="number"
min="0"
max="100"
value={alertThreshold}
onChange={(e) => setAlertThreshold(e.target.value)}
placeholder="80"
/>
<p className="text-xs text-muted-foreground">
Receive alerts when spending reaches this percentage
</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Budget'}
<Button type="submit" disabled={createBudget.isPending}>
{createBudget.isPending ? 'Creating...' : 'Create Budget'}
</Button>
</DialogFooter>
</form>
@@ -33,12 +33,16 @@ import {
Bell,
Mail,
} from 'lucide-react'
import { useUpdateBudget, useDeleteBudget } from '@/hooks/queries/useBudgets'
import { useNotificationStore } from '@/stores/notificationStore'
import type { BudgetConfig, BudgetType, LimitAction } from '@/types/agentControl'
interface BudgetDetailPanelProps {
budget: BudgetConfig | null
open: boolean
onOpenChange: (open: boolean) => void
policyId: string | null
existingBudgets: BudgetConfig[]
}
const typeIcons: Record<BudgetType, React.ElementType> = {
@@ -58,9 +62,7 @@ const typeColors: Record<BudgetType, string> = {
}
const limitActions: { value: LimitAction; label: string; description: string }[] = [
{ value: 'notify', label: 'Notify Only', description: 'Send alerts but allow requests to continue' },
{ value: 'throttle', label: 'Throttle', description: 'Rate limit requests when budget is exceeded' },
{ value: 'degrade', label: 'Degrade', description: 'Switch to a cheaper model' },
{ value: 'kill', label: 'Block', description: 'Stop all requests when budget is exceeded' },
]
@@ -71,24 +73,31 @@ export function BudgetDetailPanel({
budget,
open,
onOpenChange,
policyId,
existingBudgets,
}: BudgetDetailPanelProps) {
// Local state for editing
const [limit, setLimit] = useState('')
const [limitAction, setLimitAction] = useState<LimitAction>('notify')
const [limitAction, setLimitAction] = useState<LimitAction>('throttle')
const [throttleRate, setThrottleRate] = useState('1.0')
const [alerts, setAlerts] = useState<{ threshold: number; enabled: boolean }[]>([])
const [newThreshold, setNewThreshold] = useState('')
const [emailEnabled, setEmailEnabled] = useState(false)
const [emailRecipients, setEmailRecipients] = useState<string[]>([])
const [newEmail, setNewEmail] = useState('')
const [inAppEnabled, setInAppEnabled] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const updateBudget = useUpdateBudget()
const deleteBudgetMutation = useDeleteBudget()
const addNotification = useNotificationStore((state) => state.addNotification)
// Reset form when budget changes
useEffect(() => {
if (budget) {
setLimit(budget.limit.toString())
setLimitAction(budget.limitAction)
setThrottleRate(budget.throttleRate?.toString() ?? '1.0')
setAlerts([...budget.alerts])
setEmailEnabled(budget.notifications.email)
setEmailRecipients([...budget.notifications.emailRecipients])
@@ -141,37 +150,65 @@ export function BudgetDetailPanel({
}
const handleSubmit = async () => {
setIsSubmitting(true)
if (!policyId || !budget) return
try {
// TODO: Integrate with actual API
console.log('Updating budget:', {
id: budget.id,
limit: parseFloat(limit),
limitAction,
alerts,
notifications: {
inApp: inAppEnabled,
email: emailEnabled,
emailRecipients,
webhook: budget.notifications.webhook,
await updateBudget.mutateAsync({
policyId,
budgetId: budget.id,
updates: {
limit: parseFloat(limit),
limitAction,
throttleRate: limitAction === 'throttle' ? parseFloat(throttleRate) : undefined,
alerts,
notifications: {
inApp: inAppEnabled,
email: emailEnabled,
emailRecipients,
webhook: budget.notifications.webhook,
},
},
existingBudgets,
})
addNotification({
type: 'success',
title: 'Budget updated',
message: `"${budget.name}" has been updated successfully.`,
})
onOpenChange(false)
} catch (error) {
console.error('Failed to update budget:', error)
} finally {
setIsSubmitting(false)
addNotification({
type: 'error',
title: 'Update failed',
message: 'Failed to update budget. Please try again.',
})
}
}
const handleDelete = async () => {
if (!policyId || !budget) return
if (confirm(`Are you sure you want to delete "${budget.name}"? This action cannot be undone.`)) {
try {
// TODO: Integrate with actual API
console.log('Deleting budget:', budget.id)
await deleteBudgetMutation.mutateAsync({
policyId,
budgetId: budget.id,
existingBudgets,
})
addNotification({
type: 'success',
title: 'Budget deleted',
message: `"${budget.name}" has been deleted.`,
})
onOpenChange(false)
} catch (error) {
console.error('Failed to delete budget:', error)
addNotification({
type: 'error',
title: 'Delete failed',
message: 'Failed to delete budget. Please try again.',
})
}
}
}
@@ -290,6 +327,29 @@ export function BudgetDetailPanel({
<p className="text-xs text-muted-foreground">
{limitActions.find(a => a.value === limitAction)?.description}
</p>
{/* Throttle Rate Config - shown when throttle is selected */}
{limitAction === 'throttle' && (
<div className="mt-3 p-3 rounded-md bg-amber-50 border border-amber-200 space-y-2">
<Label htmlFor="throttleRate" className="text-sm font-medium">
Throttle Limit (req/sec)
</Label>
<Input
id="throttleRate"
type="number"
min="0.1"
step="0.1"
value={throttleRate}
onChange={(e) => {
setThrottleRate(e.target.value)
handleChange()
}}
/>
<p className="text-xs text-muted-foreground">
Maximum requests per second when budget limit is reached
</p>
</div>
)}
</div>
<Separator />
@@ -462,16 +522,17 @@ export function BudgetDetailPanel({
variant="destructive"
size="sm"
onClick={handleDelete}
disabled={deleteBudgetMutation.isPending}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
{deleteBudgetMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
<div className="flex-1" />
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleSubmit} disabled={isSubmitting || !isDirty}>
{isSubmitting ? 'Saving...' : 'Save Changes'}
<Button size="sm" onClick={handleSubmit} disabled={updateBudget.isPending || !isDirty}>
{updateBudget.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</SheetFooter>
</SheetContent>
@@ -85,10 +85,14 @@ export function CostByModelChart({
layout="vertical"
align="right"
verticalAlign="middle"
wrapperStyle={{
maxWidth: '45%',
overflow: 'hidden',
}}
formatter={(value, entry) => {
const item = data.find((d) => d.name === value)
return (
<span className="text-sm">
<span className="text-sm block truncate max-w-[120px]" title={String(value)}>
{value}{' '}
<span className="text-muted-foreground">
({item ? formatPercent(item.cost / totalCost) : ''})
@@ -1,5 +1,6 @@
import { useRef, useEffect, useCallback } from 'react'
import { useRef, useEffect, useState } from 'react'
import vegaEmbed, { type Result, type VisualizationSpec, type EmbedOptions } from 'vega-embed'
import { cn } from '@/lib/utils'
interface VegaLiteChartProps {
spec: VisualizationSpec
@@ -9,44 +10,69 @@ interface VegaLiteChartProps {
/**
* React wrapper component for VegaLite charts using vega-embed.
* Uses ResizeObserver to ensure container has valid dimensions before rendering.
* Handles mounting, updating, and cleanup of Vega views.
*/
export function VegaLiteChart({ spec, className, options }: VegaLiteChartProps) {
const containerRef = useRef<HTMLDivElement>(null)
const vegaResultRef = useRef<Result | null>(null)
const [isReady, setIsReady] = useState(false)
const renderChart = useCallback(async () => {
if (!containerRef.current || !spec) return
// Cleanup previous render to prevent memory leaks
if (vegaResultRef.current) {
vegaResultRef.current.finalize()
vegaResultRef.current = null
}
try {
const result = await vegaEmbed(containerRef.current, spec, {
actions: false,
tooltip: { theme: 'dark' },
...options,
})
vegaResultRef.current = result
} catch (error) {
console.error('Failed to render VegaLite chart:', error)
}
}, [spec, options])
// Wait for container to be ready with valid dimensions
useEffect(() => {
if (!containerRef.current) return
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentRect.width > 0) {
setIsReady(true)
}
}
})
resizeObserver.observe(containerRef.current)
// Check initial dimensions
if (containerRef.current.clientWidth > 0) {
setIsReady(true)
}
return () => resizeObserver.disconnect()
}, [])
// Render chart when ready
useEffect(() => {
if (!containerRef.current || !spec || !isReady) return
const renderChart = async () => {
// Cleanup previous render to prevent memory leaks
if (vegaResultRef.current) {
vegaResultRef.current.finalize()
vegaResultRef.current = null
}
try {
const result = await vegaEmbed(containerRef.current!, spec, {
actions: false,
tooltip: { theme: 'dark' },
...options,
})
vegaResultRef.current = result
} catch (error) {
console.error('Failed to render VegaLite chart:', error)
}
}
renderChart()
return () => {
// Cleanup on unmount
// Cleanup on unmount or before re-render
if (vegaResultRef.current) {
vegaResultRef.current.finalize()
vegaResultRef.current = null
}
}
}, [renderChart])
}, [spec, options, isReady])
return <div ref={containerRef} className={className} />
return <div ref={containerRef} className={cn("w-full", className)} />
}
@@ -204,6 +204,12 @@ function transformLatencyDistribution(
}
})
// Only return data if there are actual counts
const totalCount = Object.values(aggregated).reduce((sum, count) => sum + count, 0)
if (totalCount === 0) {
return []
}
return Object.entries(aggregated).map(([range, count]) => ({ range, count }))
}
@@ -0,0 +1,331 @@
import { useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import {
KeyRound,
Code,
BarChart3,
AlertTriangle,
BookOpen,
Check,
Copy,
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface HelpDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
const steps = [
{
title: 'Get Your API Token',
description: 'Generate an API token to authenticate SDK requests and start tracking.',
icon: KeyRound,
},
{
title: 'Complete SDK Quickstart',
description: 'Follow the SDK Quickstart to install, configure, and instrument your code.',
icon: Code,
},
{
title: 'Verify Integration',
description: 'Confirm your setup is working and start monitoring your LLM usage.',
icon: BarChart3,
},
]
const envContent = `ADEN_API_KEY=your-api-token
ADEN_API_URL=https://kube.acho.io`
export function HelpDialog({ open, onOpenChange }: HelpDialogProps) {
const [currentStep, setCurrentStep] = useState(0)
const [copied, setCopied] = useState(false)
const navigate = useNavigate()
const location = useLocation()
const isFirstStep = currentStep === 0
const isLastStep = currentStep === steps.length - 1
const CurrentIcon = steps[currentStep].icon
const goBack = () => {
if (!isFirstStep) {
setCurrentStep((prev) => prev - 1)
}
}
const goNext = () => {
if (!isLastStep) {
setCurrentStep((prev) => prev + 1)
}
}
const finish = () => {
onOpenChange(false)
setCurrentStep(0)
navigate(`${location.pathname}#settings/developers`)
}
const copyEnvToClipboard = async () => {
try {
await navigator.clipboard.writeText(envContent)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
console.error('Failed to copy')
}
}
const handleOpenChange = (open: boolean) => {
if (!open) {
setCurrentStep(0)
}
onOpenChange(open)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-[520px] p-0 gap-0 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 p-6 pb-2">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<CurrentIcon className="h-5 w-5 text-primary" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
Step {currentStep + 1} of {steps.length}
</span>
<DialogTitle className="text-lg font-semibold">
{steps[currentStep].title}
</DialogTitle>
</div>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto px-6 h-[420px]">
<DialogDescription className="sr-only">
SDK onboarding walkthrough
</DialogDescription>
<p className="text-sm text-muted-foreground mb-4">
{steps[currentStep].description}
</p>
{/* Step 1: Get Your API Token */}
{currentStep === 0 && (
<div className="flex flex-col gap-4">
{/* Warning box */}
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<span>
Keep your API token secure. Never commit it to version control or expose it in client-side code.
</span>
</div>
{/* Numbered steps */}
<div className="flex flex-col gap-3">
<NumberedStep
number={1}
title="Navigate to Settings → Developers"
subtitle="Find the API Keys section in your dashboard"
/>
<NumberedStep
number={2}
title="Generate a new API token"
subtitle='Click "Generate New" and give it a descriptive name'
/>
<NumberedStep
number={3}
title="Copy and store securely"
subtitle="The token is only shown once—save it immediately"
/>
</div>
{/* Code block */}
<div className="bg-slate-50 border border-border rounded-lg p-4">
<p className="text-sm font-medium mb-2">Add to your .env file:</p>
<div className="relative bg-white border border-border rounded-md p-3">
<pre className="text-xs font-mono whitespace-pre-wrap break-all pr-8">
{envContent}
</pre>
<button
onClick={copyEnvToClipboard}
className="absolute top-2 right-2 w-7 h-7 border border-border rounded flex items-center justify-center bg-white hover:border-primary hover:text-primary transition-colors"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
</div>
)}
{/* Step 2: Complete SDK Quickstart */}
{currentStep === 1 && (
<div className="flex flex-col gap-4">
{/* Info box */}
<div className="flex items-center gap-3 p-4 bg-primary/5 border border-primary/20 rounded-lg">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<BookOpen className="h-4 w-4 text-primary" />
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">SDK Quickstart Guide</p>
<p className="text-xs text-muted-foreground">
Navigate to <strong className="text-foreground">Settings Developers SDK Quickstart</strong> for complete setup instructions.
</p>
</div>
</div>
{/* Checklist */}
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">The quickstart covers:</p>
<ul className="flex flex-col gap-2">
<ChecklistItem text="Package installation & initialization" />
<ChecklistItem text="Instrumenting LLM calls" />
<ChecklistItem text="Tagging for cost allocation" />
<ChecklistItem text="Streaming support" />
</ul>
</div>
{/* Tip box */}
<div className="text-xs text-blue-600 bg-blue-50 border border-blue-200 rounded-lg p-3">
<strong>Tip:</strong> The SDK Quickstart includes copy-paste code snippets tailored to your account.
</div>
</div>
)}
{/* Step 3: Verify Integration */}
{currentStep === 2 && (
<div className="flex flex-col gap-4">
{/* Numbered steps */}
<div className="flex flex-col gap-3">
<NumberedStep
number={1}
title="Make a test request"
subtitle="Run the test code from the SDK Quickstart to send your first tracked request."
/>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0 text-xs font-semibold text-primary">
2
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Check your dashboard</p>
<p className="text-xs text-muted-foreground">
Within 30 seconds, you should see data appear in:
</p>
<ul className="flex flex-col gap-2 mt-2">
<li className="flex items-start gap-2 text-sm">
<Check className="h-4 w-4 text-green-500 flex-shrink-0 mt-0.5" />
<span><strong>Analytics tab</strong> Request counts and cost trends</span>
</li>
<li className="flex items-start gap-2 text-sm">
<Check className="h-4 w-4 text-green-500 flex-shrink-0 mt-0.5" />
<span><strong>Data tab</strong> Detailed request logs</span>
</li>
</ul>
</div>
</div>
</div>
{/* Warning/troubleshooting box */}
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div className="flex flex-col gap-1">
<p className="font-semibold">Not seeing data?</p>
<ul className="text-xs space-y-0.5">
<li>Verify your API key is correct in .env</li>
<li>Ensure the SDK is initialized before LLM calls</li>
<li>Check for network/firewall issues</li>
<li>Review the troubleshooting section in Documentation</li>
</ul>
</div>
</div>
{/* Help text */}
<p className="text-xs text-muted-foreground pt-3 border-t">
Need more help? Return to <strong className="text-foreground">Settings Developers Documentation</strong> for detailed troubleshooting.
</p>
</div>
)}
</div>
{/* Progress Dots */}
<div className="flex items-center justify-center gap-2 py-4">
{steps.map((_, index) => (
<button
key={index}
onClick={() => setCurrentStep(index)}
className={cn(
'w-2 h-2 rounded-full transition-colors',
index === currentStep
? 'bg-primary'
: 'bg-muted-foreground/30 hover:bg-muted-foreground/50'
)}
/>
))}
</div>
{/* Footer */}
<div className="flex justify-between p-6 pt-0">
{!isFirstStep ? (
<Button variant="outline" onClick={goBack}>
<ChevronLeft className="h-4 w-4 mr-1" />
Back
</Button>
) : (
<div />
)}
{!isLastStep ? (
<Button onClick={goNext}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<Button onClick={finish}>Get Started</Button>
)}
</div>
</DialogContent>
</Dialog>
)
}
function NumberedStep({
number,
title,
subtitle,
}: {
number: number
title: string
subtitle: string
}) {
return (
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0 text-xs font-semibold text-primary">
{number}
</div>
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">{title}</p>
<p className="text-xs text-muted-foreground">{subtitle}</p>
</div>
</div>
)
}
function ChecklistItem({ text }: { text: string }) {
return (
<li className="flex items-start gap-2 text-sm">
<Check className="h-4 w-4 text-green-500 flex-shrink-0 mt-0.5" />
<span>{text}</span>
</li>
)
}
+70
View File
@@ -0,0 +1,70 @@
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row gap-4",
month: "flex flex-col gap-4",
month_caption: "flex justify-center pt-1 relative items-center h-7",
caption_label: "text-sm font-medium",
nav: "flex items-center gap-1",
button_previous: cn(
buttonVariants({ variant: "outline" }),
"absolute left-1 h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
button_next: cn(
buttonVariants({ variant: "outline" }),
"absolute right-1 h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
month_grid: "w-full border-collapse",
weekdays: "flex",
weekday:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem] flex items-center justify-center",
week: "flex w-full mt-2",
day: "h-9 w-9 text-center text-sm p-0 relative flex items-center justify-center",
day_button: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100 hover:bg-accent hover:text-accent-foreground"
),
range_end: "day-range-end",
selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground rounded-md",
today: "bg-accent text-accent-foreground rounded-md",
outside:
"text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
disabled: "text-muted-foreground opacity-50",
range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
hidden: "invisible",
...classNames,
}}
components={{
Chevron: ({ orientation }) => {
if (orientation === "left") {
return <ChevronLeft className="h-4 w-4" />
}
return <ChevronRight className="h-4 w-4" />
},
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }
@@ -0,0 +1,68 @@
import * as React from "react"
import { format } from "date-fns"
import { Calendar as CalendarIcon } from "lucide-react"
import type { DateRange } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface DateRangePickerProps {
value?: DateRange
onChange?: (range: DateRange | undefined) => void
className?: string
}
export function DateRangePicker({
value,
onChange,
className,
}: DateRangePickerProps) {
const [open, setOpen] = React.useState(false)
return (
<div className={cn("grid gap-2", className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id="date"
variant="outline"
className={cn(
"w-[280px] justify-start text-left font-normal",
!value && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value?.from ? (
value.to ? (
<>
{format(value.from, "LLL dd, y")} -{" "}
{format(value.to, "LLL dd, y")}
</>
) : (
format(value.from, "LLL dd, y")
)
) : (
<span>Pick a date range</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
initialFocus
mode="range"
defaultMonth={value?.from}
selected={value}
onSelect={onChange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</div>
)
}
+2 -2
View File
@@ -4,7 +4,7 @@ import {
getLogsAggregated,
getMetricsSummary,
} from '@/services/agentControlApi'
import { useAgentControlStore } from '@/stores/agentControlStore'
import { useSettingsStore } from '@/stores/settingsStore'
import type { RawJsonData } from '@/types/agentControl'
// =============================================================================
@@ -16,7 +16,7 @@ import type { RawJsonData } from '@/types/agentControl'
* Routes to narrow (hourly) or wide (daily) endpoint automatically
*/
export function useAnalytics() {
const timeRange = useAgentControlStore((state) => state.timeRange)
const timeRange = useSettingsStore((state) => state.performanceDashboardTimeRange)
return useQuery({
queryKey: ['analytics', timeRange],
@@ -29,6 +29,7 @@ interface CreateBudgetParams {
spent: number
limitAction: 'kill' | 'throttle' | 'degrade' | 'notify'
degradeToModel?: string
throttleRate?: number
alerts: BudgetAlert[]
notifications: BudgetNotifications
}
+32 -7
View File
@@ -1,5 +1,5 @@
import { useQuery, useInfiniteQuery } from '@tanstack/react-query'
import { getLogs } from '@/services/agentControlApi'
import { getLogs, getLogsAggregated } from '@/services/agentControlApi'
import type { RawJsonData } from '@/types/agentControl'
// =============================================================================
@@ -11,6 +11,11 @@ interface LogsResponse {
[key: string]: unknown
}
export interface LogsFilters {
type?: string
success?: string
}
// =============================================================================
// Query Hooks
// =============================================================================
@@ -22,11 +27,12 @@ export function useLogs(
start: string,
end: string,
limit = 500,
enabled = true
enabled = true,
filters?: LogsFilters
) {
return useQuery({
queryKey: ['logs', start, end, limit],
queryFn: () => getLogs(start, end, limit, 0),
queryKey: ['logs', start, end, limit, filters],
queryFn: () => getLogs(start, end, limit, 0, filters),
enabled,
staleTime: 1 * 60 * 1000, // 1 minute
})
@@ -39,12 +45,13 @@ export function useLogsInfinite(
start: string,
end: string,
pageSize = 500,
enabled = true
enabled = true,
filters?: LogsFilters
) {
return useInfiniteQuery({
queryKey: ['logs', 'infinite', start, end],
queryKey: ['logs', 'infinite', start, end, filters],
queryFn: ({ pageParam }) =>
getLogs(start, end, pageSize, pageParam) as Promise<LogsResponse>,
getLogs(start, end, pageSize, pageParam, filters) as Promise<LogsResponse>,
getNextPageParam: (lastPage, allPages) => {
// If we got fewer rows than pageSize, there's no more data
const rowCount = lastPage.rows?.length ?? 0
@@ -59,3 +66,21 @@ export function useLogsInfinite(
staleTime: 1 * 60 * 1000,
})
}
/**
* Aggregated logs - grouped by model or agent
*/
export function useLogsAggregated(
start: string,
end: string,
groupBy: 'model' | 'agent',
limit = 100,
enabled = true
) {
return useQuery({
queryKey: ['logs', 'aggregated', start, end, groupBy, limit],
queryFn: () => getLogsAggregated(start, end, groupBy, limit),
enabled,
staleTime: 1 * 60 * 1000,
})
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Settings Query Hooks
*
* React Query hooks for fetching and updating user UI settings.
* Syncs server state to Zustand store for instant local access.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useCallback } from 'react'
import * as settingsApi from '@/services/settingsApi'
import { useSettingsStore } from '@/stores/settingsStore'
import type { UpdateUISettingsPayload } from '@/types/settings'
// Query key for settings
export const SETTINGS_QUERY_KEY = ['user', 'settings']
// =============================================================================
// Fetch Settings Hook
// =============================================================================
/**
* Hook to fetch and sync UI settings from server
*
* - Fetches settings on mount
* - Syncs server data to Zustand store (and LocalStorage)
* - Falls back to defaults on error
*/
export function useUISettings() {
const loadSettings = useSettingsStore((s) => s.loadSettings)
const query = useQuery({
queryKey: SETTINGS_QUERY_KEY,
queryFn: async () => {
const response = await settingsApi.getUISettings()
return response.data
},
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1, // Don't retry too much - offline fallback is fine
})
// Sync server data to Zustand store
useEffect(() => {
if (query.data) {
loadSettings(query.data)
}
}, [query.data, loadSettings])
return query
}
// =============================================================================
// Update Settings Hook
// =============================================================================
/**
* Hook to update UI settings with debouncing
*
* - Updates are optimistic (Zustand + LocalStorage first)
* - Server sync is debounced (500ms) to prevent API spam
* - Silent failures (settings still work via LocalStorage)
*/
export function useUpdateUISettings() {
const queryClient = useQueryClient()
const setIsSyncing = useSettingsStore((s) => s.setIsSyncing)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingUpdates = useRef<UpdateUISettingsPayload>({})
const { mutate, ...mutationRest } = useMutation({
mutationFn: (settings: UpdateUISettingsPayload) =>
settingsApi.updateUISettings(settings),
onMutate: () => {
setIsSyncing(true)
},
onSuccess: (response) => {
// Update query cache with server response
queryClient.setQueryData(SETTINGS_QUERY_KEY, response.data)
},
onSettled: () => {
setIsSyncing(false)
},
onError: (error) => {
console.warn('[useUpdateUISettings] Failed to sync settings:', error)
// Settings are already in LocalStorage, so offline works
},
})
// Debounced update function
// Note: mutate is referentially stable (unlike the mutation object itself)
const debouncedUpdate = useCallback(
(updates: UpdateUISettingsPayload) => {
// Merge with pending updates
pendingUpdates.current = { ...pendingUpdates.current, ...updates }
// Clear existing timeout
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
// Set new debounce timeout (500ms)
debounceRef.current = setTimeout(() => {
const toSend = { ...pendingUpdates.current }
pendingUpdates.current = {}
mutate(toSend)
}, 500)
},
[mutate]
)
// Cleanup on unmount - flush pending updates
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
// Flush any pending updates
if (Object.keys(pendingUpdates.current).length > 0) {
mutate(pendingUpdates.current)
}
}
}
}, [mutate])
return {
mutate,
...mutationRest,
debouncedUpdate,
}
}
+58 -4
View File
@@ -2,7 +2,30 @@ import { useEffect, useRef, useCallback, useState } from "react";
import { io, Socket } from "socket.io-client";
import { useQueryClient } from "@tanstack/react-query";
import { useAgentControlStore } from "@/stores/agentControlStore";
import type { LLMEventsBatch, PolicyUpdate } from "@/types/agentControl";
import { useNotificationStore } from "@/stores/notificationStore";
import type { LLMEventsBatch, PolicyUpdate, AgentStatus } from "@/types/agentControl";
interface AlertMessage {
type: "alert";
alert: {
budget_id: string;
budget_name: string;
threshold: number;
current_percentage: number;
spent: number;
limit: number;
action?: string;
reason?: string;
model?: string;
provider?: string;
notifications: {
inApp: boolean;
email: boolean;
emailRecipients: string[];
webhook: boolean;
};
};
}
const HIVE_URL = import.meta.env.VITE_API_URL || "http://localhost:4000";
@@ -15,8 +38,8 @@ export function useControlSocket() {
const [isConnected, setIsConnected] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const addEvents = useAgentControlStore((state) => state.addEvents);
const queryClient = useQueryClient();
const agentStatus = useAgentControlStore((state) => state.agentStatus);
const connect = useCallback(() => {
// Check existence, not connected state - prevents duplicate connections during connecting phase
@@ -53,19 +76,44 @@ export function useControlSocket() {
socketRef.current.on(
"message",
(data: LLMEventsBatch | PolicyUpdate | { type: string }) => {
// Get store functions at message time to avoid stale closures and dependency issues
const { addEvents, setAgentStatus } = useAgentControlStore.getState();
const { addNotification } = useNotificationStore.getState();
if ("type" in data && data.type === "subscribed") {
// Subscription confirmed
setIsConnected(true);
} else if ("type" in data && data.type === "agent-status") {
// Agent status update from WebSocket
setAgentStatus(data as unknown as AgentStatus);
} else if ("events" in data) {
// LLMEventsBatch - add to events buffer
addEvents((data as LLMEventsBatch).events);
} else if ("policyId" in data) {
} else if ("type" in data && data.type === "policy") {
// PolicyUpdate - invalidate budget queries
queryClient.invalidateQueries({ queryKey: ["budgets"] });
} else if ("type" in data && data.type === "alert") {
// Budget alert - show in-app notification if enabled
const alertData = (data as AlertMessage).alert;
if (alertData.notifications?.inApp) {
addNotification({
type: "budget",
title: `Budget Alert: ${alertData.budget_name}`,
message: alertData.action
? `Action "${alertData.action}" triggered. ${alertData.reason || ""}`
: `${alertData.threshold}% threshold reached (${alertData.current_percentage.toFixed(1)}% spent)`,
metadata: {
budgetId: alertData.budget_id,
threshold: alertData.threshold,
spent: alertData.spent,
limit: alertData.limit,
},
});
}
}
}
);
}, [addEvents, queryClient]);
}, [queryClient]);
const disconnect = useCallback(() => {
if (socketRef.current) {
@@ -81,10 +129,16 @@ export function useControlSocket() {
return () => disconnect();
}, [disconnect]);
// Derived agent status values
const hasActiveAgents = agentStatus?.active === true && (agentStatus?.count ?? 0) > 0;
const agentCount = agentStatus?.count ?? 0;
return {
connect,
disconnect,
isConnected,
connectionError,
hasActiveAgents,
agentCount,
};
}
@@ -0,0 +1,87 @@
/**
* Persisted Settings Hooks
*
* Convenience hooks for consuming persisted UI settings.
* These are drop-in replacements for local state in components.
*/
import { useCallback } from 'react'
import { useSettingsStore } from '@/stores/settingsStore'
import { useUpdateUISettings } from '@/hooks/queries/useSettings'
import type { TimeRange } from '@/types/settings'
// =============================================================================
// Sidebar Collapsed Hook
// =============================================================================
/**
* Hook for sidebar collapsed state with persistence
*
* Drop-in replacement for:
* const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
*
* Usage:
* const { sidebarCollapsed, setSidebarCollapsed, toggleSidebar } = useSidebarCollapsed()
*/
export function useSidebarCollapsed() {
const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed)
const setSidebarCollapsedStore = useSettingsStore((s) => s.setSidebarCollapsed)
const { debouncedUpdate } = useUpdateUISettings()
const setSidebarCollapsed = useCallback(
(collapsed: boolean) => {
// Update store (and LocalStorage) immediately
setSidebarCollapsedStore(collapsed)
// Debounced sync to server
debouncedUpdate({ sidebarCollapsed: collapsed })
},
[setSidebarCollapsedStore, debouncedUpdate]
)
const toggleSidebar = useCallback(() => {
setSidebarCollapsed(!sidebarCollapsed)
}, [sidebarCollapsed, setSidebarCollapsed])
return {
sidebarCollapsed,
setSidebarCollapsed,
toggleSidebar,
}
}
// =============================================================================
// Time Range Hook
// =============================================================================
/**
* Hook for performance dashboard time range with persistence
*
* Drop-in replacement for agentControlStore.timeRange usage:
* const timeRange = useAgentControlStore((state) => state.timeRange)
* const setTimeRange = useAgentControlStore((state) => state.setTimeRange)
*
* Usage:
* const { timeRange, setTimeRange } = usePersistedTimeRange()
*/
export function usePersistedTimeRange() {
const timeRange = useSettingsStore((s) => s.performanceDashboardTimeRange)
const setTimeRangeStore = useSettingsStore(
(s) => s.setPerformanceDashboardTimeRange
)
const { debouncedUpdate } = useUpdateUISettings()
const setTimeRange = useCallback(
(range: TimeRange) => {
// Update store (and LocalStorage) immediately
setTimeRangeStore(range)
// Debounced sync to server
debouncedUpdate({ performanceDashboardTimeRange: range })
},
[setTimeRangeStore, debouncedUpdate]
)
return {
timeRange,
setTimeRange,
}
}
+12 -4
View File
@@ -72,16 +72,23 @@ export function getAnalyticsNarrow(): Promise<RawJsonData> {
* @param end - ISO date string
* @param limit - Max records to return (default: 500)
* @param offset - Pagination offset (default: 0)
* @param filters - Optional filters for type and success
*/
export function getLogs(
start: string,
end: string,
limit = 500,
offset = 0
offset = 0,
filters?: { type?: string; success?: string }
): Promise<RawJsonData> {
return hiveClient.get(
`/tsdb/logs?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&limit=${limit}&offset=${offset}`
)
const params = new URLSearchParams()
params.set('start', start)
params.set('end', end)
params.set('limit', limit.toString())
params.set('offset', offset.toString())
if (filters?.type) params.set('type', filters.type)
if (filters?.success !== undefined) params.set('success', filters.success)
return hiveClient.get(`/tsdb/logs?${params.toString()}`)
}
/**
@@ -170,6 +177,7 @@ export function addBudgetRule(
spent: number
limitAction: 'kill' | 'throttle' | 'degrade' | 'notify'
degradeToModel?: string
throttleRate?: number
alerts: BudgetAlert[]
notifications: BudgetNotifications
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Settings API Service
*
* API client methods for user UI settings.
*/
import { serverClient } from './api'
import type { UISettingsResponse, UpdateUISettingsPayload } from '@/types/settings'
/**
* Get user UI settings from server
*/
export function getUISettings(): Promise<UISettingsResponse> {
return serverClient.get<UISettingsResponse>('/user/settings')
}
/**
* Update user UI settings on server
* Supports partial updates - only send changed fields
*/
export function updateUISettings(
settings: UpdateUISettingsPayload
): Promise<UISettingsResponse> {
return serverClient.put<UISettingsResponse>('/user/settings', settings)
}
+9 -1
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand'
import type { LLMEvent } from '@/types/agentControl'
import type { LLMEvent, AgentStatus } from '@/types/agentControl'
// =============================================================================
// Types
@@ -24,6 +24,10 @@ interface AgentControlState {
eventsBuffer: LLMEvent[]
addEvents: (events: LLMEvent[]) => void
clearEvents: () => void
// Agent status from WebSocket
agentStatus: AgentStatus | null
setAgentStatus: (status: AgentStatus) => void
}
// =============================================================================
@@ -55,4 +59,8 @@ export const useAgentControlStore = create<AgentControlState>((set) => ({
return { eventsBuffer: combined.slice(0, MAX_EVENTS_BUFFER) }
}),
clearEvents: () => set({ eventsBuffer: [] }),
// Agent status from WebSocket
agentStatus: null,
setAgentStatus: (status) => set({ agentStatus: status }),
}))
+104
View File
@@ -0,0 +1,104 @@
/**
* Settings Store
*
* Zustand store for persisted UI settings.
* - Initializes from LocalStorage on creation
* - Auto-syncs to LocalStorage on every change
* - Server sync is handled by useSettings hooks
*/
import { create } from 'zustand'
import type { UISettings, TimeRange } from '@/types/settings'
import { DEFAULT_UI_SETTINGS } from '@/types/settings'
const STORAGE_KEY = 'honeycomb_ui_settings'
// =============================================================================
// LocalStorage Helpers
// =============================================================================
/**
* Read settings from LocalStorage
* Returns defaults if not found or on error
*/
function getLocalSettings(): UISettings {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
return { ...DEFAULT_UI_SETTINGS, ...JSON.parse(stored) }
}
} catch (e) {
console.warn('[SettingsStore] Failed to parse local settings:', e)
}
return DEFAULT_UI_SETTINGS
}
/**
* Write settings to LocalStorage
* Merges with existing settings
*/
function setLocalSettings(settings: Partial<UISettings>): void {
try {
const current = getLocalSettings()
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...current, ...settings }))
} catch (e) {
console.warn('[SettingsStore] Failed to save local settings:', e)
}
}
// =============================================================================
// Store Types
// =============================================================================
interface SettingsState {
// Settings values
sidebarCollapsed: boolean
performanceDashboardTimeRange: TimeRange
// Loading/sync state
isLoaded: boolean
isSyncing: boolean
// Actions
setSidebarCollapsed: (collapsed: boolean) => void
setPerformanceDashboardTimeRange: (range: TimeRange) => void
loadSettings: (settings: UISettings) => void
setIsSyncing: (syncing: boolean) => void
}
// =============================================================================
// Store
// =============================================================================
// Initialize from LocalStorage on store creation
const initial = getLocalSettings()
export const useSettingsStore = create<SettingsState>((set) => ({
// Initial values from LocalStorage
sidebarCollapsed: initial.sidebarCollapsed,
performanceDashboardTimeRange: initial.performanceDashboardTimeRange,
isLoaded: false,
isSyncing: false,
setSidebarCollapsed: (collapsed) => {
set({ sidebarCollapsed: collapsed })
setLocalSettings({ sidebarCollapsed: collapsed })
},
setPerformanceDashboardTimeRange: (range) => {
set({ performanceDashboardTimeRange: range })
setLocalSettings({ performanceDashboardTimeRange: range })
},
loadSettings: (settings) => {
set({
sidebarCollapsed: settings.sidebarCollapsed,
performanceDashboardTimeRange: settings.performanceDashboardTimeRange,
isLoaded: true,
})
// Sync to LocalStorage
setLocalSettings(settings)
},
setIsSyncing: (syncing) => set({ isSyncing: syncing }),
}))
+1
View File
@@ -133,6 +133,7 @@ export interface BudgetConfig {
spent: number
limitAction: LimitAction
degradeToModel?: string
throttleRate?: number
alerts: BudgetAlert[]
notifications: BudgetNotifications
}
+38
View File
@@ -0,0 +1,38 @@
/**
* UI Settings Types
*
* Types for persisted user interface settings.
* Settings are stored server-side (users.preferences) with LocalStorage cache.
*/
// TimeRange is also used by agentControlStore, keep it here as the source of truth
export type TimeRange = 'today' | 'week' | 'twoWeeks' | 'month' | 'all'
/**
* User UI settings that persist across sessions and devices
*/
export interface UISettings {
sidebarCollapsed: boolean
performanceDashboardTimeRange: TimeRange
}
/**
* Default settings for new users or offline fallback
*/
export const DEFAULT_UI_SETTINGS: UISettings = {
sidebarCollapsed: false,
performanceDashboardTimeRange: 'today',
}
/**
* API response shape for settings endpoints
*/
export interface UISettingsResponse {
success: boolean
data: UISettings
}
/**
* Update payload - supports partial updates
*/
export type UpdateUISettingsPayload = Partial<UISettings>