From 232427e099c465468a140343c750f9b4b91d2a39 Mon Sep 17 00:00:00 2001 From: cecilia-marques Date: Fri, 27 Mar 2026 18:07:09 -0300 Subject: [PATCH] feat(monitoring): redesign overview tab with activity breakdown and improved UX - Extract monitoring components into separate files (overview, filters, logs table, mock data) - Add Activity Breakdown section with top connections, top tools, agents, and automations - Add KPI summary row with sparkline charts for tool calls, latency, and errors - Add AI Usage section with LLM-specific stats - Improve log detail drawer layout with better metadata display - Add latency avg/p95 pill toggle for clearer interactivity - Add mock data system (USE_MOCK_DATA flag) for development - Improve filters with UI/code mode, apply button, and streaming indicator - Replace "Custom time range" label in time range picker Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/storage/monitoring-sql.ts | 12 +- apps/mesh/src/storage/ports.ts | 2 + apps/mesh/src/tools/monitoring/list.ts | 18 +- apps/mesh/src/tools/monitoring/stats.ts | 8 +- .../monitoring/analytics-top-tools.tsx | 72 +- .../src/web/components/monitoring/hooks.ts | 200 +- .../src/web/components/monitoring/log-row.tsx | 161 +- .../web/components/monitoring/mock-data.ts | 530 +++++ .../monitoring/monitoring-filters.tsx | 539 +++++ .../monitoring/monitoring-logs-table.tsx | 633 ++++++ .../monitoring/monitoring-overview.tsx | 862 ++++++++ .../src/web/components/monitoring/types.tsx | 90 +- apps/mesh/src/web/routes/orgs/monitoring.tsx | 1764 +---------------- bun.lock | 41 +- package.json | 3 + .../ui/src/components/time-range-picker.tsx | 2 +- 16 files changed, 3010 insertions(+), 1927 deletions(-) create mode 100644 apps/mesh/src/web/components/monitoring/mock-data.ts create mode 100644 apps/mesh/src/web/components/monitoring/monitoring-filters.tsx create mode 100644 apps/mesh/src/web/components/monitoring/monitoring-logs-table.tsx create mode 100644 apps/mesh/src/web/components/monitoring/monitoring-overview.tsx diff --git a/apps/mesh/src/storage/monitoring-sql.ts b/apps/mesh/src/storage/monitoring-sql.ts index 4fce2a9916..fc8d6789fb 100644 --- a/apps/mesh/src/storage/monitoring-sql.ts +++ b/apps/mesh/src/storage/monitoring-sql.ts @@ -331,8 +331,10 @@ export class SqlMonitoringStorage implements MonitoringStorage { async query(filters: { organizationId: string; connectionId?: string; + connectionIds?: string[]; excludeConnectionIds?: string[]; virtualMcpId?: string; + virtualMcpIds?: string[]; toolName?: string; isError?: boolean; startDate?: Date; @@ -351,7 +353,10 @@ export class SqlMonitoringStorage implements MonitoringStorage { `organization_id = '${esc(filters.organizationId)}'`, ]; - if (filters.connectionId) { + if (filters.connectionIds?.length) { + const ids = filters.connectionIds.map((id) => `'${esc(id)}'`).join(","); + where.push(`connection_id IN (${ids})`); + } else if (filters.connectionId) { where.push(`connection_id = '${esc(filters.connectionId)}'`); } if (filters.excludeConnectionIds?.length) { @@ -360,7 +365,10 @@ export class SqlMonitoringStorage implements MonitoringStorage { .join(","); where.push(`connection_id NOT IN (${ids})`); } - if (filters.virtualMcpId) { + if (filters.virtualMcpIds?.length) { + const ids = filters.virtualMcpIds.map((id) => `'${esc(id)}'`).join(","); + where.push(`virtual_mcp_id IN (${ids})`); + } else if (filters.virtualMcpId) { where.push(`virtual_mcp_id = '${esc(filters.virtualMcpId)}'`); } if (filters.toolName) { diff --git a/apps/mesh/src/storage/ports.ts b/apps/mesh/src/storage/ports.ts index 770338fdc1..5776b1e804 100644 --- a/apps/mesh/src/storage/ports.ts +++ b/apps/mesh/src/storage/ports.ts @@ -200,8 +200,10 @@ export interface MonitoringStorage { query(filters: { organizationId: string; connectionId?: string; + connectionIds?: string[]; excludeConnectionIds?: string[]; virtualMcpId?: string; + virtualMcpIds?: string[]; toolName?: string; isError?: boolean; startDate?: Date; diff --git a/apps/mesh/src/tools/monitoring/list.ts b/apps/mesh/src/tools/monitoring/list.ts index b5fbc03b8c..3d5d86dd0e 100644 --- a/apps/mesh/src/tools/monitoring/list.ts +++ b/apps/mesh/src/tools/monitoring/list.ts @@ -50,6 +50,10 @@ export const MONITORING_LOGS_LIST = defineTool({ }, inputSchema: z.object({ connectionId: z.string().optional().describe("Filter by connection ID"), + connectionIds: z + .array(z.string()) + .optional() + .describe("Filter by multiple connection IDs"), excludeConnectionIds: z .array(z.string()) .optional() @@ -60,6 +64,10 @@ export const MONITORING_LOGS_LIST = defineTool({ .string() .optional() .describe("Filter by Virtual MCP (Agent) ID"), + virtualMcpIds: z + .array(z.string()) + .optional() + .describe("Filter by multiple Virtual MCP (Agent) IDs"), toolName: z.string().optional().describe("Filter by tool name"), isError: z.boolean().optional().describe("Filter by error status"), startDate: z @@ -104,7 +112,13 @@ export const MONITORING_LOGS_LIST = defineTool({ }), handler: async (input, ctx) => { // Flush buffered spans so the query sees the latest data (local mode). - await flushMonitoringData(); + // Catch flush errors to avoid failing the entire query — stale data is + // preferable to no data at all. + try { + await flushMonitoringData(); + } catch (err) { + console.warn("[monitoring] flush failed before list query:", err); + } const org = requireOrganization(ctx); @@ -126,8 +140,10 @@ export const MONITORING_LOGS_LIST = defineTool({ const filters = { organizationId: org.id, connectionId: input.connectionId, + connectionIds: input.connectionIds, excludeConnectionIds: input.excludeConnectionIds, virtualMcpId: input.virtualMcpId, + virtualMcpIds: input.virtualMcpIds, toolName: input.toolName, isError: input.isError, startDate: input.startDate ? new Date(input.startDate) : undefined, diff --git a/apps/mesh/src/tools/monitoring/stats.ts b/apps/mesh/src/tools/monitoring/stats.ts index cd6df158aa..3031ba062c 100644 --- a/apps/mesh/src/tools/monitoring/stats.ts +++ b/apps/mesh/src/tools/monitoring/stats.ts @@ -140,7 +140,13 @@ export const MONITORING_STATS = defineTool({ handler: async (input, ctx) => { const org = requireOrganization(ctx); await ctx.access.check(); - await flushMonitoringData(); + // Catch flush errors to avoid failing the entire query — stale data is + // preferable to no data at all. + try { + await flushMonitoringData(); + } catch (err) { + console.warn("[monitoring] flush failed before stats query:", err); + } if (input.interval) { const stats = await ctx.storage.monitoring.queryMetricTimeseries({ diff --git a/apps/mesh/src/web/components/monitoring/analytics-top-tools.tsx b/apps/mesh/src/web/components/monitoring/analytics-top-tools.tsx index e41df3a5ec..67b49cf6af 100644 --- a/apps/mesh/src/web/components/monitoring/analytics-top-tools.tsx +++ b/apps/mesh/src/web/components/monitoring/analytics-top-tools.tsx @@ -10,7 +10,7 @@ import { ChartContainer, ChartTooltip } from "@deco/ui/components/chart.tsx"; import { useConnections, useProjectContext } from "@decocms/mesh-sdk"; import { useNavigate } from "@tanstack/react-router"; import { Container } from "@untitledui/icons"; -import { Line, LineChart, XAxis } from "recharts"; +import { Line, LineChart, XAxis, YAxis } from "recharts"; import { useMonitoringLlmStats, useMonitoringTopTools } from "./hooks"; export type TopChartMetric = @@ -72,10 +72,15 @@ function buildToolBuckets( } >(); - // Always create exactly 10 display buckets spanning the full range - const BUCKET_COUNT = 20; + // Compute bucket count from interval so display buckets align with server data const startMs = start.getTime(); const endMs = end.getTime(); + const intervalMs = + interval === "1m" ? 60_000 : interval === "1h" ? 3_600_000 : 86_400_000; + const BUCKET_COUNT = Math.max( + 2, + Math.min(60, Math.ceil((endMs - startMs) / intervalMs)), + ); const step = (endMs - startMs) / (BUCKET_COUNT - 1); for (let i = 0; i < BUCKET_COUNT; i++) { @@ -222,9 +227,14 @@ function buildLlmBuckets( end: Date, interval: "1m" | "1h" | "1d", ): LlmBucket[] { - const BUCKET_COUNT = 20; const startMs = start.getTime(); const endMs = end.getTime(); + const intervalMs = + interval === "1m" ? 60_000 : interval === "1h" ? 3_600_000 : 86_400_000; + const BUCKET_COUNT = Math.max( + 2, + Math.min(60, Math.ceil((endMs - startMs) / intervalMs)), + ); const step = (endMs - startMs) / (BUCKET_COUNT - 1); // Create empty display buckets @@ -305,6 +315,7 @@ interface TopToolsContentProps { status?: "success" | "error"; isStreaming?: boolean; streamingRefetchInterval?: number; + onToolClick?: (toolName: string) => void; } function TopToolsContent({ @@ -316,6 +327,7 @@ function TopToolsContent({ status, isStreaming, streamingRefetchInterval, + onToolClick, }: TopToolsContentProps) { const { org } = useProjectContext(); const navigate = useNavigate(); @@ -466,7 +478,7 @@ function TopToolsContent({ > + + value >= 10000 + ? `${(value / 1000).toFixed(0)}k` + : value >= 1000 + ? `${(value / 1000).toFixed(1)}k` + : String(value) + } + /> { if (!active || !payload || payload.length === 0) @@ -525,11 +550,23 @@ function TopToolsContent({

{METRIC_LABELS[metricsMode]}

-
- {topTools.slice(0, 3).map((tool) => { +
+ {topTools.map((tool) => { const connection = connectionMap.get(tool.connectionId || ""); + const color = toolColors.get(tool.toolName); return ( -
+ ); })}
@@ -560,7 +597,7 @@ function TopToolsContent({ > + + value >= 10000 + ? `${(value / 1000).toFixed(0)}k` + : value >= 1000 + ? `${(value / 1000).toFixed(1)}k` + : String(value) + } + /> { if (!active || !payload || payload.length === 0) return null; diff --git a/apps/mesh/src/web/components/monitoring/hooks.ts b/apps/mesh/src/web/components/monitoring/hooks.ts index 823d27313a..caa5f511fc 100644 --- a/apps/mesh/src/web/components/monitoring/hooks.ts +++ b/apps/mesh/src/web/components/monitoring/hooks.ts @@ -4,6 +4,13 @@ import { useMCPToolCall, useProjectContext, } from "@decocms/mesh-sdk"; +import { + USE_MOCK_DATA, + getMockStats, + getMockTopTools, + getMockLlmStats, +} from "./mock-data"; +import { useSuspenseQuery } from "@tanstack/react-query"; /** Connection ID used for all LLM calls emitted by Decopilot. Must match server-side DECOPILOT_CONNECTION_ID. */ const DECOPILOT_CONNECTION_ID = "decopilot"; @@ -33,6 +40,103 @@ interface MonitoringTopToolsParams extends MonitoringMetricFilters { topN: number; } +interface MonitoringLlmStatsParams { + interval: "1m" | "1h" | "1d"; + startDate: string; + endDate: string; +} + +type MonitoringStatsResult = { + totalCalls: number; + totalErrors: number; + avgDurationMs: number; + p50DurationMs: number; + p95DurationMs: number; + connectionBreakdown: Array<{ + connectionId: string; + calls: number; + errors: number; + errorRate: number; + avgDurationMs: number; + }>; + timeseries: Array<{ + timestamp: string; + calls: number; + errors: number; + errorRate: number; + avg: number; + p50: number; + p95: number; + }>; +}; + +type MonitoringTopToolsResult = { + topTools: Array<{ + toolName: string; + connectionId: string | null; + calls: number; + }>; + timeseries: Array<{ + timestamp: string; + calls: number; + errors: number; + errorRate: number; + avg: number; + p50: number; + p95: number; + }>; + topToolsTimeseries: Array<{ + timestamp: string; + toolName: string; + calls: number; + errors: number; + avg: number; + p95: number; + }>; +}; + +type MonitoringLlmStatsResult = { + totalCalls: number; + totalErrors: number; + avgDurationMs: number; + p50DurationMs: number; + p95DurationMs: number; + connectionBreakdown: Array<{ + connectionId: string; + calls: number; + errors: number; + errorRate: number; + avgDurationMs: number; + }>; + topTools: Array<{ + toolName: string; + connectionId: string | null; + calls: number; + }>; + timeseries: Array<{ + timestamp: string; + calls: number; + errors: number; + errorRate: number; + avg: number; + p50: number; + p95: number; + }>; +}; + +/** + * Use a suspense query that resolves immediately with mock data. + * The queryKey includes the params so React Query treats different + * param combinations as separate cache entries. + */ +export function useMockSuspense(key: string, factory: () => T) { + return useSuspenseQuery({ + queryKey: ["mock", key], + queryFn: () => Promise.resolve(factory()), + staleTime: Number.POSITIVE_INFINITY, + }); +} + export function useMonitoringStats( params: MonitoringStatsParams, queryOptions?: MonitoringQueryOptions, @@ -43,29 +147,11 @@ export function useMonitoringStats( orgId: org.id, }); - return useMCPToolCall<{ - totalCalls: number; - totalErrors: number; - avgDurationMs: number; - p50DurationMs: number; - p95DurationMs: number; - connectionBreakdown: Array<{ - connectionId: string; - calls: number; - errors: number; - errorRate: number; - avgDurationMs: number; - }>; - timeseries: Array<{ - timestamp: string; - calls: number; - errors: number; - errorRate: number; - avg: number; - p50: number; - p95: number; - }>; - }>({ + if (USE_MOCK_DATA) { + return useMockSuspense("stats", () => getMockStats(params)); + } + + return useMCPToolCall({ client, toolName: "MONITORING_STATS", toolArguments: { @@ -93,30 +179,11 @@ export function useMonitoringTopTools( orgId: org.id, }); - return useMCPToolCall<{ - topTools: Array<{ - toolName: string; - connectionId: string | null; - calls: number; - }>; - timeseries: Array<{ - timestamp: string; - calls: number; - errors: number; - errorRate: number; - avg: number; - p50: number; - p95: number; - }>; - topToolsTimeseries: Array<{ - timestamp: string; - toolName: string; - calls: number; - errors: number; - avg: number; - p95: number; - }>; - }>({ + if (USE_MOCK_DATA) { + return useMockSuspense("topTools", () => getMockTopTools(params)); + } + + return useMCPToolCall({ client, toolName: "MONITORING_STATS", toolArguments: { @@ -134,12 +201,6 @@ export function useMonitoringTopTools( }); } -interface MonitoringLlmStatsParams { - interval: "1m" | "1h" | "1d"; - startDate: string; - endDate: string; -} - /** * Fetch aggregated stats for LLM calls made by Decopilot. * @@ -157,34 +218,11 @@ export function useMonitoringLlmStats( orgId: org.id, }); - return useMCPToolCall<{ - totalCalls: number; - totalErrors: number; - avgDurationMs: number; - p50DurationMs: number; - p95DurationMs: number; - connectionBreakdown: Array<{ - connectionId: string; - calls: number; - errors: number; - errorRate: number; - avgDurationMs: number; - }>; - topTools: Array<{ - toolName: string; - connectionId: string | null; - calls: number; - }>; - timeseries: Array<{ - timestamp: string; - calls: number; - errors: number; - errorRate: number; - avg: number; - p50: number; - p95: number; - }>; - }>({ + if (USE_MOCK_DATA) { + return useMockSuspense("llmStats", () => getMockLlmStats(params)); + } + + return useMCPToolCall({ client, toolName: "MONITORING_STATS", toolArguments: { diff --git a/apps/mesh/src/web/components/monitoring/log-row.tsx b/apps/mesh/src/web/components/monitoring/log-row.tsx index dcd0c912ad..3ced90dbdf 100644 --- a/apps/mesh/src/web/components/monitoring/log-row.tsx +++ b/apps/mesh/src/web/components/monitoring/log-row.tsx @@ -2,16 +2,26 @@ * Log Row Component * * Displays a single monitoring log entry in the table. + * Clicking the row opens a detail drawer (managed by parent). */ import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; import { Badge } from "@deco/ui/components/badge.tsx"; import { cn } from "@deco/ui/lib/utils.ts"; -import { ChevronDown, ChevronRight } from "@untitledui/icons"; -import { Fragment } from "react"; -import { ExpandedLogContent, type EnrichedMonitoringLog } from "./types.tsx"; +import type { EnrichedMonitoringLog } from "./types.tsx"; import { TableCell, TableRow } from "@deco/ui/components/table.tsx"; +// ============================================================================ +// Helpers +// ============================================================================ + +function formatPayloadSize(data: Record): string { + const bytes = JSON.stringify(data).length; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + // ============================================================================ // Types // ============================================================================ @@ -24,10 +34,10 @@ interface Connection { interface LogRowProps { log: EnrichedMonitoringLog; - isExpanded: boolean; + isSelected: boolean; connection: Connection | undefined; - virtualMcpName: string; // já resolvido pelo pai - onToggle: () => void; + virtualMcpName: string; + onSelect: () => void; lastLogRef?: (node: HTMLTableRowElement | null) => void; } @@ -37,10 +47,10 @@ interface LogRowProps { export function LogRow({ log, - isExpanded, + isSelected, connection, virtualMcpName, - onToggle, + onSelect, lastLogRef, }: LogRowProps) { const timestamp = new Date(log.timestamp); @@ -55,92 +65,65 @@ export function LogRow({ }); return ( - - - {/* Expand Icon */} - -
- {isExpanded ? ( - - ) : ( - - )} -
-
- - {/* Connection Icon */} - -
- -
-
- - {/* Tool Name + Connection Name */} - -
- {log.toolName} -
-
- {log.connectionTitle} -
-
- - {/* Agent */} - -
{virtualMcpName}
-
+ + {/* Connection Icon */} + +
+ +
+
- {/* User Name */} - - {log.userName} - + {/* Tool Name + Connection Name */} + +
+ {log.toolName} +
+
+ {log.connectionTitle} +
+
- {/* Date */} - - {dateStr} - + {/* Agent */} + +
{virtualMcpName}
+
- {/* Time */} - - {timeStr} - + {/* Date */} + + {dateStr} + - {/* Duration */} - - {log.durationMs}ms - + {/* Status Badge */} + + + {log.isError ? "Error" : "Success"} + + - {/* Status Badge */} - -
- - {log.isError ? "Error" : "OK"} - + {/* Duration + payload size hint */} + +
{log.durationMs}ms
+ {log.input && ( +
+ {formatPayloadSize(log.input)}
-
- - {isExpanded && ( - - - - - - )} - + )} + + ); } diff --git a/apps/mesh/src/web/components/monitoring/mock-data.ts b/apps/mesh/src/web/components/monitoring/mock-data.ts new file mode 100644 index 0000000000..3c5669e7bf --- /dev/null +++ b/apps/mesh/src/web/components/monitoring/mock-data.ts @@ -0,0 +1,530 @@ +/** + * Mock data for monitoring dashboard development. + * + * Set USE_MOCK_DATA to true to render the dashboard with realistic fake data + * when the monitoring backend is unavailable (e.g. missing DuckDB). + * + * TODO: Remove this file once monitoring backend works locally. + */ + +/** Flip to `false` to use real backend data. */ +export const USE_MOCK_DATA = true; + +function generateTimeseries( + startDate: string, + endDate: string, + interval: "1m" | "1h" | "1d", +) { + const start = new Date(startDate).getTime(); + const end = new Date(endDate).getTime(); + const step = + interval === "1m" ? 60_000 : interval === "1h" ? 3_600_000 : 86_400_000; + const points = []; + for (let ts = start; ts <= end; ts += step) { + const base = 5 + Math.random() * 30; + const errors = Math.random() < 0.15 ? Math.floor(Math.random() * 3) : 0; + points.push({ + timestamp: new Date(ts).toISOString(), + calls: Math.floor(base), + errors, + errorRate: base > 0 ? (errors / base) * 100 : 0, + avg: Math.floor(80 + Math.random() * 400), + p50: Math.floor(60 + Math.random() * 200), + p95: Math.floor(300 + Math.random() * 800), + }); + } + return points; +} + +const TOOL_NAMES = [ + "COLLECTION_LIST", + "COLLECTION_GET", + "SEARCH_PRODUCTS", + "CREATE_ORDER", + "SEND_EMAIL", + "GET_ANALYTICS", + "UPDATE_INVENTORY", + "GENERATE_REPORT", +]; + +const CONNECTION_IDS = [ + "conn_shopify", + "conn_mailgun", + "conn_analytics", + "conn_stripe", + "conn_inventory", +]; + +export function getMockStats(params: { + startDate: string; + endDate: string; + interval: "1m" | "1h" | "1d"; +}) { + const timeseries = generateTimeseries( + params.startDate, + params.endDate, + params.interval, + ); + const totalCalls = timeseries.reduce((s, p) => s + p.calls, 0); + const totalErrors = timeseries.reduce((s, p) => s + p.errors, 0); + const avgDurationMs = Math.round( + timeseries.reduce((s, p) => s + p.avg, 0) / Math.max(timeseries.length, 1), + ); + const p50DurationMs = Math.round( + timeseries.reduce((s, p) => s + p.p50, 0) / Math.max(timeseries.length, 1), + ); + const p95DurationMs = Math.round( + timeseries.reduce((s, p) => s + p.p95, 0) / Math.max(timeseries.length, 1), + ); + + const connectionBreakdown = CONNECTION_IDS.map((id) => { + const calls = Math.floor(totalCalls * (0.1 + Math.random() * 0.3)); + const errors = Math.floor(calls * Math.random() * 0.08); + return { + connectionId: id, + calls, + errors, + errorRate: calls > 0 ? (errors / calls) * 100 : 0, + avgDurationMs: Math.floor(100 + Math.random() * 300), + }; + }); + + return { + totalCalls, + totalErrors, + avgDurationMs, + p50DurationMs, + p95DurationMs, + connectionBreakdown, + timeseries, + }; +} + +export function getMockTopTools(params: { + startDate: string; + endDate: string; + interval: "1m" | "1h" | "1d"; + topN: number; +}) { + const tools = TOOL_NAMES.slice(0, params.topN); + const topTools = tools.map((name, i) => ({ + toolName: name, + connectionId: CONNECTION_IDS[i % CONNECTION_IDS.length]!, + calls: Math.floor(50 + Math.random() * 200), + })); + + const timeseries = generateTimeseries( + params.startDate, + params.endDate, + params.interval, + ); + + const topToolsTimeseries: Array<{ + timestamp: string; + toolName: string; + calls: number; + errors: number; + avg: number; + p95: number; + }> = []; + + for (const point of timeseries) { + for (const tool of tools) { + topToolsTimeseries.push({ + timestamp: point.timestamp, + toolName: tool, + calls: Math.floor(1 + Math.random() * 8), + errors: Math.random() < 0.1 ? 1 : 0, + avg: Math.floor(50 + Math.random() * 300), + p95: Math.floor(200 + Math.random() * 600), + }); + } + } + + return { + topTools, + timeseries, + topToolsTimeseries, + }; +} + +// ============================================================================ +// Mock Logs (for Audit tab) +// ============================================================================ + +const CONNECTION_TITLES: Record = { + conn_shopify: "Shopify", + conn_mailgun: "Mailgun", + conn_analytics: "Google Analytics", + conn_stripe: "Stripe", + conn_inventory: "Inventory Service", +}; + +const USER_NAMES = [ + "Alice Johnson", + "Bob Smith", + "Carol Lee", + "Dave Patel", + "Eve Costa", +]; + +const VIRTUAL_MCP_IDS = ["vmc_decopilot", "vmc_support", "vmc_sales"]; +const VIRTUAL_MCP_TITLES: Record = { + vmc_decopilot: "Decopilot", + vmc_support: "Support Agent", + vmc_sales: "Sales Agent", +}; + +const USER_AGENTS = [ + "cursor/0.45.0", + "claude-code/1.2.0", + "vscode-mcp/0.8.3", + null, +]; + +function randomItem(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]!; +} + +export interface MockMonitoringLog { + id: string; + connectionId: string; + connectionTitle: string; + toolName: string; + isError: boolean; + errorMessage: string | null; + durationMs: number; + timestamp: string; + organizationId: string; + userId: string | null; + requestId: string; + input: Record | null; + output: Record | null; + userAgent: string | null; + virtualMcpId: string | null; + properties: Record | null; +} + +function generateMockInput(toolName: string): Record { + switch (toolName) { + case "COLLECTION_LIST": + return { collection: "products", limit: 20, offset: 0 }; + case "COLLECTION_GET": + return { + collection: "products", + id: `prod_${Math.random().toString(36).slice(2, 8)}`, + }; + case "SEARCH_PRODUCTS": + return { + query: "summer dresses", + filters: { category: "clothing", inStock: true }, + limit: 10, + }; + case "CREATE_ORDER": + return { + items: [ + { productId: "prod_abc123", quantity: 2, price: 29.99 }, + { productId: "prod_def456", quantity: 1, price: 49.99 }, + ], + shippingAddress: { + street: "123 Main St", + city: "San Francisco", + state: "CA", + zip: "94102", + }, + }; + case "SEND_EMAIL": + return { + to: "customer@example.com", + subject: "Order Confirmation #12345", + template: "order_confirmation", + variables: { orderId: "12345", total: "$109.97" }, + }; + case "GET_ANALYTICS": + return { + metric: "page_views", + startDate: "2026-03-01", + endDate: "2026-03-25", + granularity: "day", + }; + case "UPDATE_INVENTORY": + return { productId: "prod_abc123", delta: -2, reason: "order_fulfilled" }; + case "GENERATE_REPORT": + return { reportType: "weekly_sales", format: "pdf", includeCharts: true }; + default: + return { action: toolName }; + } +} + +function generateMockOutput( + toolName: string, + isError: boolean, +): Record { + if (isError) { + return { + error: { + code: randomItem(["NOT_FOUND", "RATE_LIMITED", "TIMEOUT", "INTERNAL"]), + message: randomItem([ + "Resource not found", + "Rate limit exceeded, retry after 30s", + "Request timed out after 10s", + "Internal server error", + ]), + }, + }; + } + switch (toolName) { + case "COLLECTION_LIST": + return { + items: Array.from({ length: 5 }, (_, i) => ({ + id: `prod_${i}`, + title: `Product ${i + 1}`, + price: +(10 + Math.random() * 90).toFixed(2), + })), + total: 142, + hasMore: true, + }; + case "SEARCH_PRODUCTS": + return { + results: [ + { id: "prod_1", title: "Floral Summer Dress", score: 0.95 }, + { id: "prod_2", title: "Linen Beach Dress", score: 0.87 }, + ], + totalResults: 24, + }; + case "CREATE_ORDER": + return { orderId: "ord_xyz789", status: "confirmed", total: 109.97 }; + case "SEND_EMAIL": + return { messageId: "msg_abc", status: "queued" }; + default: + return { success: true, result: "completed" }; + } +} + +export function getMockLogs(params: { + startDate: string; + endDate: string; + limit: number; + offset: number; +}): { logs: MockMonitoringLog[]; total: number } { + const total = 127; + const count = Math.min(params.limit, Math.max(0, total - params.offset)); + const start = new Date(params.startDate).getTime(); + const end = new Date(params.endDate).getTime(); + + const logs: MockMonitoringLog[] = Array.from({ length: count }, (_, i) => { + const idx = params.offset + i; + const isError = Math.random() < 0.12; + const toolName = TOOL_NAMES[idx % TOOL_NAMES.length]!; + const connId = CONNECTION_IDS[idx % CONNECTION_IDS.length]!; + const vmcpId = randomItem(VIRTUAL_MCP_IDS); + // Distribute timestamps evenly within range, newest first + const ts = end - ((end - start) * idx) / total; + return { + id: `log_${idx}_${Math.random().toString(36).slice(2, 8)}`, + connectionId: connId, + connectionTitle: CONNECTION_TITLES[connId] ?? connId, + toolName, + isError, + errorMessage: isError + ? randomItem([ + "Connection timeout after 10000ms", + "Rate limit exceeded", + "Resource not found: prod_expired", + "Invalid input: missing required field 'id'", + ]) + : null, + durationMs: Math.floor( + isError ? 500 + Math.random() * 9500 : 30 + Math.random() * 800, + ), + timestamp: new Date(ts).toISOString(), + organizationId: "org_mock", + userId: `user_${(idx % USER_NAMES.length) + 1}`, + requestId: `req_${crypto.randomUUID().slice(0, 8)}`, + input: generateMockInput(toolName), + output: generateMockOutput(toolName, isError), + userAgent: randomItem(USER_AGENTS), + virtualMcpId: vmcpId, + properties: + Math.random() < 0.4 + ? { + thread_id: `thread_${Math.random().toString(36).slice(2, 8)}`, + session: randomItem(["web", "mobile", "api"]), + } + : null, + }; + }); + + return { logs, total }; +} + +// ============================================================================ +// Mock Automations (for Overview tab) +// ============================================================================ + +export interface MockAutomation { + id: string; + name: string; + active: boolean; + trigger_count: number; + schedule: string | null; + last_run_at: string | null; + next_run_at: string | null; +} + +export function getMockAutomations(): MockAutomation[] { + const now = Date.now(); + return [ + { + id: "auto_1", + name: "Daily Inventory Sync", + active: true, + trigger_count: 1, + schedule: "Every 24h", + last_run_at: new Date(now - 2 * 3_600_000).toISOString(), + next_run_at: new Date(now + 22 * 3_600_000).toISOString(), + }, + { + id: "auto_2", + name: "Order Confirmation Emails", + active: true, + trigger_count: 2, + schedule: "On event", + last_run_at: new Date(now - 15 * 60_000).toISOString(), + next_run_at: null, + }, + { + id: "auto_3", + name: "Weekly Sales Report", + active: true, + trigger_count: 1, + schedule: "Every 7d", + last_run_at: new Date(now - 3 * 86_400_000).toISOString(), + next_run_at: new Date(now + 4 * 86_400_000).toISOString(), + }, + { + id: "auto_4", + name: "Abandoned Cart Recovery", + active: false, + trigger_count: 1, + schedule: "Every 6h", + last_run_at: new Date(now - 7 * 86_400_000).toISOString(), + next_run_at: null, + }, + { + id: "auto_5", + name: "Support Ticket Triage", + active: true, + trigger_count: 3, + schedule: "Every 15m", + last_run_at: new Date(now - 45 * 60_000).toISOString(), + next_run_at: null, + }, + ]; +} + +// ============================================================================ +// Mock Agents (for Overview tab) +// ============================================================================ + +export interface MockAgent { + id: string; + title: string; + calls: number; + lastActiveAt: string | null; +} + +export function getMockAgents(): MockAgent[] { + const now = Date.now(); + return [ + { + id: "vmc_decopilot", + title: "Decopilot", + calls: 312, + lastActiveAt: new Date(now - 5 * 60_000).toISOString(), + }, + { + id: "vmc_support", + title: "Support Agent", + calls: 187, + lastActiveAt: new Date(now - 12 * 60_000).toISOString(), + }, + { + id: "vmc_sales", + title: "Sales Agent", + calls: 94, + lastActiveAt: new Date(now - 45 * 60_000).toISOString(), + }, + { + id: "vmc_onboarding", + title: "Onboarding Assistant", + calls: 56, + lastActiveAt: new Date(now - 3 * 3_600_000).toISOString(), + }, + { + id: "vmc_analytics", + title: "Analytics Bot", + calls: 23, + lastActiveAt: new Date(now - 8 * 3_600_000).toISOString(), + }, + ]; +} + +const MODEL_NAMES = [ + "claude-sonnet-4-20250514", + "claude-haiku-4-20250414", + "gpt-4o", +]; + +export function getMockLlmStats(params: { + startDate: string; + endDate: string; + interval: "1m" | "1h" | "1d"; +}) { + const timeseries = generateTimeseries( + params.startDate, + params.endDate, + params.interval, + ); + // Scale down for LLM calls + const llmTimeseries = timeseries.map((p) => ({ + ...p, + calls: Math.max(1, Math.floor(p.calls * 0.3)), + errors: Math.random() < 0.05 ? 1 : 0, + avg: Math.floor(800 + Math.random() * 2000), + p50: Math.floor(600 + Math.random() * 1000), + p95: Math.floor(2000 + Math.random() * 4000), + })); + + const totalCalls = llmTimeseries.reduce((s, p) => s + p.calls, 0); + const totalErrors = llmTimeseries.reduce((s, p) => s + p.errors, 0); + const avgDurationMs = Math.round( + llmTimeseries.reduce((s, p) => s + p.avg, 0) / + Math.max(llmTimeseries.length, 1), + ); + const p50DurationMs = Math.round( + llmTimeseries.reduce((s, p) => s + p.p50, 0) / + Math.max(llmTimeseries.length, 1), + ); + const p95DurationMs = Math.round( + llmTimeseries.reduce((s, p) => s + p.p95, 0) / + Math.max(llmTimeseries.length, 1), + ); + + const topTools = MODEL_NAMES.map((name) => ({ + toolName: name, + connectionId: null, + calls: Math.floor(totalCalls * (0.2 + Math.random() * 0.4)), + })); + + return { + totalCalls, + totalErrors, + avgDurationMs, + p50DurationMs, + p95DurationMs, + connectionBreakdown: [], + topTools, + timeseries: llmTimeseries, + }; +} diff --git a/apps/mesh/src/web/components/monitoring/monitoring-filters.tsx b/apps/mesh/src/web/components/monitoring/monitoring-filters.tsx new file mode 100644 index 0000000000..f6af3710bb --- /dev/null +++ b/apps/mesh/src/web/components/monitoring/monitoring-filters.tsx @@ -0,0 +1,539 @@ +/** + * Monitoring Filters Popover + * + * Filter controls for the monitoring dashboard (connections, agents, tool name, status, property filters). + */ + +import { + type MonitoringSearchParams, + type PropertyFilter, + type PropertyFilterOperator, + serializePropertyFilters, + propertyFiltersToApiParams, + propertyFiltersToRaw, + parseRawPropertyFilters, +} from "@/web/components/monitoring"; +import { Badge } from "@deco/ui/components/badge.tsx"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import { MultiSelect } from "@deco/ui/components/multi-select.tsx"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@deco/ui/components/popover.tsx"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@deco/ui/components/select.tsx"; +import { Switch } from "@deco/ui/components/switch.tsx"; +import { Textarea } from "@deco/ui/components/textarea.tsx"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; +import { FilterLines, Plus, Trash01, Code01, Grid01 } from "@untitledui/icons"; +import { useRef, useState } from "react"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface FiltersPopoverProps { + connectionIds: string[]; + virtualMcpIds: string[]; + tool: string; + status: string; + hideSystem: boolean; + aiOnly: boolean; + onAiOnlyChange: (value: boolean) => void; + propertyFilters: PropertyFilter[]; + connectionOptions: Array<{ value: string; label: string }>; + virtualMcpOptions: Array<{ value: string; label: string }>; + activeFiltersCount: number; + onUpdateFilters: (updates: Partial) => void; + connectionSearchTerm?: string; + onConnectionSearchChange?: (term: string) => void; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const OPERATOR_OPTIONS: Array<{ + value: PropertyFilterOperator; + label: string; +}> = [ + { value: "eq", label: "equals" }, + { value: "contains", label: "contains" }, + { value: "in", label: "in (list)" }, + { value: "exists", label: "exists" }, +]; + +// ============================================================================ +// Component +// ============================================================================ + +export function FiltersPopover({ + connectionIds, + virtualMcpIds, + tool, + status, + hideSystem, + aiOnly, + onAiOnlyChange, + propertyFilters, + connectionOptions, + virtualMcpOptions, + activeFiltersCount, + onUpdateFilters, + onConnectionSearchChange, +}: FiltersPopoverProps) { + const [filterPopoverOpen, setFilterPopoverOpen] = useState(false); + const [propertyFilterMode, setPropertyFilterMode] = useState<"raw" | "form">( + "form", + ); + + // Local state for text inputs to prevent focus loss during typing + const [localTool, setLocalTool] = useState(tool); + const [localPropertyFilters, setLocalPropertyFilters] = + useState(propertyFilters); + const [localRawFilters, setLocalRawFilters] = useState( + propertyFiltersToRaw(propertyFilters), + ); + + // Track previous prop values to detect external changes + const prevToolRef = useRef(tool); + const prevPropertyFiltersRef = useRef( + serializePropertyFilters(propertyFilters), + ); + + // Sync local state when props change externally (not from our own updates) + if (prevToolRef.current !== tool) { + prevToolRef.current = tool; + if (localTool !== tool) { + setLocalTool(tool); + } + } + + const currentSerialized = serializePropertyFilters(propertyFilters); + if (prevPropertyFiltersRef.current !== currentSerialized) { + prevPropertyFiltersRef.current = currentSerialized; + setLocalPropertyFilters(propertyFilters); + setLocalRawFilters(propertyFiltersToRaw(propertyFilters)); + } + + const updatePropertyFilter = ( + index: number, + updates: Partial, + ) => { + const newFilters = [...localPropertyFilters]; + const existing = newFilters[index]; + if (!existing) return; + newFilters[index] = { + key: updates.key ?? existing.key, + operator: updates.operator ?? existing.operator, + value: updates.value ?? existing.value, + }; + setLocalPropertyFilters(newFilters); + }; + + const addPropertyFilter = () => { + setLocalPropertyFilters([ + ...localPropertyFilters, + { key: "", operator: "eq", value: "" }, + ]); + }; + + const removePropertyFilter = (index: number) => { + const newFilters = localPropertyFilters.filter((_, i) => i !== index); + setLocalPropertyFilters(newFilters); + setLocalRawFilters(propertyFiltersToRaw(newFilters)); + // Immediately sync when removing + onUpdateFilters({ propertyFilters: serializePropertyFilters(newFilters) }); + }; + + const applyPropertyFilters = () => { + onUpdateFilters({ + propertyFilters: serializePropertyFilters(localPropertyFilters), + }); + }; + + const applyRawFilters = () => { + const parsed = parseRawPropertyFilters(localRawFilters); + setLocalPropertyFilters(parsed); + onUpdateFilters({ + propertyFilters: serializePropertyFilters(parsed), + }); + }; + + const toggleMode = () => { + if (propertyFilterMode === "raw") { + // Switching to form mode - parse raw + const parsed = parseRawPropertyFilters(localRawFilters); + setLocalPropertyFilters(parsed); + setPropertyFilterMode("form"); + } else { + // Switching to raw mode - serialize form + setLocalRawFilters(propertyFiltersToRaw(localPropertyFilters)); + setPropertyFilterMode("raw"); + } + }; + + return ( + + + + + +
+
+

Filter Logs

+
+ +
+
+ + + onUpdateFilters({ hideSystem: !!checked }) + } + /> +
+ +
+ + onAiOnlyChange(!!checked)} + /> +
+ +
+ + + onUpdateFilters({ connectionId: values }) + } + onSearchChange={onConnectionSearchChange} + placeholder="All servers" + variant="secondary" + className="w-full" + maxCount={2} + /> +
+ +
+ + + onUpdateFilters({ virtualMcpId: values }) + } + placeholder="All Agents" + variant="secondary" + className="w-full" + maxCount={2} + /> +
+ +
+ + ) => + setLocalTool(e.target.value) + } + onBlur={() => { + if (localTool !== tool) { + onUpdateFilters({ tool: localTool }); + } + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter" && localTool !== tool) { + onUpdateFilters({ tool: localTool }); + } + }} + className="w-full" + /> +
+ +
+ + +
+ +
+
+ + + + + + + {propertyFilterMode === "raw" + ? "Switch to form view" + : "Switch to raw text"} + + +
+ + {propertyFilterMode === "raw" ? ( +
+