UNPKG

shipdeck

Version:

Ship MVPs in 48 hours. Fix bugs in 30 seconds. The command deck for developers who ship.

1,107 lines (940 loc) โ€ข 25.9 kB
#!/usr/bin/env node /** * Shipdeck Ultimate Web Dashboard Server * Real-time monitoring and control interface for MVP builds */ const http = require('http'); const path = require('path'); const fs = require('fs'); const EventEmitter = require('events'); class DashboardServer extends EventEmitter { constructor(options = {}) { super(); this.port = options.port || 3456; this.workflowEngine = options.workflowEngine; this.aiManager = options.aiManager; this.server = null; this.clients = new Set(); // Track active workflows this.activeWorkflows = new Map(); // Dashboard state this.dashboardState = { workflows: [], agents: [], costs: { session: 0, daily: 0, monthly: 0 }, stats: { totalMVPs: 0, averageTime: 0, successRate: 100 } }; } /** * Start the dashboard server */ start() { this.server = http.createServer((req, res) => { this.handleRequest(req, res); }); this.server.on('upgrade', (request, socket, head) => { this.handleWebSocket(request, socket, head); }); this.server.listen(this.port, () => { console.log(`๐Ÿš€ Shipdeck Dashboard running at http://localhost:${this.port}`); this.emit('started', { port: this.port }); }); // Set up workflow event listeners this.setupWorkflowListeners(); return this; } /** * Handle HTTP requests */ handleRequest(req, res) { const url = new URL(req.url, `http://localhost:${this.port}`); // API endpoints if (url.pathname.startsWith('/api/')) { return this.handleAPI(url.pathname, req, res); } // Serve dashboard HTML if (url.pathname === '/' || url.pathname === '/dashboard') { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(this.getDashboardHTML()); return; } // Serve static assets if (url.pathname === '/dashboard.js') { res.writeHead(200, { 'Content-Type': 'application/javascript' }); res.end(this.getDashboardJS()); return; } if (url.pathname === '/dashboard.css') { res.writeHead(200, { 'Content-Type': 'text/css' }); res.end(this.getDashboardCSS()); return; } // 404 for everything else res.writeHead(404); res.end('Not Found'); } /** * Handle API requests */ handleAPI(pathname, req, res) { res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); switch(pathname) { case '/api/status': res.end(JSON.stringify({ status: 'active', workflows: Array.from(this.activeWorkflows.values()), state: this.dashboardState })); break; case '/api/workflows': res.end(JSON.stringify({ active: Array.from(this.activeWorkflows.values()), completed: this.dashboardState.workflows.filter(w => w.status === 'completed') })); break; case '/api/agents': res.end(JSON.stringify({ agents: this.dashboardState.agents, active: this.dashboardState.agents.filter(a => a.status === 'active') })); break; case '/api/costs': res.end(JSON.stringify(this.dashboardState.costs)); break; default: res.writeHead(404); res.end(JSON.stringify({ error: 'Endpoint not found' })); } } /** * Handle WebSocket connections for real-time updates */ handleWebSocket(request, socket, head) { const protocol = 'websocket'; const key = request.headers['sec-websocket-key']; const acceptKey = this.generateAcceptKey(key); const responseHeaders = [ 'HTTP/1.1 101 Switching Protocols', 'Upgrade: websocket', 'Connection: Upgrade', `Sec-WebSocket-Accept: ${acceptKey}`, '', '' ].join('\r\n'); socket.write(responseHeaders); // Add client to set this.clients.add(socket); // Send initial state this.sendToClient(socket, { type: 'initial', data: this.dashboardState }); // Handle client messages socket.on('data', (buffer) => { const message = this.parseWebSocketFrame(buffer); if (message) { this.handleClientMessage(socket, message); } }); // Clean up on disconnect socket.on('close', () => { this.clients.delete(socket); }); socket.on('error', (err) => { console.error('WebSocket error:', err); this.clients.delete(socket); }); } /** * Generate WebSocket accept key */ generateAcceptKey(key) { const crypto = require('crypto'); const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; return crypto .createHash('sha1') .update(key + GUID) .digest('base64'); } /** * Parse WebSocket frame (simplified) */ parseWebSocketFrame(buffer) { if (buffer.length < 2) return null; const firstByte = buffer[0]; const secondByte = buffer[1]; const fin = !!(firstByte & 0x80); const opcode = firstByte & 0x0f; const masked = !!(secondByte & 0x80); let payloadLength = secondByte & 0x7f; if (opcode !== 1) return null; // Only handle text frames let offset = 2; if (payloadLength === 126) { payloadLength = buffer.readUInt16BE(offset); offset += 2; } else if (payloadLength === 127) { offset += 8; // Skip for simplicity return null; } let maskKey; if (masked) { maskKey = buffer.slice(offset, offset + 4); offset += 4; } const payload = buffer.slice(offset, offset + payloadLength); if (masked) { for (let i = 0; i < payload.length; i++) { payload[i] ^= maskKey[i % 4]; } } try { return JSON.parse(payload.toString()); } catch { return null; } } /** * Send message to WebSocket client */ sendToClient(socket, data) { const message = JSON.stringify(data); const messageBuffer = Buffer.from(message); let frame; if (messageBuffer.length < 126) { frame = Buffer.allocUnsafe(2); frame[0] = 0x81; // FIN = 1, opcode = 1 (text) frame[1] = messageBuffer.length; } else if (messageBuffer.length < 65536) { frame = Buffer.allocUnsafe(4); frame[0] = 0x81; frame[1] = 126; frame.writeUInt16BE(messageBuffer.length, 2); } else { // Large frames not implemented for simplicity return; } socket.write(Buffer.concat([frame, messageBuffer])); } /** * Broadcast to all connected clients */ broadcast(data) { for (const client of this.clients) { this.sendToClient(client, data); } } /** * Handle messages from clients */ handleClientMessage(socket, message) { switch(message.type) { case 'pause': this.pauseWorkflow(message.workflowId); break; case 'resume': this.resumeWorkflow(message.workflowId); break; case 'cancel': this.cancelWorkflow(message.workflowId); break; case 'ping': this.sendToClient(socket, { type: 'pong' }); break; } } /** * Set up workflow engine event listeners */ setupWorkflowListeners() { if (!this.workflowEngine) return; // Workflow lifecycle events this.workflowEngine.on('workflow:started', (data) => { this.activeWorkflows.set(data.id, { id: data.id, name: data.name, status: 'running', progress: 0, startTime: Date.now(), nodes: data.nodes || [] }); this.broadcast({ type: 'workflow:started', data }); }); this.workflowEngine.on('workflow:progress', (data) => { const workflow = this.activeWorkflows.get(data.id); if (workflow) { workflow.progress = data.progress; workflow.currentNode = data.currentNode; this.broadcast({ type: 'workflow:progress', data }); } }); this.workflowEngine.on('workflow:completed', (data) => { const workflow = this.activeWorkflows.get(data.id); if (workflow) { workflow.status = 'completed'; workflow.progress = 100; workflow.endTime = Date.now(); workflow.duration = workflow.endTime - workflow.startTime; // Move to completed this.dashboardState.workflows.push(workflow); this.activeWorkflows.delete(data.id); // Update stats this.dashboardState.stats.totalMVPs++; this.broadcast({ type: 'workflow:completed', data }); } }); this.workflowEngine.on('workflow:error', (data) => { const workflow = this.activeWorkflows.get(data.id); if (workflow) { workflow.status = 'error'; workflow.error = data.error; this.broadcast({ type: 'workflow:error', data }); } }); // Agent events this.workflowEngine.on('agent:started', (data) => { this.dashboardState.agents.push({ id: data.id, name: data.name, status: 'active', task: data.task, startTime: Date.now() }); this.broadcast({ type: 'agent:started', data }); }); this.workflowEngine.on('agent:completed', (data) => { const agent = this.dashboardState.agents.find(a => a.id === data.id); if (agent) { agent.status = 'completed'; agent.endTime = Date.now(); agent.tokensUsed = data.tokensUsed; agent.cost = data.cost; // Update costs this.dashboardState.costs.session += data.cost || 0; this.broadcast({ type: 'agent:completed', data }); } }); } /** * Pause a workflow */ pauseWorkflow(workflowId) { if (this.workflowEngine) { this.workflowEngine.pauseWorkflow(workflowId); } const workflow = this.activeWorkflows.get(workflowId); if (workflow) { workflow.status = 'paused'; this.broadcast({ type: 'workflow:paused', data: { id: workflowId } }); } } /** * Resume a workflow */ resumeWorkflow(workflowId) { if (this.workflowEngine) { this.workflowEngine.resumeWorkflow(workflowId); } const workflow = this.activeWorkflows.get(workflowId); if (workflow) { workflow.status = 'running'; this.broadcast({ type: 'workflow:resumed', data: { id: workflowId } }); } } /** * Cancel a workflow */ cancelWorkflow(workflowId) { if (this.workflowEngine) { this.workflowEngine.cancelWorkflow(workflowId); } const workflow = this.activeWorkflows.get(workflowId); if (workflow) { workflow.status = 'cancelled'; this.activeWorkflows.delete(workflowId); this.broadcast({ type: 'workflow:cancelled', data: { id: workflowId } }); } } /** * Get dashboard HTML */ getDashboardHTML() { return `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Shipdeck Ultimate Dashboard</title> <link rel="stylesheet" href="/dashboard.css"> </head> <body> <div id="app"> <header class="header"> <div class="container"> <h1>๐Ÿšข Shipdeck Ultimate</h1> <div class="header-stats"> <div class="stat"> <span class="stat-label">Active Workflows</span> <span class="stat-value" id="active-workflows">0</span> </div> <div class="stat"> <span class="stat-label">Session Cost</span> <span class="stat-value" id="session-cost">$0.00</span> </div> <div class="stat"> <span class="stat-label">Success Rate</span> <span class="stat-value" id="success-rate">100%</span> </div> </div> </div> </header> <main class="main"> <div class="container"> <div class="grid"> <!-- Workflow Progress --> <section class="card workflow-section"> <h2>Active Workflows</h2> <div id="workflows-container"> <div class="empty-state">No active workflows</div> </div> </section> <!-- Agent Activity --> <section class="card agent-section"> <h2>Agent Activity</h2> <div id="agents-container"> <div class="empty-state">No active agents</div> </div> </section> <!-- Cost Tracking --> <section class="card cost-section"> <h2>Cost Analysis</h2> <div id="cost-container"> <canvas id="cost-chart"></canvas> <div class="cost-breakdown"> <div class="cost-item"> <span>Haiku:</span> <span id="haiku-cost">$0.00</span> </div> <div class="cost-item"> <span>Sonnet:</span> <span id="sonnet-cost">$0.00</span> </div> <div class="cost-item"> <span>Opus:</span> <span id="opus-cost">$0.00</span> </div> </div> </div> </section> <!-- Event Timeline --> <section class="card timeline-section"> <h2>Event Timeline</h2> <div id="timeline-container"> <div class="timeline"></div> </div> </section> </div> </div> </main> <footer class="footer"> <div class="container"> <p>Ship MVPs in 48 hours with AI โ€ข <span id="connection-status">Connecting...</span></p> </div> </footer> </div> <script src="/dashboard.js"></script> </body> </html>`; } /** * Get dashboard JavaScript */ getDashboardJS() { return `// Shipdeck Ultimate Dashboard Client class Dashboard { constructor() { this.ws = null; this.state = { workflows: [], agents: [], costs: { session: 0, daily: 0, monthly: 0 }, events: [] }; this.init(); } init() { this.connectWebSocket(); this.setupEventHandlers(); this.startUpdateLoop(); } connectWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = protocol + '//' + window.location.host; this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { document.getElementById('connection-status').textContent = '๐ŸŸข Connected'; console.log('Connected to dashboard server'); }; this.ws.onmessage = (event) => { const message = JSON.parse(event.data); this.handleMessage(message); }; this.ws.onclose = () => { document.getElementById('connection-status').textContent = '๐Ÿ”ด Disconnected'; setTimeout(() => this.connectWebSocket(), 3000); }; this.ws.onerror = (error) => { console.error('WebSocket error:', error); }; } handleMessage(message) { switch(message.type) { case 'initial': this.state = message.data; this.updateUI(); break; case 'workflow:started': this.addWorkflow(message.data); break; case 'workflow:progress': this.updateWorkflowProgress(message.data); break; case 'workflow:completed': this.completeWorkflow(message.data); break; case 'agent:started': this.addAgent(message.data); break; case 'agent:completed': this.completeAgent(message.data); break; default: this.addEvent(message); } } addWorkflow(workflow) { const container = document.getElementById('workflows-container'); // Remove empty state const emptyState = container.querySelector('.empty-state'); if (emptyState) emptyState.remove(); const workflowEl = document.createElement('div'); workflowEl.className = 'workflow-item'; workflowEl.id = 'workflow-' + workflow.id; workflowEl.innerHTML = \` <div class="workflow-header"> <h3>\${workflow.name || 'MVP Build'}</h3> <div class="workflow-controls"> <button onclick="dashboard.pauseWorkflow('\${workflow.id}')">โธ</button> <button onclick="dashboard.cancelWorkflow('\${workflow.id}')">โœ•</button> </div> </div> <div class="progress-bar"> <div class="progress-fill" style="width: 0%"></div> </div> <div class="workflow-status">Starting...</div> \`; container.appendChild(workflowEl); } updateWorkflowProgress(data) { const workflowEl = document.getElementById('workflow-' + data.id); if (!workflowEl) return; const progressFill = workflowEl.querySelector('.progress-fill'); const status = workflowEl.querySelector('.workflow-status'); progressFill.style.width = data.progress + '%'; status.textContent = data.currentNode || 'Processing...'; } completeWorkflow(data) { const workflowEl = document.getElementById('workflow-' + data.id); if (!workflowEl) return; workflowEl.classList.add('completed'); const status = workflowEl.querySelector('.workflow-status'); status.textContent = 'โœ… Completed'; } addAgent(agent) { const container = document.getElementById('agents-container'); // Remove empty state const emptyState = container.querySelector('.empty-state'); if (emptyState) emptyState.remove(); const agentEl = document.createElement('div'); agentEl.className = 'agent-item active'; agentEl.id = 'agent-' + agent.id; agentEl.innerHTML = \` <div class="agent-icon">\${this.getAgentIcon(agent.name)}</div> <div class="agent-info"> <div class="agent-name">\${agent.name}</div> <div class="agent-task">\${agent.task || 'Working...'}</div> </div> <div class="agent-status"> <div class="spinner"></div> </div> \`; container.appendChild(agentEl); } completeAgent(data) { const agentEl = document.getElementById('agent-' + data.id); if (!agentEl) return; agentEl.classList.remove('active'); const status = agentEl.querySelector('.agent-status'); status.innerHTML = \`<span class="cost">$\${(data.cost || 0).toFixed(4)}</span>\`; // Update costs this.updateCosts(data.cost || 0); } updateCosts(addedCost) { this.state.costs.session += addedCost; document.getElementById('session-cost').textContent = '$' + this.state.costs.session.toFixed(2); } addEvent(event) { const timeline = document.querySelector('.timeline'); const eventEl = document.createElement('div'); eventEl.className = 'timeline-event'; eventEl.innerHTML = \` <div class="event-time">\${new Date().toLocaleTimeString()}</div> <div class="event-type">\${event.type}</div> \`; timeline.insertBefore(eventEl, timeline.firstChild); // Keep only last 20 events while (timeline.children.length > 20) { timeline.removeChild(timeline.lastChild); } } getAgentIcon(agentName) { const icons = { 'backend-architect': '๐Ÿ—๏ธ', 'frontend-developer': '๐ŸŽจ', 'ai-engineer': '๐Ÿค–', 'test-writer-fixer': '๐Ÿงช', 'devops-automator': 'โš™๏ธ' }; return icons[agentName] || '๐Ÿค–'; } pauseWorkflow(workflowId) { this.ws.send(JSON.stringify({ type: 'pause', workflowId })); } resumeWorkflow(workflowId) { this.ws.send(JSON.stringify({ type: 'resume', workflowId })); } cancelWorkflow(workflowId) { if (confirm('Cancel this workflow?')) { this.ws.send(JSON.stringify({ type: 'cancel', workflowId })); } } setupEventHandlers() { // Keyboard shortcuts document.addEventListener('keydown', (e) => { if (e.key === 'r' && e.metaKey) { e.preventDefault(); location.reload(); } }); } startUpdateLoop() { setInterval(() => { // Update active workflow count const activeCount = document.querySelectorAll('.workflow-item:not(.completed)').length; document.getElementById('active-workflows').textContent = activeCount; // Send ping to keep connection alive if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'ping' })); } }, 5000); } updateUI() { // Update stats document.getElementById('active-workflows').textContent = this.state.workflows.filter(w => w.status === 'running').length; document.getElementById('session-cost').textContent = '$' + this.state.costs.session.toFixed(2); document.getElementById('success-rate').textContent = this.state.stats?.successRate + '%' || '100%'; } } // Initialize dashboard const dashboard = new Dashboard();`; } /** * Get dashboard CSS */ getDashboardCSS() { return `:root { --primary: #0066ff; --success: #00c853; --warning: #ff9800; --danger: #f44336; --bg: #0a0a0a; --card-bg: #1a1a1a; --text: #ffffff; --text-secondary: #888; --border: #333; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; } .container { max-width: 1400px; margin: 0 auto; padding: 0 20px; } /* Header */ .header { background: var(--card-bg); border-bottom: 1px solid var(--border); padding: 20px 0; } .header h1 { font-size: 24px; margin-bottom: 10px; } .header-stats { display: flex; gap: 30px; } .stat { display: flex; flex-direction: column; } .stat-label { font-size: 12px; color: var(--text-secondary); text-transform: uppercase; } .stat-value { font-size: 20px; font-weight: bold; color: var(--primary); } /* Main */ .main { padding: 30px 0; } .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } @media (max-width: 768px) { .grid { grid-template-columns: 1fr; } } /* Cards */ .card { background: var(--card-bg); border-radius: 8px; padding: 20px; border: 1px solid var(--border); } .card h2 { font-size: 18px; margin-bottom: 15px; color: var(--text); } /* Workflows */ .workflow-item { background: rgba(0, 102, 255, 0.1); border: 1px solid var(--primary); border-radius: 4px; padding: 15px; margin-bottom: 10px; } .workflow-item.completed { opacity: 0.6; } .workflow-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } .workflow-header h3 { font-size: 14px; color: var(--text); } .workflow-controls button { background: transparent; border: 1px solid var(--border); color: var(--text); padding: 4px 8px; margin-left: 5px; cursor: pointer; border-radius: 3px; } .workflow-controls button:hover { background: rgba(255, 255, 255, 0.1); } .progress-bar { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; margin-bottom: 8px; } .progress-fill { height: 100%; background: var(--primary); transition: width 0.3s ease; } .workflow-status { font-size: 12px; color: var(--text-secondary); } /* Agents */ .agent-item { display: flex; align-items: center; padding: 10px; background: rgba(255, 255, 255, 0.05); border-radius: 4px; margin-bottom: 8px; } .agent-item.active { border-left: 3px solid var(--success); } .agent-icon { font-size: 24px; margin-right: 12px; } .agent-info { flex: 1; } .agent-name { font-size: 14px; font-weight: bold; } .agent-task { font-size: 12px; color: var(--text-secondary); } .agent-status { display: flex; align-items: center; } .spinner { width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .cost { font-size: 12px; color: var(--success); font-weight: bold; } /* Cost Section */ .cost-breakdown { margin-top: 15px; } .cost-item { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); } .cost-item:last-child { border-bottom: none; } /* Timeline */ .timeline { max-height: 300px; overflow-y: auto; } .timeline-event { display: flex; gap: 10px; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; } .event-time { color: var(--text-secondary); min-width: 80px; } .event-type { color: var(--text); } /* Empty State */ .empty-state { text-align: center; padding: 40px; color: var(--text-secondary); font-size: 14px; } /* Footer */ .footer { background: var(--card-bg); border-top: 1px solid var(--border); padding: 20px 0; margin-top: 50px; text-align: center; font-size: 14px; color: var(--text-secondary); } #connection-status { margin-left: 10px; }`; } /** * Stop the server */ stop() { if (this.server) { // Close all WebSocket connections for (const client of this.clients) { client.end(); } this.clients.clear(); // Close HTTP server this.server.close(() => { console.log('Dashboard server stopped'); this.emit('stopped'); }); } } } module.exports = { DashboardServer };