From 94530d3ecf08a5190273b3e66dd778315f1952c7 Mon Sep 17 00:00:00 2001 From: Otto Date: Wed, 17 Jun 2026 07:55:28 +0200 Subject: [PATCH] Stream dashboard loading and header menu --- CHANGELOG.md | 8 +++ addon/config.yaml | 2 +- app/main.py | 2 +- app/static/index.html | 111 +++++++++++++++++++++++++++++++++--------- pyproject.toml | 2 +- 5 files changed, 98 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b89dabe..80f52e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.0.3 - 2026-06-17 +- Header-Menue als Pulldown umgesetzt; die separate Navigationsleiste entfaellt. +- Geraetegruppen und manuelle Kontextbereiche sind standardmaessig geschlossen. +- Dashboard startet in Phasen: leere Bedienoberflaeche, dann Status, danach + Geraetedaten. +- Detailansicht oeffnet streamartiger: zuerst Basis-Shell, dann Aktorwerte, + danach Kontextvorschlaege. + ## 1.0.2 - 2026-06-17 - v1.0-Abnahme als `docs/V1_0_ACCEPTANCE.md` dokumentiert: erledigte, teilweise erledigte und offene v1.0.x-Punkte sind getrennt sichtbar. diff --git a/addon/config.yaml b/addon/config.yaml index 4826c77..c87853b 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "1.0.2" +version: "1.0.3" 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/main.py b/app/main.py index 6027191..49b4af6 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.0.2", + version="1.0.3", lifespan=lifespan, ) app.state.settings = load_settings() diff --git a/app/static/index.html b/app/static/index.html index 864bcab..204ddf4 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -34,11 +34,10 @@ .brand-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; } .brand-mark { width:34px; height:34px; border-radius:8px; display:grid; place-items:center; background:var(--accent); color:#201204; font-weight:900; } header p { color:var(--text-soft); max-width:820px; } + .header-actions { display:grid; gap:8px; min-width:230px; } + .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); } - .quick-nav { position:sticky; top:0; z-index:10; display:flex; gap:8px; overflow-x:auto; padding:10px 18px; background:rgba(14,18,24,.96); border-bottom:1px solid var(--border); backdrop-filter:blur(8px); } - .quick-nav a { flex:0 0 auto; min-height:36px; display:grid; place-items:center; padding:8px 12px; border-radius:8px; background:#151c25; border:1px solid var(--border); color:#f3f7fb; text-decoration:none; font-weight:750; font-size:.92rem; } - .quick-nav a.primary { background:var(--accent); border-color:var(--accent); color:#211204; } 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; } section { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:12px; min-width:0; } section:target { outline:2px solid var(--complement); outline-offset:2px; } @@ -107,7 +106,8 @@ header { padding:16px 12px; } header h1 { font-size:1.55rem; } .topbar { display:grid; } - .status-pill { width:max-content; } + .header-actions { min-width:0; } + .status-pill { width:max-content; max-width:100%; white-space:normal; } main { display:block; padding:8px; } .control-panel { position:static; } section { margin-bottom:10px; padding:10px; border-radius:8px; } @@ -117,8 +117,6 @@ .card-list { grid-template-columns:1fr; } .actions { display:grid; grid-template-columns:1fr; } .actions button, button.compact { width:100%; min-width:0; margin-right:0; } - .quick-nav { padding:8px 10px; } - .quick-nav a { padding:10px 11px; } } @media (max-width: 430px) { .metric-grid { grid-template-columns:1fr; } @@ -137,16 +135,19 @@

Arbeitsdashboard für gelernte Home-Assistant-Bedienung: Geräte auswählen, Lernstand prüfen, Freigaben steuern.

Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.

-
Startdaten laden ...
+
+ + +
Seite bereit, Status folgt ...
+
-
@@ -274,6 +275,11 @@ const ACTUATOR_RESULT_LIMIT = 50; const STATUS_TIMEOUT_MS = 2000; const DASHBOARD_TIMEOUT_MS = 4500; +function jumpToSection(target) { + if (!target) return; + document.querySelector(target)?.scrollIntoView({behavior: "smooth", block: "start"}); +} + function uniqueValues(values) { return [...new Set(values.filter(Boolean))]; } @@ -445,17 +451,16 @@ async function loadStatus() { const chips = document.getElementById("status-chips"); status.innerHTML = "

Status wird geprüft ...

"; try { - const [health, websocket, ml, reconciliation, actuators] = await Promise.allSettled([ + const [health, websocket, ml, reconciliation] = await Promise.allSettled([ apiWithTimeout("health"), apiWithTimeout("health/websocket"), apiWithTimeout("ml/health"), apiWithTimeout("v1/actuators/reconciliation/state"), - apiWithTimeout("v1/actuators/summary"), ]); - const values = [health, websocket, ml, reconciliation, actuators].map(result => + const values = [health, websocket, ml, reconciliation].map(result => result.status === "fulfilled" ? result.value : null ); - const [healthValue, websocketValue, mlValue, reconciliationValue, actuatorValue] = values; + const [healthValue, websocketValue, mlValue, reconciliationValue] = values; const hasError = values.some(value => value === null); status.innerHTML = hasError ? "

Status teilweise verfügbar. Das Dashboard bleibt bedienbar.

" @@ -464,7 +469,6 @@ async function loadStatus() { `API: ${escapeHtml(healthValue?.status || "offen")}`, `WebSocket: ${escapeHtml(websocketValue?.status || "offen")}`, `Lernsystem: ${escapeHtml(mlValue?.status || "offen")}`, - `Aktoren: ${Array.isArray(actuatorValue) ? actuatorValue.length : "offen"}`, `Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}`, ].join(""); } catch (error) { @@ -747,7 +751,7 @@ function renderConfiguredActuators() { const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right)); box.innerHTML = rows.length ? ` ${groupedRows.map(([group, items]) => ` -
+
${escapeHtml(group)} (${items.length})
${items.map(({record}) => ` @@ -787,9 +791,10 @@ function renderConfiguredActuators() { async function showActuator(actuatorId, evaluationMessage = "") { currentActuatorId = actuatorId; const box = document.getElementById("actuator-detail"); + renderActuatorDetailShell(actuatorId); try { const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`); - await loadContextOptions(actuatorId); + contextOptions = []; const contexts = [ record.assignment.selected_numeric_entity_id, ...record.assignment.selected_context_entity_ids, @@ -827,7 +832,7 @@ async function showActuator(actuatorId, evaluationMessage = "") { selected: manualContextIds, }; const manualAssignment = ` -
+
Kontext selbst festlegen

Die Vorschläge sind aktorbezogen vorsortiert. Wenn etwas fehlt, trage die Entity-ID unten manuell ein, z. B. PIR, Helligkeit außen, Luftfeuchtigkeit oder Lichtzustände.

@@ -856,7 +861,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
- +
`; @@ -926,12 +931,61 @@ async function showActuator(actuatorId, evaluationMessage = "") { ${currentContextControls} ${manualAssignment} `; + void hydrateContextOptions(record); document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); } catch (error) { box.textContent = error.message; } } +function renderActuatorDetailShell(actuatorId) { + document.getElementById("actuator-detail").innerHTML = ` +
+
+

${escapeHtml(actuatorId)}

+

Basisdaten werden geladen ...

+
+
+
+
Phase 1Aktuelle Einstellung
+
Phase 2Lernstand
+
Phase 3Kontextvorschläge
+
+ `; +} + +async function hydrateContextOptions(record) { + await loadContextOptions(record.actuator_entity_id); + const manualContextSelect = document.getElementById("manual-context-select"); + const numericSelect = document.getElementById("manual-numeric-select"); + const categorySelect = document.getElementById("manual-context-category"); + if (!manualContextSelect || !numericSelect || !categorySelect) return; + const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []); + const numericOptions = contextOptions.filter(entity => entity.domain === "sensor"); + const contextCategories = [...new Set(contextOptions + .filter(entity => entity.entity_id !== record.actuator_entity_id) + .map(categoryForEntity))] + .sort(); + manualContextState = { + options: contextOptions.filter(entity => entity.entity_id !== record.actuator_entity_id), + selected: manualContextIds, + }; + numericSelect.innerHTML = ` + + ${optionGroups(numericOptions, new Set([record.assignment.selected_numeric_entity_id].filter(Boolean)))} + `; + categorySelect.innerHTML = ` + + ${contextCategories.map(category => ``).join("")} + `; + renderManualContextSelect(); +} + +async function hydrateCurrentContextOptions(actuatorId) { + const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`); + await hydrateContextOptions(record); +} + async function saveManualAssignment(actuatorId) { const numericEntityId = document.getElementById("manual-numeric-select").value || null; const selectedContextIds = Array.from( @@ -1096,7 +1150,16 @@ async function removeActuator(actuatorId) { } } -loadOverview(); +async function startDashboard() { + document.getElementById("status").innerHTML = "

Status lädt nach ...

"; + document.getElementById("configured-actuators").innerHTML = "
Geräte werden nach dem Status geladen.
"; + document.getElementById("actuator-detail").innerHTML = "
Wähle später ein Gerät aus der Übersicht.
"; + await new Promise(resolve => requestAnimationFrame(resolve)); + await loadStatus(); + await loadOverview(); +} + +void startDashboard(); diff --git a/pyproject.toml b/pyproject.toml index e30b8a0..f47e00a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "1.0.2" +version = "1.0.3" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" requires-python = ">=3.11" dependencies = [