Files
fgAI/src/fgai/dashboard.py

381 lines
31 KiB
Python

from __future__ import annotations
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from .config import ConfigStore
from .graylog_mcp import GraylogMcpClient
from .metrics import prometheus_metrics
from .feedback import FeedbackStore
HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SignalScope Monitor</title>
<style>
:root { color-scheme: dark; font-family: Arial, sans-serif; background: #031426; color: #d9e8f7; }
body { margin: 0; }
header { background: #04182d; color: white; padding: 14px 24px; border-bottom: 1px solid #1c4b70; }
h1 { margin: 0; font-size: 22px; }
main { padding: 14px; max-width: 1440px; margin: 0 auto; }
.hero { display: grid; grid-template-columns: 180px 1fr; gap: 12px; align-items: stretch; }
.hero img { width: 100%; height: 144px; object-fit: cover; border-radius: 6px; border: 1px solid #1f3b57; background: #061322; }
.hero .panel { margin-bottom: 0; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
.panel { background: #071d33; border: 1px solid #1c4b70; border-radius: 6px; padding: 14px; margin-bottom: 12px; box-shadow: 0 8px 24px rgba(0,0,0,.16); }
.metric { font-size: 28px; font-weight: 700; }
.label, .muted { color: #91abc4; font-size: 13px; margin-top: 4px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { border-bottom: 1px solid #163b59; padding: 8px; text-align: left; vertical-align: top; }
th { color: #83bce9; font-weight: 600; }
.sev-critical { color: #b00020; font-weight: 700; }
.sev-high { color: #b54708; font-weight: 700; }
.sev-medium { color: #8a6d00; font-weight: 700; }
.sev-low { color: #345995; font-weight: 700; }
code { background: #0b2944; color: #b9e3ff; padding: 2px 4px; border-radius: 4px; }
.capabilities { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.capability { border: 1px solid #1c4b70; background: #08243e; padding: 5px 8px; font-size: 12px; }
.capability.on { border-color: #1d9b72; color: #72e2ae; background: #082f2b; }
.capability.warn { border-color: #c89436; color: #ffd36e; background: #33260b; }
.tabs { display: flex; border-bottom: 1px solid #1c4b70; margin: 14px 0 12px; gap: 4px; }
.tab { border: 0; border-bottom: 3px solid transparent; background: transparent; padding: 10px 14px; color: #91abc4; cursor: pointer; }
.tab.active { border-bottom-color: #1ea9ff; color: #f2f8ff; font-weight: 700; }
[data-view] { display: none; } [data-view].active { display: block; }
.split { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(320px, 0.7fr); gap: 12px; }
.table-wrap { overflow-x: auto; }
#settingsForm label:has(#fieldPicker) { grid-column: 1 / -1; }
#fieldPicker { margin-top: 8px; max-height: 460px; overflow: auto; border: 1px solid #d9e0e7; background: #fbfcfd; }
.field-header, .field-row { display: grid; grid-template-columns: minmax(190px, 1.2fr) 100px minmax(180px, 1fr) minmax(320px, 1.4fr); gap: 10px; align-items: center; padding: 8px 10px; }
.field-header { position: sticky; top: 0; background: #edf3f7; color: #536170; font-size: 12px; font-weight: 700; z-index: 1; }
.field-row { border-top: 1px solid #e4e9ee; font-size: 13px; }
.field-row code { width: fit-content; }
.field-controls { display: flex; flex-wrap: wrap; gap: 10px; }
.field-controls label { white-space: nowrap; }
.review-actions { display: flex; flex-wrap: wrap; gap: 6px; min-width: 250px; }
.review-actions button { border: 1px solid #39709a; background: #0b2944; color: #d9e8f7; padding: 6px 8px; cursor: pointer; }
.review-actions button[data-status="false_positive"] { border-color: #b7823a; color: #ffd36e; }
.review-actions button[data-status="confirmed"] { border-color: #2a9b6e; color: #7be3ae; }
.chart { width: 100%; height: 220px; background: #04182d; border: 1px solid #163b59; }
@media (max-width: 860px) { .hero, .split { grid-template-columns: 1fr; } .hero img { display: none; } }
</style>
</head>
<body>
<header><h1>SignalScope Monitor</h1><div id="stamp" class="muted"></div><div id="capabilities" class="capabilities"></div></header>
<main>
<section class="hero">
<img src="/images/FGinspectionagent.png" alt="FortiGate AI/ML Analyzer">
<div>
<section class="grid" id="metrics"></section>
<section class="panel"><h2>Live Status</h2><div id="liveStatus" class="muted">Waiting for monitor data.</div></section>
</div>
</section>
<nav class="tabs" aria-label="Dashboard views"><button class="tab active" data-tab="overview">Overview</button><button class="tab" data-tab="findings">Findings</button><button class="tab" data-tab="diagnostics">Diagnostics</button><button class="tab" data-tab="settings">Settings</button></nav>
<div data-view="overview" class="active"><section class="split"><div class="panel"><h2>Events and Anomalies</h2><canvas id="trendChart" class="chart"></canvas></div><div class="panel"><h2>Baseline and Stream Health</h2><div id="health"></div></div></section><section class="panel"><h2>AI Assessment</h2><div id="llmAssessment" class="muted">LLM assessment disabled.</div></section><section class="split"><div class="panel"><h2>Anomalies</h2><div id="anomalies"></div></div><div class="panel"><h2>Recommendations</h2><div id="recommendations"></div></div></section></div>
<div data-view="findings"><section class="panel"><h2>Field Baseline Deviations</h2><div id="feedbackNotice" class="muted" role="status"></div><div id="fieldDeviations"></div></section><section class="panel"><h2>Related Activity Across Sources</h2><div id="relatedActivity"></div></section><section class="split"><div class="panel"><h2>Block Candidates</h2><div id="blocks"></div></div><div class="panel"><h2>Threat Intelligence</h2><div id="reputation"></div></div></section><section class="panel"><h2>Policy Findings</h2><div id="policies"></div></section></div>
<div data-view="diagnostics"><section class="panel"><h2>Diagnostics</h2><div id="diagnostics"></div></section></div>
<div data-view="settings"><section class="panel"><h2>Runtime Configuration</h2><form id="settingsForm"><div class="grid"><label>Log source<br><select name="log_source"><option value="local_syslog">Local syslog</option><option value="graylog_mcp">Graylog MCP</option></select></label><label>Graylog MCP URL<br><input name="graylog_mcp_url" type="url" placeholder="https://graylog.example/api/mcp"></label><label>Graylog streams<br><button type="button" id="loadStreams">Load streams</button><div id="streamPicker" class="muted">Load streams after URL and token are saved.</div></label><label>Discover fields<br><button type="button" id="loadFields">Load selected stream fields</button><div id="fieldPicker" class="muted">Select a stream first.</div></label><label>Graylog query<br><input name="graylog_query" placeholder="*"></label><label>Graylog field mapping (JSON)<br><textarea name="graylog_field_mapping" placeholder='{"srcip":"client_ip","dstip":"server_ip","action":"event_action"}'></textarea></label><label>Graylog MCP token<br><input name="graylog_mcp_token" type="password" placeholder="Leave blank to keep current token"></label><label>Ollama model<br><input name="llm_model" placeholder="llama3.1"></label><label><input name="llm_enabled" type="checkbox"> Enable Ollama analysis</label><label><input name="threat_intel_enabled" type="checkbox"> Enable threat intelligence</label></div><p><button type="submit">Save configuration</button> <span id="settingsResult" class="muted"></span></p></form></section></div>
</main>
<script>
function esc(value) {
return String(value ?? "").replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function metric(label, value) {
return `<div class="panel"><div class="metric">${esc(value)}</div><div class="label">${esc(label)}</div></div>`;
}
function table(rows, columns) {
if (!rows || rows.length === 0) return '<p class="muted">No data.</p>';
const head = columns.map(c => `<th>${esc(c.label)}</th>`).join('');
const body = rows.map(row => `<tr>${columns.map(c => `<td>${c.render ? c.render(row) : esc(row[c.key])}</td>`).join('')}</tr>`).join('');
return `<div class="table-wrap"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
}
function capability(label, state, detail) { return `<span class="capability ${state}">${esc(label)}: ${esc(detail)}</span>`; }
function drawTrend(history) { const canvas=document.getElementById('trendChart'), ctx=canvas.getContext('2d'), w=canvas.width=canvas.clientWidth*devicePixelRatio, h=canvas.height=canvas.clientHeight*devicePixelRatio; ctx.scale(devicePixelRatio,devicePixelRatio); const cw=canvas.clientWidth,ch=canvas.clientHeight; ctx.clearRect(0,0,cw,ch); const max=Math.max(1,...history.map(item=>item.events||0)); const line=(key,color)=>{ctx.strokeStyle=color;ctx.lineWidth=2;ctx.beginPath();history.forEach((item,index)=>{const x=12+index*Math.max(1,(cw-24)/Math.max(1,history.length-1));const y=ch-18-((item[key]||0)/max)*(ch-36);index?ctx.lineTo(x,y):ctx.moveTo(x,y)});ctx.stroke()}; line('events','#1ea9ff');line('anomalies','#ff5656'); }
async function refresh() {
const res = await fetch('/api/status', {cache: 'no-store'});
const data = await res.json();
const s = data.summary || {};
const a = data.anomaly_summary || {};
const baseline = data.baseline || {};
const threat = (data.capabilities || {}).threat_intel || {};
const mcp = (data.capabilities || {}).graylog_mcp || {};
const configuration = data.configuration || {};
drawTrend(data.history || []);
document.getElementById('stamp').textContent = data.generated_at ? `Updated ${new Date(data.generated_at * 1000).toLocaleString()}` : 'Waiting for monitor data';
document.getElementById('metrics').innerHTML = [
metric('Total events', s.total || 0),
metric('UTM events', s.utm || 0),
metric('Threat actions', s.threat_actions || 0),
metric('Anomalies high+', (a.high || 0) + (a.critical || 0))
].join('');
const llm = data.llm_assessment || {};
document.getElementById('capabilities').innerHTML = [
capability('Baseline', baseline.enabled ? 'on' : 'warn', baseline.enabled ? `${baseline.sources_ready || 0} sources ready` : 'disabled'),
capability('Ollama', llm.enabled && llm.status !== 'error' ? 'on' : 'warn', llm.enabled ? (llm.status || 'starting') : 'disabled'),
capability('Threat Intel', threat.enabled && threat.configured ? 'on' : 'warn', threat.enabled ? `${threat.provider || 'unknown'}${threat.configured ? '' : ', key missing'}` : 'disabled'),
capability('Graylog MCP', mcp.status === 'connected' ? 'on' : 'warn', configuration.log_source === 'graylog_mcp' ? (mcp.status || 'checking') : 'not selected')
].join('');
document.getElementById('liveStatus').innerHTML = [
`Log file: <code>${esc(data.log_path || '')}</code>`,
`Policy file: <code>${esc(data.policy_path || 'none')}</code>`,
`Critical anomalies: ${esc((a.critical || 0))}`,
`High anomalies: ${esc((a.high || 0))}`,
`Baseline sources ready: ${esc((data.baseline || {}).sources_ready || 0)}`
].join('<br>');
document.getElementById('health').innerHTML = [
metric('Baseline sources ready', baseline.sources_ready || 0), metric('MCP events fetched', mcp.events_fetched || 0), metric('Profiles active', (data.stream_profiles || []).length), metric('Correlated entities', (data.cross_source_correlations || []).length)
].join('');
const llmText = llm.text ? esc(llm.text).replace(/\\n/g, '<br>') : esc(llm.error || 'LLM assessment disabled or waiting for first run.');
document.getElementById('llmAssessment').innerHTML = `<div>Status: <code>${esc(llm.status || 'unknown')}</code></div><p>${llmText}</p>`;
document.getElementById('anomalies').innerHTML = table(data.anomalies || [], [
{label:'Source', key:'subject'},
{label:'Score', key:'score'},
{label:'Severity', render:r => `<span class="sev-${esc(r.severity)}">${esc(r.severity)}</span>`},
{label:'Confidence', key:'confidence'},
{label:'Rate / ports / hits', render:r => {
const e = r.evidence || {};
const rate = e.timed_events > 1 ? `${e.events_per_minute} events/min` : 'no timestamps';
return esc(`${rate}; dst ports: ${e.distinct_dst_ports || 0}; src ports: ${e.distinct_src_ports || 0}; hitcount: ${e.hitcount_total || 0}`);
}},
{label:'Reasons', render:r => esc((r.reasons || []).join('; '))}
]);
document.getElementById('recommendations').innerHTML = table(data.recommendations || [], [
{label:'Subject', key:'subject'},
{label:'Score', key:'score'},
{label:'Severity', render:r => `<span class="sev-${esc(r.severity)}">${esc(r.severity)}</span>`},
{label:'Title', key:'title'},
{label:'Recommendation', key:'recommendation'},
{label:'Policies', render:r => esc((r.related_policy_ids || []).join(', '))},
{label:'Services', render:r => esc((r.related_services || []).join(', '))}
]);
document.getElementById('blocks').innerHTML = table(data.block_candidates || [], [
{label:'Source', key:'src_ip'},
{label:'Score', key:'score'},
{label:'Reasons', render:r => esc((r.reasons || []).join('; '))}
]);
const reputationRows = Object.entries(data.reputation || {}).map(([ip, intel]) => ({ip, ...intel}));
const relatedRows = (data.cross_source_correlations || []).flatMap(correlation => (correlation.samples || []).map(sample => ({source_ip: correlation.source_ip, ...sample})));
const streamTitles = Object.fromEntries((configuration.graylog_streams || []).map(item => [item.id, item.title || item.id]));
const fieldRows = Object.entries(data.field_deviations || {}).flatMap(([entity, deviations]) => (deviations || []).map(item => ({entity, stream_title: streamTitles[item.stream_id] || item.stream_id, ...item})));
document.getElementById('fieldDeviations').innerHTML = table(fieldRows, [
{label:'Entity', key:'entity'}, {label:'Stream', key:'stream_title'}, {label:'Field', key:'field'}, {label:'Score', key:'score'}, {label:'Review', render:r => esc(r.feedback || 'unreviewed')}, {label:'Evidence', render:r => { const summary=esc(`${r.reason}; current ${r.current ?? '-'} vs baseline ${r.baseline ?? '-'}; values: ${(r.sample_values || []).join(', ') || '-'}`); const events=(r.sample_events || []).map(item => esc(`${item.timestamp} | ${item.source} -> ${item.destination} | ${item.action} ${item.service} | ${item.value} | ${item.message}`)).join('<br>'); return events ? `<details><summary>${summary}</summary><p>${events}</p></details>` : summary; }}, {label:'Review action', render:r => `<div class="review-actions"><button class="feedback" data-status="expected" data-entity="${esc(r.entity)}" data-stream="${esc(r.stream_id)}" data-field="${esc(r.field)}">Mark expected</button><button class="feedback" data-status="false_positive" data-entity="${esc(r.entity)}" data-stream="${esc(r.stream_id)}" data-field="${esc(r.field)}">Mark false positive</button><button class="feedback" data-status="confirmed" data-entity="${esc(r.entity)}" data-stream="${esc(r.stream_id)}" data-field="${esc(r.field)}">Mark confirmed</button></div>`}
]);
document.querySelectorAll('.feedback').forEach(button => button.addEventListener('click', async () => {
const note = prompt('Review note (optional):') || '';
const days = prompt('Expiry in days (0 = no expiry):', '0') || '0';
const response = await fetch('/api/feedback', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({status:button.dataset.status, entity:button.dataset.entity, stream_id:button.dataset.stream, field:button.dataset.field, note, expires_at: Number(days) > 0 ? Math.floor(Date.now()/1000) + Number(days) * 86400 : 0})});
document.getElementById('feedbackNotice').textContent = response.ok ? 'Review saved. The matching pattern will be labeled on the next refresh.' : 'Could not save review.';
refresh();
}));
document.getElementById('relatedActivity').innerHTML = table(relatedRows, [
{label:'Source IP', key:'source_ip'}, {label:'Stream', key:'stream'}, {label:'Time', key:'timestamp'},
{label:'Type', key:'type'}, {label:'Action', key:'action'}, {label:'Severity', key:'severity'},
{label:'Destination', key:'destination'}, {label:'Service', key:'service'}, {label:'Context', key:'context'}
]);
document.getElementById('reputation').innerHTML = table(reputationRows, [
{label:'IP', key:'ip'},
{label:'Provider', key:'provider'},
{label:'Status', key:'status'},
{label:'Score', key:'score'},
{label:'Malicious', key:'malicious'},
{label:'Suspicious', key:'suspicious'}
]);
document.getElementById('policies').innerHTML = table(data.policy_findings || [], [
{label:'Severity', render:r => `<span class="sev-${esc(r.severity)}">${esc(r.severity)}</span>`},
{label:'Reference', key:'reference'},
{label:'Title', key:'title'},
{label:'Detail', key:'detail'}
]);
const d = data.diagnostics || {};
const context = data.event_context || {};
const quality = data.data_quality || {};
const profileReadiness = (data.profile_readiness || []).map(item => ({...item, stream_title: streamTitles[item.stream_id] || item.stream_id}));
const correlations = data.cross_source_correlations || [];
document.getElementById('diagnostics').innerHTML =
'<h3>Cross-Source Correlations</h3>' + table(correlations, [{label:'Source IP', key:'source_ip'}, {label:'Streams', render:r => esc((r.streams || []).join(', '))}, {label:'Events', key:'events'}, {label:'Security Events', key:'security_events'}]) +
'<h3>Entities</h3>' + table(context.source_profiles || [], [{label:'Entity', key:'entity'}, {label:'Events', key:'events'}, {label:'UTM', key:'utm_events'}, {label:'Deny', key:'deny_or_threat_actions'}, {label:'Destinations', key:'distinct_destinations'}, {label:'Actions', render:r => esc((r.top_actions || []).join(', '))}]) +
'<h3>Profile Baseline Readiness</h3>' + table(profileReadiness, [{label:'Stream', key:'stream_title'}, {label:'Field', key:'field'}, {label:'Buckets', key:'buckets'}, {label:'Ready', render:r => r.ready ? 'ready' : 'learning'}]) +
'<h3>Data Quality</h3>' + table([quality], [{label:'Events', key:'events'}, {label:'Timestamp coverage', render:r => `${r.timestamp_coverage || 0}%`}, {label:'Source coverage', render:r => `${r.source_coverage || 0}%`}, {label:'Truncated streams', render:r => esc((r.truncated_streams || []).join(', ') || 'none')}]) +
'<h3>Security Event Samples</h3>' + table(context.security_event_samples || [], [{label:'Entity', key:'entity'}, {label:'Type', key:'type'}, {label:'Action', key:'action'}, {label:'Severity', key:'severity'}, {label:'Destination', key:'dst'}, {label:'Service', key:'service'}]) +
'<h3>Top Sources</h3>' + table(d.top_source_ips || [], [{label:'Value', key:'value'}, {label:'Count', key:'count'}]) +
'<h3>Top Destinations</h3>' + table(d.top_destination_ips || [], [{label:'Value', key:'value'}, {label:'Count', key:'count'}]) +
'<h3>Top Policy IDs</h3>' + table(d.top_policy_ids || [], [{label:'Value', key:'value'}, {label:'Count', key:'count'}]) +
'<h3>Top Destination Ports</h3>' + table(d.top_destination_ports || [], [{label:'Value', key:'value'}, {label:'Count', key:'count'}]) +
'<h3>Top Source Ports</h3>' + table(d.top_source_ports || [], [{label:'Value', key:'value'}, {label:'Count', key:'count'}]) +
'<h3>Top Services</h3>' + table(d.top_services || [], [{label:'Value', key:'value'}, {label:'Count', key:'count'}]) +
'<h3>Local-in Failures</h3>' + table(d.local_in_failures || [], [{label:'Source', key:'src_ip'}, {label:'Service', key:'service'}, {label:'Policy', key:'policy'}, {label:'Count', key:'count'}]);
}
async function loadSettings() {
const config = await (await fetch('/api/config', {cache: 'no-store'})).json();
const form = document.getElementById('settingsForm');
for (const [key, value] of Object.entries(config)) {
const field = form.elements.namedItem(key);
if (!field) continue;
if (field.type === 'checkbox') field.checked = Boolean(value); else field.value = value || '';
}
const token = form.elements.namedItem('graylog_mcp_token');
token.placeholder = config.graylog_mcp_token_configured ? 'Token configured; leave blank to keep it' : 'Paste a read-only token';
window.streamProfiles = config.graylog_stream_profiles || [];
if (config.graylog_mcp_token_configured && config.graylog_mcp_url) loadStreams();
}
async function loadStreams() {
const response = await fetch('/api/graylog/streams');
const payload = await response.json();
const selected = new Set((payload.selected || []).filter(item => item.enabled).map(item => item.id));
document.getElementById('streamPicker').innerHTML = (payload.streams || []).map(stream => `<label><input type="checkbox" class="graylog-stream" data-id="${esc(stream.id)}" data-title="${esc(stream.title)}" ${selected.has(stream.id) ? 'checked' : ''}> ${esc(stream.title)}</label><br>`).join('') || esc(payload.error || 'No streams found.');
}
document.getElementById('loadStreams').addEventListener('click', loadStreams);
document.getElementById('loadFields').addEventListener('click', async () => {
const selected = document.querySelector('.graylog-stream:checked');
if (!selected) { document.getElementById('fieldPicker').textContent = 'Load streams, tick one stream, then load its fields.'; return; }
const payload = await (await fetch(`/api/graylog/fields?stream_id=${encodeURIComponent(selected.dataset.id)}`)).json();
const profile = (window.streamProfiles || []).find(item => item.stream_id === selected.dataset.id) || {};
const rows = (payload.fields || []).map(field => { const name=field.name||field.field, type=(field.type||{}).type||'', props=(field.type||{}).properties||[]; return `<div class="field-row"><code>${esc(name)}</code><span>${esc(type)}</span><span>${esc(props.join(', '))}</span><div class="field-controls"><label><input type="radio" name="profile_entity" value="${esc(name)}" ${profile.entity_field===name?'checked':''}> Entity</label><label><input type="radio" name="profile_timestamp" value="${esc(name)}" ${profile.timestamp_field===name?'checked':''}> Time</label>${props.includes('enumerable')?`<label><input class="profile-categorical" type="checkbox" value="${esc(name)}" ${(profile.categorical_fields||[]).includes(name)?'checked':''}> Categorical</label>`:''}${props.includes('numeric')?`<label><input class="profile-numeric" type="checkbox" value="${esc(name)}" ${(profile.numeric_fields||[]).includes(name)?'checked':''}> Numeric</label>`:''}</div></div>`; }).join('');
document.getElementById('fieldPicker').innerHTML = rows ? `<div class="field-header"><span>Field</span><span>Type</span><span>Capabilities</span><span>Use In Profile</span></div>${rows}` : esc(payload.error || 'No fields found.');
window.activeProfileStream = selected.dataset.id;
});
document.getElementById('settingsForm').addEventListener('submit', async event => {
event.preventDefault();
const form = event.currentTarget;
const values = Object.fromEntries(new FormData(form));
values.llm_enabled = form.elements.llm_enabled.checked;
values.threat_intel_enabled = form.elements.threat_intel_enabled.checked;
values.graylog_streams = [...document.querySelectorAll('.graylog-stream')].map(item => ({id:item.dataset.id, title:item.dataset.title, enabled:item.checked}));
if (window.activeProfileStream) { const profile={stream_id:window.activeProfileStream,entity_field:form.querySelector('[name="profile_entity"]:checked')?.value||'',timestamp_field:form.querySelector('[name="profile_timestamp"]:checked')?.value||'timestamp',categorical_fields:[...form.querySelectorAll('.profile-categorical:checked')].map(item=>item.value),numeric_fields:[...form.querySelectorAll('.profile-numeric:checked')].map(item=>item.value)}; values.graylog_stream_profiles=[...(window.streamProfiles||[]).filter(item=>item.stream_id!==profile.stream_id),profile]; }
const response = await fetch('/api/config', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(values)});
document.getElementById('settingsResult').textContent = response.ok ? 'Saved. Monitor applies supported settings on its next cycle.' : 'Could not save configuration.';
if (response.ok) loadSettings();
});
document.querySelectorAll('.tab').forEach(button => button.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(item => item.classList.toggle('active', item === button));
document.querySelectorAll('[data-view]').forEach(view => view.classList.toggle('active', view.dataset.view === button.dataset.tab));
}));
refresh();
loadSettings();
setInterval(refresh, 5000);
</script>
</body>
</html>
"""
def serve_dashboard(host: str, port: int, status_file: str, *, image_dir: str | None = None, config_file: str = "state/fgai-config.json") -> None:
status_path = Path(status_file)
image_root = Path(image_dir) if image_dir else None
config_store = ConfigStore(config_file)
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
if self.path == "/":
self._send(200, "text/html; charset=utf-8", HTML.encode("utf-8"))
return
if self.path == "/api/config":
self._send(200, "application/json", json.dumps(config_store.public()).encode("utf-8"))
return
if self.path == "/api/feedback":
self._send(200, "application/json", json.dumps(FeedbackStore().entries()).encode("utf-8"))
return
if self.path == "/api/graylog/streams":
config = config_store.read()
try:
client = GraylogMcpClient(str(config.get("graylog_mcp_url", "")), str(config.get("graylog_mcp_token", "")))
client.probe()
result = client.call_tool("list_streams", {})
content = result.get("result", {}).get("content", [])
text = next((item.get("text", "") for item in content if isinstance(item, dict)), "")
if isinstance(result.get("result"), dict) and result["result"].get("isError"):
fallback = client.call_tool("list_resource", {"resource_type": "stream"})
resources = fallback.get("result", {}).get("structuredContent", {}).get("resources", [])
streams = [
{"id": str(item.get("uri", "")).rsplit(":", 1)[-1], "title": item.get("title", item.get("name", "")), "description": item.get("description", "")}
for item in resources if isinstance(item, dict) and item.get("uri")
]
else:
if not text:
raise RuntimeError("Graylog list_streams returned no text content")
streams = json.loads(text)
body = {"streams": streams, "selected": config.get("graylog_streams", [])}
self._send(200, "application/json", json.dumps(body).encode("utf-8"))
except Exception as exc:
self._send(400, "application/json", json.dumps({"error": str(exc)}).encode("utf-8"))
return
if self.path.startswith("/api/graylog/fields?"):
stream_id = self.path.split("stream_id=", 1)[-1].split("&", 1)[0]
config = config_store.read()
try:
client = GraylogMcpClient(str(config.get("graylog_mcp_url", "")), str(config.get("graylog_mcp_token", "")))
client.probe()
result = client.call_tool("list_fields", {"streams": [stream_id]})
content = result.get("result", {}).get("content", [])
text = next((item.get("text", "") for item in content if isinstance(item, dict)), "")
payload = json.loads(text)
fields = payload.get("fields", payload) if isinstance(payload, dict) else payload
self._send(200, "application/json", json.dumps({"fields": fields}).encode("utf-8"))
except Exception as exc:
self._send(400, "application/json", json.dumps({"error": str(exc)}).encode("utf-8"))
return
if self.path == "/api/status":
if status_path.exists():
body = status_path.read_bytes()
else:
body = json.dumps({"summary": {}, "anomalies": [], "block_candidates": []}).encode("utf-8")
self._send(200, "application/json", body)
return
if self.path == "/metrics":
try:
status = json.loads(status_path.read_text(encoding="utf-8")) if status_path.exists() else {}
except json.JSONDecodeError:
status = {}
self._send(200, "text/plain; version=0.0.4; charset=utf-8", prometheus_metrics(status).encode("utf-8"))
return
if self.path.startswith("/images/") and image_root:
image_path = image_root / Path(self.path).name
if image_path.exists() and image_path.is_file():
content_type = "image/png" if image_path.suffix.lower() == ".png" else "application/octet-stream"
self._send(200, content_type, image_path.read_bytes())
return
self._send(404, "text/plain; charset=utf-8", b"not found")
def do_POST(self) -> None:
if self.path == "/api/feedback" and self._is_loopback_client():
try:
payload = json.loads(self.rfile.read(min(int(self.headers.get("Content-Length", "0")), 16_384)).decode("utf-8"))
self._send(200, "application/json", json.dumps(FeedbackStore().add(payload)).encode("utf-8"))
except (ValueError, json.JSONDecodeError) as exc:
self._send(400, "application/json", json.dumps({"error": str(exc)}).encode("utf-8"))
return
if self.path != "/api/config" or not self._is_loopback_client():
self._send(403, "application/json", b'{"error":"configuration is local-only"}')
return
try:
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(min(length, 32_768)).decode("utf-8"))
if not isinstance(payload, dict):
raise ValueError("configuration must be an object")
body = json.dumps(config_store.update(payload)).encode("utf-8")
except (ValueError, json.JSONDecodeError) as exc:
self._send(400, "application/json", json.dumps({"error": str(exc)}).encode("utf-8"))
return
self._send(200, "application/json", body)
def _is_loopback_client(self) -> bool:
return self.client_address[0] in {"127.0.0.1", "::1"}
def log_message(self, format: str, *args: object) -> None:
return
def _send(self, status: int, content_type: str, body: bytes) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
server = ThreadingHTTPServer((host, port), Handler)
print(f"Dashboard listening on http://{host}:{port}")
print(f"Reading status from {status_path}")
server.serve_forever()