Skip to main content
Glama
+page.svelte9.87 kB
<script> import { onMount } from 'svelte'; import NetworkGraph from '$lib/components/NetworkGraph.svelte'; let networkComponent; let data = { nodes: [], edges: [] }; let projects = []; let stats = {}; let loading = true; let error = null; // Filter states let selectedTypes = ['prd', 'design', 'task', 'test']; let selectedProject = null; let searchQuery = ''; let showFilters = true; let selectedLayout = 'physics'; // Detail panel let selectedNode = null; let showDetailPanel = false; // Load data from API async function loadData() { try { loading = true; error = null; const response = await fetch('/api/relationships'); const result = await response.json(); if (response.ok) { data = { nodes: result.nodes, edges: result.edges }; projects = result.projects || []; stats = result.stats || {}; } else { error = result.error || 'Failed to load relationship data'; data = { nodes: [], edges: [] }; } } catch (e) { error = 'Network error: ' + e.message; data = { nodes: [], edges: [] }; } finally { loading = false; } } // Handle type filter changes function toggleType(type) { if (selectedTypes.includes(type)) { selectedTypes = selectedTypes.filter(t => t !== type); } else { selectedTypes = [...selectedTypes, type]; } } // Handle search function handleSearch() { if (!searchQuery.trim() || !networkComponent) return; const matchingNodes = data.nodes.filter(node => node.label.toLowerCase().includes(searchQuery.toLowerCase()) || node.type.toLowerCase().includes(searchQuery.toLowerCase()) ); if (matchingNodes.length > 0) { networkComponent.focusNode(matchingNodes[0].id); } } // Handle node selection function handleNodeSelected(event) { selectedNode = event.detail; showDetailPanel = true; } // Layout controls function changeLayout(layout) { selectedLayout = layout; if (networkComponent) { networkComponent.setLayout(layout); } } // Network controls function fitNetwork() { if (networkComponent) { networkComponent.fitNetwork(); } } // Get type label in Korean function getTypeLabel(type) { const labels = { prd: 'PRD', design: '설계', task: '작업', test: '테스트' }; return labels[type] || type; } // Get status color function getStatusColor(status) { switch (status) { case 'completed': case 'done': return 'text-green-600 bg-green-50'; case 'in_progress': case 'in-progress': return 'text-blue-600 bg-blue-50'; case 'pending': return 'text-yellow-600 bg-yellow-50'; case 'blocked': return 'text-red-600 bg-red-50'; default: return 'text-gray-600 bg-gray-50'; } } onMount(() => { loadData(); }); </script> <svelte:head> <title>관계도 - WorkflowMCP Dashboard</title> </svelte:head> <div class="space-y-6"> <!-- Header --> <div class="flex items-center justify-between"> <div> <h1 class="text-3xl font-bold text-gray-900">프로젝트 관계도</h1> <p class="text-gray-600 mt-1">PRD, 설계, 작업, 테스트 간의 관계를 시각화합니다</p> </div> <div class="flex items-center space-x-2"> <button on:click={() => showFilters = !showFilters} class="btn btn-secondary" > {showFilters ? '필터 숨기기' : '필터 보기'} </button> <button on:click={loadData} class="btn btn-secondary" disabled={loading}> 🔄 새로고침 </button> </div> </div> <!-- Statistics --> {#if stats && Object.keys(stats).length > 0} <div class="grid grid-cols-2 md:grid-cols-4 gap-4"> <div class="stat-card"> <div class="stat-number">{stats.total_nodes || 0}</div> <div class="stat-label">전체 노드</div> </div> <div class="stat-card"> <div class="stat-number">{stats.total_edges || 0}</div> <div class="stat-label">전체 관계</div> </div> <div class="stat-card"> <div class="stat-number">{stats.connected_prds || 0}/{stats.total_prds || 0}</div> <div class="stat-label">연결된 PRD</div> </div> <div class="stat-card"> <div class="stat-number">{stats.connected_tasks || 0}/{stats.total_tasks || 0}</div> <div class="stat-label">연결된 작업</div> </div> </div> {/if} <div class="grid grid-cols-1 lg:grid-cols-4 gap-6"> <!-- Filters Panel --> {#if showFilters} <div class="lg:col-span-1"> <div class="card space-y-6"> <h3 class="text-lg font-semibold text-gray-900">필터 및 컨트롤</h3> <!-- Search --> <div> <label class="block text-sm font-medium text-gray-700 mb-2">검색</label> <div class="flex space-x-2"> <input type="text" bind:value={searchQuery} placeholder="노드명 검색..." class="form-input flex-1" on:keydown={(e) => e.key === 'Enter' && handleSearch()} /> <button on:click={handleSearch} class="btn btn-primary px-3" disabled={!searchQuery.trim()} > 🔍 </button> </div> </div> <!-- Type Filters --> <div> <label class="block text-sm font-medium text-gray-700 mb-2">표시할 타입</label> <div class="space-y-2"> {#each ['prd', 'design', 'task', 'test'] as type} <label class="flex items-center"> <input type="checkbox" checked={selectedTypes.includes(type)} on:change={() => toggleType(type)} class="mr-2" /> <span class="type-badge type-{type}"> {getTypeLabel(type)} </span> </label> {/each} </div> </div> <!-- Project Filter --> {#if projects.length > 0} <div> <label class="block text-sm font-medium text-gray-700 mb-2">프로젝트</label> <select bind:value={selectedProject} class="form-select w-full"> <option value={null}>모든 프로젝트</option> {#each projects as project} <option value={project.id}>{project.name}</option> {/each} </select> </div> {/if} <!-- Layout Controls --> <div> <label class="block text-sm font-medium text-gray-700 mb-2">레이아웃</label> <div class="space-y-2"> <button class="w-full btn {selectedLayout === 'physics' ? 'btn-primary' : 'btn-secondary'}" on:click={() => changeLayout('physics')} > 물리 시뮬레이션 </button> <button class="w-full btn {selectedLayout === 'hierarchical' ? 'btn-primary' : 'btn-secondary'}" on:click={() => changeLayout('hierarchical')} > 계층형 </button> </div> </div> <!-- Network Controls --> <div> <label class="block text-sm font-medium text-gray-700 mb-2">뷰 컨트롤</label> <button on:click={fitNetwork} class="w-full btn btn-secondary" > 🎯 전체 보기 </button> </div> </div> </div> {/if} <!-- Network Graph --> <div class="{showFilters ? 'lg:col-span-3' : 'lg:col-span-4'}"> {#if loading} <div class="flex justify-center items-center h-96"> <div class="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600"></div> </div> {:else if error} <div class="bg-red-50 border border-red-200 rounded-md p-4"> <div class="text-red-800">{error}</div> <button class="mt-2 text-sm text-red-600 hover:text-red-800" on:click={loadData} > 다시 시도 </button> </div> {:else} <div class="card p-0 overflow-hidden"> <NetworkGraph bind:this={networkComponent} {data} {selectedTypes} {selectedProject} on:nodeSelected={handleNodeSelected} /> </div> {/if} </div> </div> <!-- Detail Panel Modal --> {#if showDetailPanel && selectedNode} <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div class="bg-white rounded-lg p-6 max-w-md w-full mx-4"> <div class="flex items-center justify-between mb-4"> <h3 class="text-lg font-semibold text-gray-900">상세 정보</h3> <button on:click={() => showDetailPanel = false} class="text-gray-400 hover:text-gray-600" > ✕ </button> </div> <div class="space-y-4"> <div> <div class="type-badge type-{selectedNode.type} mb-2"> {getTypeLabel(selectedNode.type)} </div> <h4 class="font-medium text-gray-900">{selectedNode.label}</h4> </div> {#if selectedNode.status} <div> <span class="text-sm text-gray-500">상태:</span> <span class="ml-2 px-2 py-1 text-xs rounded-full {getStatusColor(selectedNode.status)}"> {selectedNode.status} </span> </div> {/if} <div> <span class="text-sm text-gray-500">ID:</span> <span class="ml-2 font-mono text-sm">{selectedNode.entity_id}</span> </div> </div> <div class="mt-6 flex justify-end space-x-2"> <button on:click={() => showDetailPanel = false} class="btn btn-secondary" > 닫기 </button> <a href="/{selectedNode.type}s/{selectedNode.entity_id}" class="btn btn-primary" > 상세보기 </a> </div> </div> </div> {/if} </div> <style> .stat-card { @apply bg-white rounded-lg p-4 shadow-sm border; } .stat-number { @apply text-2xl font-bold text-gray-900; } .stat-label { @apply text-sm text-gray-500 mt-1; } .type-badge { @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; } .type-prd { @apply bg-blue-100 text-blue-800; } .type-design { @apply bg-red-100 text-red-800; } .type-task { @apply bg-green-100 text-green-800; } .type-test { @apply bg-yellow-100 text-yellow-800; } </style>

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/foswmine/workflow-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server