Files
openagent/dashboard/src/app/modules/page.tsx
Thomas Marchand a3d3437b1d OpenCode workspace host + MCP sync + iOS fixes (#27)
* Add multi-user auth and per-user control sessions

* Add mission store abstraction and auth UX polish

* Fix unused warnings in tooling

* Fix Bugbot review issues

- Prevent username enumeration by using generic error message
- Add pagination support to InMemoryMissionStore::list_missions
- Improve config error when JWT_SECRET missing but DASHBOARD_PASSWORD set

* Trim stored username in comparison for consistency

* Fix mission cleanup to also remove orphaned tree data

* Refactor Open Agent as OpenCode workspace host

* Remove chromiumoxide and pin @types/react

* Pin idna_adapter for MSRV compatibility

* Add host-mcp bin target

* Use isolated Playwright MCP sessions

* Allow Playwright MCP as root

* Fix iOS dashboard warnings

* Add autoFocus to username field in multi-user login mode

Mirrors the iOS implementation behavior where username field is focused
when multi-user auth mode is active.

* Fix Bugbot review issues

- Add conditional ellipsis for tool descriptions (only when > 32 chars)
- Add serde(default) to JWT usr field for backward compatibility

* Fix empty user ID fallback in multi-user auth

Add effective_user_id helper that falls back to username when id is empty,
preventing session sharing and token verification issues.

* Fix parallel mission history preservation

Load existing mission history into runner before starting parallel
execution to prevent losing conversation context.

* Fix desktop stream controls layout overflow on iPad

- Add frame(maxWidth: .infinity) constraints to ensure controls stay
  within bounds on wide displays
- Add alignment: .leading to VStacks for consistent layout
- Add Spacer() to buttons row to prevent spreading
- Increase label width to 55 for consistent FPS/Quality alignment
- Add alignment: .trailing to value text frames

* Fix queued user messages not persisted to mission history

When a user message was queued (sent while another task was running),
it was not being added to the history or persisted to the database.
This caused queued messages to be lost from mission history.

Added the same persistence logic used for initial messages to the
queued message handling code path.
2026-01-04 13:04:05 -08:00

1057 lines
35 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState, useMemo } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import {
listMcps,
listTools,
addMcp,
removeMcp,
enableMcp,
disableMcp,
refreshMcp,
refreshAllMcps,
type McpServerState,
type McpStatus,
type ToolInfo,
} from "@/lib/api";
import { ShimmerCard } from "@/components/ui/shimmer";
import { CopyButton } from "@/components/ui/copy-button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import {
Plus,
RefreshCw,
Trash2,
X,
CheckCircle,
XCircle,
AlertTriangle,
Power,
ChevronLeft,
Plug,
Settings,
Search,
} from "lucide-react";
type TabType = "installed" | "tools";
const statusConfig: Record<
McpStatus,
{ color: string; bg: string; label: string }
> = {
connected: {
color: "text-emerald-400",
bg: "bg-emerald-500/10",
label: "Connected",
},
disconnected: {
color: "text-white/40",
bg: "bg-white/[0.04]",
label: "Disconnected",
},
error: { color: "text-red-400", bg: "bg-red-500/10", label: "Error" },
disabled: {
color: "text-white/40",
bg: "bg-white/[0.04]",
label: "Disabled",
},
};
function Toggle({
checked,
onChange,
}: {
checked: boolean;
onChange: () => void;
}) {
return (
<button
onClick={(e) => {
e.stopPropagation();
onChange();
}}
className={cn(
"relative h-5 w-9 rounded-full transition-colors",
checked ? "bg-emerald-500" : "bg-white/10"
)}
>
<span
className={cn(
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
checked ? "left-[18px]" : "left-0.5"
)}
/>
</button>
);
}
function McpCard({
mcp,
onSelect,
isSelected,
}: {
mcp: McpServerState;
onSelect: (mcp: McpServerState | null) => void;
isSelected: boolean;
}) {
const status = statusConfig[mcp.status];
return (
<button
onClick={() => onSelect(isSelected ? null : mcp)}
className={cn(
"w-full rounded-xl p-4 text-left transition-all",
"bg-white/[0.02] border hover:bg-white/[0.04]",
isSelected
? "border-indigo-500/50 ring-1 ring-indigo-500/30"
: "border-white/[0.04] hover:border-white/[0.08]"
)}
>
{/* Header with icon and status */}
<div className="flex items-start gap-3 mb-3">
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-xl",
mcp.enabled ? "bg-indigo-500/10" : "bg-white/[0.04]"
)}
>
<Plug
className={cn(
"h-5 w-5",
mcp.enabled ? "text-indigo-400" : "text-white/40"
)}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-white truncate">{mcp.name}</h3>
<span
className={cn(
"flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium",
status.bg,
status.color
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
mcp.status === "connected"
? "bg-emerald-400"
: mcp.status === "error"
? "bg-red-400"
: "bg-white/40"
)}
/>
{status.label}
</span>
</div>
<div className="flex items-center gap-1 group">
<p className="text-xs text-white/40 truncate">{mcp.endpoint}</p>
<CopyButton
text={mcp.endpoint}
showOnHover
label="Copied endpoint"
/>
</div>
</div>
</div>
{/* Tags */}
<div className="flex flex-wrap gap-1 mb-3">
{mcp.tools.slice(0, 3).map((tool) => (
<span key={tool} className="tag">
{tool}
</span>
))}
{mcp.tools.length > 3 && (
<span className="tag">+{mcp.tools.length - 3}</span>
)}
{mcp.tools.length === 0 && (
<span className="text-[10px] text-white/30">No tools</span>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between pt-3 border-t border-white/[0.04]">
<span className="text-[10px] text-white/30">
{mcp.tool_calls} calls
</span>
<Toggle checked={mcp.enabled} onChange={() => {}} />
</div>
</button>
);
}
function McpDetailPanel({
mcp,
onClose,
onToggle,
onRefresh,
onConfigure,
onDelete,
}: {
mcp: McpServerState;
onClose: () => void;
onToggle: () => void;
onRefresh: () => void;
onConfigure: () => void;
onDelete: () => void;
}) {
const accuracy =
mcp.tool_calls + mcp.tool_errors > 0
? ((mcp.tool_calls / (mcp.tool_calls + mcp.tool_errors)) * 100).toFixed(1)
: "100.0";
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm animate-fade-in"
onClick={onClose}
/>
{/* Panel */}
<div
className="fixed right-0 top-0 z-50 h-full w-96 flex flex-col glass-panel border-l border-white/[0.06] animate-slide-in-right"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-start justify-between border-b border-white/[0.06] p-4">
<div className="flex items-center gap-3">
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="flex h-8 w-8 items-center justify-center rounded-lg text-white/50 hover:bg-white/[0.04] hover:text-white transition-colors"
>
<ChevronLeft className="h-5 w-5" />
</button>
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-white">{mcp.name}</h2>
{mcp.version && <span className="tag">v{mcp.version}</span>}
</div>
<p className="text-xs text-white/40">{mcp.endpoint}</p>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="flex h-8 w-8 items-center justify-center rounded-lg text-white/50 hover:bg-white/[0.04] hover:text-white transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Active toggle */}
<div className="flex items-center justify-between rounded-xl bg-white/[0.02] border border-white/[0.04] p-4">
<div className="flex items-center gap-3">
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-xl",
!mcp.enabled
? "bg-white/[0.04]"
: mcp.status === "connected"
? "bg-emerald-500/10"
: mcp.status === "error"
? "bg-red-500/10"
: "bg-yellow-500/10"
)}
>
<Power
className={cn(
"h-5 w-5",
!mcp.enabled
? "text-white/40"
: mcp.status === "connected"
? "text-emerald-400"
: mcp.status === "error"
? "text-red-400"
: "text-yellow-400"
)}
/>
</div>
<div>
<p className="font-medium text-white">
{!mcp.enabled
? "Module Disabled"
: mcp.status === "connected"
? "Module Active"
: mcp.status === "error"
? "Module Error"
: "Module Pending"}
</p>
<p className="text-xs text-white/40">
{!mcp.enabled
? "Paused"
: mcp.status === "connected"
? "Running and monitoring"
: mcp.status === "error"
? "Connection failed"
: "Connecting..."}
</p>
</div>
</div>
<Toggle checked={mcp.enabled} onChange={onToggle} />
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3">
<div className="stat-panel text-center">
<p className="stat-label flex items-center justify-center gap-1">
<AlertTriangle className="h-3 w-3" />
CALLS
</p>
<p className="text-2xl font-light text-white tabular-nums">
{mcp.tool_calls}
</p>
</div>
<div className="stat-panel text-center">
<p className="stat-label flex items-center justify-center gap-1 text-red-400">
<XCircle className="h-3 w-3" />
ERRORS
</p>
<p className="text-2xl font-light text-white tabular-nums">
{mcp.tool_errors}
</p>
</div>
<div className="stat-panel text-center">
<p className="stat-label flex items-center justify-center gap-1 text-emerald-400">
<CheckCircle className="h-3 w-3" />
ACCURACY
</p>
<p className="text-2xl font-light text-emerald-400 tabular-nums">
{accuracy}%
</p>
</div>
</div>
{/* About */}
<div>
<h3 className="text-[10px] uppercase tracking-wider text-white/40 mb-2">
About
</h3>
<p className="text-sm text-white/80">
{mcp.description || `Module running at ${mcp.endpoint}`}
</p>
{mcp.last_connected_at && (
<p className="mt-2 text-xs text-white/40">
Last updated: {new Date(mcp.last_connected_at).toLocaleString()}
</p>
)}
{mcp.error && (
<div className="mt-2 rounded-lg bg-red-500/10 border border-red-500/20 p-3">
<p className="text-xs text-red-400">Error: {mcp.error}</p>
</div>
)}
</div>
{/* Active tools */}
<div>
<h3 className="text-[10px] uppercase tracking-wider text-white/40 mb-2">
Active Checks ({mcp.tools.length})
</h3>
<div className="space-y-2">
{mcp.tools.length === 0 ? (
<p className="text-sm text-white/40">No tools discovered</p>
) : (
mcp.tools.map((tool) => (
<div
key={tool}
className="flex items-center justify-between rounded-lg bg-white/[0.02] border border-white/[0.04] px-3 py-2.5"
>
<div className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
<span className="text-sm text-white">{tool}</span>
</div>
<span className="text-xs text-white/40">Active</span>
</div>
))
)}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-white/[0.06] p-4">
<span className="text-xs text-white/30">Last updated recently</span>
<div className="flex items-center gap-2">
<button
onClick={onRefresh}
className="flex items-center gap-1.5 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.06] px-3 py-1.5 text-xs text-white/80 transition-colors"
>
<RefreshCw className="h-3 w-3" />
Refresh
</button>
<button
onClick={onConfigure}
className="flex items-center gap-1.5 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.06] px-3 py-1.5 text-xs text-white/80 transition-colors"
>
<Settings className="h-3 w-3" />
Configure
</button>
<button
onClick={onDelete}
className="flex items-center gap-1.5 rounded-lg bg-red-500/10 hover:bg-red-500/20 border border-red-500/20 px-3 py-1.5 text-xs text-red-400 transition-colors"
>
<Trash2 className="h-3 w-3" />
Remove
</button>
</div>
</div>
</div>
</>
);
}
function AddMcpModal({
onClose,
onAdd,
}: {
onClose: () => void;
onAdd: (data: {
name: string;
endpoint: string;
description?: string;
}) => Promise<void>;
}) {
const [name, setName] = useState("");
const [endpoint, setEndpoint] = useState("");
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !endpoint.trim()) return;
setLoading(true);
setError(null);
try {
await onAdd({
name: name.trim(),
endpoint: endpoint.trim(),
description: description.trim() || undefined,
});
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to add MCP");
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-fade-in">
<div className="w-full max-w-md rounded-2xl glass-panel border border-white/[0.08] p-6 animate-slide-up">
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Add MCP Server</h2>
<button
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-lg text-white/50 hover:bg-white/[0.04] hover:text-white transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-white/60 mb-1.5">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Supabase MCP"
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2.5 text-sm text-white placeholder-white/30 focus:border-indigo-500/50 focus:outline-none transition-colors"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-white/60 mb-1.5">
Endpoint URL
</label>
<input
type="url"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="http://127.0.0.1:4011"
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2.5 text-sm text-white placeholder-white/30 focus:border-indigo-500/50 focus:outline-none transition-colors"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-white/60 mb-1.5">
Description (optional)
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this MCP do?"
rows={2}
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2.5 text-sm text-white placeholder-white/30 focus:border-indigo-500/50 focus:outline-none transition-colors resize-none"
/>
</div>
{error && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="rounded-lg bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.06] px-4 py-2.5 text-sm text-white/80 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading || !name.trim() || !endpoint.trim()}
className="rounded-lg bg-indigo-500 hover:bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Adding..." : "Add MCP"}
</button>
</div>
</form>
</div>
</div>
);
}
function ConfigureMcpModal({
mcp,
onClose,
onSave,
}: {
mcp: McpServerState;
onClose: () => void;
onSave: (data: {
name: string;
endpoint: string;
description?: string;
}) => Promise<void>;
}) {
const [name, setName] = useState(mcp.name);
const [endpoint, setEndpoint] = useState(mcp.endpoint);
const [description, setDescription] = useState(mcp.description || "");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !endpoint.trim()) return;
setLoading(true);
setError(null);
try {
await onSave({
name: name.trim(),
endpoint: endpoint.trim(),
description: description.trim() || undefined,
});
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update MCP");
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-fade-in">
<div className="w-full max-w-md rounded-2xl glass-panel border border-white/[0.08] p-6 animate-slide-up">
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">
Configure {mcp.name}
</h2>
<button
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-lg text-white/50 hover:bg-white/[0.04] hover:text-white transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-white/60 mb-1.5">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Supabase MCP"
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2.5 text-sm text-white placeholder-white/30 focus:border-indigo-500/50 focus:outline-none transition-colors"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-white/60 mb-1.5">
Endpoint URL
</label>
<input
type="url"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="https://mcp.supabase.com/mcp"
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2.5 text-sm text-white placeholder-white/30 focus:border-indigo-500/50 focus:outline-none transition-colors"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-white/60 mb-1.5">
Description (optional)
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this MCP do?"
rows={2}
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2.5 text-sm text-white placeholder-white/30 focus:border-indigo-500/50 focus:outline-none transition-colors resize-none"
/>
</div>
{/* Connection status info */}
<div className="rounded-lg bg-white/[0.02] border border-white/[0.04] p-3">
<p className="text-xs text-white/60 mb-2">Connection Status</p>
<div className="flex items-center gap-2">
<span
className={cn(
"h-2 w-2 rounded-full",
mcp.status === "connected"
? "bg-emerald-400"
: mcp.status === "error"
? "bg-red-400"
: "bg-white/40"
)}
/>
<span className="text-sm text-white capitalize">
{mcp.status}
</span>
</div>
{mcp.error && (
<p className="mt-2 text-xs text-red-400">{mcp.error}</p>
)}
</div>
{error && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="rounded-lg bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.06] px-4 py-2.5 text-sm text-white/80 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading || !name.trim() || !endpoint.trim()}
className="rounded-lg bg-indigo-500 hover:bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Saving..." : "Save Changes"}
</button>
</div>
</form>
</div>
</div>
);
}
function ToolsTab({ tools }: { tools: ToolInfo[] }) {
const mcpTools = tools.filter(
(t) => typeof t.source === "object" && "mcp" in t.source
);
return (
<div className="space-y-6">
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4 text-sm text-white/60">
Tools are provided by MCP servers and surfaced to OpenCode. Enable or
disable an MCP in the Installed tab to control availability.
</div>
<div>
<h3 className="mb-3 text-sm font-medium text-white">
MCP Tools ({mcpTools.length})
</h3>
{mcpTools.length === 0 ? (
<p className="text-sm text-white/40">No MCP tools discovered yet.</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{mcpTools.map((tool) => (
<div
key={tool.name}
className="flex items-center justify-between rounded-xl bg-white/[0.02] border border-white/[0.04] hover:bg-white/[0.04] hover:border-white/[0.08] p-4 transition-colors"
>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-500/10">
<Plug className="h-4 w-4 text-indigo-400" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white">
{tool.name}
</p>
<p className="text-xs text-white/40">
from{" "}
{typeof tool.source === "object" && "mcp" in tool.source
? tool.source.mcp.name
: "unknown"}
</p>
<p
className="truncate text-[11px] text-white/30 max-w-[150px]"
title={tool.description}
>
{tool.description.length > 32
? `${tool.description.slice(0, 32)}...`
: tool.description}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
export default function ModulesPage() {
const [activeTab, setActiveTab] = useState<TabType>("installed");
const [mcps, setMcps] = useState<McpServerState[]>([]);
const [tools, setTools] = useState<ToolInfo[]>([]);
const [selectedMcp, setSelectedMcp] = useState<McpServerState | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [showConfigureModal, setShowConfigureModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [mcpToDelete, setMcpToDelete] = useState<McpServerState | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [toolSearch, setToolSearch] = useState("");
// Filter MCPs by search
const filteredMcps = useMemo(() => {
if (!searchQuery.trim()) return mcps;
const query = searchQuery.toLowerCase();
return mcps.filter(
(m) =>
m.name.toLowerCase().includes(query) ||
m.endpoint.toLowerCase().includes(query) ||
m.tools.some((t) => t.toLowerCase().includes(query))
);
}, [mcps, searchQuery]);
// Filter tools by search
const filteredTools = useMemo(() => {
if (!toolSearch.trim()) return tools;
const query = toolSearch.toLowerCase();
return tools.filter(
(t) =>
t.name.toLowerCase().includes(query) ||
t.description.toLowerCase().includes(query)
);
}, [tools, toolSearch]);
const fetchData = useCallback(async () => {
try {
const [mcpsData, toolsData] = await Promise.all([
listMcps().catch(() => []),
listTools().catch(() => []),
]);
setMcps(mcpsData);
setTools(toolsData);
// Update selected MCP if it exists
if (selectedMcp) {
const updated = mcpsData.find((m) => m.id === selectedMcp.id);
if (updated) setSelectedMcp(updated);
}
} catch (error) {
console.error("Failed to fetch data:", error);
toast.error("Failed to fetch modules");
} finally {
setLoading(false);
}
}, [selectedMcp]);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 5000);
return () => clearInterval(interval);
}, [fetchData]);
const handleAddMcp = async (data: {
name: string;
endpoint: string;
description?: string;
}) => {
await addMcp(data);
toast.success(`Added ${data.name}`);
await fetchData();
};
const handleConfigureMcp = async (data: {
name: string;
endpoint: string;
description?: string;
}) => {
if (!selectedMcp) return;
// For now, we'll remove and re-add since there's no update endpoint
// In a full implementation, you'd have an updateMcp API endpoint
await removeMcp(selectedMcp.id);
const newMcp = await addMcp(data);
setSelectedMcp(newMcp);
toast.success(`Updated ${data.name}`);
await fetchData();
};
const handleToggleMcp = async (mcp: McpServerState) => {
try {
if (mcp.enabled) {
await disableMcp(mcp.id);
toast.success(`Disabled ${mcp.name}`);
} else {
await enableMcp(mcp.id);
toast.success(`Enabled ${mcp.name}`);
}
await fetchData();
} catch (error) {
console.error("Failed to toggle MCP:", error);
toast.error(`Failed to toggle ${mcp.name}`);
}
};
const handleRefreshMcp = async (mcp: McpServerState) => {
try {
await refreshMcp(mcp.id);
toast.success(`Refreshed ${mcp.name}`);
await fetchData();
} catch (error) {
console.error("Failed to refresh MCP:", error);
toast.error(`Failed to refresh ${mcp.name}`);
}
};
const handleDeleteMcp = async (mcp: McpServerState) => {
setMcpToDelete(mcp);
setShowDeleteConfirm(true);
};
const confirmDeleteMcp = async () => {
if (!mcpToDelete) return;
try {
await removeMcp(mcpToDelete.id);
toast.success(`Removed ${mcpToDelete.name}`);
setSelectedMcp(null);
await fetchData();
} catch (error) {
console.error("Failed to delete MCP:", error);
toast.error(`Failed to remove ${mcpToDelete.name}`);
} finally {
setShowDeleteConfirm(false);
setMcpToDelete(null);
}
};
const handleRefreshAll = async () => {
setRefreshing(true);
try {
await refreshAllMcps();
toast.success("Refreshed all MCP servers");
await fetchData();
} catch (error) {
console.error("Failed to refresh MCPs:", error);
toast.error("Failed to refresh MCP servers");
} finally {
setRefreshing(false);
}
};
return (
<div className="h-screen overflow-auto p-6">
{/* Header */}
<div className="mb-6 flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold text-white">Modules</h1>
<p className="mt-1 text-sm text-white/50">
Manage and discover check modules
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRefreshAll}
disabled={refreshing}
className="flex items-center gap-2 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.06] px-3 py-2 text-sm text-white/80 transition-colors disabled:opacity-50"
>
<RefreshCw
className={cn("h-4 w-4", refreshing && "animate-spin")}
/>
Refresh
</button>
<button
onClick={() => setShowAddModal(true)}
className="flex items-center gap-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 px-3 py-2 text-sm font-medium text-white transition-colors"
>
<Plus className="h-4 w-4" />
Add MCP
</button>
</div>
</div>
{/* Tabs */}
<div className="mb-6 flex items-center justify-between flex-wrap gap-4">
<div className="inline-flex rounded-lg bg-white/[0.02] border border-white/[0.04] p-1">
<button
onClick={() => setActiveTab("installed")}
className={cn(
"px-4 py-2 rounded-md text-sm font-medium transition-colors",
activeTab === "installed"
? "bg-white/[0.08] text-white"
: "text-white/40 hover:text-white/60"
)}
>
Installed ({mcps.length})
</button>
<button
onClick={() => setActiveTab("tools")}
className={cn(
"px-4 py-2 rounded-md text-sm font-medium transition-colors",
activeTab === "tools"
? "bg-white/[0.08] text-white"
: "text-white/40 hover:text-white/60"
)}
>
Tools ({tools.length})
</button>
</div>
{/* Search */}
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/30" />
<input
type="text"
placeholder={
activeTab === "installed" ? "Search MCPs..." : "Search tools..."
}
value={activeTab === "installed" ? searchQuery : toolSearch}
onChange={(e) =>
activeTab === "installed"
? setSearchQuery(e.target.value)
: setToolSearch(e.target.value)
}
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] py-2 pl-9 pr-3 text-sm text-white placeholder-white/30 focus:border-indigo-500/50 focus:outline-none transition-colors"
/>
</div>
</div>
{/* Content */}
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<ShimmerCard />
<ShimmerCard />
<ShimmerCard />
</div>
) : activeTab === "installed" ? (
filteredMcps.length === 0 ? (
<div className="flex flex-col items-center py-16 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/[0.02] mb-4">
<Plug className="h-8 w-8 text-white/30" />
</div>
<p className="text-white/80">
{searchQuery
? "No MCPs match your search"
: "No MCP servers configured"}
</p>
<p className="mt-1 text-sm text-white/40">
{searchQuery
? "Try a different search term"
: 'Click "Add MCP" to connect to an MCP server'}
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filteredMcps.map((mcp) => (
<McpCard
key={mcp.id}
mcp={mcp}
onSelect={setSelectedMcp}
isSelected={selectedMcp?.id === mcp.id}
/>
))}
</div>
)
) : (
<ToolsTab tools={filteredTools} />
)}
{/* Detail panel (overlay) */}
{selectedMcp && (
<McpDetailPanel
mcp={selectedMcp}
onClose={() => setSelectedMcp(null)}
onToggle={() => handleToggleMcp(selectedMcp)}
onRefresh={() => handleRefreshMcp(selectedMcp)}
onConfigure={() => setShowConfigureModal(true)}
onDelete={() => handleDeleteMcp(selectedMcp)}
/>
)}
{/* Add modal */}
{showAddModal && (
<AddMcpModal
onClose={() => setShowAddModal(false)}
onAdd={handleAddMcp}
/>
)}
{/* Configure modal */}
{showConfigureModal && selectedMcp && (
<ConfigureMcpModal
mcp={selectedMcp}
onClose={() => setShowConfigureModal(false)}
onSave={handleConfigureMcp}
/>
)}
{/* Delete confirmation dialog */}
<ConfirmDialog
open={showDeleteConfirm}
title={`Remove ${mcpToDelete?.name}?`}
description="This will disconnect the MCP server and remove it from your configuration. This action cannot be undone."
confirmLabel="Remove"
variant="danger"
onConfirm={confirmDeleteMcp}
onCancel={() => {
setShowDeleteConfirm(false);
setMcpToDelete(null);
}}
/>
</div>
);
}