from __future__ import annotations from collections import Counter from datetime import UTC, datetime import hashlib import json import logging import os from threading import Lock, Thread from time import perf_counter from typing import Any from sqlalchemy import func from sqlmodel import Session, select from ..core.redaction import redact_message from ..core.time import utc_now from ..db.models import App, AppSetting, AuditEvent, Connector, DiscoveryRun, JobRun, Policy, Secret from . import ai_remediation_planner, ai_security_reviewer from .exposure_preferences import get_exposure_preferences from .audit_logger import log_event logger = logging.getLogger(__name__) DEFAULT_CACHE_TTL_SECONDS = 60 CACHE_KEY = 'safety-dashboard-v1' CACHE_SCHEMA_VERSION = 3 CACHE_SETTING_KEY = 'safety_center:dashboard_cache' DEFAULT_PERSISTENT_CACHE_MAX_STALE_SECONDS = 900 _dashboard_cache: dict[str, Any] | None = None _skip_persistent_cache_once = False _background_refresh_lock = Lock() _background_refresh_running = False SECTION_DEFINITIONS = [ ('setup-coverage', 'Setup & Coverage', '/setup', {'coverage', 'connector_config', 'setup'}), ('external-exposure', 'External Exposure', '/network', {'exposure'}), ('authentication-coverage', 'Authentication Coverage', '/apps/authentik', {'auth'}), ('secret-hygiene', 'Secret Hygiene', '/secrets', {'secrets'}), ('policy-posture', 'Policy Posture', '/policies', {'policies'}), ('inventory-app-review', 'Inventory & App Review', '/inventory', {'inventory', 'connectors'}), ('media-pipeline', 'Media Pipeline', '/media-pipeline', {'media'}), ('unraid-host-safety', 'Unraid & Host Safety', '/unraid', {'unraid'}), ('audit-activity', 'Audit & Activity', '/audit', {'audit'}), ] SEVERITY_PENALTY = {'critical': 20, 'high': 12, 'medium': 6, 'low': 2, 'info': 0} CATEGORY_MAP = { 'auth_coverage': 'auth', 'coverage': 'setup', 'connector_config': 'connectors', } SOURCE_LABELS = { 'inventory': 'Inventory', 'portainer': 'Portainer', 'unraid': 'Unraid', 'npm': 'NPM', 'authentik': 'Authentik', 'secret_storage': 'Secret Storage', 'policies': 'Policies', 'audit': 'Audit', 'media_pipeline': 'Media Pipeline', } def _now() -> str: return datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z') def _parse_utc(value: str) -> datetime: return datetime.fromisoformat(value.replace('Z', '+00:00')) def _ttl_seconds() -> int: raw = os.getenv('SAFETY_CACHE_TTL_SECONDS', str(DEFAULT_CACHE_TTL_SECONDS)) try: return max(1, int(raw)) except (TypeError, ValueError): return DEFAULT_CACHE_TTL_SECONDS def _persistent_cache_max_stale_seconds() -> int: raw = os.getenv('SAFETY_PERSISTENT_CACHE_MAX_STALE_SECONDS', str(DEFAULT_PERSISTENT_CACHE_MAX_STALE_SECONDS)) try: return max(0, int(raw)) except (TypeError, ValueError): return DEFAULT_PERSISTENT_CACHE_MAX_STALE_SECONDS def invalidate_cache() -> None: global _dashboard_cache, _skip_persistent_cache_once _dashboard_cache = None _skip_persistent_cache_once = True def _redact(value: Any) -> Any: if isinstance(value, str): return redact_message(value) if isinstance(value, list): return [_redact(item) for item in value] if isinstance(value, dict): return { str(key): _redact(item) for key, item in value.items() if 'secret' not in str(key).lower() and 'token' not in str(key).lower() } return value def _state_key(kind: str, item_id: str) -> str: return f'safety_center:{kind}:{item_id}:status' def _get_state(session: Session, kind: str, item_id: str, default: str = 'open') -> str: row = session.get(AppSetting, _state_key(kind, item_id)) if not row: return default return str(row.value_json.get('status') or default) def _set_state(session: Session, kind: str, item_id: str, status: str) -> dict[str, Any]: row = session.get(AppSetting, _state_key(kind, item_id)) if not row: row = AppSetting(key=_state_key(kind, item_id), is_secret=False) row.value_json = {'status': status, 'updatedAt': _now()} row.updated_at = utc_now() session.add(row) session.commit() log_event( session, source='manual', identity='safety_center', action_id=f'safety.{kind}.{status}', target=item_id, target_type=kind, risk='low', policy_decision='allowed', reason=f'Safety Center {kind} marked {status}.', result='success', redacted_metadata={'kind': kind, 'id': item_id, 'status': status}, ) invalidate_cache() return {'success': True, 'id': item_id, 'status': status} def _category(category: str) -> str: return CATEGORY_MAP.get(category, category) def _action_type(raw: str | None) -> str: allowed = { 'open_page', 'configure_connector', 'configure_secret', 'run_review', 'create_remediation_plan', 'run_readonly_check', 'review_policy', } if raw == 'run_readonly_check': return raw if raw in {'policy_draft', 'review_policy'}: return 'review_policy' if raw == 'configure_secret': return raw if raw == 'configure_connector': return raw return 'open_page' if raw in {None, 'link'} else (raw if raw in allowed else 'open_page') def _suggested_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]: result: list[dict[str, Any]] = [] for action in actions: label = str(action.get('label') or action.get('type') or 'Open details') href = action.get('href') or action.get('link') result.append({ 'label': _redact(label), 'actionType': _action_type(action.get('type')), 'href': href or '/safety', 'policyDecision': action.get('policyDecision'), 'executable': False, }) return result def _findings(session: Session, review: Any) -> list[dict[str, Any]]: findings: list[dict[str, Any]] = [] for finding in review.findings: source_status = _get_state(session, 'finding', finding.id, finding.status) category = _category(finding.category) findings.append({ 'id': finding.id, 'title': _redact(finding.title), 'description': _redact(finding.description), 'severity': finding.severity, 'category': category, 'affectedResources': _redact(finding.affectedResources), 'evidence': _redact([item.model_dump() for item in finding.evidence]), 'suggestedActions': _suggested_actions([item.model_dump() for item in finding.suggestedActions]), 'source': 'AI Security Reviewer', 'status': source_status, }) return findings def _coverage(session: Session, summary: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], list[str], list[str], dict[str, Any]]: breakdown = summary.get('scoreBreakdown') if isinstance(summary.get('scoreBreakdown'), dict) else {} sources = breakdown.get('coverageSources') if isinstance(breakdown.get('coverageSources'), dict) else {} if not sources: sources = { 'inventory': {'status': 'connected'}, 'portainer': {'status': _connector_status(session, 'portainer')}, 'unraid': {'status': _connector_status(session, 'unraid')}, 'npm': {'status': _connector_status(session, 'nginx_proxy_manager')}, 'authentik': {'status': _connector_status(session, 'authentik')}, 'secret_storage': {'status': 'connected'}, 'policies': {'status': 'connected'}, 'audit': {'status': 'partial'}, 'media_pipeline': {'status': 'partial'}, } normalized = { key: {'label': SOURCE_LABELS.get(key, key), 'status': str(value.get('status', 'unknown')), 'effectOnConfidence': value.get('effectOnConfidence', 'medium')} for key, value in sources.items() if isinstance(value, dict) } connected = [key for key, value in normalized.items() if value['status'] == 'connected'] missing = [key for key, value in normalized.items() if value['status'] in {'missing_config', 'unavailable', 'unsupported', 'error', 'unknown'}] completeness_weight = sum(_coverage_weight(value['status']) for value in normalized.values()) completeness = {'percentage': round((completeness_weight / max(len(normalized), 1)) * 100), 'connectedSources': connected, 'missingSources': missing} return normalized, connected, missing, completeness def _connector_status(session: Session, connector_id: str) -> str: connector = session.get(Connector, connector_id) return str(connector.status if connector else 'unknown') def _coverage_weight(status: str) -> float: if status == 'connected': return 1.0 if status == 'partial': return 0.5 return 0.0 def _counts(findings: list[dict[str, Any]]) -> Counter: return Counter(finding['severity'] for finding in findings if finding['status'] == 'open') def _has_real_high_or_critical(findings: list[dict[str, Any]]) -> bool: proven_high_risk_categories = {'auth', 'auth_coverage', 'exposure', 'policies', 'secrets', 'unraid'} return any( finding['status'] == 'open' and finding['severity'] in {'high', 'critical'} and finding['category'] in proven_high_risk_categories for finding in findings ) def _status(score: int, counts: Counter, missing: list[str], findings: list[dict[str, Any]]) -> str: if counts['critical'] > 0: return 'critical' if missing: return 'warning' if counts['high'] > 0 and _has_real_high_or_critical(findings) else 'partial' if score < 85 or counts['medium'] or counts['high']: return 'warning' return 'healthy' def _section_status(score: int, counts: Counter, missing_only: bool) -> str: if counts['critical'] > 0: return 'critical' if missing_only and counts['high'] == 0: return 'partial' if score < 85 or counts['medium'] or counts['high']: return 'warning' return 'healthy' def _section_for_category(category: str) -> str: for section_id, _, _, categories in SECTION_DEFINITIONS: if category in categories: return section_id return 'setup-coverage' def _section_summary(section_id: str, section_findings: list[dict[str, Any]]) -> str: if not section_findings: return 'Geen open rule-based findings voor deze sectie.' top = section_findings[0] return str(top['title']) def _sections(findings: list[dict[str, Any]], recommendations: list[dict[str, Any]], coverage: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: result = [] for section_id, title, href, categories in SECTION_DEFINITIONS: section_findings = [ finding for finding in findings if finding['status'] == 'open' and _section_for_category(finding['category']) == section_id ] section_recommendations = [item for item in recommendations if item['sectionId'] == section_id and item['status'] == 'open'] counts = _counts(section_findings) score = max(0, 100 - sum(SEVERITY_PENALTY.get(finding['severity'], 0) for finding in section_findings)) related_sources = _related_sources(section_id) source_weight = sum(_coverage_weight(str(coverage.get(source, {}).get('status', 'unknown'))) for source in related_sources) missing_only = bool(related_sources) and source_weight < len(related_sources) and not any(finding['severity'] in {'high', 'critical'} for finding in section_findings) completeness = round((source_weight / max(len(related_sources), 1)) * 100) if related_sources else 100 result.append({ 'id': section_id, 'title': title, 'status': _section_status(score, counts, missing_only), 'score': score, 'summary': _section_summary(section_id, section_findings), 'findingsCount': len(section_findings), 'recommendationsCount': len(section_recommendations), 'dataCompleteness': completeness, 'href': href, 'categories': sorted(categories), }) return result def _related_sources(section_id: str) -> list[str]: return { 'setup-coverage': ['inventory', 'portainer', 'secret_storage', 'policies'], 'external-exposure': ['npm'], 'authentication-coverage': ['authentik', 'npm'], 'secret-hygiene': ['secret_storage'], 'policy-posture': ['policies'], 'inventory-app-review': ['inventory', 'portainer'], 'media-pipeline': ['media_pipeline'], 'unraid-host-safety': ['unraid', 'portainer'], 'audit-activity': ['audit'], }.get(section_id, []) def _base_recommendations(session: Session, findings: list[dict[str, Any]], coverage: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: recommendations: list[dict[str, Any]] = [] for source, detail in coverage.items(): if detail['status'] not in {'missing_config', 'unavailable', 'unsupported', 'error', 'unknown'}: continue if source in {'npm', 'authentik', 'unraid'}: recommendations.append(_recommendation( rec_id=f'safety-rec-configure-{source}', title=f'Configureer {SOURCE_LABELS.get(source, source)} connector', description='Vul de connectorconfiguratie en secret in en draai daarna een read-only test.', severity='medium', confidence='high', source='Setup & Coverage', affected=[source], action_type='configure_connector', href='/setup', section_id='setup-coverage', )) for finding in findings: if finding['severity'] not in {'critical', 'high', 'medium'}: continue section_id = _section_for_category(finding['category']) action = finding['suggestedActions'][0] if finding['suggestedActions'] else {} recommendations.append(_recommendation( rec_id=f'safety-rec-{finding["id"]}', title=f'Review: {finding["title"]}', description=finding['description'], severity=finding['severity'], confidence='high', source=section_id, affected=finding['affectedResources'], action_type=action.get('actionType') or 'open_page', href=action.get('href') or '/safety', section_id=section_id, source_finding_id=finding['id'], policy_decision=action.get('policyDecision'), )) return list({item['id']: item for item in recommendations}.values()) def _recommendation( *, rec_id: str, title: str, description: str, severity: str, confidence: str, source: str, affected: list[str], action_type: str, href: str, section_id: str, source_finding_id: str | None = None, policy_decision: str | None = None, ) -> dict[str, Any]: return { 'id': rec_id, 'title': _redact(title), 'description': _redact(description), 'severity': severity, 'confidence': confidence, 'source': source, 'affectedResources': _redact(affected), 'actionType': _action_type(action_type), 'href': href, 'policyDecision': policy_decision, 'executable': False, 'canCreateRemediationPlan': bool(source_finding_id), 'sourceFindingId': source_finding_id, 'sectionId': section_id, 'status': 'open', } def _add_local_findings(session: Session, findings: list[dict[str, Any]]) -> list[dict[str, Any]]: result = list(findings) latest_run = session.exec(select(DiscoveryRun).order_by(DiscoveryRun.started_at.desc())).first() if not latest_run: result.append(_local_finding('safety-discovery-not-run', 'Container discovery is nog niet gedraaid', 'Discovery coverage ontbreekt; inventory review is daardoor partial.', 'medium', 'setup', ['discovery'], ['No discovery run found'], '/inventory')) apps_need_review = session.exec(select(App).where(App.needs_review == True)).all() # noqa: E712 if apps_need_review: href = f'/apps/{apps_need_review[0].id}' if len(apps_need_review) == 1 else '/inventory' result.append(_local_finding('safety-apps-need-review', 'Apps wachten op review', f'{len(apps_need_review)} app(s) vragen operator review voordat bredere automation veilig is.', 'medium', 'inventory', [app.id for app in apps_need_review[:6]], ['needs_review=true'], href)) jobs = session.exec(select(JobRun).where(JobRun.status.in_(['queued', 'running']))).all() if len(jobs) >= 5: result.append(_local_finding('safety-many-open-jobs', 'Veel open jobs actief', f'{len(jobs)} jobs staan queued/running.', 'low', 'audit', ['jobs'], ['open jobs >= 5'], '/jobs')) return list({item['id']: item for item in result}.values()) def _local_finding(finding_id: str, title: str, description: str, severity: str, category: str, affected: list[str], evidence: list[str], href: str) -> dict[str, Any]: return { 'id': finding_id, 'title': title, 'description': description, 'severity': severity, 'category': category, 'affectedResources': affected, 'evidence': [{'source': 'safety_center', 'label': title, 'value': evidence, 'redacted': False}], 'suggestedActions': [{'label': 'Open details', 'actionType': 'open_page', 'href': href, 'policyDecision': 'allowed', 'executable': False}], 'source': 'Safety Center', 'status': 'open', } def _source_signature(session: Session) -> str: from .app_registry import list_active_apps from .inventory_discovery import latest_discovered_by_container policies = [ { 'id': item.id, 'action': item.action, 'target': item.target, 'voice': item.voice, 'manual': item.manual, 'risk': item.risk, } for item in session.exec(select(Policy)).all() ] connectors = [ { 'id': item.id, 'status': item.status, 'lastTest': item.last_test, 'secretRef': item.secret_ref, 'capabilities': item.capabilities, } for item in session.exec(select(Connector)).all() ] apps = [ { 'id': item.id, 'status': item.status, 'connectorStatus': item.connector_status, 'containerStatus': item.container_status, 'needsReview': item.needs_review, 'discoveryStatus': item.discovery_status, 'protected': item.protected, } for item in list_active_apps(session) ] settings = [ { 'key': item.key, 'updatedAt': item.updated_at.isoformat() if item.updated_at else None, 'isSecret': item.is_secret, 'status': item.value_json.get('status') if isinstance(item.value_json, dict) else None, } for item in session.exec(select(AppSetting)).all() if item.key != CACHE_SETTING_KEY and not item.key.startswith('ai:chat:') and item.key != 'ai:modelforge:last_inference' and not item.key.startswith('ai_security:review_cache') and item.key not in {'media_pipeline:summary_cache', 'network_exposure:cache'} and (not item.is_secret or item.key.startswith('safety_center:')) ] secret_rows = [ { 'id': item.id, 'name': item.name, 'updatedAt': item.updated_at.isoformat() if item.updated_at else None, 'usedBy': item.used_by_json, } for item in session.exec(select(Secret)).all() ] discovered = [ { 'containerId': item.container_id, 'name': item.name, 'image': item.image, 'state': item.state, 'status': item.status, 'importStatus': item.import_status, 'matchedAppId': item.matched_app_id, } for item in latest_discovered_by_container(session).values() ] job_count = session.exec(select(func.count(JobRun.id))).one() latest_job_started = session.exec(select(func.max(JobRun.started_at))).one() non_safety_audit = AuditEvent.action_id.not_like('safety.%') audit_count = session.exec(select(func.count(AuditEvent.id)).where(non_safety_audit)).one() latest_audit_timestamp = session.exec(select(func.max(AuditEvent.timestamp)).where(non_safety_audit)).one() signature_payload = { 'cacheSchemaVersion': CACHE_SCHEMA_VERSION, 'exposurePreferences': get_exposure_preferences(session).model_dump(), 'policies': sorted(policies, key=lambda item: item['id']), 'connectors': sorted(connectors, key=lambda item: item['id']), 'apps': sorted(apps, key=lambda item: item['id']), 'settings': sorted(settings, key=lambda item: item['key']), 'secrets': sorted(secret_rows, key=lambda item: item['id']), 'discovered': sorted(discovered, key=lambda item: item['containerId']), 'counts': { 'jobs': job_count, 'latestJobStarted': latest_job_started.isoformat() if latest_job_started else None, 'audit': audit_count, 'latestAuditTimestamp': latest_audit_timestamp.isoformat() if latest_audit_timestamp else None, }, } serialized = json.dumps(signature_payload, sort_keys=True, default=str, separators=(',', ':')) return hashlib.sha256(serialized.encode('utf-8')).hexdigest() def _payload(session: Session) -> dict[str, Any]: review = ai_security_reviewer.generate_review(session) security_summary = review.summary.model_dump() coverage, connected, missing, completeness = _coverage(session, security_summary) findings = _add_local_findings(session, _findings(session, review)) counts = _counts(findings) score = max(0, min(100, 100 - sum(SEVERITY_PENALTY.get(finding['severity'], 0) for finding in findings if finding['status'] == 'open'))) recommendations = _base_recommendations(session, findings, coverage) sections = _sections(findings, recommendations, coverage) status = _status(score, counts, missing, findings) summary = { 'status': status, 'score': score, 'generatedAt': _now(), 'dataCompleteness': completeness, 'coverageStatus': coverage, 'criticalCount': counts['critical'], 'highCount': counts['high'], 'mediumCount': counts['medium'], 'warningCount': counts['low'] + counts['medium'] + counts['high'], 'recommendationCount': len([item for item in recommendations if item['status'] == 'open']), 'connectedSources': connected, 'missingSources': missing, 'sections': sections, } score_breakdown = { 'baseScore': 100, 'finalScore': score, 'penalties': [ { 'source': finding['source'], 'reason': finding['title'], 'severity': finding['severity'], 'points': SEVERITY_PENALTY.get(finding['severity'], 0), 'findingId': finding['id'], } for finding in findings if finding['status'] == 'open' and SEVERITY_PENALTY.get(finding['severity'], 0) > 0 ], 'bonuses': [], 'missingCoverageImpact': 'Missing coverage lowers confidence and data completeness; it is not treated as critical without evidence.', 'criticalEvidence': [finding['id'] for finding in findings if finding['status'] == 'open' and finding['severity'] == 'critical'], 'explanation': f'Startscore 100 minus open finding penalties. Current status is {status}.', 'missingSources': missing, 'dataCompleteness': completeness, 'coverageStatus': coverage, } return {'summary': _redact(summary), 'sections': _redact(sections), 'findings': _redact(findings), 'recommendations': _redact(recommendations), 'scoreBreakdown': _redact(score_breakdown)} def _coverage_sources(payload: dict[str, Any]) -> list[dict[str, Any]]: coverage = payload.get('summary', {}).get('coverageStatus', {}) if not isinstance(coverage, dict): return [] return [ { 'id': source, 'label': detail.get('label') or SOURCE_LABELS.get(source, source), 'status': detail.get('status', 'unknown'), 'effectOnConfidence': detail.get('effectOnConfidence'), } for source, detail in sorted(coverage.items()) if isinstance(detail, dict) ] def _with_cache_metadata(payload: dict[str, Any], *, hit: bool, source: str, generated_at: str, expires_at: str, ttl_seconds: int, generation_duration_ms: int, stale_reason: str | None = None) -> dict[str, Any]: dashboard = dict(payload) dashboard['generatedAt'] = generated_at dashboard['coverageSources'] = _coverage_sources(payload) cache_meta: dict[str, Any] = { 'hit': hit, 'ttlSeconds': ttl_seconds, 'expiresAt': expires_at, 'source': source, 'generationDurationMs': generation_duration_ms, } if stale_reason: cache_meta['staleReason'] = _redact(stale_reason) dashboard['cache'] = cache_meta return _redact(dashboard) def _persistent_cache(session: Session, signature: str) -> dict[str, Any] | None: row = session.get(AppSetting, CACHE_SETTING_KEY) if not row or not isinstance(row.value_json, dict): return None value = row.value_json if value.get('schemaVersion') != CACHE_SCHEMA_VERSION: return None if value.get('signature') != signature: return None payload = value.get('payload') if not isinstance(payload, dict): return None return { 'key': CACHE_KEY, 'signature': signature, 'payload': payload, 'generatedAt': value.get('generatedAt'), 'expiresAt': value.get('expiresAt'), 'generationDurationMs': value.get('generationDurationMs', 0), } def _store_persistent_cache(session: Session, cache: dict[str, Any]) -> None: row = session.get(AppSetting, CACHE_SETTING_KEY) if not row: row = AppSetting(key=CACHE_SETTING_KEY, is_secret=False) row.value_json = { 'schemaVersion': CACHE_SCHEMA_VERSION, 'signature': cache.get('signature'), 'payload': _redact(cache.get('payload')), 'generatedAt': cache.get('generatedAt'), 'expiresAt': cache.get('expiresAt'), 'generationDurationMs': cache.get('generationDurationMs', 0), } row.updated_at = utc_now() session.add(row) session.commit() def _schedule_background_refresh() -> bool: global _background_refresh_running with _background_refresh_lock: if _background_refresh_running: return False _background_refresh_running = True def refresh_worker() -> None: global _background_refresh_running try: from ..db.session import engine with Session(engine) as refresh_session: get_dashboard(refresh_session, force_refresh=True) except Exception: # noqa: BLE001 - stale safety data remains available on refresh failure. logger.exception('Background Safety dashboard refresh failed') finally: with _background_refresh_lock: _background_refresh_running = False Thread(target=refresh_worker, name='appops-safety-refresh', daemon=True).start() return True def get_dashboard(session: Session, force_refresh: bool = False) -> dict[str, Any]: global _dashboard_cache, _skip_persistent_cache_once ttl_seconds = _ttl_seconds() now_dt = datetime.now(UTC) signature = _source_signature(session) if _dashboard_cache and not force_refresh and _dashboard_cache.get('signature') == signature: expires_at = _parse_utc(str(_dashboard_cache['expiresAt'])) if now_dt < expires_at: return _with_cache_metadata( _dashboard_cache['payload'], hit=True, source='cache', generated_at=str(_dashboard_cache['generatedAt']), expires_at=str(_dashboard_cache['expiresAt']), ttl_seconds=ttl_seconds, generation_duration_ms=int(_dashboard_cache.get('generationDurationMs', 0)), ) skip_persistent = _skip_persistent_cache_once _skip_persistent_cache_once = False persisted = None if force_refresh or skip_persistent else _persistent_cache(session, signature) if persisted: expires_at = _parse_utc(str(persisted['expiresAt'])) max_stale_seconds = _persistent_cache_max_stale_seconds() stale_until = expires_at.timestamp() + max_stale_seconds if now_dt < expires_at: _dashboard_cache = persisted return _with_cache_metadata( persisted['payload'], hit=True, source='cache', generated_at=str(persisted['generatedAt']), expires_at=str(persisted['expiresAt']), ttl_seconds=ttl_seconds, generation_duration_ms=int(persisted.get('generationDurationMs', 0)), ) if max_stale_seconds > 0 and now_dt.timestamp() < stale_until: _dashboard_cache = persisted _schedule_background_refresh() return _with_cache_metadata( persisted['payload'], hit=True, source='stale-while-revalidate', generated_at=str(persisted['generatedAt']), expires_at=str(persisted['expiresAt']), ttl_seconds=ttl_seconds, generation_duration_ms=int(persisted.get('generationDurationMs', 0)), ) started = perf_counter() try: payload = _payload(session) except Exception as exc: # A stale dashboard must still match the current configuration. In # particular it cannot bring back suppressed ACL warnings after a # diagnostics preference change. if _dashboard_cache and _dashboard_cache.get('signature') == signature: return _with_cache_metadata( _dashboard_cache['payload'], hit=True, source='stale', generated_at=str(_dashboard_cache['generatedAt']), expires_at=str(_dashboard_cache['expiresAt']), ttl_seconds=ttl_seconds, generation_duration_ms=int(_dashboard_cache.get('generationDurationMs', 0)), stale_reason=str(exc), ) raise duration_ms = int((perf_counter() - started) * 1000) generated_at = str(payload['summary']['generatedAt']) expires_at = (now_dt.replace(microsecond=0).timestamp() + ttl_seconds) expires_at_iso = datetime.fromtimestamp(expires_at, UTC).isoformat().replace('+00:00', 'Z') _dashboard_cache = { 'key': CACHE_KEY, 'signature': signature, 'payload': payload, 'generatedAt': generated_at, 'expiresAt': expires_at_iso, 'generationDurationMs': duration_ms, } _store_persistent_cache(session, _dashboard_cache) logger.info('Safety dashboard generated in %sms with score=%s status=%s', duration_ms, payload['summary'].get('score'), payload['summary'].get('status')) return _with_cache_metadata( payload, hit=False, source='fresh', generated_at=generated_at, expires_at=expires_at_iso, ttl_seconds=ttl_seconds, generation_duration_ms=duration_ms, ) def get_summary(session: Session) -> dict[str, Any]: return get_dashboard(session)['summary'] def get_sections(session: Session) -> list[dict[str, Any]]: return get_dashboard(session)['sections'] def get_findings(session: Session) -> list[dict[str, Any]]: return get_dashboard(session)['findings'] def get_recommendations(session: Session) -> list[dict[str, Any]]: return get_dashboard(session)['recommendations'] def get_score_breakdown(session: Session) -> dict[str, Any]: return get_dashboard(session)['scoreBreakdown'] def refresh(session: Session) -> dict[str, Any]: invalidate_cache() dashboard = get_dashboard(session, force_refresh=True) log_event( session, source='manual', identity='safety_center', action_id='safety.refresh', target='safety-center', target_type='safety', risk='low', policy_decision='allowed', reason='Safety Center read-only refresh.', result='success', redacted_metadata={'score': dashboard['summary']['score'], 'status': dashboard['summary']['status'], 'findings': len(dashboard['findings']), 'cache': dashboard['cache']['source']}, ) return dashboard def acknowledge_finding(session: Session, finding_id: str) -> dict[str, Any]: return _set_state(session, 'finding', finding_id, 'acknowledged') def dismiss_finding(session: Session, finding_id: str) -> dict[str, Any]: return _set_state(session, 'finding', finding_id, 'dismissed') def dismiss_recommendation(session: Session, recommendation_id: str) -> dict[str, Any]: return _set_state(session, 'recommendation', recommendation_id, 'dismissed') def create_remediation_plan(session: Session, recommendation_id: str) -> dict[str, Any]: recommendation = next((item for item in get_recommendations(session) if item['id'] == recommendation_id), None) if not recommendation: return {'success': False, 'status': 'not_found', 'recommendationId': recommendation_id, 'executable': False, 'href': '/ai/remediation-planner'} finding_id = recommendation.get('sourceFindingId') plan_id = None if finding_id: plan = ai_remediation_planner.generate_from_finding(session, str(finding_id)) plan_id = plan.id log_event( session, source='manual', identity='safety_center', action_id='safety.recommendation.create_remediation_plan', target=recommendation_id, target_type='safety_recommendation', risk='low', policy_decision='allowed', reason='Safety Center linked a non-executing remediation plan.', result='success', redacted_metadata={'recommendationId': recommendation_id, 'planId': plan_id}, ) invalidate_cache() href = f'/ai/remediation-planner?plan={plan_id}' if plan_id else '/ai/remediation-planner' return {'success': True, 'status': 'plan_linked', 'recommendationId': recommendation_id, 'planId': plan_id, 'href': href, 'executable': False}