fix advisor and drop down refresh

This commit is contained in:
larssand
2026-07-01 08:56:08 +02:00
parent fcfbe04a58
commit a3cfab1f5a
5 changed files with 66 additions and 5 deletions

View File

@@ -176,16 +176,24 @@ function inferredProfileType(row) {
}
function profileDiscoveryDetails(row) {
const d = row.discovery || {};
const detailId = `profile-discovery:${row.stream_id || row.stream_name || ''}:${d.field_count || 0}`;
if (!d.field_count) {
const common = (row.common_fields || []).slice(0,8).map(item => `${item.field} ${(Number(item.coverage || 0)*100).toFixed(0)}%/${item.unique_values}`).join(', ');
return `<details><summary>waiting for discovery data</summary><p>This row was produced before profile discovery metadata was added. Wait for the next monitor cycle after updating, or re-apply/edit the profile.</p><p><b>Observed common fields</b><br>${esc(common || '-')}</p></details>`;
return `<details data-detail-id="${esc(detailId)}"><summary>waiting for discovery data</summary><p>This row was produced before profile discovery metadata was added. Wait for the next monitor cycle after updating, or re-apply/edit the profile.</p><p><b>Observed common fields</b><br>${esc(common || '-')}</p></details>`;
}
const selected = d.selected_fields || {};
const top = (d.top_fields || []).slice(0,8).map(item => `${item.field} ${(Number(item.coverage || 0)*100).toFixed(0)}%/${item.unique_values}`).join(', ');
const shared = (d.shared_fields || row.shared_fields || []).slice(0,8).map(item => `${item.field} (${item.streams} streams)`).join(', ');
const rejected = (d.rejected_fields || []).slice(0,8).map(item => `${item.field}: ${item.reason}`).join('; ');
const reasons = (d.reasons || []).join('; ');
return `<details><summary>${esc(d.field_count)} fields analyzed</summary><p><b>Selected</b><br>Entity: ${esc((selected.entity || []).join(', ') || '-')}<br>Time: ${esc((selected.time || []).join(', ') || '-')}<br>Categorical: ${esc((selected.categorical || []).join(', ') || '-')}<br>Numeric: ${esc((selected.numeric || []).join(', ') || '-')}</p><p><b>Shared across streams</b><br>${esc(shared || '-')}</p><p><b>Top fields</b><br>${esc(top || '-')}</p><p><b>Rejected</b><br>${esc(rejected || '-')}</p><p>${esc(reasons || '')}</p></details>`;
return `<details data-detail-id="${esc(detailId)}"><summary>${esc(d.field_count)} fields analyzed</summary><p><b>Selected</b><br>Entity: ${esc((selected.entity || []).join(', ') || '-')}<br>Time: ${esc((selected.time || []).join(', ') || '-')}<br>Categorical: ${esc((selected.categorical || []).join(', ') || '-')}<br>Numeric: ${esc((selected.numeric || []).join(', ') || '-')}</p><p><b>Shared across streams</b><br>${esc(shared || '-')}</p><p><b>Top fields</b><br>${esc(top || '-')}</p><p><b>Rejected</b><br>${esc(rejected || '-')}</p><p>${esc(reasons || '')}</p></details>`;
}
function profileAdvisorLabel(row, advisor) {
const rowAdvisor = row.profile_advisor || {};
if (rowAdvisor.status === 'heuristic' && rowAdvisor.error) return 'heuristic (advisor error)';
if (rowAdvisor.status) return rowAdvisor.status;
if (advisor.enabled && advisor.status === 'error') return 'heuristic (advisor error)';
return advisor.enabled ? (advisor.status || 'checking') : 'heuristic';
}
function drawTrend(history) {
const canvas=document.getElementById('trendChart'), ctx=canvas.getContext('2d'), ratio=window.devicePixelRatio||1, cw=canvas.clientWidth, ch=canvas.clientHeight;
@@ -308,7 +316,8 @@ async function refresh() {
`MCP aggregate events: ${esc(mcp.aggregate_events || 0)}`,
`MCP raw sample events: ${esc(mcp.raw_events_fetched || mcp.events_fetched || 0)}`,
`MCP coverage: ${esc(mcp.coverage_status || 'unknown')}${mcp.partial_streams ? ` (${esc(mcp.partial_streams)} partial)` : ''}${mcp.truncated_streams ? ` (${esc(mcp.truncated_streams)} truncated)` : ''}${mcp.sample_limited_streams ? ` (${esc(mcp.sample_limited_streams)} sample capped)` : ''}`,
mcp.coverage_warning ? `<span class="sev-high">${esc(mcp.coverage_warning)}</span>` : ''
mcp.coverage_warning ? `<span class="sev-high">${esc(mcp.coverage_warning)}</span>` : '',
advisor.status === 'error' ? `<span class="sev-high">Profile advisor error: ${esc(advisor.error || 'unknown error')}</span>` : ''
].filter(Boolean).join('<br>');
document.getElementById('health').innerHTML = [
metric('Enabled streams', enabledStreams.length), metric('Streams missing profile', streamsMissingProfile), metric('MCP aggregate events', mcp.aggregate_events || 0), metric('MCP raw sample', mcp.raw_events_fetched || mcp.events_fetched || 0), metric('Partial streams', mcp.partial_streams || 0), metric('Sample capped', mcp.sample_limited_streams || 0), metric('Truncated streams', mcp.truncated_streams || 0), metric('Correlated entities', correlations.length)
@@ -324,7 +333,7 @@ async function refresh() {
{label:'Events', key:'events'},
{label:'Type', render:r => esc(inferredProfileType(r))},
{label:'Confidence', key:'confidence'},
{label:'Advisor', render:r => esc((r.profile_advisor || {}).status || (advisor.enabled ? advisor.status : 'heuristic'))},
{label:'Advisor', render:r => esc(profileAdvisorLabel(r, advisor))},
{label:'Profile', render:r => r.profile_exists ? 'exists' : 'new'},
{label:'Entity fields', render:r => esc((r.entity_fields || []).join(', ') || '-')},
{label:'Time', key:'timestamp_field'},

View File

@@ -7,6 +7,31 @@ from urllib import request
from .models import BlockCandidate, Finding
def _json_object_from_text(text: str) -> dict[str, object]:
text = text.strip()
if text.startswith("```"):
lines = text.splitlines()
if lines and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
text = "\n".join(lines).strip()
try:
payload = json.loads(text)
return payload if isinstance(payload, dict) else {}
except json.JSONDecodeError:
pass
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1 or end <= start:
return {}
try:
payload = json.loads(text[start:end + 1])
return payload if isinstance(payload, dict) else {}
except json.JSONDecodeError:
return {}
def ollama_summary(
findings: list[Finding],
candidates: list[BlockCandidate],
@@ -121,6 +146,6 @@ def ollama_profile_advice(suggestions: list[dict[str, object]], model: str | Non
req = request.Request(f"{host}/api/generate", data=body, method="POST", headers={"Content-Type": "application/json"})
with request.urlopen(req, timeout=selected_timeout) as response:
data = json.loads(response.read().decode("utf-8"))
payload = json.loads(str(data.get("response", "{}")))
payload = _json_object_from_text(str(data.get("response", "{}")))
profiles = payload.get("profiles", [])
return profiles if isinstance(profiles, list) else []

View File

@@ -264,6 +264,8 @@ def build_status(
profile_advisor_status = {"enabled": True, "status": "ok", "model": str(runtime_values.get("profile_advisor_model", "") or "qwen3:8b")}
except Exception as exc:
profile_advisor_status = {"enabled": True, "status": "error", "error": str(exc), "model": str(runtime_values.get("profile_advisor_model", "") or "qwen3:8b")}
for suggestion in profile_suggestions:
suggestion.setdefault("profile_advisor", {"status": "heuristic", "error": str(exc)})
intel_ips = sorted(
{
ip

23
tests/test_llm.py Normal file
View File

@@ -0,0 +1,23 @@
import unittest
from fgai.llm import _json_object_from_text
class LlmTests(unittest.TestCase):
def test_json_object_from_text_accepts_markdown_wrapped_json(self):
payload = _json_object_from_text(
'```json\n{"profiles":[{"stream_id":"windows","entity_fields":["username"]}]}\n```'
)
self.assertEqual(payload["profiles"][0]["stream_id"], "windows")
def test_json_object_from_text_extracts_object_from_extra_text(self):
payload = _json_object_from_text(
'Here is the profile:\n{"profiles":[{"stream_id":"firewall"}]}\nDone.'
)
self.assertEqual(payload["profiles"][0]["stream_id"], "firewall")
if __name__ == "__main__":
unittest.main()

View File

@@ -137,6 +137,8 @@ class MonitorTests(unittest.TestCase):
advisor = status["capabilities"]["profile_advisor"]
self.assertEqual(advisor["status"], "error")
self.assertIn("timeout", advisor["error"])
self.assertEqual(status["profile_suggestions"][0]["profile_advisor"]["status"], "heuristic")
self.assertIn("timeout", status["profile_suggestions"][0]["profile_advisor"]["error"])
def test_cached_status_with_error_keeps_last_good_dashboard_data(self):
with tempfile.TemporaryDirectory() as tmp: