@@ -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 {