@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }),
|
||||
}))
|
||||
|
||||
@@ -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 }),
|
||||
}))
|
||||
@@ -133,6 +133,7 @@ export interface BudgetConfig {
|
||||
spent: number
|
||||
limitAction: LimitAction
|
||||
degradeToModel?: string
|
||||
throttleRate?: number
|
||||
alerts: BudgetAlert[]
|
||||
notifications: BudgetNotifications
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user