Fix ingress logging and dashboard cache navigation
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

This commit is contained in:
2026-06-18 00:30:12 +02:00
parent 1d176cce45
commit 6323b93f23
9 changed files with 147 additions and 16 deletions

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ __pycache__/
.env .env
.env.local .env.local
.env.* .env.*
/.actuator_store/
/MagicMock/

View File

@@ -1,5 +1,14 @@
# Changelog # Changelog
## 1.5.4 - 2026-06-18
- Add-on-Start vertraut Ingress-Proxy-Headern nicht mehr blind. Uvicorn loggt
damit den direkten Docker-/Ingress-Peer statt LAN-IPs aus `X-Forwarded-For`.
- Dashboard behält bereits geladene System-, Lern- und Discovery-Daten beim
Wechseln der Ansichten und aktualisiert sie nur im Hintergrund.
- Details sind kein eigener Menüpunkt mehr, sondern gehören zum ausgewählten
Aktor aus der Lernübersicht. Bereits geöffnete Details bleiben sichtbar und
laden nur bei expliziter Aktualisierung neu.
## 1.2.0 - 2026-06-17 ## 1.2.0 - 2026-06-17
- Automatische Sensor-Gewichtungsanpassung aus Nutzerfeedback: - Automatische Sensor-Gewichtungsanpassung aus Nutzerfeedback:
korrektes Feedback staerkt aktuelle Kontextsignale leicht, falsches Feedback korrektes Feedback staerkt aktuelle Kontextsignale leicht, falsches Feedback

View File

@@ -1,5 +1,5 @@
name: SillyHome Next name: SillyHome Next
version: "1.5.3" version: "1.5.4"
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

@@ -21,5 +21,4 @@ if [ -f /data/options.json ]; then
fi fi
mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" "$SILLYHOME_ACTUATOR_STORE" mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" "$SILLYHOME_ACTUATOR_STORE"
exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000 \ exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000
--proxy-headers --forwarded-allow-ips='*'

View File

@@ -117,7 +117,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.5.3", version="1.5.4",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()

View File

@@ -154,7 +154,6 @@
<select id="section-jump" onchange="showView(this.value)"> <select id="section-jump" onchange="showView(this.value)">
<option value="status-section">Startseite / System</option> <option value="status-section">Startseite / System</option>
<option value="observed">Lernen</option> <option value="observed">Lernen</option>
<option value="detail">Details</option>
<option value="choose">Discovery & Einrichtung</option> <option value="choose">Discovery & Einrichtung</option>
<option value="settings">Einstellungen</option> <option value="settings">Einstellungen</option>
<option value="guide">Ablauf</option> <option value="guide">Ablauf</option>
@@ -309,7 +308,12 @@ let manualContextState = {options: [], selected: new Set()};
let cachedActuators = null; let cachedActuators = null;
let cachedEntities = null; let cachedEntities = null;
let cachedDiscovery = null; let cachedDiscovery = null;
let cachedSystemOverview = null;
let cachedDashboardOverview = null;
let cachedDetailHtml = new Map();
let discoveryLoadPromise = null; let discoveryLoadPromise = null;
let overviewLoadPromise = null;
let systemLoadPromise = null;
let currentSensorWeightGroups = []; let currentSensorWeightGroups = [];
let visibleActuatorLimit = 24; let visibleActuatorLimit = 24;
const ACTUATOR_RESULT_LIMIT = 50; const ACTUATOR_RESULT_LIMIT = 50;
@@ -465,14 +469,29 @@ function jumpToSection(target) {
} }
function showView(viewId) { function showView(viewId) {
if (viewId === "detail" && !currentActuatorId) {
viewId = "observed";
}
for (const section of document.querySelectorAll(".app-view")) { for (const section of document.querySelectorAll(".app-view")) {
section.classList.toggle("active", section.id === viewId); section.classList.toggle("active", section.id === viewId);
} }
localStorage.setItem("sillyhome.ui.view", viewId); localStorage.setItem("sillyhome.ui.view", viewId);
if (viewId === "status-section") { if (viewId === "status-section") {
void loadSystemOverview(); if (cachedSystemOverview) {
renderDashboardStatus(cachedSystemOverview);
refreshSystemOverviewInBackground();
} else {
void loadSystemOverview();
}
} else if (viewId === "observed") { } else if (viewId === "observed") {
void loadOverview(); if (cachedActuators) {
renderConfiguredActuators();
refreshOverviewInBackground();
} else {
void loadOverview();
}
} else if (viewId === "choose") {
if (cachedDiscovery) renderActuatorDiscovery();
} else if (viewId === "settings") { } else if (viewId === "settings") {
syncSettingsView(); syncSettingsView();
} }
@@ -484,8 +503,10 @@ function setLanguage(language) {
localStorage.setItem("sillyhome.ui.language", uiLang); localStorage.setItem("sillyhome.ui.language", uiLang);
syncSettingsView(); syncSettingsView();
if (cachedActuators) renderConfiguredActuators(); if (cachedActuators) renderConfiguredActuators();
void loadSystemOverview(); if (cachedSystemOverview) renderDashboardStatus(cachedSystemOverview);
if (currentActuatorId) void showActuator(currentActuatorId); if (currentActuatorId && cachedDetailHtml.has(currentActuatorId)) {
document.getElementById("actuator-detail").innerHTML = cachedDetailHtml.get(currentActuatorId);
}
} }
function syncSettingsView() { function syncSettingsView() {
@@ -514,6 +535,9 @@ function invalidateDashboardCache() {
cachedActuators = null; cachedActuators = null;
cachedEntities = null; cachedEntities = null;
cachedDiscovery = null; cachedDiscovery = null;
cachedDashboardOverview = null;
cachedSystemOverview = null;
cachedDetailHtml.clear();
} }
async function api(path, options = {}) { async function api(path, options = {}) {
@@ -654,13 +678,24 @@ function optionGroups(entities, selectedIds = new Set()) {
} }
async function loadOverview() { async function loadOverview() {
if (overviewLoadPromise) return overviewLoadPromise;
overviewLoadPromise = doLoadOverview().finally(() => {
overviewLoadPromise = null;
});
return overviewLoadPromise;
}
async function doLoadOverview() {
const startedAt = performance.now(); const startedAt = performance.now();
const budget = document.getElementById("load-budget"); const budget = document.getElementById("load-budget");
if (budget) budget.textContent = "Startdaten laden ..."; if (budget) budget.textContent = "Startdaten laden ...";
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>"; if (!cachedActuators) {
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
}
try { try {
const dashboard = await api("v1/actuators/dashboard/start"); const dashboard = await api("v1/actuators/dashboard/start");
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt); dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
cachedDashboardOverview = dashboard;
cachedActuators = dashboard.actuators || []; cachedActuators = dashboard.actuators || [];
cachedEntities = []; cachedEntities = [];
renderDashboardStatus(dashboard); renderDashboardStatus(dashboard);
@@ -685,12 +720,21 @@ async function loadOverview() {
} }
async function loadSystemOverview() { async function loadSystemOverview() {
if (systemLoadPromise) return systemLoadPromise;
systemLoadPromise = doLoadSystemOverview().finally(() => {
systemLoadPromise = null;
});
return systemLoadPromise;
}
async function doLoadSystemOverview() {
const startedAt = performance.now(); const startedAt = performance.now();
const budget = document.getElementById("load-budget"); const budget = document.getElementById("load-budget");
if (budget) budget.textContent = "Systemübersicht lädt ..."; if (budget) budget.textContent = "Systemübersicht lädt ...";
try { try {
const dashboard = await api("v1/actuators/dashboard/system"); const dashboard = await api("v1/actuators/dashboard/system");
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt); dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
cachedSystemOverview = dashboard;
cachedActuators = dashboard.actuators || cachedActuators; cachedActuators = dashboard.actuators || cachedActuators;
renderDashboardStatus(dashboard); renderDashboardStatus(dashboard);
if (budget) { if (budget) {
@@ -706,6 +750,22 @@ async function loadSystemOverview() {
} }
} }
function refreshOverviewInBackground() {
if (!overviewLoadPromise) {
overviewLoadPromise = doLoadOverview().finally(() => {
overviewLoadPromise = null;
});
}
}
function refreshSystemOverviewInBackground() {
if (!systemLoadPromise) {
systemLoadPromise = doLoadSystemOverview().finally(() => {
systemLoadPromise = null;
});
}
}
function scheduleDashboardExtras() { function scheduleDashboardExtras() {
const run = () => { const run = () => {
void loadDashboardExtras(); void loadDashboardExtras();
@@ -1131,10 +1191,20 @@ async function showActuator(actuatorId, evaluationMessage = "") {
for (const section of document.querySelectorAll(".app-view")) { for (const section of document.querySelectorAll(".app-view")) {
section.classList.toggle("active", section.id === "detail"); section.classList.toggle("active", section.id === "detail");
} }
document.getElementById("section-jump").value = "detail"; document.getElementById("section-jump").value = "observed";
localStorage.setItem("sillyhome.ui.view", "detail"); localStorage.setItem("sillyhome.ui.view", "detail");
const box = document.getElementById("actuator-detail"); const box = document.getElementById("actuator-detail");
renderActuatorDetailShell(actuatorId); if (!evaluationMessage && cachedDetailHtml.has(actuatorId)) {
box.innerHTML = cachedDetailHtml.get(actuatorId);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
renderConfiguredActuators();
return;
}
if (cachedDetailHtml.has(actuatorId)) {
box.innerHTML = cachedDetailHtml.get(actuatorId);
} else {
renderActuatorDetailShell(actuatorId);
}
try { try {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/detail`); const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/detail`);
contextOptions = []; contextOptions = [];
@@ -1403,13 +1473,13 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<button class="secondary compact" onclick="setRelatedAutomation('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(automation.entity_id)}', ${automation.enabled ? "false" : "true"})">${automation.enabled ? "Pausieren" : "Fortsetzen"}</button> <button class="secondary compact" onclick="setRelatedAutomation('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(automation.entity_id)}', ${automation.enabled ? "false" : "true"})">${automation.enabled ? "Pausieren" : "Fortsetzen"}</button>
</li>`).join("")}</ul>` </li>`).join("")}</ul>`
: "<p class='muted'>Keine eindeutig passende HA-Automation gefunden.</p>"; : "<p class='muted'>Keine eindeutig passende HA-Automation gefunden.</p>";
box.innerHTML = ` const detailHtml = `
<div class="detail-header"> <div class="detail-header">
<div> <div>
<h3>${escapeHtml(record.actuator_entity_id)}</h3> <h3>${escapeHtml(record.actuator_entity_id)}</h3>
<p class="muted">Alle wichtigen Aktionen für dieses Gerät.</p> <p class="muted">Alle wichtigen Aktionen für dieses Gerät.</p>
</div> </div>
<button class="secondary compact" onclick="loadOverview()">Alles aktualisieren</button> <button class="secondary compact" onclick="showActuator('${escapeHtml(record.actuator_entity_id)}', 'Aktualisiert.')">Details aktualisieren</button>
</div> </div>
<div class="grid-two"> <div class="grid-two">
<div> <div>
@@ -1461,7 +1531,10 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${currentContextControls} ${currentContextControls}
${manualAssignment} ${manualAssignment}
`; `;
box.innerHTML = detailHtml;
cachedDetailHtml.set(actuatorId, detailHtml);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
renderConfiguredActuators();
} catch (error) { } catch (error) {
box.textContent = error.message; box.textContent = error.message;
} }
@@ -1768,8 +1841,11 @@ async function startDashboard() {
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Öffne „Lernen“, um Geräte zu laden.</div>"; document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Öffne „Lernen“, um Geräte zu laden.</div>";
document.getElementById("actuator-detail").innerHTML = "<div class='empty-state'>Wähle später ein Gerät aus der Übersicht.</div>"; document.getElementById("actuator-detail").innerHTML = "<div class='empty-state'>Wähle später ein Gerät aus der Übersicht.</div>";
syncSettingsView(); syncSettingsView();
document.getElementById("section-jump").value = "status-section"; const initialView = localStorage.getItem("sillyhome.ui.view") === "detail"
showView("status-section"); ? "observed"
: (localStorage.getItem("sillyhome.ui.view") || "status-section");
document.getElementById("section-jump").value = initialView;
showView(initialView);
await new Promise(resolve => requestAnimationFrame(resolve)); await new Promise(resolve => requestAnimationFrame(resolve));
setTimeout(() => void loadStatus(), 100); setTimeout(() => void loadStatus(), 100);
} }

View File

@@ -0,0 +1,35 @@
# SillyHome Next v1.5.4 Operating Guide
Diese Version korrigiert Ingress-Logging und Dashboard-Navigation.
## Ingress-/Access-Logs
- Das Add-on startet Uvicorn ohne `--proxy-headers` und ohne
`--forwarded-allow-ips='*'`.
- Vorher konnte Uvicorn LAN-Adressen aus `X-Forwarded-For` anzeigen. Diese
Adresse war dann der urspruengliche Client oder Home-Assistant-Proxy, nicht
der direkte Container-Peer.
- Nach dem Update sollten Access-Logs den direkten Docker-/Ingress-Peer zeigen.
`GET ... HTTP/1.1` bleibt normal und ist kein Hinweis auf fehlendes Streaming.
## Dashboard-Verhalten
- Die Startseite nutzt weiter `/v1/actuators/dashboard/system`.
- Die Lernuebersicht nutzt weiter `/v1/actuators/dashboard/start`.
- Bereits geladene System-, Lern- und Discovery-Daten bleiben beim Wechseln der
Ansichten im Browser erhalten und werden nur im Hintergrund aufgefrischt.
- Details sind kein eigener Menuepunkt mehr. Sie werden nur ueber ein
ausgewaehltes beobachtetes Geraet geoeffnet.
- Ein bereits geoeffneter Aktor zeigt seine Detaildaten sofort aus dem
Browser-Cache. Neue Detaildaten werden erst ueber `Details aktualisieren`
oder nach einer Speichern-/Schaltaktion geladen.
## Pruefung
1. Add-on aktualisieren und neu starten.
2. Ingress hart neu laden.
3. Zwischen Startseite, Lernen und Discovery wechseln.
4. Erwartung: Bereits geladene Inhalte bleiben sichtbar; keine volle
Neuladung bei jedem Ansichtswechsel.
5. Details eines Aktors oeffnen, wegwechseln und wieder Details oeffnen.
Erwartung: Die zuletzt geladene Detailansicht steht sofort wieder da.

View File

@@ -16,3 +16,10 @@ def test_addon_version_invalidates_application_build_layer() -> None:
config_copy = dockerfile.index("COPY config.yaml /tmp/addon-config.yaml") config_copy = dockerfile.index("COPY config.yaml /tmp/addon-config.yaml")
repository_clone = dockerfile.index("git clone --depth 1 --branch main") repository_clone = dockerfile.index("git clone --depth 1 --branch main")
assert config_copy < repository_clone assert config_copy < repository_clone
def test_addon_does_not_trust_forwarded_lan_ips() -> None:
run_script = Path("addon/run.sh").read_text(encoding="utf-8")
assert "--proxy-headers" not in run_script
assert "--forwarded-allow-ips" not in run_script

View File

@@ -17,6 +17,7 @@ def test_dashboard_is_served_at_root() -> None:
assert "Liste durchsuchen" in response.text assert "Liste durchsuchen" in response.text
assert "Geräteliste bei Bedarf laden" in response.text assert "Geräteliste bei Bedarf laden" in response.text
assert "Vorschläge können Home Assistant stark abfragen" in response.text assert "Vorschläge können Home Assistant stark abfragen" in response.text
assert '<option value="detail">Details</option>' not in response.text
assert "Wie gewohnt bedienen" in response.text assert "Wie gewohnt bedienen" in response.text
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text
@@ -42,5 +43,7 @@ def test_dashboard_is_served_at_root() -> None:
assert 'api("v1/entities")' not in response.text assert 'api("v1/entities")' not in response.text
assert 'details class="collapsible"' in response.text assert 'details class="collapsible"' in response.text
assert 'class="group-panel"' in response.text assert 'class="group-panel"' in response.text
assert "cachedDetailHtml" in response.text
assert "refreshOverviewInBackground" in response.text
assert "Automation-Entwurf" not in response.text assert "Automation-Entwurf" not in response.text
assert "Manuelle Overrides" not in response.text assert "Manuelle Overrides" not in response.text