feat: Gandalf M2M API, manual/automated execution sub-tabs, cleanup tuning
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -811,6 +811,20 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Execution History</h3>
|
<h3>Execution History</h3>
|
||||||
|
|
||||||
|
<!-- Manual / Automated sub-tabs -->
|
||||||
|
<div style="display: flex; gap: 0; margin-bottom: 20px; border: 2px solid var(--terminal-green);">
|
||||||
|
<button id="subTabManual"
|
||||||
|
onclick="setExecutionView('manual')"
|
||||||
|
style="flex:1; padding:10px 16px; background:rgba(0,255,65,0.2); border:none; border-right:2px solid var(--terminal-green); color:var(--terminal-amber); font-family:var(--font-mono); font-size:0.9em; cursor:pointer; text-shadow: 0 0 5px #ffb000;">
|
||||||
|
[ 👤 Manual Runs <span id="countManual"></span>]
|
||||||
|
</button>
|
||||||
|
<button id="subTabAutomated"
|
||||||
|
onclick="setExecutionView('automated')"
|
||||||
|
style="flex:1; padding:10px 16px; background:transparent; border:none; color:var(--terminal-green); font-family:var(--font-mono); font-size:0.9em; cursor:pointer;">
|
||||||
|
[ 🤖 Automated <span id="countAutomated"></span>]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Search and Filter Section -->
|
<!-- Search and Filter Section -->
|
||||||
<div style="background: rgba(0, 255, 65, 0.05); border: 2px solid var(--terminal-green); padding: 15px; margin-bottom: 20px;">
|
<div style="background: rgba(0, 255, 65, 0.05); border: 2px solid var(--terminal-green); padding: 15px; margin-bottom: 20px;">
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px;">
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px;">
|
||||||
@@ -1358,6 +1372,7 @@
|
|||||||
|
|
||||||
let executionOffset = 0;
|
let executionOffset = 0;
|
||||||
const executionLimit = 50;
|
const executionLimit = 50;
|
||||||
|
let executionView = localStorage.getItem('pulse_executionView') || 'manual';
|
||||||
|
|
||||||
async function loadExecutions(append = false) {
|
async function loadExecutions(append = false) {
|
||||||
try {
|
try {
|
||||||
@@ -1374,11 +1389,12 @@
|
|||||||
allExecutions = executions;
|
allExecutions = executions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dashboard view (always first 5)
|
// Dashboard view (first 5 manual runs only)
|
||||||
if (!append) {
|
if (!append) {
|
||||||
const dashHtml = executions.length === 0 ?
|
const manualExecs = executions.filter(e => !isAutomatedRun(e));
|
||||||
|
const dashHtml = manualExecs.length === 0 ?
|
||||||
'<div class="empty">No executions yet</div>' :
|
'<div class="empty">No executions yet</div>' :
|
||||||
executions.slice(0, 5).map(e => `
|
manualExecs.slice(0, 5).map(e => `
|
||||||
<div class="execution-item" onclick="viewExecution('${e.id}')">
|
<div class="execution-item" onclick="viewExecution('${e.id}')">
|
||||||
<span class="status ${e.status}">${e.status}</span>
|
<span class="status ${e.status}">${e.status}</span>
|
||||||
<strong>${e.workflow_name || '[Quick Command]'}</strong>
|
<strong>${e.workflow_name || '[Quick Command]'}</strong>
|
||||||
@@ -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() {
|
function renderFilteredExecutions() {
|
||||||
const searchTerm = (document.getElementById('executionSearch')?.value || '').toLowerCase();
|
const searchTerm = (document.getElementById('executionSearch')?.value || '').toLowerCase();
|
||||||
const statusFilter = document.getElementById('statusFilter')?.value || '';
|
const statusFilter = document.getElementById('statusFilter')?.value || '';
|
||||||
|
|
||||||
|
updateSubTabCounts();
|
||||||
|
|
||||||
// Filter executions
|
// Filter executions
|
||||||
let filtered = allExecutions.filter(e => {
|
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
|
// Status filter
|
||||||
if (statusFilter && e.status !== statusFilter) return false;
|
if (statusFilter && e.status !== statusFilter) return false;
|
||||||
|
|
||||||
@@ -2580,6 +2641,7 @@
|
|||||||
// Initialize
|
// Initialize
|
||||||
loadUser().then((success) => {
|
loadUser().then((success) => {
|
||||||
if (success) {
|
if (success) {
|
||||||
|
setExecutionView(executionView);
|
||||||
refreshData();
|
refreshData();
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
setInterval(refreshData, 30000);
|
setInterval(refreshData, 30000);
|
||||||
|
|||||||
87
server.js
87
server.js
@@ -116,24 +116,26 @@ async function initDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-cleanup old executions (runs daily)
|
// Auto-cleanup old executions (runs hourly)
|
||||||
async function cleanupOldExecutions() {
|
async function cleanupOldExecutions() {
|
||||||
try {
|
try {
|
||||||
const retentionDays = parseInt(process.env.EXECUTION_RETENTION_DAYS) || 30;
|
const retentionDays = parseInt(process.env.EXECUTION_RETENTION_DAYS) || 1;
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
`DELETE FROM executions
|
`DELETE FROM executions
|
||||||
WHERE status IN ('completed', 'failed')
|
WHERE status IN ('completed', 'failed')
|
||||||
AND started_at < DATE_SUB(NOW(), INTERVAL ? DAY)`,
|
AND started_at < DATE_SUB(NOW(), INTERVAL ? DAY)`,
|
||||||
[retentionDays]
|
[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) {
|
} catch (error) {
|
||||||
console.error('[Cleanup] Error removing old executions:', error);
|
console.error('[Cleanup] Error removing old executions:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup daily at 3 AM
|
// Run cleanup hourly
|
||||||
setInterval(cleanupOldExecutions, 24 * 60 * 60 * 1000);
|
setInterval(cleanupOldExecutions, 60 * 60 * 1000);
|
||||||
// Run cleanup on startup
|
// Run cleanup on startup
|
||||||
cleanupOldExecutions();
|
cleanupOldExecutions();
|
||||||
|
|
||||||
@@ -481,6 +483,16 @@ async function authenticateSSO(req, res, next) {
|
|||||||
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
|
// Workflow Execution Engine
|
||||||
async function executeWorkflowSteps(executionId, workflowId, definition, username) {
|
async function executeWorkflowSteps(executionId, workflowId, definition, username) {
|
||||||
try {
|
try {
|
||||||
@@ -840,14 +852,19 @@ app.get('/api/executions', authenticateSSO, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const limit = parseInt(req.query.limit) || 50;
|
const limit = parseInt(req.query.limit) || 50;
|
||||||
const offset = parseInt(req.query.offset) || 0;
|
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(
|
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]
|
[limit, offset]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get total count
|
// 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;
|
const total = countRows[0].total;
|
||||||
|
|
||||||
res.json({
|
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)
|
// Health check (no auth required)
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user