Spaces:
Sleeping
Sleeping
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 |
-
|
| 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! I
|
| 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 |
/**
|