diff --git a/src/fgai/baseline.py b/src/fgai/baseline.py
index 3ce3a91..fde084a 100644
--- a/src/fgai/baseline.py
+++ b/src/fgai/baseline.py
@@ -182,6 +182,16 @@ class BaselineStore:
output[entity].append(evidence)
return output
+ def profile_readiness(self, profiles: dict[str, object]) -> list[dict[str, object]]:
+ rows: list[dict[str, object]] = []
+ with self._connect() as connection:
+ for stream_id, profile in profiles.items():
+ fields = [*getattr(profile, "categorical_fields", ()), *getattr(profile, "numeric_fields", ())]
+ for field in fields:
+ count = connection.execute("select count(distinct bucket_start) from profile_buckets where stream_id=? and field=?", (stream_id, str(field).lower())).fetchone()[0]
+ rows.append({"stream_id": stream_id, "field": str(field), "buckets": count, "ready": count >= 12})
+ return rows
+
def profiles(self, source_ips: set[str]) -> dict[str, dict[str, object]]:
profiles: dict[str, dict[str, object]] = {}
with self._connect() as connection:
diff --git a/src/fgai/dashboard.py b/src/fgai/dashboard.py
index 843ff6e..3ff180f 100644
--- a/src/fgai/dashboard.py
+++ b/src/fgai/dashboard.py
@@ -186,10 +186,12 @@ async function refresh() {
]);
const d = data.diagnostics || {};
const context = data.event_context || {};
+ const profileReadiness = data.profile_readiness || [];
const correlations = data.cross_source_correlations || [];
document.getElementById('diagnostics').innerHTML =
'
Cross-Source Correlations
' + 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'}]) +
'Entities
' + 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(', '))}]) +
+ 'Profile Baseline Readiness
' + table(profileReadiness, [{label:'Stream', key:'stream_id'}, {label:'Field', key:'field'}, {label:'Buckets', key:'buckets'}, {label:'Ready', render:r => r.ready ? 'ready' : 'learning'}]) +
'Security Event Samples
' + 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'}]) +
'Top Sources
' + table(d.top_source_ips || [], [{label:'Value', key:'value'}, {label:'Count', key:'count'}]) +
'Top Destinations
' + table(d.top_destination_ips || [], [{label:'Value', key:'value'}, {label:'Count', key:'count'}]) +
diff --git a/src/fgai/monitor.py b/src/fgai/monitor.py
index ec42567..d134d90 100644
--- a/src/fgai/monitor.py
+++ b/src/fgai/monitor.py
@@ -77,6 +77,7 @@ def build_status(
anomalies = detect_source_anomalies(events, limit=anomaly_limit, baselines=profiles, field_deviations=field_deviations)
baseline_events = baseline.ingest(events) if baseline else 0
profile_baseline_fields = baseline.ingest_profile_fields(events, stream_profiles) if baseline else 0
+ profile_readiness = baseline.profile_readiness(stream_profiles) if baseline else []
intel_ips = sorted(
{
ip
@@ -114,6 +115,7 @@ def build_status(
"capabilities": {"threat_intel": threat_intel_status, "graylog_mcp": mcp_status},
"configuration": runtime_config,
"stream_profiles": [{"stream_id": item.stream_id, "entity_field": item.entity_field, "timestamp_field": item.timestamp_field, "categorical_fields": list(item.categorical_fields), "numeric_fields": list(item.numeric_fields)} for item in stream_profiles.values()],
+ "profile_readiness": profile_readiness,
"diagnostics": {
"top_source_ips": top_field_values(events, "srcip", limit=10),
"top_destination_ips": top_field_values(events, "dstip", limit=10),