stream_coverage i status-API.
This commit is contained in:
@@ -68,6 +68,11 @@ checkboxes decide which streams are monitored. Click `Edit profile` on one strea
|
|||||||
to load its fields and edit only that stream's profile; saving with no active
|
to load its fields and edit only that stream's profile; saving with no active
|
||||||
profile editor leaves existing profiles unchanged.
|
profile editor leaves existing profiles unchanged.
|
||||||
|
|
||||||
|
When many Graylog streams are available, use `Diagnostics -> Stream Coverage` to
|
||||||
|
see which streams are enabled, which have profiles, how many profile fields are
|
||||||
|
baseline-ready, how many events were fetched, and whether a stream is `ready`,
|
||||||
|
`learning`, `missing_profile`, `no_events`, or `not_enabled`.
|
||||||
|
|
||||||
Enabled streams are normalized through the same event model. Stream profiles
|
Enabled streams are normalized through the same event model. Stream profiles
|
||||||
define the entity, timestamp, categorical, and numeric fields used for baselines.
|
define the entity, timestamp, categorical, and numeric fields used for baselines.
|
||||||
The dashboard and Ollama then correlate behavior across sources, for example a
|
The dashboard and Ollama then correlate behavior across sources, for example a
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ Acceptance: deployment, restart, upgrade, backup, and monitoring have documented
|
|||||||
Goal: optimize the UI for security investigation rather than raw tables.
|
Goal: optimize the UI for security investigation rather than raw tables.
|
||||||
|
|
||||||
- [x] Separate stream enablement from one-profile-at-a-time profile editing.
|
- [x] Separate stream enablement from one-profile-at-a-time profile editing.
|
||||||
|
- [x] Add stream coverage diagnostics for enabled/profiled/ready/no-event streams.
|
||||||
- [ ] Replace remaining long tables with compact incident and entity cards where appropriate.
|
- [ ] Replace remaining long tables with compact incident and entity cards where appropriate.
|
||||||
- [ ] Add incident filters for stream, severity, entity type, review state, and time range.
|
- [ ] Add incident filters for stream, severity, entity type, review state, and time range.
|
||||||
- [ ] Add baseline versus current charts per selected entity and field.
|
- [ ] Add baseline versus current charts per selected entity and field.
|
||||||
@@ -104,6 +105,7 @@ Acceptance: common triage can be completed from the dashboard without manually p
|
|||||||
Goal: add log sources and outputs without adding source-specific logic everywhere.
|
Goal: add log sources and outputs without adding source-specific logic everywhere.
|
||||||
|
|
||||||
- [ ] Define versioned stream-profile templates for FortiGate, Windows, DNS/AdGuard, Nginx, Squid, VPN, and Proxmox.
|
- [ ] Define versioned stream-profile templates for FortiGate, Windows, DNS/AdGuard, Nginx, Squid, VPN, and Proxmox.
|
||||||
|
- [x] Add inventory-style stream coverage to guide which streams need profiles before templates are added.
|
||||||
- [ ] Add import/export for profile templates and detector settings.
|
- [ ] Add import/export for profile templates and detector settings.
|
||||||
- [ ] Separate source adapters, normalizers, detectors, enrichers, and output adapters into explicit extension interfaces.
|
- [ ] Separate source adapters, normalizers, detectors, enrichers, and output adapters into explicit extension interfaces.
|
||||||
- [ ] Add optional webhook/SIEM ticket output for confirmed high-severity incidents.
|
- [ ] Add optional webhook/SIEM ticket output for confirmed high-severity incidents.
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ async function refresh() {
|
|||||||
const threat = (data.capabilities || {}).threat_intel || {};
|
const threat = (data.capabilities || {}).threat_intel || {};
|
||||||
const mcp = (data.capabilities || {}).graylog_mcp || {};
|
const mcp = (data.capabilities || {}).graylog_mcp || {};
|
||||||
const configuration = data.configuration || {};
|
const configuration = data.configuration || {};
|
||||||
|
const streamCoverage = data.stream_coverage || [];
|
||||||
|
const enabledStreams = streamCoverage.filter(item => item.enabled);
|
||||||
|
const streamsMissingProfile = enabledStreams.filter(item => !item.profile_ready).length;
|
||||||
const rawCorrelations = data.cross_source_correlations || [];
|
const rawCorrelations = data.cross_source_correlations || [];
|
||||||
const correlationsCached = rawCorrelations.length === 0 && uiCache.correlations.length > 0;
|
const correlationsCached = rawCorrelations.length === 0 && uiCache.correlations.length > 0;
|
||||||
const correlations = rawCorrelations.length ? rawCorrelations : uiCache.correlations;
|
const correlations = rawCorrelations.length ? rawCorrelations : uiCache.correlations;
|
||||||
@@ -143,7 +146,7 @@ async function refresh() {
|
|||||||
`Baseline sources ready: ${esc((data.baseline || {}).sources_ready || 0)}`
|
`Baseline sources ready: ${esc((data.baseline || {}).sources_ready || 0)}`
|
||||||
].join('<br>');
|
].join('<br>');
|
||||||
document.getElementById('health').innerHTML = [
|
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', correlations.length)
|
metric('Enabled streams', enabledStreams.length), metric('Streams missing profile', streamsMissingProfile), metric('MCP events fetched', mcp.events_fetched || 0), metric('Correlated entities', correlations.length)
|
||||||
].join('');
|
].join('');
|
||||||
const llmText = llm.text ? esc(llm.text).replace(/\\n/g, '<br>') : esc(llm.error || 'LLM assessment disabled or waiting for first run.');
|
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('llmAssessment').innerHTML = `<div>Status: <code>${esc(llm.status || 'unknown')}</code></div><p>${llmText}</p>`;
|
||||||
@@ -223,6 +226,7 @@ async function refresh() {
|
|||||||
const profileNames = Object.fromEntries((data.stream_profiles || []).map(item => [item.stream_id, item.name || item.stream_id]));
|
const profileNames = Object.fromEntries((data.stream_profiles || []).map(item => [item.stream_id, item.name || item.stream_id]));
|
||||||
const profileReadiness = (data.profile_readiness || []).map(item => ({...item, profile_name: item.profile_name || profileNames[item.stream_id] || item.stream_id, stream_title: item.stream_name || item.stream_title || streamTitles[item.stream_id] || item.stream_id}));
|
const profileReadiness = (data.profile_readiness || []).map(item => ({...item, profile_name: item.profile_name || profileNames[item.stream_id] || item.stream_id, stream_title: item.stream_name || item.stream_title || streamTitles[item.stream_id] || item.stream_id}));
|
||||||
document.getElementById('diagnostics').innerHTML =
|
document.getElementById('diagnostics').innerHTML =
|
||||||
|
'<h3>Stream Coverage</h3>' + table(streamCoverage, [{label:'Stream', key:'stream_name'}, {label:'Enabled', key:'enabled', render:r => r.enabled ? 'yes' : 'no'}, {label:'Profile', render:r => esc(r.profile || 'missing')}, {label:'Entity Field', key:'entity_field'}, {label:'Tracked Fields', key:'tracked_fields'}, {label:'Ready Fields', key:'readiness'}, {label:'Events', key:'events_fetched'}, {label:'Latest Event', key:'latest_event_time'}, {label:'Health', key:'health'}], 'stream-coverage') +
|
||||||
'<h3>Cross-Source Correlations</h3>' + table(correlations, [{label:'Entity', key:'entity', render:r => esc(`${r.entity || r.source_ip} (${r.entity_type || 'ip'})`)}, {label:'Streams', render:r => esc((r.streams || []).join(', '))}, {label:'Events', key:'events'}, {label:'Security Events', key:'security_events'}], 'correlations') +
|
'<h3>Cross-Source Correlations</h3>' + table(correlations, [{label:'Entity', key:'entity', render:r => esc(`${r.entity || r.source_ip} (${r.entity_type || 'ip'})`)}, {label:'Streams', render:r => esc((r.streams || []).join(', '))}, {label:'Events', key:'events'}, {label:'Security Events', key:'security_events'}], 'correlations') +
|
||||||
'<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(', '))}], 'entities') +
|
'<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(', '))}], 'entities') +
|
||||||
'<h3>Profile Baseline Readiness</h3>' + table(profileReadiness, [{label:'Profile', key:'profile_name'}, {label:'Stream', key:'stream_title'}, {label:'Field', key:'field'}, {label:'Buckets', key:'buckets'}, {label:'Ready', key:'ready', render:r => r.ready ? 'ready' : 'learning'}], 'profile-readiness') +
|
'<h3>Profile Baseline Readiness</h3>' + table(profileReadiness, [{label:'Profile', key:'profile_name'}, {label:'Stream', key:'stream_title'}, {label:'Field', key:'field'}, {label:'Buckets', key:'buckets'}, {label:'Ready', key:'ready', render:r => r.ready ? 'ready' : 'learning'}], 'profile-readiness') +
|
||||||
|
|||||||
@@ -51,6 +51,47 @@ def _range_seconds(value: object) -> int:
|
|||||||
return 3600
|
return 3600
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_coverage(runtime_values: dict[str, object], stream_profiles: dict[str, object], stream_status: dict[str, object], profile_readiness: list[dict[str, object]], stream_titles: dict[str, str]) -> list[dict[str, object]]:
|
||||||
|
configured = [
|
||||||
|
item for item in runtime_values.get("graylog_streams", [])
|
||||||
|
if isinstance(item, dict) and item.get("id")
|
||||||
|
]
|
||||||
|
status_by_id = {
|
||||||
|
str(item.get("stream_id", "")): item
|
||||||
|
for item in stream_status.get("streams", [])
|
||||||
|
if isinstance(item, dict) and item.get("stream_id")
|
||||||
|
}
|
||||||
|
readiness_by_stream: dict[str, list[dict[str, object]]] = {}
|
||||||
|
for item in profile_readiness:
|
||||||
|
readiness_by_stream.setdefault(str(item.get("stream_id", "")), []).append(item)
|
||||||
|
ids = list(dict.fromkeys([str(item.get("id", "")) for item in configured] + list(stream_profiles) + list(status_by_id)))
|
||||||
|
rows = []
|
||||||
|
for stream_id in ids:
|
||||||
|
profile = stream_profiles.get(stream_id)
|
||||||
|
readiness = readiness_by_stream.get(stream_id, [])
|
||||||
|
ready_fields = sum(1 for item in readiness if item.get("ready"))
|
||||||
|
total_fields = len(readiness)
|
||||||
|
status = status_by_id.get(stream_id, {})
|
||||||
|
enabled = next((bool(item.get("enabled")) for item in configured if str(item.get("id", "")) == stream_id), False)
|
||||||
|
rows.append({
|
||||||
|
"stream_id": stream_id,
|
||||||
|
"stream_name": _stream_name(stream_id, stream_titles, profile),
|
||||||
|
"enabled": enabled,
|
||||||
|
"profile": _profile_name(stream_id, stream_titles, profile) if profile else "",
|
||||||
|
"profile_ready": bool(profile),
|
||||||
|
"entity_field": str(getattr(profile, "entity_field", "")) if profile else "",
|
||||||
|
"tracked_fields": len(getattr(profile, "categorical_fields", ())) + len(getattr(profile, "numeric_fields", ())) if profile else 0,
|
||||||
|
"ready_fields": ready_fields,
|
||||||
|
"total_fields": total_fields,
|
||||||
|
"readiness": f"{ready_fields}/{total_fields}" if total_fields else "0/0",
|
||||||
|
"events_fetched": int(status.get("events_fetched", 0) or 0),
|
||||||
|
"latest_event_time": str(status.get("latest_event_time", "")),
|
||||||
|
"truncated": bool(status.get("truncated")),
|
||||||
|
"health": "not_enabled" if not enabled else "missing_profile" if not profile else "no_events" if int(status.get("events_fetched", 0) or 0) == 0 else "learning" if total_fields and ready_fields < total_fields else "ready" if total_fields else "profile_needs_fields",
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def build_status(
|
def build_status(
|
||||||
log_path: str,
|
log_path: str,
|
||||||
*,
|
*,
|
||||||
@@ -146,6 +187,7 @@ def build_status(
|
|||||||
}
|
}
|
||||||
for item in profile_readiness
|
for item in profile_readiness
|
||||||
]
|
]
|
||||||
|
stream_coverage = _stream_coverage(runtime_values, stream_profiles, mcp_status, profile_readiness, stream_titles)
|
||||||
intel_ips = sorted(
|
intel_ips = sorted(
|
||||||
{
|
{
|
||||||
ip
|
ip
|
||||||
@@ -184,6 +226,7 @@ def build_status(
|
|||||||
"capabilities": {"threat_intel": threat_intel_status, "graylog_mcp": mcp_status},
|
"capabilities": {"threat_intel": threat_intel_status, "graylog_mcp": mcp_status},
|
||||||
"configuration": runtime_config,
|
"configuration": runtime_config,
|
||||||
"stream_profiles": [{"stream_id": item.stream_id, "name": _profile_name(item.stream_id, stream_titles, item), "stream_name": _stream_name(item.stream_id, stream_titles, item), "entity_field": item.entity_field, "timestamp_field": item.timestamp_field, "categorical_fields": list(item.categorical_fields), "numeric_fields": list(item.numeric_fields), "detectors": item.detectors, "field_weights": item.field_weights} for item in stream_profiles.values()],
|
"stream_profiles": [{"stream_id": item.stream_id, "name": _profile_name(item.stream_id, stream_titles, item), "stream_name": _stream_name(item.stream_id, stream_titles, item), "entity_field": item.entity_field, "timestamp_field": item.timestamp_field, "categorical_fields": list(item.categorical_fields), "numeric_fields": list(item.numeric_fields), "detectors": item.detectors, "field_weights": item.field_weights} for item in stream_profiles.values()],
|
||||||
|
"stream_coverage": stream_coverage,
|
||||||
"profile_readiness": profile_readiness,
|
"profile_readiness": profile_readiness,
|
||||||
"diagnostics": {
|
"diagnostics": {
|
||||||
"top_source_ips": top_field_values(events, "srcip", limit=10),
|
"top_source_ips": top_field_values(events, "srcip", limit=10),
|
||||||
|
|||||||
@@ -54,6 +54,29 @@ class MonitorTests(unittest.TestCase):
|
|||||||
self.assertEqual(status["profile_readiness"][0]["profile_name"], "Fortigate profile")
|
self.assertEqual(status["profile_readiness"][0]["profile_name"], "Fortigate profile")
|
||||||
self.assertEqual(status["stream_profiles"][0]["name"], "Fortigate profile")
|
self.assertEqual(status["stream_profiles"][0]["name"], "Fortigate profile")
|
||||||
|
|
||||||
|
def test_stream_coverage_marks_missing_profiles(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config_path = Path(tmp) / "config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"graylog_streams": [
|
||||||
|
{"id": "firewall", "title": "Firewall", "enabled": True},
|
||||||
|
{"id": "dns", "title": "DNS", "enabled": True},
|
||||||
|
],
|
||||||
|
"graylog_stream_profiles": [{"stream_id": "firewall", "name": "Firewall profile", "entity_field": "srcip", "categorical_fields": ["action"]}],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with patch("fgai.monitor.BaselineStore.profile_readiness", return_value=[{"stream_id": "firewall", "field": "action", "buckets": 12, "ready": True}]):
|
||||||
|
status = build_status(str(Path(tmp) / "missing.log"), baseline_path=str(Path(tmp) / "baseline.sqlite3"), config_path=str(config_path))
|
||||||
|
|
||||||
|
coverage = {item["stream_id"]: item for item in status["stream_coverage"]}
|
||||||
|
self.assertEqual(coverage["firewall"]["health"], "no_events")
|
||||||
|
self.assertEqual(coverage["firewall"]["readiness"], "1/1")
|
||||||
|
self.assertEqual(coverage["dns"]["health"], "missing_profile")
|
||||||
|
|
||||||
def test_add_llm_assessment_records_error_without_ollama(self):
|
def test_add_llm_assessment_records_error_without_ollama(self):
|
||||||
status = {"summary": {}, "anomalies": []}
|
status = {"summary": {}, "anomalies": []}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user