Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
256 lines
8.2 KiB
JavaScript
256 lines
8.2 KiB
JavaScript
// OpenFang Logs Page — Real-time log viewer (SSE streaming + polling fallback) + Audit Trail tab
|
|
'use strict';
|
|
|
|
function logsPage() {
|
|
return {
|
|
tab: 'live',
|
|
// -- Live logs state --
|
|
entries: [],
|
|
levelFilter: '',
|
|
textFilter: '',
|
|
autoRefresh: true,
|
|
hovering: false,
|
|
loading: true,
|
|
loadError: '',
|
|
_pollTimer: null,
|
|
|
|
// -- SSE streaming state --
|
|
_eventSource: null,
|
|
streamConnected: false,
|
|
streamPaused: false,
|
|
|
|
// -- Audit state --
|
|
auditEntries: [],
|
|
tipHash: '',
|
|
chainValid: null,
|
|
filterAction: '',
|
|
auditLoading: false,
|
|
auditLoadError: '',
|
|
|
|
startStreaming: function() {
|
|
var self = this;
|
|
if (this._eventSource) { this._eventSource.close(); this._eventSource = null; }
|
|
|
|
var url = '/api/logs/stream';
|
|
var sep = '?';
|
|
var token = OpenFangAPI.getToken();
|
|
if (token) { url += sep + 'token=' + encodeURIComponent(token); sep = '&'; }
|
|
|
|
try {
|
|
this._eventSource = new EventSource(url);
|
|
} catch(e) {
|
|
// EventSource not supported or blocked; fall back to polling
|
|
this.streamConnected = false;
|
|
this.startPolling();
|
|
return;
|
|
}
|
|
|
|
this._eventSource.onopen = function() {
|
|
self.streamConnected = true;
|
|
self.loading = false;
|
|
self.loadError = '';
|
|
};
|
|
|
|
this._eventSource.onmessage = function(event) {
|
|
if (self.streamPaused) return;
|
|
try {
|
|
var entry = JSON.parse(event.data);
|
|
// Avoid duplicate entries by checking seq
|
|
var dominated = false;
|
|
for (var i = 0; i < self.entries.length; i++) {
|
|
if (self.entries[i].seq === entry.seq) { dominated = true; break; }
|
|
}
|
|
if (!dominated) {
|
|
self.entries.push(entry);
|
|
// Cap at 500 entries (remove oldest)
|
|
if (self.entries.length > 500) {
|
|
self.entries.splice(0, self.entries.length - 500);
|
|
}
|
|
// Auto-scroll to bottom
|
|
if (self.autoRefresh && !self.hovering) {
|
|
self.$nextTick(function() {
|
|
var el = document.getElementById('log-container');
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
});
|
|
}
|
|
}
|
|
} catch(e) {
|
|
// Ignore parse errors (heartbeat comments are not delivered to onmessage)
|
|
}
|
|
};
|
|
|
|
this._eventSource.onerror = function() {
|
|
self.streamConnected = false;
|
|
if (self._eventSource) {
|
|
self._eventSource.close();
|
|
self._eventSource = null;
|
|
}
|
|
// Fall back to polling
|
|
self.startPolling();
|
|
};
|
|
},
|
|
|
|
startPolling: function() {
|
|
var self = this;
|
|
this.streamConnected = false;
|
|
this.fetchLogs();
|
|
if (this._pollTimer) clearInterval(this._pollTimer);
|
|
this._pollTimer = setInterval(function() {
|
|
if (self.autoRefresh && !self.hovering && self.tab === 'live' && !self.streamPaused) {
|
|
self.fetchLogs();
|
|
}
|
|
}, 2000);
|
|
},
|
|
|
|
async fetchLogs() {
|
|
if (this.loading) this.loadError = '';
|
|
try {
|
|
var data = await OpenFangAPI.get('/api/audit/recent?n=200');
|
|
this.entries = data.entries || [];
|
|
if (this.autoRefresh && !this.hovering) {
|
|
this.$nextTick(function() {
|
|
var el = document.getElementById('log-container');
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
});
|
|
}
|
|
if (this.loading) this.loading = false;
|
|
} catch(e) {
|
|
if (this.loading) {
|
|
this.loadError = e.message || 'Could not load logs.';
|
|
this.loading = false;
|
|
}
|
|
}
|
|
},
|
|
|
|
async loadData() {
|
|
this.loading = true;
|
|
return this.fetchLogs();
|
|
},
|
|
|
|
togglePause: function() {
|
|
this.streamPaused = !this.streamPaused;
|
|
if (!this.streamPaused && this.streamConnected) {
|
|
// Resume: scroll to bottom
|
|
var self = this;
|
|
this.$nextTick(function() {
|
|
var el = document.getElementById('log-container');
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
});
|
|
}
|
|
},
|
|
|
|
clearLogs: function() {
|
|
this.entries = [];
|
|
},
|
|
|
|
classifyLevel: function(action) {
|
|
if (!action) return 'info';
|
|
var a = action.toLowerCase();
|
|
if (a.indexOf('error') !== -1 || a.indexOf('fail') !== -1 || a.indexOf('crash') !== -1) return 'error';
|
|
if (a.indexOf('warn') !== -1 || a.indexOf('deny') !== -1 || a.indexOf('block') !== -1) return 'warn';
|
|
return 'info';
|
|
},
|
|
|
|
get filteredEntries() {
|
|
var self = this;
|
|
var levelF = this.levelFilter;
|
|
var textF = this.textFilter.toLowerCase();
|
|
return this.entries.filter(function(e) {
|
|
if (levelF && self.classifyLevel(e.action) !== levelF) return false;
|
|
if (textF) {
|
|
var haystack = ((e.action || '') + ' ' + (e.detail || '') + ' ' + (e.agent_id || '')).toLowerCase();
|
|
if (haystack.indexOf(textF) === -1) return false;
|
|
}
|
|
return true;
|
|
});
|
|
},
|
|
|
|
get connectionLabel() {
|
|
if (this.streamPaused) return 'Paused';
|
|
if (this.streamConnected) return 'Live';
|
|
if (this._pollTimer) return 'Polling';
|
|
return 'Disconnected';
|
|
},
|
|
|
|
get connectionClass() {
|
|
if (this.streamPaused) return 'paused';
|
|
if (this.streamConnected) return 'live';
|
|
if (this._pollTimer) return 'polling';
|
|
return 'disconnected';
|
|
},
|
|
|
|
exportLogs: function() {
|
|
var lines = this.filteredEntries.map(function(e) {
|
|
return new Date(e.timestamp).toISOString() + ' [' + e.action + '] ' + (e.detail || '');
|
|
});
|
|
var blob = new Blob([lines.join('\n')], { type: 'text/plain' });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'openfang-logs-' + new Date().toISOString().slice(0, 10) + '.txt';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
},
|
|
|
|
// -- Audit methods --
|
|
get filteredAuditEntries() {
|
|
var self = this;
|
|
if (!self.filterAction) return self.auditEntries;
|
|
return self.auditEntries.filter(function(e) { return e.action === self.filterAction; });
|
|
},
|
|
|
|
async loadAudit() {
|
|
this.auditLoading = true;
|
|
this.auditLoadError = '';
|
|
try {
|
|
var data = await OpenFangAPI.get('/api/audit/recent?n=200');
|
|
this.auditEntries = data.entries || [];
|
|
this.tipHash = data.tip_hash || '';
|
|
} catch(e) {
|
|
this.auditEntries = [];
|
|
this.auditLoadError = e.message || 'Could not load audit log.';
|
|
}
|
|
this.auditLoading = false;
|
|
},
|
|
|
|
auditAgentName: function(agentId) {
|
|
if (!agentId) return '-';
|
|
var agents = Alpine.store('app').agents || [];
|
|
var agent = agents.find(function(a) { return a.id === agentId; });
|
|
return agent ? agent.name : agentId.substring(0, 8) + '...';
|
|
},
|
|
|
|
friendlyAction: function(action) {
|
|
if (!action) return 'Unknown';
|
|
var map = {
|
|
'AgentSpawn': 'Agent Created', 'AgentKill': 'Agent Stopped', 'AgentTerminated': 'Agent Stopped',
|
|
'ToolInvoke': 'Tool Used', 'ToolResult': 'Tool Completed', 'AgentMessage': 'Message',
|
|
'NetworkAccess': 'Network Access', 'ShellExec': 'Shell Command', 'FileAccess': 'File Access',
|
|
'MemoryAccess': 'Memory Access', 'AuthAttempt': 'Login Attempt', 'AuthSuccess': 'Login Success',
|
|
'AuthFailure': 'Login Failed', 'CapabilityDenied': 'Permission Denied', 'RateLimited': 'Rate Limited'
|
|
};
|
|
return map[action] || action.replace(/([A-Z])/g, ' $1').trim();
|
|
},
|
|
|
|
async verifyChain() {
|
|
try {
|
|
var data = await OpenFangAPI.get('/api/audit/verify');
|
|
this.chainValid = data.valid === true;
|
|
if (this.chainValid) {
|
|
OpenFangToast.success('Audit chain verified — ' + (data.entries || 0) + ' entries valid');
|
|
} else {
|
|
OpenFangToast.error('Audit chain broken!');
|
|
}
|
|
} catch(e) {
|
|
this.chainValid = false;
|
|
OpenFangToast.error('Chain verification failed: ' + e.message);
|
|
}
|
|
},
|
|
|
|
destroy: function() {
|
|
if (this._eventSource) { this._eventSource.close(); this._eventSource = null; }
|
|
if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
|
|
}
|
|
};
|
|
}
|