From 47fa8eb0cea7e64b2452fd09ee9f76e364168fe5 Mon Sep 17 00:00:00 2001 From: Otto Date: Wed, 17 Jun 2026 22:34:38 +0200 Subject: [PATCH] Split dashboard views and compact detail loading --- README.md | 2 + addon/config.yaml | 2 +- app/api/v1/actuators.py | 41 +++++++ app/main.py | 2 +- app/static/index.html | 195 +++++++++++++++++++++++++++++---- docs/V1_5_0_OPERATING_GUIDE.md | 47 ++++++++ pyproject.toml | 2 +- tests/api/test_actuators.py | 17 +++ tests/test_dashboard.py | 2 +- 9 files changed, 284 insertions(+), 26 deletions(-) create mode 100644 docs/V1_5_0_OPERATING_GUIDE.md diff --git a/README.md b/README.md index c740fbe..011eb6b 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ nach einer ausdrücklichen Freigabe ausführen. [`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) +- Version 1.5.0 Menü-Dashboard und kompakte Detaildaten: + [`docs/V1_5_0_OPERATING_GUIDE.md`](docs/V1_5_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 b6baaa2..9c99025 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "1.4.0" +version: "1.5.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 231ab8f..556f1a9 100644 --- a/app/api/v1/actuators.py +++ b/app/api/v1/actuators.py @@ -417,6 +417,47 @@ def get_actuator(actuator_entity_id: str, request: Request) -> ActuatorRecord: raise HTTPException(status_code=404, detail=str(exc)) from exc +@router.get("/{actuator_entity_id}/detail", response_model=ActuatorRecord) +def get_actuator_detail(actuator_entity_id: str, request: Request) -> ActuatorRecord: + try: + record = _service(request).get_actuator(actuator_entity_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + selected_ids = { + entity_id + for entity_id in [ + record.assignment.selected_numeric_entity_id, + *record.assignment.selected_context_entity_ids, + ] + if entity_id + } + compact_snapshots = [ + snapshot.model_copy(update={"patterns": []}) + for snapshot in record.behavior.model_snapshots[-5:] + ] + compact_behavior = record.behavior.model_copy( + update={ + "patterns": [], + "model_snapshots": compact_snapshots, + } + ) + return record.model_copy( + update={ + "behavior": compact_behavior, + "numeric_candidates": [ + candidate + for candidate in record.numeric_candidates + if candidate.entity_id in selected_ids + ], + "context_candidates": [ + candidate + for candidate in record.context_candidates + if candidate.entity_id in selected_ids + ], + } + ) + + @router.delete("/{actuator_entity_id}", status_code=204) def delete_actuator(actuator_entity_id: str, request: Request) -> None: _service(request).delete_actuator(actuator_entity_id) diff --git a/app/main.py b/app/main.py index 798393d..baf51e5 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.4.0", + version="1.5.0", lifespan=lifespan, ) app.state.settings = load_settings() diff --git a/app/static/index.html b/app/static/index.html index ff97ad8..52bd379 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -39,8 +39,10 @@ .header-actions label { margin:0; font-size:.82rem; } .status-pill { display:flex; align-items:center; gap:8px; padding:8px 10px; border:1px solid var(--border); border-radius:8px; background:#101722; color:#d9e6f0; white-space:nowrap; } .dot { width:9px; height:9px; border-radius:50%; background:var(--complement); box-shadow:0 0 0 3px rgba(28,199,255,.15); } - main { display:grid; grid-template-columns:minmax(270px,.72fr) minmax(0,1.58fr); grid-template-areas:"control board" "control detail" "status status" "guide guide"; gap:12px; padding:12px; max-width:1480px; margin:0 auto; } + main { display:block; padding:12px; max-width:1480px; margin:0 auto; } section { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:12px; min-width:0; } + .app-view { display:none; } + .app-view.active { display:block; } section:target { outline:2px solid var(--complement); outline-offset:2px; } .control-panel { grid-area:control; align-self:start; position:sticky; top:58px; } .board-panel { grid-area:board; } @@ -118,7 +120,7 @@ .topbar { display:grid; } .header-actions { min-width:0; } .status-pill { width:max-content; max-width:100%; white-space:normal; } - main { display:block; padding:8px; } + main { padding:8px; } .control-panel { position:static; } section { margin-bottom:10px; padding:10px; border-radius:8px; } .steps { grid-template-columns:1fr; } @@ -147,19 +149,20 @@
- + + + + + +
Seite bereit, Status folgt ...
-
+

Steuerung

v1 @@ -210,7 +213,7 @@
-
+

Beobachtete Geräte

@@ -221,13 +224,13 @@
Noch nicht geladen.
-
+

Lernfortschritt und Freigabe

Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.

Öffne bei einem beobachteten Gerät die Details.
-
+

System & Cache

@@ -241,7 +244,30 @@
-
+
+
+
+

Einstellungen

+

Sprache und Standardwerte für die Bedienoberfläche.

+
+
+
+
+ + +

Die API speichert stabile technische Werte. Die Oberfläche übersetzt sie in die gewählte Sprache.

+
+
+

Performance-Standard

+

Startansichten dürfen maximal 3 Sekunden brauchen. Schwere Daten werden nur nach Menüwechsel oder bei Bearbeitung geladen.

+
+
+
+ +
So gehst du vor
@@ -358,14 +384,113 @@ const I18N = { error: "Fehler", }, }, + en: { + safety_stage: { + observe: "Observe only", + suggest: "Show suggestions", + shadow: "Review mode without switching", + partial: "Partial approval", + active: "Active approval", + }, + behavior_mode: { + shadow: "Review mode", + active: "Active", + paused: "Paused", + }, + behavior_status: { + collecting: "Collecting examples", + trained: "Learned", + blocked: "Blocked", + }, + lifecycle_status: { + trained: "learned", + pending_history: "collecting history", + pending_assignment: "finding context", + review_required: "review required", + archived: "waiting", + orphaned: "actuator missing", + stale: "training stale", + invalid: "invalid", + }, + job_status: { + pending: "waiting", + running: "running", + completed: "completed", + failed: "failed", + }, + job_kind: { + discovery: "Discovery", + reconciliation: "Reconciliation", + training: "Training", + evaluation: "Evaluation", + automation_refresh: "Automation check", + }, + severity: { + info: "Info", + warning: "Warning", + critical: "Critical", + }, + anomaly_category: { + missing_context: "missing context", + low_samples: "not enough samples", + unclear_sources: "unclear sources", + stale_training: "stale training", + low_confidence_prediction: "low confidence", + manual_block: "manual block", + safety_blockers: "safety blockers", + automation_conflict: "automation conflict", + feedback_error_rate: "feedback error rate", + }, + performance_status: { + ok: "fast", + slow: "too slow", + running: "running", + unknown: "unknown", + }, + connection_status: { + connected: "connected", + disconnected: "disconnected", + unavailable: "unavailable", + error: "error", + }, + }, }; -const uiLang = "de"; +let uiLang = localStorage.getItem("sillyhome.ui.language") || "de"; function jumpToSection(target) { if (!target) return; document.querySelector(target)?.scrollIntoView({behavior: "smooth", block: "start"}); } +function showView(viewId) { + for (const section of document.querySelectorAll(".app-view")) { + section.classList.toggle("active", section.id === viewId); + } + localStorage.setItem("sillyhome.ui.view", viewId); + if (viewId === "status-section") { + void loadSystemOverview(); + } else if (viewId === "observed") { + void loadOverview(); + } else if (viewId === "settings") { + syncSettingsView(); + } + document.getElementById(viewId)?.scrollIntoView({behavior: "smooth", block: "start"}); +} + +function setLanguage(language) { + uiLang = I18N[language] ? language : "de"; + localStorage.setItem("sillyhome.ui.language", uiLang); + syncSettingsView(); + if (cachedActuators) renderConfiguredActuators(); + void loadSystemOverview(); + if (currentActuatorId) void showActuator(currentActuatorId); +} + +function syncSettingsView() { + const select = document.getElementById("language-select"); + if (select) select.value = uiLang; +} + function translate(group, value, fallback = "") { if (value == null || value === "") return fallback || "offen"; return I18N[uiLang]?.[group]?.[value] || fallback || String(value); @@ -552,6 +677,28 @@ async function loadOverview() { scheduleDashboardExtras(); } +async function loadSystemOverview() { + const startedAt = performance.now(); + const budget = document.getElementById("load-budget"); + if (budget) budget.textContent = "Systemübersicht lädt ..."; + try { + const dashboard = await apiWithTimeout("v1/actuators/dashboard/start", DASHBOARD_TIMEOUT_MS); + dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt); + cachedActuators = dashboard.actuators || cachedActuators; + renderDashboardStatus(dashboard); + if (budget) { + const loadMs = dashboard._load_elapsed_ms; + budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS + ? `Systemübersicht bereit in ${loadMs} ms` + : `Systemübersicht langsam: ${loadMs} ms`; + } + scheduleDashboardExtras(); + } catch (error) { + document.getElementById("status").innerHTML = `

Systemübersicht verzögert: ${escapeHtml(error.message)}

`; + if (budget) budget.textContent = "Systemübersicht verzögert"; + } +} + function scheduleDashboardExtras() { const run = () => { void loadDashboardExtras(); @@ -974,10 +1121,15 @@ function renderConfiguredActuators() { async function showActuator(actuatorId, evaluationMessage = "") { currentActuatorId = actuatorId; + for (const section of document.querySelectorAll(".app-view")) { + section.classList.toggle("active", section.id === "detail"); + } + document.getElementById("section-jump").value = "detail"; + localStorage.setItem("sillyhome.ui.view", "detail"); const box = document.getElementById("actuator-detail"); renderActuatorDetailShell(actuatorId); try { - const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`); + const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/detail`); contextOptions = []; const contexts = [ record.assignment.selected_numeric_entity_id, @@ -1177,9 +1329,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
`; - const learnedAutomationActions = record.behavior.patterns.filter( - pattern => pattern.source === "automation", - ).length; + const learnedAutomationActions = "wird bei Bedarf im Training ausgewertet"; const relatedAutomations = record.behavior.related_automations || []; const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []); const numericOptions = contextOptions.filter(entity => entity.domain === "sensor"); @@ -1268,7 +1418,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {

Betriebsart: ${escapeHtml(behaviorLabel(record))}

Gelernte Handlungen: ${record.behavior.sample_count}

Davon eindeutig geregelt: ${record.behavior.high_confidence_sample_count}

-

Davon erkannte HA-Automationen: ${learnedAutomationActions}

+

Erkannte HA-Automationen: ${escapeHtml(learnedAutomationActions)}

Letztes Training: ${escapeHtml(formatDateTime(record.behavior.last_trained_at))}

Was noch passiert: ${escapeHtml(record.behavior.reason)}

Freigabestatus: ${escapeHtml(record.behavior.activation_reason)}

@@ -1304,7 +1454,6 @@ async function showActuator(actuatorId, evaluationMessage = "") { ${currentContextControls} ${manualAssignment} `; - void hydrateContextOptions(record); document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); } catch (error) { box.textContent = error.message; @@ -1609,10 +1758,12 @@ async function removeActuator(actuatorId) { async function startDashboard() { document.getElementById("status").innerHTML = "

Status lädt nach ...

"; - document.getElementById("configured-actuators").innerHTML = "
Geräte werden geladen.
"; + document.getElementById("configured-actuators").innerHTML = "
Öffne „Lernen“, um Geräte zu laden.
"; document.getElementById("actuator-detail").innerHTML = "
Wähle später ein Gerät aus der Übersicht.
"; + syncSettingsView(); + document.getElementById("section-jump").value = "status-section"; + showView("status-section"); await new Promise(resolve => requestAnimationFrame(resolve)); - await loadOverview(); void loadStatus(); } diff --git a/docs/V1_5_0_OPERATING_GUIDE.md b/docs/V1_5_0_OPERATING_GUIDE.md new file mode 100644 index 0000000..bceaf7b --- /dev/null +++ b/docs/V1_5_0_OPERATING_GUIDE.md @@ -0,0 +1,47 @@ +# SillyHome Next v1.5.0 Operating Guide + +v1.5.0 trennt Dashboard-Ansichten, Datenabruf und Detaildaten weiter auf. Ziel +ist, dass die Seite auf mobiler Datenverbindung schneller nutzbar wird und keine +schweren Lern-, Discovery- oder Detaildaten beim Start lädt. + +## Menüstruktur + +- Startseite / System: Systemübersicht, Cache, Performance, Status. +- Lernen: konfigurierte Aktoren und Lernstand. +- Details: genau ein ausgewählter Aktor. +- Discovery & Einrichtung: Geräteliste, Vorschläge und neue Aktoren. +- Einstellungen: Sprache und Standardverhalten. +- Ablauf: Bedienhinweise. + +Beim Öffnen der Seite wird immer nur die Startseite geladen. Andere Ansichten +laden erst beim Öffnen. + +## Kompakte Detaildaten + +Neuer Endpunkt: + +```text +GET /v1/actuators/{actuator_entity_id}/detail +``` + +Dieser Endpunkt entfernt große Musterlisten und Snapshot-Muster aus dem ersten +Detailabruf. Geladen werden nur die Werte, die für die erste Detailansicht +benötigt werden. Kontextvorschläge bleiben ein separater Abruf und laufen erst +auf Nutzeraktion. + +## Sprache + +Die Sprache kann unter `Einstellungen` gewählt werden. Deutsch ist Standard. +Technische API-Werte bleiben stabil, werden aber im Dashboard über die +Sprachschicht angezeigt. + +## Performance-Regeln + +- Kein Discovery beim Start. +- Keine Aufgabenliste beim Start. +- Keine Kontextvorschläge beim Öffnen eines Aktors. +- Keine Musterlisten im ersten Detailabruf. +- Geräteübersicht rendert begrenzt und lädt weitere Karten per Button nach. + +Die Angabe „bereit in X ms“ beschreibt nur den jeweiligen API-/Ansichtsabruf. +Sie ist nicht gleichzusetzen mit der kompletten HA/Ingress-Navigationszeit. diff --git a/pyproject.toml b/pyproject.toml index c177843..6b599af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "1.4.0" +version = "1.5.0" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" requires-python = ">=3.11" dependencies = [ diff --git a/tests/api/test_actuators.py b/tests/api/test_actuators.py index 51c3686..0fc06cb 100644 --- a/tests/api/test_actuators.py +++ b/tests/api/test_actuators.py @@ -462,6 +462,23 @@ def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> N assert anomalies_response.json() +def test_actuator_detail_uses_compact_payload(tmp_path: Path) -> None: + with TestClient(app) as client: + _install_service(tmp_path) + client.get("/v1/actuators/discovery") + client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"}) + + response = client.get("/v1/actuators/light.abstellkammer/detail") + + assert response.status_code == 200 + payload = response.json() + assert payload["behavior"]["patterns"] == [] + assert all( + snapshot["patterns"] == [] + for snapshot in payload["behavior"]["model_snapshots"] + ) + + def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None: with TestClient(app) as client: _install_service(tmp_path) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 21de7ad..aad8eb9 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -24,7 +24,7 @@ def test_dashboard_is_served_at_root() -> None: assert "SillyHome übernehmen lassen" in response.text assert "Passende Home-Assistant-Automationen" in response.text assert "Pausieren" in response.text - assert "Davon erkannte HA-Automationen" in response.text + assert "Erkannte HA-Automationen" in response.text assert "Aktuelle Situation auswerten" in response.text assert "Kontext selbst festlegen" in response.text assert "Entity-IDs manuell ergänzen" in response.text