nothingworry commited on
Commit
1d2a779
·
1 Parent(s): 0e8c152

feat: Add real-time reasoning visualizer, tool timeline, and tenant heatmap components

Browse files
frontend/app/analytics/page.tsx CHANGED
@@ -3,6 +3,7 @@
3
  import Link from "next/link";
4
 
5
  import { AnalyticsPanel } from "@/components/analytics-panel";
 
6
  import { Footer } from "@/components/footer";
7
  import { TenantSelector } from "@/components/tenant-selector";
8
  import { useTenant } from "@/contexts/TenantContext";
@@ -34,14 +35,11 @@ export default function AnalyticsPage() {
34
  <div className="rounded-2xl border border-red-500/50 bg-red-500/10 p-8 text-center">
35
  <h2 className="text-2xl font-bold text-red-300 mb-2">Access Denied</h2>
36
  <p className="text-slate-300 mb-4">
37
- You need <strong>Admin</strong> or <strong>Owner</strong> role to view analytics.
38
  </p>
39
  <p className="text-sm text-slate-400">
40
  Your current role: <strong className="text-slate-200">{role.charAt(0).toUpperCase() + role.slice(1)}</strong>
41
  </p>
42
- <p className="text-sm text-slate-400 mt-2">
43
- Please switch your role using the dropdown in the header.
44
- </p>
45
  </div>
46
  <Footer />
47
  </main>
@@ -72,6 +70,11 @@ export default function AnalyticsPage() {
72
  </header>
73
 
74
  <AnalyticsPanel />
 
 
 
 
 
75
  <Footer />
76
  </main>
77
  );
 
3
  import Link from "next/link";
4
 
5
  import { AnalyticsPanel } from "@/components/analytics-panel";
6
+ import { TenantHeatmap } from "@/components/tenant-heatmap";
7
  import { Footer } from "@/components/footer";
8
  import { TenantSelector } from "@/components/tenant-selector";
9
  import { useTenant } from "@/contexts/TenantContext";
 
35
  <div className="rounded-2xl border border-red-500/50 bg-red-500/10 p-8 text-center">
36
  <h2 className="text-2xl font-bold text-red-300 mb-2">Access Denied</h2>
37
  <p className="text-slate-300 mb-4">
38
+ Unable to access analytics. Please check your role permissions.
39
  </p>
40
  <p className="text-sm text-slate-400">
41
  Your current role: <strong className="text-slate-200">{role.charAt(0).toUpperCase() + role.slice(1)}</strong>
42
  </p>
 
 
 
43
  </div>
44
  <Footer />
45
  </main>
 
70
  </header>
71
 
72
  <AnalyticsPanel />
73
+
74
+ <div className="mt-6">
75
+ <TenantHeatmap days={7} />
76
+ </div>
77
+
78
  <Footer />
79
  </main>
80
  );
frontend/components/chat-panel.tsx CHANGED
@@ -2,11 +2,15 @@
2
 
3
  import { useMemo, useState } from "react";
4
  import { useTenant } from "@/contexts/TenantContext";
 
 
5
 
6
  type Message = {
7
  role: "user" | "assistant" | "system";
8
  content: string;
9
  meta?: string;
 
 
10
  };
11
 
12
  const API_BASE =
@@ -20,11 +24,14 @@ export function ChatPanel() {
20
  {
21
  role: "assistant",
22
  content:
23
- "Hi there! Im the IntegraChat orchestrator. Ask anything about your tenant data and I will route the right MCP tools.",
24
  meta: "Agent ready",
25
  },
26
  ]);
27
  const [lastDecision, setLastDecision] = useState<string | null>(null);
 
 
 
28
 
29
  const conversationPayload = useMemo(
30
  () =>
@@ -69,14 +76,26 @@ export function ChatPanel() {
69
  const assistantText =
70
  data?.text ??
71
  "Agent responded but text field was empty. Inspect FastAPI logs for clues.";
 
 
 
 
 
72
  setHistory((prev) => [
73
  ...prev,
74
  {
75
  role: "assistant",
76
  content: assistantText,
77
  meta: data?.decision?.reason ?? "response",
 
 
78
  },
79
  ]);
 
 
 
 
 
80
  setLastDecision(
81
  data?.decision
82
  ? `${data.decision.action} · ${data.decision.tool ?? "llm"}`
@@ -162,6 +181,32 @@ export function ChatPanel() {
162
  : "No tool invocation yet"}
163
  </div>
164
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  </section>
166
  );
167
  }
 
2
 
3
  import { useMemo, useState } from "react";
4
  import { useTenant } from "@/contexts/TenantContext";
5
+ import { ReasoningVisualizer } from "@/components/reasoning-visualizer";
6
+ import { ToolTimeline } from "@/components/tool-timeline";
7
 
8
  type Message = {
9
  role: "user" | "assistant" | "system";
10
  content: string;
11
  meta?: string;
12
+ reasoningTrace?: Array<Record<string, any>>;
13
+ toolTraces?: Array<Record<string, any>>;
14
  };
15
 
16
  const API_BASE =
 
24
  {
25
  role: "assistant",
26
  content:
27
+ "Hi there! I'm the IntegraChat orchestrator. Ask anything about your tenant data and I will route the right MCP tools.",
28
  meta: "Agent ready",
29
  },
30
  ]);
31
  const [lastDecision, setLastDecision] = useState<string | null>(null);
32
+ const [showVisualizations, setShowVisualizations] = useState(true);
33
+ const [currentReasoningTrace, setCurrentReasoningTrace] = useState<Array<Record<string, any>>>([]);
34
+ const [currentToolTraces, setCurrentToolTraces] = useState<Array<Record<string, any>>>([]);
35
 
36
  const conversationPayload = useMemo(
37
  () =>
 
76
  const assistantText =
77
  data?.text ??
78
  "Agent responded but text field was empty. Inspect FastAPI logs for clues.";
79
+
80
+ // Extract reasoning trace and tool traces
81
+ const reasoningTrace = data?.reasoning_trace || [];
82
+ const toolTraces = data?.tool_traces || [];
83
+
84
  setHistory((prev) => [
85
  ...prev,
86
  {
87
  role: "assistant",
88
  content: assistantText,
89
  meta: data?.decision?.reason ?? "response",
90
+ reasoningTrace,
91
+ toolTraces,
92
  },
93
  ]);
94
+
95
+ // Update current visualizations
96
+ setCurrentReasoningTrace(reasoningTrace);
97
+ setCurrentToolTraces(toolTraces);
98
+
99
  setLastDecision(
100
  data?.decision
101
  ? `${data.decision.action} · ${data.decision.tool ?? "llm"}`
 
181
  : "No tool invocation yet"}
182
  </div>
183
  </div>
184
+
185
+ {/* Visualizations Toggle */}
186
+ {(currentReasoningTrace.length > 0 || currentToolTraces.length > 0) && (
187
+ <div className="mt-6">
188
+ <button
189
+ onClick={() => setShowVisualizations(!showVisualizations)}
190
+ className="mb-4 flex w-full items-center justify-between rounded-xl border border-white/10 bg-slate-950/40 px-4 py-3 text-sm font-semibold text-white transition hover:bg-slate-900/60"
191
+ >
192
+ <span>Visualizations</span>
193
+ <span>{showVisualizations ? "▼" : "▶"}</span>
194
+ </button>
195
+
196
+ {showVisualizations && (
197
+ <div className="space-y-6">
198
+ <ReasoningVisualizer
199
+ reasoningTrace={currentReasoningTrace}
200
+ isActive={isSending}
201
+ />
202
+ <ToolTimeline
203
+ toolTraces={currentToolTraces}
204
+ reasoningTrace={currentReasoningTrace}
205
+ />
206
+ </div>
207
+ )}
208
+ </div>
209
+ )}
210
  </section>
211
  );
212
  }
frontend/components/reasoning-visualizer.tsx ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ type ReasoningStep = {
6
+ step: string;
7
+ status: "pending" | "running" | "completed" | "error";
8
+ message?: string;
9
+ details?: Record<string, any>;
10
+ timestamp?: number;
11
+ };
12
+
13
+ type ReasoningVisualizerProps = {
14
+ reasoningTrace?: Array<Record<string, any>>;
15
+ isActive?: boolean;
16
+ onComplete?: () => void;
17
+ };
18
+
19
+ const STEP_ICONS: Record<string, string> = {
20
+ request_received: "📥",
21
+ admin_rules_check: "🛡️",
22
+ intent_detection: "🧠",
23
+ rag_prefetch: "📚",
24
+ tool_scoring: "📊",
25
+ tool_selection: "🎯",
26
+ tool_execution: "⚙️",
27
+ llm_response: "💬",
28
+ result_merger: "🔀",
29
+ parallel_execution: "⚡",
30
+ error: "❌",
31
+ };
32
+
33
+ const STEP_LABELS: Record<string, string> = {
34
+ request_received: "Request Received",
35
+ admin_rules_check: "Checking Admin Rules",
36
+ intent_detection: "Detecting Intent",
37
+ rag_prefetch: "Pre-fetching RAG Results",
38
+ tool_scoring: "Scoring Tools",
39
+ tool_selection: "Selecting Tools",
40
+ tool_execution: "Executing Tools",
41
+ llm_response: "Generating Response",
42
+ result_merger: "Merging Results",
43
+ parallel_execution: "Parallel Execution",
44
+ error: "Error",
45
+ };
46
+
47
+ export function ReasoningVisualizer({
48
+ reasoningTrace = [],
49
+ isActive = false,
50
+ onComplete,
51
+ }: ReasoningVisualizerProps) {
52
+ const [steps, setSteps] = useState<ReasoningStep[]>([]);
53
+ const [currentStepIndex, setCurrentStepIndex] = useState(0);
54
+
55
+ useEffect(() => {
56
+ if (!reasoningTrace || reasoningTrace.length === 0) {
57
+ setSteps([]);
58
+ setCurrentStepIndex(0);
59
+ return;
60
+ }
61
+
62
+ // Convert reasoning trace to visual steps
63
+ const visualSteps: ReasoningStep[] = reasoningTrace.map((trace, idx) => {
64
+ const stepName = trace.step || "unknown";
65
+ const icon = STEP_ICONS[stepName] || "⚙️";
66
+ const label = STEP_LABELS[stepName] || stepName.replace(/_/g, " ");
67
+
68
+ // Build message from trace data
69
+ let message = label;
70
+ const details: Record<string, any> = {};
71
+
72
+ if (stepName === "admin_rules_check") {
73
+ const matchCount = trace.match_count || 0;
74
+ message = matchCount > 0
75
+ ? `Found ${matchCount} rule violation(s)`
76
+ : "No violations found";
77
+ details.matches = trace.matches || [];
78
+ } else if (stepName === "intent_detection") {
79
+ message = `Intent: ${trace.intent || "unknown"}`;
80
+ details.intent = trace.intent;
81
+ } else if (stepName === "rag_prefetch") {
82
+ const hitCount = trace.hit_count || 0;
83
+ message = hitCount > 0
84
+ ? `Found ${hitCount} relevant document(s)`
85
+ : "No documents found";
86
+ details.hit_count = hitCount;
87
+ details.latency_ms = trace.latency_ms;
88
+ } else if (stepName === "tool_selection") {
89
+ const decision = trace.decision;
90
+ if (decision) {
91
+ message = `Selected: ${decision.tool || "llm"} (${decision.action})`;
92
+ details.decision = decision;
93
+ }
94
+ } else if (stepName === "tool_execution") {
95
+ const tool = trace.tool || "unknown";
96
+ const hitCount = trace.hit_count || 0;
97
+ message = `${tool.toUpperCase()}: ${hitCount} result(s)`;
98
+ details.tool = tool;
99
+ details.hit_count = hitCount;
100
+ } else if (stepName === "result_merger") {
101
+ const mergedItems = trace.merged_items || 0;
102
+ message = `Merged ${mergedItems} result(s)`;
103
+ details.merged_items = mergedItems;
104
+ details.sources = trace.sources || [];
105
+ } else if (stepName === "llm_response") {
106
+ message = "Generating final response";
107
+ details.latency_ms = trace.latency_ms;
108
+ details.estimated_tokens = trace.estimated_tokens;
109
+ }
110
+
111
+ return {
112
+ step: stepName,
113
+ status: idx < currentStepIndex ? "completed" : idx === currentStepIndex ? "running" : "pending",
114
+ message,
115
+ details,
116
+ timestamp: Date.now(),
117
+ };
118
+ });
119
+
120
+ setSteps(visualSteps);
121
+
122
+ // Animate through steps if active
123
+ if (isActive && visualSteps.length > 0) {
124
+ const interval = setInterval(() => {
125
+ setCurrentStepIndex((prev) => {
126
+ if (prev < visualSteps.length - 1) {
127
+ return prev + 1;
128
+ } else {
129
+ clearInterval(interval);
130
+ if (onComplete) onComplete();
131
+ return prev;
132
+ }
133
+ });
134
+ }, 800); // 800ms per step
135
+
136
+ return () => clearInterval(interval);
137
+ } else if (!isActive && visualSteps.length > 0) {
138
+ // Show all steps as completed if not active
139
+ setCurrentStepIndex(visualSteps.length);
140
+ }
141
+ }, [reasoningTrace, isActive, currentStepIndex, onComplete]);
142
+
143
+ if (steps.length === 0) {
144
+ return (
145
+ <div className="rounded-2xl border border-white/10 bg-slate-950/40 p-6">
146
+ <p className="text-sm text-slate-400 text-center">
147
+ No reasoning trace available. Send a message to see the agent's reasoning path.
148
+ </p>
149
+ </div>
150
+ );
151
+ }
152
+
153
+ return (
154
+ <div className="rounded-2xl border border-white/10 bg-slate-950/40 p-6">
155
+ <div className="mb-4 flex items-center justify-between">
156
+ <h3 className="text-lg font-semibold text-white">Real-Time Reasoning Path</h3>
157
+ <span className="text-xs text-slate-400">{steps.length} steps</span>
158
+ </div>
159
+
160
+ <div className="space-y-3">
161
+ {steps.map((step, idx) => {
162
+ const isCompleted = step.status === "completed";
163
+ const isRunning = step.status === "running";
164
+ const isPending = step.status === "pending";
165
+
166
+ return (
167
+ <div
168
+ key={idx}
169
+ className={`relative flex items-start gap-4 rounded-xl border p-4 transition-all ${
170
+ isRunning
171
+ ? "border-cyan-500/50 bg-cyan-500/10 shadow-lg shadow-cyan-500/20"
172
+ : isCompleted
173
+ ? "border-emerald-500/30 bg-emerald-500/5"
174
+ : "border-white/5 bg-white/5 opacity-50"
175
+ }`}
176
+ >
177
+ {/* Step number and icon */}
178
+ <div
179
+ className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-lg transition-all ${
180
+ isRunning
181
+ ? "bg-cyan-500 text-white animate-pulse"
182
+ : isCompleted
183
+ ? "bg-emerald-500 text-white"
184
+ : "bg-slate-700 text-slate-400"
185
+ }`}
186
+ >
187
+ {isRunning ? (
188
+ <span className="animate-spin">⏳</span>
189
+ ) : isCompleted ? (
190
+ "✓"
191
+ ) : (
192
+ idx + 1
193
+ )}
194
+ </div>
195
+
196
+ {/* Step content */}
197
+ <div className="flex-1 min-w-0">
198
+ <div className="flex items-center gap-2">
199
+ <span className="text-lg">{STEP_ICONS[step.step] || "⚙️"}</span>
200
+ <h4 className="font-semibold text-white">
201
+ {STEP_LABELS[step.step] || step.step.replace(/_/g, " ")}
202
+ </h4>
203
+ {isRunning && (
204
+ <span className="ml-auto text-xs text-cyan-300 animate-pulse">
205
+ Running...
206
+ </span>
207
+ )}
208
+ </div>
209
+ <p className="mt-1 text-sm text-slate-300">{step.message}</p>
210
+
211
+ {/* Step details */}
212
+ {step.details && Object.keys(step.details).length > 0 && isCompleted && (
213
+ <div className="mt-2 space-y-1 text-xs text-slate-400">
214
+ {step.details.latency_ms && (
215
+ <span>⏱️ {step.details.latency_ms}ms</span>
216
+ )}
217
+ {step.details.hit_count !== undefined && (
218
+ <span>📊 {step.details.hit_count} hits</span>
219
+ )}
220
+ {step.details.estimated_tokens && (
221
+ <span>🔢 ~{step.details.estimated_tokens} tokens</span>
222
+ )}
223
+ {step.details.score && (
224
+ <span>⭐ Score: {step.details.score.toFixed(2)}</span>
225
+ )}
226
+ </div>
227
+ )}
228
+ </div>
229
+
230
+ {/* Connecting line */}
231
+ {idx < steps.length - 1 && (
232
+ <div
233
+ className={`absolute left-[29px] top-[50px] h-6 w-0.5 ${
234
+ isCompleted ? "bg-emerald-500/50" : "bg-slate-700"
235
+ }`}
236
+ />
237
+ )}
238
+ </div>
239
+ );
240
+ })}
241
+ </div>
242
+ </div>
243
+ );
244
+ }
245
+
frontend/components/tenant-heatmap.tsx ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useTenant } from "@/contexts/TenantContext";
5
+
6
+ type HeatmapData = {
7
+ hour: number;
8
+ day: number; // 0-6 (Sunday-Saturday)
9
+ count: number;
10
+ };
11
+
12
+ type ToolUsageTrend = {
13
+ tool: string;
14
+ count: number;
15
+ trend: "up" | "down" | "stable";
16
+ };
17
+
18
+ type TenantHeatmapProps = {
19
+ days?: number;
20
+ };
21
+
22
+ const API_BASE =
23
+ process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
24
+
25
+ const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
26
+ const HOURS = Array.from({ length: 24 }, (_, i) => i);
27
+
28
+ export function TenantHeatmap({ days = 7 }: TenantHeatmapProps) {
29
+ const { tenantId, userRole } = useTenant();
30
+ const [loading, setLoading] = useState(false);
31
+ const [heatmapData, setHeatmapData] = useState<HeatmapData[]>([]);
32
+ const [toolTrends, setToolTrends] = useState<ToolUsageTrend[]>([]);
33
+ const [maxCount, setMaxCount] = useState(1);
34
+
35
+ useEffect(() => {
36
+ async function fetchHeatmapData() {
37
+ if (!tenantId) return;
38
+
39
+ setLoading(true);
40
+ try {
41
+ // Fetch activity data
42
+ const activityRes = await fetch(
43
+ `${API_BASE}/analytics/activity?days=${days}`,
44
+ {
45
+ headers: {
46
+ "x-tenant-id": tenantId,
47
+ "x-user-role": userRole,
48
+ },
49
+ },
50
+ );
51
+
52
+ if (!activityRes.ok) {
53
+ throw new Error(`Activity endpoint returned ${activityRes.status}`);
54
+ }
55
+
56
+ const activityData = await activityRes.json();
57
+
58
+ // Fetch tool usage stats
59
+ const toolRes = await fetch(
60
+ `${API_BASE}/analytics/tool-usage?days=${days}`,
61
+ {
62
+ headers: {
63
+ "x-tenant-id": tenantId,
64
+ "x-user-role": userRole,
65
+ },
66
+ },
67
+ );
68
+
69
+ if (!toolRes.ok) {
70
+ throw new Error(`Tool usage endpoint returned ${toolRes.status}`);
71
+ }
72
+
73
+ const toolData = await toolRes.json();
74
+
75
+ // Process activity data into heatmap format
76
+ const heatmap: HeatmapData[] = [];
77
+ const activityByHour: Record<string, number> = {};
78
+
79
+ // Group activities by hour and day
80
+ if (activityData.activities) {
81
+ activityData.activities.forEach((activity: any) => {
82
+ const timestamp = new Date(activity.timestamp || activity.created_at);
83
+ const hour = timestamp.getHours();
84
+ const day = timestamp.getDay();
85
+ const key = `${day}-${hour}`;
86
+
87
+ activityByHour[key] = (activityByHour[key] || 0) + 1;
88
+ });
89
+ }
90
+
91
+ // Build heatmap data
92
+ for (let day = 0; day < 7; day++) {
93
+ for (let hour = 0; hour < 24; hour++) {
94
+ const key = `${day}-${hour}`;
95
+ const count = activityByHour[key] || 0;
96
+ heatmap.push({ hour, day, count });
97
+ }
98
+ }
99
+
100
+ setHeatmapData(heatmap);
101
+ setMaxCount(Math.max(...heatmap.map((h) => h.count), 1));
102
+
103
+ // Process tool trends
104
+ const trends: ToolUsageTrend[] = [];
105
+ if (toolData.tool_usage) {
106
+ Object.entries(toolData.tool_usage).forEach(([tool, stats]: [string, any]) => {
107
+ trends.push({
108
+ tool,
109
+ count: stats.count || 0,
110
+ trend: "stable", // Could be calculated from historical data
111
+ });
112
+ });
113
+ }
114
+
115
+ // Sort by count descending
116
+ trends.sort((a, b) => b.count - a.count);
117
+ setToolTrends(trends.slice(0, 10)); // Top 10 tools
118
+ } catch (err) {
119
+ console.error("Failed to fetch heatmap data:", err);
120
+ } finally {
121
+ setLoading(false);
122
+ }
123
+ }
124
+
125
+ fetchHeatmapData();
126
+ }, [tenantId, userRole, days]);
127
+
128
+ const getIntensity = (count: number) => {
129
+ if (maxCount === 0) return 0;
130
+ const ratio = count / maxCount;
131
+ if (ratio === 0) return 0;
132
+ if (ratio < 0.2) return 1;
133
+ if (ratio < 0.4) return 2;
134
+ if (ratio < 0.6) return 3;
135
+ if (ratio < 0.8) return 4;
136
+ return 5;
137
+ };
138
+
139
+ const getColorClass = (intensity: number) => {
140
+ const colors = [
141
+ "bg-slate-800/30 border-slate-700/30", // 0
142
+ "bg-cyan-500/20 border-cyan-500/30", // 1
143
+ "bg-cyan-500/40 border-cyan-500/50", // 2
144
+ "bg-cyan-500/60 border-cyan-500/70", // 3
145
+ "bg-cyan-500/80 border-cyan-500/90", // 4
146
+ "bg-cyan-500 border-cyan-400", // 5
147
+ ];
148
+ return colors[intensity] || colors[0];
149
+ };
150
+
151
+ return (
152
+ <div className="space-y-6">
153
+ {/* Query Heatmap */}
154
+ <div className="rounded-2xl border border-white/10 bg-slate-950/40 p-6">
155
+ <div className="mb-4 flex items-center justify-between">
156
+ <h3 className="text-lg font-semibold text-white">Query Activity Heatmap</h3>
157
+ <span className="text-xs text-slate-400">Last {days} days</span>
158
+ </div>
159
+
160
+ {loading ? (
161
+ <div className="flex h-64 items-center justify-center">
162
+ <span className="text-slate-400">Loading heatmap data...</span>
163
+ </div>
164
+ ) : (
165
+ <div className="overflow-x-auto">
166
+ <div className="inline-block min-w-full">
167
+ {/* Hour labels */}
168
+ <div className="mb-2 flex">
169
+ <div className="w-12 shrink-0" />
170
+ {HOURS.map((hour) => (
171
+ <div
172
+ key={hour}
173
+ className="flex-1 text-center text-[10px] text-slate-500"
174
+ >
175
+ {hour % 6 === 0 ? hour : ""}
176
+ </div>
177
+ ))}
178
+ </div>
179
+
180
+ {/* Heatmap grid */}
181
+ <div className="space-y-1">
182
+ {DAYS.map((dayName, dayIdx) => {
183
+ const dayData = heatmapData.filter((d) => d.day === dayIdx);
184
+ return (
185
+ <div key={dayIdx} className="flex items-center gap-2">
186
+ <div className="w-12 shrink-0 text-xs text-slate-400">
187
+ {dayName}
188
+ </div>
189
+ <div className="flex flex-1 gap-0.5">
190
+ {HOURS.map((hour) => {
191
+ const data = dayData.find((d) => d.hour === hour);
192
+ const count = data?.count || 0;
193
+ const intensity = getIntensity(count);
194
+
195
+ return (
196
+ <div
197
+ key={hour}
198
+ className={`h-4 flex-1 rounded border transition-all hover:scale-110 ${getColorClass(
199
+ intensity,
200
+ )}`}
201
+ title={`${dayName} ${hour}:00 - ${count} queries`}
202
+ />
203
+ );
204
+ })}
205
+ </div>
206
+ </div>
207
+ );
208
+ })}
209
+ </div>
210
+
211
+ {/* Legend */}
212
+ <div className="mt-4 flex items-center justify-center gap-4 text-xs text-slate-400">
213
+ <span>Less</span>
214
+ <div className="flex gap-0.5">
215
+ {[0, 1, 2, 3, 4, 5].map((intensity) => (
216
+ <div
217
+ key={intensity}
218
+ className={`h-3 w-3 rounded border ${getColorClass(intensity)}`}
219
+ />
220
+ ))}
221
+ </div>
222
+ <span>More</span>
223
+ </div>
224
+ </div>
225
+ </div>
226
+ )}
227
+ </div>
228
+
229
+ {/* Tool Usage Trends */}
230
+ <div className="rounded-2xl border border-white/10 bg-slate-950/40 p-6">
231
+ <div className="mb-4 flex items-center justify-between">
232
+ <h3 className="text-lg font-semibold text-white">Per-Tool Usage Trends</h3>
233
+ <span className="text-xs text-slate-400">Top 10 tools</span>
234
+ </div>
235
+
236
+ {loading ? (
237
+ <div className="flex h-32 items-center justify-center">
238
+ <span className="text-slate-400">Loading tool trends...</span>
239
+ </div>
240
+ ) : toolTrends.length === 0 ? (
241
+ <div className="flex h-32 items-center justify-center">
242
+ <span className="text-slate-400">No tool usage data available</span>
243
+ </div>
244
+ ) : (
245
+ <div className="space-y-3">
246
+ {toolTrends.map((trend, idx) => {
247
+ const maxToolCount = Math.max(...toolTrends.map((t) => t.count), 1);
248
+ const widthPercent = (trend.count / maxToolCount) * 100;
249
+
250
+ return (
251
+ <div key={idx} className="space-y-1">
252
+ <div className="flex items-center justify-between text-sm">
253
+ <div className="flex items-center gap-2">
254
+ <span className="font-medium text-white">{trend.tool}</span>
255
+ {trend.trend === "up" && (
256
+ <span className="text-xs text-emerald-400">↑</span>
257
+ )}
258
+ {trend.trend === "down" && (
259
+ <span className="text-xs text-red-400">↓</span>
260
+ )}
261
+ </div>
262
+ <span className="text-slate-300">{trend.count}</span>
263
+ </div>
264
+ <div className="h-2 overflow-hidden rounded-full bg-slate-800">
265
+ <div
266
+ className="h-full bg-gradient-to-r from-cyan-500 to-cyan-400 transition-all"
267
+ style={{ width: `${widthPercent}%` }}
268
+ />
269
+ </div>
270
+ </div>
271
+ );
272
+ })}
273
+ </div>
274
+ )}
275
+ </div>
276
+ </div>
277
+ );
278
+ }
279
+
frontend/components/tool-timeline.tsx ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+
5
+ type ToolInvocation = {
6
+ tool: string;
7
+ startTime: number;
8
+ endTime: number;
9
+ latency: number;
10
+ status: "success" | "error";
11
+ resultCount?: number;
12
+ };
13
+
14
+ type ToolTimelineProps = {
15
+ toolTraces?: Array<Record<string, any>>;
16
+ reasoningTrace?: Array<Record<string, any>>;
17
+ };
18
+
19
+ export function ToolTimeline({ toolTraces = [], reasoningTrace = [] }: ToolTimelineProps) {
20
+ const timelineData = useMemo(() => {
21
+ if (!toolTraces || toolTraces.length === 0) {
22
+ return [];
23
+ }
24
+
25
+ // Extract tool invocations from traces
26
+ const invocations: ToolInvocation[] = toolTraces.map((trace, idx) => {
27
+ const tool = trace.tool || trace.tool_name || "unknown";
28
+ const startTime = trace.start_time || trace.timestamp || idx * 100; // Fallback timing
29
+ const latency = trace.latency_ms || trace.latency || 0;
30
+ const endTime = startTime + latency;
31
+ const status = trace.status === "error" || trace.error ? "error" : "success";
32
+ const resultCount = trace.result_count || trace.hits || trace.results?.length || 0;
33
+
34
+ return {
35
+ tool,
36
+ startTime,
37
+ endTime,
38
+ latency,
39
+ status,
40
+ resultCount,
41
+ };
42
+ });
43
+
44
+ // Sort by start time
45
+ invocations.sort((a, b) => a.startTime - b.startTime);
46
+
47
+ // Calculate timeline bounds
48
+ const minTime = Math.min(...invocations.map((i) => i.startTime), 0);
49
+ const maxTime = Math.max(...invocations.map((i) => i.endTime), minTime + 1000);
50
+
51
+ // Normalize times to 0-100% for visualization
52
+ const timeRange = maxTime - minTime || 1000;
53
+
54
+ return invocations.map((inv) => ({
55
+ ...inv,
56
+ startPercent: ((inv.startTime - minTime) / timeRange) * 100,
57
+ widthPercent: (inv.latency / timeRange) * 100,
58
+ }));
59
+ }, [toolTraces]);
60
+
61
+ if (timelineData.length === 0) {
62
+ return (
63
+ <div className="rounded-2xl border border-white/10 bg-slate-950/40 p-6">
64
+ <p className="text-sm text-slate-400 text-center">
65
+ No tool invocations yet. Tools will appear here as they are executed.
66
+ </p>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ const totalLatency = timelineData.reduce((sum, inv) => sum + inv.latency, 0);
72
+ const maxLatency = Math.max(...timelineData.map((inv) => inv.latency), 0);
73
+
74
+ return (
75
+ <div className="rounded-2xl border border-white/10 bg-slate-950/40 p-6">
76
+ <div className="mb-4 flex items-center justify-between">
77
+ <h3 className="text-lg font-semibold text-white">Tool Invocation Timeline</h3>
78
+ <div className="text-xs text-slate-400">
79
+ <span className="text-cyan-300">{timelineData.length}</span> tools ·{" "}
80
+ <span className="text-emerald-300">{totalLatency}ms</span> total
81
+ </div>
82
+ </div>
83
+
84
+ {/* Timeline visualization */}
85
+ <div className="relative mb-6 h-32 rounded-xl border border-white/10 bg-slate-900/50 p-4">
86
+ {/* Time axis */}
87
+ <div className="absolute inset-x-4 top-2 flex justify-between text-xs text-slate-500">
88
+ <span>0ms</span>
89
+ <span>{maxLatency}ms</span>
90
+ </div>
91
+
92
+ {/* Tool bars */}
93
+ <div className="relative mt-8 h-full">
94
+ {timelineData.map((inv, idx) => {
95
+ const height = 24;
96
+ const top = idx * (height + 8);
97
+ const color =
98
+ inv.status === "error"
99
+ ? "bg-red-500/60 border-red-400"
100
+ : "bg-cyan-500/60 border-cyan-400";
101
+
102
+ return (
103
+ <div key={idx} className="absolute inset-x-0" style={{ top: `${top}px` }}>
104
+ {/* Tool label */}
105
+ <div className="absolute -left-24 top-0 flex h-6 w-20 items-center justify-end pr-2 text-xs text-slate-300">
106
+ <span className="truncate font-medium">{inv.tool}</span>
107
+ </div>
108
+
109
+ {/* Timeline bar */}
110
+ <div
111
+ className={`relative h-6 rounded border transition-all hover:opacity-100 ${
112
+ inv.status === "error" ? "opacity-80" : "opacity-70"
113
+ } ${color}`}
114
+ style={{
115
+ left: `${inv.startPercent}%`,
116
+ width: `${Math.max(inv.widthPercent, 2)}%`,
117
+ }}
118
+ title={`${inv.tool}: ${inv.latency}ms (${inv.status})`}
119
+ >
120
+ {/* Latency label */}
121
+ {inv.widthPercent > 5 && (
122
+ <span className="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-white">
123
+ {inv.latency}ms
124
+ </span>
125
+ )}
126
+
127
+ {/* Result count badge */}
128
+ {inv.resultCount > 0 && inv.widthPercent > 8 && (
129
+ <span className="absolute -right-2 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-emerald-500 text-[8px] font-bold text-white">
130
+ {inv.resultCount > 9 ? "9+" : inv.resultCount}
131
+ </span>
132
+ )}
133
+
134
+ {/* Error indicator */}
135
+ {inv.status === "error" && (
136
+ <span className="absolute -right-1 -top-1 text-red-400">⚠️</span>
137
+ )}
138
+ </div>
139
+ </div>
140
+ );
141
+ })}
142
+ </div>
143
+ </div>
144
+
145
+ {/* Tool list */}
146
+ <div className="space-y-2">
147
+ {timelineData.map((inv, idx) => (
148
+ <div
149
+ key={idx}
150
+ className="flex items-center justify-between rounded-lg border border-white/5 bg-white/5 px-4 py-2"
151
+ >
152
+ <div className="flex items-center gap-3">
153
+ <div
154
+ className={`h-3 w-3 rounded-full ${
155
+ inv.status === "error" ? "bg-red-500" : "bg-emerald-500"
156
+ }`}
157
+ />
158
+ <span className="font-medium text-white">{inv.tool}</span>
159
+ {inv.resultCount > 0 && (
160
+ <span className="text-xs text-slate-400">
161
+ {inv.resultCount} result{inv.resultCount !== 1 ? "s" : ""}
162
+ </span>
163
+ )}
164
+ </div>
165
+ <div className="flex items-center gap-4 text-sm">
166
+ <span className="text-slate-400">{inv.latency}ms</span>
167
+ {inv.status === "error" && (
168
+ <span className="text-red-400">Error</span>
169
+ )}
170
+ </div>
171
+ </div>
172
+ ))}
173
+ </div>
174
+
175
+ {/* Summary stats */}
176
+ <div className="mt-4 grid grid-cols-3 gap-3 rounded-lg border border-white/5 bg-white/5 p-3">
177
+ <div className="text-center">
178
+ <p className="text-xs text-slate-400">Total Tools</p>
179
+ <p className="text-lg font-semibold text-white">{timelineData.length}</p>
180
+ </div>
181
+ <div className="text-center">
182
+ <p className="text-xs text-slate-400">Total Time</p>
183
+ <p className="text-lg font-semibold text-emerald-300">{totalLatency}ms</p>
184
+ </div>
185
+ <div className="text-center">
186
+ <p className="text-xs text-slate-400">Avg Latency</p>
187
+ <p className="text-lg font-semibold text-cyan-300">
188
+ {Math.round(totalLatency / timelineData.length)}ms
189
+ </p>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ );
194
+ }
195
+
frontend/lib/permissions.ts CHANGED
@@ -21,7 +21,7 @@ const PERMISSIONS: Record<PermissionAction, UserRole[]> = {
21
  manage_rules: ["admin", "owner"],
22
  ingest_documents: ["editor", "admin", "owner"],
23
  delete_documents: ["admin", "owner"],
24
- view_analytics: ["admin", "owner"],
25
  };
26
 
27
  /**
 
21
  manage_rules: ["admin", "owner"],
22
  ingest_documents: ["editor", "admin", "owner"],
23
  delete_documents: ["admin", "owner"],
24
+ view_analytics: ["viewer", "editor", "admin", "owner"],
25
  };
26
 
27
  /**