diff --git a/README.md b/README.md index 9092b2f..c740fbe 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ nach einer ausdrücklichen Freigabe ausführen. [`docs/V1_2_0_OPERATING_GUIDE.md`](docs/V1_2_0_OPERATING_GUIDE.md) - Version 1.3.0 Anomalie- und Performance-Überwachung: [`docs/V1_3_0_OPERATING_GUIDE.md`](docs/V1_3_0_OPERATING_GUIDE.md) +- Version 1.4.0 deutsches Dashboard und gestufter Datenabruf: + [`docs/V1_4_0_OPERATING_GUIDE.md`](docs/V1_4_0_OPERATING_GUIDE.md) - Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md) ## Reifegrad diff --git a/addon/config.yaml b/addon/config.yaml index 03c01f1..b6baaa2 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "1.3.0" +version: "1.4.0" slug: sillyhome_next description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren url: http://192.168.6.31:3000/pino/sillyhome-next diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py index 5c556cb..231ab8f 100644 --- a/app/api/v1/actuators.py +++ b/app/api/v1/actuators.py @@ -306,6 +306,15 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]: @router.get("/dashboard", response_model=DashboardOverview) def dashboard_overview(request: Request) -> DashboardOverview: + return _dashboard_overview(request, include_background=True) + + +@router.get("/dashboard/start", response_model=DashboardOverview) +def dashboard_start(request: Request) -> DashboardOverview: + return _dashboard_overview(request, include_background=False) + + +def _dashboard_overview(request: Request, *, include_background: bool) -> DashboardOverview: cache_payload = _load_entity_cache_payload(request) raw_entities = cache_payload.get("entities", []) if not isinstance(raw_entities, list): @@ -317,14 +326,14 @@ def dashboard_overview(request: Request) -> DashboardOverview: DashboardDiscoveryGroup.model_validate(group) for group in raw_groups if isinstance(group, dict) - ] if isinstance(raw_groups, list) else [] + ] if include_background and isinstance(raw_groups, list) else [] reconciliation = _reconciliation_state_or_default(request) ws_status = getattr(request.app.state, "ws_status", None) actuators = list_configured_summary(request) store = getattr(request.app.state, "actuator_store", None) jobs = ( store.load_job_queue() - if isinstance(store, ActuatorStore) + if include_background and isinstance(store, ActuatorStore) else JobQueueState() ) job_p95_duration_ms, slow_job_count, performance_status = _performance_status(jobs) diff --git a/app/main.py b/app/main.py index 30970b9..798393d 100644 --- a/app/main.py +++ b/app/main.py @@ -105,7 +105,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app = FastAPI( title="SillyHome Next API", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", - version="1.3.0", + version="1.4.0", lifespan=lifespan, ) app.state.settings = load_settings() diff --git a/app/static/index.html b/app/static/index.html index 6b28fc1..ff97ad8 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -283,15 +283,102 @@ let cachedEntities = null; let cachedDiscovery = null; let discoveryLoadPromise = null; let currentSensorWeightGroups = []; +let visibleActuatorLimit = 24; const ACTUATOR_RESULT_LIMIT = 50; const STATUS_TIMEOUT_MS = 2000; const DASHBOARD_TIMEOUT_MS = 3000; +const I18N = { + de: { + safety_stage: { + observe: "Nur beobachten", + suggest: "Vorschläge anzeigen", + shadow: "Prüfmodus ohne Schalten", + partial: "Teilfreigabe", + active: "Aktiv freigegeben", + }, + behavior_mode: { + shadow: "Prüfmodus", + active: "Aktiv", + paused: "Pausiert", + }, + behavior_status: { + collecting: "Sammelt Lernbeispiele", + trained: "Gelernt", + blocked: "Blockiert", + }, + lifecycle_status: { + trained: "gelernt", + pending_history: "sammelt Historie", + pending_assignment: "sucht Kontext", + review_required: "bitte prüfen", + archived: "wartet", + orphaned: "Aktor fehlt", + stale: "Training veraltet", + invalid: "ungültig", + }, + job_status: { + pending: "wartet", + running: "läuft", + completed: "abgeschlossen", + failed: "fehlgeschlagen", + }, + job_kind: { + discovery: "Geräte-Erkennung", + reconciliation: "Abgleich", + training: "Training", + evaluation: "Auswertung", + automation_refresh: "Automation-Prüfung", + }, + severity: { + info: "Hinweis", + warning: "Warnung", + critical: "Kritisch", + }, + anomaly_category: { + missing_context: "fehlender Kontext", + low_samples: "zu wenig Lernbeispiele", + unclear_sources: "unklare Quellen", + stale_training: "veraltetes Training", + low_confidence_prediction: "geringe Sicherheit", + manual_block: "manuelle Sperre", + safety_blockers: "Sicherheitsblocker", + automation_conflict: "Automation-Konflikt", + feedback_error_rate: "Feedback-Fehlerquote", + }, + performance_status: { + ok: "schnell", + slow: "zu langsam", + running: "läuft", + unknown: "noch offen", + }, + connection_status: { + connected: "verbunden", + disconnected: "getrennt", + unavailable: "nicht verfügbar", + error: "Fehler", + }, + }, +}; +const uiLang = "de"; function jumpToSection(target) { if (!target) return; document.querySelector(target)?.scrollIntoView({behavior: "smooth", block: "start"}); } +function translate(group, value, fallback = "") { + if (value == null || value === "") return fallback || "offen"; + return I18N[uiLang]?.[group]?.[value] || fallback || String(value); +} + +function formatDateTime(value) { + if (!value) return "noch offen"; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) + ? String(value) + : parsed.toLocaleString("de-DE"); +} + function uniqueValues(values) { return [...new Set(values.filter(Boolean))]; } @@ -324,12 +411,12 @@ function lifecycleLabel(record) { const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status; if (behaviorStatus === "trained") return "Kontext erkannt"; const labels = { - trained: "lernt", - pending_history: "sammelt Historie", - pending_assignment: "sucht Kontext", - review_required: "geringe Zuordnungssicherheit", - archived: "wartet auf Kontext", - orphaned: "Aktor nicht gefunden", + trained: translate("lifecycle_status", "trained"), + pending_history: translate("lifecycle_status", "pending_history"), + pending_assignment: translate("lifecycle_status", "pending_assignment"), + review_required: translate("lifecycle_status", "review_required"), + archived: translate("lifecycle_status", "archived"), + orphaned: translate("lifecycle_status", "orphaned"), }; return labels[lifecycleStatus] || lifecycleStatus; } @@ -347,7 +434,7 @@ function behaviorLabel(record) { const mode = record.behavior_mode || record.behavior?.mode; const status = record.behavior_status || record.behavior?.status; if (mode === "active") return "aktiv freigegeben"; - if (status === "trained") return "Shadow-Vorhersage"; + if (status === "trained") return "Prüfmodus mit Vorhersage"; if (status === "blocked") return "Lernen blockiert"; return "sammelt Handlungen"; } @@ -440,7 +527,7 @@ async function loadOverview() { if (budget) budget.textContent = "Startdaten laden ..."; document.getElementById("configured-actuators").innerHTML = "
Beobachtete Geräte werden geladen ...
"; try { - const dashboard = await apiWithTimeout("v1/actuators/dashboard", DASHBOARD_TIMEOUT_MS); + const dashboard = await apiWithTimeout("v1/actuators/dashboard/start", DASHBOARD_TIMEOUT_MS); dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt); cachedActuators = dashboard.actuators || []; cachedEntities = []; @@ -462,6 +549,38 @@ async function loadOverview() { document.getElementById("configured-actuators").innerHTML = "Status teilweise verfügbar. Das Dashboard bleibt bedienbar.
" - : `System bereit
Letzte automatische Prüfung: ${escapeHtml(reconciliationValue.last_completed_at || "noch nie")}
`; + : `System bereit
Letzte automatische Prüfung: ${escapeHtml(formatDateTime(reconciliationValue.last_completed_at))}
`; chips.innerHTML = [ `API: ${escapeHtml(healthValue?.status || "offen")}`, - `WebSocket: ${escapeHtml(websocketValue?.status || "offen")}`, + `WebSocket: ${escapeHtml(translate("connection_status", websocketValue?.status, websocketValue?.status || "offen"))}`, `Lernsystem: ${escapeHtml(mlValue?.status || "offen")}`, `Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}`, ].join(""); @@ -499,7 +618,6 @@ function renderDashboardStatus(dashboard) { const status = document.getElementById("status"); const chips = document.getElementById("status-chips"); const stats = document.getElementById("dashboard-stats"); - const jobsBox = document.getElementById("job-queue"); const system = dashboard.system || {}; const cache = dashboard.cache || {}; const actuators = dashboard.actuators || []; @@ -531,8 +649,8 @@ function renderDashboardStatus(dashboard) {Dashboard bereit in ${escapeHtml(loadMs || "offen")} ms. Budget: ${escapeHtml(system.performance_budget_ms || DASHBOARD_TIMEOUT_MS)} ms.
-WebSocket: ${escapeHtml(system.websocket_status || "unbekannt")}
-Letzte automatische Prüfung: ${escapeHtml(system.reconciliation_last_completed_at || "noch nicht abgeschlossen")}
+WebSocket: ${escapeHtml(translate("connection_status", system.websocket_status, system.websocket_status || "unbekannt"))}
+Letzte automatische Prüfung: ${escapeHtml(formatDateTime(system.reconciliation_last_completed_at))}
`; chips.innerHTML = [ `API: ${escapeHtml(system.api_status || "ok")}`, @@ -547,7 +665,7 @@ function renderDashboardStatus(dashboard) { stats.innerHTML = [ `${escapeHtml(job.summary || "Keine Zusammenfassung")}
-Start: ${escapeHtml(job.started_at || "offen")} · Dauer: ${escapeHtml(job.duration_ms == null ? "läuft/offen" : `${job.duration_ms} ms`)}
+Start: ${escapeHtml(formatDateTime(job.started_at))} · Dauer: ${escapeHtml(job.duration_ms == null ? "läuft/offen" : `${job.duration_ms} ms`)}
${job.error ? `${escapeHtml(job.error)}
` : ""} - ${job.status === "failed" ? "Retry: Aktion im Dashboard erneut starten; der nächste Lauf schreibt einen neuen Queue-Eintrag.
" : ""} + ${job.status === "failed" ? "Erneut versuchen: Aktion im Dashboard noch einmal starten; der nächste Lauf schreibt einen neuen Eintrag.
" : ""}Noch keine Aktoren ausgewählt.
"; + `).join("")} + ${moreButton} + ` : "Noch keine Aktoren ausgewählt.
"; } catch (error) { box.textContent = error.message; } @@ -942,7 +1072,7 @@ async function showActuator(actuatorId, evaluationMessage = "") { @@ -1001,7 +1131,7 @@ async function showActuator(actuatorId, evaluationMessage = "") { ${timeProfiles.length ? timeProfiles.map(profile => `${Math.round((profile.confidence || 0) * 100)} % Profilklarheit
${escapeHtml(snapshot.sample_count)} Samples · ${escapeHtml(snapshot.high_confidence_sample_count)} eindeutig · Ø ${Math.round((snapshot.average_confidence || 0) * 100)} %
+${escapeHtml(snapshot.sample_count)} Beispiele · ${escapeHtml(snapshot.high_confidence_sample_count)} eindeutig · Ø ${Math.round((snapshot.average_confidence || 0) * 100)} %
${escapeHtml(snapshot.reason || "Kein Kommentar")}
${snapshot.version_id !== activeModelVersion ? `` : ""} @@ -1026,7 +1156,7 @@ async function showActuator(actuatorId, evaluationMessage = "") { `).join("") : "${escapeHtml(conflict.automation_entity_id)}: ${escapeHtml(conflict.status)} ${escapeHtml(conflict.reason)}${escapeHtml(conflict.automation_entity_id)}: ${escapeHtml(translate("severity", conflict.severity, conflict.status))} ${escapeHtml(conflict.reason)}${escapeHtml(anomaly.detail)}
-Erkannt: ${escapeHtml(anomaly.detected_at || "offen")}
+Erkannt: ${escapeHtml(formatDateTime(anomaly.detected_at))}
Keine offenen Anomalien fuer diesen Aktor.
"} @@ -1139,7 +1269,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {Gelernte Handlungen: ${record.behavior.sample_count}
Davon eindeutig geregelt: ${record.behavior.high_confidence_sample_count}
Davon erkannte HA-Automationen: ${learnedAutomationActions}
-Letztes Training: ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}
+Letztes Training: ${escapeHtml(formatDateTime(record.behavior.last_trained_at))}
Was noch passiert: ${escapeHtml(record.behavior.reason)}
Freigabestatus: ${escapeHtml(record.behavior.activation_reason)}
Status lädt nach ...
"; - document.getElementById("configured-actuators").innerHTML = "