wu981526092's picture
🚀 Deploy AgentGraph: Complete agent monitoring and knowledge graph system
c2ea5ed
import React, { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Link,
Download,
Eye,
Upload,
Hash,
FileText,
Database,
Calendar,
} from "lucide-react";
import { useModal } from "@/context/ModalContext";
import { useToast } from "@/hooks/use-toast";
import { useNavigation } from "@/context/NavigationContext";
import { useAgentGraph } from "@/context/AgentGraphContext";
import { api } from "@/lib/api";
import langfuseIcon from "@/static/langfuse.png";
import langsmithIcon from "@/static/langsmith.png";
import { PreprocessingMethodModal } from "@/components/shared/modals/PreprocessingMethodModal";
interface Connection {
id: string;
platform: string;
status: string;
connected_at: string;
last_sync: string | null;
projects: Array<{
id: string;
name: string;
description?: string;
created_at?: string;
}>;
}
interface FetchedTrace {
id: string;
name: string;
platform: string;
fetched_at: string;
generated_timestamp?: string;
imported: boolean;
data?: any;
connection_id?: string;
}
export function ConnectionsView() {
const [connections, setConnections] = useState<Connection[]>([]);
const [fetchedTraces, setFetchedTraces] = useState<FetchedTrace[]>([]);
const [selectedTraces, setSelectedTraces] = useState<Set<string>>(new Set());
const [fetchingConnections, setFetchingConnections] = useState<Set<string>>(
new Set()
);
const [selectedProjects, setSelectedProjects] = useState<Map<string, string>>(
new Map()
);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [connectionToDelete, setConnectionToDelete] =
useState<Connection | null>(null);
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
const [previewTrace, setPreviewTrace] = useState<FetchedTrace | null>(null);
const [preprocessingModalOpen, setPreprocessingModalOpen] = useState(false);
const [tracesToImport, setTracesToImport] = useState<FetchedTrace[]>([]);
const [isImporting, setIsImporting] = useState(false);
const { openModal } = useModal();
const { toast } = useToast();
const { actions: navigationActions } = useNavigation();
const { actions: agentGraphActions } = useAgentGraph();
const loadConnections = async () => {
try {
const response = await api.observability.getConnections();
setConnections(
response.connections.map((conn: any) => ({
...conn,
last_sync: null, // Add missing field
projects: conn.projects || [], // Add projects field
})) || []
);
// Load fetched traces for each connection using connection-specific API
const allFetchedTraces: FetchedTrace[] = [];
for (const conn of response.connections) {
try {
const fetchedResponse = await api.observability.getFetchedTracesByConnection(
conn.id
);
const tracesWithConnectionId = fetchedResponse.traces.map((trace: any) => ({
...trace,
connection_id: conn.id
}));
allFetchedTraces.push(...tracesWithConnectionId);
} catch (error) {
console.warn(
`Failed to load fetched traces for connection ${conn.id}:`,
error
);
}
}
setFetchedTraces(allFetchedTraces);
} catch (error) {
console.warn("Failed to load connections:", error);
setConnections([]);
}
};
useEffect(() => {
loadConnections();
}, []);
useEffect(() => {
const handleConnectionUpdate = () => {
loadConnections();
};
window.addEventListener(
"observability-connection-updated",
handleConnectionUpdate
);
return () => {
window.removeEventListener(
"observability-connection-updated",
handleConnectionUpdate
);
};
}, []);
// Auto-select first project for connections that don't have a project selected
useEffect(() => {
if (connections.length > 0) {
const newSelected = new Map(selectedProjects);
let hasChanges = false;
connections.forEach(connection => {
if (!selectedProjects.has(connection.id) && connection.projects && connection.projects.length > 0) {
const firstProject = connection.projects[0];
if (firstProject?.name) {
newSelected.set(connection.id, firstProject.name);
hasChanges = true;
}
}
});
if (hasChanges) {
setSelectedProjects(newSelected);
}
}
}, [connections, selectedProjects]);
const handleAddConnection = () => {
openModal("observability-connection", "Connect to AI Observability");
};
const handleEditConnection = (connection: Connection) => {
openModal(
"observability-connection",
`Edit ${connection.platform} Connection`,
{
editConnection: connection,
}
);
};
const handleDeleteConnection = async (connection: Connection) => {
setConnectionToDelete(connection);
setDeleteDialogOpen(true);
};
const confirmDelete = async () => {
if (!connectionToDelete) return;
try {
await api.observability.deleteConnection(connectionToDelete.id);
loadConnections();
setDeleteDialogOpen(false);
setConnectionToDelete(null);
} catch (error) {
console.error("Failed to delete connection:", error);
}
};
const cancelDelete = () => {
setDeleteDialogOpen(false);
setConnectionToDelete(null);
};
const handleFetchTraces = async (connectionId: string, projectName?: string) => {
setFetchingConnections((prev) => new Set(prev).add(connectionId));
try {
// Get the connection to find project info
const connection = connections.find(conn => conn.id === connectionId);
const selectedProjectName = selectedProjects.get(connectionId) || projectName;
let projectId: string | undefined;
if (connection && selectedProjectName) {
const selectedProject = connection.projects.find(p => p.name === selectedProjectName);
projectId = selectedProject?.id;
}
// Trigger fetch from specific connection with project info
await api.observability.fetchTracesByConnection(connectionId, 50, projectId, selectedProjectName);
// Reload all traces to ensure consistency
loadConnections();
} catch (error) {
console.error("Failed to fetch traces:", error);
} finally {
setFetchingConnections((prev) => {
const newSet = new Set(prev);
newSet.delete(connectionId);
return newSet;
});
}
};
const handleDownloadTrace = async (trace: FetchedTrace) => {
try {
// Much simpler! Just need the trace ID
const response = await api.observability.downloadTrace(trace.id);
const jsonContent = JSON.stringify(response.data, null, 2);
// Create blob and download
const blob = new Blob([jsonContent], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${trace.name}_${trace.platform}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: "Download Complete",
description: `${trace.name} has been downloaded successfully.`,
});
} catch (error) {
console.error("Error downloading trace:", error);
toast({
title: "Download Failed",
description: "Failed to download trace. Please try again.",
variant: "destructive",
});
}
};
const handleBulkImport = async () => {
const selectedTracesToImport = fetchedTraces.filter(
(t) => selectedTraces.has(t.id) && !t.imported
);
if (selectedTracesToImport.length === 0) return;
// Store traces to import and show preprocessing method modal
setTracesToImport(selectedTracesToImport);
setPreprocessingModalOpen(true);
};
const handlePreprocessingMethodConfirm = async (preprocessingOptions: {
max_char: number | null;
topk: number;
raw: boolean;
hierarchy: boolean;
replace: boolean;
}) => {
setIsImporting(true);
setPreprocessingModalOpen(false);
const successfulImports: string[] = [];
const failedImports: string[] = [];
try {
// Process each trace by uploading the preprocessed data
for (const trace of tracesToImport) {
try {
console.log(
`Processing trace ${trace.id} from ${trace.platform} with preprocessing options:`, preprocessingOptions
);
// Find the connection for this trace
const connection = connections.find(conn => conn.id === trace.connection_id);
if (!connection) {
throw new Error(`No connection found for trace ${trace.id}`);
}
// Call backend import API using connection-specific endpoint
const result = await api.observability.importTracesByConnection(
connection.id,
[trace.id],
preprocessingOptions
);
if (result.imported > 0) {
successfulImports.push(trace.id);
console.log(`Successfully processed trace ${trace.id}`);
} else {
failedImports.push(trace.id);
console.log(`Failed to process trace ${trace.id}:`, result.errors);
}
} catch (error) {
console.error(`Failed to process trace ${trace.id}:`, error);
failedImports.push(trace.id);
}
}
// Update UI state for successfully imported traces
if (successfulImports.length > 0) {
setFetchedTraces((prev) =>
prev.map((t) =>
successfulImports.includes(t.id) ? { ...t, imported: true } : t
)
);
}
// Clear selection
setSelectedTraces(new Set());
// Refresh traces list after successful import
if (successfulImports.length > 0) {
try {
const tracesData = await api.traces.list();
// Update traces in AgentGraphContext
agentGraphActions.setTraces(
Array.isArray(tracesData) ? tracesData : []
);
} catch (error) {
console.error("Failed to refresh traces list:", error);
}
}
// Show appropriate toast based on results
if (successfulImports.length > 0 && failedImports.length === 0) {
toast({
title: "Traces Imported Successfully",
description: `${successfulImports.length} trace${
successfulImports.length > 1 ? "s" : ""
} imported to your traces.`,
variant: "default",
});
// Navigate to main page (traces section) only if all imports succeeded
navigationActions.setCurrentSection("traces");
} else if (successfulImports.length > 0 && failedImports.length > 0) {
toast({
title: "Partial Import Success",
description: `${successfulImports.length} traces imported successfully, ${failedImports.length} failed.`,
variant: "default",
});
} else {
toast({
title: "Import Failed",
description:
"Failed to import all selected traces. Please try again.",
variant: "destructive",
});
}
} catch (error) {
console.error("Failed to import traces:", error);
toast({
title: "Import Failed",
description: "Failed to import traces. Please try again.",
variant: "destructive",
});
} finally {
setIsImporting(false);
setTracesToImport([]);
}
};
const handlePreprocessingMethodCancel = () => {
setPreprocessingModalOpen(false);
setTracesToImport([]);
};
const handlePreviewTrace = (trace: FetchedTrace) => {
setPreviewTrace(trace);
setPreviewDialogOpen(true);
};
const getPlatformIcon = (platform: string) => {
switch (platform.toLowerCase()) {
case "langfuse":
return (
<img
src={langfuseIcon}
alt="Langfuse"
className="h-6 w-6 object-contain"
/>
);
case "langsmith":
return (
<img
src={langsmithIcon}
alt="LangSmith"
className="h-6 w-6 object-contain"
/>
);
default:
return "AI";
}
};
if (connections.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8">
<div className="text-center space-y-6 max-w-md">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto">
<Link className="h-8 w-8 text-primary" />
</div>
<div className="space-y-2">
<h2 className="text-2xl font-bold">Connect AI Observability</h2>
<p className="text-muted-foreground">
Connect to your AI observability platforms to fetch and import
traces
</p>
</div>
<Button onClick={handleAddConnection} size="lg" className="gap-2">
<Link className="h-4 w-4" />
Add Connection
</Button>
</div>
</div>
);
}
return (
<>
<div className="flex-1 p-6 space-y-6 overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Connections</h1>
<p className="text-muted-foreground">
Manage your AI observability platform connections
</p>
</div>
<Button onClick={handleAddConnection} className="gap-2">
<Link className="h-4 w-4" />
Add Connection
</Button>
</div>
{/* Platform Connection Cards */}
<div className="space-y-6">
{connections.map((connection) => {
const isFetching = fetchingConnections.has(connection.id);
const selectedProject = selectedProjects.get(connection.id);
const connectionTraces = fetchedTraces.filter(
(trace) => trace.connection_id === connection.id
);
const connectionSelectedTraces = new Set(
Array.from(selectedTraces).filter((id) =>
connectionTraces.some((trace) => trace.id === id)
)
);
return (
<Card key={connection.id} className="overflow-hidden">
<CardContent className="p-6">
{/* Connection Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
{getPlatformIcon(connection.platform)}
</div>
<div>
<h3 className="text-lg font-semibold capitalize">
{connection.platform === "langfuse"
? "Langfuse"
: "LangSmith"}
</h3>
<p className="text-sm text-muted-foreground">
Connected{" "}
{new Date(
connection.connected_at
).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditConnection(connection)}
>
Edit
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteConnection(connection)}
>
Delete
</Button>
</div>
</div>
{/* Fetch Controls */}
<div className="space-y-4 mb-6">
<div className="flex items-center gap-3">
{/* Project Selection */}
{connection.projects &&
connection.projects.length > 0 && (
<div className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-2 block">
Select Project
</label>
<select
value={
selectedProject ||
connection.projects[0]?.name ||
""
}
onChange={(e) => {
const newSelected = new Map(selectedProjects);
newSelected.set(connection.id, e.target.value);
setSelectedProjects(newSelected);
}}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md bg-white"
disabled={isFetching}
>
{connection.projects.map((project) => (
<option key={project.id} value={project.name}>
{project.name}
</option>
))}
</select>
</div>
)}
{/* Fetch Button */}
<div className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-2 block">
Fetch Traces
</label>
<Button
onClick={() =>
handleFetchTraces(
connection.id,
selectedProject
)
}
disabled={isFetching}
className="w-full"
>
{isFetching ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent mr-2" />
Fetching...
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
Fetch Traces
</>
)}
</Button>
</div>
</div>
</div>
{/* Connection Fetched Traces */}
{connectionTraces.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-md font-semibold">
Fetched Traces ({connectionTraces.length})
</h4>
<div className="flex items-center gap-2">
{/* Import All Button */}
<Button
size="sm"
variant="outline"
onClick={() => {
// Import all non-imported traces without changing selection
const tracesToImportAll = connectionTraces.filter(
(trace) => !trace.imported
);
if (tracesToImportAll.length === 0) return;
// Store traces to import and show preprocessing method modal
setTracesToImport(tracesToImportAll);
setPreprocessingModalOpen(true);
}}
disabled={connectionTraces.every(
(trace) => trace.imported
)}
title="Import all traces"
>
<Upload className="h-3 w-3 mr-1" />
Import All (
{connectionTraces.filter((t) => !t.imported).length})
</Button>
{/* Import Selected Button */}
<Button
size="sm"
onClick={handleBulkImport}
disabled={
connectionSelectedTraces.size === 0 ||
!Array.from(connectionSelectedTraces).some(
(id) =>
!connectionTraces.find((t) => t.id === id)
?.imported
)
}
title="Import selected traces"
>
<Upload className="h-3 w-3 mr-1" />
Import Selected ({connectionSelectedTraces.size})
</Button>
</div>
</div>
<div className="border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left p-3 font-medium">
<input
type="checkbox"
className="mr-2"
onChange={(e) => {
const newSelected = new Set(
selectedTraces
);
if (e.target.checked) {
connectionTraces.forEach((trace) =>
newSelected.add(trace.id)
);
} else {
connectionTraces.forEach((trace) =>
newSelected.delete(trace.id)
);
}
setSelectedTraces(newSelected);
}}
checked={
connectionTraces.length > 0 &&
connectionTraces.every((trace) =>
selectedTraces.has(trace.id)
)
}
/>
Name
</th>
<th className="text-left p-3 font-medium">
Fetch Time
</th>
<th className="text-left p-3 font-medium">
Generated At
</th>
<th className="text-left p-3 font-medium">
Actions
</th>
</tr>
</thead>
<tbody>
{connectionTraces.map((trace) => (
<tr
key={trace.id}
className="border-b hover:bg-muted/20"
>
<td className="p-3">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={selectedTraces.has(trace.id)}
onChange={(e) => {
const newSelected = new Set(
selectedTraces
);
if (e.target.checked) {
newSelected.add(trace.id);
} else {
newSelected.delete(trace.id);
}
setSelectedTraces(newSelected);
}}
/>
<span className="font-medium">
{trace.name}
</span>
{trace.imported && (
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
Imported
</span>
)}
</div>
</td>
<td className="p-3 text-sm text-muted-foreground">
{new Date(
trace.fetched_at
).toLocaleString()}
</td>
<td className="p-3 text-sm text-muted-foreground">
{trace.generated_timestamp
? new Date(
trace.generated_timestamp
).toLocaleString()
: new Date(
trace.fetched_at
).toLocaleString()}
</td>
<td className="p-3">
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() =>
handlePreviewTrace(trace)
}
>
<Eye className="h-3 w-3" />
</Button>
<Button
size="sm"
onClick={() =>
handleDownloadTrace(trace)
}
variant="outline"
title="Download JSON"
>
<Download className="h-3 w-3" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Connection</DialogTitle>
<DialogDescription>
Are you sure you want to delete the {connectionToDelete?.platform}{" "}
connection? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={cancelDelete}>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Preview Trace Dialog */}
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
<DialogHeader>
<DialogTitle>Preview {previewTrace?.name}</DialogTitle>
<DialogDescription>
{previewTrace?.platform} trace data
</DialogDescription>
</DialogHeader>
{/* Content Statistics */}
{previewTrace && (
<div className="px-4 py-2 bg-muted/50 rounded-lg border">
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1 flex-shrink-0">
<Hash className="h-4 w-4" />
{JSON.stringify(
previewTrace.data,
null,
2
).length.toLocaleString()}{" "}
characters
</span>
<span className="flex items-center gap-1 flex-shrink-0">
<FileText className="h-4 w-4" />
{JSON.stringify(previewTrace.data, null, 2)
.split("\n")
.length.toLocaleString()}{" "}
lines
</span>
<span className="flex items-center gap-1 flex-shrink-0">
<Database className="h-4 w-4" />
{(() => {
const content = JSON.stringify(previewTrace.data, null, 2);
const wordCount = content.trim()
? content.trim().split(/\s+/).length
: 0;
return wordCount.toLocaleString();
})()}{" "}
words
</span>
<span className="flex items-center gap-1 flex-shrink-0">
<Calendar className="h-4 w-4" />
<span className="truncate max-w-40">
{previewTrace.generated_timestamp
? new Date(
previewTrace.generated_timestamp
).toLocaleString()
: new Date(previewTrace.fetched_at).toLocaleString()}
</span>
</span>
</div>
</div>
)}
<div className="flex-1 overflow-hidden">
<pre className="bg-muted p-4 rounded-md text-sm overflow-auto max-h-[50vh] whitespace-pre-wrap">
{previewTrace ? JSON.stringify(previewTrace.data, null, 2) : ""}
</pre>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setPreviewDialogOpen(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Preprocessing Method Selection Modal */}
<PreprocessingMethodModal
open={preprocessingModalOpen}
onOpenChange={setPreprocessingModalOpen}
onConfirm={handlePreprocessingMethodConfirm}
onCancel={handlePreprocessingMethodCancel}
isLoading={isImporting}
/>
</>
);
}