Compare commits

...

1 Commits

Author SHA1 Message Date
94530d3ecf Stream dashboard loading and header menu
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 07:55:28 +02:00
5 changed files with 98 additions and 27 deletions

View File

@@ -1,5 +1,13 @@
# Changelog # 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 ## 1.0.2 - 2026-06-17
- v1.0-Abnahme als `docs/V1_0_ACCEPTANCE.md` dokumentiert: erledigte, - v1.0-Abnahme als `docs/V1_0_ACCEPTANCE.md` dokumentiert: erledigte,
teilweise erledigte und offene v1.0.x-Punkte sind getrennt sichtbar. teilweise erledigte und offene v1.0.x-Punkte sind getrennt sichtbar.

View File

@@ -1,5 +1,5 @@
name: SillyHome Next name: SillyHome Next
version: "1.0.2" version: "1.0.3"
slug: sillyhome_next slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next url: http://192.168.6.31:3000/pino/sillyhome-next

View File

@@ -105,7 +105,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="SillyHome Next API", title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="1.0.2", version="1.0.3",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()

View File

@@ -34,11 +34,10 @@
.brand-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; } .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; } .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 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; } .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); } .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; } 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 { 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; } section:target { outline:2px solid var(--complement); outline-offset:2px; }
@@ -107,7 +106,8 @@
header { padding:16px 12px; } header { padding:16px 12px; }
header h1 { font-size:1.55rem; } header h1 { font-size:1.55rem; }
.topbar { display:grid; } .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; } main { display:block; padding:8px; }
.control-panel { position:static; } .control-panel { position:static; }
section { margin-bottom:10px; padding:10px; border-radius:8px; } section { margin-bottom:10px; padding:10px; border-radius:8px; }
@@ -117,8 +117,6 @@
.card-list { grid-template-columns:1fr; } .card-list { grid-template-columns:1fr; }
.actions { display:grid; grid-template-columns:1fr; } .actions { display:grid; grid-template-columns:1fr; }
.actions button, button.compact { width:100%; min-width:0; margin-right:0; } .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) { @media (max-width: 430px) {
.metric-grid { grid-template-columns:1fr; } .metric-grid { grid-template-columns:1fr; }
@@ -137,16 +135,19 @@
<p>Arbeitsdashboard für gelernte Home-Assistant-Bedienung: Geräte auswählen, Lernstand prüfen, Freigaben steuern.</p> <p>Arbeitsdashboard für gelernte Home-Assistant-Bedienung: Geräte auswählen, Lernstand prüfen, Freigaben steuern.</p>
<p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p> <p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
</div> </div>
<div class="status-pill"><span class="dot"></span><span id="load-budget">Startdaten laden ...</span></div> <div class="header-actions">
<label for="section-jump">Menü</label>
<select id="section-jump" onchange="jumpToSection(this.value)">
<option value="#choose">Steuerung</option>
<option value="#observed">Geräte</option>
<option value="#detail">Freigabe</option>
<option value="#status-section">System</option>
<option value="#guide">Ablauf</option>
</select>
<div class="status-pill"><span class="dot"></span><span id="load-budget">Seite bereit, Status folgt ...</span></div>
</div>
</div> </div>
</header> </header>
<nav class="quick-nav" aria-label="Schnellnavigation">
<a class="primary" href="#choose">Steuerung</a>
<a href="#observed">Geräte</a>
<a href="#detail">Freigabe</a>
<a href="#status-section">System</a>
<a href="#guide">Ablauf</a>
</nav>
<main> <main>
<section class="control-panel" id="choose"> <section class="control-panel" id="choose">
<div class="panel-title"> <div class="panel-title">
@@ -274,6 +275,11 @@ const ACTUATOR_RESULT_LIMIT = 50;
const STATUS_TIMEOUT_MS = 2000; const STATUS_TIMEOUT_MS = 2000;
const DASHBOARD_TIMEOUT_MS = 4500; const DASHBOARD_TIMEOUT_MS = 4500;
function jumpToSection(target) {
if (!target) return;
document.querySelector(target)?.scrollIntoView({behavior: "smooth", block: "start"});
}
function uniqueValues(values) { function uniqueValues(values) {
return [...new Set(values.filter(Boolean))]; return [...new Set(values.filter(Boolean))];
} }
@@ -445,17 +451,16 @@ async function loadStatus() {
const chips = document.getElementById("status-chips"); const chips = document.getElementById("status-chips");
status.innerHTML = "<p class='muted'>Status wird geprüft ...</p>"; status.innerHTML = "<p class='muted'>Status wird geprüft ...</p>";
try { try {
const [health, websocket, ml, reconciliation, actuators] = await Promise.allSettled([ const [health, websocket, ml, reconciliation] = await Promise.allSettled([
apiWithTimeout("health"), apiWithTimeout("health"),
apiWithTimeout("health/websocket"), apiWithTimeout("health/websocket"),
apiWithTimeout("ml/health"), apiWithTimeout("ml/health"),
apiWithTimeout("v1/actuators/reconciliation/state"), 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 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); const hasError = values.some(value => value === null);
status.innerHTML = hasError status.innerHTML = hasError
? "<p class='warn'>Status teilweise verfügbar. Das Dashboard bleibt bedienbar.</p>" ? "<p class='warn'>Status teilweise verfügbar. Das Dashboard bleibt bedienbar.</p>"
@@ -464,7 +469,6 @@ async function loadStatus() {
`<span class="chip">API: ${escapeHtml(healthValue?.status || "offen")}</span>`, `<span class="chip">API: ${escapeHtml(healthValue?.status || "offen")}</span>`,
`<span class="chip">WebSocket: ${escapeHtml(websocketValue?.status || "offen")}</span>`, `<span class="chip">WebSocket: ${escapeHtml(websocketValue?.status || "offen")}</span>`,
`<span class="chip">Lernsystem: ${escapeHtml(mlValue?.status || "offen")}</span>`, `<span class="chip">Lernsystem: ${escapeHtml(mlValue?.status || "offen")}</span>`,
`<span class="chip">Aktoren: ${Array.isArray(actuatorValue) ? actuatorValue.length : "offen"}</span>`,
`<span class="chip">Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}</span>`, `<span class="chip">Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}</span>`,
].join(""); ].join("");
} catch (error) { } catch (error) {
@@ -747,7 +751,7 @@ function renderConfiguredActuators() {
const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right)); const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right));
box.innerHTML = rows.length ? ` box.innerHTML = rows.length ? `
${groupedRows.map(([group, items]) => ` ${groupedRows.map(([group, items]) => `
<details class="group-panel" open> <details class="group-panel">
<summary>${escapeHtml(group)} (${items.length})</summary> <summary>${escapeHtml(group)} (${items.length})</summary>
<div class="card-list"> <div class="card-list">
${items.map(({record}) => ` ${items.map(({record}) => `
@@ -787,9 +791,10 @@ function renderConfiguredActuators() {
async function showActuator(actuatorId, evaluationMessage = "") { async function showActuator(actuatorId, evaluationMessage = "") {
currentActuatorId = actuatorId; currentActuatorId = actuatorId;
const box = document.getElementById("actuator-detail"); const box = document.getElementById("actuator-detail");
renderActuatorDetailShell(actuatorId);
try { try {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`); const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
await loadContextOptions(actuatorId); contextOptions = [];
const contexts = [ const contexts = [
record.assignment.selected_numeric_entity_id, record.assignment.selected_numeric_entity_id,
...record.assignment.selected_context_entity_ids, ...record.assignment.selected_context_entity_ids,
@@ -827,7 +832,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
selected: manualContextIds, selected: manualContextIds,
}; };
const manualAssignment = ` const manualAssignment = `
<details class="manual-context" open> <details class="manual-context">
<summary>Kontext selbst festlegen</summary> <summary>Kontext selbst festlegen</summary>
<p class="muted">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.</p> <p class="muted">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.</p>
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label> <label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
@@ -856,7 +861,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<textarea id="manual-context-freeform" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs, getrennt durch Komma, Leerzeichen oder neue Zeilen">${escapeHtml(manualOnlyIds.join("\n"))}</textarea> <textarea id="manual-context-freeform" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs, getrennt durch Komma, Leerzeichen oder neue Zeilen">${escapeHtml(manualOnlyIds.join("\n"))}</textarea>
<div class="actions"> <div class="actions">
<button onclick="saveManualAssignment('${escapeHtml(record.actuator_entity_id)}')">Diese Kontext-Auswahl speichern</button> <button onclick="saveManualAssignment('${escapeHtml(record.actuator_entity_id)}')">Diese Kontext-Auswahl speichern</button>
<button class="secondary" onclick="loadContextOptions('${escapeHtml(record.actuator_entity_id)}').then(() => showActuator('${escapeHtml(record.actuator_entity_id)}'))">Vorschläge neu laden</button> <button class="secondary" onclick="hydrateCurrentContextOptions('${escapeHtml(record.actuator_entity_id)}')">Vorschläge neu laden</button>
</div> </div>
</details> </details>
`; `;
@@ -926,12 +931,61 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${currentContextControls} ${currentContextControls}
${manualAssignment} ${manualAssignment}
`; `;
void hydrateContextOptions(record);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
} catch (error) { } catch (error) {
box.textContent = error.message; box.textContent = error.message;
} }
} }
function renderActuatorDetailShell(actuatorId) {
document.getElementById("actuator-detail").innerHTML = `
<div class="detail-header">
<div>
<h3>${escapeHtml(actuatorId)}</h3>
<p class="muted">Basisdaten werden geladen ...</p>
</div>
</div>
<div class="metric-grid">
<div class="metric"><strong>Phase 1</strong>Aktuelle Einstellung</div>
<div class="metric"><strong>Phase 2</strong>Lernstand</div>
<div class="metric"><strong>Phase 3</strong>Kontextvorschläge</div>
</div>
`;
}
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 = `
<option value="">Keinen numerischen Hauptsensor verwenden</option>
${optionGroups(numericOptions, new Set([record.assignment.selected_numeric_entity_id].filter(Boolean)))}
`;
categorySelect.innerHTML = `
<option value="">Alle relevanten Vorschläge</option>
${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}
`;
renderManualContextSelect();
}
async function hydrateCurrentContextOptions(actuatorId) {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
await hydrateContextOptions(record);
}
async function saveManualAssignment(actuatorId) { async function saveManualAssignment(actuatorId) {
const numericEntityId = document.getElementById("manual-numeric-select").value || null; const numericEntityId = document.getElementById("manual-numeric-select").value || null;
const selectedContextIds = Array.from( const selectedContextIds = Array.from(
@@ -1096,7 +1150,16 @@ async function removeActuator(actuatorId) {
} }
} }
loadOverview(); async function startDashboard() {
document.getElementById("status").innerHTML = "<p class='muted'>Status lädt nach ...</p>";
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Geräte werden nach dem Status geladen.</div>";
document.getElementById("actuator-detail").innerHTML = "<div class='empty-state'>Wähle später ein Gerät aus der Übersicht.</div>";
await new Promise(resolve => requestAnimationFrame(resolve));
await loadStatus();
await loadOverview();
}
void startDashboard();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "1.0.2" version = "1.0.3"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [