mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
329 lines
11 KiB
JavaScript
329 lines
11 KiB
JavaScript
import http from 'http';
|
|
import { URL } from 'url';
|
|
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { scanSessions } from './session-scanner.js';
|
|
import { aggregateData } from './data-aggregator.js';
|
|
import { resolvePath, getRecentPaths, trackRecentPath, normalizePathForDisplay, getWorkflowDir } from '../utils/path-resolver.js';
|
|
|
|
const TEMPLATE_PATH = join(import.meta.dirname, '../templates/dashboard.html');
|
|
const CSS_FILE = join(import.meta.dirname, '../templates/dashboard.css');
|
|
const JS_FILE = join(import.meta.dirname, '../templates/dashboard.js');
|
|
|
|
/**
|
|
* Create and start the dashboard server
|
|
* @param {Object} options - Server options
|
|
* @param {number} options.port - Port to listen on (default: 3456)
|
|
* @param {string} options.initialPath - Initial project path
|
|
* @returns {Promise<http.Server>}
|
|
*/
|
|
export async function startServer(options = {}) {
|
|
const port = options.port || 3456;
|
|
const initialPath = options.initialPath || process.cwd();
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
const url = new URL(req.url, `http://localhost:${port}`);
|
|
const pathname = url.pathname;
|
|
|
|
// CORS headers for API requests
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(200);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// API: Get workflow data for a path
|
|
if (pathname === '/api/data') {
|
|
const projectPath = url.searchParams.get('path') || initialPath;
|
|
const data = await getWorkflowData(projectPath);
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(data));
|
|
return;
|
|
}
|
|
|
|
// API: Get recent paths
|
|
if (pathname === '/api/recent-paths') {
|
|
const paths = getRecentPaths();
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ paths }));
|
|
return;
|
|
}
|
|
|
|
// API: Get session detail data (context, summaries, impl-plan, review)
|
|
if (pathname === '/api/session-detail') {
|
|
const sessionPath = url.searchParams.get('path');
|
|
const dataType = url.searchParams.get('type') || 'all';
|
|
|
|
if (!sessionPath) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Session path is required' }));
|
|
return;
|
|
}
|
|
|
|
const detail = await getSessionDetailData(sessionPath, dataType);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(detail));
|
|
return;
|
|
}
|
|
|
|
// Serve dashboard HTML
|
|
if (pathname === '/' || pathname === '/index.html') {
|
|
const html = generateServerDashboard(initialPath);
|
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
res.end(html);
|
|
return;
|
|
}
|
|
|
|
// 404
|
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
res.end('Not Found');
|
|
|
|
} catch (error) {
|
|
console.error('Server error:', error);
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: error.message }));
|
|
}
|
|
});
|
|
|
|
return new Promise((resolve, reject) => {
|
|
server.listen(port, () => {
|
|
console.log(`Dashboard server running at http://localhost:${port}`);
|
|
resolve(server);
|
|
});
|
|
server.on('error', reject);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get workflow data for a project path
|
|
* @param {string} projectPath
|
|
* @returns {Promise<Object>}
|
|
*/
|
|
async function getWorkflowData(projectPath) {
|
|
const resolvedPath = resolvePath(projectPath);
|
|
const workflowDir = join(resolvedPath, '.workflow');
|
|
|
|
// Track this path
|
|
trackRecentPath(resolvedPath);
|
|
|
|
// Check if .workflow exists
|
|
if (!existsSync(workflowDir)) {
|
|
return {
|
|
generatedAt: new Date().toISOString(),
|
|
activeSessions: [],
|
|
archivedSessions: [],
|
|
liteTasks: { litePlan: [], liteFix: [] },
|
|
reviewData: { dimensions: {} },
|
|
statistics: {
|
|
totalSessions: 0,
|
|
activeSessions: 0,
|
|
totalTasks: 0,
|
|
completedTasks: 0,
|
|
reviewFindings: 0,
|
|
litePlanCount: 0,
|
|
liteFixCount: 0
|
|
},
|
|
projectPath: normalizePathForDisplay(resolvedPath),
|
|
recentPaths: getRecentPaths()
|
|
};
|
|
}
|
|
|
|
// Scan and aggregate data
|
|
const sessions = await scanSessions(workflowDir);
|
|
const data = await aggregateData(sessions, workflowDir);
|
|
|
|
data.projectPath = normalizePathForDisplay(resolvedPath);
|
|
data.recentPaths = getRecentPaths();
|
|
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Get session detail data (context, summaries, impl-plan, review)
|
|
* @param {string} sessionPath - Path to session directory
|
|
* @param {string} dataType - Type of data to load: context, summary, impl-plan, review, or all
|
|
* @returns {Promise<Object>}
|
|
*/
|
|
async function getSessionDetailData(sessionPath, dataType) {
|
|
const result = {};
|
|
|
|
// Normalize path
|
|
const normalizedPath = sessionPath.replace(/\\/g, '/');
|
|
|
|
try {
|
|
// Load context-package.json (in .process/ subfolder)
|
|
if (dataType === 'context' || dataType === 'all') {
|
|
// Try .process/context-package.json first (common location)
|
|
let contextFile = join(normalizedPath, '.process', 'context-package.json');
|
|
if (!existsSync(contextFile)) {
|
|
// Fallback to session root
|
|
contextFile = join(normalizedPath, 'context-package.json');
|
|
}
|
|
if (existsSync(contextFile)) {
|
|
try {
|
|
result.context = JSON.parse(readFileSync(contextFile, 'utf8'));
|
|
} catch (e) {
|
|
result.context = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load task JSONs from .task/ folder
|
|
if (dataType === 'tasks' || dataType === 'all') {
|
|
const taskDir = join(normalizedPath, '.task');
|
|
result.tasks = [];
|
|
if (existsSync(taskDir)) {
|
|
const files = readdirSync(taskDir).filter(f => f.endsWith('.json') && f.startsWith('IMPL-'));
|
|
for (const file of files) {
|
|
try {
|
|
const content = JSON.parse(readFileSync(join(taskDir, file), 'utf8'));
|
|
result.tasks.push({
|
|
filename: file,
|
|
task_id: file.replace('.json', ''),
|
|
...content
|
|
});
|
|
} catch (e) {
|
|
// Skip unreadable files
|
|
}
|
|
}
|
|
// Sort by task ID
|
|
result.tasks.sort((a, b) => a.task_id.localeCompare(b.task_id));
|
|
}
|
|
}
|
|
|
|
// Load summaries from .summaries/
|
|
if (dataType === 'summary' || dataType === 'all') {
|
|
const summariesDir = join(normalizedPath, '.summaries');
|
|
result.summaries = [];
|
|
if (existsSync(summariesDir)) {
|
|
const files = readdirSync(summariesDir).filter(f => f.endsWith('.md'));
|
|
for (const file of files) {
|
|
try {
|
|
const content = readFileSync(join(summariesDir, file), 'utf8');
|
|
result.summaries.push({ name: file.replace('.md', ''), content });
|
|
} catch (e) {
|
|
// Skip unreadable files
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load IMPL_PLAN.md
|
|
if (dataType === 'impl-plan' || dataType === 'all') {
|
|
const implPlanFile = join(normalizedPath, 'IMPL_PLAN.md');
|
|
if (existsSync(implPlanFile)) {
|
|
try {
|
|
result.implPlan = readFileSync(implPlanFile, 'utf8');
|
|
} catch (e) {
|
|
result.implPlan = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load review data from .review/
|
|
if (dataType === 'review' || dataType === 'all') {
|
|
const reviewDir = join(normalizedPath, '.review');
|
|
result.review = { dimensions: {} };
|
|
if (existsSync(reviewDir)) {
|
|
const dimensionsDir = join(reviewDir, 'dimensions');
|
|
if (existsSync(dimensionsDir)) {
|
|
const files = readdirSync(dimensionsDir).filter(f => f.endsWith('.json'));
|
|
for (const file of files) {
|
|
try {
|
|
const dimName = file.replace('.json', '');
|
|
const content = JSON.parse(readFileSync(join(dimensionsDir, file), 'utf8'));
|
|
result.review.dimensions[dimName] = content.findings || content;
|
|
} catch (e) {
|
|
// Skip unreadable files
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error loading session detail:', error);
|
|
result.error = error.message;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Generate dashboard HTML for server mode
|
|
* @param {string} initialPath
|
|
* @returns {string}
|
|
*/
|
|
function generateServerDashboard(initialPath) {
|
|
let html = readFileSync(TEMPLATE_PATH, 'utf8');
|
|
|
|
// Read CSS and JS files
|
|
const cssContent = existsSync(CSS_FILE) ? readFileSync(CSS_FILE, 'utf8') : '';
|
|
let jsContent = existsSync(JS_FILE) ? readFileSync(JS_FILE, 'utf8') : '';
|
|
|
|
// Inject CSS content
|
|
html = html.replace('{{CSS_CONTENT}}', cssContent);
|
|
|
|
// Prepare JS content with empty initial data (will be loaded dynamically)
|
|
const emptyData = {
|
|
generatedAt: new Date().toISOString(),
|
|
activeSessions: [],
|
|
archivedSessions: [],
|
|
liteTasks: { litePlan: [], liteFix: [] },
|
|
reviewData: { dimensions: {} },
|
|
statistics: { totalSessions: 0, activeSessions: 0, totalTasks: 0, completedTasks: 0, reviewFindings: 0, litePlanCount: 0, liteFixCount: 0 }
|
|
};
|
|
|
|
// Replace JS placeholders
|
|
jsContent = jsContent.replace('{{WORKFLOW_DATA}}', JSON.stringify(emptyData, null, 2));
|
|
jsContent = jsContent.replace(/\{\{PROJECT_PATH\}\}/g, normalizePathForDisplay(initialPath).replace(/\\/g, '/'));
|
|
jsContent = jsContent.replace('{{RECENT_PATHS}}', JSON.stringify(getRecentPaths()));
|
|
|
|
// Add server mode flag and dynamic loading functions at the start of JS
|
|
const serverModeScript = `
|
|
// Server mode - load data dynamically
|
|
window.SERVER_MODE = true;
|
|
window.INITIAL_PATH = '${normalizePathForDisplay(initialPath).replace(/\\/g, '/')}';
|
|
|
|
async function loadDashboardData(path) {
|
|
try {
|
|
const res = await fetch('/api/data?path=' + encodeURIComponent(path));
|
|
if (!res.ok) throw new Error('Failed to load data');
|
|
return await res.json();
|
|
} catch (err) {
|
|
console.error('Error loading data:', err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function loadRecentPaths() {
|
|
try {
|
|
const res = await fetch('/api/recent-paths');
|
|
if (!res.ok) return [];
|
|
const data = await res.json();
|
|
return data.paths || [];
|
|
} catch (err) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
`;
|
|
|
|
// Prepend server mode script to JS content
|
|
jsContent = serverModeScript + jsContent;
|
|
|
|
// Inject JS content
|
|
html = html.replace('{{JS_CONTENT}}', jsContent);
|
|
|
|
// Replace any remaining placeholders in HTML
|
|
html = html.replace(/\{\{PROJECT_PATH\}\}/g, normalizePathForDisplay(initialPath).replace(/\\/g, '/'));
|
|
|
|
return html;
|
|
}
|