mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37:45 +08:00
feat: Add Notifications Component with WebSocket and Auto Refresh
- Implemented a Notifications component for real-time updates using WebSocket. - Added silent refresh functionality to update data without notification bubbles. - Introduced auto-refresh mechanism to periodically check for changes in workflow data. - Enhanced data handling with session and task updates, ensuring UI reflects the latest state. feat: Create Hook Manager View for Managing Hooks - Developed a Hook Manager view to manage project and global hooks. - Added functionality to create, edit, and delete hooks with a user-friendly interface. - Implemented quick install templates for common hooks to streamline user experience. - Included environment variables reference for hooks to assist users in configuration. feat: Implement MCP Manager View for Server Management - Created an MCP Manager view for managing MCP servers within projects. - Enabled adding and removing servers from projects with a clear UI. - Displayed available servers from other projects for easy access and management. - Provided an overview of all projects and their associated MCP servers. feat: Add Version Fetcher Utility for GitHub Releases - Implemented a version fetcher utility to retrieve release information from GitHub. - Added functions to fetch the latest release, recent releases, and latest commit details. - Included functionality to download and extract repository zip files. - Ensured cleanup of temporary directories after downloads to maintain system hygiene.
This commit is contained in:
349
ccw/src/templates/dashboard-js/components/carousel.js
Normal file
349
ccw/src/templates/dashboard-js/components/carousel.js
Normal file
@@ -0,0 +1,349 @@
|
||||
// ==========================================
|
||||
// CAROUSEL COMPONENT
|
||||
// ==========================================
|
||||
// Active session carousel with detailed task info and smooth transitions
|
||||
|
||||
let carouselIndex = 0;
|
||||
let carouselSessions = [];
|
||||
let carouselInterval = null;
|
||||
let carouselPaused = false;
|
||||
const CAROUSEL_INTERVAL_MS = 5000; // 5 seconds
|
||||
|
||||
function initCarousel() {
|
||||
const prevBtn = document.getElementById('carouselPrev');
|
||||
const nextBtn = document.getElementById('carouselNext');
|
||||
const pauseBtn = document.getElementById('carouselPause');
|
||||
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', () => {
|
||||
carouselPrev();
|
||||
resetCarouselInterval();
|
||||
});
|
||||
}
|
||||
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', () => {
|
||||
carouselNext();
|
||||
resetCarouselInterval();
|
||||
});
|
||||
}
|
||||
|
||||
if (pauseBtn) {
|
||||
pauseBtn.addEventListener('click', toggleCarouselPause);
|
||||
}
|
||||
}
|
||||
|
||||
function updateCarousel() {
|
||||
// Get active sessions from workflowData
|
||||
const previousSessions = carouselSessions;
|
||||
const previousIndex = carouselIndex;
|
||||
const previousSessionId = previousSessions[previousIndex]?.session_id;
|
||||
|
||||
carouselSessions = workflowData.activeSessions || [];
|
||||
|
||||
// Try to preserve current position
|
||||
if (previousSessionId && carouselSessions.length > 0) {
|
||||
// Find if the same session still exists
|
||||
const newIndex = carouselSessions.findIndex(s => s.session_id === previousSessionId);
|
||||
if (newIndex !== -1) {
|
||||
carouselIndex = newIndex;
|
||||
} else if (previousIndex < carouselSessions.length) {
|
||||
// Keep same index if valid
|
||||
carouselIndex = previousIndex;
|
||||
} else {
|
||||
// Reset to last valid index
|
||||
carouselIndex = Math.max(0, carouselSessions.length - 1);
|
||||
}
|
||||
} else {
|
||||
carouselIndex = 0;
|
||||
}
|
||||
|
||||
renderCarouselDots();
|
||||
renderCarouselSlide('none');
|
||||
startCarouselInterval();
|
||||
}
|
||||
|
||||
function renderCarouselDots() {
|
||||
const dotsContainer = document.getElementById('carouselDots');
|
||||
if (!dotsContainer) return;
|
||||
|
||||
if (carouselSessions.length === 0) {
|
||||
dotsContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
dotsContainer.innerHTML = carouselSessions.map((_, index) => `
|
||||
<button class="carousel-dot w-2 h-2 rounded-full transition-all duration-200 ${index === carouselIndex ? 'bg-primary w-4' : 'bg-muted-foreground/40 hover:bg-muted-foreground/60'}"
|
||||
onclick="carouselGoToIndex(${index})" title="Session ${index + 1}"></button>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function updateActiveDot() {
|
||||
const dots = document.querySelectorAll('.carousel-dot');
|
||||
dots.forEach((dot, index) => {
|
||||
if (index === carouselIndex) {
|
||||
dot.classList.remove('bg-muted-foreground/40', 'hover:bg-muted-foreground/60', 'w-2');
|
||||
dot.classList.add('bg-primary', 'w-4');
|
||||
} else {
|
||||
dot.classList.remove('bg-primary', 'w-4');
|
||||
dot.classList.add('bg-muted-foreground/40', 'hover:bg-muted-foreground/60', 'w-2');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function carouselGoToIndex(index) {
|
||||
if (index < 0 || index >= carouselSessions.length) return;
|
||||
const direction = index > carouselIndex ? 'left' : (index < carouselIndex ? 'right' : 'none');
|
||||
carouselIndex = index;
|
||||
renderCarouselSlide(direction);
|
||||
updateActiveDot();
|
||||
resetCarouselInterval();
|
||||
}
|
||||
|
||||
function renderCarouselSlide(direction = 'none') {
|
||||
const container = document.getElementById('carouselContent');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
if (carouselSessions.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="carousel-empty flex items-center justify-center h-full text-muted-foreground">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-2">🎯</div>
|
||||
<p class="text-sm">No active sessions</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const session = carouselSessions[carouselIndex];
|
||||
const tasks = session.tasks || [];
|
||||
const completed = tasks.filter(t => t.status === 'completed').length;
|
||||
const inProgress = tasks.filter(t => t.status === 'in_progress').length;
|
||||
const pending = tasks.filter(t => t.status === 'pending').length;
|
||||
const taskCount = session.taskCount || tasks.length;
|
||||
const progress = taskCount > 0 ? Math.round((completed / taskCount) * 100) : 0;
|
||||
|
||||
// Get session type badge
|
||||
const sessionType = session.type || 'workflow';
|
||||
const typeBadgeClass = getSessionTypeBadgeClass(sessionType);
|
||||
|
||||
const sessionKey = `session-${session.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
|
||||
// Animation class based on direction
|
||||
const animClass = direction === 'left' ? 'carousel-slide-left' :
|
||||
direction === 'right' ? 'carousel-slide-right' : 'carousel-fade-in';
|
||||
|
||||
// Get recent task activity
|
||||
const recentTasks = getRecentTaskActivity(tasks);
|
||||
|
||||
// Format timestamps
|
||||
const createdTime = session.created_at ? formatRelativeTime(session.created_at) : '';
|
||||
const updatedTime = session.updated_at ? formatRelativeTime(session.updated_at) : '';
|
||||
|
||||
// Get more tasks for display (up to 4)
|
||||
const displayTasks = getRecentTaskActivity(tasks, 4);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="carousel-slide ${animClass} h-full">
|
||||
<div class="session-card h-full p-3 cursor-pointer hover:bg-hover/30 transition-colors"
|
||||
onclick="showSessionDetailPage('${sessionKey}')">
|
||||
|
||||
<!-- Two Column Layout -->
|
||||
<div class="flex gap-4 h-full">
|
||||
|
||||
<!-- Left Column: Session Info -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Session Header -->
|
||||
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded ${typeBadgeClass}">${sessionType}</span>
|
||||
${inProgress > 0 ? `<span class="inline-flex items-center gap-1 text-xs text-warning"><span class="w-2 h-2 rounded-full bg-warning animate-pulse"></span>${inProgress} running</span>` : ''}
|
||||
</div>
|
||||
<h4 class="font-semibold text-foreground text-sm line-clamp-1 mb-2" title="${escapeHtml(session.session_id)}">${escapeHtml(session.session_id)}</h4>
|
||||
|
||||
<!-- Progress -->
|
||||
<div class="mb-2">
|
||||
<div class="flex items-center justify-between text-xs mb-1">
|
||||
<span class="text-muted-foreground">Progress</span>
|
||||
<span class="text-foreground font-medium">${completed}/${taskCount}</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-500 ${progress === 100 ? 'bg-success' : 'bg-primary'}" style="width: ${progress}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Status Summary -->
|
||||
<div class="flex items-center gap-3 text-xs mb-2">
|
||||
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-success"></span>${completed}</span>
|
||||
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-warning ${inProgress > 0 ? 'animate-pulse' : ''}"></span>${inProgress}</span>
|
||||
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-muted-foreground"></span>${pending}</span>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-auto flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>📅 ${createdTime}</span>
|
||||
${updatedTime && updatedTime !== createdTime ? `<span>🔄 ${updatedTime}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Task List -->
|
||||
<div class="w-[45%] flex flex-col border-l border-border pl-3">
|
||||
<div class="text-xs font-medium text-muted-foreground mb-1.5">Recent Tasks</div>
|
||||
<div class="task-list flex-1 space-y-1 overflow-hidden">
|
||||
${displayTasks.length > 0 ? displayTasks.map(task => `
|
||||
<div class="flex items-center gap-1.5 text-xs">
|
||||
<span class="shrink-0">${getTaskStatusEmoji(task.status)}</span>
|
||||
<span class="truncate flex-1 ${task.status === 'in_progress' ? 'text-foreground font-medium' : 'text-muted-foreground'}">${escapeHtml(task.title || task.id || 'Task')}</span>
|
||||
</div>
|
||||
`).join('') : `
|
||||
<div class="text-xs text-muted-foreground">No tasks yet</div>
|
||||
`}
|
||||
</div>
|
||||
<!-- Progress percentage -->
|
||||
<div class="mt-auto pt-1 text-right">
|
||||
<span class="text-xl font-bold ${progress === 100 ? 'text-success' : 'text-primary'}">${progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Store session data for navigation
|
||||
if (!sessionDataStore[sessionKey]) {
|
||||
sessionDataStore[sessionKey] = session;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionTypeBadgeClass(type) {
|
||||
const classes = {
|
||||
'tdd': 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
'review': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
'test': 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
'docs': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
'workflow': 'bg-primary-light text-primary'
|
||||
};
|
||||
return classes[type] || classes['workflow'];
|
||||
}
|
||||
|
||||
function getRecentTaskActivity(tasks, limit = 4) {
|
||||
if (!tasks || tasks.length === 0) return [];
|
||||
|
||||
// Get in_progress tasks first, then most recently updated
|
||||
const sorted = [...tasks].sort((a, b) => {
|
||||
// in_progress first
|
||||
if (a.status === 'in_progress' && b.status !== 'in_progress') return -1;
|
||||
if (b.status === 'in_progress' && a.status !== 'in_progress') return 1;
|
||||
// Then by updated_at
|
||||
const timeA = a.updated_at || a.created_at || '';
|
||||
const timeB = b.updated_at || b.created_at || '';
|
||||
return timeB.localeCompare(timeA);
|
||||
});
|
||||
|
||||
// Return top N tasks
|
||||
return sorted.slice(0, limit);
|
||||
}
|
||||
|
||||
function getTaskStatusEmoji(status) {
|
||||
const emojis = {
|
||||
'completed': '✅',
|
||||
'in_progress': '🔄',
|
||||
'pending': '⏸️',
|
||||
'blocked': '🚫'
|
||||
};
|
||||
return emojis[status] || '📋';
|
||||
}
|
||||
|
||||
function getTaskStatusIcon(status) {
|
||||
return status === 'in_progress' ? 'animate-spin-slow' : '';
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSecs < 60) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
// Format as date for older
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
function carouselNext() {
|
||||
if (carouselSessions.length === 0) return;
|
||||
carouselIndex = (carouselIndex + 1) % carouselSessions.length;
|
||||
renderCarouselSlide('left');
|
||||
updateActiveDot();
|
||||
}
|
||||
|
||||
function carouselPrev() {
|
||||
if (carouselSessions.length === 0) return;
|
||||
carouselIndex = (carouselIndex - 1 + carouselSessions.length) % carouselSessions.length;
|
||||
renderCarouselSlide('right');
|
||||
updateActiveDot();
|
||||
}
|
||||
|
||||
function startCarouselInterval() {
|
||||
stopCarouselInterval();
|
||||
if (!carouselPaused && carouselSessions.length > 1) {
|
||||
carouselInterval = setInterval(carouselNext, CAROUSEL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function stopCarouselInterval() {
|
||||
if (carouselInterval) {
|
||||
clearInterval(carouselInterval);
|
||||
carouselInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resetCarouselInterval() {
|
||||
if (!carouselPaused) {
|
||||
startCarouselInterval();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCarouselPause() {
|
||||
carouselPaused = !carouselPaused;
|
||||
const icon = document.getElementById('carouselPauseIcon');
|
||||
|
||||
if (carouselPaused) {
|
||||
stopCarouselInterval();
|
||||
// Change to play icon
|
||||
if (icon) {
|
||||
icon.innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
|
||||
}
|
||||
} else {
|
||||
startCarouselInterval();
|
||||
// Change to pause icon
|
||||
if (icon) {
|
||||
icon.innerHTML = '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Jump to specific session in carousel
|
||||
function carouselGoTo(sessionId) {
|
||||
const index = carouselSessions.findIndex(s => s.session_id === sessionId);
|
||||
if (index !== -1) {
|
||||
carouselIndex = index;
|
||||
renderCarouselSlide('none');
|
||||
updateActiveDot();
|
||||
resetCarouselInterval();
|
||||
}
|
||||
}
|
||||
273
ccw/src/templates/dashboard-js/components/hook-manager.js
Normal file
273
ccw/src/templates/dashboard-js/components/hook-manager.js
Normal file
@@ -0,0 +1,273 @@
|
||||
// Hook Manager Component
|
||||
// Manages Claude Code hooks configuration from settings.json
|
||||
|
||||
// ========== Hook State ==========
|
||||
let hookConfig = {
|
||||
global: { hooks: {} },
|
||||
project: { hooks: {} }
|
||||
};
|
||||
|
||||
// ========== Hook Templates ==========
|
||||
const HOOK_TEMPLATES = {
|
||||
'ccw-notify': {
|
||||
event: 'PostToolUse',
|
||||
matcher: 'Write',
|
||||
command: 'curl',
|
||||
args: ['-s', '-X', 'POST', '-H', 'Content-Type: application/json', '-d', '{"type":"summary_written","filePath":"$CLAUDE_FILE_PATHS"}', 'http://localhost:3456/api/hook']
|
||||
},
|
||||
'log-tool': {
|
||||
event: 'PostToolUse',
|
||||
matcher: '',
|
||||
command: 'bash',
|
||||
args: ['-c', 'echo "[$(date)] Tool: $CLAUDE_TOOL_NAME, Files: $CLAUDE_FILE_PATHS" >> ~/.claude/tool-usage.log']
|
||||
},
|
||||
'lint-check': {
|
||||
event: 'PostToolUse',
|
||||
matcher: 'Write',
|
||||
command: 'bash',
|
||||
args: ['-c', 'for f in $CLAUDE_FILE_PATHS; do if [[ "$f" =~ \\.(js|ts|jsx|tsx)$ ]]; then npx eslint "$f" --fix 2>/dev/null || true; fi; done']
|
||||
},
|
||||
'git-add': {
|
||||
event: 'PostToolUse',
|
||||
matcher: 'Write',
|
||||
command: 'bash',
|
||||
args: ['-c', 'for f in $CLAUDE_FILE_PATHS; do git add "$f" 2>/dev/null || true; done']
|
||||
}
|
||||
};
|
||||
|
||||
// ========== Initialization ==========
|
||||
function initHookManager() {
|
||||
// Initialize Hook navigation
|
||||
document.querySelectorAll('.nav-item[data-view="hook-manager"]').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
setActiveNavItem(item);
|
||||
currentView = 'hook-manager';
|
||||
currentFilter = null;
|
||||
currentLiteType = null;
|
||||
currentSessionDetailKey = null;
|
||||
updateContentTitle();
|
||||
renderHookManager();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Data Loading ==========
|
||||
async function loadHookConfig() {
|
||||
try {
|
||||
const response = await fetch(`/api/hooks?path=${encodeURIComponent(projectPath)}`);
|
||||
if (!response.ok) throw new Error('Failed to load hook config');
|
||||
const data = await response.json();
|
||||
hookConfig = data;
|
||||
updateHookBadge();
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('Failed to load hook config:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveHook(scope, event, hookData) {
|
||||
try {
|
||||
const response = await fetch('/api/hooks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectPath: projectPath,
|
||||
scope: scope,
|
||||
event: event,
|
||||
hookData: hookData
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save hook');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
await loadHookConfig();
|
||||
renderHookManager();
|
||||
showRefreshToast(`Hook saved successfully`, 'success');
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Failed to save hook:', err);
|
||||
showRefreshToast(`Failed to save hook: ${err.message}`, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeHook(scope, event, hookIndex) {
|
||||
try {
|
||||
const response = await fetch('/api/hooks', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectPath: projectPath,
|
||||
scope: scope,
|
||||
event: event,
|
||||
hookIndex: hookIndex
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to remove hook');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
await loadHookConfig();
|
||||
renderHookManager();
|
||||
showRefreshToast(`Hook removed successfully`, 'success');
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Failed to remove hook:', err);
|
||||
showRefreshToast(`Failed to remove hook: ${err.message}`, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Badge Update ==========
|
||||
function updateHookBadge() {
|
||||
const badge = document.getElementById('badgeHooks');
|
||||
if (badge) {
|
||||
let totalHooks = 0;
|
||||
|
||||
// Count global hooks
|
||||
if (hookConfig.global?.hooks) {
|
||||
for (const event of Object.keys(hookConfig.global.hooks)) {
|
||||
const hooks = hookConfig.global.hooks[event];
|
||||
totalHooks += Array.isArray(hooks) ? hooks.length : 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Count project hooks
|
||||
if (hookConfig.project?.hooks) {
|
||||
for (const event of Object.keys(hookConfig.project.hooks)) {
|
||||
const hooks = hookConfig.project.hooks[event];
|
||||
totalHooks += Array.isArray(hooks) ? hooks.length : 1;
|
||||
}
|
||||
}
|
||||
|
||||
badge.textContent = totalHooks;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Hook Modal Functions ==========
|
||||
let editingHookData = null;
|
||||
|
||||
function openHookCreateModal(editData = null) {
|
||||
const modal = document.getElementById('hookCreateModal');
|
||||
const title = document.getElementById('hookModalTitle');
|
||||
|
||||
if (modal) {
|
||||
modal.classList.remove('hidden');
|
||||
editingHookData = editData;
|
||||
|
||||
// Set title based on mode
|
||||
title.textContent = editData ? 'Edit Hook' : 'Create Hook';
|
||||
|
||||
// Clear or populate form
|
||||
if (editData) {
|
||||
document.getElementById('hookEvent').value = editData.event || '';
|
||||
document.getElementById('hookMatcher').value = editData.matcher || '';
|
||||
document.getElementById('hookCommand').value = editData.command || '';
|
||||
document.getElementById('hookArgs').value = (editData.args || []).join('\n');
|
||||
|
||||
// Set scope radio
|
||||
const scopeRadio = document.querySelector(`input[name="hookScope"][value="${editData.scope || 'project'}"]`);
|
||||
if (scopeRadio) scopeRadio.checked = true;
|
||||
} else {
|
||||
document.getElementById('hookEvent').value = '';
|
||||
document.getElementById('hookMatcher').value = '';
|
||||
document.getElementById('hookCommand').value = '';
|
||||
document.getElementById('hookArgs').value = '';
|
||||
document.querySelector('input[name="hookScope"][value="project"]').checked = true;
|
||||
}
|
||||
|
||||
// Focus on event select
|
||||
document.getElementById('hookEvent').focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeHookCreateModal() {
|
||||
const modal = document.getElementById('hookCreateModal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
editingHookData = null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyHookTemplate(templateName) {
|
||||
const template = HOOK_TEMPLATES[templateName];
|
||||
if (!template) return;
|
||||
|
||||
document.getElementById('hookEvent').value = template.event;
|
||||
document.getElementById('hookMatcher').value = template.matcher;
|
||||
document.getElementById('hookCommand').value = template.command;
|
||||
document.getElementById('hookArgs').value = template.args.join('\n');
|
||||
}
|
||||
|
||||
async function submitHookCreate() {
|
||||
const event = document.getElementById('hookEvent').value;
|
||||
const matcher = document.getElementById('hookMatcher').value.trim();
|
||||
const command = document.getElementById('hookCommand').value.trim();
|
||||
const argsText = document.getElementById('hookArgs').value.trim();
|
||||
const scope = document.querySelector('input[name="hookScope"]:checked').value;
|
||||
|
||||
// Validate required fields
|
||||
if (!event) {
|
||||
showRefreshToast('Hook event is required', 'error');
|
||||
document.getElementById('hookEvent').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
showRefreshToast('Command is required', 'error');
|
||||
document.getElementById('hookCommand').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse args (one per line)
|
||||
const args = argsText ? argsText.split('\n').map(a => a.trim()).filter(a => a) : [];
|
||||
|
||||
// Build hook data
|
||||
const hookData = {
|
||||
command: command
|
||||
};
|
||||
|
||||
if (args.length > 0) {
|
||||
hookData.args = args;
|
||||
}
|
||||
|
||||
if (matcher) {
|
||||
hookData.matcher = matcher;
|
||||
}
|
||||
|
||||
// If editing, include original index for replacement
|
||||
if (editingHookData && editingHookData.index !== undefined) {
|
||||
hookData.replaceIndex = editingHookData.index;
|
||||
}
|
||||
|
||||
// Submit to API
|
||||
await saveHook(scope, event, hookData);
|
||||
closeHookCreateModal();
|
||||
}
|
||||
|
||||
// ========== Helpers ==========
|
||||
function getHookEventDescription(event) {
|
||||
const descriptions = {
|
||||
'PreToolUse': 'Runs before a tool is executed',
|
||||
'PostToolUse': 'Runs after a tool completes',
|
||||
'Notification': 'Runs when a notification is triggered',
|
||||
'Stop': 'Runs when the agent stops'
|
||||
};
|
||||
return descriptions[event] || event;
|
||||
}
|
||||
|
||||
function getHookEventIcon(event) {
|
||||
const icons = {
|
||||
'PreToolUse': '⏳',
|
||||
'PostToolUse': '✅',
|
||||
'Notification': '🔔',
|
||||
'Stop': '🛑'
|
||||
};
|
||||
return icons[event] || '🪝';
|
||||
}
|
||||
285
ccw/src/templates/dashboard-js/components/mcp-manager.js
Normal file
285
ccw/src/templates/dashboard-js/components/mcp-manager.js
Normal file
@@ -0,0 +1,285 @@
|
||||
// MCP Manager Component
|
||||
// Manages MCP server configuration from .claude.json
|
||||
|
||||
// ========== MCP State ==========
|
||||
let mcpConfig = null;
|
||||
let mcpAllProjects = {};
|
||||
let mcpCurrentProjectServers = {};
|
||||
|
||||
// ========== Initialization ==========
|
||||
function initMcpManager() {
|
||||
// Initialize MCP navigation
|
||||
document.querySelectorAll('.nav-item[data-view="mcp-manager"]').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
setActiveNavItem(item);
|
||||
currentView = 'mcp-manager';
|
||||
currentFilter = null;
|
||||
currentLiteType = null;
|
||||
currentSessionDetailKey = null;
|
||||
updateContentTitle();
|
||||
renderMcpManager();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Data Loading ==========
|
||||
async function loadMcpConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/mcp-config');
|
||||
if (!response.ok) throw new Error('Failed to load MCP config');
|
||||
const data = await response.json();
|
||||
mcpConfig = data;
|
||||
mcpAllProjects = data.projects || {};
|
||||
|
||||
// Get current project servers
|
||||
const currentPath = projectPath.replace(/\//g, '\\');
|
||||
mcpCurrentProjectServers = mcpAllProjects[currentPath]?.mcpServers || {};
|
||||
|
||||
// Update badge count
|
||||
updateMcpBadge();
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('Failed to load MCP config:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMcpServer(serverName, enable) {
|
||||
try {
|
||||
const response = await fetch('/api/mcp-toggle', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectPath: projectPath,
|
||||
serverName: serverName,
|
||||
enable: enable
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to toggle MCP server');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// Reload config and re-render
|
||||
await loadMcpConfig();
|
||||
renderMcpManager();
|
||||
showRefreshToast(`MCP server "${serverName}" ${enable ? 'enabled' : 'disabled'}`, 'success');
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle MCP server:', err);
|
||||
showRefreshToast(`Failed to toggle MCP server: ${err.message}`, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyMcpServerToProject(serverName, serverConfig) {
|
||||
try {
|
||||
const response = await fetch('/api/mcp-copy-server', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectPath: projectPath,
|
||||
serverName: serverName,
|
||||
serverConfig: serverConfig
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to copy MCP server');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
await loadMcpConfig();
|
||||
renderMcpManager();
|
||||
showRefreshToast(`MCP server "${serverName}" added to project`, 'success');
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Failed to copy MCP server:', err);
|
||||
showRefreshToast(`Failed to add MCP server: ${err.message}`, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMcpServerFromProject(serverName) {
|
||||
try {
|
||||
const response = await fetch('/api/mcp-remove-server', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectPath: projectPath,
|
||||
serverName: serverName
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to remove MCP server');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
await loadMcpConfig();
|
||||
renderMcpManager();
|
||||
showRefreshToast(`MCP server "${serverName}" removed from project`, 'success');
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Failed to remove MCP server:', err);
|
||||
showRefreshToast(`Failed to remove MCP server: ${err.message}`, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Badge Update ==========
|
||||
function updateMcpBadge() {
|
||||
const badge = document.getElementById('badgeMcpServers');
|
||||
if (badge) {
|
||||
const currentPath = projectPath.replace(/\//g, '\\');
|
||||
const projectData = mcpAllProjects[currentPath];
|
||||
const servers = projectData?.mcpServers || {};
|
||||
const disabledServers = projectData?.disabledMcpServers || [];
|
||||
|
||||
const totalServers = Object.keys(servers).length;
|
||||
const enabledServers = totalServers - disabledServers.length;
|
||||
|
||||
badge.textContent = `${enabledServers}/${totalServers}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Helpers ==========
|
||||
function getAllAvailableMcpServers() {
|
||||
const allServers = {};
|
||||
|
||||
// Collect servers from all projects
|
||||
for (const [path, config] of Object.entries(mcpAllProjects)) {
|
||||
const servers = config.mcpServers || {};
|
||||
for (const [name, serverConfig] of Object.entries(servers)) {
|
||||
if (!allServers[name]) {
|
||||
allServers[name] = {
|
||||
config: serverConfig,
|
||||
usedIn: []
|
||||
};
|
||||
}
|
||||
allServers[name].usedIn.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
return allServers;
|
||||
}
|
||||
|
||||
function isServerEnabledInCurrentProject(serverName) {
|
||||
const currentPath = projectPath.replace(/\//g, '\\');
|
||||
const projectData = mcpAllProjects[currentPath];
|
||||
if (!projectData) return false;
|
||||
|
||||
const disabledServers = projectData.disabledMcpServers || [];
|
||||
return !disabledServers.includes(serverName);
|
||||
}
|
||||
|
||||
function isServerInCurrentProject(serverName) {
|
||||
const currentPath = projectPath.replace(/\//g, '\\');
|
||||
const projectData = mcpAllProjects[currentPath];
|
||||
if (!projectData) return false;
|
||||
|
||||
const servers = projectData.mcpServers || {};
|
||||
return serverName in servers;
|
||||
}
|
||||
|
||||
// ========== MCP Create Modal ==========
|
||||
function openMcpCreateModal() {
|
||||
const modal = document.getElementById('mcpCreateModal');
|
||||
if (modal) {
|
||||
modal.classList.remove('hidden');
|
||||
// Clear form
|
||||
document.getElementById('mcpServerName').value = '';
|
||||
document.getElementById('mcpServerCommand').value = '';
|
||||
document.getElementById('mcpServerArgs').value = '';
|
||||
document.getElementById('mcpServerEnv').value = '';
|
||||
// Focus on name input
|
||||
document.getElementById('mcpServerName').focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeMcpCreateModal() {
|
||||
const modal = document.getElementById('mcpCreateModal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitMcpCreate() {
|
||||
const name = document.getElementById('mcpServerName').value.trim();
|
||||
const command = document.getElementById('mcpServerCommand').value.trim();
|
||||
const argsText = document.getElementById('mcpServerArgs').value.trim();
|
||||
const envText = document.getElementById('mcpServerEnv').value.trim();
|
||||
|
||||
// Validate required fields
|
||||
if (!name) {
|
||||
showRefreshToast('Server name is required', 'error');
|
||||
document.getElementById('mcpServerName').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
showRefreshToast('Command is required', 'error');
|
||||
document.getElementById('mcpServerCommand').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse args (one per line)
|
||||
const args = argsText ? argsText.split('\n').map(a => a.trim()).filter(a => a) : [];
|
||||
|
||||
// Parse env vars (KEY=VALUE per line)
|
||||
const env = {};
|
||||
if (envText) {
|
||||
envText.split('\n').forEach(line => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && trimmed.includes('=')) {
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
const value = trimmed.substring(eqIndex + 1).trim();
|
||||
if (key) {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Build server config
|
||||
const serverConfig = {
|
||||
command: command,
|
||||
args: args
|
||||
};
|
||||
|
||||
// Only add env if there are values
|
||||
if (Object.keys(env).length > 0) {
|
||||
serverConfig.env = env;
|
||||
}
|
||||
|
||||
// Submit to API
|
||||
try {
|
||||
const response = await fetch('/api/mcp-copy-server', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectPath: projectPath,
|
||||
serverName: name,
|
||||
serverConfig: serverConfig
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create MCP server');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
closeMcpCreateModal();
|
||||
await loadMcpConfig();
|
||||
renderMcpManager();
|
||||
showRefreshToast(`MCP server "${name}" created successfully`, 'success');
|
||||
} else {
|
||||
showRefreshToast(result.error || 'Failed to create MCP server', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create MCP server:', err);
|
||||
showRefreshToast(`Failed to create MCP server: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ function initNavigation() {
|
||||
});
|
||||
});
|
||||
|
||||
// Project Overview Navigation
|
||||
// View Navigation (Project Overview, MCP Manager, etc.)
|
||||
document.querySelectorAll('.nav-item[data-view]').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
setActiveNavItem(item);
|
||||
@@ -69,7 +69,13 @@ function initNavigation() {
|
||||
currentLiteType = null;
|
||||
currentSessionDetailKey = null;
|
||||
updateContentTitle();
|
||||
renderProjectOverview();
|
||||
|
||||
// Route to appropriate view
|
||||
if (currentView === 'mcp-manager') {
|
||||
renderMcpManager();
|
||||
} else if (currentView === 'project-overview') {
|
||||
renderProjectOverview();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -83,6 +89,8 @@ function updateContentTitle() {
|
||||
const titleEl = document.getElementById('contentTitle');
|
||||
if (currentView === 'project-overview') {
|
||||
titleEl.textContent = 'Project Overview';
|
||||
} else if (currentView === 'mcp-manager') {
|
||||
titleEl.textContent = 'MCP Server Management';
|
||||
} else if (currentView === 'liteTasks') {
|
||||
const names = { 'lite-plan': 'Lite Plan Sessions', 'lite-fix': 'Lite Fix Sessions' };
|
||||
titleEl.textContent = names[currentLiteType] || 'Lite Tasks';
|
||||
|
||||
194
ccw/src/templates/dashboard-js/components/notifications.js
Normal file
194
ccw/src/templates/dashboard-js/components/notifications.js
Normal file
@@ -0,0 +1,194 @@
|
||||
// ==========================================
|
||||
// NOTIFICATIONS COMPONENT
|
||||
// ==========================================
|
||||
// Real-time silent refresh (no notification bubbles)
|
||||
|
||||
let wsConnection = null;
|
||||
let autoRefreshInterval = null;
|
||||
let lastDataHash = null;
|
||||
const AUTO_REFRESH_INTERVAL_MS = 30000; // 30 seconds
|
||||
|
||||
// ========== WebSocket Connection ==========
|
||||
function initWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
|
||||
try {
|
||||
wsConnection = new WebSocket(wsUrl);
|
||||
|
||||
wsConnection.onopen = () => {
|
||||
console.log('[WS] Connected');
|
||||
};
|
||||
|
||||
wsConnection.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handleNotification(data);
|
||||
} catch (e) {
|
||||
console.error('[WS] Failed to parse message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
wsConnection.onclose = () => {
|
||||
console.log('[WS] Disconnected, reconnecting in 5s...');
|
||||
setTimeout(initWebSocket, 5000);
|
||||
};
|
||||
|
||||
wsConnection.onerror = (error) => {
|
||||
console.error('[WS] Error:', error);
|
||||
};
|
||||
} catch (e) {
|
||||
console.log('[WS] WebSocket not available, using polling');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Notification Handler ==========
|
||||
function handleNotification(data) {
|
||||
const { type, payload } = data;
|
||||
|
||||
// Silent refresh - no notification bubbles
|
||||
switch (type) {
|
||||
case 'session_updated':
|
||||
case 'summary_written':
|
||||
case 'task_completed':
|
||||
case 'new_session':
|
||||
// Just refresh data silently
|
||||
refreshIfNeeded();
|
||||
// Optionally highlight in carousel if it's the current session
|
||||
if (payload.sessionId && typeof carouselGoTo === 'function') {
|
||||
carouselGoTo(payload.sessionId);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('[WS] Unknown notification type:', type);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Auto Refresh ==========
|
||||
function initAutoRefresh() {
|
||||
// Calculate initial hash
|
||||
lastDataHash = calculateDataHash();
|
||||
|
||||
// Start polling interval
|
||||
autoRefreshInterval = setInterval(checkForChanges, AUTO_REFRESH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function calculateDataHash() {
|
||||
if (!workflowData) return null;
|
||||
|
||||
// Simple hash based on key data points
|
||||
const hashData = {
|
||||
activeSessions: (workflowData.activeSessions || []).length,
|
||||
archivedSessions: (workflowData.archivedSessions || []).length,
|
||||
totalTasks: workflowData.statistics?.totalTasks || 0,
|
||||
completedTasks: workflowData.statistics?.completedTasks || 0,
|
||||
generatedAt: workflowData.generatedAt
|
||||
};
|
||||
|
||||
return JSON.stringify(hashData);
|
||||
}
|
||||
|
||||
async function checkForChanges() {
|
||||
if (!window.SERVER_MODE) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/data?path=${encodeURIComponent(projectPath)}`);
|
||||
if (!response.ok) return;
|
||||
|
||||
const newData = await response.json();
|
||||
const newHash = JSON.stringify({
|
||||
activeSessions: (newData.activeSessions || []).length,
|
||||
archivedSessions: (newData.archivedSessions || []).length,
|
||||
totalTasks: newData.statistics?.totalTasks || 0,
|
||||
completedTasks: newData.statistics?.completedTasks || 0,
|
||||
generatedAt: newData.generatedAt
|
||||
});
|
||||
|
||||
if (newHash !== lastDataHash) {
|
||||
lastDataHash = newHash;
|
||||
// Silent refresh - no notification
|
||||
await refreshWorkspaceData(newData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[AutoRefresh] Check failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshIfNeeded() {
|
||||
if (!window.SERVER_MODE) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/data?path=${encodeURIComponent(projectPath)}`);
|
||||
if (!response.ok) return;
|
||||
|
||||
const newData = await response.json();
|
||||
await refreshWorkspaceData(newData);
|
||||
} catch (e) {
|
||||
console.error('[Refresh] Failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWorkspaceData(newData) {
|
||||
// Update global data
|
||||
window.workflowData = newData;
|
||||
|
||||
// Clear and repopulate stores
|
||||
Object.keys(sessionDataStore).forEach(k => delete sessionDataStore[k]);
|
||||
Object.keys(liteTaskDataStore).forEach(k => delete liteTaskDataStore[k]);
|
||||
|
||||
[...(newData.activeSessions || []), ...(newData.archivedSessions || [])].forEach(s => {
|
||||
const key = `session-${s.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
sessionDataStore[key] = s;
|
||||
});
|
||||
|
||||
[...(newData.liteTasks?.litePlan || []), ...(newData.liteTasks?.liteFix || [])].forEach(s => {
|
||||
const key = `lite-${s.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
liteTaskDataStore[key] = s;
|
||||
});
|
||||
|
||||
// Update UI silently
|
||||
updateStats();
|
||||
updateBadges();
|
||||
updateCarousel();
|
||||
|
||||
// Re-render current view if needed
|
||||
if (currentView === 'sessions') {
|
||||
renderSessions();
|
||||
} else if (currentView === 'liteTasks') {
|
||||
renderLiteTasks();
|
||||
}
|
||||
|
||||
lastDataHash = calculateDataHash();
|
||||
}
|
||||
|
||||
// ========== Cleanup ==========
|
||||
function stopAutoRefresh() {
|
||||
if (autoRefreshInterval) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
autoRefreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeWebSocket() {
|
||||
if (wsConnection) {
|
||||
wsConnection.close();
|
||||
wsConnection = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Navigation Helper ==========
|
||||
function goToSession(sessionId) {
|
||||
// Find session in carousel and navigate
|
||||
const sessionKey = `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
|
||||
// Jump to session in carousel if visible
|
||||
if (typeof carouselGoTo === 'function') {
|
||||
carouselGoTo(sessionId);
|
||||
}
|
||||
|
||||
// Navigate to session detail
|
||||
if (sessionDataStore[sessionKey]) {
|
||||
showSessionDetailPage(sessionKey);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user