mirror of
https://github.com/coleam00/Archon.git
synced 2026-01-01 04:09:08 -05:00
feat: TanStack Query Migration Phase 2 - Cleanup and Test Reorganization (#588)
* refactor: migrate layouts to TanStack Query and Radix UI patterns - Created new modern layout components in src/components/layout/ - Migrated from old MainLayout/SideNavigation to new system - Added BackendStatus component with proper separation of concerns - Fixed horizontal scrollbar issues in project list - Renamed old layouts folder to agent-chat for unused chat panel - Added layout directory to Biome configuration - Fixed all linting and TypeScript issues in new layout code - Uses TanStack Query for backend health monitoring - Temporarily imports old settings/credentials until full migration * test: reorganize test infrastructure with colocated tests in subdirectories - Move tests into dedicated tests/ subdirectories within each feature - Create centralized test utilities in src/features/testing/ - Update all import paths to match new structure - Configure tsconfig.prod.json to exclude test files - Remove legacy test files from old test/ directory - All 32 tests passing with proper provider wrapping * fix: use error boundary wrapper for ProjectPage - Export ProjectsViewWithBoundary from projects feature module - Update ProjectPage to use boundary-wrapped version - Provides proper error containment and recovery with TanStack Query integration * cleanup: remove unused MCP client components - Remove ToolTestingPanel, ClientCard, and MCPClients components - These were part of an unimplemented MCP clients feature - Clean up commented import in MCPPage - Preparing for proper MCP feature migration to features directory * cleanup: remove unused mcpService.ts - Remove duplicate/unused mcpService.ts (579 lines) - Keep mcpServerService.ts which is actively used by MCPPage and useMCPQueries - mcpService was never imported or used anywhere in the codebase * cleanup: remove unused mcpClientService and update deprecation comments - Remove mcpClientService.ts (445 lines) - no longer used after removing MCP client components - Update deprecation comments in mcpServerService to remove references to deleted service - This completes the MCP service cleanup * fix: correct test directory exclusion in coverage config Update coverage exclusion from 'test/' to 'tests/' to match actual project structure and ensure proper test file exclusion from coverage. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * docs: fix ArchonChatPanel import path in agent-chat.mdx Update import from deprecated layouts to agent-chat directory. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * refactor: improve backend health hook and types - Use existing ETag infrastructure in useBackendHealth for 70% bandwidth reduction - Honor React Query cancellation signals with proper timeout handling - Remove duplicate HealthResponse interface, import from shared types - Add React type import to fix potential strict TypeScript issues 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove .d.ts exclusion from production TypeScript config Removing **/*.d.ts exclusion to fix import.meta.env type errors in production builds. The exclusion was preventing src/env.d.ts from being included, breaking ImportMetaEnv interface definitions. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * feat: implement modern MCP feature architecture - Add new /features/mcp with TanStack Query integration - Components: McpClientList, McpStatusBar, McpConfigSection - Services: mcpApi with ETag caching - Hooks: useMcpStatus, useMcpConfig, useMcpClients, useMcpSessionInfo - Views: McpView with error boundary wrapper - Full TypeScript types for MCP protocol Part of TanStack Query migration phase 2. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * refactor: complete MCP modernization and cleanup - Remove deprecated mcpServerService.ts (237 lines) - Remove unused useMCPQueries.ts hooks (77 lines) - Simplify MCPPage.tsx to use new feature architecture - Export useSmartPolling from ui/hooks for MCP feature - Add Python MCP API routes for backend integration This completes the MCP migration to TanStack Query with: - ETag caching for 70% bandwidth reduction - Smart polling with visibility awareness - Vertical slice architecture - Full TypeScript type safety 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * fix: correct MCP transport mode display and complete cleanup - Fix backend API to return correct "streamable-http" transport mode - Update frontend to dynamically display transport type from config - Remove unused MCP functions (startMCPServer, stopMCPServer, getMCPServerStatus) - Clean up unused MCPServerResponse interface - Update log messages to show accurate transport mode - Complete aggressive MCP cleanup with 75% code reduction (617 lines removed) Backend changes: - python/src/server/api_routes/mcp_api.py: Fix transport and logs - Reduced from 818 to 201 lines while preserving all functionality Frontend changes: - McpStatusBar: Dynamic transport display based on config - McpView: Pass config to status bar component - api.ts: Remove unused MCP management functions All MCP tools tested and verified working after cleanup. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * simplify MCP API to status-only endpoints - Remove Docker container management functionality - Remove start/stop/restart endpoints - Simplify to status and config endpoints only - Container is now managed entirely via docker-compose * feat: complete MCP feature migration to TanStack Query - Add MCP feature with TanStack Query hooks and services - Create useMcpQueries hook with smart polling for status/config - Implement mcpApi service with streamable-http transport - Add MCP page component with real-time updates - Export MCP hooks from features/ui for global access - Fix logging bug in mcp_api.py (invalid error kwarg) - Update docker command to v2 syntax (docker compose) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: clean up unused CSS and unify Tron-themed scrollbars - Remove 200+ lines of unused CSS classes (62% file size reduction) - Delete unused: glass classes, neon-dividers, card animations, screensaver animations - Remove unused knowledge-item-card and hide-scrollbar styles - Remove unused flip-card and card expansion animations - Update scrollbar-thin to match Tron theme with blue glow effects - Add gradient and glow effects to thin scrollbars for consistency - Keep only actively used styles: neon-grid, scrollbars, animation delays File reduced from 11.2KB to 4.3KB with no visual regressions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: address CodeRabbit CSS review feedback - Fix neon-grid Tailwind @apply with arbitrary values (breaking build) - Convert hardcoded RGBA colors to HSL tokens using --blue-accent - Add prefers-reduced-motion accessibility support - Add Firefox dark mode scrollbar-color support - Optimize transitions to specific properties instead of 'all' 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * fix: properly close Docker client to prevent resource leak - Add finally block to ensure Docker client is closed - Prevents resource leak in get_container_status function - Fix linting issues (whitespace and newline) 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,16 @@ docker-compose.yml
|
||||
# Tests
|
||||
coverage
|
||||
test-results
|
||||
tests/
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.spec.ts
|
||||
**/*.spec.tsx
|
||||
**/__tests__
|
||||
**/*.e2e.test.ts
|
||||
**/*.integration.test.ts
|
||||
vitest.config.ts
|
||||
tsconfig.prod.json
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
import React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
const createMockIcon = (name: string) => {
|
||||
const MockIcon = React.forwardRef(({ className, ...props }: any, ref: any) => (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
data-testid={`${name.toLowerCase()}-icon`}
|
||||
data-lucide={name}
|
||||
{...props}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))
|
||||
MockIcon.displayName = name
|
||||
return MockIcon
|
||||
}
|
||||
|
||||
// Export all icons used in the app
|
||||
export const Settings = createMockIcon('Settings')
|
||||
export const Check = createMockIcon('Check')
|
||||
export const CheckCircle = createMockIcon('CheckCircle')
|
||||
export const X = createMockIcon('X')
|
||||
export const XCircle = createMockIcon('XCircle')
|
||||
export const Eye = createMockIcon('Eye')
|
||||
export const EyeOff = createMockIcon('EyeOff')
|
||||
export const Save = createMockIcon('Save')
|
||||
export const Loader = createMockIcon('Loader')
|
||||
export const Loader2 = createMockIcon('Loader2')
|
||||
export const RefreshCw = createMockIcon('RefreshCw')
|
||||
export const Play = createMockIcon('Play')
|
||||
export const Pause = createMockIcon('Pause')
|
||||
export const Square = createMockIcon('Square')
|
||||
export const FileText = createMockIcon('FileText')
|
||||
export const Download = createMockIcon('Download')
|
||||
export const Upload = createMockIcon('Upload')
|
||||
export const ChevronDown = createMockIcon('ChevronDown')
|
||||
export const ChevronUp = createMockIcon('ChevronUp')
|
||||
export const ChevronLeft = createMockIcon('ChevronLeft')
|
||||
export const ChevronRight = createMockIcon('ChevronRight')
|
||||
export const Plus = createMockIcon('Plus')
|
||||
export const Minus = createMockIcon('Minus')
|
||||
export const Edit = createMockIcon('Edit')
|
||||
export const Edit2 = createMockIcon('Edit2')
|
||||
export const Edit3 = createMockIcon('Edit3')
|
||||
export const Trash = createMockIcon('Trash')
|
||||
export const Trash2 = createMockIcon('Trash2')
|
||||
export const User = createMockIcon('User')
|
||||
export const Users = createMockIcon('Users')
|
||||
export const Bot = createMockIcon('Bot')
|
||||
export const Database = createMockIcon('Database')
|
||||
export const Server = createMockIcon('Server')
|
||||
export const Globe = createMockIcon('Globe')
|
||||
export const Search = createMockIcon('Search')
|
||||
export const Filter = createMockIcon('Filter')
|
||||
export const Copy = createMockIcon('Copy')
|
||||
export const ExternalLink = createMockIcon('ExternalLink')
|
||||
export const Info = createMockIcon('Info')
|
||||
export const AlertCircle = createMockIcon('AlertCircle')
|
||||
export const AlertTriangle = createMockIcon('AlertTriangle')
|
||||
export const Zap = createMockIcon('Zap')
|
||||
export const Code = createMockIcon('Code')
|
||||
export const Terminal = createMockIcon('Terminal')
|
||||
export const Book = createMockIcon('Book')
|
||||
export const BookOpen = createMockIcon('BookOpen')
|
||||
export const Folder = createMockIcon('Folder')
|
||||
export const FolderOpen = createMockIcon('FolderOpen')
|
||||
export const File = createMockIcon('File')
|
||||
export const Hash = createMockIcon('Hash')
|
||||
export const Tag = createMockIcon('Tag')
|
||||
export const Clock = createMockIcon('Clock')
|
||||
export const Calendar = createMockIcon('Calendar')
|
||||
export const MapPin = createMockIcon('MapPin')
|
||||
export const Link = createMockIcon('Link')
|
||||
export const Mail = createMockIcon('Mail')
|
||||
export const Phone = createMockIcon('Phone')
|
||||
export const Home = createMockIcon('Home')
|
||||
export const Menu = createMockIcon('Menu')
|
||||
export const MoreHorizontal = createMockIcon('MoreHorizontal')
|
||||
export const MoreVertical = createMockIcon('MoreVertical')
|
||||
export const Refresh = createMockIcon('Refresh')
|
||||
export const RotateCcw = createMockIcon('RotateCcw')
|
||||
export const RotateCw = createMockIcon('RotateCw')
|
||||
export const Sun = createMockIcon('Sun')
|
||||
export const Moon = createMockIcon('Moon')
|
||||
export const Monitor = createMockIcon('Monitor')
|
||||
export const Wifi = createMockIcon('Wifi')
|
||||
export const WifiOff = createMockIcon('WifiOff')
|
||||
export const Volume2 = createMockIcon('Volume2')
|
||||
export const VolumeX = createMockIcon('VolumeX')
|
||||
export const BarChart = createMockIcon('BarChart')
|
||||
export const PieChart = createMockIcon('PieChart')
|
||||
export const TrendingUp = createMockIcon('TrendingUp')
|
||||
export const TrendingDown = createMockIcon('TrendingDown')
|
||||
export const ArrowUp = createMockIcon('ArrowUp')
|
||||
export const ArrowDown = createMockIcon('ArrowDown')
|
||||
export const ArrowLeft = createMockIcon('ArrowLeft')
|
||||
export const ArrowRight = createMockIcon('ArrowRight')
|
||||
export const Send = createMockIcon('Send')
|
||||
export const MessageSquare = createMockIcon('MessageSquare')
|
||||
export const MessageCircle = createMockIcon('MessageCircle')
|
||||
export const Heart = createMockIcon('Heart')
|
||||
export const Star = createMockIcon('Star')
|
||||
export const Bookmark = createMockIcon('Bookmark')
|
||||
export const Share = createMockIcon('Share')
|
||||
export const Share2 = createMockIcon('Share2')
|
||||
export const Maximize = createMockIcon('Maximize')
|
||||
export const Minimize = createMockIcon('Minimize')
|
||||
export const Expand = createMockIcon('Expand')
|
||||
export const Shrink = createMockIcon('Shrink')
|
||||
export const Move = createMockIcon('Move')
|
||||
export const Shuffle = createMockIcon('Shuffle')
|
||||
export const Repeat = createMockIcon('Repeat')
|
||||
export const StopCircle = createMockIcon('StopCircle')
|
||||
export const SkipBack = createMockIcon('SkipBack')
|
||||
export const SkipForward = createMockIcon('SkipForward')
|
||||
export const FastForward = createMockIcon('FastForward')
|
||||
export const Rewind = createMockIcon('Rewind')
|
||||
export const Camera = createMockIcon('Camera')
|
||||
export const Image = createMockIcon('Image')
|
||||
export const Video = createMockIcon('Video')
|
||||
export const Mic = createMockIcon('Mic')
|
||||
export const MicOff = createMockIcon('MicOff')
|
||||
export const Headphones = createMockIcon('Headphones')
|
||||
export const Speaker = createMockIcon('Speaker')
|
||||
export const Bell = createMockIcon('Bell')
|
||||
export const BellOff = createMockIcon('BellOff')
|
||||
export const Shield = createMockIcon('Shield')
|
||||
export const ShieldCheck = createMockIcon('ShieldCheck')
|
||||
export const ShieldAlert = createMockIcon('ShieldAlert')
|
||||
export const Key = createMockIcon('Key')
|
||||
export const Lock = createMockIcon('Lock')
|
||||
export const Unlock = createMockIcon('Unlock')
|
||||
export const LogIn = createMockIcon('LogIn')
|
||||
export const LogOut = createMockIcon('LogOut')
|
||||
export const UserPlus = createMockIcon('UserPlus')
|
||||
export const UserMinus = createMockIcon('UserMinus')
|
||||
export const UserCheck = createMockIcon('UserCheck')
|
||||
export const UserX = createMockIcon('UserX')
|
||||
export const Package = createMockIcon('Package')
|
||||
export const Package2 = createMockIcon('Package2')
|
||||
export const ShoppingCart = createMockIcon('ShoppingCart')
|
||||
export const ShoppingBag = createMockIcon('ShoppingBag')
|
||||
export const CreditCard = createMockIcon('CreditCard')
|
||||
export const DollarSign = createMockIcon('DollarSign')
|
||||
export const Percent = createMockIcon('Percent')
|
||||
export const Activity = createMockIcon('Activity')
|
||||
export const Cpu = createMockIcon('Cpu')
|
||||
export const HardDrive = createMockIcon('HardDrive')
|
||||
export const MemoryStick = createMockIcon('MemoryStick')
|
||||
export const Smartphone = createMockIcon('Smartphone')
|
||||
export const Tablet = createMockIcon('Tablet')
|
||||
export const Laptop = createMockIcon('Laptop')
|
||||
export const Monitor2 = createMockIcon('Monitor2')
|
||||
export const Tv = createMockIcon('Tv')
|
||||
export const Watch = createMockIcon('Watch')
|
||||
export const Gamepad2 = createMockIcon('Gamepad2')
|
||||
export const Mouse = createMockIcon('Mouse')
|
||||
export const Keyboard = createMockIcon('Keyboard')
|
||||
export const Printer = createMockIcon('Printer')
|
||||
export const Scanner = createMockIcon('Scanner')
|
||||
export const Webcam = createMockIcon('Webcam')
|
||||
export const Bluetooth = createMockIcon('Bluetooth')
|
||||
export const Usb = createMockIcon('Usb')
|
||||
export const Zap2 = createMockIcon('Zap2')
|
||||
export const Battery = createMockIcon('Battery')
|
||||
export const BatteryCharging = createMockIcon('BatteryCharging')
|
||||
export const Plug = createMockIcon('Plug')
|
||||
export const Power = createMockIcon('Power')
|
||||
export const PowerOff = createMockIcon('PowerOff')
|
||||
export const BarChart2 = createMockIcon('BarChart2')
|
||||
export const BarChart3 = createMockIcon('BarChart3')
|
||||
export const BarChart4 = createMockIcon('BarChart4')
|
||||
export const LineChart = createMockIcon('LineChart')
|
||||
export const PieChart2 = createMockIcon('PieChart2')
|
||||
export const Layers = createMockIcon('Layers')
|
||||
export const Layers2 = createMockIcon('Layers2')
|
||||
export const Layers3 = createMockIcon('Layers3')
|
||||
export const Grid = createMockIcon('Grid')
|
||||
export const Grid2x2 = createMockIcon('Grid2x2')
|
||||
export const Grid3x3 = createMockIcon('Grid3x3')
|
||||
export const List = createMockIcon('List')
|
||||
export const ListChecks = createMockIcon('ListChecks')
|
||||
export const ListTodo = createMockIcon('ListTodo')
|
||||
export const CheckSquare = createMockIcon('CheckSquare')
|
||||
export const Square2 = createMockIcon('Square2')
|
||||
export const Circle = createMockIcon('Circle')
|
||||
export const CircleCheck = createMockIcon('CircleCheck')
|
||||
export const CircleX = createMockIcon('CircleX')
|
||||
export const CircleDot = createMockIcon('CircleDot')
|
||||
export const Target = createMockIcon('Target')
|
||||
export const Focus = createMockIcon('Focus')
|
||||
export const Crosshair = createMockIcon('Crosshair')
|
||||
export const Locate = createMockIcon('Locate')
|
||||
export const LocateFixed = createMockIcon('LocateFixed')
|
||||
export const Navigation = createMockIcon('Navigation')
|
||||
export const Navigation2 = createMockIcon('Navigation2')
|
||||
export const Compass = createMockIcon('Compass')
|
||||
export const Map = createMockIcon('Map')
|
||||
export const TestTube = createMockIcon('TestTube')
|
||||
export const FlaskConical = createMockIcon('FlaskConical')
|
||||
export const Bug = createMockIcon('Bug')
|
||||
export const GitBranch = createMockIcon('GitBranch')
|
||||
export const GitCommit = createMockIcon('GitCommit')
|
||||
export const GitMerge = createMockIcon('GitMerge')
|
||||
export const GitPullRequest = createMockIcon('GitPullRequest')
|
||||
export const Github = createMockIcon('Github')
|
||||
export const Gitlab = createMockIcon('Gitlab')
|
||||
export const Bitbucket = createMockIcon('Bitbucket')
|
||||
export const Network = createMockIcon('Network')
|
||||
export const GitGraph = createMockIcon('GitGraph')
|
||||
export const ListFilter = createMockIcon('ListFilter')
|
||||
export const CheckSquare2 = createMockIcon('CheckSquare2')
|
||||
export const CircleSlash2 = createMockIcon('CircleSlash2')
|
||||
export const Clock3 = createMockIcon('Clock3')
|
||||
export const GitCommitHorizontal = createMockIcon('GitCommitHorizontal')
|
||||
export const CalendarDays = createMockIcon('CalendarDays')
|
||||
export const Sparkles = createMockIcon('Sparkles')
|
||||
export const Layout = createMockIcon('Layout')
|
||||
export const Table = createMockIcon('Table')
|
||||
export const Columns = createMockIcon('Columns')
|
||||
export const GitPullRequestDraft = createMockIcon('GitPullRequestDraft')
|
||||
export const BrainCircuit = createMockIcon('BrainCircuit')
|
||||
export const Wrench = createMockIcon('Wrench')
|
||||
export const PlugZap = createMockIcon('PlugZap')
|
||||
export const BoxIcon = createMockIcon('BoxIcon')
|
||||
export const Box = createMockIcon('Box')
|
||||
export const Brain = createMockIcon('Brain')
|
||||
export const LinkIcon = createMockIcon('LinkIcon')
|
||||
export const Sparkle = createMockIcon('Sparkle')
|
||||
export const FolderTree = createMockIcon('FolderTree')
|
||||
export const Lightbulb = createMockIcon('Lightbulb')
|
||||
export const Rocket = createMockIcon('Rocket')
|
||||
export const Building = createMockIcon('Building')
|
||||
export const FileCode = createMockIcon('FileCode')
|
||||
export const FileJson = createMockIcon('FileJson')
|
||||
export const Braces = createMockIcon('Braces')
|
||||
export const Binary = createMockIcon('Binary')
|
||||
export const Palette = createMockIcon('Palette')
|
||||
export const Paintbrush = createMockIcon('Paintbrush')
|
||||
export const Type = createMockIcon('Type')
|
||||
export const Heading = createMockIcon('Heading')
|
||||
export const AlignLeft = createMockIcon('AlignLeft')
|
||||
export const AlignCenter = createMockIcon('AlignCenter')
|
||||
export const AlignRight = createMockIcon('AlignRight')
|
||||
export const Bold = createMockIcon('Bold')
|
||||
export const Italic = createMockIcon('Italic')
|
||||
export const Underline = createMockIcon('Underline')
|
||||
export const Strikethrough = createMockIcon('Strikethrough')
|
||||
export const FileCheck = createMockIcon('FileCheck')
|
||||
export const FileX = createMockIcon('FileX')
|
||||
export const FilePlus = createMockIcon('FilePlus')
|
||||
export const FileMinus = createMockIcon('FileMinus')
|
||||
export const FolderPlus = createMockIcon('FolderPlus')
|
||||
export const FolderMinus = createMockIcon('FolderMinus')
|
||||
export const FolderCheck = createMockIcon('FolderCheck')
|
||||
export const FolderX = createMockIcon('FolderX')
|
||||
export const startMCPServer = createMockIcon('startMCPServer')
|
||||
export const Pin = createMockIcon('Pin')
|
||||
export const CheckCircle2 = createMockIcon('CheckCircle2')
|
||||
export const Clipboard = createMockIcon('Clipboard')
|
||||
export const LayoutGrid = createMockIcon('LayoutGrid')
|
||||
export const Pencil = createMockIcon('Pencil')
|
||||
export const MousePointer = createMockIcon('MousePointer')
|
||||
export const GripVertical = createMockIcon('GripVertical')
|
||||
export const History = createMockIcon('History')
|
||||
export const PlusCircle = createMockIcon('PlusCircle')
|
||||
export const MinusCircle = createMockIcon('MinusCircle')
|
||||
export const ChevronDownIcon = createMockIcon('ChevronDownIcon')
|
||||
export const FileIcon = createMockIcon('FileIcon')
|
||||
export const AlertCircleIcon = createMockIcon('AlertCircleIcon')
|
||||
export const Clock4 = createMockIcon('Clock4')
|
||||
export const XIcon = createMockIcon('XIcon')
|
||||
export const CheckIcon = createMockIcon('CheckIcon')
|
||||
export const TrashIcon = createMockIcon('TrashIcon')
|
||||
export const EyeIcon = createMockIcon('EyeIcon')
|
||||
export const EditIcon = createMockIcon('EditIcon')
|
||||
export const DownloadIcon = createMockIcon('DownloadIcon')
|
||||
export const RefreshIcon = createMockIcon('RefreshIcon')
|
||||
export const SearchIcon = createMockIcon('SearchIcon')
|
||||
export const FilterIcon = createMockIcon('FilterIcon')
|
||||
export const PlusIcon = createMockIcon('PlusIcon')
|
||||
export const FolderIcon = createMockIcon('FolderIcon')
|
||||
export const FileTextIcon = createMockIcon('FileTextIcon')
|
||||
export const BookOpenIcon = createMockIcon('BookOpenIcon')
|
||||
export const DatabaseIcon = createMockIcon('DatabaseIcon')
|
||||
export const GlobeIcon = createMockIcon('GlobeIcon')
|
||||
export const TagIcon = createMockIcon('TagIcon')
|
||||
export const CalendarIcon = createMockIcon('CalendarIcon')
|
||||
export const ClockIcon = createMockIcon('ClockIcon')
|
||||
export const UserIcon = createMockIcon('UserIcon')
|
||||
export const SettingsIcon = createMockIcon('SettingsIcon')
|
||||
export const InfoIcon = createMockIcon('InfoIcon')
|
||||
export const WarningIcon = createMockIcon('WarningIcon')
|
||||
export const ErrorIcon = createMockIcon('ErrorIcon')
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
|
||||
"files": {
|
||||
"includes": ["src/features/**"]
|
||||
"includes": ["src/features/**", "src/components/layout/**"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { KnowledgeBasePage } from './pages/KnowledgeBasePage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
import { MCPPage } from './pages/MCPPage';
|
||||
import { OnboardingPage } from './pages/OnboardingPage';
|
||||
import { MainLayout } from './components/layouts/MainLayout';
|
||||
import { MainLayout } from './components/layout/MainLayout';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { ToastProvider as FeaturesToastProvider } from './features/ui/components/ToastProvider';
|
||||
|
||||
193
archon-ui-main/src/components/layout/MainLayout.tsx
Normal file
193
archon-ui-main/src/components/layout/MainLayout.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { AlertCircle, WifiOff } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useToast } from "../../features/ui/hooks/useToast";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { credentialsService } from "../../services/credentialsService";
|
||||
import { isLmConfigured } from "../../utils/onboarding";
|
||||
|
||||
// TEMPORARY: Import from old components until they're migrated to features
|
||||
import { BackendStartupError } from "../BackendStartupError";
|
||||
import { useBackendHealth } from "./hooks/useBackendHealth";
|
||||
import { Navigation } from "./Navigation";
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface BackendStatusProps {
|
||||
isHealthLoading: boolean;
|
||||
isBackendError: boolean;
|
||||
healthData: { ready: boolean } | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend health indicator component
|
||||
*/
|
||||
function BackendStatus({ isHealthLoading, isBackendError, healthData }: BackendStatusProps) {
|
||||
if (isHealthLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-50 dark:bg-yellow-950/30 text-yellow-700 dark:text-yellow-400 text-sm">
|
||||
<div className="w-2 h-2 bg-yellow-500 rounded-full animate-pulse" />
|
||||
<span>Connecting...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isBackendError) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-400 text-sm">
|
||||
<WifiOff className="w-4 h-4" />
|
||||
<span>Backend Offline</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (healthData?.ready === false) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-50 dark:bg-yellow-950/30 text-yellow-700 dark:text-yellow-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>Backend Starting...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modern main layout using TanStack Query and Radix UI patterns
|
||||
* Uses CSS Grid for layout instead of fixed positioning
|
||||
*/
|
||||
export function MainLayout({ children, className }: MainLayoutProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { showToast } = useToast();
|
||||
|
||||
// Backend health monitoring with TanStack Query
|
||||
const {
|
||||
data: healthData,
|
||||
isError: isBackendError,
|
||||
error: backendError,
|
||||
isLoading: isHealthLoading,
|
||||
failureCount,
|
||||
} = useBackendHealth();
|
||||
|
||||
// Track if backend has completely failed (for showing BackendStartupError)
|
||||
const backendStartupFailed = isBackendError && failureCount >= 5;
|
||||
|
||||
// TEMPORARY: Handle onboarding redirect using old logic until migrated
|
||||
useEffect(() => {
|
||||
const checkOnboarding = async () => {
|
||||
// Skip if backend failed to start
|
||||
if (backendStartupFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if not ready, already on onboarding, or already dismissed
|
||||
if (!healthData?.ready || location.pathname === "/onboarding") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if onboarding was already dismissed
|
||||
if (localStorage.getItem("onboardingDismissed") === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch credentials in parallel (using old service temporarily)
|
||||
const [ragCreds, apiKeyCreds] = await Promise.all([
|
||||
credentialsService.getCredentialsByCategory("rag_strategy"),
|
||||
credentialsService.getCredentialsByCategory("api_keys"),
|
||||
]);
|
||||
|
||||
// Check if LM is configured (using old utility temporarily)
|
||||
const configured = isLmConfigured(ragCreds, apiKeyCreds);
|
||||
|
||||
if (!configured) {
|
||||
// Redirect to onboarding
|
||||
navigate("/onboarding", { replace: true });
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't block app
|
||||
console.error("ONBOARDING_CHECK_FAILED:", error);
|
||||
showToast(`Configuration check failed. You can manually configure in Settings.`, "warning");
|
||||
}
|
||||
};
|
||||
|
||||
checkOnboarding();
|
||||
}, [healthData?.ready, backendStartupFailed, location.pathname, navigate, showToast]);
|
||||
|
||||
// Show backend error toast (once)
|
||||
useEffect(() => {
|
||||
if (isBackendError && backendError) {
|
||||
const errorMessage = backendError instanceof Error ? backendError.message : "Backend connection failed";
|
||||
showToast(`Backend unavailable: ${errorMessage}. Some features may not work.`, "error");
|
||||
}
|
||||
}, [isBackendError, backendError, showToast]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative min-h-screen bg-white dark:bg-black overflow-hidden", className)}>
|
||||
{/* TEMPORARY: Show backend startup error using old component */}
|
||||
{backendStartupFailed && <BackendStartupError />}
|
||||
|
||||
{/* Fixed full-page background grid that doesn't scroll */}
|
||||
<div className="fixed inset-0 neon-grid pointer-events-none z-0" />
|
||||
|
||||
{/* Floating Navigation */}
|
||||
<div className="fixed left-6 top-1/2 -translate-y-1/2 z-50 flex flex-col gap-4">
|
||||
<Navigation />
|
||||
<BackendStatus isHealthLoading={isHealthLoading} isBackendError={isBackendError} healthData={healthData} />
|
||||
</div>
|
||||
|
||||
{/* Main Content Area - matches old layout exactly */}
|
||||
<div className="relative flex-1 pl-[100px] z-10">
|
||||
<div className="container mx-auto px-8 relative">
|
||||
<div className="min-h-screen pt-8 pb-16">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TEMPORARY: Floating Chat Button (disabled) - from old layout */}
|
||||
<div className="fixed bottom-6 right-6 z-50 group">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="w-14 h-14 rounded-full flex items-center justify-center backdrop-blur-md bg-gradient-to-b from-gray-100/80 to-gray-50/60 dark:from-gray-700/30 dark:to-gray-800/30 shadow-[0_0_10px_rgba(156,163,175,0.3)] dark:shadow-[0_0_10px_rgba(156,163,175,0.3)] cursor-not-allowed opacity-60 overflow-hidden border border-gray-300 dark:border-gray-600"
|
||||
aria-label="Knowledge Assistant - Coming Soon"
|
||||
>
|
||||
<img src="/logo-neon.png" alt="Archon" className="w-7 h-7 grayscale opacity-50" />
|
||||
</button>
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full right-0 mb-2 px-3 py-2 bg-gray-800 dark:bg-gray-900 text-white text-sm rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap">
|
||||
<div className="font-medium">Coming Soon</div>
|
||||
<div className="text-xs text-gray-300">Knowledge Assistant is under development</div>
|
||||
<div className="absolute bottom-0 right-6 transform translate-y-1/2 rotate-45 w-2 h-2 bg-gray-800 dark:bg-gray-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout variant without navigation for special pages
|
||||
*/
|
||||
export function MinimalLayout({ children, className }: MainLayoutProps) {
|
||||
return (
|
||||
<div className={cn("min-h-screen bg-white dark:bg-black", "flex items-center justify-center", className)}>
|
||||
{/* Background Grid Effect */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-50"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(59, 130, 246, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59, 130, 246, 0.03) 1px, transparent 1px)`,
|
||||
backgroundSize: "50px 50px",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Centered Content */}
|
||||
<div className="relative w-full max-w-4xl px-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
archon-ui-main/src/components/layout/Navigation.tsx
Normal file
178
archon-ui-main/src/components/layout/Navigation.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { BookOpen, Settings } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
// TEMPORARY: Use old SettingsContext until settings are migrated
|
||||
import { useSettings } from "../../contexts/SettingsContext";
|
||||
import { glassmorphism } from "../../features/ui/primitives/styles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../features/ui/primitives/tooltip";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface NavigationItem {
|
||||
path: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface NavigationProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modern navigation component using Radix UI patterns
|
||||
* No fixed positioning - parent controls layout
|
||||
*/
|
||||
export function Navigation({ className }: NavigationProps) {
|
||||
const location = useLocation();
|
||||
const { projectsEnabled } = useSettings();
|
||||
|
||||
// Navigation items configuration
|
||||
const navigationItems: NavigationItem[] = [
|
||||
{
|
||||
path: "/",
|
||||
icon: <BookOpen className="h-5 w-5" />,
|
||||
label: "Knowledge Base",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
path: "/mcp",
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
height="20"
|
||||
width="20"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="MCP Server Icon"
|
||||
>
|
||||
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
|
||||
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
|
||||
</svg>
|
||||
),
|
||||
label: "MCP Server",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
label: "Settings",
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const isProjectsActive = location.pathname.startsWith("/projects");
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-6 py-6 px-3",
|
||||
"rounded-xl w-[72px]",
|
||||
// Using glassmorphism from primitives
|
||||
glassmorphism.background.subtle,
|
||||
"border border-gray-200 dark:border-zinc-800/50",
|
||||
"shadow-[0_10px_30px_-15px_rgba(0,0,0,0.1)] dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Logo - Always visible, conditionally clickable for Projects */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{projectsEnabled ? (
|
||||
<Link
|
||||
to="/projects"
|
||||
className={cn(
|
||||
"relative p-2 rounded-lg transition-all duration-300",
|
||||
"flex items-center justify-center",
|
||||
"hover:bg-white/10 dark:hover:bg-white/5",
|
||||
isProjectsActive && [
|
||||
"bg-gradient-to-b from-white/20 to-white/5 dark:from-white/10 dark:to-black/20",
|
||||
"shadow-[0_5px_15px_-5px_rgba(59,130,246,0.3)] dark:shadow-[0_5px_15px_-5px_rgba(59,130,246,0.5)]",
|
||||
"transform scale-110",
|
||||
],
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src="/logo-neon.png"
|
||||
alt="Archon"
|
||||
className={cn(
|
||||
"w-8 h-8 transition-all duration-300",
|
||||
isProjectsActive && "filter drop-shadow-[0_0_8px_rgba(59,130,246,0.7)]",
|
||||
)}
|
||||
/>
|
||||
{/* Active state decorations */}
|
||||
{isProjectsActive && (
|
||||
<>
|
||||
<span className="absolute inset-0 rounded-lg border border-blue-300 dark:border-blue-500/30" />
|
||||
<span className="absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[2px] bg-blue-500 shadow-[0_0_10px_2px_rgba(59,130,246,0.4)] dark:shadow-[0_0_20px_5px_rgba(59,130,246,0.7)]" />
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="p-2 rounded-lg opacity-50 cursor-not-allowed">
|
||||
<img src="/logo-neon.png" alt="Archon" className="w-8 h-8 grayscale" />
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{projectsEnabled ? "Project Management" : "Projects Disabled"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-8 h-px bg-gradient-to-r from-transparent via-gray-300 dark:via-gray-700 to-transparent" />
|
||||
|
||||
{/* Navigation Items */}
|
||||
<nav className="flex flex-col gap-4">
|
||||
{navigationItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
const isEnabled = item.enabled !== false;
|
||||
|
||||
return (
|
||||
<Tooltip key={item.path}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
to={isEnabled ? item.path : "#"}
|
||||
className={cn(
|
||||
"relative p-3 rounded-lg transition-all duration-300",
|
||||
"flex items-center justify-center",
|
||||
isActive
|
||||
? [
|
||||
"bg-gradient-to-b from-white/20 to-white/5 dark:from-white/10 dark:to-black/20",
|
||||
"text-blue-600 dark:text-blue-400",
|
||||
"shadow-[0_5px_15px_-5px_rgba(59,130,246,0.3)] dark:shadow-[0_5px_15px_-5px_rgba(59,130,246,0.5)]",
|
||||
]
|
||||
: [
|
||||
"text-gray-500 dark:text-zinc-500",
|
||||
"hover:text-blue-600 dark:hover:text-blue-400",
|
||||
"hover:bg-white/10 dark:hover:bg-white/5",
|
||||
],
|
||||
!isEnabled && "opacity-50 cursor-not-allowed pointer-events-none",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (!isEnabled) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
{/* Active state decorations with neon line */}
|
||||
{isActive && (
|
||||
<>
|
||||
<span className="absolute inset-0 rounded-lg border border-blue-300 dark:border-blue-500/30" />
|
||||
<span className="absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[2px] bg-blue-500 shadow-[0_0_10px_2px_rgba(59,130,246,0.4)] dark:shadow-[0_0_20px_5px_rgba(59,130,246,0.7)]" />
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { callAPIWithETag } from "../../../features/projects/shared/apiWithEtag";
|
||||
import type { HealthResponse } from "../types";
|
||||
|
||||
/**
|
||||
* Hook to monitor backend health status using TanStack Query
|
||||
* Uses ETag caching for bandwidth reduction (~70% savings per project docs)
|
||||
*/
|
||||
export function useBackendHealth() {
|
||||
return useQuery<HealthResponse>({
|
||||
queryKey: ["backend", "health"],
|
||||
queryFn: ({ signal }) => {
|
||||
// Use existing ETag infrastructure with timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
// Chain signals: React Query's signal + our timeout
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => controller.abort());
|
||||
}
|
||||
|
||||
return callAPIWithETag<HealthResponse>("/api/health", {
|
||||
signal: controller.signal,
|
||||
}).finally(() => {
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
},
|
||||
// Retry configuration for startup scenarios
|
||||
retry: (failureCount) => {
|
||||
// Keep retrying during startup, up to 5 times
|
||||
if (failureCount < 5) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
retryDelay: (attemptIndex) => {
|
||||
// Exponential backoff: 1.5s, 2.25s, 3.375s, etc.
|
||||
return Math.min(1500 * 1.5 ** attemptIndex, 10000);
|
||||
},
|
||||
// Refetch every 30 seconds when healthy
|
||||
refetchInterval: 30000,
|
||||
// Keep trying to connect on window focus
|
||||
refetchOnWindowFocus: true,
|
||||
// Consider data fresh for 20 seconds
|
||||
staleTime: 20000,
|
||||
});
|
||||
}
|
||||
3
archon-ui-main/src/components/layout/index.ts
Normal file
3
archon-ui-main/src/components/layout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { useBackendHealth } from "./hooks/useBackendHealth";
|
||||
export { MainLayout, MinimalLayout } from "./MainLayout";
|
||||
export { Navigation } from "./Navigation";
|
||||
28
archon-ui-main/src/components/layout/types.ts
Normal file
28
archon-ui-main/src/components/layout/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type React from "react";
|
||||
|
||||
export interface NavigationItem {
|
||||
path: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
ready: boolean;
|
||||
message?: string;
|
||||
server_status?: string;
|
||||
credentials_status?: string;
|
||||
database_status?: string;
|
||||
uptime?: number;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
projectsEnabled: boolean;
|
||||
theme?: "light" | "dark" | "system";
|
||||
// Add other settings as needed
|
||||
}
|
||||
|
||||
export interface OnboardingCheckResult {
|
||||
shouldShowOnboarding: boolean;
|
||||
reason: "dismissed" | "missing_rag" | "missing_api_key" | null;
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { SideNavigation } from './SideNavigation';
|
||||
import { ArchonChatPanel } from './ArchonChatPanel';
|
||||
import { X } from 'lucide-react';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { credentialsService } from '../../services/credentialsService';
|
||||
import { isLmConfigured } from '../../utils/onboarding';
|
||||
import { BackendStartupError } from '../BackendStartupError';
|
||||
/**
|
||||
* Props for the MainLayout component
|
||||
*/
|
||||
interface MainLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
/**
|
||||
* MainLayout - The main layout component for the application
|
||||
*
|
||||
* This component provides the overall layout structure including:
|
||||
* - Side navigation
|
||||
* - Main content area
|
||||
* - Knowledge chat panel (slidable)
|
||||
*/
|
||||
export const MainLayout: React.FC<MainLayoutProps> = ({
|
||||
children
|
||||
}) => {
|
||||
// State to track if chat panel is open
|
||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [backendReady, setBackendReady] = useState(false);
|
||||
const [backendStartupFailed, setBackendStartupFailed] = useState(false);
|
||||
|
||||
// Check backend readiness
|
||||
useEffect(() => {
|
||||
|
||||
const checkBackendHealth = async (retryCount = 0) => {
|
||||
const maxRetries = 3; // 3 retries total
|
||||
const retryDelay = 1500; // 1.5 seconds between retries
|
||||
|
||||
try {
|
||||
// Create AbortController for proper timeout handling
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
// Check if backend is responding with a simple health check
|
||||
const response = await fetch(`${credentialsService['baseUrl']}/api/health`, {
|
||||
method: 'GET',
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
const healthData = await response.json();
|
||||
console.log('📋 Backend health check:', healthData);
|
||||
|
||||
// Check if backend is truly ready (not just started)
|
||||
if (healthData.ready === true) {
|
||||
console.log('✅ Backend is fully initialized');
|
||||
setBackendReady(true);
|
||||
setBackendStartupFailed(false);
|
||||
} else {
|
||||
// Backend is starting up but not ready yet
|
||||
console.log(`🔄 Backend initializing... (attempt ${retryCount + 1}/${maxRetries}):`, healthData.message || 'Loading credentials...');
|
||||
|
||||
// Retry with shorter interval during initialization
|
||||
if (retryCount < maxRetries) {
|
||||
setTimeout(() => {
|
||||
checkBackendHealth(retryCount + 1);
|
||||
}, retryDelay); // Constant 1.5s retry during initialization
|
||||
} else {
|
||||
console.warn('Backend initialization taking too long - proceeding anyway');
|
||||
// Don't mark as failed yet, just not fully ready
|
||||
setBackendReady(false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Backend health check failed: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle AbortError separately for timeout
|
||||
const errorMessage = error instanceof Error
|
||||
? (error.name === 'AbortError' ? 'Request timeout (5s)' : error.message)
|
||||
: 'Unknown error';
|
||||
// Only log after first attempt to reduce noise during normal startup
|
||||
if (retryCount > 0) {
|
||||
console.log(`Backend not ready yet (attempt ${retryCount + 1}/${maxRetries}):`, errorMessage);
|
||||
}
|
||||
|
||||
// Retry if we haven't exceeded max retries
|
||||
if (retryCount < maxRetries) {
|
||||
setTimeout(() => {
|
||||
checkBackendHealth(retryCount + 1);
|
||||
}, retryDelay * Math.pow(1.5, retryCount)); // Exponential backoff for connection errors
|
||||
} else {
|
||||
console.error('Backend startup failed after maximum retries - showing error message');
|
||||
setBackendReady(false);
|
||||
setBackendStartupFailed(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Start the health check process
|
||||
setTimeout(() => {
|
||||
checkBackendHealth();
|
||||
}, 1000); // Wait 1 second for initial app startup
|
||||
}, []); // Empty deps - only run once on mount
|
||||
|
||||
// Check for onboarding redirect after backend is ready
|
||||
useEffect(() => {
|
||||
const checkOnboarding = async () => {
|
||||
// Skip if backend failed to start
|
||||
if (backendStartupFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if not ready, already on onboarding, or already dismissed
|
||||
if (!backendReady || location.pathname === '/onboarding') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if onboarding was already dismissed
|
||||
if (localStorage.getItem('onboardingDismissed') === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch credentials in parallel
|
||||
const [ragCreds, apiKeyCreds] = await Promise.all([
|
||||
credentialsService.getCredentialsByCategory('rag_strategy'),
|
||||
credentialsService.getCredentialsByCategory('api_keys')
|
||||
]);
|
||||
|
||||
// Check if LM is configured
|
||||
const configured = isLmConfigured(ragCreds, apiKeyCreds);
|
||||
|
||||
if (!configured) {
|
||||
// Redirect to onboarding
|
||||
navigate('/onboarding', { replace: true });
|
||||
}
|
||||
} catch (error) {
|
||||
// Detailed error handling per alpha principles - fail loud but don't block
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
const errorDetails = {
|
||||
context: 'Onboarding configuration check',
|
||||
pathname: location.pathname,
|
||||
error: errorMessage,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Log with full context and stack trace
|
||||
console.error('ONBOARDING_CHECK_FAILED:', errorDetails, error);
|
||||
|
||||
// Make error visible to user but don't block app functionality
|
||||
showToast(
|
||||
`Configuration check failed: ${errorMessage}. You can manually configure in Settings.`,
|
||||
'warning'
|
||||
);
|
||||
|
||||
// Let user continue - onboarding is optional, they can configure manually
|
||||
}
|
||||
};
|
||||
|
||||
checkOnboarding();
|
||||
}, [backendReady, backendStartupFailed, location.pathname, navigate, showToast]);
|
||||
|
||||
return <div className="relative min-h-screen bg-white dark:bg-black overflow-hidden">
|
||||
{/* Show backend startup error if backend failed to start */}
|
||||
{backendStartupFailed && <BackendStartupError />}
|
||||
|
||||
{/* Fixed full-page background grid that doesn't scroll */}
|
||||
<div className="fixed inset-0 neon-grid pointer-events-none z-0"></div>
|
||||
{/* Floating Navigation */}
|
||||
<div className="fixed left-6 top-1/2 -translate-y-1/2 z-50">
|
||||
<SideNavigation />
|
||||
</div>
|
||||
{/* Main Content Area - no left margin to allow grid to extend full width */}
|
||||
<div className="relative flex-1 pl-[100px] z-10">
|
||||
<div className="container mx-auto px-8 relative">
|
||||
<div className="min-h-screen pt-8 pb-16">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Floating Chat Button - Only visible when chat is closed */}
|
||||
{!isChatOpen && (
|
||||
<div className="fixed bottom-6 right-6 z-50 group">
|
||||
<button
|
||||
disabled
|
||||
className="w-14 h-14 rounded-full flex items-center justify-center backdrop-blur-md bg-gradient-to-b from-gray-100/80 to-gray-50/60 dark:from-gray-700/30 dark:to-gray-800/30 shadow-[0_0_10px_rgba(156,163,175,0.3)] dark:shadow-[0_0_10px_rgba(156,163,175,0.3)] cursor-not-allowed opacity-60 overflow-hidden border border-gray-300 dark:border-gray-600"
|
||||
aria-label="Knowledge Assistant - Coming Soon">
|
||||
<img src="/logo-neon.png" alt="Archon" className="w-7 h-7 grayscale opacity-50" />
|
||||
</button>
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full right-0 mb-2 px-3 py-2 bg-gray-800 dark:bg-gray-900 text-white text-sm rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap">
|
||||
<div className="font-medium">Coming Soon</div>
|
||||
<div className="text-xs text-gray-300">Knowledge Assistant is under development</div>
|
||||
<div className="absolute bottom-0 right-6 transform translate-y-1/2 rotate-45 w-2 h-2 bg-gray-800 dark:bg-gray-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Chat Sidebar - Slides in/out from right */}
|
||||
<div className="fixed top-0 right-0 h-full z-40 transition-transform duration-300 ease-in-out transform" style={{
|
||||
transform: isChatOpen ? 'translateX(0)' : 'translateX(100%)'
|
||||
}}>
|
||||
{/* Close button - Only visible when chat is open */}
|
||||
{isChatOpen && <button onClick={() => setIsChatOpen(false)} className="absolute -left-14 bottom-6 z-50 w-12 h-12 rounded-full flex items-center justify-center backdrop-blur-md bg-gradient-to-b from-white/10 to-black/30 dark:from-white/10 dark:to-black/30 from-pink-100/80 to-pink-50/60 border border-pink-200 dark:border-pink-500/30 shadow-[0_0_15px_rgba(236,72,153,0.2)] dark:shadow-[0_0_15px_rgba(236,72,153,0.5)] hover:shadow-[0_0_20px_rgba(236,72,153,0.4)] dark:hover:shadow-[0_0_20px_rgba(236,72,153,0.7)] transition-all duration-300" aria-label="Close Knowledge Assistant">
|
||||
<X className="w-5 h-5 text-pink-500" />
|
||||
</button>}
|
||||
{/* Knowledge Chat Panel */}
|
||||
<ArchonChatPanel data-id="archon-chat" />
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
@@ -1,130 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { BookOpen, HardDrive, Settings } from 'lucide-react';
|
||||
import { useSettings } from '../../contexts/SettingsContext';
|
||||
/**
|
||||
* Interface for navigation items
|
||||
*/
|
||||
export interface NavigationItem {
|
||||
path: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
/**
|
||||
* Props for the SideNavigation component
|
||||
*/
|
||||
interface SideNavigationProps {
|
||||
className?: string;
|
||||
'data-id'?: string;
|
||||
}
|
||||
/**
|
||||
* Tooltip component for navigation items
|
||||
*/
|
||||
const NavTooltip: React.FC<{
|
||||
show: boolean;
|
||||
label: string;
|
||||
position?: 'left' | 'right';
|
||||
}> = ({
|
||||
show,
|
||||
label,
|
||||
position = 'right'
|
||||
}) => {
|
||||
if (!show) return null;
|
||||
return <div className={`absolute ${position === 'right' ? 'left-full ml-2' : 'right-full mr-2'} top-1/2 -translate-y-1/2 px-2 py-1 rounded bg-black/80 text-white text-xs whitespace-nowrap z-50`} style={{
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
{label}
|
||||
<div className={`absolute top-1/2 -translate-y-1/2 ${position === 'right' ? 'left-0 -translate-x-full' : 'right-0 translate-x-full'} border-4 ${position === 'right' ? 'border-r-black/80 border-transparent' : 'border-l-black/80 border-transparent'}`}></div>
|
||||
</div>;
|
||||
};
|
||||
/**
|
||||
* SideNavigation - A vertical navigation component
|
||||
*
|
||||
* This component renders a navigation sidebar with icons and the application logo.
|
||||
* It highlights the active route and provides hover effects.
|
||||
*/
|
||||
export const SideNavigation: React.FC<SideNavigationProps> = ({
|
||||
className = '',
|
||||
'data-id': dataId
|
||||
}) => {
|
||||
// State to track which tooltip is currently visible
|
||||
const [activeTooltip, setActiveTooltip] = useState<string | null>(null);
|
||||
const { projectsEnabled } = useSettings();
|
||||
|
||||
// Default navigation items
|
||||
const navigationItems: NavigationItem[] = [{
|
||||
path: '/',
|
||||
icon: <BookOpen className="h-5 w-5" />,
|
||||
label: 'Knowledge Base'
|
||||
}, {
|
||||
path: '/mcp',
|
||||
icon: <svg fill="currentColor" fillRule="evenodd" height="20" width="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path><path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path></svg>,
|
||||
label: 'MCP Server'
|
||||
}, {
|
||||
path: '/settings',
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
label: 'Settings'
|
||||
}];
|
||||
// Logo configuration
|
||||
const logoSrc = "/logo-neon.png";
|
||||
const logoAlt = 'Knowledge Base Logo';
|
||||
// Get current location to determine active route
|
||||
const location = useLocation();
|
||||
const isProjectsActive = location.pathname === '/projects' && projectsEnabled;
|
||||
|
||||
const logoClassName = `
|
||||
logo-container p-2 relative rounded-lg transition-all duration-300
|
||||
${isProjectsActive ? 'bg-gradient-to-b from-white/20 to-white/5 dark:from-white/10 dark:to-black/20 shadow-[0_5px_15px_-5px_rgba(59,130,246,0.3)] dark:shadow-[0_5px_15px_-5px_rgba(59,130,246,0.5)] transform scale-110' : ''}
|
||||
${projectsEnabled ? 'hover:bg-white/10 dark:hover:bg-white/5 cursor-pointer' : 'opacity-50 cursor-not-allowed'}
|
||||
`;
|
||||
|
||||
return <div data-id={dataId} className={`flex flex-col items-center gap-6 py-6 px-3 rounded-xl backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border border-gray-200 dark:border-zinc-800/50 shadow-[0_10px_30px_-15px_rgba(0,0,0,0.1)] dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)] ${className}`}>
|
||||
{/* Logo - Conditionally clickable based on Projects enabled */}
|
||||
{projectsEnabled ? (
|
||||
<Link
|
||||
to="/projects"
|
||||
className={logoClassName}
|
||||
onMouseEnter={() => setActiveTooltip('logo')}
|
||||
onMouseLeave={() => setActiveTooltip(null)}
|
||||
>
|
||||
<img src={logoSrc} alt={logoAlt} className={`w-8 h-8 transition-all duration-300 ${isProjectsActive ? 'filter drop-shadow-[0_0_8px_rgba(59,130,246,0.7)]' : ''}`} />
|
||||
{/* Active state decorations */}
|
||||
{isProjectsActive && <>
|
||||
<span className="absolute inset-0 rounded-lg border border-blue-300 dark:border-blue-500/30"></span>
|
||||
<span className="absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[2px] bg-blue-500 shadow-[0_0_10px_2px_rgba(59,130,246,0.4)] dark:shadow-[0_0_20px_5px_rgba(59,130,246,0.7)]"></span>
|
||||
</>}
|
||||
<NavTooltip show={activeTooltip === 'logo'} label="Project Management" />
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
className={logoClassName}
|
||||
onMouseEnter={() => setActiveTooltip('logo')}
|
||||
onMouseLeave={() => setActiveTooltip(null)}
|
||||
>
|
||||
<img src={logoSrc} alt={logoAlt} className="w-8 h-8 transition-all duration-300" />
|
||||
<NavTooltip show={activeTooltip === 'logo'} label="Projects Disabled" />
|
||||
</div>
|
||||
)}
|
||||
{/* Navigation links */}
|
||||
<nav className="flex flex-col gap-4">
|
||||
{navigationItems.map(item => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return <Link key={item.path} to={item.path} className={`
|
||||
relative p-3 rounded-lg flex items-center justify-center
|
||||
transition-all duration-300
|
||||
${isActive ? 'bg-gradient-to-b from-white/20 to-white/5 dark:from-white/10 dark:to-black/20 text-blue-600 dark:text-blue-400 shadow-[0_5px_15px_-5px_rgba(59,130,246,0.3)] dark:shadow-[0_5px_15px_-5px_rgba(59,130,246,0.5)]' : 'text-gray-500 dark:text-zinc-500 hover:text-blue-600 dark:hover:text-blue-400'}
|
||||
`} onMouseEnter={() => setActiveTooltip(item.path)} onMouseLeave={() => setActiveTooltip(null)} aria-label={item.label}>
|
||||
{/* Active state decorations - Modified to place neon line below button with adjusted width */}
|
||||
{isActive && <>
|
||||
<span className="absolute inset-0 rounded-lg border border-blue-300 dark:border-blue-500/30"></span>
|
||||
{/* Neon line positioned below the button with reduced width to respect curved edges */}
|
||||
<span className="absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[2px] bg-blue-500 shadow-[0_0_10px_2px_rgba(59,130,246,0.4)] dark:shadow-[0_0_20px_5px_rgba(59,130,246,0.7)]"></span>
|
||||
</>}
|
||||
{item.icon}
|
||||
{/* Custom tooltip */}
|
||||
<NavTooltip show={activeTooltip === item.path} label={item.label} />
|
||||
</Link>;
|
||||
})}
|
||||
</nav>
|
||||
</div>;
|
||||
};
|
||||
@@ -1,508 +0,0 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Server, Activity, Clock, ChevronRight, Hammer, Settings, Trash2, Plug, PlugZap } from 'lucide-react';
|
||||
import { Client } from './MCPClients';
|
||||
import { mcpClientService } from '../../services/mcpClientService';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
interface ClientCardProps {
|
||||
client: Client;
|
||||
onSelect: () => void;
|
||||
onEdit?: (client: Client) => void;
|
||||
onDelete?: (client: Client) => void;
|
||||
onConnectionChange?: () => void;
|
||||
}
|
||||
|
||||
export const ClientCard = ({
|
||||
client,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onConnectionChange
|
||||
}: ClientCardProps) => {
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const particlesRef = useRef<HTMLDivElement>(null);
|
||||
const { showToast } = useToast();
|
||||
|
||||
// Special styling for Archon client
|
||||
const isArchonClient = client.name.includes('Archon') || client.name.includes('archon');
|
||||
|
||||
// Status-based styling
|
||||
const statusConfig = {
|
||||
online: {
|
||||
color: isArchonClient ? 'archon' : 'cyan',
|
||||
glow: isArchonClient ? 'shadow-[0_0_25px_rgba(59,130,246,0.7),0_0_15px_rgba(168,85,247,0.5)] dark:shadow-[0_0_35px_rgba(59,130,246,0.8),0_0_20px_rgba(168,85,247,0.7)]' : 'shadow-[0_0_15px_rgba(34,211,238,0.5)] dark:shadow-[0_0_20px_rgba(34,211,238,0.7)]',
|
||||
border: isArchonClient ? 'border-blue-400/60 dark:border-blue-500/60' : 'border-cyan-400/50 dark:border-cyan-500/40',
|
||||
badge: isArchonClient ? 'bg-blue-500/30 text-blue-400 border-blue-500/40' : 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
|
||||
pulse: isArchonClient ? 'bg-blue-400' : 'bg-cyan-400'
|
||||
},
|
||||
offline: {
|
||||
color: 'gray',
|
||||
glow: 'shadow-[0_0_15px_rgba(156,163,175,0.3)] dark:shadow-[0_0_15px_rgba(156,163,175,0.4)]',
|
||||
border: 'border-gray-400/30 dark:border-gray-600/30',
|
||||
badge: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
pulse: 'bg-gray-400'
|
||||
},
|
||||
error: {
|
||||
color: 'pink',
|
||||
glow: 'shadow-[0_0_15px_rgba(236,72,153,0.5)] dark:shadow-[0_0_20px_rgba(236,72,153,0.7)]',
|
||||
border: 'border-pink-400/50 dark:border-pink-500/40',
|
||||
badge: 'bg-pink-500/20 text-pink-400 border-pink-500/30',
|
||||
pulse: 'bg-pink-400'
|
||||
}
|
||||
};
|
||||
|
||||
// Handle mouse movement for bioluminescent effect
|
||||
useEffect(() => {
|
||||
if (!isArchonClient || !particlesRef.current) return;
|
||||
|
||||
const currentMousePos = { x: 0, y: 0 };
|
||||
const glowOrganisms: HTMLDivElement[] = [];
|
||||
let isMousePresent = false;
|
||||
|
||||
const createBioluminescentOrganism = (targetX: number, targetY: number, delay = 0) => {
|
||||
const organism = document.createElement('div');
|
||||
organism.className = 'absolute rounded-full pointer-events-none';
|
||||
|
||||
const startX = targetX + (Math.random() - 0.5) * 100;
|
||||
const startY = targetY + (Math.random() - 0.5) * 100;
|
||||
const size = 8 + Math.random() * 12;
|
||||
|
||||
organism.style.left = `${startX}px`;
|
||||
organism.style.top = `${startY}px`;
|
||||
organism.style.width = `${size}px`;
|
||||
organism.style.height = `${size}px`;
|
||||
organism.style.transform = 'translate(-50%, -50%)';
|
||||
organism.style.opacity = '0';
|
||||
|
||||
const hues = [180, 200, 220, 240, 260, 280];
|
||||
const hue = hues[Math.floor(Math.random() * hues.length)];
|
||||
|
||||
organism.style.background = 'transparent';
|
||||
|
||||
organism.style.boxShadow = `
|
||||
0 0 ${size * 2}px hsla(${hue}, 90%, 60%, 0.4),
|
||||
0 0 ${size * 4}px hsla(${hue}, 80%, 50%, 0.25),
|
||||
0 0 ${size * 6}px hsla(${hue}, 70%, 40%, 0.15),
|
||||
0 0 ${size * 8}px hsla(${hue}, 60%, 30%, 0.08)
|
||||
`;
|
||||
|
||||
organism.style.filter = `blur(${2 + Math.random() * 3}px) opacity(0.6)`;
|
||||
|
||||
particlesRef.current?.appendChild(organism);
|
||||
|
||||
setTimeout(() => {
|
||||
const duration = 1200 + Math.random() * 800;
|
||||
|
||||
organism.style.transition = `all ${duration}ms cubic-bezier(0.2, 0.0, 0.1, 1)`;
|
||||
organism.style.left = `${targetX + (Math.random() - 0.5) * 50}px`;
|
||||
organism.style.top = `${targetY + (Math.random() - 0.5) * 50}px`;
|
||||
organism.style.opacity = '0.8';
|
||||
organism.style.transform = 'translate(-50%, -50%) scale(1.2)';
|
||||
|
||||
setTimeout(() => {
|
||||
if (!isMousePresent) {
|
||||
organism.style.transition = `all 2500ms cubic-bezier(0.6, 0.0, 0.9, 1)`;
|
||||
organism.style.left = `${startX + (Math.random() - 0.5) * 300}px`;
|
||||
organism.style.top = `${startY + (Math.random() - 0.5) * 300}px`;
|
||||
organism.style.opacity = '0';
|
||||
organism.style.transform = 'translate(-50%, -50%) scale(0.2)';
|
||||
organism.style.filter = `blur(${8 + Math.random() * 5}px) opacity(0.2)`;
|
||||
}
|
||||
}, duration + 800);
|
||||
|
||||
setTimeout(() => {
|
||||
if (particlesRef.current?.contains(organism)) {
|
||||
particlesRef.current.removeChild(organism);
|
||||
const index = glowOrganisms.indexOf(organism);
|
||||
if (index > -1) glowOrganisms.splice(index, 1);
|
||||
}
|
||||
}, duration + 2000);
|
||||
|
||||
}, delay);
|
||||
|
||||
return organism;
|
||||
};
|
||||
|
||||
const spawnOrganismsTowardMouse = () => {
|
||||
if (!isMousePresent) return;
|
||||
|
||||
const count = 3 + Math.random() * 4;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const organism = createBioluminescentOrganism(
|
||||
currentMousePos.x,
|
||||
currentMousePos.y,
|
||||
i * 100
|
||||
);
|
||||
glowOrganisms.push(organism);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isMousePresent = true;
|
||||
clearInterval(ambientInterval);
|
||||
ambientInterval = setInterval(createAmbientGlow, 1500);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!particlesRef.current) return;
|
||||
|
||||
const rect = particlesRef.current.getBoundingClientRect();
|
||||
currentMousePos.x = e.clientX - rect.left;
|
||||
currentMousePos.y = e.clientY - rect.top;
|
||||
|
||||
isMousePresent = true;
|
||||
|
||||
if (Math.random() < 0.4) {
|
||||
spawnOrganismsTowardMouse();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setTimeout(() => {
|
||||
isMousePresent = false;
|
||||
clearInterval(ambientInterval);
|
||||
}, 800);
|
||||
};
|
||||
|
||||
const createAmbientGlow = () => {
|
||||
if (!particlesRef.current || isMousePresent) return;
|
||||
|
||||
const x = Math.random() * particlesRef.current.clientWidth;
|
||||
const y = Math.random() * particlesRef.current.clientHeight;
|
||||
const organism = createBioluminescentOrganism(x, y);
|
||||
|
||||
organism.style.opacity = '0.3';
|
||||
organism.style.filter = `blur(${4 + Math.random() * 4}px) opacity(0.4)`;
|
||||
organism.style.animation = 'pulse 4s ease-in-out infinite';
|
||||
organism.style.transform = 'translate(-50%, -50%) scale(0.8)';
|
||||
|
||||
glowOrganisms.push(organism);
|
||||
};
|
||||
|
||||
let ambientInterval = setInterval(createAmbientGlow, 1500);
|
||||
|
||||
const cardElement = particlesRef.current;
|
||||
cardElement.addEventListener('mouseenter', handleMouseEnter);
|
||||
cardElement.addEventListener('mousemove', handleMouseMove);
|
||||
cardElement.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
return () => {
|
||||
cardElement.removeEventListener('mouseenter', handleMouseEnter);
|
||||
cardElement.removeEventListener('mousemove', handleMouseMove);
|
||||
cardElement.removeEventListener('mouseleave', handleMouseLeave);
|
||||
clearInterval(ambientInterval);
|
||||
};
|
||||
}, [isArchonClient]);
|
||||
|
||||
const currentStatus = statusConfig[client.status];
|
||||
|
||||
// Handle card flip
|
||||
const toggleFlip = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsFlipped(!isFlipped);
|
||||
};
|
||||
|
||||
// Handle edit
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(client);
|
||||
};
|
||||
|
||||
// Handle connect/disconnect
|
||||
const handleConnect = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsConnecting(true);
|
||||
|
||||
try {
|
||||
if (client.status === 'offline') {
|
||||
await mcpClientService.connectClient(client.id);
|
||||
showToast(`Connected to ${client.name}`, 'success');
|
||||
} else {
|
||||
await mcpClientService.disconnectClient(client.id);
|
||||
showToast(`Disconnected from ${client.name}`, 'success');
|
||||
}
|
||||
|
||||
// The parent component should handle refreshing the client list
|
||||
// No need to reload the entire page
|
||||
onConnectionChange?.();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Connection operation failed', 'error');
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Special background for Archon client
|
||||
const archonBackground = isArchonClient ? 'bg-gradient-to-b from-white/80 via-blue-50/30 to-white/60 dark:from-white/10 dark:via-blue-900/10 dark:to-black/30' : 'bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flip-card h-[220px] cursor-pointer ${isArchonClient ? 'order-first' : ''}`}
|
||||
style={{ perspective: '1500px' }}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className={`relative w-full h-full transition-all duration-500 transform-style-preserve-3d ${isFlipped ? 'rotate-y-180' : ''} ${isHovered && !isFlipped ? 'hover-lift' : ''}`}>
|
||||
{/* Front Side */}
|
||||
<div
|
||||
className={`absolute w-full h-full backface-hidden backdrop-blur-md ${archonBackground} rounded-xl p-5 ${currentStatus.border} ${currentStatus.glow} transition-all duration-300 ${isArchonClient ? 'archon-card-border overflow-hidden' : ''}`}
|
||||
ref={isArchonClient ? particlesRef : undefined}
|
||||
>
|
||||
{/* Particle container for Archon client */}
|
||||
{isArchonClient && (
|
||||
<div className="absolute inset-0 rounded-xl overflow-hidden pointer-events-none">
|
||||
<div className="particles-container"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtle aurora glow effect for Archon client */}
|
||||
{isArchonClient && (
|
||||
<div className="absolute inset-0 rounded-xl overflow-hidden opacity-20">
|
||||
<div className="absolute -inset-[100px] bg-[radial-gradient(circle,rgba(59,130,246,0.8)_0%,rgba(168,85,247,0.6)_40%,transparent_70%)] blur-3xl animate-[pulse_8s_ease-in-out_infinite]"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connect/Disconnect button */}
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting}
|
||||
className={`absolute top-3 right-3 p-1.5 rounded-full ${
|
||||
client.status === 'offline'
|
||||
? 'bg-green-200/50 dark:bg-green-900/50 hover:bg-green-300/50 dark:hover:bg-green-800/50'
|
||||
: 'bg-orange-200/50 dark:bg-orange-900/50 hover:bg-orange-300/50 dark:hover:bg-orange-800/50'
|
||||
} transition-colors transform hover:scale-110 transition-transform duration-200 z-20 ${isConnecting ? 'animate-pulse' : ''}`}
|
||||
title={client.status === 'offline' ? 'Connect client' : 'Disconnect client'}
|
||||
>
|
||||
{client.status === 'offline' ? (
|
||||
<Plug className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<PlugZap className="w-4 h-4 text-orange-600 dark:text-orange-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Edit button - moved to be second from right */}
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className={`absolute top-3 right-12 p-1.5 rounded-full ${isArchonClient ? 'bg-blue-200/50 dark:bg-blue-900/50 hover:bg-blue-300/50 dark:hover:bg-blue-800/50' : 'bg-gray-200/50 dark:bg-gray-800/50 hover:bg-gray-300/50 dark:hover:bg-gray-700/50'} transition-colors transform hover:scale-110 transition-transform duration-200 z-20`}
|
||||
title="Edit client configuration"
|
||||
>
|
||||
<Settings className={`w-4 h-4 ${isArchonClient ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'}`} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete button - only for non-Archon clients */}
|
||||
{!isArchonClient && onDelete && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(client);
|
||||
}}
|
||||
className="absolute top-3 right-[84px] p-1.5 rounded-full bg-red-200/50 dark:bg-red-900/50 hover:bg-red-300/50 dark:hover:bg-red-800/50 transition-colors transform hover:scale-110 transition-transform duration-200 z-20"
|
||||
title="Delete client"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Client info */}
|
||||
<div className="flex items-start">
|
||||
{isArchonClient ? (
|
||||
<div className="p-3 rounded-lg bg-gradient-to-br from-blue-500/20 to-purple-500/20 mr-3 relative pulse-soft">
|
||||
<img src="/logo-neon.png" alt="Archon" className="w-6 h-6 drop-shadow-[0_0_8px_rgba(59,130,246,0.8)] animate-glow-pulse" />
|
||||
<div className="absolute inset-0 rounded-lg bg-blue-500/10 animate-pulse opacity-60"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`p-3 rounded-lg bg-${currentStatus.color}-500/10 text-${currentStatus.color}-400 mr-3 pulse-soft`}>
|
||||
<Server className="w-6 h-6" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className={`font-bold text-gray-800 dark:text-white text-lg ${isArchonClient ? 'bg-gradient-to-r from-blue-400 to-purple-500 text-transparent bg-clip-text animate-text-shimmer' : ''}`}>
|
||||
{client.name}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{client.ip}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-2">
|
||||
<div className="flex items-center text-sm">
|
||||
<Clock className="w-4 h-4 text-gray-500 dark:text-gray-400 mr-2" />
|
||||
<span className="text-gray-700 dark:text-gray-300">Last seen:</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-auto">
|
||||
{client.lastSeen}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<Activity className="w-4 h-4 text-gray-500 dark:text-gray-400 mr-2" />
|
||||
<span className="text-gray-700 dark:text-gray-300">Version:</span>
|
||||
<span className={`text-gray-600 dark:text-gray-400 ml-auto ${isArchonClient ? 'font-medium text-blue-600 dark:text-blue-400' : ''}`}>
|
||||
{client.version}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<Hammer className="w-4 h-4 text-gray-500 dark:text-gray-400 mr-2" />
|
||||
<span className="text-gray-700 dark:text-gray-300">Tools:</span>
|
||||
<span className={`text-gray-600 dark:text-gray-400 ml-auto ${isArchonClient ? 'font-medium text-blue-600 dark:text-blue-400' : ''}`}>
|
||||
{client.tools.length} available
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Error message display */}
|
||||
{client.status === 'error' && client.lastError && (
|
||||
<div className="mt-3 p-2 bg-red-50/80 dark:bg-red-900/20 border border-red-200 dark:border-red-800/40 rounded-md">
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-red-400 mt-0.5 mr-2 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-red-700 dark:text-red-300 mb-1">Last Error:</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-400 break-words">
|
||||
{client.lastError}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status badge - moved to bottom left */}
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<div className={`px-2.5 py-1 rounded-full text-xs font-medium flex items-center gap-1.5 border ${currentStatus.badge}`}>
|
||||
<div className="relative flex h-2 w-2">
|
||||
<span className={`animate-ping-slow absolute inline-flex h-full w-full rounded-full ${currentStatus.pulse} opacity-75`}></span>
|
||||
<span className={`relative inline-flex rounded-full h-2 w-2 ${currentStatus.pulse}`}></span>
|
||||
</div>
|
||||
{client.status.charAt(0).toUpperCase() + client.status.slice(1)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools button - with Hammer icon */}
|
||||
<button
|
||||
onClick={toggleFlip}
|
||||
className={`absolute bottom-4 right-4 p-1.5 rounded-full ${isArchonClient ? 'bg-blue-200/50 dark:bg-blue-900/50 hover:bg-blue-300/50 dark:hover:bg-blue-800/50' : 'bg-gray-200/50 dark:bg-gray-800/50 hover:bg-gray-300/50 dark:hover:bg-gray-700/50'} transition-colors transform hover:scale-110 transition-transform duration-200 z-10`}
|
||||
title="View available tools"
|
||||
>
|
||||
<Hammer className={`w-4 h-4 ${isArchonClient ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Back Side */}
|
||||
<div className={`absolute w-full h-full backface-hidden backdrop-blur-md ${archonBackground} rounded-xl p-5 rotate-y-180 ${currentStatus.border} ${currentStatus.glow} transition-all duration-300 ${isArchonClient ? 'archon-card-border' : ''}`}>
|
||||
{/* Subtle aurora glow effect for Archon client */}
|
||||
{isArchonClient && (
|
||||
<div className="absolute inset-0 rounded-xl overflow-hidden opacity-20">
|
||||
<div className="absolute -inset-[100px] bg-[radial-gradient(circle,rgba(59,130,246,0.8)_0%,rgba(168,85,247,0.6)_40%,transparent_70%)] blur-3xl animate-[pulse_8s_ease-in-out_infinite]"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connect/Disconnect button - also on back side */}
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting}
|
||||
className={`absolute top-3 right-3 p-1.5 rounded-full ${
|
||||
client.status === 'offline'
|
||||
? 'bg-green-200/50 dark:bg-green-900/50 hover:bg-green-300/50 dark:hover:bg-green-800/50'
|
||||
: 'bg-orange-200/50 dark:bg-orange-900/50 hover:bg-orange-300/50 dark:hover:bg-orange-800/50'
|
||||
} transition-colors transform hover:scale-110 transition-transform duration-200 z-20 ${isConnecting ? 'animate-pulse' : ''}`}
|
||||
title={client.status === 'offline' ? 'Connect client' : 'Disconnect client'}
|
||||
>
|
||||
{client.status === 'offline' ? (
|
||||
<Plug className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<PlugZap className="w-4 h-4 text-orange-600 dark:text-orange-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Edit button - also on back side */}
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className={`absolute top-3 right-12 p-1.5 rounded-full ${isArchonClient ? 'bg-blue-200/50 dark:bg-blue-900/50 hover:bg-blue-300/50 dark:hover:bg-blue-800/50' : 'bg-gray-200/50 dark:bg-gray-800/50 hover:bg-gray-300/50 dark:hover:bg-gray-700/50'} transition-colors transform hover:scale-110 transition-transform duration-200 z-20`}
|
||||
title="Edit client configuration"
|
||||
>
|
||||
<Settings className={`w-4 h-4 ${isArchonClient ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'}`} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete button on back side - only for non-Archon clients */}
|
||||
{!isArchonClient && onDelete && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(client);
|
||||
}}
|
||||
className="absolute top-3 right-[84px] p-1.5 rounded-full bg-red-200/50 dark:bg-red-900/50 hover:bg-red-300/50 dark:hover:bg-red-800/50 transition-colors transform hover:scale-110 transition-transform duration-200 z-20"
|
||||
title="Delete client"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<h3 className={`font-bold text-gray-800 dark:text-white mb-3 flex items-center ${isArchonClient ? 'bg-gradient-to-r from-blue-400 to-purple-500 text-transparent bg-clip-text animate-text-shimmer' : ''}`}>
|
||||
<Hammer className={`w-4 h-4 mr-2 ${isArchonClient ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'}`} />
|
||||
Available Tools ({client.tools.length})
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2 overflow-y-auto max-h-[140px] pr-1 hide-scrollbar">
|
||||
{client.tools.length === 0 ? (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{client.status === 'offline'
|
||||
? 'Client offline - tools unavailable'
|
||||
: 'No tools discovered'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
client.tools.map(tool => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className={`p-2 rounded-md ${isArchonClient ? 'bg-blue-50/50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700/50 hover:border-blue-300 dark:hover:border-blue-600/50' : 'bg-gray-100/50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700/50 hover:border-gray-300 dark:hover:border-gray-600/50'} transition-colors transform hover:translate-x-1 transition-transform duration-200`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`font-mono text-xs ${isArchonClient ? 'text-blue-600 dark:text-blue-400' : 'text-blue-600 dark:text-blue-400'}`}>
|
||||
{tool.name}
|
||||
</span>
|
||||
<ChevronRight className="w-3 h-3 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
|
||||
{tool.description}
|
||||
</p>
|
||||
{tool.parameters.length > 0 && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
{tool.parameters.length} parameter{tool.parameters.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status badge - also at bottom left on back side */}
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<div className={`px-2.5 py-1 rounded-full text-xs font-medium flex items-center gap-1.5 border ${currentStatus.badge}`}>
|
||||
<div className="relative flex h-2 w-2">
|
||||
<span className={`animate-ping-slow absolute inline-flex h-full w-full rounded-full ${currentStatus.pulse} opacity-75`}></span>
|
||||
<span className={`relative inline-flex rounded-full h-2 w-2 ${currentStatus.pulse}`}></span>
|
||||
</div>
|
||||
{client.status.charAt(0).toUpperCase() + client.status.slice(1)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flip button - back to front */}
|
||||
<button
|
||||
onClick={toggleFlip}
|
||||
className={`absolute bottom-4 right-4 p-1.5 rounded-full ${isArchonClient ? 'bg-blue-200/50 dark:bg-blue-900/50 hover:bg-blue-300/50 dark:hover:bg-blue-800/50' : 'bg-gray-200/50 dark:bg-gray-800/50 hover:bg-gray-300/50 dark:hover:bg-gray-700/50'} transition-colors transform hover:scale-110 transition-transform duration-200 z-10`}
|
||||
title="Show client details"
|
||||
>
|
||||
<Server className={`w-4 h-4 ${isArchonClient ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,858 +0,0 @@
|
||||
import React, { useState, memo, useEffect } from 'react';
|
||||
import { Plus, Settings, Trash2, X } from 'lucide-react';
|
||||
import { ClientCard } from './ClientCard';
|
||||
import { ToolTestingPanel } from './ToolTestingPanel';
|
||||
import { Button } from '../ui/Button';
|
||||
import { mcpClientService, MCPClient, MCPClientConfig } from '../../services/mcpClientService';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { DeleteConfirmModal } from '../common/DeleteConfirmModal';
|
||||
|
||||
// Client interface (keeping for backward compatibility)
|
||||
export interface Client {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'online' | 'offline' | 'error';
|
||||
ip: string;
|
||||
lastSeen: string;
|
||||
version: string;
|
||||
tools: Tool[];
|
||||
region?: string;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
// Tool interface
|
||||
export interface Tool {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: ToolParameter[];
|
||||
}
|
||||
|
||||
// Tool parameter interface
|
||||
export interface ToolParameter {
|
||||
name: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'array';
|
||||
required: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const MCPClients = memo(() => {
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// State for selected client and panel visibility
|
||||
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
const [isAddClientModalOpen, setIsAddClientModalOpen] = useState(false);
|
||||
|
||||
// State for edit drawer
|
||||
const [editClient, setEditClient] = useState<Client | null>(null);
|
||||
const [isEditDrawerOpen, setIsEditDrawerOpen] = useState(false);
|
||||
|
||||
const { showToast } = useToast();
|
||||
|
||||
// State for delete confirmation modal
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [clientToDelete, setClientToDelete] = useState<Client | null>(null);
|
||||
|
||||
// Load clients when component mounts
|
||||
useEffect(() => {
|
||||
loadAllClients();
|
||||
|
||||
// Set up periodic status checks every 10 seconds
|
||||
const statusInterval = setInterval(() => {
|
||||
// Silently refresh client statuses without loading state
|
||||
refreshClientStatuses();
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(statusInterval);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Refresh client statuses without showing loading state
|
||||
*/
|
||||
const refreshClientStatuses = async () => {
|
||||
try {
|
||||
const dbClients = await mcpClientService.getClients();
|
||||
|
||||
setClients(prevClients =>
|
||||
prevClients.map(client => {
|
||||
const dbClient = dbClients.find(db => db.id === client.id);
|
||||
if (dbClient) {
|
||||
return {
|
||||
...client,
|
||||
status: dbClient.status === 'connected' ? 'online' :
|
||||
dbClient.status === 'error' ? 'error' : 'offline',
|
||||
lastSeen: dbClient.last_seen ? new Date(dbClient.last_seen).toLocaleString() : 'Never',
|
||||
lastError: dbClient.last_error || undefined
|
||||
};
|
||||
}
|
||||
return client;
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh client statuses:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load all clients: Archon (hardcoded) + real database clients
|
||||
*/
|
||||
const loadAllClients = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Load ALL clients from database (including Archon)
|
||||
let dbClients: MCPClient[] = [];
|
||||
try {
|
||||
dbClients = await mcpClientService.getClients();
|
||||
} catch (clientError) {
|
||||
console.warn('Failed to load database clients:', clientError);
|
||||
dbClients = [];
|
||||
}
|
||||
|
||||
// Convert database clients to our Client interface and load their tools
|
||||
const convertedClients: Client[] = await Promise.all(
|
||||
dbClients.map(async (dbClient) => {
|
||||
const client = convertDbClientToClient(dbClient);
|
||||
// Load tools for connected clients using universal method
|
||||
if (client.status === 'online') {
|
||||
await loadTools(client);
|
||||
}
|
||||
return client;
|
||||
})
|
||||
);
|
||||
|
||||
// Set all clients (Archon will be included as a regular client)
|
||||
setClients(convertedClients);
|
||||
} catch (error) {
|
||||
console.error('Failed to load MCP clients:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to load clients');
|
||||
setClients([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert database MCP client to our Client interface
|
||||
*/
|
||||
const convertDbClientToClient = (dbClient: MCPClient): Client => {
|
||||
// Map database status to our status types
|
||||
const statusMap: Record<string, 'online' | 'offline' | 'error'> = {
|
||||
'connected': 'online',
|
||||
'disconnected': 'offline',
|
||||
'connecting': 'offline',
|
||||
'error': 'error'
|
||||
};
|
||||
|
||||
// Extract connection info (Streamable HTTP-only)
|
||||
const config = dbClient.connection_config;
|
||||
const ip = config.url || 'N/A';
|
||||
|
||||
return {
|
||||
id: dbClient.id,
|
||||
name: dbClient.name,
|
||||
status: statusMap[dbClient.status] || 'offline',
|
||||
ip,
|
||||
lastSeen: dbClient.last_seen ? new Date(dbClient.last_seen).toLocaleString() : 'Never',
|
||||
version: config.version || 'Unknown',
|
||||
region: config.region || 'Unknown',
|
||||
tools: [], // Will be loaded separately
|
||||
lastError: dbClient.last_error || undefined
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Load tools from any MCP client using universal client service
|
||||
*/
|
||||
const loadTools = async (client: Client) => {
|
||||
try {
|
||||
const toolsResponse = await mcpClientService.getClientTools(client.id);
|
||||
|
||||
// Convert client tools to our Tool interface format
|
||||
const convertedTools: Tool[] = toolsResponse.tools.map((clientTool: any, index: number) => {
|
||||
const parameters: ToolParameter[] = [];
|
||||
|
||||
// Extract parameters from tool schema
|
||||
if (clientTool.tool_schema?.inputSchema?.properties) {
|
||||
const required = clientTool.tool_schema.inputSchema.required || [];
|
||||
Object.entries(clientTool.tool_schema.inputSchema.properties).forEach(([name, schema]: [string, any]) => {
|
||||
parameters.push({
|
||||
name,
|
||||
type: schema.type === 'integer' ? 'number' :
|
||||
schema.type === 'array' ? 'array' :
|
||||
schema.type === 'boolean' ? 'boolean' : 'string',
|
||||
required: required.includes(name),
|
||||
description: schema.description || `${name} parameter`
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${client.id}-${index}`,
|
||||
name: clientTool.tool_name,
|
||||
description: clientTool.tool_description || 'No description available',
|
||||
parameters
|
||||
};
|
||||
});
|
||||
|
||||
client.tools = convertedTools;
|
||||
console.log(`Loaded ${convertedTools.length} tools for client ${client.name}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load tools for client ${client.name}:`, error);
|
||||
client.tools = [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle adding a new client
|
||||
*/
|
||||
const handleAddClient = async (clientConfig: MCPClientConfig) => {
|
||||
try {
|
||||
// Create client in database
|
||||
const newClient = await mcpClientService.createClient(clientConfig);
|
||||
|
||||
// Convert and add to local state
|
||||
const convertedClient = convertDbClientToClient(newClient);
|
||||
|
||||
// Try to load tools if client is connected
|
||||
if (convertedClient.status === 'online') {
|
||||
await loadTools(convertedClient);
|
||||
}
|
||||
|
||||
setClients(prev => [...prev, convertedClient]);
|
||||
|
||||
// Close modal
|
||||
setIsAddClientModalOpen(false);
|
||||
|
||||
console.log('Client added successfully:', newClient.name);
|
||||
} catch (error) {
|
||||
console.error('Failed to add client:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to add client');
|
||||
throw error; // Re-throw so modal can handle it
|
||||
}
|
||||
};
|
||||
|
||||
// Handle client selection
|
||||
const handleSelectClient = async (client: Client) => {
|
||||
setSelectedClient(client);
|
||||
setIsPanelOpen(true);
|
||||
|
||||
// Refresh tools for the selected client if needed
|
||||
if (client.tools.length === 0 && client.status === 'online') {
|
||||
await loadTools(client);
|
||||
|
||||
// Update the client in the list
|
||||
setClients(prev => prev.map(c => c.id === client.id ? client : c));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle client editing
|
||||
const handleEditClient = (client: Client) => {
|
||||
setEditClient(client);
|
||||
setIsEditDrawerOpen(true);
|
||||
};
|
||||
|
||||
// Handle client deletion (triggers confirmation modal)
|
||||
const handleDeleteClient = (client: Client) => {
|
||||
setClientToDelete(client);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
// Refresh clients list (for after connection state changes)
|
||||
const refreshClients = async () => {
|
||||
try {
|
||||
const dbClients = await mcpClientService.getClients();
|
||||
const convertedClients = await Promise.all(
|
||||
dbClients.map(async (dbClient) => {
|
||||
const client = convertDbClientToClient(dbClient);
|
||||
if (client.status === 'online') {
|
||||
await loadTools(client);
|
||||
}
|
||||
return client;
|
||||
})
|
||||
);
|
||||
setClients(convertedClients);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh clients:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to refresh clients');
|
||||
}
|
||||
};
|
||||
|
||||
// Confirm deletion and execute
|
||||
const confirmDeleteClient = async () => {
|
||||
if (!clientToDelete) return;
|
||||
|
||||
try {
|
||||
await mcpClientService.deleteClient(clientToDelete.id);
|
||||
setClients(prev => prev.filter(c => c.id !== clientToDelete.id));
|
||||
showToast(`MCP Client "${clientToDelete.name}" deleted successfully`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete MCP client:', error);
|
||||
showToast(error instanceof Error ? error.message : 'Failed to delete MCP client', 'error');
|
||||
} finally {
|
||||
setShowDeleteConfirm(false);
|
||||
setClientToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel deletion
|
||||
const cancelDeleteClient = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
setClientToDelete(null);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="relative min-h-[80vh] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-cyan-400 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading MCP clients...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<p className="text-red-600 dark:text-red-400">{error}</p>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="text-red-500 hover:text-red-600 text-sm mt-2"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Client Button */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">MCP Clients</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Connect and manage your MCP-enabled applications
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsAddClientModalOpen(true)}
|
||||
variant="primary"
|
||||
accentColor="cyan"
|
||||
className="shadow-cyan-500/20 shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Client
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Client Grid */}
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 relative z-10">
|
||||
{clients.map(client => (
|
||||
<ClientCard
|
||||
key={client.id}
|
||||
client={client}
|
||||
onSelect={() => handleSelectClient(client)}
|
||||
onEdit={() => handleEditClient(client)}
|
||||
onDelete={() => handleDeleteClient(client)}
|
||||
onConnectionChange={refreshClients}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool Testing Panel */}
|
||||
<ToolTestingPanel
|
||||
client={selectedClient}
|
||||
isOpen={isPanelOpen}
|
||||
onClose={() => setIsPanelOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Add Client Modal */}
|
||||
{isAddClientModalOpen && (
|
||||
<AddClientModal
|
||||
isOpen={isAddClientModalOpen}
|
||||
onClose={() => setIsAddClientModalOpen(false)}
|
||||
onSubmit={handleAddClient}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Client Drawer */}
|
||||
{isEditDrawerOpen && editClient && (
|
||||
<EditClientDrawer
|
||||
client={editClient}
|
||||
isOpen={isEditDrawerOpen}
|
||||
onClose={() => {
|
||||
setIsEditDrawerOpen(false);
|
||||
setEditClient(null);
|
||||
}}
|
||||
onUpdate={(updatedClient) => {
|
||||
// Update the client in state or remove if deleted
|
||||
setClients(prev => {
|
||||
if (!updatedClient) { // If updatedClient is null, it means deletion
|
||||
return prev.filter(c => c.id !== editClient?.id); // Remove the client that was being edited
|
||||
}
|
||||
return prev.map(c => c.id === updatedClient.id ? updatedClient : c);
|
||||
});
|
||||
setIsEditDrawerOpen(false);
|
||||
setEditClient(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal for Clients */}
|
||||
{showDeleteConfirm && clientToDelete && (
|
||||
<DeleteConfirmModal
|
||||
itemName={clientToDelete.name}
|
||||
onConfirm={confirmDeleteClient}
|
||||
onCancel={cancelDeleteClient}
|
||||
type="client"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Add Client Modal Component
|
||||
interface AddClientModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (config: MCPClientConfig) => Promise<void>;
|
||||
}
|
||||
|
||||
const AddClientModal: React.FC<AddClientModalProps> = ({ isOpen, onClose, onSubmit }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
url: '',
|
||||
auto_connect: true
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
setError('Client name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Validate URL
|
||||
if (!formData.url.trim()) {
|
||||
setError('MCP server URL is required');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure URL is valid
|
||||
try {
|
||||
const url = new URL(formData.url);
|
||||
if (!url.protocol.startsWith('http')) {
|
||||
setError('URL must start with http:// or https://');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Invalid URL format');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const connection_config = {
|
||||
url: formData.url.trim()
|
||||
};
|
||||
|
||||
const clientConfig: MCPClientConfig = {
|
||||
name: formData.name.trim(),
|
||||
transport_type: 'http',
|
||||
connection_config,
|
||||
auto_connect: formData.auto_connect
|
||||
};
|
||||
|
||||
await onSubmit(clientConfig);
|
||||
|
||||
// Reset form on success
|
||||
setFormData({
|
||||
name: '',
|
||||
url: '',
|
||||
auto_connect: true
|
||||
});
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to add client');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-white/90 dark:bg-black/90 border border-gray-200 dark:border-gray-800 rounded-lg p-6 w-full max-w-md relative backdrop-blur-lg">
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-cyan-400 via-blue-500 to-cyan-400 shadow-[0_0_10px_rgba(34,211,238,0.6)]"></div>
|
||||
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Add New MCP Client
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Client Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Client Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-white/50 dark:bg-black/50 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
placeholder="Enter client name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MCP Server URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
MCP Server URL *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.url}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, url: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-white/50 dark:bg-black/50 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
placeholder="http://host.docker.internal:8051/mcp"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
The HTTP endpoint URL of the MCP server
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<strong>Docker Note:</strong> Use <code>host.docker.internal</code> instead of <code>localhost</code>
|
||||
to access services running on your host machine
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto Connect */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="auto_connect"
|
||||
checked={formData.auto_connect}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, auto_connect: e.target.checked }))}
|
||||
className="mr-2"
|
||||
/>
|
||||
<label htmlFor="auto_connect" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Auto-connect on startup
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="text-red-600 dark:text-red-400 text-sm bg-red-50 dark:bg-red-900/20 p-2 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="ghost" onClick={onClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
accentColor="cyan"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : 'Add Client'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Edit Client Drawer Component
|
||||
interface EditClientDrawerProps {
|
||||
client: Client;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onUpdate: (client: Client | null) => void; // Allow null to indicate deletion
|
||||
}
|
||||
|
||||
const EditClientDrawer: React.FC<EditClientDrawerProps> = ({ client, isOpen, onClose, onUpdate }) => {
|
||||
const [editFormData, setEditFormData] = useState({
|
||||
name: client.name,
|
||||
url: '',
|
||||
auto_connect: true
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
||||
// State for delete confirmation modal (moved here)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [clientToDelete, setClientToDelete] = useState<Client | null>(null);
|
||||
|
||||
const { showToast } = useToast(); // Initialize useToast here
|
||||
|
||||
// Load current client config when drawer opens
|
||||
useEffect(() => {
|
||||
if (isOpen && client) {
|
||||
// Get client config from the API and populate form
|
||||
loadClientConfig();
|
||||
}
|
||||
}, [isOpen, client.id]);
|
||||
|
||||
const loadClientConfig = async () => {
|
||||
try {
|
||||
const dbClient = await mcpClientService.getClient(client.id);
|
||||
const config = dbClient.connection_config;
|
||||
|
||||
setEditFormData({
|
||||
name: dbClient.name,
|
||||
url: config.url || '',
|
||||
auto_connect: dbClient.auto_connect
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load client config:', error);
|
||||
setError('Failed to load client configuration');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Validate URL
|
||||
if (!editFormData.url.trim()) {
|
||||
setError('MCP server URL is required');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure URL is valid
|
||||
try {
|
||||
const url = new URL(editFormData.url);
|
||||
if (!url.protocol.startsWith('http')) {
|
||||
setError('URL must start with http:// or https://');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Invalid URL format');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const connection_config = {
|
||||
url: editFormData.url.trim()
|
||||
};
|
||||
|
||||
// Update client via API
|
||||
const updatedClient = await mcpClientService.updateClient(client.id, {
|
||||
name: editFormData.name,
|
||||
transport_type: 'http',
|
||||
connection_config,
|
||||
auto_connect: editFormData.auto_connect
|
||||
});
|
||||
|
||||
// Update local state
|
||||
const convertedClient = {
|
||||
...client,
|
||||
name: updatedClient.name,
|
||||
ip: editFormData.url
|
||||
};
|
||||
|
||||
onUpdate(convertedClient);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to update client');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async () => {
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
await mcpClientService.connectClient(client.id);
|
||||
// Reload the client to get updated status
|
||||
loadClientConfig();
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to connect');
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
await mcpClientService.disconnectClient(client.id);
|
||||
// Reload the client to get updated status
|
||||
loadClientConfig();
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to disconnect');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (confirm(`Are you sure you want to delete "${client.name}"?`)) {
|
||||
try {
|
||||
await mcpClientService.deleteClient(client.id);
|
||||
onClose();
|
||||
// Trigger a reload of the clients list
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to delete client');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-end justify-center z-50" onClick={onClose}>
|
||||
<div
|
||||
className="bg-white/90 dark:bg-black/90 border border-gray-200 dark:border-gray-800 rounded-t-lg p-6 w-full max-w-2xl relative backdrop-blur-lg animate-slide-up max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-cyan-400 via-blue-500 to-cyan-400 shadow-[0_0_10px_rgba(34,211,238,0.6)]"></div>
|
||||
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<Settings className="w-5 h-5 mr-2 text-cyan-500" />
|
||||
Edit Client Configuration
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleUpdateSubmit} className="space-y-4">
|
||||
{/* Client Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Client Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editFormData.name}
|
||||
onChange={(e) => setEditFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-white/50 dark:bg-black/50 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MCP Server URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
MCP Server URL *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editFormData.url}
|
||||
onChange={(e) => setEditFormData(prev => ({ ...prev, url: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-white/50 dark:bg-black/50 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
placeholder="http://host.docker.internal:8051/mcp"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
The HTTP endpoint URL of the MCP server
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<strong>Docker Note:</strong> Use <code>host.docker.internal</code> instead of <code>localhost</code>
|
||||
to access services running on your host machine
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto Connect */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="edit_auto_connect"
|
||||
checked={editFormData.auto_connect}
|
||||
onChange={(e) => setEditFormData(prev => ({ ...prev, auto_connect: e.target.checked }))}
|
||||
className="mr-2"
|
||||
/>
|
||||
<label htmlFor="edit_auto_connect" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Auto-connect on startup
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="text-red-600 dark:text-red-400 text-sm bg-red-50 dark:bg-red-900/20 p-2 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Quick Actions</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
accentColor="green"
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || client.status === 'online'}
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : client.status === 'online' ? 'Connected' : 'Connect'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
accentColor="orange"
|
||||
onClick={handleDisconnect}
|
||||
disabled={client.status === 'offline'}
|
||||
>
|
||||
{client.status === 'offline' ? 'Disconnected' : 'Disconnect'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
accentColor="pink"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete Client
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
accentColor="cyan"
|
||||
onClick={() => window.open(`/api/mcp/clients/${client.id}/status`, '_blank')}
|
||||
>
|
||||
Debug Status
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Buttons */}
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
accentColor="cyan"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Updating...' : 'Update Configuration'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,568 +0,0 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { X, Play, ChevronDown, TerminalSquare, Copy, Check, MinusCircle, Maximize2, Minimize2, Hammer, GripHorizontal } from 'lucide-react';
|
||||
import { Client, Tool } from './MCPClients';
|
||||
import { Button } from '../ui/Button';
|
||||
import { mcpClientService } from '../../services/mcpClientService';
|
||||
|
||||
interface ToolTestingPanelProps {
|
||||
client: Client | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface TerminalLine {
|
||||
id: string;
|
||||
content: string;
|
||||
isTyping: boolean;
|
||||
isCommand: boolean;
|
||||
isError?: boolean;
|
||||
isWarning?: boolean;
|
||||
}
|
||||
|
||||
export const ToolTestingPanel = ({
|
||||
client,
|
||||
isOpen,
|
||||
onClose
|
||||
}: ToolTestingPanelProps) => {
|
||||
const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
|
||||
const [terminalOutput, setTerminalOutput] = useState<TerminalLine[]>([{
|
||||
id: '1',
|
||||
content: '> Tool testing terminal ready',
|
||||
isTyping: false,
|
||||
isCommand: true
|
||||
}]);
|
||||
const [paramValues, setParamValues] = useState<Record<string, string>>({});
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [panelHeight, setPanelHeight] = useState(400);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const resizeHandleRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const previousHeightRef = useRef<number>(400);
|
||||
|
||||
// Reset selected tool when client changes
|
||||
useEffect(() => {
|
||||
if (client && client.tools.length > 0) {
|
||||
setSelectedTool(client.tools[0]);
|
||||
setParamValues({});
|
||||
} else {
|
||||
setSelectedTool(null);
|
||||
setParamValues({});
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
// Auto-scroll terminal to bottom when output changes
|
||||
useEffect(() => {
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
|
||||
}
|
||||
}, [terminalOutput]);
|
||||
|
||||
// Handle resizing functionality
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isResizing && panelRef.current) {
|
||||
const containerHeight = window.innerHeight;
|
||||
const mouseY = e.clientY;
|
||||
const newHeight = containerHeight - mouseY;
|
||||
if (newHeight >= 200 && newHeight <= containerHeight * 0.8) {
|
||||
setPanelHeight(newHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
document.body.style.cursor = 'default';
|
||||
document.body.style.userSelect = 'auto';
|
||||
};
|
||||
|
||||
if (isResizing) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'ns-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isResizing]);
|
||||
|
||||
// Handle tool selection
|
||||
const handleToolSelect = (tool: Tool) => {
|
||||
setSelectedTool(tool);
|
||||
setParamValues({});
|
||||
};
|
||||
|
||||
// Handle parameter value change
|
||||
const handleParamChange = (paramName: string, value: string) => {
|
||||
setParamValues(prev => ({
|
||||
...prev,
|
||||
[paramName]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// Simulate typing animation for terminal output
|
||||
const addTypingLine = (content: string, isCommand: boolean = false, isError: boolean = false, isWarning: boolean = false) => {
|
||||
const newLineId = Date.now().toString() + Math.random().toString(36).substring(2);
|
||||
|
||||
setTerminalOutput(prev => [...prev, {
|
||||
id: newLineId,
|
||||
content: '',
|
||||
isTyping: true,
|
||||
isCommand,
|
||||
isError,
|
||||
isWarning
|
||||
}]);
|
||||
|
||||
// Simulate typing animation
|
||||
let currentText = '';
|
||||
const textArray = content.split('');
|
||||
const typeInterval = setInterval(() => {
|
||||
if (textArray.length > 0) {
|
||||
currentText += textArray.shift();
|
||||
setTerminalOutput(prev => prev.map(line =>
|
||||
line.id === newLineId ? {
|
||||
...line,
|
||||
content: currentText
|
||||
} : line
|
||||
));
|
||||
} else {
|
||||
clearInterval(typeInterval);
|
||||
setTerminalOutput(prev => prev.map(line =>
|
||||
line.id === newLineId ? {
|
||||
...line,
|
||||
isTyping: false
|
||||
} : line
|
||||
));
|
||||
}
|
||||
}, 15); // Faster typing
|
||||
|
||||
return newLineId;
|
||||
};
|
||||
|
||||
// Add instant line (no typing effect)
|
||||
const addInstantLine = (content: string, isCommand: boolean = false, isError: boolean = false, isWarning: boolean = false) => {
|
||||
const newLineId = Date.now().toString() + Math.random().toString(36).substring(2);
|
||||
|
||||
setTerminalOutput(prev => [...prev, {
|
||||
id: newLineId,
|
||||
content,
|
||||
isTyping: false,
|
||||
isCommand,
|
||||
isError,
|
||||
isWarning
|
||||
}]);
|
||||
|
||||
return newLineId;
|
||||
};
|
||||
|
||||
// Convert parameter values to proper types
|
||||
const convertParameterValues = (): Record<string, any> => {
|
||||
if (!selectedTool) return {};
|
||||
|
||||
const convertedParams: Record<string, any> = {};
|
||||
|
||||
selectedTool.parameters.forEach(param => {
|
||||
const value = paramValues[param.name];
|
||||
|
||||
if (value !== undefined && value !== '') {
|
||||
try {
|
||||
switch (param.type) {
|
||||
case 'number':
|
||||
convertedParams[param.name] = Number(value);
|
||||
if (isNaN(convertedParams[param.name])) {
|
||||
throw new Error(`Invalid number: ${value}`);
|
||||
}
|
||||
break;
|
||||
case 'boolean':
|
||||
convertedParams[param.name] = value.toLowerCase() === 'true' || value === '1';
|
||||
break;
|
||||
case 'array':
|
||||
// Try to parse as JSON array first, fallback to comma-separated
|
||||
try {
|
||||
convertedParams[param.name] = JSON.parse(value);
|
||||
if (!Array.isArray(convertedParams[param.name])) {
|
||||
throw new Error('Not an array');
|
||||
}
|
||||
} catch {
|
||||
convertedParams[param.name] = value.split(',').map(v => v.trim()).filter(v => v);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
convertedParams[param.name] = value;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Parameter conversion error for ${param.name}:`, error);
|
||||
convertedParams[param.name] = value; // Fallback to string
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return convertedParams;
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Execute tool using universal MCP client service (works for ALL clients)
|
||||
const executeTool = async () => {
|
||||
if (!selectedTool || !client) return;
|
||||
|
||||
try {
|
||||
const convertedParams = convertParameterValues();
|
||||
|
||||
addTypingLine(`> Connecting to ${client.name} via MCP protocol...`);
|
||||
|
||||
// Call the client tool via MCP service
|
||||
const result = await mcpClientService.callClientTool({
|
||||
client_id: client.id,
|
||||
tool_name: selectedTool.name,
|
||||
arguments: convertedParams
|
||||
});
|
||||
|
||||
setTimeout(() => addTypingLine('> Tool executed successfully'), 300);
|
||||
|
||||
// Display the result
|
||||
setTimeout(() => {
|
||||
if (result) {
|
||||
let resultText = '';
|
||||
|
||||
if (typeof result === 'object') {
|
||||
if (result.content) {
|
||||
// Handle MCP content response
|
||||
if (Array.isArray(result.content)) {
|
||||
resultText = result.content.map((item: any) =>
|
||||
item.text || JSON.stringify(item, null, 2)
|
||||
).join('\n');
|
||||
} else {
|
||||
resultText = result.content.text || JSON.stringify(result.content, null, 2);
|
||||
}
|
||||
} else {
|
||||
resultText = JSON.stringify(result, null, 2);
|
||||
}
|
||||
} else {
|
||||
resultText = String(result);
|
||||
}
|
||||
|
||||
addInstantLine('> Result:');
|
||||
addInstantLine(resultText);
|
||||
} else {
|
||||
addTypingLine('> No result returned');
|
||||
}
|
||||
|
||||
addTypingLine('> Completed successfully');
|
||||
setIsExecuting(false);
|
||||
}, 600);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('MCP tool execution failed:', error);
|
||||
setTimeout(() => {
|
||||
addTypingLine(`> ERROR: Failed to execute tool on ${client.name}`, false, true);
|
||||
addTypingLine(`> ${error.message || 'Unknown error occurred'}`, false, true);
|
||||
addTypingLine('> Execution failed');
|
||||
setIsExecuting(false);
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
// Validate required parameters
|
||||
const validateParameters = (): string | null => {
|
||||
if (!selectedTool) return 'No tool selected';
|
||||
|
||||
for (const param of selectedTool.parameters) {
|
||||
if (param.required && !paramValues[param.name]) {
|
||||
return `Required parameter '${param.name}' is missing`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Handle tool execution
|
||||
const executeSelectedTool = () => {
|
||||
if (!selectedTool || !client || isExecuting) return;
|
||||
|
||||
// Validate required parameters
|
||||
const validationError = validateParameters();
|
||||
if (validationError) {
|
||||
addTypingLine(`> ERROR: ${validationError}`, false, true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExecuting(true);
|
||||
|
||||
// Add command to terminal
|
||||
const params = selectedTool.parameters.map(p => {
|
||||
const value = paramValues[p.name];
|
||||
return value ? `${p.name}=${value}` : undefined;
|
||||
}).filter(Boolean).join(' ');
|
||||
|
||||
const command = `> execute ${selectedTool.name} ${params}`;
|
||||
addTypingLine(command, true);
|
||||
|
||||
// Execute using universal client service for ALL clients
|
||||
setTimeout(() => {
|
||||
executeTool();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// Handle copy terminal output
|
||||
const copyTerminalOutput = () => {
|
||||
const textContent = terminalOutput.map(line => line.content).join('\n');
|
||||
navigator.clipboard.writeText(textContent);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
};
|
||||
|
||||
// Handle resize start
|
||||
const handleResizeStart = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
};
|
||||
|
||||
// Handle maximize/minimize
|
||||
const toggleMaximize = () => {
|
||||
if (isMaximized) {
|
||||
setPanelHeight(previousHeightRef.current);
|
||||
} else {
|
||||
previousHeightRef.current = panelHeight;
|
||||
setPanelHeight(window.innerHeight * 0.8);
|
||||
}
|
||||
setIsMaximized(!isMaximized);
|
||||
};
|
||||
|
||||
// Clear terminal
|
||||
const clearTerminal = () => {
|
||||
setTerminalOutput([{
|
||||
id: Date.now().toString(),
|
||||
content: '> Terminal cleared',
|
||||
isTyping: false,
|
||||
isCommand: true
|
||||
}]);
|
||||
};
|
||||
|
||||
if (!isOpen || !client) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`fixed bottom-0 left-1/2 transform -translate-x-1/2 backdrop-blur-md bg-gradient-to-t from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border-t border-gray-200 dark:border-gray-800 transition-all duration-500 ease-in-out z-30 shadow-2xl rounded-t-xl overflow-hidden ${isOpen ? 'translate-y-0' : 'translate-y-full'}`}
|
||||
style={{
|
||||
height: `${panelHeight}px`,
|
||||
width: 'calc(100% - 4rem)',
|
||||
maxWidth: '1400px'
|
||||
}}
|
||||
>
|
||||
{/* Resize handle at the top */}
|
||||
<div
|
||||
ref={resizeHandleRef}
|
||||
className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize group transform -translate-y-1 z-10"
|
||||
onMouseDown={handleResizeStart}
|
||||
>
|
||||
<div className="w-16 h-1 mx-auto bg-gray-300 dark:bg-gray-600 rounded-full group-hover:bg-cyan-400 dark:group-hover:bg-cyan-500 transition-colors"></div>
|
||||
</div>
|
||||
|
||||
{/* Panel with neon effect */}
|
||||
<div className="relative h-full">
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-cyan-500 shadow-[0_0_20px_5px_rgba(34,211,238,0.7),0_0_10px_2px_rgba(34,211,238,1.0)] dark:shadow-[0_0_25px_8px_rgba(34,211,238,0.8),0_0_15px_3px_rgba(34,211,238,1.0)]"></div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white flex items-center">
|
||||
<span className={`w-2 h-2 rounded-full mr-2 ${
|
||||
client.status === 'online'
|
||||
? 'bg-cyan-400 shadow-[0_0_8px_rgba(34,211,238,0.6)]'
|
||||
: client.status === 'offline'
|
||||
? 'bg-gray-400'
|
||||
: 'bg-pink-400 shadow-[0_0_8px_rgba(236,72,153,0.6)]'
|
||||
}`}></span>
|
||||
{client.name}
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||
{client.ip}
|
||||
</span>
|
||||
<span className="ml-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{client.tools.length} tools available
|
||||
</span>
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={clearTerminal}
|
||||
className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400 transition-colors"
|
||||
title="Clear terminal"
|
||||
>
|
||||
<TerminalSquare className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleMaximize}
|
||||
className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400 transition-colors"
|
||||
title={isMaximized ? 'Minimize panel' : 'Maximize panel'}
|
||||
>
|
||||
{isMaximized ? <Minimize2 className="w-5 h-5" /> : <Maximize2 className="w-5 h-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400 transition-colors"
|
||||
title="Close panel"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-4 h-[calc(100%-73px)] overflow-y-auto">
|
||||
{client.tools.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<Hammer className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No Tools Available</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{client.status === 'offline'
|
||||
? 'Client is offline. Tools will be available when connected.'
|
||||
: 'No tools discovered for this client.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Left column: Tool selection and parameters */}
|
||||
<div>
|
||||
{/* Tool selection and execute button row */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Select Tool
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
className="w-full bg-white/50 dark:bg-black/50 border border-gray-300 dark:border-gray-700 rounded-md py-2 pl-3 pr-10 text-gray-900 dark:text-white appearance-none focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500"
|
||||
value={selectedTool?.id || ''}
|
||||
onChange={e => {
|
||||
const tool = client.tools.find(t => t.id === e.target.value);
|
||||
if (tool) handleToolSelect(tool);
|
||||
}}
|
||||
>
|
||||
{client.tools.map(tool => (
|
||||
<option key={tool.id} value={tool.id}>
|
||||
{tool.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
accentColor="cyan"
|
||||
onClick={executeSelectedTool}
|
||||
disabled={!selectedTool || isExecuting}
|
||||
>
|
||||
{isExecuting ? (
|
||||
<div className="flex items-center">
|
||||
<span className="inline-block w-4 h-4 mr-2 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
|
||||
Executing...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Execute Tool
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool description */}
|
||||
{selectedTool && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{selectedTool.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Parameters */}
|
||||
{selectedTool && selectedTool.parameters.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{selectedTool.parameters.map(param => (
|
||||
<div key={param.name}>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||
{param.name}
|
||||
{param.required && <span className="text-pink-500 ml-1">*</span>}
|
||||
<span className="text-gray-400 ml-1">({param.type})</span>
|
||||
</label>
|
||||
<input
|
||||
type={param.type === 'number' ? 'number' : 'text'}
|
||||
value={paramValues[param.name] || ''}
|
||||
onChange={e => handleParamChange(param.name, e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm bg-white/50 dark:bg-black/50 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 transition-all duration-200"
|
||||
placeholder={param.description || `Enter ${param.name}`}
|
||||
/>
|
||||
{param.description && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{param.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column: Terminal output */}
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 bg-gray-900 rounded-lg overflow-hidden relative border border-gray-800 h-full">
|
||||
<div className="flex items-center justify-between bg-gray-800 px-3 py-2">
|
||||
<div className="flex items-center">
|
||||
<TerminalSquare className="w-4 h-4 text-cyan-400 mr-2" />
|
||||
<span className="text-xs text-gray-300 font-medium">
|
||||
Terminal Output
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyTerminalOutput}
|
||||
className="p-1 rounded hover:bg-gray-700 transition-colors"
|
||||
title="Copy output"
|
||||
>
|
||||
{isCopied ?
|
||||
<Check className="w-4 h-4 text-green-400" /> :
|
||||
<Copy className="w-4 h-4 text-gray-400 hover:text-gray-300" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="p-3 h-[calc(100%-36px)] overflow-y-auto font-mono text-xs text-gray-300 space-y-1"
|
||||
>
|
||||
{terminalOutput.map(line => (
|
||||
<div key={line.id} className={`
|
||||
${line.isCommand ? 'text-cyan-400' : ''}
|
||||
${line.isWarning ? 'text-yellow-400' : ''}
|
||||
${line.isError ? 'text-pink-400' : ''}
|
||||
${line.isTyping ? 'terminal-typing' : ''}
|
||||
whitespace-pre-wrap
|
||||
`}>
|
||||
{line.content}
|
||||
{line.isTyping && <span className="terminal-cursor">▌</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
108
archon-ui-main/src/features/mcp/components/McpClientList.tsx
Normal file
108
archon-ui-main/src/features/mcp/components/McpClientList.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { cn, glassmorphism, compoundStyles } from '../../ui/primitives';
|
||||
import { Monitor, Clock, Activity } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { McpClient } from '../types';
|
||||
|
||||
interface McpClientListProps {
|
||||
clients: McpClient[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const clientIcons: Record<string, string> = {
|
||||
'Claude': '🤖',
|
||||
'Cursor': '💻',
|
||||
'Windsurf': '🏄',
|
||||
'Cline': '🔧',
|
||||
'KiRo': '🚀',
|
||||
'Augment': '⚡',
|
||||
'Gemini': '🌐',
|
||||
'Unknown': '❓'
|
||||
};
|
||||
|
||||
export const McpClientList: React.FC<McpClientListProps> = ({
|
||||
clients,
|
||||
className
|
||||
}) => {
|
||||
const formatDuration = (connectedAt: string): string => {
|
||||
const now = new Date();
|
||||
const connected = new Date(connectedAt);
|
||||
const seconds = Math.floor((now.getTime() - connected.getTime()) / 1000);
|
||||
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||
};
|
||||
|
||||
const formatLastActivity = (lastActivity: string): string => {
|
||||
const now = new Date();
|
||||
const activity = new Date(lastActivity);
|
||||
const seconds = Math.floor((now.getTime() - activity.getTime()) / 1000);
|
||||
|
||||
if (seconds < 5) return 'Active';
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
return 'Idle';
|
||||
};
|
||||
|
||||
if (clients.length === 0) {
|
||||
return (
|
||||
<div className={cn(compoundStyles.card, "p-6 text-center rounded-lg relative overflow-hidden", className)}>
|
||||
<div className="absolute top-3 right-3 px-2 py-1 bg-cyan-500/20 text-cyan-400 text-xs font-semibold rounded-full border border-cyan-500/30">
|
||||
Coming Soon
|
||||
</div>
|
||||
<Monitor className="w-12 h-12 mx-auto mb-3 text-zinc-500" />
|
||||
<p className="text-zinc-400">Client detection coming soon</p>
|
||||
<p className="text-sm text-zinc-500 mt-2">
|
||||
We'll automatically detect when AI assistants connect to the MCP server
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{clients.map((client, index) => (
|
||||
<motion.div
|
||||
key={client.session_id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-4 rounded-lg",
|
||||
glassmorphism.background.card,
|
||||
glassmorphism.border.default,
|
||||
client.status === 'active'
|
||||
? "border-green-500/50 shadow-[0_0_15px_rgba(34,197,94,0.2)]"
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{clientIcons[client.client_type] || '❓'}</span>
|
||||
<div>
|
||||
<p className="font-medium text-white">{client.client_type}</p>
|
||||
<p className="text-xs text-zinc-400">Session: {client.session_id.slice(0, 8)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-zinc-400">{formatDuration(client.connected_at)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Activity className="w-3 h-3 text-green-400" />
|
||||
<span className={cn(
|
||||
"text-zinc-400",
|
||||
client.status === 'active' && "text-green-400"
|
||||
)}>
|
||||
{formatLastActivity(client.last_activity)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
298
archon-ui-main/src/features/mcp/components/McpConfigSection.tsx
Normal file
298
archon-ui-main/src/features/mcp/components/McpConfigSection.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { Copy, ExternalLink } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { useToast } from "../../ui/hooks";
|
||||
import { Button, cn, glassmorphism, Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/primitives";
|
||||
import type { McpServerConfig, McpServerStatus, SupportedIDE } from "../types";
|
||||
|
||||
interface McpConfigSectionProps {
|
||||
config?: McpServerConfig;
|
||||
status: McpServerStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ideConfigurations: Record<
|
||||
SupportedIDE,
|
||||
{
|
||||
title: string;
|
||||
steps: string[];
|
||||
configGenerator: (config: McpServerConfig) => string;
|
||||
supportsOneClick?: boolean;
|
||||
}
|
||||
> = {
|
||||
claudecode: {
|
||||
title: "Claude Code Configuration",
|
||||
steps: ["Open a terminal and run the following command:", "The connection will be established automatically"],
|
||||
configGenerator: (config) =>
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "archon",
|
||||
transport: "http",
|
||||
url: `http://${config.host}:${config.port}/mcp`,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
gemini: {
|
||||
title: "Gemini CLI Configuration",
|
||||
steps: [
|
||||
"Locate or create the settings file at ~/.gemini/settings.json",
|
||||
"Add the configuration shown below to the file",
|
||||
"Launch Gemini CLI in your terminal",
|
||||
"Test the connection by typing /mcp to list available tools",
|
||||
],
|
||||
configGenerator: (config) =>
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
archon: {
|
||||
httpUrl: `http://${config.host}:${config.port}/mcp`,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
cursor: {
|
||||
title: "Cursor Configuration",
|
||||
steps: [
|
||||
"Option A: Use the one-click install button below (recommended)",
|
||||
"Option B: Manually edit ~/.cursor/mcp.json",
|
||||
"Add the configuration shown below",
|
||||
"Restart Cursor for changes to take effect",
|
||||
],
|
||||
configGenerator: (config) =>
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
archon: {
|
||||
url: `http://${config.host}:${config.port}/mcp`,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
supportsOneClick: true,
|
||||
},
|
||||
windsurf: {
|
||||
title: "Windsurf Configuration",
|
||||
steps: [
|
||||
'Open Windsurf and click the "MCP servers" button (hammer icon)',
|
||||
'Click "Configure" and then "View raw config"',
|
||||
"Add the configuration shown below to the mcpServers object",
|
||||
'Click "Refresh" to connect to the server',
|
||||
],
|
||||
configGenerator: (config) =>
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
archon: {
|
||||
serverUrl: `http://${config.host}:${config.port}/mcp`,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
cline: {
|
||||
title: "Cline Configuration",
|
||||
steps: [
|
||||
"Open VS Code settings (Cmd/Ctrl + ,)",
|
||||
'Search for "cline.mcpServers"',
|
||||
'Click "Edit in settings.json"',
|
||||
"Add the configuration shown below",
|
||||
"Restart VS Code for changes to take effect",
|
||||
],
|
||||
configGenerator: (config) =>
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
archon: {
|
||||
command: "npx",
|
||||
args: ["mcp-remote", `http://${config.host}:${config.port}/mcp`, "--allow-http"],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
kiro: {
|
||||
title: "Kiro Configuration",
|
||||
steps: [
|
||||
"Open Kiro settings",
|
||||
"Navigate to MCP Servers section",
|
||||
"Add the configuration shown below",
|
||||
"Save and restart Kiro",
|
||||
],
|
||||
configGenerator: (config) =>
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
archon: {
|
||||
command: "npx",
|
||||
args: ["mcp-remote", `http://${config.host}:${config.port}/mcp`, "--allow-http"],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
augment: {
|
||||
title: "Augment Configuration",
|
||||
steps: [
|
||||
"Open Augment settings",
|
||||
"Navigate to Extensions > MCP",
|
||||
"Add the configuration shown below",
|
||||
"Reload configuration",
|
||||
],
|
||||
configGenerator: (config) =>
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
archon: {
|
||||
url: `http://${config.host}:${config.port}/mcp`,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const McpConfigSection: React.FC<McpConfigSectionProps> = ({ config, status, className }) => {
|
||||
const [selectedIDE, setSelectedIDE] = useState<SupportedIDE>("claudecode");
|
||||
const { showToast } = useToast();
|
||||
|
||||
if (status.status !== "running" || !config) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"p-6 text-center rounded-lg",
|
||||
glassmorphism.background.subtle,
|
||||
glassmorphism.border.default,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className="text-zinc-400">Start the MCP server to see configuration options</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleCopyConfig = () => {
|
||||
const configText = ideConfigurations[selectedIDE].configGenerator(config);
|
||||
navigator.clipboard.writeText(configText);
|
||||
showToast("Configuration copied to clipboard", "success");
|
||||
};
|
||||
|
||||
const handleCursorOneClick = () => {
|
||||
const httpConfig = {
|
||||
url: `http://${config.host}:${config.port}/mcp`,
|
||||
};
|
||||
const configString = JSON.stringify(httpConfig);
|
||||
const base64Config = btoa(configString);
|
||||
const deeplink = `cursor://anysphere.cursor-deeplink/mcp/install?name=archon&config=${base64Config}`;
|
||||
window.location.href = deeplink;
|
||||
showToast("Opening Cursor with Archon MCP configuration...", "info");
|
||||
};
|
||||
|
||||
const handleClaudeCodeCommand = () => {
|
||||
const command = `claude mcp add --transport http archon http://${config.host}:${config.port}/mcp`;
|
||||
navigator.clipboard.writeText(command);
|
||||
showToast("Command copied to clipboard", "success");
|
||||
};
|
||||
|
||||
const selectedConfig = ideConfigurations[selectedIDE];
|
||||
const configText = selectedConfig.configGenerator(config);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-6", className)}>
|
||||
{/* Universal MCP Note */}
|
||||
<div className={cn("p-3 rounded-lg", glassmorphism.background.blue, glassmorphism.border.blue)}>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
<span className="font-semibold">Note:</span> Archon works with any application that supports MCP. Below are
|
||||
instructions for common tools, but these steps can be adapted for any MCP-compatible client.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* IDE Selection Tabs */}
|
||||
<Tabs
|
||||
defaultValue="claudecode"
|
||||
value={selectedIDE}
|
||||
onValueChange={(value) => setSelectedIDE(value as SupportedIDE)}
|
||||
>
|
||||
<TabsList className="grid grid-cols-4 lg:grid-cols-7 w-full">
|
||||
<TabsTrigger value="claudecode">Claude Code</TabsTrigger>
|
||||
<TabsTrigger value="gemini">Gemini</TabsTrigger>
|
||||
<TabsTrigger value="cursor">Cursor</TabsTrigger>
|
||||
<TabsTrigger value="windsurf">Windsurf</TabsTrigger>
|
||||
<TabsTrigger value="cline">Cline</TabsTrigger>
|
||||
<TabsTrigger value="kiro">Kiro</TabsTrigger>
|
||||
<TabsTrigger value="augment">Augment</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={selectedIDE} className="mt-6 space-y-4">
|
||||
{/* Configuration Title and Steps */}
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-800 dark:text-white mb-3">{selectedConfig.title}</h4>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-600 dark:text-zinc-400">
|
||||
{selectedConfig.steps.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Special Commands for Claude Code */}
|
||||
{selectedIDE === "claudecode" && (
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 rounded-lg flex items-center justify-between",
|
||||
glassmorphism.background.subtle,
|
||||
glassmorphism.border.default,
|
||||
)}
|
||||
>
|
||||
<code className="text-sm font-mono text-cyan-600 dark:text-cyan-400">
|
||||
claude mcp add --transport http archon http://{config.host}:{config.port}/mcp
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={handleClaudeCodeCommand}>
|
||||
<Copy className="w-3 h-3 mr-1" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configuration Display */}
|
||||
<div className={cn("relative rounded-lg p-4", glassmorphism.background.subtle, glassmorphism.border.default)}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-zinc-500 dark:text-zinc-400">Configuration</span>
|
||||
<Button variant="outline" size="sm" onClick={handleCopyConfig}>
|
||||
<Copy className="w-3 h-3 mr-1" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs font-mono text-gray-800 dark:text-zinc-200 overflow-x-auto">
|
||||
<code>{configText}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* One-Click Install for Cursor */}
|
||||
{selectedIDE === "cursor" && selectedConfig.supportsOneClick && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="cyan" onClick={handleCursorOneClick} className="shadow-lg">
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
One-Click Install for Cursor
|
||||
</Button>
|
||||
<span className="text-xs text-zinc-500">Opens Cursor with configuration</span>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
107
archon-ui-main/src/features/mcp/components/McpStatusBar.tsx
Normal file
107
archon-ui-main/src/features/mcp/components/McpStatusBar.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { cn, glassmorphism } from '../../ui/primitives';
|
||||
import { CheckCircle, AlertCircle, Clock, Server, Users } from 'lucide-react';
|
||||
import type { McpServerStatus, McpSessionInfo, McpServerConfig } from '../types';
|
||||
|
||||
interface McpStatusBarProps {
|
||||
status: McpServerStatus;
|
||||
sessionInfo?: McpSessionInfo;
|
||||
config?: McpServerConfig;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const McpStatusBar: React.FC<McpStatusBarProps> = ({
|
||||
status,
|
||||
sessionInfo,
|
||||
config,
|
||||
className
|
||||
}) => {
|
||||
const formatUptime = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 24) {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ${hours % 24}h ${minutes}m`;
|
||||
}
|
||||
return `${hours}h ${minutes}m ${secs}s`;
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (status.status === 'running') {
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
}
|
||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (status.status === 'running') {
|
||||
return 'text-green-500 shadow-[0_0_10px_rgba(34,197,94,0.5)]';
|
||||
}
|
||||
return 'text-red-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center gap-6 px-4 py-2 rounded-lg",
|
||||
glassmorphism.background.subtle,
|
||||
glassmorphism.border.default,
|
||||
"font-mono text-sm",
|
||||
className
|
||||
)}>
|
||||
{/* Status Indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon()}
|
||||
<span className={cn("font-semibold", getStatusColor())}>
|
||||
{status.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-4 bg-zinc-700" />
|
||||
|
||||
{/* Uptime */}
|
||||
{status.uptime !== null && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-zinc-400">UP</span>
|
||||
<span className="text-white">{formatUptime(status.uptime)}</span>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-zinc-700" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Server Info */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-cyan-500" />
|
||||
<span className="text-zinc-400">MCP</span>
|
||||
<span className="text-white">8051</span>
|
||||
</div>
|
||||
|
||||
{/* Active Sessions */}
|
||||
{sessionInfo && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-zinc-700" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-pink-500" />
|
||||
<span className="text-zinc-400">SESSIONS</span>
|
||||
<span className="text-cyan-400 text-sm">Coming Soon</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Transport Type */}
|
||||
<div className="w-px h-4 bg-zinc-700 ml-auto" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-400">TRANSPORT</span>
|
||||
<span className="text-cyan-400">
|
||||
{config?.transport === 'streamable-http' ? 'HTTP' :
|
||||
config?.transport === 'sse' ? 'SSE' :
|
||||
config?.transport || 'HTTP'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
3
archon-ui-main/src/features/mcp/components/index.ts
Normal file
3
archon-ui-main/src/features/mcp/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./McpStatusBar";
|
||||
export * from "./McpClientList";
|
||||
export * from "./McpConfigSection";
|
||||
1
archon-ui-main/src/features/mcp/hooks/index.ts
Normal file
1
archon-ui-main/src/features/mcp/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./useMcpQueries";
|
||||
60
archon-ui-main/src/features/mcp/hooks/useMcpQueries.ts
Normal file
60
archon-ui-main/src/features/mcp/hooks/useMcpQueries.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSmartPolling } from "../../ui/hooks";
|
||||
import { mcpApi } from "../services";
|
||||
|
||||
// Query keys factory
|
||||
export const mcpKeys = {
|
||||
all: ["mcp"] as const,
|
||||
status: () => [...mcpKeys.all, "status"] as const,
|
||||
config: () => [...mcpKeys.all, "config"] as const,
|
||||
sessions: () => [...mcpKeys.all, "sessions"] as const,
|
||||
clients: () => [...mcpKeys.all, "clients"] as const,
|
||||
};
|
||||
|
||||
export function useMcpStatus() {
|
||||
const { refetchInterval } = useSmartPolling(5000); // 5 second polling
|
||||
|
||||
return useQuery({
|
||||
queryKey: mcpKeys.status(),
|
||||
queryFn: () => mcpApi.getStatus(),
|
||||
refetchInterval,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 3000,
|
||||
throwOnError: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMcpConfig() {
|
||||
return useQuery({
|
||||
queryKey: mcpKeys.config(),
|
||||
queryFn: () => mcpApi.getConfig(),
|
||||
staleTime: Infinity, // Config rarely changes
|
||||
throwOnError: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMcpClients() {
|
||||
const { refetchInterval } = useSmartPolling(10000); // 10 second polling
|
||||
|
||||
return useQuery({
|
||||
queryKey: mcpKeys.clients(),
|
||||
queryFn: () => mcpApi.getClients(),
|
||||
refetchInterval,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 8000,
|
||||
throwOnError: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMcpSessionInfo() {
|
||||
const { refetchInterval } = useSmartPolling(10000);
|
||||
|
||||
return useQuery({
|
||||
queryKey: mcpKeys.sessions(),
|
||||
queryFn: () => mcpApi.getSessionInfo(),
|
||||
refetchInterval,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 8000,
|
||||
throwOnError: true,
|
||||
});
|
||||
}
|
||||
6
archon-ui-main/src/features/mcp/index.ts
Normal file
6
archon-ui-main/src/features/mcp/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./components";
|
||||
export * from "./hooks";
|
||||
export * from "./services";
|
||||
export * from "./types";
|
||||
export { McpView } from "./views/McpView";
|
||||
export { McpViewWithBoundary } from "./views/McpViewWithBoundary";
|
||||
1
archon-ui-main/src/features/mcp/services/index.ts
Normal file
1
archon-ui-main/src/features/mcp/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./mcpApi";
|
||||
54
archon-ui-main/src/features/mcp/services/mcpApi.ts
Normal file
54
archon-ui-main/src/features/mcp/services/mcpApi.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { callAPIWithETag } from "../../projects/shared/apiWithEtag";
|
||||
import type {
|
||||
McpServerStatus,
|
||||
McpServerConfig,
|
||||
McpSessionInfo,
|
||||
McpClient
|
||||
} from "../types";
|
||||
|
||||
export const mcpApi = {
|
||||
async getStatus(): Promise<McpServerStatus> {
|
||||
try {
|
||||
const response =
|
||||
await callAPIWithETag<McpServerStatus>("/api/mcp/status");
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to get MCP status:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getConfig(): Promise<McpServerConfig> {
|
||||
try {
|
||||
const response =
|
||||
await callAPIWithETag<McpServerConfig>("/api/mcp/config");
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to get MCP config:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getSessionInfo(): Promise<McpSessionInfo> {
|
||||
try {
|
||||
const response =
|
||||
await callAPIWithETag<McpSessionInfo>("/api/mcp/sessions");
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to get session info:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getClients(): Promise<McpClient[]> {
|
||||
try {
|
||||
const response = await callAPIWithETag<{ clients: McpClient[] }>(
|
||||
"/api/mcp/clients",
|
||||
);
|
||||
return response.clients || [];
|
||||
} catch (error) {
|
||||
console.error("Failed to get MCP clients:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
1
archon-ui-main/src/features/mcp/types/index.ts
Normal file
1
archon-ui-main/src/features/mcp/types/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./mcp";
|
||||
54
archon-ui-main/src/features/mcp/types/mcp.ts
Normal file
54
archon-ui-main/src/features/mcp/types/mcp.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// Core MCP interfaces matching backend schema
|
||||
export interface McpServerStatus {
|
||||
status: "running" | "starting" | "stopped" | "stopping";
|
||||
uptime: number | null;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
export interface McpServerConfig {
|
||||
transport: string;
|
||||
host: string;
|
||||
port: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface McpClient {
|
||||
session_id: string;
|
||||
client_type:
|
||||
| "Claude"
|
||||
| "Cursor"
|
||||
| "Windsurf"
|
||||
| "Cline"
|
||||
| "KiRo"
|
||||
| "Augment"
|
||||
| "Gemini"
|
||||
| "Unknown";
|
||||
connected_at: string;
|
||||
last_activity: string;
|
||||
status: "active" | "idle";
|
||||
}
|
||||
|
||||
export interface McpSessionInfo {
|
||||
active_sessions: number;
|
||||
session_timeout: number;
|
||||
server_uptime_seconds?: number;
|
||||
clients?: McpClient[];
|
||||
}
|
||||
|
||||
// we actually support all ides and mcp clients
|
||||
export type SupportedIDE =
|
||||
| "windsurf"
|
||||
| "cursor"
|
||||
| "claudecode"
|
||||
| "cline"
|
||||
| "kiro"
|
||||
| "augment"
|
||||
| "gemini";
|
||||
|
||||
export interface IdeConfiguration {
|
||||
ide: SupportedIDE;
|
||||
title: string;
|
||||
steps: string[];
|
||||
config: string;
|
||||
supportsOneClick?: boolean;
|
||||
}
|
||||
110
archon-ui-main/src/features/mcp/views/McpView.tsx
Normal file
110
archon-ui-main/src/features/mcp/views/McpView.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Loader, Server } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useStaggeredEntrance } from "../../../hooks/useStaggeredEntrance";
|
||||
import { McpClientList, McpConfigSection, McpStatusBar } from "../components";
|
||||
import { useMcpClients, useMcpConfig, useMcpSessionInfo, useMcpStatus } from "../hooks";
|
||||
|
||||
export const McpView: React.FC = () => {
|
||||
const { data: status, isLoading: statusLoading } = useMcpStatus();
|
||||
const { data: config } = useMcpConfig();
|
||||
const { data: clients = [] } = useMcpClients();
|
||||
const { data: sessionInfo } = useMcpSessionInfo();
|
||||
|
||||
// Staggered entrance animation
|
||||
const isVisible = useStaggeredEntrance([1, 2, 3, 4], 0.15);
|
||||
|
||||
// Animation variants
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.15,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: "easeOut",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const titleVariants = {
|
||||
hidden: { opacity: 0, x: -20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: "easeOut",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (statusLoading || !status) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader className="animate-spin text-gray-500" size={32} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate={isVisible ? "visible" : "hidden"}
|
||||
variants={containerVariants}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Title with MCP icon */}
|
||||
<motion.h1
|
||||
className="text-3xl font-bold text-gray-800 dark:text-white mb-8 flex items-center gap-3"
|
||||
variants={titleVariants}
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
height="28"
|
||||
width="28"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-pink-500 filter drop-shadow-[0_0_8px_rgba(236,72,153,0.8)]"
|
||||
aria-label="MCP icon"
|
||||
>
|
||||
<title>MCP icon</title>
|
||||
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
|
||||
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
|
||||
</svg>
|
||||
MCP Status Dashboard
|
||||
</motion.h1>
|
||||
|
||||
{/* Status Bar */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<McpStatusBar status={status} sessionInfo={sessionInfo} config={config} />
|
||||
</motion.div>
|
||||
|
||||
{/* Connected Clients */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-800 dark:text-white flex items-center gap-2">
|
||||
<Server className="w-5 h-5 text-cyan-500" />
|
||||
Connected Clients
|
||||
</h2>
|
||||
<McpClientList clients={clients} />
|
||||
</motion.div>
|
||||
|
||||
{/* IDE Configuration */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-800 dark:text-white">IDE Configuration</h2>
|
||||
<McpConfigSection config={config} status={status} />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
import { FeatureErrorBoundary } from "../../ui/components";
|
||||
import { McpView } from "./McpView";
|
||||
|
||||
export const McpViewWithBoundary = () => {
|
||||
return (
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<FeatureErrorBoundary featureName="MCP Dashboard" onReset={reset}>
|
||||
<McpView />
|
||||
</FeatureErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '../../../testing/test-utils';
|
||||
import { ProjectCard } from '../ProjectCard';
|
||||
import type { Project } from '../../types';
|
||||
|
||||
describe('ProjectCard', () => {
|
||||
const mockProject: Project = {
|
||||
id: 'project-1',
|
||||
title: 'Test Project',
|
||||
description: 'Test Description',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
pinned: false,
|
||||
features: [],
|
||||
docs: [],
|
||||
};
|
||||
|
||||
const mockTaskCounts = {
|
||||
todo: 5,
|
||||
doing: 3,
|
||||
review: 2,
|
||||
done: 10,
|
||||
};
|
||||
|
||||
const mockHandlers = {
|
||||
onSelect: vi.fn(),
|
||||
onPin: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render project title', () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={mockProject}
|
||||
isSelected={false}
|
||||
taskCounts={mockTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Project')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display task counts', () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={mockProject}
|
||||
isSelected={false}
|
||||
taskCounts={mockTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
// Task count badges should be visible
|
||||
// Note: Component only shows todo, doing, and done (not review)
|
||||
const fives = screen.getAllByText('5');
|
||||
expect(fives.length).toBeGreaterThan(0); // todo count
|
||||
expect(screen.getByText('10')).toBeInTheDocument(); // done
|
||||
// Doing count might be displayed as 3 or duplicated - implementation detail
|
||||
});
|
||||
|
||||
it('should call onSelect when clicked', () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={mockProject}
|
||||
isSelected={false}
|
||||
taskCounts={mockTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = screen.getByRole('listitem');
|
||||
fireEvent.click(card);
|
||||
|
||||
expect(mockHandlers.onSelect).toHaveBeenCalledWith(mockProject);
|
||||
expect(mockHandlers.onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should apply selected styles when isSelected is true', () => {
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={mockProject}
|
||||
isSelected={true}
|
||||
taskCounts={mockTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = container.querySelector('[role="listitem"]');
|
||||
// Check for selected-specific classes
|
||||
expect(card?.className).toContain('scale-[1.02]');
|
||||
expect(card?.className).toContain('border-purple');
|
||||
});
|
||||
|
||||
it('should apply pinned styles when project is pinned', () => {
|
||||
const pinnedProject = { ...mockProject, pinned: true };
|
||||
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={pinnedProject}
|
||||
isSelected={false}
|
||||
taskCounts={mockTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = container.querySelector('[role="listitem"]');
|
||||
// Check for pinned-specific classes
|
||||
expect(card?.className).toContain('from-purple');
|
||||
expect(card?.className).toContain('border-purple-500');
|
||||
});
|
||||
|
||||
it('should render aurora glow effect when selected', () => {
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={mockProject}
|
||||
isSelected={true}
|
||||
taskCounts={mockTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
// Aurora glow div should exist when selected
|
||||
const glowEffect = container.querySelector('.animate-\\[pulse_8s_ease-in-out_infinite\\]');
|
||||
expect(glowEffect).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render aurora glow effect when not selected', () => {
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={mockProject}
|
||||
isSelected={false}
|
||||
taskCounts={mockTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
// Aurora glow div should not exist when not selected
|
||||
const glowEffect = container.querySelector('.animate-\\[pulse_8s_ease-in-out_infinite\\]');
|
||||
expect(glowEffect).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show zero task counts correctly', () => {
|
||||
const zeroTaskCounts = {
|
||||
todo: 0,
|
||||
doing: 0,
|
||||
review: 0,
|
||||
done: 0,
|
||||
};
|
||||
|
||||
render(
|
||||
<ProjectCard
|
||||
project={mockProject}
|
||||
isSelected={false}
|
||||
taskCounts={zeroTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
// All counts should show 0 (ProjectCard may not show review count)
|
||||
const zeros = screen.getAllByText('0');
|
||||
expect(zeros.length).toBeGreaterThanOrEqual(3); // At least todo, doing, done
|
||||
});
|
||||
|
||||
it('should handle very long project titles', () => {
|
||||
const longTitleProject = {
|
||||
...mockProject,
|
||||
title: 'This is an extremely long project title that should be truncated properly to avoid breaking the layout of the card component',
|
||||
};
|
||||
|
||||
render(
|
||||
<ProjectCard
|
||||
project={longTitleProject}
|
||||
isSelected={false}
|
||||
taskCounts={mockTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
const title = screen.getByText(/This is an extremely long project title/);
|
||||
expect(title).toBeInTheDocument();
|
||||
// Title should have line-clamp-2 class
|
||||
expect(title.className).toContain('line-clamp-2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { projectKeys, useProjects, useCreateProject, useUpdateProject, useDeleteProject } from '../useProjectQueries';
|
||||
import type { Project } from '../../types';
|
||||
import React from 'react';
|
||||
|
||||
// Mock the services
|
||||
vi.mock('../../services', () => ({
|
||||
projectService: {
|
||||
listProjects: vi.fn(),
|
||||
createProject: vi.fn(),
|
||||
updateProject: vi.fn(),
|
||||
deleteProject: vi.fn(),
|
||||
getProjectFeatures: vi.fn(),
|
||||
},
|
||||
taskService: {
|
||||
getTaskCountsForAllProjects: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the toast hook
|
||||
vi.mock('../../../ui/hooks/useToast', () => ({
|
||||
useToast: () => ({
|
||||
showToast: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock smart polling
|
||||
vi.mock('../../../ui/hooks', () => ({
|
||||
useSmartPolling: () => ({
|
||||
refetchInterval: 5000,
|
||||
isPaused: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test wrapper with QueryClient
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
};
|
||||
|
||||
describe('useProjectQueries', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('projectKeys', () => {
|
||||
it('should generate correct query keys', () => {
|
||||
expect(projectKeys.all).toEqual(['projects']);
|
||||
expect(projectKeys.lists()).toEqual(['projects', 'list']);
|
||||
expect(projectKeys.detail('123')).toEqual(['projects', 'detail', '123']);
|
||||
expect(projectKeys.tasks('123')).toEqual(['projects', 'detail', '123', 'tasks']);
|
||||
expect(projectKeys.features('123')).toEqual(['projects', 'detail', '123', 'features']);
|
||||
expect(projectKeys.documents('123')).toEqual(['projects', 'detail', '123', 'documents']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useProjects', () => {
|
||||
it('should fetch projects list', async () => {
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Test Project',
|
||||
description: 'Test Description',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
pinned: false,
|
||||
features: [],
|
||||
docs: [],
|
||||
},
|
||||
];
|
||||
|
||||
const { projectService } = await import('../../services');
|
||||
vi.mocked(projectService.listProjects).mockResolvedValue(mockProjects);
|
||||
|
||||
const { result } = renderHook(() => useProjects(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(result.current.data).toEqual(mockProjects);
|
||||
});
|
||||
|
||||
expect(projectService.listProjects).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateProject', () => {
|
||||
it('should optimistically add project and replace with server response', async () => {
|
||||
const newProject: Project = {
|
||||
id: 'real-id',
|
||||
title: 'New Project',
|
||||
description: 'New Description',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
pinned: false,
|
||||
features: [],
|
||||
docs: [],
|
||||
};
|
||||
|
||||
const { projectService } = await import('../../services');
|
||||
vi.mocked(projectService.createProject).mockResolvedValue({
|
||||
project: newProject,
|
||||
message: 'Created',
|
||||
});
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useCreateProject(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync({
|
||||
title: 'New Project',
|
||||
description: 'New Description',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(projectService.createProject).toHaveBeenCalledWith({
|
||||
title: 'New Project',
|
||||
description: 'New Description',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should rollback on error', async () => {
|
||||
const { projectService } = await import('../../services');
|
||||
vi.mocked(projectService.createProject).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useCreateProject(), { wrapper });
|
||||
|
||||
await expect(
|
||||
result.current.mutateAsync({
|
||||
title: 'New Project',
|
||||
description: 'New Description',
|
||||
})
|
||||
).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateProject', () => {
|
||||
it('should handle pinning a project', async () => {
|
||||
const updatedProject: Project = {
|
||||
id: '1',
|
||||
title: 'Test Project',
|
||||
description: 'Test Description',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
pinned: true,
|
||||
features: [],
|
||||
docs: [],
|
||||
};
|
||||
|
||||
const { projectService } = await import('../../services');
|
||||
vi.mocked(projectService.updateProject).mockResolvedValue(updatedProject);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useUpdateProject(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync({
|
||||
projectId: '1',
|
||||
updates: { pinned: true },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(projectService.updateProject).toHaveBeenCalledWith('1', { pinned: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteProject', () => {
|
||||
it('should optimistically remove project', async () => {
|
||||
const { projectService } = await import('../../services');
|
||||
vi.mocked(projectService.deleteProject).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useDeleteProject(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync('project-to-delete');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(projectService.deleteProject).toHaveBeenCalledWith('project-to-delete');
|
||||
});
|
||||
});
|
||||
|
||||
it('should rollback on delete error', async () => {
|
||||
const { projectService } = await import('../../services');
|
||||
vi.mocked(projectService.deleteProject).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useDeleteProject(), { wrapper });
|
||||
|
||||
await expect(
|
||||
result.current.mutateAsync('project-to-delete')
|
||||
).rejects.toThrow('Permission denied');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,3 +19,4 @@ export * from "./hooks";
|
||||
export * from "./tasks";
|
||||
// Views
|
||||
export { ProjectsView } from "./views/ProjectsView";
|
||||
export { ProjectsViewWithBoundary } from "./views/ProjectsViewWithBoundary";
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { taskKeys, useProjectTasks, useCreateTask } from '../useTaskQueries';
|
||||
import type { Task } from '../../types';
|
||||
import React from 'react';
|
||||
|
||||
// Mock the services
|
||||
vi.mock('../../services', () => ({
|
||||
taskService: {
|
||||
getTasksByProject: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the toast hook
|
||||
vi.mock('../../../../ui/hooks/useToast', () => ({
|
||||
useToast: () => ({
|
||||
showToast: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock smart polling
|
||||
vi.mock('../../../../ui/hooks', () => ({
|
||||
useSmartPolling: () => ({
|
||||
refetchInterval: 5000,
|
||||
isPaused: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test wrapper with QueryClient
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
};
|
||||
|
||||
describe('useTaskQueries', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('taskKeys', () => {
|
||||
it('should generate correct query keys', () => {
|
||||
expect(taskKeys.all('project-123')).toEqual(['projects', 'project-123', 'tasks']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useProjectTasks', () => {
|
||||
it('should fetch tasks for a project', async () => {
|
||||
const mockTasks: Task[] = [
|
||||
{
|
||||
id: 'task-1',
|
||||
project_id: 'project-123',
|
||||
title: 'Test Task',
|
||||
description: 'Test Description',
|
||||
status: 'todo',
|
||||
assignee: 'User',
|
||||
task_order: 100,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const { taskService } = await import('../../services');
|
||||
vi.mocked(taskService.getTasksByProject).mockResolvedValue(mockTasks);
|
||||
|
||||
const { result } = renderHook(() => useProjectTasks('project-123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(result.current.data).toEqual(mockTasks);
|
||||
});
|
||||
|
||||
expect(taskService.getTasksByProject).toHaveBeenCalledWith('project-123');
|
||||
});
|
||||
|
||||
it('should not fetch tasks when projectId is undefined', () => {
|
||||
const { result } = renderHook(() => useProjectTasks(undefined), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should respect enabled flag', () => {
|
||||
const { result } = renderHook(() => useProjectTasks('project-123', false), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateTask', () => {
|
||||
it('should optimistically add task and replace with server response', async () => {
|
||||
const newTask: Task = {
|
||||
id: 'real-task-id',
|
||||
project_id: 'project-123',
|
||||
title: 'New Task',
|
||||
description: 'New Description',
|
||||
status: 'todo',
|
||||
assignee: 'User',
|
||||
task_order: 100,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const { taskService } = await import('../../services');
|
||||
vi.mocked(taskService.createTask).mockResolvedValue(newTask);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useCreateTask(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync({
|
||||
project_id: 'project-123',
|
||||
title: 'New Task',
|
||||
description: 'New Description',
|
||||
status: 'todo',
|
||||
assignee: 'User',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(taskService.createTask).toHaveBeenCalledWith({
|
||||
project_id: 'project-123',
|
||||
title: 'New Task',
|
||||
description: 'New Description',
|
||||
status: 'todo',
|
||||
assignee: 'User',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should provide default values for optional fields', async () => {
|
||||
const newTask: Task = {
|
||||
id: 'real-task-id',
|
||||
project_id: 'project-123',
|
||||
title: 'Minimal Task',
|
||||
description: '',
|
||||
status: 'todo',
|
||||
assignee: 'User',
|
||||
task_order: 100,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const { taskService } = await import('../../services');
|
||||
vi.mocked(taskService.createTask).mockResolvedValue(newTask);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useCreateTask(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync({
|
||||
project_id: 'project-123',
|
||||
title: 'Minimal Task',
|
||||
description: '',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should rollback on error', async () => {
|
||||
const { taskService } = await import('../../services');
|
||||
vi.mocked(taskService.createTask).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useCreateTask(), { wrapper });
|
||||
|
||||
await expect(
|
||||
result.current.mutateAsync({
|
||||
project_id: 'project-123',
|
||||
title: 'Failed Task',
|
||||
description: 'This will fail',
|
||||
})
|
||||
).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
});
|
||||
42
archon-ui-main/src/features/testing/test-utils.tsx
Normal file
42
archon-ui-main/src/features/testing/test-utils.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { render as rtlRender } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ToastProvider } from '../ui/components/ToastProvider';
|
||||
import { TooltipProvider } from '../ui/primitives/tooltip';
|
||||
|
||||
/**
|
||||
* Custom render function that wraps components with all necessary providers
|
||||
* This follows the best practice of having a centralized test render utility
|
||||
*/
|
||||
export function renderWithProviders(
|
||||
ui: React.ReactElement,
|
||||
{
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
}),
|
||||
...renderOptions
|
||||
} = {}
|
||||
) {
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<ToastProvider>
|
||||
{children}
|
||||
</ToastProvider>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
|
||||
}
|
||||
|
||||
// Re-export everything from React Testing Library
|
||||
export * from '@testing-library/react';
|
||||
|
||||
// Override the default render with our custom one
|
||||
export { renderWithProviders as render };
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./useSmartPolling";
|
||||
export * from "./useThemeAware";
|
||||
export * from "./useToast";
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useSmartPolling } from '../useSmartPolling';
|
||||
|
||||
describe('useSmartPolling', () => {
|
||||
beforeEach(() => {
|
||||
// Reset document visibility state
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'visible',
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
value: false,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
// Mock document.hasFocus
|
||||
document.hasFocus = vi.fn(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return the base interval when document is visible and focused', () => {
|
||||
const { result } = renderHook(() => useSmartPolling(5000));
|
||||
|
||||
expect(result.current.refetchInterval).toBe(5000);
|
||||
expect(result.current.isActive).toBe(true);
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
expect(result.current.hasFocus).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable polling when document is hidden', () => {
|
||||
const { result } = renderHook(() => useSmartPolling(5000));
|
||||
|
||||
// Initially should be active
|
||||
expect(result.current.isActive).toBe(true);
|
||||
expect(result.current.refetchInterval).toBe(5000);
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
act(() => {
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
value: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
// Should be disabled (returns false)
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
expect(result.current.isActive).toBe(false);
|
||||
expect(result.current.refetchInterval).toBe(false);
|
||||
});
|
||||
|
||||
it('should resume polling when document becomes visible again', () => {
|
||||
const { result } = renderHook(() => useSmartPolling(5000));
|
||||
|
||||
// Make hidden
|
||||
act(() => {
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
value: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
expect(result.current.refetchInterval).toBe(false);
|
||||
|
||||
// Make visible again
|
||||
act(() => {
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
value: false,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
expect(result.current.isActive).toBe(true);
|
||||
expect(result.current.refetchInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should slow down to 60 seconds when window loses focus', () => {
|
||||
const { result } = renderHook(() => useSmartPolling(5000));
|
||||
|
||||
// Initially focused
|
||||
expect(result.current.refetchInterval).toBe(5000);
|
||||
expect(result.current.hasFocus).toBe(true);
|
||||
|
||||
// Simulate window blur
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
|
||||
// Should be slowed down to 60 seconds
|
||||
expect(result.current.hasFocus).toBe(false);
|
||||
expect(result.current.isActive).toBe(false);
|
||||
expect(result.current.refetchInterval).toBe(60000);
|
||||
});
|
||||
|
||||
it('should resume normal speed when window regains focus', () => {
|
||||
const { result } = renderHook(() => useSmartPolling(5000));
|
||||
|
||||
// Blur window
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
|
||||
expect(result.current.refetchInterval).toBe(60000);
|
||||
|
||||
// Focus window again
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
});
|
||||
|
||||
expect(result.current.hasFocus).toBe(true);
|
||||
expect(result.current.isActive).toBe(true);
|
||||
expect(result.current.refetchInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should handle different base intervals', () => {
|
||||
const { result: result1 } = renderHook(() => useSmartPolling(1000));
|
||||
const { result: result2 } = renderHook(() => useSmartPolling(10000));
|
||||
|
||||
expect(result1.current.refetchInterval).toBe(1000);
|
||||
expect(result2.current.refetchInterval).toBe(10000);
|
||||
|
||||
// When blurred, both should be 60 seconds
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
|
||||
expect(result1.current.refetchInterval).toBe(60000);
|
||||
expect(result2.current.refetchInterval).toBe(60000);
|
||||
});
|
||||
|
||||
it('should use default interval of 10000ms when not specified', () => {
|
||||
const { result } = renderHook(() => useSmartPolling());
|
||||
|
||||
expect(result.current.refetchInterval).toBe(10000);
|
||||
});
|
||||
|
||||
it('should cleanup event listeners on unmount', () => {
|
||||
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
|
||||
const windowRemoveEventListenerSpy = vi.spyOn(window, 'removeEventListener');
|
||||
|
||||
const { unmount } = renderHook(() => useSmartPolling(5000));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
|
||||
expect(windowRemoveEventListenerSpy).toHaveBeenCalledWith('focus', expect.any(Function));
|
||||
expect(windowRemoveEventListenerSpy).toHaveBeenCalledWith('blur', expect.any(Function));
|
||||
|
||||
removeEventListenerSpy.mockRestore();
|
||||
windowRemoveEventListenerSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should correctly report isActive state', () => {
|
||||
const { result } = renderHook(() => useSmartPolling(5000));
|
||||
|
||||
// Active when both visible and focused
|
||||
expect(result.current.isActive).toBe(true);
|
||||
|
||||
// Not active when not focused
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
expect(result.current.isActive).toBe(false);
|
||||
|
||||
// Not active when hidden
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('focus')); // Focus first
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
value: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
expect(result.current.isActive).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { mcpServerService } from '../services/mcpServerService';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
// Query keys
|
||||
export const mcpKeys = {
|
||||
all: ['mcp'] as const,
|
||||
status: () => [...mcpKeys.all, 'status'] as const,
|
||||
config: () => [...mcpKeys.all, 'config'] as const,
|
||||
tools: () => [...mcpKeys.all, 'tools'] as const,
|
||||
};
|
||||
|
||||
// Fetch MCP server status
|
||||
export function useMCPStatus() {
|
||||
return useQuery({
|
||||
queryKey: mcpKeys.status(),
|
||||
queryFn: () => mcpServerService.getStatus(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - status rarely changes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch MCP server config
|
||||
export function useMCPConfig(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: mcpKeys.config(),
|
||||
queryFn: () => mcpServerService.getConfiguration(),
|
||||
enabled,
|
||||
staleTime: Infinity, // Config never changes unless server restarts
|
||||
});
|
||||
}
|
||||
|
||||
// Start server mutation
|
||||
export function useStartMCPServer() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => mcpServerService.startServer(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.status() });
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.config() });
|
||||
showToast('MCP server started successfully', 'success');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
showToast(error.message || 'Failed to start server', 'error');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Stop server mutation
|
||||
export function useStopMCPServer() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => mcpServerService.stopServer(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.status() });
|
||||
queryClient.removeQueries({ queryKey: mcpKeys.config() });
|
||||
showToast('MCP server stopped', 'info');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
showToast(error.message || 'Failed to stop server', 'error');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// List MCP tools
|
||||
export function useMCPTools(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: mcpKeys.tools(),
|
||||
queryFn: () => mcpServerService.listTools(),
|
||||
enabled,
|
||||
staleTime: Infinity, // Tools don't change during runtime
|
||||
});
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
--blue-accent: 217 91% 60%;
|
||||
}
|
||||
.dark {
|
||||
/* Dark mode variables - keep exactly as they were */
|
||||
/* Dark mode variables */
|
||||
--background: 0 0% 0%;
|
||||
--foreground: 0 0% 100%;
|
||||
--muted: 240 4% 16%;
|
||||
@@ -67,119 +67,22 @@
|
||||
}
|
||||
}
|
||||
@layer components {
|
||||
/* Grid pattern for background (actually used in MainLayout) */
|
||||
.neon-grid {
|
||||
@apply bg-[linear-gradient(to_right,#a855f720_1px,transparent_1px),linear-gradient(to_bottom,#a855f720_1px,transparent_1px)] bg-[size:40px_40px];
|
||||
@apply dark:bg-[linear-gradient(to_right,#a855f730_1px,transparent_1px),linear-gradient(to_bottom,#a855f730_1px,transparent_1px)];
|
||||
background-image:
|
||||
linear-gradient(to right, #a855f720 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #a855f720 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
.neon-divider-h {
|
||||
@apply h-[1px] w-full;
|
||||
}
|
||||
.neon-divider-h.purple {
|
||||
@apply bg-purple-500;
|
||||
}
|
||||
.neon-divider-h.green {
|
||||
@apply bg-emerald-500;
|
||||
}
|
||||
.neon-divider-h.pink {
|
||||
@apply bg-pink-500;
|
||||
}
|
||||
.neon-divider-h.blue {
|
||||
@apply bg-blue-500;
|
||||
}
|
||||
.neon-divider-v {
|
||||
@apply w-[1px] h-full;
|
||||
}
|
||||
.neon-divider-v.purple {
|
||||
@apply bg-purple-500;
|
||||
}
|
||||
.neon-divider-v.green {
|
||||
@apply bg-emerald-500;
|
||||
}
|
||||
.neon-divider-v.pink {
|
||||
@apply bg-pink-500;
|
||||
}
|
||||
.neon-divider-v.blue {
|
||||
@apply bg-blue-500;
|
||||
}
|
||||
.knowledge-item-card {
|
||||
@apply relative backdrop-blur-md bg-gradient-to-b from-white/10 to-black/30 border border-purple-500/30 rounded-md p-4 transition-all duration-300;
|
||||
@apply before:content-[""] before:absolute before:top-0 before:left-0 before:w-full before:h-[2px] before:bg-purple-500 before:shadow-[0_0_20px_5px_rgba(168,85,247,0.7)];
|
||||
@apply after:content-[""] after:absolute after:top-0 after:left-0 after:right-0 after:h-16 after:bg-gradient-to-b after:from-purple-500/20 after:to-purple-500/5 after:rounded-t-md after:pointer-events-none;
|
||||
@apply shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)];
|
||||
}
|
||||
.knowledge-item-card:hover {
|
||||
@apply border-purple-500/70 shadow-[0_15px_40px_-15px_rgba(0,0,0,0.9)] before:shadow-[0_0_25px_8px_rgba(168,85,247,0.8)];
|
||||
@apply translate-y-[-2px];
|
||||
}
|
||||
/* Glassmorphism utility classes */
|
||||
.glass {
|
||||
/* Light mode (base) styles */
|
||||
@apply backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 border border-gray-200 shadow-[0_10px_30px_-15px_rgba(0,0,0,0.1)];
|
||||
/* Dark mode overrides */
|
||||
@apply dark:bg-gradient-to-b dark:from-white/10 dark:to-black/30 dark:border-zinc-800/50 dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)];
|
||||
}
|
||||
.glass-purple {
|
||||
/* Light mode (base) styles */
|
||||
@apply backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 border border-purple-300 shadow-[0_10px_30px_-15px_rgba(168,85,247,0.15)];
|
||||
@apply before:content-[""] before:absolute before:top-0 before:left-0 before:w-full before:h-[2px] before:bg-purple-500 before:shadow-[0_0_10px_2px_rgba(168,85,247,0.4)];
|
||||
@apply after:content-[""] after:absolute after:top-0 after:left-0 after:right-0 after:h-16 after:bg-gradient-to-b after:from-purple-100 after:to-white after:rounded-t-md after:pointer-events-none;
|
||||
/* Dark mode overrides */
|
||||
@apply dark:from-white/10 dark:to-black/30 dark:border-purple-500/30 dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)];
|
||||
@apply dark:before:shadow-[0_0_20px_5px_rgba(168,85,247,0.7)];
|
||||
@apply dark:after:from-purple-500/20 dark:after:to-purple-500/5;
|
||||
}
|
||||
.glass-green {
|
||||
/* Light mode (base) styles */
|
||||
@apply backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 border border-emerald-300 shadow-[0_10px_30px_-15px_rgba(16,185,129,0.15)];
|
||||
@apply before:content-[""] before:absolute before:top-0 before:left-0 before:w-full before:h-[2px] before:bg-emerald-500 before:shadow-[0_0_10px_2px_rgba(16,185,129,0.4)];
|
||||
@apply after:content-[""] after:absolute after:top-0 after:left-0 after:right-0 after:h-16 after:bg-gradient-to-b after:from-emerald-100 after:to-white after:rounded-t-md after:pointer-events-none;
|
||||
/* Dark mode overrides */
|
||||
@apply dark:from-white/10 dark:to-black/30 dark:border-emerald-500/30 dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)];
|
||||
@apply dark:before:shadow-[0_0_20px_5px_rgba(16,185,129,0.7)];
|
||||
@apply dark:after:from-emerald-500/20 dark:after:to-emerald-500/5;
|
||||
}
|
||||
.glass-pink {
|
||||
/* Light mode (base) styles */
|
||||
@apply backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 border border-pink-300 shadow-[0_10px_30px_-15px_rgba(236,72,153,0.15)];
|
||||
@apply before:content-[""] before:absolute before:top-0 before:left-0 before:w-full before:h-[2px] before:bg-pink-500 before:shadow-[0_0_10px_2px_rgba(236,72,153,0.4)];
|
||||
@apply after:content-[""] after:absolute after:top-0 after:left-0 after:right-0 after:h-16 after:bg-gradient-to-b after:from-pink-100 after:to-white after:rounded-t-md after:pointer-events-none;
|
||||
/* Dark mode overrides */
|
||||
@apply dark:from-white/10 dark:to-black/30 dark:border-pink-500/30 dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)];
|
||||
@apply dark:before:shadow-[0_0_20px_5px_rgba(236,72,153,0.7)];
|
||||
@apply dark:after:from-pink-500/20 dark:after:to-pink-500/5;
|
||||
}
|
||||
.glass-blue {
|
||||
/* Light mode (base) styles */
|
||||
@apply backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 border border-blue-300 shadow-[0_10px_30px_-15px_rgba(59,130,246,0.15)];
|
||||
@apply before:content-[""] before:absolute before:top-0 before:left-0 before:w-full before:h-[2px] before:bg-blue-500 before:shadow-[0_0_10px_2px_rgba(59,130,246,0.4)];
|
||||
@apply after:content-[""] after:absolute after:top-0 after:left-0 after:right-0 after:h-16 after:bg-gradient-to-b after:from-blue-100 after:to-white after:rounded-t-md after:pointer-events-none;
|
||||
/* Dark mode overrides */
|
||||
@apply dark:from-white/10 dark:to-black/30 dark:border-blue-500/30 dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)];
|
||||
@apply dark:before:shadow-[0_0_20px_5px_rgba(59,130,246,0.7)];
|
||||
@apply dark:after:from-blue-500/20 dark:after:to-blue-500/5;
|
||||
}
|
||||
/* Hide scrollbar but allow scrolling */
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
}
|
||||
/* Card flip animations */
|
||||
.flip-card .backface-hidden {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
.rotate-y-180 {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
.transform-style-preserve-3d {
|
||||
transform-style: preserve-3d;
|
||||
-webkit-transform-style: preserve-3d;
|
||||
|
||||
.dark .neon-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, #a855f730 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #a855f730 1px, transparent 1px);
|
||||
}
|
||||
}
|
||||
/* Animation delays */
|
||||
|
||||
/* Animation delays (checked for usage) */
|
||||
.animation-delay-150 {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
@@ -187,105 +90,17 @@
|
||||
animation-delay: 300ms;
|
||||
}
|
||||
|
||||
/* Card expansion animation */
|
||||
.card-collapsed {
|
||||
height: 140px;
|
||||
transition: height 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.card-expanded {
|
||||
height: 280px;
|
||||
transition: height 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
/* Ensure scrollable content in expanded cards */
|
||||
.card-expanded .flex-1.overflow-hidden > .absolute {
|
||||
/* Removed max-height to allow full scrolling */
|
||||
}
|
||||
|
||||
/* Screensaver Animations */
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0) translateX(0);
|
||||
}
|
||||
33% {
|
||||
transform: translateY(-30px) translateX(10px);
|
||||
}
|
||||
66% {
|
||||
transform: translateY(30px) translateX(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hologram {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: rotateY(0deg) scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: rotateY(10deg) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes etherealFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) scale(1.05);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulse glow animation (used in GlassCrawlDepthSelector) */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px 10px rgba(59, 130, 246, 0.5),
|
||||
0 0 40px 20px rgba(59, 130, 246, 0.3);
|
||||
box-shadow:
|
||||
0 0 20px 10px hsl(var(--blue-accent) / 0.50),
|
||||
0 0 40px 20px hsl(var(--blue-accent) / 0.30);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 30px 15px rgba(59, 130, 246, 0.7),
|
||||
0 0 60px 30px rgba(59, 130, 246, 0.4);
|
||||
box-shadow:
|
||||
0 0 30px 15px hsl(var(--blue-accent) / 0.70),
|
||||
0 0 60px 30px hsl(var(--blue-accent) / 0.40);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,10 +108,16 @@
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-pulse-glow {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles (used in multiple components) */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(59, 130, 246, 0.3) transparent;
|
||||
scrollbar-color: hsl(var(--blue-accent) / 0.30) transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
@@ -308,27 +129,31 @@
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(59, 130, 246, 0.3);
|
||||
background-color: hsl(var(--blue-accent) / 0.30);
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(59, 130, 246, 0.5);
|
||||
background-color: hsl(var(--blue-accent) / 0.50);
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar {
|
||||
scrollbar-color: hsl(var(--blue-accent) / 0.45) transparent;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(59, 130, 246, 0.4);
|
||||
background-color: hsl(var(--blue-accent) / 0.40);
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(59, 130, 246, 0.6);
|
||||
background-color: hsl(var(--blue-accent) / 0.60);
|
||||
}
|
||||
|
||||
/* Thin scrollbar styles */
|
||||
/* Thin scrollbar styles (used in KanbanColumn and other components) - Tron-themed */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||
scrollbar-color: hsl(var(--blue-accent) / 0.40) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
@@ -341,18 +166,47 @@
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
hsl(var(--blue-accent) / 0.60),
|
||||
hsl(var(--blue-accent) / 0.30)
|
||||
);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 3px hsl(var(--blue-accent) / 0.40);
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(156, 163, 175, 0.7);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
hsl(var(--blue-accent) / 0.80),
|
||||
hsl(var(--blue-accent) / 0.50)
|
||||
);
|
||||
box-shadow:
|
||||
0 0 6px hsl(var(--blue-accent) / 0.60),
|
||||
inset 0 0 3px hsl(var(--blue-accent) / 0.30);
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin {
|
||||
scrollbar-color: hsl(var(--blue-accent) / 0.50) transparent;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(75, 85, 99, 0.5);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
hsl(var(--blue-accent) / 0.50),
|
||||
hsl(var(--blue-accent) / 0.20)
|
||||
);
|
||||
box-shadow: 0 0 4px hsl(var(--blue-accent) / 0.50);
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(75, 85, 99, 0.7);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
hsl(var(--blue-accent) / 0.70),
|
||||
hsl(var(--blue-accent) / 0.40)
|
||||
);
|
||||
box-shadow:
|
||||
0 0 8px hsl(var(--blue-accent) / 0.70),
|
||||
inset 0 0 3px hsl(var(--blue-accent) / 0.40);
|
||||
}
|
||||
@@ -1,676 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Play, Square, Copy, Server, AlertCircle, CheckCircle, Loader } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { useStaggeredEntrance } from '../hooks/useStaggeredEntrance';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { mcpServerService, ServerStatus, ServerConfig } from '../services/mcpServerService';
|
||||
import { IDEGlobalRules } from '../components/settings/IDEGlobalRules';
|
||||
// import { MCPClients } from '../components/mcp/MCPClients'; // Commented out - feature not implemented
|
||||
import { McpViewWithBoundary } from '../features/mcp';
|
||||
|
||||
// Supported IDE/Agent types
|
||||
type SupportedIDE = 'windsurf' | 'cursor' | 'claudecode' | 'cline' | 'kiro' | 'augment' | 'gemini';
|
||||
|
||||
/**
|
||||
* MCP Dashboard Page Component
|
||||
*
|
||||
* This is the main dashboard for managing the MCP (Model Context Protocol) server.
|
||||
* It provides a comprehensive interface for:
|
||||
*
|
||||
* 1. Server Control Tab:
|
||||
* - Start/stop the MCP server
|
||||
* - Monitor server status and uptime
|
||||
* - View and copy connection configuration
|
||||
*
|
||||
* 2. MCP Clients Tab:
|
||||
* - Interactive client management interface
|
||||
* - Tool discovery and testing
|
||||
* - Real-time tool execution
|
||||
* - Parameter input and result visualization
|
||||
*
|
||||
* The page uses a tab-based layout with preserved server functionality
|
||||
* and enhanced client management capabilities.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
export const MCPPage = () => {
|
||||
const [serverStatus, setServerStatus] = useState<ServerStatus>({
|
||||
status: 'stopped',
|
||||
uptime: null,
|
||||
logs: []
|
||||
});
|
||||
const [config, setConfig] = useState<ServerConfig | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
const [selectedIDE, setSelectedIDE] = useState<SupportedIDE>('windsurf');
|
||||
const statusPollInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const { showToast } = useToast();
|
||||
|
||||
// Tab state for switching between Server Control and Clients
|
||||
const [activeTab, setActiveTab] = useState<'server' | 'clients'>('server');
|
||||
|
||||
// Use staggered entrance animation
|
||||
const { isVisible, containerVariants, itemVariants, titleVariants } = useStaggeredEntrance(
|
||||
[1, 2, 3],
|
||||
0.15
|
||||
);
|
||||
|
||||
// Load initial status and start polling
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
loadConfiguration();
|
||||
|
||||
// Start polling for status updates every 5 seconds
|
||||
statusPollInterval.current = setInterval(loadStatus, 5000);
|
||||
|
||||
return () => {
|
||||
if (statusPollInterval.current) {
|
||||
clearInterval(statusPollInterval.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
// Ensure configuration is loaded when server is running
|
||||
useEffect(() => {
|
||||
if (serverStatus.status === 'running' && !config) {
|
||||
loadConfiguration();
|
||||
}
|
||||
}, [serverStatus.status]);
|
||||
|
||||
|
||||
/**
|
||||
* Load the current MCP server status
|
||||
* Called on mount and every 5 seconds via polling
|
||||
*/
|
||||
const loadStatus = async () => {
|
||||
try {
|
||||
const status = await mcpServerService.getStatus();
|
||||
setServerStatus(status);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load server status:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load the MCP server configuration
|
||||
* Falls back to default values if database load fails
|
||||
*/
|
||||
const loadConfiguration = async () => {
|
||||
try {
|
||||
const cfg = await mcpServerService.getConfiguration();
|
||||
console.log('Loaded configuration:', cfg);
|
||||
setConfig(cfg);
|
||||
} catch (error) {
|
||||
console.error('Failed to load configuration:', error);
|
||||
// Set a default config if loading fails
|
||||
// Try to detect port from environment or use default
|
||||
const defaultPort = import.meta.env.ARCHON_MCP_PORT || 8051;
|
||||
setConfig({
|
||||
transport: 'http',
|
||||
host: 'localhost',
|
||||
port: typeof defaultPort === 'string' ? parseInt(defaultPort) : defaultPort
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Start the MCP server
|
||||
*/
|
||||
const handleStartServer = async () => {
|
||||
try {
|
||||
setIsStarting(true);
|
||||
const response = await mcpServerService.startServer();
|
||||
showToast(response.message, 'success');
|
||||
// Immediately refresh status
|
||||
await loadStatus();
|
||||
} catch (error: any) {
|
||||
showToast(error.message || 'Failed to start server', 'error');
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopServer = async () => {
|
||||
try {
|
||||
setIsStopping(true);
|
||||
const response = await mcpServerService.stopServer();
|
||||
showToast(response.message, 'success');
|
||||
// Immediately refresh status
|
||||
await loadStatus();
|
||||
} catch (error: any) {
|
||||
showToast(error.message || 'Failed to stop server', 'error');
|
||||
} finally {
|
||||
setIsStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleCopyConfig = () => {
|
||||
if (!config) return;
|
||||
|
||||
const configText = getConfigForIDE(selectedIDE);
|
||||
navigator.clipboard.writeText(configText);
|
||||
showToast('Configuration copied to clipboard', 'success');
|
||||
};
|
||||
|
||||
const generateCursorDeeplink = () => {
|
||||
if (!config) return '';
|
||||
|
||||
const httpConfig = {
|
||||
url: `http://${config.host}:${config.port}/mcp`
|
||||
};
|
||||
|
||||
const configString = JSON.stringify(httpConfig);
|
||||
const base64Config = btoa(configString);
|
||||
return `cursor://anysphere.cursor-deeplink/mcp/install?name=archon&config=${base64Config}`;
|
||||
};
|
||||
|
||||
const handleCursorOneClick = () => {
|
||||
const deeplink = generateCursorDeeplink();
|
||||
window.location.href = deeplink;
|
||||
showToast('Opening Cursor with Archon MCP configuration...', 'info');
|
||||
};
|
||||
|
||||
|
||||
|
||||
const getConfigForIDE = (ide: SupportedIDE) => {
|
||||
if (!config || !config.host || !config.port) {
|
||||
return '// Configuration not available. Please ensure the server is running.';
|
||||
}
|
||||
|
||||
const mcpUrl = `http://${config.host}:${config.port}/mcp`;
|
||||
|
||||
switch(ide) {
|
||||
case 'claudecode':
|
||||
return JSON.stringify({
|
||||
name: "archon",
|
||||
transport: "http",
|
||||
url: mcpUrl
|
||||
}, null, 2);
|
||||
|
||||
case 'cline':
|
||||
case 'kiro':
|
||||
// Cline and Kiro use stdio transport with mcp-remote
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
archon: {
|
||||
command: "npx",
|
||||
args: ["mcp-remote", mcpUrl, "--allow-http"]
|
||||
}
|
||||
}
|
||||
}, null, 2);
|
||||
|
||||
case 'windsurf':
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
archon: {
|
||||
serverUrl: mcpUrl
|
||||
}
|
||||
}
|
||||
}, null, 2);
|
||||
|
||||
case 'cursor':
|
||||
case 'augment':
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
archon: {
|
||||
url: mcpUrl
|
||||
}
|
||||
}
|
||||
}, null, 2);
|
||||
|
||||
default:
|
||||
return '';
|
||||
case 'gemini':
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
archon: {
|
||||
httpUrl: mcpUrl
|
||||
}
|
||||
}
|
||||
}, null, 2);
|
||||
}
|
||||
};
|
||||
|
||||
const getIDEInstructions = (ide: SupportedIDE) => {
|
||||
switch (ide) {
|
||||
case 'windsurf':
|
||||
return {
|
||||
title: 'Windsurf Configuration',
|
||||
steps: [
|
||||
'1. Open Windsurf and click the "MCP servers" button (hammer icon)',
|
||||
'2. Click "Configure" and then "View raw config"',
|
||||
'3. Add the configuration shown below to the mcpServers object',
|
||||
'4. Click "Refresh" to connect to the server'
|
||||
]
|
||||
};
|
||||
case 'cursor':
|
||||
return {
|
||||
title: 'Cursor Configuration',
|
||||
steps: [
|
||||
'1. Option A: Use the one-click install button below (recommended)',
|
||||
'2. Option B: Manually edit ~/.cursor/mcp.json',
|
||||
'3. Add the configuration shown below',
|
||||
'4. Restart Cursor for changes to take effect'
|
||||
]
|
||||
};
|
||||
case 'claudecode':
|
||||
return {
|
||||
title: 'Claude Code Configuration',
|
||||
steps: [
|
||||
'1. Open a terminal and run the following command:',
|
||||
`2. claude mcp add --transport http archon http://${config?.host}:${config?.port}/mcp`,
|
||||
'3. The connection will be established automatically'
|
||||
]
|
||||
};
|
||||
case 'cline':
|
||||
return {
|
||||
title: 'Cline Configuration',
|
||||
steps: [
|
||||
'1. Open VS Code settings (Cmd/Ctrl + ,)',
|
||||
'2. Search for "cline.mcpServers"',
|
||||
'3. Click "Edit in settings.json"',
|
||||
'4. Add the configuration shown below',
|
||||
'5. Restart VS Code for changes to take effect'
|
||||
]
|
||||
};
|
||||
case 'kiro':
|
||||
return {
|
||||
title: 'Kiro Configuration',
|
||||
steps: [
|
||||
'1. Open Kiro settings',
|
||||
'2. Navigate to MCP Servers section',
|
||||
'3. Add the configuration shown below',
|
||||
'4. Save and restart Kiro'
|
||||
]
|
||||
};
|
||||
case 'augment':
|
||||
return {
|
||||
title: 'Augment Configuration',
|
||||
steps: [
|
||||
'1. Open Augment settings',
|
||||
'2. Navigate to Extensions > MCP',
|
||||
'3. Add the configuration shown below',
|
||||
'4. Reload configuration'
|
||||
]
|
||||
};
|
||||
case 'gemini':
|
||||
return {
|
||||
title: 'Gemini CLI Configuration',
|
||||
steps: [
|
||||
'1. Locate or create the settings file at ~/.gemini/settings.json',
|
||||
'2. Add the configuration shown below to the file',
|
||||
'3. Launch Gemini CLI in your terminal',
|
||||
'4. Test the connection by typing /mcp to list available tools'
|
||||
]
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: 'Configuration',
|
||||
steps: ['Add the configuration to your IDE settings']
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const formatUptime = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${hours}h ${minutes}m ${secs}s`;
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (serverStatus.status) {
|
||||
case 'running':
|
||||
return <CheckCircle className="w-5 h-5 text-green-500" />;
|
||||
case 'starting':
|
||||
case 'stopping':
|
||||
return <Loader className="w-5 h-5 text-blue-500 animate-spin" />;
|
||||
default:
|
||||
return <AlertCircle className="w-5 h-5 text-red-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (serverStatus.status) {
|
||||
case 'running':
|
||||
return 'text-green-500';
|
||||
case 'starting':
|
||||
case 'stopping':
|
||||
return 'text-blue-500';
|
||||
default:
|
||||
return 'text-red-500';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader className="animate-spin text-gray-500" size={32} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate={isVisible ? 'visible' : 'hidden'}
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.h1
|
||||
className="text-3xl font-bold text-gray-800 dark:text-white mb-8 flex items-center gap-3"
|
||||
variants={titleVariants}
|
||||
>
|
||||
<svg fill="currentColor" fillRule="evenodd" height="28" width="28" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" className="text-pink-500 filter drop-shadow-[0_0_8px_rgba(236,72,153,0.8)]">
|
||||
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path>
|
||||
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path>
|
||||
</svg>
|
||||
MCP Dashboard
|
||||
</motion.h1>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<motion.div className="mb-6 border-b border-gray-200 dark:border-gray-800" variants={itemVariants}>
|
||||
<div className="flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('server')}
|
||||
className={`pb-3 relative ${
|
||||
activeTab === 'server'
|
||||
? 'text-blue-600 dark:text-blue-400 font-medium'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Server Control
|
||||
{activeTab === 'server' && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.5)]"></span>
|
||||
)}
|
||||
</button>
|
||||
{/* TODO: MCP Client feature not implemented - commenting out for now
|
||||
<button
|
||||
onClick={() => setActiveTab('clients')}
|
||||
className={`pb-3 relative ${
|
||||
activeTab === 'clients'
|
||||
? 'text-cyan-600 dark:text-cyan-400 font-medium'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
MCP Clients
|
||||
{activeTab === 'clients' && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-cyan-500 shadow-[0_0_10px_rgba(34,211,238,0.5)]"></span>
|
||||
)}
|
||||
</button>
|
||||
*/}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Server Control Tab */}
|
||||
{activeTab === 'server' && (
|
||||
<>
|
||||
{/* Server Control */}
|
||||
<motion.div className="grid grid-cols-1 gap-6" variants={itemVariants}>
|
||||
|
||||
{/* Left Column: Archon MCP Server */}
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4 flex items-center">
|
||||
<Server className="mr-2 text-blue-500" size={20} />
|
||||
Archon MCP Server
|
||||
</h2>
|
||||
|
||||
<Card accentColor="blue" className="space-y-6 flex-1">
|
||||
{/* Status Display */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className="flex items-center gap-3 cursor-help"
|
||||
title={import.meta.env.DEV ?
|
||||
`Debug Info:\nStatus: ${serverStatus.status}\nConfig: ${config ? 'loaded' : 'null'}\n${config ? `Details: ${JSON.stringify(config, null, 2)}` : ''}` :
|
||||
undefined
|
||||
}
|
||||
>
|
||||
{getStatusIcon()}
|
||||
<div>
|
||||
<p className={`font-semibold ${getStatusColor()}`}>
|
||||
Status: {serverStatus.status.charAt(0).toUpperCase() + serverStatus.status.slice(1)}
|
||||
</p>
|
||||
{serverStatus.uptime !== null && (
|
||||
<p className="text-sm text-gray-600 dark:text-zinc-400">
|
||||
Uptime: {formatUptime(serverStatus.uptime)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex gap-2 items-center">
|
||||
{serverStatus.status === 'stopped' ? (
|
||||
<Button
|
||||
onClick={handleStartServer}
|
||||
disabled={isStarting}
|
||||
variant="primary"
|
||||
accentColor="green"
|
||||
className="shadow-emerald-500/20 shadow-sm"
|
||||
>
|
||||
{isStarting ? (
|
||||
<>
|
||||
<Loader className="w-4 h-4 mr-2 animate-spin inline" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2 inline" />
|
||||
Start Server
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleStopServer}
|
||||
disabled={isStopping || serverStatus.status !== 'running'}
|
||||
variant="primary"
|
||||
accentColor="pink"
|
||||
className="shadow-pink-500/20 shadow-sm"
|
||||
>
|
||||
{isStopping ? (
|
||||
<>
|
||||
<Loader className="w-4 h-4 mr-2 animate-spin inline" />
|
||||
Stopping...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Square className="w-4 h-4 mr-2 inline" />
|
||||
Stop Server
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Details */}
|
||||
{serverStatus.status === 'running' && config && (
|
||||
<div className="border-t border-gray-200 dark:border-zinc-800 pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-zinc-300">
|
||||
IDE Configuration
|
||||
<span className="ml-2 px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full">
|
||||
HTTP Mode
|
||||
</span>
|
||||
</h3>
|
||||
<Button
|
||||
variant="secondary"
|
||||
accentColor="blue"
|
||||
size="sm"
|
||||
onClick={handleCopyConfig}
|
||||
>
|
||||
<Copy className="w-3 h-3 mr-1 inline" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Note about universal MCP compatibility */}
|
||||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
<span className="font-semibold">Note:</span> Archon works with any application that supports MCP.
|
||||
Below are instructions for common tools, but these steps can be adapted for any MCP-compatible client.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* IDE Selection Tabs */}
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-wrap border-b border-gray-200 dark:border-zinc-700 mb-3">
|
||||
<button
|
||||
onClick={() => setSelectedIDE('claudecode')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
selectedIDE === 'claudecode'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
|
||||
} cursor-pointer`}
|
||||
>
|
||||
Claude Code
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedIDE('gemini')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
selectedIDE === 'gemini'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
|
||||
} cursor-pointer`}
|
||||
>
|
||||
Gemini CLI
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedIDE('cursor')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
selectedIDE === 'cursor'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
|
||||
} cursor-pointer`}
|
||||
>
|
||||
Cursor
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedIDE('windsurf')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
selectedIDE === 'windsurf'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
|
||||
} cursor-pointer`}
|
||||
>
|
||||
Windsurf
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedIDE('cline')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
selectedIDE === 'cline'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
|
||||
} cursor-pointer`}
|
||||
>
|
||||
Cline
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedIDE('kiro')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
selectedIDE === 'kiro'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
|
||||
} cursor-pointer`}
|
||||
>
|
||||
Kiro
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedIDE('augment')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
selectedIDE === 'augment'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
|
||||
} cursor-pointer`}
|
||||
>
|
||||
Augment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IDE Instructions */}
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
|
||||
{getIDEInstructions(selectedIDE).title}
|
||||
</h4>
|
||||
<ul className="text-sm text-gray-600 dark:text-zinc-400 space-y-1">
|
||||
{getIDEInstructions(selectedIDE).steps.map((step, index) => (
|
||||
<li key={index}>{step}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-black/50 rounded-lg p-4 font-mono text-sm relative">
|
||||
<pre className="text-gray-600 dark:text-zinc-400 whitespace-pre-wrap">
|
||||
{getConfigForIDE(selectedIDE)}
|
||||
</pre>
|
||||
<p className="text-xs text-gray-500 dark:text-zinc-500 mt-3 font-sans">
|
||||
{selectedIDE === 'cursor'
|
||||
? 'Copy this configuration and add it to ~/.cursor/mcp.json'
|
||||
: selectedIDE === 'windsurf'
|
||||
? 'Copy this configuration and add it to your Windsurf MCP settings'
|
||||
: selectedIDE === 'claudecode'
|
||||
? 'This shows the configuration format for Claude Code'
|
||||
: selectedIDE === 'cline'
|
||||
? 'Copy this configuration and add it to VS Code settings.json under "cline.mcpServers"'
|
||||
: selectedIDE === 'kiro'
|
||||
? 'Copy this configuration and add it to your Kiro MCP settings'
|
||||
: selectedIDE === 'augment'
|
||||
? 'Copy this configuration and add it to your Augment MCP settings'
|
||||
: 'Copy this configuration and add it to your IDE settings'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* One-click install button for Cursor */}
|
||||
{selectedIDE === 'cursor' && serverStatus.status === 'running' && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
accentColor="blue"
|
||||
onClick={handleCursorOneClick}
|
||||
className="w-full"
|
||||
>
|
||||
<Server className="w-4 h-4 mr-2 inline" />
|
||||
One-Click Install for Cursor
|
||||
</Button>
|
||||
<p className="text-xs text-gray-500 dark:text-zinc-500 mt-2 text-center">
|
||||
Requires Cursor to be installed and will open a deeplink
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
|
||||
{/* Global Rules Section */}
|
||||
<motion.div className="mt-6" variants={itemVariants}>
|
||||
<h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4 flex items-center">
|
||||
<Server className="mr-2 text-pink-500" size={20} />
|
||||
Global IDE Rules
|
||||
</h2>
|
||||
<IDEGlobalRules />
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Clients Tab - commented out as feature not implemented
|
||||
{activeTab === 'clients' && (
|
||||
<motion.div variants={itemVariants}>
|
||||
<MCPClients />
|
||||
</motion.div>
|
||||
)}
|
||||
*/}
|
||||
</motion.div>
|
||||
);
|
||||
return <McpViewWithBoundary />;
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ProjectsView } from '../features/projects';
|
||||
import { ProjectsViewWithBoundary } from '../features/projects';
|
||||
|
||||
// Minimal wrapper for routing compatibility
|
||||
// All implementation is in features/projects/views/ProjectsView.tsx
|
||||
// Uses ProjectsViewWithBoundary for proper error handling
|
||||
|
||||
function ProjectPage(props: any) {
|
||||
return <ProjectsView {...props} />;
|
||||
return <ProjectsViewWithBoundary {...props} />;
|
||||
}
|
||||
|
||||
export { ProjectPage };
|
||||
@@ -1,19 +1,8 @@
|
||||
/**
|
||||
* API service layer for communicating with the MCP server backend.
|
||||
* API service layer for backend communication.
|
||||
*/
|
||||
|
||||
// Types for API responses
|
||||
export interface MCPServerResponse {
|
||||
success: boolean;
|
||||
status: 'starting' | 'running' | 'stopped' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface MCPServerStatus {
|
||||
status: 'starting' | 'running' | 'stopped' | 'error';
|
||||
uptime?: number;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
export interface CrawlResponse {
|
||||
success: boolean;
|
||||
@@ -125,19 +114,6 @@ export async function apiRequest<T>(
|
||||
}
|
||||
}
|
||||
|
||||
// MCP Server Management
|
||||
export async function startMCPServer(): Promise<MCPServerResponse> {
|
||||
return retry(() => apiRequest<MCPServerResponse>('/mcp/start', { method: 'POST' }));
|
||||
}
|
||||
|
||||
export async function stopMCPServer(): Promise<MCPServerResponse> {
|
||||
return retry(() => apiRequest<MCPServerResponse>('/mcp/stop', { method: 'POST' }));
|
||||
}
|
||||
|
||||
export async function getMCPServerStatus(): Promise<MCPServerStatus> {
|
||||
return retry(() => apiRequest<MCPServerStatus>('/mcp/status'));
|
||||
}
|
||||
|
||||
// Crawling Operations
|
||||
export async function crawlSinglePage(url: string): Promise<CrawlResponse> {
|
||||
return retry(() => apiRequest<CrawlResponse>('/crawl/single', {
|
||||
|
||||
@@ -1,445 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { getApiUrl } from '../config/api';
|
||||
|
||||
// ========================================
|
||||
// TYPES & INTERFACES
|
||||
// ========================================
|
||||
|
||||
export interface MCPClientConfig {
|
||||
name: string;
|
||||
transport_type: 'http'; // Only Streamable HTTP is supported for MCP clients
|
||||
connection_config: {
|
||||
url: string; // The Streamable HTTP endpoint URL (e.g., http://localhost:8051/mcp)
|
||||
};
|
||||
auto_connect?: boolean;
|
||||
health_check_interval?: number;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface MCPClient {
|
||||
id: string;
|
||||
name: string;
|
||||
transport_type: 'http'; // Only Streamable HTTP is supported
|
||||
connection_config: {
|
||||
url: string;
|
||||
};
|
||||
status: 'connected' | 'disconnected' | 'connecting' | 'error';
|
||||
auto_connect: boolean;
|
||||
health_check_interval: number;
|
||||
last_seen: string | null;
|
||||
last_error: string | null;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MCPClientTool {
|
||||
id: string;
|
||||
client_id: string;
|
||||
tool_name: string;
|
||||
tool_description: string | null;
|
||||
tool_schema: Record<string, any>;
|
||||
discovered_at: string;
|
||||
}
|
||||
|
||||
export interface ToolCallRequest {
|
||||
client_id: string;
|
||||
tool_name: string;
|
||||
arguments: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ClientStatus {
|
||||
client_id: string;
|
||||
status: string;
|
||||
last_seen: string | null;
|
||||
last_error: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface ToolsResponse {
|
||||
client_id: string;
|
||||
tools: MCPClientTool[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AllToolsResponse {
|
||||
archon_tools: MCPTool[];
|
||||
client_tools: { client: MCPClient; tools: MCPClientTool[] }[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
// Zod schemas for MCP protocol
|
||||
const MCPParameterSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
type: z.string().optional(),
|
||||
});
|
||||
|
||||
const MCPToolSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
inputSchema: z.object({
|
||||
type: z.literal('object'),
|
||||
properties: z.record(z.any()).optional(),
|
||||
required: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type MCPTool = z.infer<typeof MCPToolSchema>;
|
||||
export type MCPParameter = z.infer<typeof MCPParameterSchema>;
|
||||
|
||||
import { getApiUrl } from '../config/api';
|
||||
|
||||
/**
|
||||
* MCP Client Service - Universal MCP client that connects to any MCP servers
|
||||
* This service communicates with the standalone Python MCP client service
|
||||
*/
|
||||
class MCPClientService {
|
||||
private baseUrl = getApiUrl();
|
||||
|
||||
// ========================================
|
||||
// CLIENT MANAGEMENT
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Get all configured MCP clients
|
||||
*/
|
||||
async getClients(): Promise<MCPClient[]> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get MCP clients');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new MCP client
|
||||
*/
|
||||
async createClient(config: MCPClientConfig): Promise<MCPClient> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to create MCP client');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific MCP client
|
||||
*/
|
||||
async getClient(clientId: string): Promise<MCPClient> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to get MCP client');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an MCP client
|
||||
*/
|
||||
async updateClient(clientId: string, updates: Partial<MCPClientConfig>): Promise<MCPClient> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to update MCP client');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an MCP client
|
||||
*/
|
||||
async deleteClient(clientId: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to delete MCP client');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// CONNECTION MANAGEMENT
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Connect to an MCP client
|
||||
*/
|
||||
async connectClient(clientId: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/connect`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to connect to MCP client');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from an MCP client
|
||||
*/
|
||||
async disconnectClient(clientId: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/disconnect`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to disconnect from MCP client');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client status and health
|
||||
*/
|
||||
async getClientStatus(clientId: string): Promise<ClientStatus> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/status`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to get client status');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a client configuration before saving
|
||||
*/
|
||||
async testClientConfig(config: MCPClientConfig): Promise<{ success: boolean; message: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/test-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to test client configuration');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// TOOL DISCOVERY & EXECUTION
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Get tools from a specific client
|
||||
*/
|
||||
async getClientTools(clientId: string): Promise<ToolsResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/tools`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to get client tools');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a tool on a specific client
|
||||
*/
|
||||
async callClientTool(request: ToolCallRequest): Promise<any> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/tools/call`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to call client tool');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools from all connected clients (including Archon via MCP client)
|
||||
*/
|
||||
async getAllAvailableTools(): Promise<AllToolsResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/tools/all`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to get all available tools');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover tools from a specific client (force refresh)
|
||||
*/
|
||||
async discoverClientTools(clientId: string): Promise<ToolsResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/tools/discover`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to discover client tools');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// CONVENIENCE METHODS
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Connect to multiple clients at once
|
||||
*/
|
||||
async connectMultipleClients(clientIds: string[]): Promise<Array<{ clientId: string; success: boolean; message: string }>> {
|
||||
const results = await Promise.allSettled(
|
||||
clientIds.map(async (clientId) => {
|
||||
try {
|
||||
const result = await this.connectClient(clientId);
|
||||
return { clientId, ...result };
|
||||
} catch (error) {
|
||||
return {
|
||||
clientId,
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results.map((result, index) =>
|
||||
result.status === 'fulfilled'
|
||||
? result.value
|
||||
: { clientId: clientIds[index], success: false, message: result.reason?.message || 'Failed to connect' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status for all clients
|
||||
*/
|
||||
async getAllClientStatuses(): Promise<Array<{ client: MCPClient; status: ClientStatus }>> {
|
||||
const clients = await this.getClients();
|
||||
|
||||
const statuses = await Promise.allSettled(
|
||||
clients.map(async (client) => {
|
||||
try {
|
||||
const status = await this.getClientStatus(client.id);
|
||||
return { client, status };
|
||||
} catch (error) {
|
||||
return {
|
||||
client,
|
||||
status: {
|
||||
client_id: client.id,
|
||||
status: 'error',
|
||||
last_seen: null,
|
||||
last_error: error instanceof Error ? error.message : 'Unknown error',
|
||||
is_active: false
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return statuses.map((result) =>
|
||||
result.status === 'fulfilled' ? result.value : result.reason
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-connect to all clients marked with auto_connect
|
||||
*/
|
||||
async autoConnectClients(): Promise<Array<{ clientId: string; success: boolean; message: string }>> {
|
||||
const clients = await this.getClients();
|
||||
const autoConnectClients = clients.filter(client => client.auto_connect);
|
||||
|
||||
if (autoConnectClients.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.connectMultipleClients(autoConnectClients.map(c => c.id));
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ARCHON INTEGRATION HELPERS
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Create Archon MCP client using Streamable HTTP transport
|
||||
*/
|
||||
async createArchonClient(): Promise<MCPClient> {
|
||||
// Require ARCHON_MCP_PORT to be set
|
||||
const mcpPort = import.meta.env.ARCHON_MCP_PORT;
|
||||
if (!mcpPort) {
|
||||
throw new Error(
|
||||
'ARCHON_MCP_PORT environment variable is required. ' +
|
||||
'Please set it in your environment variables. ' +
|
||||
'Default value: 8051'
|
||||
);
|
||||
}
|
||||
|
||||
// Get the host from the API URL
|
||||
const apiUrl = getApiUrl();
|
||||
const url = new URL(apiUrl || `http://${window.location.hostname}:${mcpPort}`);
|
||||
const mcpUrl = `${url.protocol}//${url.hostname}:${mcpPort}/mcp`;
|
||||
|
||||
const archonConfig: MCPClientConfig = {
|
||||
name: 'Archon',
|
||||
transport_type: 'http',
|
||||
connection_config: {
|
||||
url: mcpUrl
|
||||
},
|
||||
auto_connect: true,
|
||||
health_check_interval: 30,
|
||||
is_default: true
|
||||
};
|
||||
|
||||
return this.createClient(archonConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default Archon client (or create if doesn't exist)
|
||||
*/
|
||||
async getOrCreateArchonClient(): Promise<MCPClient> {
|
||||
const clients = await this.getClients();
|
||||
const archonClient = clients.find(client => client.is_default || client.name === 'Archon');
|
||||
|
||||
if (archonClient) {
|
||||
return archonClient;
|
||||
}
|
||||
|
||||
return this.createArchonClient();
|
||||
}
|
||||
}
|
||||
|
||||
export const mcpClientService = new MCPClientService();
|
||||
@@ -1,237 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface ServerStatus {
|
||||
status: 'running' | 'starting' | 'stopped' | 'stopping';
|
||||
uptime: number | null;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
export interface ServerResponse {
|
||||
success: boolean;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
transport: string;
|
||||
host: string;
|
||||
port: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
// Zod schemas for MCP protocol
|
||||
const MCPParameterSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
type: z.string().optional(),
|
||||
});
|
||||
|
||||
const MCPToolSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
inputSchema: z.object({
|
||||
type: z.literal('object'),
|
||||
properties: z.record(z.any()).optional(),
|
||||
required: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
const MCPToolsListResponseSchema = z.object({
|
||||
tools: z.array(MCPToolSchema),
|
||||
});
|
||||
|
||||
const MCPResponseSchema = z.object({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
id: z.union([z.string(), z.number()]),
|
||||
result: z.any().optional(),
|
||||
error: z.object({
|
||||
code: z.number(),
|
||||
message: z.string(),
|
||||
data: z.any().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type MCPTool = z.infer<typeof MCPToolSchema>;
|
||||
export type MCPParameter = z.infer<typeof MCPParameterSchema>;
|
||||
|
||||
|
||||
/**
|
||||
* MCP Server Service - Handles the Archon MCP server lifecycle via FastAPI
|
||||
*/
|
||||
class MCPServerService {
|
||||
private baseUrl = ''; // Use relative URL to go through Vite proxy
|
||||
|
||||
// ========================================
|
||||
// SERVER MANAGEMENT
|
||||
// ========================================
|
||||
|
||||
async startServer(): Promise<ServerResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to start MCP server');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async stopServer(): Promise<ServerResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/stop`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to stop MCP server');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getStatus(): Promise<ServerStatus> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/status`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get server status');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getConfiguration(): Promise<ServerConfig> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/config`);
|
||||
|
||||
if (!response.ok) {
|
||||
// Return default config if endpoint doesn't exist yet
|
||||
return {
|
||||
transport: 'sse',
|
||||
host: 'localhost',
|
||||
port: 8051
|
||||
};
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async updateConfiguration(config: Partial<ServerConfig>): Promise<ServerResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to update configuration');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
|
||||
// ========================================
|
||||
// LEGACY ARCHON TOOL ACCESS (For backward compatibility)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Make an MCP call to the running Archon server via SSE
|
||||
*/
|
||||
private async makeMCPCall(method: string, params?: any): Promise<any> {
|
||||
const status = await this.getStatus();
|
||||
if (status.status !== 'running') {
|
||||
throw new Error('MCP server is not running');
|
||||
}
|
||||
|
||||
const config = await this.getConfiguration();
|
||||
const mcpUrl = `http://${config.host}:${config.port}/${config.transport}`;
|
||||
|
||||
// Generate unique request ID
|
||||
const id = Math.random().toString(36).substring(2);
|
||||
|
||||
const mcpRequest = {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method,
|
||||
params: params || {}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(mcpUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(mcpRequest)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const mcpResponse = await response.json();
|
||||
|
||||
// Validate MCP response format
|
||||
const validatedResponse = MCPResponseSchema.parse(mcpResponse);
|
||||
|
||||
if (validatedResponse.error) {
|
||||
throw new Error(`MCP Error: ${validatedResponse.error.message}`);
|
||||
}
|
||||
|
||||
return validatedResponse.result;
|
||||
} catch (error) {
|
||||
console.error('MCP call failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available tools from the running Archon MCP server
|
||||
* @deprecated Use mcpClientService for tool discovery instead
|
||||
*/
|
||||
async getAvailableTools(): Promise<MCPTool[]> {
|
||||
try {
|
||||
console.log('Attempting direct MCP tools/list call to Archon server...');
|
||||
const result = await this.makeMCPCall('tools/list');
|
||||
const validatedResult = MCPToolsListResponseSchema.parse(result);
|
||||
console.log('Successfully retrieved tools from Archon server:', validatedResult.tools.length);
|
||||
return validatedResult.tools;
|
||||
} catch (mcpError) {
|
||||
console.warn('Direct MCP call to Archon server failed:', mcpError);
|
||||
throw new Error(`Failed to retrieve tools from Archon server: ${mcpError instanceof Error ? mcpError.message : mcpError}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a specific tool on the Archon MCP server
|
||||
* @deprecated Use mcpClientService for tool calls instead
|
||||
*/
|
||||
async callTool(name: string, arguments_: Record<string, any>): Promise<any> {
|
||||
try {
|
||||
const result = await this.makeMCPCall('tools/call', {
|
||||
name,
|
||||
arguments: arguments_
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Failed to call Archon MCP tool ${name}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mcpServerService = new MCPServerService();
|
||||
|
||||
/**
|
||||
* Legacy function - use mcpServerService.getAvailableTools() instead
|
||||
* @deprecated Use mcpServerService.getAvailableTools() or mcpClientService instead
|
||||
*/
|
||||
export const getMCPTools = async () => {
|
||||
console.warn('getMCPTools is deprecated. Use mcpServerService.getAvailableTools() or mcpClientService instead.');
|
||||
return mcpServerService.getAvailableTools();
|
||||
};
|
||||
@@ -1,580 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface ServerStatus {
|
||||
status: 'running' | 'starting' | 'stopped' | 'stopping';
|
||||
uptime: number | null;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
export interface ServerResponse {
|
||||
success: boolean;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
transport: string;
|
||||
host: string;
|
||||
port: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
// Multi-client interfaces
|
||||
export interface MCPClientConfig {
|
||||
name: string;
|
||||
transport_type: 'sse' | 'stdio' | 'docker' | 'npx';
|
||||
connection_config: Record<string, any>;
|
||||
auto_connect?: boolean;
|
||||
health_check_interval?: number;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface MCPClient {
|
||||
id: string;
|
||||
name: string;
|
||||
transport_type: 'sse' | 'stdio' | 'docker' | 'npx';
|
||||
connection_config: Record<string, any>;
|
||||
status: 'connected' | 'disconnected' | 'connecting' | 'error';
|
||||
auto_connect: boolean;
|
||||
health_check_interval: number;
|
||||
last_seen: string | null;
|
||||
last_error: string | null;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MCPClientTool {
|
||||
id: string;
|
||||
client_id: string;
|
||||
tool_name: string;
|
||||
tool_description: string | null;
|
||||
tool_schema: Record<string, any>;
|
||||
discovered_at: string;
|
||||
}
|
||||
|
||||
export interface ToolCallRequest {
|
||||
client_id: string;
|
||||
tool_name: string;
|
||||
arguments: Record<string, any>;
|
||||
}
|
||||
|
||||
interface StreamLogOptions {
|
||||
autoReconnect?: boolean;
|
||||
reconnectDelay?: number;
|
||||
}
|
||||
|
||||
// Zod schemas for MCP protocol
|
||||
const MCPParameterSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
type: z.string().optional(),
|
||||
});
|
||||
|
||||
const MCPToolSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
inputSchema: z.object({
|
||||
type: z.literal('object'),
|
||||
properties: z.record(z.any()).optional(),
|
||||
required: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
const MCPToolsListResponseSchema = z.object({
|
||||
tools: z.array(MCPToolSchema),
|
||||
});
|
||||
|
||||
const MCPResponseSchema = z.object({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
id: z.union([z.string(), z.number()]),
|
||||
result: z.any().optional(),
|
||||
error: z.object({
|
||||
code: z.number(),
|
||||
message: z.string(),
|
||||
data: z.any().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type MCPTool = z.infer<typeof MCPToolSchema>;
|
||||
export type MCPParameter = z.infer<typeof MCPParameterSchema>;
|
||||
|
||||
|
||||
class MCPService {
|
||||
private baseUrl = ''; // Use relative URL to go through Vite proxy
|
||||
|
||||
// ========================================
|
||||
// SERVER MANAGEMENT (Original functionality)
|
||||
// ========================================
|
||||
|
||||
async startServer(): Promise<ServerResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to start MCP server');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async stopServer(): Promise<ServerResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/stop`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to stop MCP server');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getStatus(): Promise<ServerStatus> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/status`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get server status');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getConfiguration(): Promise<ServerConfig> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/config`);
|
||||
|
||||
if (!response.ok) {
|
||||
// Return default config if endpoint doesn't exist yet
|
||||
return {
|
||||
transport: 'sse',
|
||||
host: 'localhost',
|
||||
port: 8051
|
||||
};
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async updateConfiguration(config: Partial<ServerConfig>): Promise<ServerResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to update configuration');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getLogs(options: { limit?: number } = {}): Promise<LogEntry[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.limit) {
|
||||
params.append('limit', options.limit.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/logs?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch logs');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.logs || [];
|
||||
}
|
||||
|
||||
async clearLogs(): Promise<ServerResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/logs`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to clear logs');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
|
||||
// ========================================
|
||||
// CLIENT MANAGEMENT (New functionality)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Get all configured MCP clients
|
||||
*/
|
||||
async getClients(): Promise<MCPClient[]> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get MCP clients');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new MCP client
|
||||
*/
|
||||
async createClient(config: MCPClientConfig): Promise<MCPClient> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to create MCP client');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific MCP client
|
||||
*/
|
||||
async getClient(clientId: string): Promise<MCPClient> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to get MCP client');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an MCP client
|
||||
*/
|
||||
async updateClient(clientId: string, updates: Partial<MCPClientConfig>): Promise<MCPClient> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to update MCP client');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an MCP client
|
||||
*/
|
||||
async deleteClient(clientId: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to delete MCP client');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to an MCP client
|
||||
*/
|
||||
async connectClient(clientId: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/connect`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to connect to MCP client');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from an MCP client
|
||||
*/
|
||||
async disconnectClient(clientId: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/disconnect`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to disconnect from MCP client');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client status and health
|
||||
*/
|
||||
async getClientStatus(clientId: string): Promise<{
|
||||
client_id: string;
|
||||
status: string;
|
||||
last_seen: string | null;
|
||||
last_error: string | null;
|
||||
is_active: boolean;
|
||||
}> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/status`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to get client status');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools from a specific client
|
||||
*/
|
||||
async getClientTools(clientId: string): Promise<{
|
||||
client_id: string;
|
||||
tools: MCPClientTool[];
|
||||
count: number;
|
||||
}> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/tools`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to get client tools');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a client configuration before saving
|
||||
*/
|
||||
async testClientConfig(config: MCPClientConfig): Promise<{ success: boolean; message: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/test-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to test client configuration');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a tool on a specific client
|
||||
*/
|
||||
async callClientTool(request: ToolCallRequest): Promise<any> {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/clients/tools/call`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to call client tool');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// LEGACY TOOL FUNCTIONALITY (Updated for multi-client)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Make an MCP call to the running server via SSE
|
||||
*/
|
||||
private async makeMCPCall(method: string, params?: any): Promise<any> {
|
||||
const status = await this.getStatus();
|
||||
if (status.status !== 'running') {
|
||||
throw new Error('MCP server is not running');
|
||||
}
|
||||
|
||||
const config = await this.getConfiguration();
|
||||
const mcpUrl = `http://${config.host}:${config.port}/mcp`;
|
||||
|
||||
// Generate unique request ID
|
||||
const id = Math.random().toString(36).substring(2);
|
||||
|
||||
const mcpRequest = {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method,
|
||||
params: params || {}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(mcpUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(mcpRequest)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const mcpResponse = await response.json();
|
||||
|
||||
// Validate MCP response format
|
||||
const validatedResponse = MCPResponseSchema.parse(mcpResponse);
|
||||
|
||||
if (validatedResponse.error) {
|
||||
throw new Error(`MCP Error: ${validatedResponse.error.message}`);
|
||||
}
|
||||
|
||||
return validatedResponse.result;
|
||||
} catch (error) {
|
||||
console.error('MCP call failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available tools from the running MCP server (legacy - for Archon default client)
|
||||
*/
|
||||
async getAvailableTools(): Promise<MCPTool[]> {
|
||||
try {
|
||||
// Skip the broken backend endpoint and try direct MCP protocol call
|
||||
console.log('Attempting direct MCP tools/list call...');
|
||||
const result = await this.makeMCPCall('tools/list');
|
||||
const validatedResult = MCPToolsListResponseSchema.parse(result);
|
||||
console.log('Successfully retrieved tools via MCP protocol:', validatedResult.tools.length);
|
||||
return validatedResult.tools;
|
||||
} catch (mcpError) {
|
||||
console.warn('Direct MCP call failed, falling back to backend endpoint:', mcpError);
|
||||
|
||||
// Fallback to backend endpoint (which returns debug placeholder)
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/mcp/tools`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('Backend endpoint returned:', data);
|
||||
|
||||
// If we only get the debug placeholder, return empty array with warning
|
||||
if (data.tools.length === 1 && data.tools[0].name === 'debug_placeholder') {
|
||||
console.warn('Backend returned debug placeholder - MCP tool introspection is not working');
|
||||
// Return empty array instead of the placeholder
|
||||
return [];
|
||||
}
|
||||
|
||||
// Convert the backend format to MCP tool format
|
||||
const tools: MCPTool[] = data.tools.map((tool: any) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: tool.parameters.reduce((props: any, param: any) => {
|
||||
props[param.name] = {
|
||||
type: param.type,
|
||||
description: param.description
|
||||
};
|
||||
return props;
|
||||
}, {}),
|
||||
required: tool.parameters.filter((p: any) => p.required).map((p: any) => p.name)
|
||||
}
|
||||
}));
|
||||
return tools;
|
||||
}
|
||||
throw new Error('Backend endpoint failed');
|
||||
} catch (backendError) {
|
||||
console.error('Both MCP protocol and backend endpoint failed:', { mcpError, backendError });
|
||||
throw new Error(`Failed to retrieve tools: MCP protocol failed (${mcpError instanceof Error ? mcpError.message : mcpError}), backend also failed (${backendError instanceof Error ? backendError.message : backendError})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a specific MCP tool (legacy - for Archon default client)
|
||||
*/
|
||||
async callTool(name: string, arguments_: Record<string, any>): Promise<any> {
|
||||
try {
|
||||
const result = await this.makeMCPCall('tools/call', {
|
||||
name,
|
||||
arguments: arguments_
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Failed to call MCP tool ${name}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated tools from all connected clients
|
||||
*/
|
||||
async getAllAvailableTools(): Promise<{
|
||||
archon_tools: MCPTool[];
|
||||
client_tools: { client: MCPClient; tools: MCPClientTool[] }[];
|
||||
total_count: number;
|
||||
}> {
|
||||
try {
|
||||
// Get Archon tools (default client)
|
||||
const archonTools = await this.getAvailableTools();
|
||||
|
||||
// Get all clients and their tools
|
||||
const clients = await this.getClients();
|
||||
const clientTools = await Promise.all(
|
||||
clients
|
||||
.filter(client => client.status === 'connected' && !client.is_default)
|
||||
.map(async (client) => {
|
||||
try {
|
||||
const toolsData = await this.getClientTools(client.id);
|
||||
return { client, tools: toolsData.tools };
|
||||
} catch {
|
||||
return { client, tools: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const totalCount = archonTools.length + clientTools.reduce((sum, ct) => sum + ct.tools.length, 0);
|
||||
|
||||
return {
|
||||
archon_tools: archonTools,
|
||||
client_tools: clientTools,
|
||||
total_count: totalCount
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get all available tools:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mcpService = new MCPService();
|
||||
|
||||
/**
|
||||
* Legacy function - replaced by mcpService.getAvailableTools()
|
||||
* @deprecated Use mcpService.getAvailableTools() instead
|
||||
*/
|
||||
export const getMCPTools = async () => {
|
||||
console.warn('getMCPTools is deprecated. Use mcpService.getAvailableTools() instead.');
|
||||
return mcpService.getAvailableTools();
|
||||
};
|
||||
@@ -1,294 +0,0 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import React from 'react'
|
||||
|
||||
describe('Component Tests', () => {
|
||||
test('button component works', () => {
|
||||
const onClick = vi.fn()
|
||||
const MockButton = ({ children, ...props }: any) => (
|
||||
<button {...props}>{children}</button>
|
||||
)
|
||||
|
||||
render(<MockButton onClick={onClick}>Click me</MockButton>)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('input component works', () => {
|
||||
const MockInput = () => {
|
||||
const [value, setValue] = React.useState('')
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Test input"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockInput />)
|
||||
const input = screen.getByPlaceholderText('Test input')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'test' } })
|
||||
expect((input as HTMLInputElement).value).toBe('test')
|
||||
})
|
||||
|
||||
test('modal component works', () => {
|
||||
const MockModal = () => {
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setIsOpen(true)}>Open Modal</button>
|
||||
{isOpen && (
|
||||
<div role="dialog">
|
||||
<h2>Modal Title</h2>
|
||||
<button onClick={() => setIsOpen(false)}>Close</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockModal />)
|
||||
|
||||
// Modal not visible initially
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
|
||||
// Open modal
|
||||
fireEvent.click(screen.getByText('Open Modal'))
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
|
||||
// Close modal
|
||||
fireEvent.click(screen.getByText('Close'))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('progress bar component works', () => {
|
||||
const MockProgressBar = ({ value, max }: { value: number; max: number }) => (
|
||||
<div>
|
||||
<div>Progress: {Math.round((value / max) * 100)}%</div>
|
||||
<div style={{ width: `${(value / max) * 100}%` }}>Bar</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const { rerender } = render(<MockProgressBar value={0} max={100} />)
|
||||
expect(screen.getByText('Progress: 0%')).toBeInTheDocument()
|
||||
|
||||
rerender(<MockProgressBar value={50} max={100} />)
|
||||
expect(screen.getByText('Progress: 50%')).toBeInTheDocument()
|
||||
|
||||
rerender(<MockProgressBar value={100} max={100} />)
|
||||
expect(screen.getByText('Progress: 100%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('tooltip component works', () => {
|
||||
const MockTooltip = ({ children, tooltip }: any) => {
|
||||
const [show, setShow] = React.useState(false)
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onMouseEnter={() => setShow(true)}
|
||||
onMouseLeave={() => setShow(false)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{show && <div role="tooltip">{tooltip}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockTooltip tooltip="This is a tooltip">Hover me</MockTooltip>)
|
||||
|
||||
const button = screen.getByText('Hover me')
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseEnter(button)
|
||||
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseLeave(button)
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('accordion component works', () => {
|
||||
const MockAccordion = () => {
|
||||
const [expanded, setExpanded] = React.useState(false)
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setExpanded(!expanded)}>
|
||||
Section 1 {expanded ? '−' : '+'}
|
||||
</button>
|
||||
{expanded && <div>Section content</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockAccordion />)
|
||||
|
||||
expect(screen.queryByText('Section content')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Section 1 +'))
|
||||
expect(screen.getByText('Section content')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Section 1 −'))
|
||||
expect(screen.queryByText('Section content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('table sorting works', () => {
|
||||
const MockTable = () => {
|
||||
const [data, setData] = React.useState([
|
||||
{ name: 'Alice', age: 30 },
|
||||
{ name: 'Bob', age: 25 },
|
||||
{ name: 'Charlie', age: 35 }
|
||||
])
|
||||
|
||||
const sortByName = () => {
|
||||
setData([...data].sort((a, b) => a.name.localeCompare(b.name)))
|
||||
}
|
||||
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th onClick={sortByName} style={{ cursor: 'pointer' }}>
|
||||
Name
|
||||
</th>
|
||||
<th>Age</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, index) => (
|
||||
<tr key={index}>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.age}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockTable />)
|
||||
|
||||
const cells = screen.getAllByRole('cell')
|
||||
expect(cells[0]).toHaveTextContent('Alice')
|
||||
|
||||
fireEvent.click(screen.getByText('Name'))
|
||||
|
||||
// After sorting, Alice should still be first (already sorted)
|
||||
const sortedCells = screen.getAllByRole('cell')
|
||||
expect(sortedCells[0]).toHaveTextContent('Alice')
|
||||
})
|
||||
|
||||
test('pagination works', () => {
|
||||
const MockPagination = () => {
|
||||
const [page, setPage] = React.useState(1)
|
||||
return (
|
||||
<div>
|
||||
<div>Page {page}</div>
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockPagination />)
|
||||
|
||||
expect(screen.getByText('Page 1')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Next'))
|
||||
expect(screen.getByText('Page 2')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Previous'))
|
||||
expect(screen.getByText('Page 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('form validation works', () => {
|
||||
const MockForm = () => {
|
||||
const [email, setEmail] = React.useState('')
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
const validate = (value: string) => {
|
||||
if (!value) {
|
||||
setError('Email is required')
|
||||
} else if (!value.includes('@')) {
|
||||
setError('Invalid email format')
|
||||
} else {
|
||||
setError('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value)
|
||||
validate(e.target.value)
|
||||
}}
|
||||
/>
|
||||
{error && <div role="alert">{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockForm />)
|
||||
|
||||
const input = screen.getByPlaceholderText('Email')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'invalid' } })
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Invalid email format')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'valid@email.com' } })
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('search filtering works', () => {
|
||||
const MockSearch = () => {
|
||||
const [query, setQuery] = React.useState('')
|
||||
const items = ['Apple', 'Banana', 'Cherry', 'Date']
|
||||
const filtered = items.filter(item =>
|
||||
item.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
placeholder="Search items"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<ul>
|
||||
{filtered.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockSearch />)
|
||||
|
||||
// All items visible initially
|
||||
expect(screen.getByText('Apple')).toBeInTheDocument()
|
||||
expect(screen.getByText('Banana')).toBeInTheDocument()
|
||||
|
||||
// Filter items
|
||||
const input = screen.getByPlaceholderText('Search items')
|
||||
fireEvent.change(input, { target: { value: 'a' } })
|
||||
|
||||
expect(screen.getByText('Apple')).toBeInTheDocument()
|
||||
expect(screen.getByText('Banana')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Cherry')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { DeleteConfirmModal } from '../../../src/components/common/DeleteConfirmModal';
|
||||
|
||||
describe('DeleteConfirmModal', () => {
|
||||
const defaultProps = {
|
||||
itemName: 'Test Item',
|
||||
onConfirm: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
type: 'task' as const,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders with correct title and message for task type', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Delete Task')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Are you sure you want to delete the "Test Item" task/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with correct title and message for project type', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} type="project" />);
|
||||
|
||||
expect(screen.getByText('Delete Project')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Are you sure you want to delete the "Test Item" project/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with correct title and message for client type', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} type="client" />);
|
||||
|
||||
expect(screen.getByText('Delete MCP Client')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Are you sure you want to delete the "Test Item" client/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onConfirm when Delete button is clicked', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onCancel when Cancel button is clicked', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onCancel when Escape key is pressed', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
|
||||
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onCancel when backdrop is clicked', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
// Click the backdrop
|
||||
const backdrop = screen.getByTestId('modal-backdrop');
|
||||
fireEvent.click(backdrop);
|
||||
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call onCancel when modal content is clicked', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
// Click the modal dialog itself
|
||||
fireEvent.click(screen.getByRole('dialog'));
|
||||
|
||||
expect(defaultProps.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('has proper accessibility attributes', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
expect(dialog).toHaveAttribute('aria-labelledby');
|
||||
expect(dialog).toHaveAttribute('aria-describedby');
|
||||
});
|
||||
|
||||
it('focuses Cancel button by default', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
expect(cancelButton).toHaveFocus();
|
||||
});
|
||||
|
||||
it('has proper button types', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
const deleteButton = screen.getByText('Delete');
|
||||
|
||||
expect(cancelButton).toHaveAttribute('type', 'button');
|
||||
expect(deleteButton).toHaveAttribute('type', 'button');
|
||||
});
|
||||
});
|
||||
@@ -1,407 +0,0 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest'
|
||||
import React from 'react'
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock('../../../src/contexts/ToastContext', () => ({
|
||||
useToast: () => ({
|
||||
showToast: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/services/projectService', () => ({
|
||||
projectService: {
|
||||
getProjectDocuments: vi.fn().mockResolvedValue([]),
|
||||
deleteDocument: vi.fn().mockResolvedValue(undefined),
|
||||
updateDocument: vi.fn().mockResolvedValue({ id: 'doc-1', title: 'Updated' }),
|
||||
getDocument: vi.fn().mockResolvedValue({ id: 'doc-1', title: 'Document 1' })
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/services/knowledgeBaseService', () => ({
|
||||
knowledgeBaseService: {
|
||||
getItems: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}))
|
||||
|
||||
// Create a minimal DocsTab component for testing
|
||||
const DocsTabTest = () => {
|
||||
const [documents, setDocuments] = React.useState([
|
||||
{
|
||||
id: 'doc-1',
|
||||
title: 'Document 1',
|
||||
content: { type: 'prp' },
|
||||
document_type: 'prp',
|
||||
updated_at: '2025-07-30T12:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
title: 'Document 2',
|
||||
content: { type: 'technical' },
|
||||
document_type: 'technical',
|
||||
updated_at: '2025-07-30T13:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'doc-3',
|
||||
title: 'Document 3',
|
||||
content: { type: 'business' },
|
||||
document_type: 'business',
|
||||
updated_at: '2025-07-30T14:00:00Z'
|
||||
}
|
||||
])
|
||||
|
||||
const [selectedDocument, setSelectedDocument] = React.useState(documents[0])
|
||||
const { showToast } = { showToast: vi.fn() }
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700">
|
||||
{documents.map(doc => (
|
||||
<div
|
||||
key={doc.id}
|
||||
data-testid={`document-card-${doc.id}`}
|
||||
className={`flex-shrink-0 w-48 p-4 rounded-lg cursor-pointer ${
|
||||
selectedDocument?.id === doc.id ? 'border-2 border-blue-500' : 'border border-gray-200'
|
||||
}`}
|
||||
onClick={() => setSelectedDocument(doc)}
|
||||
>
|
||||
<div className={`text-xs ${doc.document_type}`}>{doc.document_type}</div>
|
||||
<h4>{doc.title}</h4>
|
||||
{selectedDocument?.id !== doc.id && (
|
||||
<button
|
||||
data-testid={`delete-${doc.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`Delete "${doc.title}"?`)) {
|
||||
setDocuments(prev => prev.filter(d => d.id !== doc.id))
|
||||
if (selectedDocument?.id === doc.id) {
|
||||
setSelectedDocument(documents.find(d => d.id !== doc.id) || null)
|
||||
}
|
||||
showToast('Document deleted', 'success')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
data-testid="new-document-card"
|
||||
className="flex-shrink-0 w-48 h-32 rounded-lg border-2 border-dashed"
|
||||
onClick={() => console.log('New document')}
|
||||
>
|
||||
New Document
|
||||
</div>
|
||||
</div>
|
||||
{selectedDocument && (
|
||||
<div data-testid="selected-document">
|
||||
Selected: {selectedDocument.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('DocsTab Document Cards Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('renders all document cards', () => {
|
||||
render(<DocsTabTest />)
|
||||
|
||||
expect(screen.getByTestId('document-card-doc-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('document-card-doc-2')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('document-card-doc-3')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('new-document-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('shows active state on selected document', () => {
|
||||
render(<DocsTabTest />)
|
||||
|
||||
const doc1 = screen.getByTestId('document-card-doc-1')
|
||||
expect(doc1.className).toContain('border-blue-500')
|
||||
|
||||
const doc2 = screen.getByTestId('document-card-doc-2')
|
||||
expect(doc2.className).not.toContain('border-blue-500')
|
||||
})
|
||||
|
||||
test('switches between documents', () => {
|
||||
render(<DocsTabTest />)
|
||||
|
||||
// Initially doc-1 is selected
|
||||
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 1')
|
||||
|
||||
// Click on doc-2
|
||||
fireEvent.click(screen.getByTestId('document-card-doc-2'))
|
||||
|
||||
// Now doc-2 should be selected
|
||||
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 2')
|
||||
|
||||
// Check active states
|
||||
expect(screen.getByTestId('document-card-doc-1').className).not.toContain('border-blue-500')
|
||||
expect(screen.getByTestId('document-card-doc-2').className).toContain('border-blue-500')
|
||||
})
|
||||
|
||||
test('deletes document with confirmation', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
render(<DocsTabTest />)
|
||||
|
||||
// Click delete on doc-2
|
||||
const deleteButton = screen.getByTestId('delete-doc-2')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Delete "Document 2"?')
|
||||
|
||||
// Document should be removed
|
||||
expect(screen.queryByTestId('document-card-doc-2')).not.toBeInTheDocument()
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('cancels delete when user declines', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
|
||||
render(<DocsTabTest />)
|
||||
|
||||
// Click delete on doc-2
|
||||
const deleteButton = screen.getByTestId('delete-doc-2')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
// Document should still be there
|
||||
expect(screen.getByTestId('document-card-doc-2')).toBeInTheDocument()
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('selects next document when deleting active document', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
render(<DocsTabTest />)
|
||||
|
||||
// doc-1 is initially selected
|
||||
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 1')
|
||||
|
||||
// Switch to doc-2
|
||||
fireEvent.click(screen.getByTestId('document-card-doc-2'))
|
||||
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 2')
|
||||
|
||||
// Switch to doc-1 to delete a non-selected document
|
||||
fireEvent.click(screen.getByTestId('document-card-doc-1'))
|
||||
|
||||
// Delete doc-2 (not currently selected - it should have delete button)
|
||||
const deleteButton = screen.getByTestId('delete-doc-2')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
// Should automatically select another document
|
||||
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document')
|
||||
expect(screen.queryByTestId('document-card-doc-2')).not.toBeInTheDocument()
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('does not show delete button on active card', () => {
|
||||
render(<DocsTabTest />)
|
||||
|
||||
// doc-1 is active, should not have delete button
|
||||
expect(screen.queryByTestId('delete-doc-1')).not.toBeInTheDocument()
|
||||
|
||||
// doc-2 is not active, should have delete button
|
||||
expect(screen.getByTestId('delete-doc-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('horizontal scroll container has correct classes', () => {
|
||||
const { container } = render(<DocsTabTest />)
|
||||
|
||||
const scrollContainer = container.querySelector('.overflow-x-auto')
|
||||
expect(scrollContainer).toBeInTheDocument()
|
||||
expect(scrollContainer?.className).toContain('scrollbar-thin')
|
||||
expect(scrollContainer?.className).toContain('scrollbar-thumb-gray-300')
|
||||
})
|
||||
|
||||
test('document cards maintain fixed width', () => {
|
||||
render(<DocsTabTest />)
|
||||
|
||||
const cards = screen.getAllByTestId(/document-card-doc-/)
|
||||
cards.forEach(card => {
|
||||
expect(card.className).toContain('flex-shrink-0')
|
||||
expect(card.className).toContain('w-48')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DocsTab Document API Integration', () => {
|
||||
test('calls deleteDocument API when deleting a document', async () => {
|
||||
const { projectService } = await import('../../../src/services/projectService')
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
// Create a test component that uses the actual deletion logic
|
||||
const DocsTabWithAPI = () => {
|
||||
const [documents, setDocuments] = React.useState([
|
||||
{ id: 'doc-1', title: 'Document 1', content: {}, document_type: 'prp', updated_at: '2025-07-30' },
|
||||
{ id: 'doc-2', title: 'Document 2', content: {}, document_type: 'spec', updated_at: '2025-07-30' }
|
||||
])
|
||||
const [selectedDocument, setSelectedDocument] = React.useState(documents[0])
|
||||
const project = { id: 'proj-123', title: 'Test Project' }
|
||||
const { showToast } = { showToast: vi.fn() }
|
||||
|
||||
const handleDelete = async (docId: string) => {
|
||||
try {
|
||||
// This mirrors the actual DocsTab deletion logic
|
||||
await projectService.deleteDocument(project.id, docId)
|
||||
setDocuments(prev => prev.filter(d => d.id !== docId))
|
||||
if (selectedDocument?.id === docId) {
|
||||
setSelectedDocument(documents.find(d => d.id !== docId) || null)
|
||||
}
|
||||
showToast('Document deleted', 'success')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete document:', error)
|
||||
showToast('Failed to delete document', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{documents.map(doc => (
|
||||
<div key={doc.id} data-testid={`doc-${doc.id}`}>
|
||||
<span>{doc.title}</span>
|
||||
<button
|
||||
data-testid={`delete-${doc.id}`}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete "${doc.title}"?`)) {
|
||||
handleDelete(doc.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<DocsTabWithAPI />)
|
||||
|
||||
// Click delete button
|
||||
fireEvent.click(screen.getByTestId('delete-doc-2'))
|
||||
|
||||
// Wait for async operations
|
||||
await waitFor(() => {
|
||||
expect(projectService.deleteDocument).toHaveBeenCalledWith('proj-123', 'doc-2')
|
||||
})
|
||||
|
||||
// Verify document is removed from UI
|
||||
expect(screen.queryByTestId('doc-doc-2')).not.toBeInTheDocument()
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('handles deletion API errors gracefully', async () => {
|
||||
const { projectService } = await import('../../../src/services/projectService')
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
// Make deleteDocument reject
|
||||
projectService.deleteDocument = vi.fn().mockRejectedValue(new Error('API Error'))
|
||||
|
||||
const DocsTabWithError = () => {
|
||||
const [documents, setDocuments] = React.useState([
|
||||
{ id: 'doc-1', title: 'Document 1', content: {}, document_type: 'prp', updated_at: '2025-07-30' }
|
||||
])
|
||||
const project = { id: 'proj-123', title: 'Test Project' }
|
||||
const showToast = vi.fn()
|
||||
|
||||
const handleDelete = async (docId: string) => {
|
||||
try {
|
||||
await projectService.deleteDocument(project.id, docId)
|
||||
setDocuments(prev => prev.filter(d => d.id !== docId))
|
||||
showToast('Document deleted', 'success')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete document:', error)
|
||||
showToast('Failed to delete document', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{documents.map(doc => (
|
||||
<div key={doc.id} data-testid={`doc-${doc.id}`}>
|
||||
<button
|
||||
data-testid={`delete-${doc.id}`}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete "${doc.title}"?`)) {
|
||||
handleDelete(doc.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div data-testid="toast-container" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<DocsTabWithError />)
|
||||
|
||||
// Click delete button
|
||||
fireEvent.click(screen.getByTestId('delete-doc-1'))
|
||||
|
||||
// Wait for async operations
|
||||
await waitFor(() => {
|
||||
expect(projectService.deleteDocument).toHaveBeenCalledWith('proj-123', 'doc-1')
|
||||
})
|
||||
|
||||
// Document should still be in UI due to error
|
||||
expect(screen.getByTestId('doc-doc-1')).toBeInTheDocument()
|
||||
|
||||
// Error should be logged
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to delete document:', expect.any(Error))
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('deletion persists after page refresh', async () => {
|
||||
const { projectService } = await import('../../../src/services/projectService')
|
||||
|
||||
// Simulate documents before deletion
|
||||
let mockDocuments = [
|
||||
{ id: 'doc-1', title: 'Document 1', content: {}, document_type: 'prp', updated_at: '2025-07-30' },
|
||||
{ id: 'doc-2', title: 'Document 2', content: {}, document_type: 'spec', updated_at: '2025-07-30' }
|
||||
]
|
||||
|
||||
// First render - before deletion
|
||||
const { rerender } = render(<div data-testid="docs-count">{mockDocuments.length}</div>)
|
||||
expect(screen.getByTestId('docs-count')).toHaveTextContent('2')
|
||||
|
||||
// Mock deleteDocument to also update the mock data
|
||||
projectService.deleteDocument = vi.fn().mockImplementation(async (projectId, docId) => {
|
||||
mockDocuments = mockDocuments.filter(d => d.id !== docId)
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
// Mock the list function to return current state
|
||||
projectService.listProjectDocuments = vi.fn().mockImplementation(async () => {
|
||||
return mockDocuments
|
||||
})
|
||||
|
||||
// Perform deletion
|
||||
await projectService.deleteDocument('proj-123', 'doc-2')
|
||||
|
||||
// Simulate page refresh by re-fetching documents
|
||||
const refreshedDocs = await projectService.listProjectDocuments('proj-123')
|
||||
|
||||
// Re-render with refreshed data
|
||||
rerender(<div data-testid="docs-count">{refreshedDocs.length}</div>)
|
||||
|
||||
// Should only have 1 document after refresh
|
||||
expect(screen.getByTestId('docs-count')).toHaveTextContent('1')
|
||||
expect(refreshedDocs).toHaveLength(1)
|
||||
expect(refreshedDocs[0].id).toBe('doc-1')
|
||||
})
|
||||
})
|
||||
@@ -1,227 +0,0 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import React from 'react'
|
||||
import { DocumentCard, NewDocumentCard } from '../../../src/components/project-tasks/DocumentCard'
|
||||
import type { ProjectDoc } from '../../../src/components/project-tasks/DocumentCard'
|
||||
|
||||
describe('DocumentCard', () => {
|
||||
const mockDocument: ProjectDoc = {
|
||||
id: 'doc-1',
|
||||
title: 'Test Document',
|
||||
content: { test: 'content' },
|
||||
document_type: 'prp',
|
||||
updated_at: '2025-07-30T12:00:00Z',
|
||||
}
|
||||
|
||||
const mockHandlers = {
|
||||
onSelect: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('renders document card with correct content', () => {
|
||||
render(
|
||||
<DocumentCard
|
||||
document={mockDocument}
|
||||
isActive={false}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Document')).toBeInTheDocument()
|
||||
expect(screen.getByText('prp')).toBeInTheDocument()
|
||||
expect(screen.getByText('7/30/2025')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('shows correct icon and color for different document types', () => {
|
||||
const documentTypes = [
|
||||
{ type: 'prp', expectedClass: 'text-blue-600' },
|
||||
{ type: 'technical', expectedClass: 'text-green-600' },
|
||||
{ type: 'business', expectedClass: 'text-purple-600' },
|
||||
{ type: 'meeting_notes', expectedClass: 'text-orange-600' },
|
||||
]
|
||||
|
||||
documentTypes.forEach(({ type, expectedClass }) => {
|
||||
const { container, rerender } = render(
|
||||
<DocumentCard
|
||||
document={{ ...mockDocument, document_type: type }}
|
||||
isActive={false}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const badge = container.querySelector(`.${expectedClass}`)
|
||||
expect(badge).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
test('applies active styles when selected', () => {
|
||||
const { container } = render(
|
||||
<DocumentCard
|
||||
document={mockDocument}
|
||||
isActive={true}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card.className).toContain('border-blue-500')
|
||||
expect(card.className).toContain('scale-105')
|
||||
})
|
||||
|
||||
test('calls onSelect when clicked', () => {
|
||||
render(
|
||||
<DocumentCard
|
||||
document={mockDocument}
|
||||
isActive={false}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const card = screen.getByText('Test Document').closest('div')
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(mockHandlers.onSelect).toHaveBeenCalledWith(mockDocument)
|
||||
})
|
||||
|
||||
test('shows delete button on hover', () => {
|
||||
const { container } = render(
|
||||
<DocumentCard
|
||||
document={mockDocument}
|
||||
isActive={false}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const card = container.firstChild as HTMLElement
|
||||
|
||||
// Delete button should not be visible initially
|
||||
expect(screen.queryByLabelText('Delete Test Document')).not.toBeInTheDocument()
|
||||
|
||||
// Hover over the card
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
// Delete button should now be visible
|
||||
expect(screen.getByLabelText('Delete Test Document')).toBeInTheDocument()
|
||||
|
||||
// Mouse leave
|
||||
fireEvent.mouseLeave(card)
|
||||
|
||||
// Delete button should be hidden again
|
||||
expect(screen.queryByLabelText('Delete Test Document')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('does not show delete button on active card', () => {
|
||||
const { container } = render(
|
||||
<DocumentCard
|
||||
document={mockDocument}
|
||||
isActive={true}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const card = container.firstChild as HTMLElement
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
expect(screen.queryByLabelText('Delete Test Document')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('confirms before deleting', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
const { container } = render(
|
||||
<DocumentCard
|
||||
document={mockDocument}
|
||||
isActive={false}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const card = container.firstChild as HTMLElement
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
const deleteButton = screen.getByLabelText('Delete Test Document')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Delete "Test Document"?')
|
||||
expect(mockHandlers.onDelete).toHaveBeenCalledWith('doc-1')
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('cancels delete when user declines', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
|
||||
const { container } = render(
|
||||
<DocumentCard
|
||||
document={mockDocument}
|
||||
isActive={false}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const card = container.firstChild as HTMLElement
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
const deleteButton = screen.getByLabelText('Delete Test Document')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled()
|
||||
expect(mockHandlers.onDelete).not.toHaveBeenCalled()
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('applies dark mode styles correctly', () => {
|
||||
const { container } = render(
|
||||
<DocumentCard
|
||||
document={mockDocument}
|
||||
isActive={false}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={true}
|
||||
/>
|
||||
)
|
||||
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card.className).toContain('dark:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('NewDocumentCard', () => {
|
||||
test('renders new document card', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<NewDocumentCard onClick={onClick} />)
|
||||
|
||||
expect(screen.getByText('New Document')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('calls onClick when clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<NewDocumentCard onClick={onClick} />)
|
||||
|
||||
const card = screen.getByText('New Document').closest('div')
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,272 +0,0 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
|
||||
// Test the PRP to Markdown conversion logic
|
||||
describe('MilkdownEditor PRP Conversion', () => {
|
||||
// Helper function to format values (extracted from component)
|
||||
const formatValue = (value: any, indent = ''): string => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => `${indent}- ${formatValue(item, indent + ' ')}`).join('\n') + '\n'
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
let result = ''
|
||||
Object.entries(value).forEach(([key, val]) => {
|
||||
const formattedKey = key.replace(/_/g, ' ')
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
|
||||
if (typeof val === 'string' || typeof val === 'number') {
|
||||
result += `${indent}**${formattedKey}:** ${val}\n\n`
|
||||
} else {
|
||||
result += `${indent}### ${formattedKey}\n\n${formatValue(val, indent)}`
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// Simplified version of convertPRPToMarkdown for testing
|
||||
const convertPRPToMarkdown = (content: any, docTitle = 'Test Doc'): string => {
|
||||
let markdown = `# ${content.title || docTitle}\n\n`
|
||||
|
||||
// Metadata section
|
||||
if (content.version || content.author || content.date || content.status) {
|
||||
markdown += `## Metadata\n\n`
|
||||
if (content.version) markdown += `- **Version:** ${content.version}\n`
|
||||
if (content.author) markdown += `- **Author:** ${content.author}\n`
|
||||
if (content.date) markdown += `- **Date:** ${content.date}\n`
|
||||
if (content.status) markdown += `- **Status:** ${content.status}\n`
|
||||
markdown += '\n'
|
||||
}
|
||||
|
||||
// Goal section
|
||||
if (content.goal) {
|
||||
markdown += `## Goal\n\n${content.goal}\n\n`
|
||||
}
|
||||
|
||||
// Why section
|
||||
if (content.why) {
|
||||
markdown += `## Why\n\n`
|
||||
if (Array.isArray(content.why)) {
|
||||
content.why.forEach(item => markdown += `- ${item}\n`)
|
||||
} else {
|
||||
markdown += `${content.why}\n`
|
||||
}
|
||||
markdown += '\n'
|
||||
}
|
||||
|
||||
// What section
|
||||
if (content.what) {
|
||||
markdown += `## What\n\n`
|
||||
if (typeof content.what === 'string') {
|
||||
markdown += `${content.what}\n\n`
|
||||
} else if (content.what.description) {
|
||||
markdown += `${content.what.description}\n\n`
|
||||
|
||||
if (content.what.success_criteria) {
|
||||
markdown += `### Success Criteria\n\n`
|
||||
content.what.success_criteria.forEach((criterion: string) => {
|
||||
markdown += `- [ ] ${criterion}\n`
|
||||
})
|
||||
markdown += '\n'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle all other sections dynamically
|
||||
const handledKeys = [
|
||||
'title', 'version', 'author', 'date', 'status', 'goal', 'why', 'what',
|
||||
'document_type'
|
||||
]
|
||||
|
||||
Object.entries(content).forEach(([key, value]) => {
|
||||
if (!handledKeys.includes(key) && value) {
|
||||
const sectionTitle = key.replace(/_/g, ' ')
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
|
||||
markdown += `## ${sectionTitle}\n\n`
|
||||
markdown += formatValue(value)
|
||||
markdown += '\n'
|
||||
}
|
||||
})
|
||||
|
||||
return markdown
|
||||
}
|
||||
|
||||
test('converts basic PRP structure to markdown', () => {
|
||||
const prp = {
|
||||
title: 'Test PRP',
|
||||
version: '1.0',
|
||||
author: 'Test Author',
|
||||
date: '2025-07-30',
|
||||
status: 'draft',
|
||||
goal: 'Test goal'
|
||||
}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp)
|
||||
|
||||
expect(markdown).toContain('# Test PRP')
|
||||
expect(markdown).toContain('## Metadata')
|
||||
expect(markdown).toContain('- **Version:** 1.0')
|
||||
expect(markdown).toContain('- **Author:** Test Author')
|
||||
expect(markdown).toContain('- **Date:** 2025-07-30')
|
||||
expect(markdown).toContain('- **Status:** draft')
|
||||
expect(markdown).toContain('## Goal\n\nTest goal')
|
||||
})
|
||||
|
||||
test('handles array why section', () => {
|
||||
const prp = {
|
||||
title: 'Test PRP',
|
||||
why: ['Reason 1', 'Reason 2', 'Reason 3']
|
||||
}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp)
|
||||
|
||||
expect(markdown).toContain('## Why')
|
||||
expect(markdown).toContain('- Reason 1')
|
||||
expect(markdown).toContain('- Reason 2')
|
||||
expect(markdown).toContain('- Reason 3')
|
||||
})
|
||||
|
||||
test('handles string why section', () => {
|
||||
const prp = {
|
||||
title: 'Test PRP',
|
||||
why: 'Single reason for the change'
|
||||
}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp)
|
||||
|
||||
expect(markdown).toContain('## Why')
|
||||
expect(markdown).toContain('Single reason for the change')
|
||||
})
|
||||
|
||||
test('handles complex what section with success criteria', () => {
|
||||
const prp = {
|
||||
title: 'Test PRP',
|
||||
what: {
|
||||
description: 'Main description of what we are building',
|
||||
success_criteria: [
|
||||
'Criterion 1',
|
||||
'Criterion 2',
|
||||
'Criterion 3'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp)
|
||||
|
||||
expect(markdown).toContain('## What')
|
||||
expect(markdown).toContain('Main description of what we are building')
|
||||
expect(markdown).toContain('### Success Criteria')
|
||||
expect(markdown).toContain('- [ ] Criterion 1')
|
||||
expect(markdown).toContain('- [ ] Criterion 2')
|
||||
expect(markdown).toContain('- [ ] Criterion 3')
|
||||
})
|
||||
|
||||
test('handles dynamic sections', () => {
|
||||
const prp = {
|
||||
title: 'Test PRP',
|
||||
user_personas: {
|
||||
developer: {
|
||||
name: 'Developer Dan',
|
||||
goals: ['Write clean code', 'Ship features fast']
|
||||
}
|
||||
},
|
||||
technical_requirements: {
|
||||
frontend: 'React 18',
|
||||
backend: 'FastAPI',
|
||||
database: 'PostgreSQL'
|
||||
}
|
||||
}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp)
|
||||
|
||||
expect(markdown).toContain('## User Personas')
|
||||
expect(markdown).toContain('### Developer')
|
||||
expect(markdown).toContain('**Name:** Developer Dan')
|
||||
expect(markdown).toContain('## Technical Requirements')
|
||||
expect(markdown).toContain('**Frontend:** React 18')
|
||||
expect(markdown).toContain('**Backend:** FastAPI')
|
||||
})
|
||||
|
||||
test('formats nested objects correctly', () => {
|
||||
const value = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: 'Deep value'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatted = formatValue(value)
|
||||
|
||||
expect(formatted).toContain('### Level1')
|
||||
expect(formatted).toContain('### Level2')
|
||||
expect(formatted).toContain('**Level3:** Deep value')
|
||||
})
|
||||
|
||||
test('formats arrays correctly', () => {
|
||||
const value = ['Item 1', 'Item 2', { nested: 'Nested item' }]
|
||||
|
||||
const formatted = formatValue(value)
|
||||
|
||||
expect(formatted).toContain('- Item 1')
|
||||
expect(formatted).toContain('- Item 2')
|
||||
expect(formatted).toContain('**Nested:** Nested item')
|
||||
})
|
||||
|
||||
test('handles empty content', () => {
|
||||
const prp = {}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp, 'Default Title')
|
||||
|
||||
expect(markdown).toBe('# Default Title\n\n')
|
||||
})
|
||||
|
||||
test('skips null and undefined values', () => {
|
||||
const prp = {
|
||||
title: 'Test PRP',
|
||||
null_field: null,
|
||||
undefined_field: undefined,
|
||||
empty_string: '',
|
||||
valid_field: 'Valid content'
|
||||
}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp)
|
||||
|
||||
expect(markdown).not.toContain('Null Field')
|
||||
expect(markdown).not.toContain('Undefined Field')
|
||||
expect(markdown).not.toContain('Empty String')
|
||||
expect(markdown).toContain('## Valid Field')
|
||||
expect(markdown).toContain('Valid content')
|
||||
})
|
||||
|
||||
test('converts snake_case to Title Case', () => {
|
||||
const prp = {
|
||||
title: 'Test PRP',
|
||||
user_journey_mapping: 'Content',
|
||||
api_endpoint_design: 'More content'
|
||||
}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp)
|
||||
|
||||
expect(markdown).toContain('## User Journey Mapping')
|
||||
expect(markdown).toContain('## Api Endpoint Design')
|
||||
})
|
||||
|
||||
test('preserves markdown formatting in content', () => {
|
||||
const prp = {
|
||||
title: 'Test PRP',
|
||||
description: '**Bold text** and *italic text* with `code`'
|
||||
}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp)
|
||||
|
||||
expect(markdown).toContain('**Bold text** and *italic text* with `code`')
|
||||
})
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('TasksTab Drag and Drop Integration', () => {
|
||||
it('should properly manage movingTaskIds during drag operations', () => {
|
||||
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
|
||||
const fileContent = readFileSync(tasksTabPath, 'utf-8');
|
||||
|
||||
// Check that moveTask adds task to movingTaskIds
|
||||
expect(fileContent).toContain('setMovingTaskIds(prev => new Set([...prev, taskId]))');
|
||||
|
||||
// Check that moveTask removes task from movingTaskIds in finally block
|
||||
expect(fileContent).toContain('finally {');
|
||||
expect(fileContent).toMatch(/finally\s*{\s*\/\/\s*Remove from loading set\s*setMovingTaskIds/);
|
||||
|
||||
// Check that the cleanup happens even on error
|
||||
const moveTaskMatch = fileContent.match(/const moveTask[\s\S]*?\n{2}\};/);
|
||||
expect(moveTaskMatch).toBeTruthy();
|
||||
if (moveTaskMatch) {
|
||||
const moveTaskFunction = moveTaskMatch[0];
|
||||
expect(moveTaskFunction).toContain('try {');
|
||||
expect(moveTaskFunction).toContain('catch (error)');
|
||||
expect(moveTaskFunction).toContain('finally {');
|
||||
}
|
||||
});
|
||||
|
||||
it('should pass movingTaskIds to TaskBoardView', () => {
|
||||
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
|
||||
const fileContent = readFileSync(tasksTabPath, 'utf-8');
|
||||
|
||||
// Check that movingTaskIds is passed to TaskBoardView
|
||||
expect(fileContent).toContain('movingTaskIds={movingTaskIds}');
|
||||
});
|
||||
|
||||
it('should handle task completion through moveTask', () => {
|
||||
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
|
||||
const fileContent = readFileSync(tasksTabPath, 'utf-8');
|
||||
|
||||
// Check that completeTask calls moveTask
|
||||
expect(fileContent).toMatch(/completeTask.*moveTask\(taskId, 'done'\)/s);
|
||||
});
|
||||
|
||||
it('should have optimistic updates in moveTask', () => {
|
||||
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
|
||||
const fileContent = readFileSync(tasksTabPath, 'utf-8');
|
||||
|
||||
// Check for optimistic update comment and implementation
|
||||
expect(fileContent).toContain('// Optimistically update UI for immediate feedback');
|
||||
expect(fileContent).toContain('setTasks(prev => prev.map(task =>');
|
||||
});
|
||||
|
||||
it('should revert on error as indicated by comment', () => {
|
||||
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
|
||||
const fileContent = readFileSync(tasksTabPath, 'utf-8');
|
||||
|
||||
// Check for revert comment
|
||||
expect(fileContent).toContain('// Revert optimistic update - polling will sync correct state');
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import React from 'react'
|
||||
import { PRPViewer } from '../../../src/components/prp/PRPViewer'
|
||||
import type { PRPContent } from '../../../src/components/prp/types/prp.types'
|
||||
|
||||
describe('PRPViewer', () => {
|
||||
const mockContent: PRPContent = {
|
||||
title: 'Test PRP',
|
||||
version: '1.0',
|
||||
author: 'Test Author',
|
||||
date: '2025-07-30',
|
||||
status: 'draft',
|
||||
goal: 'Test goal with [Image #1] placeholder',
|
||||
why: 'Test reason with [Image #2] reference',
|
||||
what: {
|
||||
description: 'Test description with [Image #3] and [Image #4]',
|
||||
success_criteria: ['Criterion 1', 'Criterion 2 with [Image #5]']
|
||||
},
|
||||
context: {
|
||||
background: 'Background with [Image #6]',
|
||||
objectives: ['Objective 1', 'Objective 2']
|
||||
}
|
||||
}
|
||||
|
||||
test('renders without [Image #N] placeholders', () => {
|
||||
render(<PRPViewer content={mockContent} />)
|
||||
|
||||
// Check that [Image #N] placeholders are replaced
|
||||
expect(screen.queryByText(/\[Image #\d+\]/)).not.toBeInTheDocument()
|
||||
|
||||
// Check that content is present
|
||||
expect(screen.getByText(/Test goal/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Test reason/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Test description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('processes nested content with image placeholders', () => {
|
||||
const { container } = render(<PRPViewer content={mockContent} />)
|
||||
|
||||
// Check that the content has been processed
|
||||
const htmlContent = container.innerHTML
|
||||
|
||||
// Should not contain raw [Image #N] text
|
||||
expect(htmlContent).not.toMatch(/\[Image #\d+\]/)
|
||||
|
||||
// Should contain processed markdown image syntax
|
||||
expect(htmlContent).toContain('Image 1')
|
||||
expect(htmlContent).toContain('Image 2')
|
||||
})
|
||||
|
||||
test('renders metadata section correctly', () => {
|
||||
render(<PRPViewer content={mockContent} />)
|
||||
|
||||
expect(screen.getByText('Test PRP')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Author')).toBeInTheDocument()
|
||||
expect(screen.getByText('draft')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('handles empty content gracefully', () => {
|
||||
render(<PRPViewer content={{} as PRPContent} />)
|
||||
|
||||
// Should render without errors
|
||||
expect(screen.getByText(/Metadata/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('handles null content', () => {
|
||||
render(<PRPViewer content={null as any} />)
|
||||
|
||||
expect(screen.getByText('No PRP content available')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('handles string content in objects', () => {
|
||||
const stringContent = {
|
||||
title: 'String Test',
|
||||
description: 'This has [Image #1] in it'
|
||||
}
|
||||
|
||||
render(<PRPViewer content={stringContent as any} />)
|
||||
|
||||
// Should process the image placeholder
|
||||
expect(screen.queryByText(/\[Image #1\]/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/This has/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('handles array content with image placeholders', () => {
|
||||
const arrayContent = {
|
||||
title: 'Array Test',
|
||||
items: [
|
||||
'Item 1 with [Image #1]',
|
||||
'Item 2 with [Image #2]',
|
||||
{ nested: 'Nested with [Image #3]' }
|
||||
]
|
||||
}
|
||||
|
||||
render(<PRPViewer content={arrayContent as any} />)
|
||||
|
||||
// Should process all image placeholders
|
||||
expect(screen.queryByText(/\[Image #\d+\]/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders collapsible sections', () => {
|
||||
render(<PRPViewer content={mockContent} />)
|
||||
|
||||
// Find collapsible sections
|
||||
const contextSection = screen.getByText('Context').closest('div')
|
||||
expect(contextSection).toBeInTheDocument()
|
||||
|
||||
// Should have chevron icon for collapsible sections
|
||||
const chevrons = screen.getAllByTestId('chevron-icon')
|
||||
expect(chevrons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('toggles section visibility', () => {
|
||||
render(<PRPViewer content={mockContent} />)
|
||||
|
||||
// Find a collapsible section header
|
||||
const contextHeader = screen.getByText('Context').closest('button')
|
||||
|
||||
// The section should be visible initially (defaultOpen for first 5 sections)
|
||||
expect(screen.getByText(/Background with/)).toBeInTheDocument()
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(contextHeader!)
|
||||
|
||||
// Content should be hidden
|
||||
expect(screen.queryByText(/Background with/)).not.toBeInTheDocument()
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(contextHeader!)
|
||||
|
||||
// Content should be visible again
|
||||
expect(screen.getByText(/Background with/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('applies dark mode styles', () => {
|
||||
const { container } = render(<PRPViewer content={mockContent} isDarkMode={true} />)
|
||||
|
||||
const viewer = container.querySelector('.prp-viewer')
|
||||
expect(viewer?.className).toContain('dark')
|
||||
})
|
||||
|
||||
test('uses section overrides when provided', () => {
|
||||
const CustomSection = ({ data, title }: any) => (
|
||||
<div data-testid="custom-section">
|
||||
<h3>{title}</h3>
|
||||
<p>Custom rendering of: {JSON.stringify(data)}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
const overrides = {
|
||||
context: CustomSection
|
||||
}
|
||||
|
||||
render(<PRPViewer content={mockContent} sectionOverrides={overrides} />)
|
||||
|
||||
expect(screen.getByTestId('custom-section')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Custom rendering of/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('sorts sections by group', () => {
|
||||
const complexContent = {
|
||||
title: 'Complex PRP',
|
||||
// These should be sorted in a specific order
|
||||
validation_gates: { test: 'validation' },
|
||||
user_personas: { test: 'personas' },
|
||||
context: { test: 'context' },
|
||||
user_flows: { test: 'flows' },
|
||||
success_metrics: { test: 'metrics' }
|
||||
}
|
||||
|
||||
const { container } = render(<PRPViewer content={complexContent as any} />)
|
||||
|
||||
// Get all section titles in order
|
||||
const sectionTitles = Array.from(
|
||||
container.querySelectorAll('h3')
|
||||
).map(el => el.textContent)
|
||||
|
||||
// Context should come before personas
|
||||
const contextIndex = sectionTitles.findIndex(t => t?.includes('Context'))
|
||||
const personasIndex = sectionTitles.findIndex(t => t?.includes('Personas'))
|
||||
|
||||
expect(contextIndex).toBeLessThan(personasIndex)
|
||||
})
|
||||
})
|
||||
@@ -1,206 +0,0 @@
|
||||
/**
|
||||
* Tests for API configuration port requirements
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
describe('API Configuration', () => {
|
||||
let originalEnv: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original environment
|
||||
originalEnv = { ...import.meta.env };
|
||||
|
||||
// Clear the module cache to ensure fresh imports
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
Object.keys(import.meta.env).forEach(key => {
|
||||
delete (import.meta.env as any)[key];
|
||||
});
|
||||
Object.assign(import.meta.env, originalEnv);
|
||||
});
|
||||
|
||||
describe('getApiUrl', () => {
|
||||
it('should use VITE_API_URL when provided', async () => {
|
||||
// Set VITE_API_URL
|
||||
(import.meta.env as any).VITE_API_URL = 'http://custom-api:9999';
|
||||
|
||||
const { getApiUrl } = await import('../../src/config/api');
|
||||
expect(getApiUrl()).toBe('http://custom-api:9999');
|
||||
});
|
||||
|
||||
it('should return empty string in production mode', async () => {
|
||||
// Set production mode
|
||||
(import.meta.env as any).PROD = true;
|
||||
|
||||
// It should not use VITE_API_URL
|
||||
(import.meta.env as any).VITE_API_URL = 'http://custom-api:9999';
|
||||
|
||||
const { getApiUrl } = await import('../../src/config/api');
|
||||
expect(getApiUrl()).toBe('');
|
||||
});
|
||||
|
||||
it('should use default port 8181 when no port environment variables are set in development', async () => {
|
||||
// Development mode without any port variables
|
||||
delete (import.meta.env as any).PROD;
|
||||
delete (import.meta.env as any).VITE_API_URL;
|
||||
delete (import.meta.env as any).VITE_ARCHON_SERVER_PORT;
|
||||
delete (import.meta.env as any).VITE_PORT;
|
||||
delete (import.meta.env as any).ARCHON_SERVER_PORT;
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'http:',
|
||||
hostname: 'localhost'
|
||||
},
|
||||
writable: true
|
||||
});
|
||||
|
||||
const { getApiUrl } = await import('../../src/config/api');
|
||||
|
||||
expect(getApiUrl()).toBe('http://localhost:8181');
|
||||
});
|
||||
|
||||
it('should use VITE_ARCHON_SERVER_PORT when set in development', async () => {
|
||||
// Development mode with custom port via VITE_ prefix
|
||||
delete (import.meta.env as any).PROD;
|
||||
delete (import.meta.env as any).VITE_API_URL;
|
||||
(import.meta.env as any).VITE_ARCHON_SERVER_PORT = '9191';
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'http:',
|
||||
hostname: 'localhost'
|
||||
},
|
||||
writable: true
|
||||
});
|
||||
|
||||
const { getApiUrl } = await import('../../src/config/api');
|
||||
expect(getApiUrl()).toBe('http://localhost:9191');
|
||||
});
|
||||
|
||||
it('should use custom port with https protocol', async () => {
|
||||
// Development mode with custom port and https via VITE_ prefix
|
||||
delete (import.meta.env as any).PROD;
|
||||
delete (import.meta.env as any).VITE_API_URL;
|
||||
(import.meta.env as any).VITE_ARCHON_SERVER_PORT = '8443';
|
||||
|
||||
// Mock window.location with https
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'https:',
|
||||
hostname: 'example.com'
|
||||
},
|
||||
writable: true
|
||||
});
|
||||
|
||||
const { getApiUrl } = await import('../../src/config/api');
|
||||
expect(getApiUrl()).toBe('https://example.com:8443');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Port validation', () => {
|
||||
it('should handle various port formats', async () => {
|
||||
const testCases = [
|
||||
{ port: '80', expected: 'http://localhost:80' },
|
||||
{ port: '443', expected: 'http://localhost:443' },
|
||||
{ port: '3000', expected: 'http://localhost:3000' },
|
||||
{ port: '8080', expected: 'http://localhost:8080' },
|
||||
{ port: '65535', expected: 'http://localhost:65535' },
|
||||
];
|
||||
|
||||
for (const { port, expected } of testCases) {
|
||||
vi.resetModules();
|
||||
delete (import.meta.env as any).PROD;
|
||||
delete (import.meta.env as any).VITE_API_URL;
|
||||
(import.meta.env as any).VITE_ARCHON_SERVER_PORT = port;
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'http:',
|
||||
hostname: 'localhost'
|
||||
},
|
||||
writable: true
|
||||
});
|
||||
|
||||
const { getApiUrl } = await import('../../src/config/api');
|
||||
expect(getApiUrl()).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP Client Service Configuration', () => {
|
||||
let originalEnv: any;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...import.meta.env };
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.keys(import.meta.env).forEach(key => {
|
||||
delete (import.meta.env as any)[key];
|
||||
});
|
||||
Object.assign(import.meta.env, originalEnv);
|
||||
});
|
||||
|
||||
it('should throw error when ARCHON_MCP_PORT is not set', async () => {
|
||||
delete (import.meta.env as any).ARCHON_MCP_PORT;
|
||||
|
||||
const { mcpClientService } = await import('../../src/services/mcpClientService');
|
||||
|
||||
await expect(mcpClientService.createArchonClient()).rejects.toThrow('ARCHON_MCP_PORT environment variable is required');
|
||||
await expect(mcpClientService.createArchonClient()).rejects.toThrow('Default value: 8051');
|
||||
});
|
||||
|
||||
it('should use ARCHON_MCP_PORT when set', async () => {
|
||||
(import.meta.env as any).ARCHON_MCP_PORT = '9051';
|
||||
(import.meta.env as any).ARCHON_SERVER_PORT = '8181';
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'http:',
|
||||
hostname: 'localhost'
|
||||
},
|
||||
writable: true
|
||||
});
|
||||
|
||||
// Mock the API call
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 'test-id',
|
||||
name: 'Archon',
|
||||
transport_type: 'http',
|
||||
connection_status: 'connected'
|
||||
})
|
||||
});
|
||||
|
||||
const { mcpClientService } = await import('../../src/services/mcpClientService');
|
||||
|
||||
try {
|
||||
await mcpClientService.createArchonClient();
|
||||
|
||||
// Verify the fetch was called with the correct URL
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/mcp/clients'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('9051')
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
// If it fails due to actual API call, that's okay for this test
|
||||
// We're mainly testing that it constructs the URL correctly
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,236 +0,0 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import React from 'react'
|
||||
import { credentialsService } from '../src/services/credentialsService'
|
||||
|
||||
describe('Error Handling Tests', () => {
|
||||
test('api error simulation', () => {
|
||||
const MockApiComponent = () => {
|
||||
const [error, setError] = React.useState('')
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Simulate API error
|
||||
throw new Error('Network error')
|
||||
} catch (err) {
|
||||
setError('Failed to load data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={fetchData}>Load Data</button>
|
||||
{loading && <div>Loading...</div>}
|
||||
{error && <div role="alert">{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockApiComponent />)
|
||||
|
||||
fireEvent.click(screen.getByText('Load Data'))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Failed to load data')
|
||||
})
|
||||
|
||||
test('timeout error simulation', () => {
|
||||
const MockTimeoutComponent = () => {
|
||||
const [status, setStatus] = React.useState('idle')
|
||||
|
||||
const handleTimeout = () => {
|
||||
setStatus('loading')
|
||||
setTimeout(() => {
|
||||
setStatus('timeout')
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={handleTimeout}>Start Request</button>
|
||||
{status === 'loading' && <div>Loading...</div>}
|
||||
{status === 'timeout' && <div role="alert">Request timed out</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockTimeoutComponent />)
|
||||
|
||||
fireEvent.click(screen.getByText('Start Request'))
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
|
||||
// Wait for timeout
|
||||
setTimeout(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Request timed out')
|
||||
}, 150)
|
||||
})
|
||||
|
||||
test('form validation errors', () => {
|
||||
const MockFormErrors = () => {
|
||||
const [values, setValues] = React.useState({ name: '', email: '' })
|
||||
const [errors, setErrors] = React.useState<string[]>([])
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: string[] = []
|
||||
if (!values.name) newErrors.push('Name is required')
|
||||
if (!values.email) newErrors.push('Email is required')
|
||||
if (values.email && !values.email.includes('@')) {
|
||||
newErrors.push('Invalid email format')
|
||||
}
|
||||
setErrors(newErrors)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
placeholder="Name"
|
||||
value={values.name}
|
||||
onChange={(e) => setValues({ ...values, name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
placeholder="Email"
|
||||
value={values.email}
|
||||
onChange={(e) => setValues({ ...values, email: e.target.value })}
|
||||
/>
|
||||
<button onClick={validate}>Submit</button>
|
||||
{errors.length > 0 && (
|
||||
<div role="alert">
|
||||
{errors.map((error, index) => (
|
||||
<div key={index}>{error}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockFormErrors />)
|
||||
|
||||
// Submit empty form
|
||||
fireEvent.click(screen.getByText('Submit'))
|
||||
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveTextContent('Name is required')
|
||||
expect(alert).toHaveTextContent('Email is required')
|
||||
})
|
||||
|
||||
test('connection error recovery', () => {
|
||||
const MockConnection = () => {
|
||||
const [connected, setConnected] = React.useState(true)
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
const handleDisconnect = () => {
|
||||
setConnected(false)
|
||||
setError('Connection lost')
|
||||
}
|
||||
|
||||
const handleReconnect = () => {
|
||||
setConnected(true)
|
||||
setError('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Status: {connected ? 'Connected' : 'Disconnected'}</div>
|
||||
{error && <div role="alert">{error}</div>}
|
||||
<button onClick={handleDisconnect}>Simulate Disconnect</button>
|
||||
<button onClick={handleReconnect}>Reconnect</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockConnection />)
|
||||
|
||||
expect(screen.getByText('Status: Connected')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Simulate Disconnect'))
|
||||
expect(screen.getByText('Status: Disconnected')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Connection lost')
|
||||
|
||||
fireEvent.click(screen.getByText('Reconnect'))
|
||||
expect(screen.getByText('Status: Connected')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('user friendly error messages', () => {
|
||||
const MockErrorMessages = () => {
|
||||
const [errorType, setErrorType] = React.useState('')
|
||||
|
||||
const getErrorMessage = (type: string) => {
|
||||
switch (type) {
|
||||
case '401':
|
||||
return 'Please log in to continue'
|
||||
case '403':
|
||||
return "You don't have permission to access this"
|
||||
case '404':
|
||||
return "We couldn't find what you're looking for"
|
||||
case '500':
|
||||
return 'Something went wrong on our end'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setErrorType('401')}>401 Error</button>
|
||||
<button onClick={() => setErrorType('403')}>403 Error</button>
|
||||
<button onClick={() => setErrorType('404')}>404 Error</button>
|
||||
<button onClick={() => setErrorType('500')}>500 Error</button>
|
||||
{errorType && (
|
||||
<div role="alert">{getErrorMessage(errorType)}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockErrorMessages />)
|
||||
|
||||
fireEvent.click(screen.getByText('401 Error'))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Please log in to continue')
|
||||
|
||||
fireEvent.click(screen.getByText('404 Error'))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent("We couldn't find what you're looking for")
|
||||
|
||||
fireEvent.click(screen.getByText('500 Error'))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong on our end')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CredentialsService Error Handling', () => {
|
||||
const originalFetch = global.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn() as any
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
})
|
||||
|
||||
test('should handle network errors with context', async () => {
|
||||
const mockError = new Error('Network request failed')
|
||||
;(global.fetch as any).mockRejectedValueOnce(mockError)
|
||||
|
||||
await expect(credentialsService.createCredential({
|
||||
key: 'TEST_KEY',
|
||||
value: 'test',
|
||||
is_encrypted: false,
|
||||
category: 'test'
|
||||
})).rejects.toThrow(/Network error while creating credential 'test_key'/)
|
||||
})
|
||||
|
||||
test('should preserve context in error messages', async () => {
|
||||
const mockError = new Error('database error')
|
||||
;(global.fetch as any).mockRejectedValueOnce(mockError)
|
||||
|
||||
await expect(credentialsService.updateCredential({
|
||||
key: 'OPENAI_API_KEY',
|
||||
value: 'sk-test',
|
||||
is_encrypted: true,
|
||||
category: 'api_keys'
|
||||
})).rejects.toThrow(/Updating credential 'OPENAI_API_KEY' failed/)
|
||||
})
|
||||
})
|
||||
@@ -1,246 +0,0 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { usePolling } from '../../src/hooks/usePolling';
|
||||
|
||||
describe('usePolling Hook - REAL Tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
// Mock fetch globally
|
||||
global.fetch = vi.fn();
|
||||
// Reset document visibility state
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
value: false,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'visible',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should poll the endpoint at specified intervals', async () => {
|
||||
const mockResponse = { data: 'test' };
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockResponse,
|
||||
headers: new Headers({ 'etag': '"v1"' })
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePolling('/api/test', { interval: 1000 })
|
||||
);
|
||||
|
||||
// Initially loading
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.data).toBeUndefined();
|
||||
|
||||
// Wait for first fetch to complete
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(mockResponse);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
}, { timeout: 5000 });
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance timer to trigger second poll
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
}, { timeout: 5000 });
|
||||
|
||||
// Check ETag header was sent on second request
|
||||
const secondCall = (global.fetch as any).mock.calls[1];
|
||||
expect(secondCall[1].headers['If-None-Match']).toBe('"v1"');
|
||||
}, 15000);
|
||||
|
||||
it('should handle 304 Not Modified responses correctly', async () => {
|
||||
const initialData = { value: 'initial' };
|
||||
|
||||
// First call returns data
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => initialData,
|
||||
headers: new Headers({ 'etag': '"v1"' })
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePolling('/api/test', { interval: 1000 })
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(initialData);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
}, { timeout: 5000 });
|
||||
|
||||
// Second call returns 304 Not Modified
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 304,
|
||||
json: async () => null,
|
||||
headers: new Headers({ 'etag': '"v1"' })
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
}, { timeout: 5000 });
|
||||
|
||||
// Data should remain unchanged after 304
|
||||
expect(result.current.data).toEqual(initialData);
|
||||
}, 15000);
|
||||
|
||||
it('should pause polling when tab becomes inactive', async () => {
|
||||
// This test verifies that polling stops when the tab is hidden
|
||||
// The hook behavior is complex due to multiple useEffect hooks
|
||||
// so we'll just verify the key behavior: no excessive polling when hidden
|
||||
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: 'test' }),
|
||||
headers: new Headers()
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePolling('/api/test', { interval: 1000 }));
|
||||
|
||||
// Wait for initial fetch
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual({ data: 'test' });
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
}, { timeout: 5000 });
|
||||
|
||||
// Clear the mock to start fresh
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
await act(async () => {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'hidden',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
value: true,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
// Advance timers significantly while hidden
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// Should have minimal or no calls while hidden (allowing for edge cases)
|
||||
const hiddenCallCount = (global.fetch as any).mock.calls.length;
|
||||
expect(hiddenCallCount).toBeLessThanOrEqual(1);
|
||||
|
||||
// Simulate tab becoming visible again
|
||||
await act(async () => {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'visible',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
value: false,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
// Should trigger immediate refetch when becoming visible
|
||||
await waitFor(() => {
|
||||
expect((global.fetch as any).mock.calls.length).toBeGreaterThan(hiddenCallCount);
|
||||
}, { timeout: 5000 });
|
||||
}, 15000);
|
||||
|
||||
it('should handle errors and retry with backoff', async () => {
|
||||
// First call fails
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePolling('/api/test', { interval: 1000 })
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
expect(result.current.error?.message).toBe('Network error');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
}, { timeout: 5000 });
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call succeeds
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: 'recovered' }),
|
||||
headers: new Headers()
|
||||
});
|
||||
|
||||
// Advance timer for retry
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual({ data: 'recovered' });
|
||||
expect(result.current.error).toBeNull();
|
||||
}, { timeout: 5000 });
|
||||
}, 15000);
|
||||
|
||||
it('should cleanup on unmount', async () => {
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: 'test' }),
|
||||
headers: new Headers()
|
||||
});
|
||||
|
||||
const { unmount, result } = renderHook(() =>
|
||||
usePolling('/api/test', { interval: 1000 })
|
||||
);
|
||||
|
||||
// Wait for initial fetch to complete
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
}, { timeout: 5000 });
|
||||
|
||||
// Clear any pending timers before unmount
|
||||
vi.clearAllTimers();
|
||||
|
||||
unmount();
|
||||
|
||||
// Reset mocks to clear call count
|
||||
const callCountBeforeAdvance = (global.fetch as any).mock.calls.length;
|
||||
|
||||
// Advance timers after unmount
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// No additional calls should be made after unmount
|
||||
expect((global.fetch as any).mock.calls.length).toBe(callCountBeforeAdvance);
|
||||
}, 15000);
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import React from 'react'
|
||||
import { isLmConfigured } from '../src/utils/onboarding'
|
||||
import type { NormalizedCredential } from '../src/utils/onboarding'
|
||||
|
||||
// Mock useNavigate for onboarding page test
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => vi.fn()
|
||||
}))
|
||||
|
||||
describe('Page Load Tests', () => {
|
||||
test('simple page component renders', () => {
|
||||
const MockPage = () => <h1>Projects</h1>
|
||||
render(<MockPage />)
|
||||
expect(screen.getByText('Projects')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('knowledge base mock renders', () => {
|
||||
const MockKnowledgePage = () => <h1>Knowledge Base</h1>
|
||||
render(<MockKnowledgePage />)
|
||||
expect(screen.getByText('Knowledge Base')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('settings mock renders', () => {
|
||||
const MockSettingsPage = () => <h1>Settings</h1>
|
||||
render(<MockSettingsPage />)
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('mcp mock renders', () => {
|
||||
const MockMCPPage = () => <h1>MCP Servers</h1>
|
||||
render(<MockMCPPage />)
|
||||
expect(screen.getByText('MCP Servers')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('tasks mock renders', () => {
|
||||
const MockTasksPage = () => (
|
||||
<div>
|
||||
<h1>Tasks</h1>
|
||||
<div>TODO</div>
|
||||
<div>In Progress</div>
|
||||
<div>Done</div>
|
||||
</div>
|
||||
)
|
||||
render(<MockTasksPage />)
|
||||
expect(screen.getByText('Tasks')).toBeInTheDocument()
|
||||
expect(screen.getByText('TODO')).toBeInTheDocument()
|
||||
expect(screen.getByText('In Progress')).toBeInTheDocument()
|
||||
expect(screen.getByText('Done')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('onboarding page renders', () => {
|
||||
const MockOnboardingPage = () => <h1>Welcome to Archon</h1>
|
||||
render(<MockOnboardingPage />)
|
||||
expect(screen.getByText('Welcome to Archon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Onboarding Detection Tests', () => {
|
||||
test('isLmConfigured returns true when provider is openai and OPENAI_API_KEY exists', () => {
|
||||
const ragCreds: NormalizedCredential[] = [
|
||||
{ key: 'LLM_PROVIDER', value: 'openai', category: 'rag_strategy' }
|
||||
]
|
||||
const apiKeyCreds: NormalizedCredential[] = [
|
||||
{ key: 'OPENAI_API_KEY', value: 'sk-test123', category: 'api_keys' }
|
||||
]
|
||||
|
||||
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(true)
|
||||
})
|
||||
|
||||
test('isLmConfigured returns true when provider is openai and OPENAI_API_KEY is encrypted', () => {
|
||||
const ragCreds: NormalizedCredential[] = [
|
||||
{ key: 'LLM_PROVIDER', value: 'openai', category: 'rag_strategy' }
|
||||
]
|
||||
const apiKeyCreds: NormalizedCredential[] = [
|
||||
{ key: 'OPENAI_API_KEY', is_encrypted: true, encrypted_value: 'encrypted_sk-test123', category: 'api_keys' }
|
||||
]
|
||||
|
||||
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(true)
|
||||
})
|
||||
|
||||
test('isLmConfigured returns false when provider is openai and no OPENAI_API_KEY', () => {
|
||||
const ragCreds: NormalizedCredential[] = [
|
||||
{ key: 'LLM_PROVIDER', value: 'openai', category: 'rag_strategy' }
|
||||
]
|
||||
const apiKeyCreds: NormalizedCredential[] = []
|
||||
|
||||
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(false)
|
||||
})
|
||||
|
||||
test('isLmConfigured returns true when provider is ollama regardless of API keys', () => {
|
||||
const ragCreds: NormalizedCredential[] = [
|
||||
{ key: 'LLM_PROVIDER', value: 'ollama', category: 'rag_strategy' }
|
||||
]
|
||||
const apiKeyCreds: NormalizedCredential[] = []
|
||||
|
||||
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(true)
|
||||
})
|
||||
|
||||
test('isLmConfigured returns true when no provider but OPENAI_API_KEY exists', () => {
|
||||
const ragCreds: NormalizedCredential[] = []
|
||||
const apiKeyCreds: NormalizedCredential[] = [
|
||||
{ key: 'OPENAI_API_KEY', value: 'sk-test123', category: 'api_keys' }
|
||||
]
|
||||
|
||||
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(true)
|
||||
})
|
||||
|
||||
test('isLmConfigured returns false when no provider and no OPENAI_API_KEY', () => {
|
||||
const ragCreds: NormalizedCredential[] = []
|
||||
const apiKeyCreds: NormalizedCredential[] = []
|
||||
|
||||
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,124 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('ProjectPage Performance Optimizations', () => {
|
||||
const projectPagePath = join(process.cwd(), 'src/pages/ProjectPage.tsx');
|
||||
const projectServicePath = join(process.cwd(), 'src/services/projectService.ts');
|
||||
|
||||
it('should use batch API call for task counts instead of N+1 queries', () => {
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Verify batch endpoint is being used
|
||||
expect(fileContent).toContain('getTaskCountsForAllProjects');
|
||||
|
||||
// Verify we're NOT using Promise.allSettled for parallel fetching
|
||||
expect(fileContent).not.toContain('Promise.allSettled');
|
||||
|
||||
// Verify single batch API call pattern
|
||||
expect(fileContent).toContain('await projectService.getTaskCountsForAllProjects()');
|
||||
});
|
||||
|
||||
it('should have memoized handleProjectSelect to prevent duplicate calls', () => {
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Check that handleProjectSelect is wrapped with useCallback
|
||||
expect(fileContent).toMatch(/const handleProjectSelect = useCallback\(/);
|
||||
|
||||
// Check for early return if same project
|
||||
expect(fileContent).toContain('if (selectedProject?.id === project.id) return');
|
||||
|
||||
// Check dependency array includes selectedProject?.id
|
||||
expect(fileContent).toMatch(/\}, \[.*selectedProject\?\.id.*\]\)/);
|
||||
});
|
||||
|
||||
it('should implement task counts cache with TTL', () => {
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Check cache ref is defined
|
||||
expect(fileContent).toContain('const taskCountsCache = useRef');
|
||||
|
||||
// Check cache structure includes timestamp
|
||||
expect(fileContent).toContain('timestamp: number');
|
||||
|
||||
// Check cache is checked before API call (5-minute TTL = 300000ms)
|
||||
expect(fileContent).toContain('(now - taskCountsCache.current.timestamp) < 300000');
|
||||
|
||||
// Check cache is updated after successful API call
|
||||
expect(fileContent).toContain('taskCountsCache.current = {');
|
||||
});
|
||||
|
||||
it('should disable polling during project switching and drag operations', () => {
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Check useTaskPolling enabled parameter includes conditions
|
||||
expect(fileContent).toMatch(/enabled:.*!isSwitchingProject.*movingTaskIds\.size === 0/);
|
||||
|
||||
// Verify isSwitchingProject state exists
|
||||
expect(fileContent).toContain('const [isSwitchingProject, setIsSwitchingProject]');
|
||||
});
|
||||
|
||||
it('should have debounce utility implemented', () => {
|
||||
const debouncePath = join(process.cwd(), 'src/utils/debounce.ts');
|
||||
const fileContent = readFileSync(debouncePath, 'utf-8');
|
||||
|
||||
// Check debounce function exists
|
||||
expect(fileContent).toContain('export function debounce');
|
||||
|
||||
// Check it has proper TypeScript types
|
||||
expect(fileContent).toContain('T extends (...args: any[]) => any');
|
||||
|
||||
// Check timeout clearing logic
|
||||
expect(fileContent).toContain('clearTimeout(timeoutId)');
|
||||
});
|
||||
|
||||
it('should apply debouncing to loadTaskCountsForAllProjects', () => {
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Check debounce is imported
|
||||
expect(fileContent).toContain('import { debounce } from "../utils/debounce"');
|
||||
|
||||
// Check debounced version is created
|
||||
expect(fileContent).toContain('const debouncedLoadTaskCounts = useMemo');
|
||||
expect(fileContent).toContain('debounce((projectIds: string[])');
|
||||
|
||||
// Check debounced version is used instead of direct calls
|
||||
expect(fileContent).toContain('debouncedLoadTaskCounts(projectIds)');
|
||||
|
||||
// Verify 1000ms delay
|
||||
expect(fileContent).toContain('}, 1000)');
|
||||
});
|
||||
|
||||
it('should have batch task counts endpoint in backend service', () => {
|
||||
const serviceContent = readFileSync(projectServicePath, 'utf-8');
|
||||
|
||||
// Check the service method exists
|
||||
expect(serviceContent).toContain('async getTaskCountsForAllProjects()');
|
||||
|
||||
// Check it calls the correct endpoint
|
||||
expect(serviceContent).toContain('/api/projects/task-counts');
|
||||
|
||||
// Check return type
|
||||
expect(serviceContent).toContain('Promise<Record<string, TaskCounts>>');
|
||||
});
|
||||
|
||||
it('should not make duplicate API calls on project switch', () => {
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Check that tasks are cleared immediately on switch
|
||||
expect(fileContent).toContain('setTasks([]); // Clear stale tasks immediately');
|
||||
|
||||
// Check loading state is managed properly
|
||||
expect(fileContent).toContain('setIsSwitchingProject(true)');
|
||||
expect(fileContent).toContain('setIsSwitchingProject(false)');
|
||||
});
|
||||
|
||||
it('should have correct import statements for performance utilities', () => {
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Check all necessary React hooks are imported
|
||||
expect(fileContent).toContain('useCallback');
|
||||
expect(fileContent).toContain('useMemo');
|
||||
expect(fileContent).toContain('useRef');
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('ProjectPage Polling Conflict Prevention', () => {
|
||||
it('should have movingTaskIds check in polling useEffect', () => {
|
||||
// Read the actual source file to verify the implementation
|
||||
const projectPagePath = join(process.cwd(), 'src/pages/ProjectPage.tsx');
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Check that movingTaskIds state is declared
|
||||
expect(fileContent).toContain('const [movingTaskIds, setMovingTaskIds] = useState<Set<string>>(new Set())');
|
||||
|
||||
// Check that movingTaskIds is checked before updating tasks
|
||||
expect(fileContent).toContain('if (movingTaskIds.size === 0)');
|
||||
|
||||
// Check that merge logic is present for non-moving tasks
|
||||
expect(fileContent).toContain('if (movingTaskIds.has(task.id))');
|
||||
expect(fileContent).toContain('return task; // Preserve local state for moving tasks');
|
||||
|
||||
// Check that movingTaskIds is in the dependency array
|
||||
expect(fileContent).toMatch(/\}, \[.*movingTaskIds.*\]\)/);
|
||||
});
|
||||
|
||||
it('should pass movingTaskIds props to TasksTab', () => {
|
||||
const projectPagePath = join(process.cwd(), 'src/pages/ProjectPage.tsx');
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Check that movingTaskIds is passed as prop
|
||||
expect(fileContent).toContain('movingTaskIds={movingTaskIds}');
|
||||
expect(fileContent).toContain('setMovingTaskIds={setMovingTaskIds}');
|
||||
});
|
||||
|
||||
it('should have TasksTab accept movingTaskIds props', () => {
|
||||
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
|
||||
const fileContent = readFileSync(tasksTabPath, 'utf-8');
|
||||
|
||||
// Check that TasksTab accepts the props
|
||||
expect(fileContent).toContain('movingTaskIds: Set<string>');
|
||||
expect(fileContent).toContain('setMovingTaskIds: (ids: Set<string>) => void');
|
||||
});
|
||||
});
|
||||
@@ -1,393 +0,0 @@
|
||||
/**
|
||||
* Unit tests for projectService document CRUD operations
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type { Document } from '../../src/services/projectService';
|
||||
|
||||
// Mock fetch globally
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe('projectService Document Operations', () => {
|
||||
let projectService: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset all mocks
|
||||
vi.resetAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
// Import fresh instance of projectService
|
||||
const module = await import('../../src/services/projectService');
|
||||
projectService = module.projectService;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getDocument', () => {
|
||||
const mockDocument: Document = {
|
||||
id: 'doc-123',
|
||||
project_id: 'proj-456',
|
||||
title: 'Test Document',
|
||||
content: { type: 'markdown', text: 'Test content' },
|
||||
document_type: 'prp',
|
||||
metadata: { version: '1.0' },
|
||||
tags: ['test', 'sample'],
|
||||
author: 'test-user',
|
||||
created_at: '2025-08-18T10:00:00Z',
|
||||
updated_at: '2025-08-18T10:00:00Z'
|
||||
};
|
||||
|
||||
it('should successfully fetch a document', async () => {
|
||||
// Mock successful response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ document: mockDocument })
|
||||
});
|
||||
|
||||
const result = await projectService.getDocument('proj-456', 'doc-123');
|
||||
|
||||
expect(result).toEqual(mockDocument);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/projects/proj-456/docs/doc-123',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include projectId in error message when fetch fails', async () => {
|
||||
// Mock failed response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => '{"error": "Document not found"}'
|
||||
});
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(projectService.getDocument('proj-456', 'doc-123')).rejects.toThrow();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to get document doc-123 from project proj-456:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
// Mock network error
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(projectService.getDocument('proj-456', 'doc-123')).rejects.toThrow('Network error');
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to get document doc-123 from project proj-456:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDocument', () => {
|
||||
const mockUpdatedDocument: Document = {
|
||||
id: 'doc-123',
|
||||
project_id: 'proj-456',
|
||||
title: 'Updated Document',
|
||||
content: { type: 'markdown', text: 'Updated content' },
|
||||
document_type: 'prp',
|
||||
metadata: { version: '2.0' },
|
||||
tags: ['updated', 'test'],
|
||||
author: 'test-user',
|
||||
created_at: '2025-08-18T10:00:00Z',
|
||||
updated_at: '2025-08-18T11:00:00Z'
|
||||
};
|
||||
|
||||
const updates = {
|
||||
title: 'Updated Document',
|
||||
content: { type: 'markdown', text: 'Updated content' },
|
||||
tags: ['updated', 'test']
|
||||
};
|
||||
|
||||
it('should successfully update a document', async () => {
|
||||
// Mock successful response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ document: mockUpdatedDocument })
|
||||
});
|
||||
|
||||
const result = await projectService.updateDocument('proj-456', 'doc-123', updates);
|
||||
|
||||
expect(result).toEqual(mockUpdatedDocument);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/projects/proj-456/docs/doc-123',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
body: JSON.stringify(updates)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include projectId in error message when update fails', async () => {
|
||||
// Mock failed response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => '{"error": "Invalid update data"}'
|
||||
});
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(projectService.updateDocument('proj-456', 'doc-123', updates)).rejects.toThrow();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to update document doc-123 in project proj-456:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle partial updates', async () => {
|
||||
const partialUpdate = { title: 'Only Title Updated' };
|
||||
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ document: { ...mockUpdatedDocument, title: 'Only Title Updated' } })
|
||||
});
|
||||
|
||||
const result = await projectService.updateDocument('proj-456', 'doc-123', partialUpdate);
|
||||
|
||||
expect(result.title).toBe('Only Title Updated');
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/projects/proj-456/docs/doc-123',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify(partialUpdate)
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteDocument', () => {
|
||||
it('should successfully delete a document', async () => {
|
||||
// Mock successful response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({})
|
||||
});
|
||||
|
||||
await expect(projectService.deleteDocument('proj-456', 'doc-123')).resolves.toBeUndefined();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/projects/proj-456/docs/doc-123',
|
||||
expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include projectId in error message when deletion fails', async () => {
|
||||
// Mock failed response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: async () => '{"error": "Permission denied"}'
|
||||
});
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(projectService.deleteDocument('proj-456', 'doc-123')).rejects.toThrow();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to delete document doc-123 from project proj-456:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle 404 errors appropriately', async () => {
|
||||
// Mock 404 response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => '{"error": "Document not found"}'
|
||||
});
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(projectService.deleteDocument('proj-456', 'doc-123')).rejects.toThrow();
|
||||
|
||||
// Verify the error is logged with project context
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
const errorLog = consoleSpy.mock.calls[0];
|
||||
expect(errorLog[0]).toContain('proj-456');
|
||||
expect(errorLog[0]).toContain('doc-123');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle network timeouts', async () => {
|
||||
// Mock timeout error
|
||||
const timeoutError = new Error('Request timeout');
|
||||
(global.fetch as any).mockRejectedValueOnce(timeoutError);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(projectService.deleteDocument('proj-456', 'doc-123')).rejects.toThrow('Failed to call API');
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to delete document doc-123 from project proj-456:',
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Request timeout')
|
||||
})
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listProjectDocuments', () => {
|
||||
const mockDocuments: Document[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
project_id: 'proj-456',
|
||||
title: 'Document 1',
|
||||
content: { type: 'markdown', text: 'Content 1' },
|
||||
document_type: 'prp',
|
||||
created_at: '2025-08-18T10:00:00Z',
|
||||
updated_at: '2025-08-18T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
project_id: 'proj-456',
|
||||
title: 'Document 2',
|
||||
content: { type: 'markdown', text: 'Content 2' },
|
||||
document_type: 'spec',
|
||||
created_at: '2025-08-18T11:00:00Z',
|
||||
updated_at: '2025-08-18T11:00:00Z'
|
||||
}
|
||||
];
|
||||
|
||||
it('should successfully list all project documents', async () => {
|
||||
// Mock successful response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ documents: mockDocuments })
|
||||
});
|
||||
|
||||
const result = await projectService.listProjectDocuments('proj-456');
|
||||
|
||||
expect(result).toEqual(mockDocuments);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/projects/proj-456/docs',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no documents exist', async () => {
|
||||
// Mock response with no documents
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ documents: [] })
|
||||
});
|
||||
|
||||
const result = await projectService.listProjectDocuments('proj-456');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle null documents field gracefully', async () => {
|
||||
// Mock response with null documents
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ documents: null })
|
||||
});
|
||||
|
||||
const result = await projectService.listProjectDocuments('proj-456');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDocument', () => {
|
||||
const newDocumentData = {
|
||||
title: 'New Document',
|
||||
content: { type: 'markdown', text: 'New content' },
|
||||
document_type: 'prp',
|
||||
tags: ['new', 'test']
|
||||
};
|
||||
|
||||
const mockCreatedDocument: Document = {
|
||||
id: 'doc-new',
|
||||
project_id: 'proj-456',
|
||||
...newDocumentData,
|
||||
author: 'test-user',
|
||||
created_at: '2025-08-18T12:00:00Z',
|
||||
updated_at: '2025-08-18T12:00:00Z'
|
||||
};
|
||||
|
||||
it('should successfully create a new document', async () => {
|
||||
// Mock successful response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ document: mockCreatedDocument })
|
||||
});
|
||||
|
||||
const result = await projectService.createDocument('proj-456', newDocumentData);
|
||||
|
||||
expect(result).toEqual(mockCreatedDocument);
|
||||
expect(result.id).toBeDefined();
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/projects/proj-456/docs',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
body: JSON.stringify(newDocumentData)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle validation errors', async () => {
|
||||
// Mock validation error response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 422,
|
||||
text: async () => '{"error": "Title is required"}'
|
||||
});
|
||||
|
||||
const invalidData = { content: 'Missing title' };
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(projectService.createDocument('proj-456', invalidData)).rejects.toThrow();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to create document for project proj-456:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,243 +0,0 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import React from 'react'
|
||||
|
||||
describe('User Flow Tests', () => {
|
||||
test('create project flow mock', () => {
|
||||
const MockCreateProject = () => {
|
||||
const [project, setProject] = React.useState('')
|
||||
return (
|
||||
<div>
|
||||
<h1>Create Project</h1>
|
||||
<input
|
||||
placeholder="Project title"
|
||||
value={project}
|
||||
onChange={(e) => setProject(e.target.value)}
|
||||
/>
|
||||
<button>Create</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockCreateProject />)
|
||||
expect(screen.getByText('Create Project')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Project title')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('search functionality mock', () => {
|
||||
const MockSearch = () => {
|
||||
const [query, setQuery] = React.useState('')
|
||||
return (
|
||||
<div>
|
||||
<h1>Search</h1>
|
||||
<input
|
||||
placeholder="Search knowledge base"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{query && <div>Results for: {query}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockSearch />)
|
||||
const input = screen.getByPlaceholderText('Search knowledge base')
|
||||
fireEvent.change(input, { target: { value: 'test query' } })
|
||||
expect(screen.getByText('Results for: test query')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('settings toggle mock', () => {
|
||||
const MockSettings = () => {
|
||||
const [theme, setTheme] = React.useState('light')
|
||||
return (
|
||||
<div>
|
||||
<h1>Settings</h1>
|
||||
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
|
||||
Theme: {theme}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockSettings />)
|
||||
const button = screen.getByText('Theme: light')
|
||||
fireEvent.click(button)
|
||||
expect(screen.getByText('Theme: dark')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('file upload mock', () => {
|
||||
const MockUpload = () => {
|
||||
const [uploaded, setUploaded] = React.useState(false)
|
||||
return (
|
||||
<div>
|
||||
<h1>Upload Documents</h1>
|
||||
<input type="file" onChange={() => setUploaded(true)} data-testid="file-input" />
|
||||
{uploaded && <div>File uploaded successfully</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockUpload />)
|
||||
const input = screen.getByTestId('file-input')
|
||||
fireEvent.change(input)
|
||||
expect(screen.getByText('File uploaded successfully')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('connection status mock', () => {
|
||||
const MockConnection = () => {
|
||||
const [connected, setConnected] = React.useState(true)
|
||||
return (
|
||||
<div>
|
||||
<h1>Connection Status</h1>
|
||||
<div>{connected ? 'Connected' : 'Disconnected'}</div>
|
||||
<button onClick={() => setConnected(!connected)}>
|
||||
Toggle Connection
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockConnection />)
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Toggle Connection'))
|
||||
expect(screen.getByText('Disconnected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('task management mock', () => {
|
||||
const MockTasks = () => {
|
||||
const [tasks, setTasks] = React.useState(['Task 1', 'Task 2'])
|
||||
const addTask = () => setTasks([...tasks, `Task ${tasks.length + 1}`])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Task Management</h1>
|
||||
<button onClick={addTask}>Add Task</button>
|
||||
<ul>
|
||||
{tasks.map((task, index) => (
|
||||
<li key={index}>{task}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockTasks />)
|
||||
expect(screen.getByText('Task 1')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Add Task'))
|
||||
expect(screen.getByText('Task 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('navigation mock', () => {
|
||||
const MockNav = () => {
|
||||
const [currentPage, setCurrentPage] = React.useState('home')
|
||||
return (
|
||||
<div>
|
||||
<nav>
|
||||
<button onClick={() => setCurrentPage('projects')}>Projects</button>
|
||||
<button onClick={() => setCurrentPage('settings')}>Settings</button>
|
||||
</nav>
|
||||
<main>
|
||||
<h1>Current page: {currentPage}</h1>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockNav />)
|
||||
expect(screen.getByText('Current page: home')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(screen.getByText('Current page: projects')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('form validation mock', () => {
|
||||
const MockForm = () => {
|
||||
const [email, setEmail] = React.useState('')
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!email.includes('@')) {
|
||||
setError('Invalid email')
|
||||
} else {
|
||||
setError('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Form Validation</h1>
|
||||
<input
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<button onClick={handleSubmit}>Submit</button>
|
||||
{error && <div role="alert">{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockForm />)
|
||||
const input = screen.getByPlaceholderText('Email')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'invalid' } })
|
||||
fireEvent.click(screen.getByText('Submit'))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Invalid email')
|
||||
})
|
||||
|
||||
test('theme switching mock', () => {
|
||||
const MockTheme = () => {
|
||||
const [isDark, setIsDark] = React.useState(false)
|
||||
return (
|
||||
<div className={isDark ? 'dark' : 'light'}>
|
||||
<h1>Theme Test</h1>
|
||||
<button onClick={() => setIsDark(!isDark)}>
|
||||
Switch to {isDark ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockTheme />)
|
||||
const button = screen.getByText('Switch to Dark')
|
||||
fireEvent.click(button)
|
||||
expect(screen.getByText('Switch to Light')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('data filtering mock', () => {
|
||||
const MockFilter = () => {
|
||||
const [filter, setFilter] = React.useState('')
|
||||
const items = ['Apple', 'Banana', 'Cherry']
|
||||
const filtered = items.filter(item =>
|
||||
item.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Filter Test</h1>
|
||||
<input
|
||||
placeholder="Filter items"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<ul>
|
||||
{filtered.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockFilter />)
|
||||
const input = screen.getByPlaceholderText('Filter items')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'a' } })
|
||||
expect(screen.getByText('Apple')).toBeInTheDocument()
|
||||
expect(screen.getByText('Banana')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Cherry')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,138 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateTaskOrder, calculateReorderPosition, getDefaultTaskOrder } from '../../src/utils/taskOrdering';
|
||||
import { Task } from '../../src/types/project';
|
||||
|
||||
// Mock task factory
|
||||
const createMockTask = (id: string, task_order: number): Task => ({
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
description: '',
|
||||
status: 'todo',
|
||||
assignee: { name: 'Test User', avatar: '' },
|
||||
feature: '',
|
||||
featureColor: '#3b82f6',
|
||||
task_order,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
project_id: 'test-project'
|
||||
});
|
||||
|
||||
describe('taskOrdering utilities', () => {
|
||||
describe('calculateTaskOrder', () => {
|
||||
it('does not mutate existingTasks', () => {
|
||||
const existingTasks = [createMockTask('1', 200), createMockTask('2', 100)];
|
||||
const snapshot = existingTasks.map(t => t.task_order);
|
||||
calculateTaskOrder({ position: 'first', existingTasks });
|
||||
expect(existingTasks.map(t => t.task_order)).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('should return seed value for first task when no existing tasks', () => {
|
||||
const result = calculateTaskOrder({
|
||||
position: 'first',
|
||||
existingTasks: []
|
||||
});
|
||||
expect(result).toBe(65536);
|
||||
});
|
||||
|
||||
it('should calculate first position correctly', () => {
|
||||
const existingTasks = [createMockTask('1', 100), createMockTask('2', 200)];
|
||||
const result = calculateTaskOrder({
|
||||
position: 'first',
|
||||
existingTasks
|
||||
});
|
||||
expect(result).toBe(50); // Math.floor(100 / 2)
|
||||
});
|
||||
|
||||
it('should calculate last position correctly', () => {
|
||||
const existingTasks = [createMockTask('1', 100), createMockTask('2', 200)];
|
||||
const result = calculateTaskOrder({
|
||||
position: 'last',
|
||||
existingTasks
|
||||
});
|
||||
expect(result).toBe(1224); // 200 + 1024
|
||||
});
|
||||
|
||||
it('should calculate between position correctly', () => {
|
||||
const result = calculateTaskOrder({
|
||||
position: 'between',
|
||||
existingTasks: [],
|
||||
beforeTaskOrder: 100,
|
||||
afterTaskOrder: 200
|
||||
});
|
||||
expect(result).toBe(150); // Math.floor((100 + 200) / 2)
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultTaskOrder', () => {
|
||||
it('should return seed value when no existing tasks', () => {
|
||||
const result = getDefaultTaskOrder([]);
|
||||
expect(result).toBe(65536);
|
||||
});
|
||||
|
||||
it('should return first position when existing tasks present', () => {
|
||||
const existingTasks = [createMockTask('1', 100), createMockTask('2', 200)];
|
||||
const result = getDefaultTaskOrder(existingTasks);
|
||||
expect(result).toBe(50); // Math.floor(100 / 2)
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateReorderPosition', () => {
|
||||
const statusTasks = [
|
||||
createMockTask('1', 100),
|
||||
createMockTask('2', 200),
|
||||
createMockTask('3', 300)
|
||||
];
|
||||
|
||||
it('should calculate position for moving to first', () => {
|
||||
const result = calculateReorderPosition(statusTasks, 1, 0);
|
||||
expect(result).toBeLessThan(statusTasks[0].task_order);
|
||||
});
|
||||
|
||||
it('should calculate position for moving to last', () => {
|
||||
const result = calculateReorderPosition(statusTasks, 0, 2);
|
||||
expect(result).toBeGreaterThan(statusTasks[2].task_order);
|
||||
});
|
||||
|
||||
it('should calculate position for moving down within middle (1 -> 2)', () => {
|
||||
const result = calculateReorderPosition(statusTasks, 1, 2);
|
||||
// After excluding moving index 1, insert between 300 and end => should be >300 (or handled by "last" path)
|
||||
expect(result).toBeGreaterThan(statusTasks[2].task_order);
|
||||
});
|
||||
|
||||
it('should calculate position for moving up within middle (2 -> 1)', () => {
|
||||
const result = calculateReorderPosition(statusTasks, 2, 1);
|
||||
// With fixed neighbor calculation, this should work correctly
|
||||
expect(result).toBeGreaterThan(statusTasks[0].task_order); // > 100
|
||||
expect(result).toBeLessThan(statusTasks[1].task_order); // < 200
|
||||
});
|
||||
|
||||
it('should calculate position for moving between items', () => {
|
||||
const result = calculateReorderPosition(statusTasks, 0, 1);
|
||||
// Moving task 0 (order 100) to position 1 should place it before task 1 (order 200)
|
||||
// Since we removed the moving task, it should be between start and 200
|
||||
expect(result).toBeLessThan(statusTasks[1].task_order); // < 200
|
||||
expect(result).toBeGreaterThan(0); // > 0
|
||||
});
|
||||
|
||||
it('should return integer values only', () => {
|
||||
const result1 = calculateReorderPosition(statusTasks, 1, 0);
|
||||
const result2 = calculateReorderPosition(statusTasks, 0, 2);
|
||||
const result3 = calculateReorderPosition(statusTasks, 2, 1);
|
||||
|
||||
expect(Number.isInteger(result1)).toBe(true);
|
||||
expect(Number.isInteger(result2)).toBe(true);
|
||||
expect(Number.isInteger(result3)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle bounds checking correctly', () => {
|
||||
// Test with tasks that have equal order values (edge case)
|
||||
const equalTasks = [
|
||||
createMockTask('1', 100),
|
||||
createMockTask('2', 100)
|
||||
];
|
||||
const result = calculateReorderPosition(equalTasks, 0, 1);
|
||||
expect(Number.isInteger(result)).toBe(true);
|
||||
expect(result).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
archon-ui-main/tests/README.md
Normal file
58
archon-ui-main/tests/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Test Structure
|
||||
|
||||
## Test Organization
|
||||
|
||||
We follow a hybrid testing strategy:
|
||||
|
||||
### Unit Tests (Colocated)
|
||||
Unit tests live next to the code they test in the `src/features` directory:
|
||||
```
|
||||
src/features/projects/
|
||||
├── components/
|
||||
│ ├── ProjectCard.tsx
|
||||
│ └── ProjectCard.test.tsx
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
Tests that cross multiple features/systems:
|
||||
```
|
||||
tests/integration/
|
||||
└── api.integration.test.ts
|
||||
```
|
||||
|
||||
### E2E Tests
|
||||
Full user flow tests:
|
||||
```
|
||||
tests/e2e/
|
||||
└── user-flows.e2e.test.ts
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm run test
|
||||
|
||||
# Run tests in watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Run with coverage
|
||||
npm run test:coverage
|
||||
|
||||
# Run specific test file
|
||||
npx vitest run src/features/ui/hooks/useSmartPolling.test.ts
|
||||
```
|
||||
|
||||
## Test Naming Conventions
|
||||
|
||||
- **Unit tests**: `ComponentName.test.tsx` or `hookName.test.ts`
|
||||
- **Integration tests**: `feature.integration.test.ts`
|
||||
- **E2E tests**: `flow-name.e2e.test.ts`
|
||||
|
||||
## Test Setup
|
||||
|
||||
Global test setup is in `tests/setup.ts` which:
|
||||
- Sets environment variables
|
||||
- Mocks fetch and localStorage
|
||||
- Mocks DOM APIs
|
||||
- Mocks external libraries (lucide-react)
|
||||
@@ -35,17 +35,28 @@ Object.defineProperty(window, 'localStorage', {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
window.HTMLElement.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
// Mock lucide-react icons - create a proxy that returns icon name for any icon
|
||||
vi.mock('lucide-react', () => {
|
||||
return new Proxy({}, {
|
||||
get: (target, prop) => {
|
||||
if (typeof prop === 'string') {
|
||||
return () => prop
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
})
|
||||
// Mock lucide-react icons - simple implementation
|
||||
vi.mock('lucide-react', () => ({
|
||||
Trash2: () => 'Trash2',
|
||||
X: () => 'X',
|
||||
AlertCircle: () => 'AlertCircle',
|
||||
Loader2: () => 'Loader2',
|
||||
BookOpen: () => 'BookOpen',
|
||||
Settings: () => 'Settings',
|
||||
WifiOff: () => 'WifiOff',
|
||||
ChevronDown: () => 'ChevronDown',
|
||||
ChevronRight: () => 'ChevronRight',
|
||||
Plus: () => 'Plus',
|
||||
Search: () => 'Search',
|
||||
Activity: () => 'Activity',
|
||||
CheckCircle2: () => 'CheckCircle2',
|
||||
ListTodo: () => 'ListTodo',
|
||||
MoreHorizontal: () => 'MoreHorizontal',
|
||||
Pin: () => 'Pin',
|
||||
PinOff: () => 'PinOff',
|
||||
Clipboard: () => 'Clipboard',
|
||||
// Add more icons as needed
|
||||
}))
|
||||
|
||||
// Mock ResizeObserver
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
@@ -23,6 +23,6 @@
|
||||
/* Path mapping */
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["src", "test"],
|
||||
"include": ["src", "tests"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
15
archon-ui-main/tsconfig.prod.json
Normal file
15
archon-ui-main/tsconfig.prod.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": [
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/__tests__/**",
|
||||
"**/tests/**",
|
||||
"src/features/testing/**",
|
||||
"test/**",
|
||||
"tests/**",
|
||||
"coverage/**"
|
||||
]
|
||||
}
|
||||
@@ -321,15 +321,18 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './test/setup.ts',
|
||||
setupFiles: './tests/setup.ts',
|
||||
css: true,
|
||||
include: [
|
||||
'src/**/*.{test,spec}.{ts,tsx}', // Tests colocated in features
|
||||
'tests/**/*.{test,spec}.{ts,tsx}' // Tests in tests directory
|
||||
],
|
||||
exclude: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/cypress/**',
|
||||
'**/.{idea,git,cache,output,temp}/**',
|
||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
|
||||
'**/*.test.{ts,tsx}',
|
||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*'
|
||||
],
|
||||
env: {
|
||||
VITE_HOST: host,
|
||||
@@ -340,7 +343,7 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'test/',
|
||||
'tests/',
|
||||
'**/*.d.ts',
|
||||
'**/*.config.*',
|
||||
'**/mockData.ts',
|
||||
|
||||
@@ -8,10 +8,12 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './test/setup.ts',
|
||||
setupFiles: './tests/setup.ts',
|
||||
include: [
|
||||
'test/**/*.test.{ts,tsx}',
|
||||
'test/**/*.spec.{ts,tsx}'
|
||||
'src/**/*.test.{ts,tsx}', // Colocated tests in features
|
||||
'src/**/*.spec.{ts,tsx}',
|
||||
'tests/**/*.test.{ts,tsx}', // Tests in tests directory
|
||||
'tests/**/*.spec.{ts,tsx}'
|
||||
],
|
||||
exclude: ['node_modules', 'dist', '.git', '.cache', 'test.backup', '*.backup/**', 'test-backups'],
|
||||
reporters: ['dot', 'json'],
|
||||
@@ -35,7 +37,7 @@ export default defineConfig({
|
||||
reportOnFailure: true, // Generate coverage reports even when tests fail
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'test/',
|
||||
'tests/',
|
||||
'**/*.d.ts',
|
||||
'**/*.config.*',
|
||||
'**/mockData.ts',
|
||||
|
||||
@@ -45,7 +45,7 @@ graph LR
|
||||
<TabItem value="component" label="React Component">
|
||||
|
||||
```tsx
|
||||
import { ArchonChatPanel } from '@/components/layouts/ArchonChatPanel';
|
||||
import { ArchonChatPanel } from '@/components/agent-chat/ArchonChatPanel';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
|
||||
@@ -1,552 +1,89 @@
|
||||
"""
|
||||
MCP API endpoints for Archon
|
||||
|
||||
Handles:
|
||||
- MCP server lifecycle (start/stop/status)
|
||||
- MCP server configuration management
|
||||
- Tool discovery and testing
|
||||
Provides status and configuration endpoints for the MCP service.
|
||||
The MCP container is managed by docker-compose, not by this API.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import docker
|
||||
from docker.errors import APIError, NotFound
|
||||
from docker.errors import NotFound
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Import unified logging
|
||||
from ..config.logfire_config import api_logger, mcp_logger, safe_set_attribute, safe_span
|
||||
from ..utils import get_supabase_client
|
||||
from ..config.logfire_config import api_logger, safe_set_attribute, safe_span
|
||||
|
||||
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
|
||||
|
||||
|
||||
class ServerConfig(BaseModel):
|
||||
transport: str = "sse"
|
||||
host: str = "localhost"
|
||||
port: int = 8051
|
||||
|
||||
|
||||
class ServerResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
status: str | None = None
|
||||
pid: int | None = None
|
||||
|
||||
|
||||
class LogEntry(BaseModel):
|
||||
timestamp: str
|
||||
level: str
|
||||
message: str
|
||||
|
||||
|
||||
class MCPServerManager:
|
||||
"""Manages the MCP Docker container lifecycle."""
|
||||
|
||||
def __init__(self):
|
||||
self.container_name = None # Will be resolved dynamically
|
||||
self.docker_client = None
|
||||
self.container = None
|
||||
self.status: str = "stopped"
|
||||
self.start_time: float | None = None
|
||||
self.logs: deque = deque(maxlen=1000) # Keep last 1000 log entries for internal use
|
||||
self.log_reader_task: asyncio.Task | None = None
|
||||
self._operation_lock = asyncio.Lock() # Prevent concurrent start/stop operations
|
||||
self._last_operation_time = 0
|
||||
self._min_operation_interval = 2.0 # Minimum 2 seconds between operations
|
||||
self._initialize_docker_client()
|
||||
|
||||
def _resolve_container(self):
|
||||
"""Simple container resolution - just use fixed name."""
|
||||
if not self.docker_client:
|
||||
return None
|
||||
|
||||
def get_container_status() -> dict[str, Any]:
|
||||
"""Get simple MCP container status without Docker management."""
|
||||
docker_client = None
|
||||
try:
|
||||
# Simple: Just look for the fixed container name
|
||||
container = self.docker_client.containers.get("archon-mcp")
|
||||
self.container_name = "archon-mcp"
|
||||
mcp_logger.info("Found MCP container")
|
||||
return container
|
||||
except NotFound:
|
||||
mcp_logger.warning("MCP container not found - is it running?")
|
||||
self.container_name = "archon-mcp"
|
||||
return None
|
||||
docker_client = docker.from_env()
|
||||
container = docker_client.containers.get("archon-mcp")
|
||||
|
||||
def _initialize_docker_client(self):
|
||||
"""Initialize Docker client and get container reference."""
|
||||
try:
|
||||
self.docker_client = docker.from_env()
|
||||
self.container = self._resolve_container()
|
||||
if not self.container:
|
||||
mcp_logger.warning("MCP container not found during initialization")
|
||||
except Exception as e:
|
||||
mcp_logger.error(f"Failed to initialize Docker client: {str(e)}")
|
||||
self.docker_client = None
|
||||
|
||||
def _get_container_status(self) -> str:
|
||||
"""Get the current status of the MCP container."""
|
||||
if not self.docker_client:
|
||||
return "docker_unavailable"
|
||||
|
||||
try:
|
||||
if self.container:
|
||||
self.container.reload() # Refresh container info
|
||||
else:
|
||||
# Try to resolve container again if we don't have it
|
||||
self.container = self._resolve_container()
|
||||
if not self.container:
|
||||
return "not_found"
|
||||
|
||||
return self.container.status
|
||||
except NotFound:
|
||||
# Try to resolve again in case container was recreated
|
||||
self.container = self._resolve_container()
|
||||
if self.container:
|
||||
return self.container.status
|
||||
return "not_found"
|
||||
except Exception as e:
|
||||
mcp_logger.error(f"Error getting container status: {str(e)}")
|
||||
return "error"
|
||||
|
||||
def _is_log_reader_active(self) -> bool:
|
||||
"""Check if the log reader task is active."""
|
||||
return self.log_reader_task is not None and not self.log_reader_task.done()
|
||||
|
||||
async def _ensure_log_reader_running(self):
|
||||
"""Ensure the log reader task is running if container is active."""
|
||||
if not self.container:
|
||||
return
|
||||
|
||||
# Cancel existing task if any
|
||||
if self.log_reader_task:
|
||||
self.log_reader_task.cancel()
|
||||
try:
|
||||
await self.log_reader_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Start new log reader task
|
||||
self.log_reader_task = asyncio.create_task(self._read_container_logs())
|
||||
self._add_log("INFO", "Connected to MCP container logs")
|
||||
mcp_logger.info(f"Started log reader for already-running container: {self.container_name}")
|
||||
|
||||
async def start_server(self) -> dict[str, Any]:
|
||||
"""Start the MCP Docker container."""
|
||||
async with self._operation_lock:
|
||||
# Check throttling
|
||||
current_time = time.time()
|
||||
if current_time - self._last_operation_time < self._min_operation_interval:
|
||||
wait_time = self._min_operation_interval - (
|
||||
current_time - self._last_operation_time
|
||||
)
|
||||
mcp_logger.warning(f"Start operation throttled, please wait {wait_time:.1f}s")
|
||||
return {
|
||||
"success": False,
|
||||
"status": self.status,
|
||||
"message": f"Please wait {wait_time:.1f}s before starting server again",
|
||||
}
|
||||
|
||||
with safe_span("mcp_server_start") as span:
|
||||
safe_set_attribute(span, "action", "start_server")
|
||||
|
||||
if not self.docker_client:
|
||||
mcp_logger.error("Docker client not available")
|
||||
return {
|
||||
"success": False,
|
||||
"status": "docker_unavailable",
|
||||
"message": "Docker is not available. Is Docker socket mounted?",
|
||||
}
|
||||
|
||||
# Check current container status
|
||||
container_status = self._get_container_status()
|
||||
|
||||
if container_status == "not_found":
|
||||
mcp_logger.error(f"Container {self.container_name} not found")
|
||||
return {
|
||||
"success": False,
|
||||
"status": "not_found",
|
||||
"message": f"MCP container {self.container_name} not found. Run docker-compose up -d archon-mcp",
|
||||
}
|
||||
# Get container status
|
||||
container_status = container.status
|
||||
|
||||
# Map Docker statuses to simple statuses
|
||||
if container_status == "running":
|
||||
mcp_logger.warning("MCP server start attempted while already running")
|
||||
return {
|
||||
"success": False,
|
||||
"status": "running",
|
||||
"message": "MCP server is already running",
|
||||
}
|
||||
|
||||
try:
|
||||
# Start the container
|
||||
self.container.start()
|
||||
self.status = "starting"
|
||||
self.start_time = time.time()
|
||||
self._last_operation_time = time.time()
|
||||
self._add_log("INFO", "MCP container starting...")
|
||||
mcp_logger.info(f"Starting MCP container: {self.container_name}")
|
||||
safe_set_attribute(span, "container_id", self.container.id)
|
||||
|
||||
# Start reading logs from the container
|
||||
if self.log_reader_task:
|
||||
self.log_reader_task.cancel()
|
||||
self.log_reader_task = asyncio.create_task(self._read_container_logs())
|
||||
|
||||
# Give it a moment to start
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Check if container is running
|
||||
self.container.reload()
|
||||
if self.container.status == "running":
|
||||
self.status = "running"
|
||||
self._add_log("INFO", "MCP container started successfully")
|
||||
mcp_logger.info(
|
||||
f"MCP container started successfully - container_id={self.container.id}"
|
||||
)
|
||||
safe_set_attribute(span, "success", True)
|
||||
safe_set_attribute(span, "status", "running")
|
||||
return {
|
||||
"success": True,
|
||||
"status": self.status,
|
||||
"message": "MCP server started successfully",
|
||||
"container_id": self.container.id[:12],
|
||||
}
|
||||
else:
|
||||
self.status = "failed"
|
||||
self._add_log(
|
||||
"ERROR", f"MCP container failed to start. Status: {self.container.status}"
|
||||
)
|
||||
mcp_logger.error(
|
||||
f"MCP container failed to start - status: {self.container.status}"
|
||||
)
|
||||
safe_set_attribute(span, "success", False)
|
||||
safe_set_attribute(span, "status", self.container.status)
|
||||
return {
|
||||
"success": False,
|
||||
"status": self.status,
|
||||
"message": f"MCP container failed to start. Status: {self.container.status}",
|
||||
}
|
||||
|
||||
except APIError as e:
|
||||
self.status = "failed"
|
||||
self._add_log("ERROR", f"Docker API error: {str(e)}")
|
||||
mcp_logger.error(f"Docker API error during MCP startup - error={str(e)}")
|
||||
safe_set_attribute(span, "success", False)
|
||||
safe_set_attribute(span, "error", str(e))
|
||||
return {
|
||||
"success": False,
|
||||
"status": self.status,
|
||||
"message": f"Docker API error: {str(e)}",
|
||||
}
|
||||
except Exception as e:
|
||||
self.status = "failed"
|
||||
self._add_log("ERROR", f"Failed to start MCP server: {str(e)}")
|
||||
mcp_logger.error(
|
||||
f"Exception during MCP server startup - error={str(e)}, error_type={type(e).__name__}"
|
||||
)
|
||||
safe_set_attribute(span, "success", False)
|
||||
safe_set_attribute(span, "error", str(e))
|
||||
return {
|
||||
"success": False,
|
||||
"status": self.status,
|
||||
"message": f"Failed to start MCP server: {str(e)}",
|
||||
}
|
||||
|
||||
async def stop_server(self) -> dict[str, Any]:
|
||||
"""Stop the MCP Docker container."""
|
||||
async with self._operation_lock:
|
||||
# Check throttling
|
||||
current_time = time.time()
|
||||
if current_time - self._last_operation_time < self._min_operation_interval:
|
||||
wait_time = self._min_operation_interval - (
|
||||
current_time - self._last_operation_time
|
||||
)
|
||||
mcp_logger.warning(f"Stop operation throttled, please wait {wait_time:.1f}s")
|
||||
return {
|
||||
"success": False,
|
||||
"status": self.status,
|
||||
"message": f"Please wait {wait_time:.1f}s before stopping server again",
|
||||
}
|
||||
|
||||
with safe_span("mcp_server_stop") as span:
|
||||
safe_set_attribute(span, "action", "stop_server")
|
||||
|
||||
if not self.docker_client:
|
||||
mcp_logger.error("Docker client not available")
|
||||
return {
|
||||
"success": False,
|
||||
"status": "docker_unavailable",
|
||||
"message": "Docker is not available",
|
||||
}
|
||||
|
||||
# Check current container status
|
||||
container_status = self._get_container_status()
|
||||
|
||||
if container_status not in ["running", "restarting"]:
|
||||
mcp_logger.warning(
|
||||
f"MCP server stop attempted when not running. Status: {container_status}"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"status": container_status,
|
||||
"message": f"MCP server is not running (status: {container_status})",
|
||||
}
|
||||
|
||||
try:
|
||||
self.status = "stopping"
|
||||
self._add_log("INFO", "Stopping MCP container...")
|
||||
mcp_logger.info(f"Stopping MCP container: {self.container_name}")
|
||||
safe_set_attribute(span, "container_id", self.container.id)
|
||||
|
||||
# Cancel log reading task
|
||||
if self.log_reader_task:
|
||||
self.log_reader_task.cancel()
|
||||
try:
|
||||
await self.log_reader_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Stop the container with timeout
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self.container.stop(timeout=10), # 10 second timeout
|
||||
)
|
||||
|
||||
self.status = "stopped"
|
||||
self.start_time = None
|
||||
self._last_operation_time = time.time()
|
||||
self._add_log("INFO", "MCP container stopped")
|
||||
mcp_logger.info("MCP container stopped successfully")
|
||||
safe_set_attribute(span, "success", True)
|
||||
safe_set_attribute(span, "status", "stopped")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"status": self.status,
|
||||
"message": "MCP server stopped successfully",
|
||||
}
|
||||
|
||||
except APIError as e:
|
||||
self._add_log("ERROR", f"Docker API error: {str(e)}")
|
||||
mcp_logger.error(f"Docker API error during MCP stop - error={str(e)}")
|
||||
safe_set_attribute(span, "success", False)
|
||||
safe_set_attribute(span, "error", str(e))
|
||||
return {
|
||||
"success": False,
|
||||
"status": self.status,
|
||||
"message": f"Docker API error: {str(e)}",
|
||||
}
|
||||
except Exception as e:
|
||||
self._add_log("ERROR", f"Error stopping MCP server: {str(e)}")
|
||||
mcp_logger.error(
|
||||
f"Exception during MCP server stop - error={str(e)}, error_type={type(e).__name__}"
|
||||
)
|
||||
safe_set_attribute(span, "success", False)
|
||||
safe_set_attribute(span, "error", str(e))
|
||||
return {
|
||||
"success": False,
|
||||
"status": self.status,
|
||||
"message": f"Error stopping MCP server: {str(e)}",
|
||||
}
|
||||
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
"""Get the current server status."""
|
||||
# Update status based on actual container state
|
||||
container_status = self._get_container_status()
|
||||
|
||||
# Map Docker statuses to our statuses
|
||||
status_map = {
|
||||
"running": "running",
|
||||
"restarting": "restarting",
|
||||
"paused": "paused",
|
||||
"exited": "stopped",
|
||||
"dead": "stopped",
|
||||
"created": "stopped",
|
||||
"removing": "stopping",
|
||||
"not_found": "not_found",
|
||||
"docker_unavailable": "docker_unavailable",
|
||||
"error": "error",
|
||||
}
|
||||
|
||||
self.status = status_map.get(container_status, "unknown")
|
||||
|
||||
# If container is running but log reader isn't active, start it
|
||||
if self.status == "running" and not self._is_log_reader_active():
|
||||
asyncio.create_task(self._ensure_log_reader_running())
|
||||
|
||||
uptime = None
|
||||
if self.status == "running" and self.start_time:
|
||||
uptime = int(time.time() - self.start_time)
|
||||
elif self.status == "running" and self.container:
|
||||
status = "running"
|
||||
# Try to get uptime from container info
|
||||
try:
|
||||
self.container.reload()
|
||||
started_at = self.container.attrs["State"]["StartedAt"]
|
||||
# Parse ISO format datetime
|
||||
from datetime import datetime
|
||||
|
||||
started_at = container.attrs["State"]["StartedAt"]
|
||||
started_time = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
||||
uptime = int((datetime.now(started_time.tzinfo) - started_time).total_seconds())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Convert log entries to strings for backward compatibility
|
||||
recent_logs = []
|
||||
for log in list(self.logs)[-10:]:
|
||||
if isinstance(log, dict):
|
||||
recent_logs.append(f"[{log['level']}] {log['message']}")
|
||||
uptime = None
|
||||
else:
|
||||
recent_logs.append(str(log))
|
||||
status = "stopped"
|
||||
uptime = None
|
||||
|
||||
return {
|
||||
"status": self.status,
|
||||
"status": status,
|
||||
"uptime": uptime,
|
||||
"logs": recent_logs,
|
||||
"container_status": container_status, # Include raw Docker status
|
||||
"logs": [], # No log streaming anymore
|
||||
"container_status": container_status
|
||||
}
|
||||
|
||||
def _add_log(self, level: str, message: str):
|
||||
"""Add a log entry for internal tracking."""
|
||||
log_entry = {
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"level": level,
|
||||
"message": message,
|
||||
except NotFound:
|
||||
return {
|
||||
"status": "not_found",
|
||||
"uptime": None,
|
||||
"logs": [],
|
||||
"container_status": "not_found",
|
||||
"message": "MCP container not found. Run: docker compose up -d archon-mcp"
|
||||
}
|
||||
self.logs.append(log_entry)
|
||||
|
||||
|
||||
async def _read_container_logs(self):
|
||||
"""Read logs from Docker container."""
|
||||
if not self.container:
|
||||
return
|
||||
|
||||
try:
|
||||
# Stream logs from container
|
||||
log_generator = self.container.logs(stream=True, follow=True, tail=100)
|
||||
|
||||
while True:
|
||||
try:
|
||||
log_line = await asyncio.get_event_loop().run_in_executor(
|
||||
None, next, log_generator, None
|
||||
)
|
||||
|
||||
if log_line is None:
|
||||
break
|
||||
|
||||
# Decode bytes to string
|
||||
if isinstance(log_line, bytes):
|
||||
log_line = log_line.decode("utf-8").strip()
|
||||
|
||||
if log_line:
|
||||
level, message = self._parse_log_line(log_line)
|
||||
self._add_log(level, message)
|
||||
|
||||
except StopIteration:
|
||||
break
|
||||
except Exception as e:
|
||||
self._add_log("ERROR", f"Log reading error: {str(e)}")
|
||||
break
|
||||
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except APIError as e:
|
||||
if "container not found" not in str(e).lower():
|
||||
self._add_log("ERROR", f"Docker API error reading logs: {str(e)}")
|
||||
except Exception as e:
|
||||
self._add_log("ERROR", f"Error reading container logs: {str(e)}")
|
||||
api_logger.error("Failed to get container status", exc_info=True)
|
||||
return {
|
||||
"status": "error",
|
||||
"uptime": None,
|
||||
"logs": [],
|
||||
"container_status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
finally:
|
||||
# Check if container stopped
|
||||
if docker_client is not None:
|
||||
try:
|
||||
self.container.reload()
|
||||
if self.container.status not in ["running", "restarting"]:
|
||||
self._add_log(
|
||||
"INFO", f"MCP container stopped with status: {self.container.status}"
|
||||
)
|
||||
docker_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_log_line(self, line: str) -> tuple[str, str]:
|
||||
"""Parse a log line to extract level and message."""
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return "INFO", ""
|
||||
|
||||
# Try to extract log level from common formats
|
||||
if line.startswith("[") and "]" in line:
|
||||
end_bracket = line.find("]")
|
||||
potential_level = line[1:end_bracket].upper()
|
||||
if potential_level in ["INFO", "DEBUG", "WARNING", "ERROR", "CRITICAL"]:
|
||||
return potential_level, line[end_bracket + 1 :].strip()
|
||||
|
||||
# Check for common log level indicators
|
||||
line_lower = line.lower()
|
||||
if any(word in line_lower for word in ["error", "exception", "failed", "critical"]):
|
||||
return "ERROR", line
|
||||
elif any(word in line_lower for word in ["warning", "warn"]):
|
||||
return "WARNING", line
|
||||
elif any(word in line_lower for word in ["debug"]):
|
||||
return "DEBUG", line
|
||||
else:
|
||||
return "INFO", line
|
||||
|
||||
|
||||
|
||||
# Global MCP manager instance
|
||||
mcp_manager = MCPServerManager()
|
||||
|
||||
|
||||
@router.post("/start", response_model=ServerResponse)
|
||||
async def start_server():
|
||||
"""Start the MCP server."""
|
||||
with safe_span("api_mcp_start") as span:
|
||||
safe_set_attribute(span, "endpoint", "/mcp/start")
|
||||
safe_set_attribute(span, "method", "POST")
|
||||
|
||||
try:
|
||||
result = await mcp_manager.start_server()
|
||||
api_logger.info(
|
||||
"MCP server start API called - success=%s", result.get("success", False)
|
||||
)
|
||||
safe_set_attribute(span, "success", result.get("success", False))
|
||||
return result
|
||||
except Exception as e:
|
||||
api_logger.error("MCP server start API failed - error=%s", str(e))
|
||||
safe_set_attribute(span, "success", False)
|
||||
safe_set_attribute(span, "error", str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/stop", response_model=ServerResponse)
|
||||
async def stop_server():
|
||||
"""Stop the MCP server."""
|
||||
with safe_span("api_mcp_stop") as span:
|
||||
safe_set_attribute(span, "endpoint", "/mcp/stop")
|
||||
safe_set_attribute(span, "method", "POST")
|
||||
|
||||
try:
|
||||
result = await mcp_manager.stop_server()
|
||||
api_logger.info(f"MCP server stop API called - success={result.get('success', False)}")
|
||||
safe_set_attribute(span, "success", result.get("success", False))
|
||||
return result
|
||||
except Exception as e:
|
||||
api_logger.error(f"MCP server stop API failed - error={str(e)}")
|
||||
safe_set_attribute(span, "success", False)
|
||||
safe_set_attribute(span, "error", str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status():
|
||||
"""Get MCP server status."""
|
||||
with safe_span("api_mcp_status") as span:
|
||||
safe_set_attribute(span, "endpoint", "/mcp/status")
|
||||
safe_set_attribute(span, "endpoint", "/api/mcp/status")
|
||||
safe_set_attribute(span, "method", "GET")
|
||||
|
||||
try:
|
||||
status = mcp_manager.get_status()
|
||||
status = get_container_status()
|
||||
api_logger.debug(f"MCP server status checked - status={status.get('status')}")
|
||||
safe_set_attribute(span, "status", status.get("status"))
|
||||
safe_set_attribute(span, "uptime", status.get("uptime"))
|
||||
@@ -557,7 +94,6 @@ async def get_status():
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_mcp_config():
|
||||
"""Get MCP server configuration."""
|
||||
@@ -569,18 +105,16 @@ async def get_mcp_config():
|
||||
api_logger.info("Getting MCP server configuration")
|
||||
|
||||
# Get actual MCP port from environment or use default
|
||||
import os
|
||||
|
||||
mcp_port = int(os.getenv("ARCHON_MCP_PORT", "8051"))
|
||||
|
||||
# Configuration for SSE-only mode with actual port
|
||||
# Configuration for streamable-http mode with actual port
|
||||
config = {
|
||||
"host": "localhost",
|
||||
"port": mcp_port,
|
||||
"transport": "sse",
|
||||
"transport": "streamable-http",
|
||||
}
|
||||
|
||||
# Get only model choice from database
|
||||
# Get only model choice from database (simplified)
|
||||
try:
|
||||
from ..services.credential_service import credential_service
|
||||
|
||||
@@ -588,172 +122,87 @@ async def get_mcp_config():
|
||||
"MODEL_CHOICE", "gpt-4o-mini"
|
||||
)
|
||||
config["model_choice"] = model_choice
|
||||
config["use_contextual_embeddings"] = (
|
||||
await credential_service.get_credential("USE_CONTEXTUAL_EMBEDDINGS", "false")
|
||||
).lower() == "true"
|
||||
config["use_hybrid_search"] = (
|
||||
await credential_service.get_credential("USE_HYBRID_SEARCH", "false")
|
||||
).lower() == "true"
|
||||
config["use_agentic_rag"] = (
|
||||
await credential_service.get_credential("USE_AGENTIC_RAG", "false")
|
||||
).lower() == "true"
|
||||
config["use_reranking"] = (
|
||||
await credential_service.get_credential("USE_RERANKING", "false")
|
||||
).lower() == "true"
|
||||
except Exception:
|
||||
# Fallback to default model
|
||||
config["model_choice"] = "gpt-4o-mini"
|
||||
config["use_contextual_embeddings"] = False
|
||||
config["use_hybrid_search"] = False
|
||||
config["use_agentic_rag"] = False
|
||||
config["use_reranking"] = False
|
||||
|
||||
api_logger.info("MCP configuration (SSE-only mode)")
|
||||
api_logger.info("MCP configuration (streamable-http mode)")
|
||||
safe_set_attribute(span, "host", config["host"])
|
||||
safe_set_attribute(span, "port", config["port"])
|
||||
safe_set_attribute(span, "transport", "sse")
|
||||
safe_set_attribute(span, "transport", "streamable-http")
|
||||
safe_set_attribute(span, "model_choice", config.get("model_choice", "gpt-4o-mini"))
|
||||
|
||||
return config
|
||||
except Exception as e:
|
||||
api_logger.error("Failed to get MCP configuration", error=str(e))
|
||||
api_logger.error("Failed to get MCP configuration", exc_info=True)
|
||||
safe_set_attribute(span, "error", str(e))
|
||||
raise HTTPException(status_code=500, detail={"error": str(e)})
|
||||
|
||||
|
||||
@router.post("/config")
|
||||
async def save_configuration(config: ServerConfig):
|
||||
"""Save MCP server configuration."""
|
||||
with safe_span("api_save_mcp_config") as span:
|
||||
safe_set_attribute(span, "endpoint", "/api/mcp/config")
|
||||
safe_set_attribute(span, "method", "POST")
|
||||
safe_set_attribute(span, "transport", config.transport)
|
||||
safe_set_attribute(span, "host", config.host)
|
||||
safe_set_attribute(span, "port", config.port)
|
||||
|
||||
try:
|
||||
api_logger.info(
|
||||
f"Saving MCP server configuration | transport={config.transport} | host={config.host} | port={config.port}"
|
||||
)
|
||||
supabase_client = get_supabase_client()
|
||||
|
||||
config_json = config.model_dump_json()
|
||||
|
||||
# Save MCP config using credential service
|
||||
from ..services.credential_service import credential_service
|
||||
|
||||
success = await credential_service.set_credential(
|
||||
"mcp_config",
|
||||
config_json,
|
||||
category="mcp",
|
||||
description="MCP server configuration settings",
|
||||
)
|
||||
|
||||
if success:
|
||||
api_logger.info("MCP configuration saved successfully")
|
||||
safe_set_attribute(span, "operation", "save")
|
||||
else:
|
||||
raise Exception("Failed to save MCP configuration")
|
||||
|
||||
safe_set_attribute(span, "success", True)
|
||||
return {"success": True, "message": "Configuration saved"}
|
||||
|
||||
except Exception as e:
|
||||
api_logger.error(f"Failed to save MCP configuration | error={str(e)}")
|
||||
safe_set_attribute(span, "error", str(e))
|
||||
raise HTTPException(status_code=500, detail={"error": str(e)})
|
||||
|
||||
|
||||
|
||||
@router.get("/tools")
|
||||
async def get_mcp_tools():
|
||||
"""Get available MCP tools by querying the running MCP server's registered tools."""
|
||||
with safe_span("api_get_mcp_tools") as span:
|
||||
safe_set_attribute(span, "endpoint", "/api/mcp/tools")
|
||||
@router.get("/clients")
|
||||
async def get_mcp_clients():
|
||||
"""Get connected MCP clients with type detection."""
|
||||
with safe_span("api_mcp_clients") as span:
|
||||
safe_set_attribute(span, "endpoint", "/api/mcp/clients")
|
||||
safe_set_attribute(span, "method", "GET")
|
||||
|
||||
try:
|
||||
api_logger.info("Getting MCP tools from registered server instance")
|
||||
# TODO: Implement real client detection in the future
|
||||
# For now, return empty array as expected by frontend
|
||||
api_logger.debug("Getting MCP clients - returning empty array")
|
||||
|
||||
# Check if server is running
|
||||
server_status = mcp_manager.get_status()
|
||||
is_running = server_status.get("status") == "running"
|
||||
safe_set_attribute(span, "server_running", is_running)
|
||||
|
||||
if not is_running:
|
||||
api_logger.warning("MCP server not running when requesting tools")
|
||||
return {
|
||||
"tools": [],
|
||||
"count": 0,
|
||||
"server_running": False,
|
||||
"source": "server_not_running",
|
||||
"message": "MCP server is not running. Start the server to see available tools.",
|
||||
"clients": [],
|
||||
"total": 0
|
||||
}
|
||||
|
||||
# SIMPLE DEBUG: Just check if we can see any tools at all
|
||||
try:
|
||||
# Try to inspect the process to see what tools exist
|
||||
api_logger.info("Debugging: Attempting to check MCP server tools")
|
||||
|
||||
# For now, just return the known modules info since server is registering them
|
||||
# This will at least show the UI that tools exist while we debug the real issue
|
||||
if is_running:
|
||||
return {
|
||||
"tools": [
|
||||
{
|
||||
"name": "debug_placeholder",
|
||||
"description": "MCP server is running and modules are registered, but tool introspection is not working yet",
|
||||
"module": "debug",
|
||||
"parameters": [],
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
"server_running": True,
|
||||
"source": "debug_placeholder",
|
||||
"message": "MCP server is running with 3 modules registered. Tool introspection needs to be fixed.",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"tools": [],
|
||||
"count": 0,
|
||||
"server_running": False,
|
||||
"source": "server_not_running",
|
||||
"message": "MCP server is not running. Start the server to see available tools.",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
api_logger.error("Failed to debug MCP server tools", error=str(e))
|
||||
|
||||
return {
|
||||
"tools": [],
|
||||
"count": 0,
|
||||
"server_running": is_running,
|
||||
"source": "debug_error",
|
||||
"message": f"Debug failed: {str(e)}",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
api_logger.error("Failed to get MCP tools", error=str(e))
|
||||
api_logger.error(f"Failed to get MCP clients - error={str(e)}")
|
||||
safe_set_attribute(span, "error", str(e))
|
||||
safe_set_attribute(span, "source", "general_error")
|
||||
|
||||
return {
|
||||
"tools": [],
|
||||
"count": 0,
|
||||
"server_running": False,
|
||||
"source": "general_error",
|
||||
"message": f"Error retrieving MCP tools: {str(e)}",
|
||||
"clients": [],
|
||||
"total": 0,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
async def get_mcp_sessions():
|
||||
"""Get MCP session information."""
|
||||
with safe_span("api_mcp_sessions") as span:
|
||||
safe_set_attribute(span, "endpoint", "/api/mcp/sessions")
|
||||
safe_set_attribute(span, "method", "GET")
|
||||
|
||||
try:
|
||||
# Basic session info for now
|
||||
status = get_container_status()
|
||||
|
||||
session_info = {
|
||||
"active_sessions": 0, # TODO: Implement real session tracking
|
||||
"session_timeout": 3600, # 1 hour default
|
||||
}
|
||||
|
||||
# Add uptime if server is running
|
||||
if status.get("status") == "running" and status.get("uptime"):
|
||||
session_info["server_uptime_seconds"] = status["uptime"]
|
||||
|
||||
api_logger.debug(f"MCP session info - sessions={session_info.get('active_sessions')}")
|
||||
safe_set_attribute(span, "active_sessions", session_info.get("active_sessions"))
|
||||
|
||||
return session_info
|
||||
except Exception as e:
|
||||
api_logger.error(f"Failed to get MCP sessions - error={str(e)}")
|
||||
safe_set_attribute(span, "error", str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def mcp_health():
|
||||
"""Health check for MCP API."""
|
||||
"""Health check for MCP API - used by bug report service and tests."""
|
||||
with safe_span("api_mcp_health") as span:
|
||||
safe_set_attribute(span, "endpoint", "/api/mcp/health")
|
||||
safe_set_attribute(span, "method", "GET")
|
||||
|
||||
# Removed health check logging to reduce console noise
|
||||
# Simple health check - no logging to reduce noise
|
||||
result = {"status": "healthy", "service": "mcp"}
|
||||
safe_set_attribute(span, "status", "healthy")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user