From 6d945a19134fcfb5445f0c5232dcfc6c28fcb95a Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 3 Mar 2026 16:04:22 -0500 Subject: [PATCH] feat: Gandalf M2M API, manual/automated execution sub-tabs, cleanup tuning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server.js: add authenticateGandalf middleware (X-Gandalf-API-Key header) and two internal endpoints used by Gandalf link diagnostics: POST /api/internal/command — submit SSH command to a worker, returns execution_id GET /api/internal/executions/:id — poll execution status/logs Also tag automated executions as started_by 'gandalf:*' / 'scheduler:*'; add hide_internal query param to GET /api/executions; change cleanup from daily/30d to hourly/1d to keep execution history lean - index.html: add Manual / Automated sub-tabs on Execution History tab so Gandalf diagnostic runs don't clutter the manual run view; persists selected tab to localStorage; dashboard recent-run strip filters to manual runs only; sub-tabs show live counts Co-Authored-By: Claude Sonnet 4.6 --- public/index.html | 68 ++++++++++++++++++++++++++++++++++-- server.js | 87 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 145 insertions(+), 10 deletions(-) diff --git a/public/index.html b/public/index.html index c4e19f7..049bf10 100644 --- a/public/index.html +++ b/public/index.html @@ -811,6 +811,20 @@

Execution History

+ +
+ + +
+
@@ -1358,6 +1372,7 @@ let executionOffset = 0; const executionLimit = 50; + let executionView = localStorage.getItem('pulse_executionView') || 'manual'; async function loadExecutions(append = false) { try { @@ -1374,11 +1389,12 @@ allExecutions = executions; } - // Dashboard view (always first 5) + // Dashboard view (first 5 manual runs only) if (!append) { - const dashHtml = executions.length === 0 ? + const manualExecs = executions.filter(e => !isAutomatedRun(e)); + const dashHtml = manualExecs.length === 0 ? '
No executions yet
' : - executions.slice(0, 5).map(e => ` + manualExecs.slice(0, 5).map(e => `
${e.status} ${e.workflow_name || '[Quick Command]'} @@ -1402,12 +1418,57 @@ } } + function isAutomatedRun(e) { + const by = e.started_by || ''; + return by.startsWith('gandalf:') || by.startsWith('scheduler:'); + } + + function updateSubTabCounts() { + const manual = allExecutions.filter(e => !isAutomatedRun(e)).length; + const automated = allExecutions.filter(e => isAutomatedRun(e)).length; + const cm = document.getElementById('countManual'); + const ca = document.getElementById('countAutomated'); + if (cm) cm.textContent = manual ? `(${manual}) ` : ''; + if (ca) ca.textContent = automated ? `(${automated}) ` : ''; + } + + function setExecutionView(view) { + executionView = view; + localStorage.setItem('pulse_executionView', view); + const manualBtn = document.getElementById('subTabManual'); + const autoBtn = document.getElementById('subTabAutomated'); + if (manualBtn && autoBtn) { + if (view === 'manual') { + manualBtn.style.background = 'rgba(0,255,65,0.2)'; + manualBtn.style.color = 'var(--terminal-amber)'; + manualBtn.style.textShadow = '0 0 5px #ffb000'; + autoBtn.style.background = 'transparent'; + autoBtn.style.color = 'var(--terminal-green)'; + autoBtn.style.textShadow = 'none'; + } else { + autoBtn.style.background = 'rgba(0,255,65,0.2)'; + autoBtn.style.color = 'var(--terminal-amber)'; + autoBtn.style.textShadow = '0 0 5px #ffb000'; + manualBtn.style.background = 'transparent'; + manualBtn.style.color = 'var(--terminal-green)'; + manualBtn.style.textShadow = 'none'; + } + } + renderFilteredExecutions(); + } + function renderFilteredExecutions() { const searchTerm = (document.getElementById('executionSearch')?.value || '').toLowerCase(); const statusFilter = document.getElementById('statusFilter')?.value || ''; + updateSubTabCounts(); + // Filter executions let filtered = allExecutions.filter(e => { + // View filter (manual vs automated) + if (executionView === 'manual' && isAutomatedRun(e)) return false; + if (executionView === 'automated' && !isAutomatedRun(e)) return false; + // Status filter if (statusFilter && e.status !== statusFilter) return false; @@ -2580,6 +2641,7 @@ // Initialize loadUser().then((success) => { if (success) { + setExecutionView(executionView); refreshData(); connectWebSocket(); setInterval(refreshData, 30000); diff --git a/server.js b/server.js index 0ac5d29..719cdb7 100644 --- a/server.js +++ b/server.js @@ -116,24 +116,26 @@ async function initDatabase() { } } -// Auto-cleanup old executions (runs daily) +// Auto-cleanup old executions (runs hourly) async function cleanupOldExecutions() { try { - const retentionDays = parseInt(process.env.EXECUTION_RETENTION_DAYS) || 30; + const retentionDays = parseInt(process.env.EXECUTION_RETENTION_DAYS) || 1; const [result] = await pool.query( `DELETE FROM executions WHERE status IN ('completed', 'failed') AND started_at < DATE_SUB(NOW(), INTERVAL ? DAY)`, [retentionDays] ); - console.log(`[Cleanup] Removed ${result.affectedRows} executions older than ${retentionDays} days`); + if (result.affectedRows > 0) { + console.log(`[Cleanup] Removed ${result.affectedRows} executions older than ${retentionDays} day(s)`); + } } catch (error) { console.error('[Cleanup] Error removing old executions:', error); } } -// Run cleanup daily at 3 AM -setInterval(cleanupOldExecutions, 24 * 60 * 60 * 1000); +// Run cleanup hourly +setInterval(cleanupOldExecutions, 60 * 60 * 1000); // Run cleanup on startup cleanupOldExecutions(); @@ -481,6 +483,16 @@ async function authenticateSSO(req, res, next) { next(); } +// Gandalf machine-to-machine API key auth +function authenticateGandalf(req, res, next) { + const apiKey = req.headers['x-gandalf-api-key']; + if (!apiKey || apiKey !== process.env.GANDALF_API_KEY) { + return res.status(401).json({ error: 'Unauthorized' }); + } + req.user = { username: 'gandalf:link_stats', isAdmin: false }; + next(); +} + // Workflow Execution Engine async function executeWorkflowSteps(executionId, workflowId, definition, username) { try { @@ -840,14 +852,19 @@ app.get('/api/executions', authenticateSSO, async (req, res) => { try { const limit = parseInt(req.query.limit) || 50; const offset = parseInt(req.query.offset) || 0; + const hideInternal = req.query.hide_internal === 'true'; + + const whereClause = hideInternal + ? "WHERE started_by NOT LIKE 'gandalf:%' AND started_by NOT LIKE 'scheduler:%'" + : ''; const [rows] = await pool.query( - 'SELECT e.*, w.name as workflow_name FROM executions e LEFT JOIN workflows w ON e.workflow_id = w.id ORDER BY e.started_at DESC LIMIT ? OFFSET ?', + `SELECT e.*, w.name as workflow_name FROM executions e LEFT JOIN workflows w ON e.workflow_id = w.id ${whereClause} ORDER BY e.started_at DESC LIMIT ? OFFSET ?`, [limit, offset] ); // Get total count - const [countRows] = await pool.query('SELECT COUNT(*) as total FROM executions'); + const [countRows] = await pool.query(`SELECT COUNT(*) as total FROM executions ${whereClause}`); const total = countRows[0].total; res.json({ @@ -970,6 +987,62 @@ app.delete('/api/scheduled-commands/:id', authenticateSSO, async (req, res) => { } }); +// Internal M2M API for Gandalf +app.post('/api/internal/command', authenticateGandalf, async (req, res) => { + try { + const { worker_id, command } = req.body; + if (!worker_id || !command) { + return res.status(400).json({ error: 'worker_id and command are required' }); + } + + const workerWs = workers.get(worker_id); + if (!workerWs || workerWs.readyState !== WebSocket.OPEN) { + return res.status(400).json({ error: 'Worker not connected' }); + } + + const executionId = generateUUID(); + + await pool.query( + 'INSERT INTO executions (id, workflow_id, status, started_by, started_at, logs) VALUES (?, ?, ?, ?, NOW(), ?)', + [executionId, null, 'running', req.user.username, JSON.stringify([{ + step: 'internal_command', + action: 'command_sent', + worker_id: worker_id, + command: command, + timestamp: new Date().toISOString() + }])] + ); + + workerWs.send(JSON.stringify({ + type: 'execute_command', + execution_id: executionId, + command: command, + worker_id: worker_id, + timeout: 60000 + })); + + res.json({ execution_id: executionId }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.get('/api/internal/executions/:id', authenticateGandalf, async (req, res) => { + try { + const [rows] = await pool.query('SELECT * FROM executions WHERE id = ?', [req.params.id]); + if (rows.length === 0) { + return res.status(404).json({ error: 'Not found' }); + } + const execution = rows[0]; + res.json({ + ...execution, + logs: JSON.parse(execution.logs || '[]') + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // Health check (no auth required) app.get('/health', async (req, res) => { try {