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
## 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.

View File

@@ -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

View File

@@ -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()

View File

@@ -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 @@
<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>
</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>
</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>
<section class="control-panel" id="choose">
<div class="panel-title">
@@ -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 = "<p class='muted'>Status wird geprüft ...</p>";
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
? "<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">WebSocket: ${escapeHtml(websocketValue?.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>`,
].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]) => `
<details class="group-panel" open>
<details class="group-panel">
<summary>${escapeHtml(group)} (${items.length})</summary>
<div class="card-list">
${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 = `
<details class="manual-context" open>
<details class="manual-context">
<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>
<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>
<div class="actions">
<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>
</details>
`;
@@ -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 = `
<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) {
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 = "<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>
</body>
</html>

View File

@@ -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 = [