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
JavaScript
/**
* 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;
}
(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;
}
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 };