Rework dashboard loading and cache entity metadata
This commit is contained in:
@@ -6,27 +6,40 @@
|
||||
<title>SillyHome Next</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; scroll-behavior:smooth; }
|
||||
body { margin: 0; font-size:16px; }
|
||||
header { padding: 22px; background: linear-gradient(135deg,#142b3a,#193f36); }
|
||||
body { margin: 0; font-size:15px; }
|
||||
header { padding: 18px 20px; background: linear-gradient(135deg,#142b3a,#193f36); }
|
||||
h1,h2,h3 { margin: 0 0 12px; }
|
||||
header p { margin: 5px 0; color: #c3d1dc; }
|
||||
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap: 14px; padding: 14px; }
|
||||
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; }
|
||||
main { display: grid; grid-template-columns: minmax(280px,.85fr) minmax(0,1.35fr); gap: 12px; padding: 12px; max-width:1380px; margin:0 auto; }
|
||||
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 8px; padding: 12px; min-width:0; }
|
||||
section:target { outline:2px solid #66dfa9; outline-offset:2px; }
|
||||
.wide { grid-column: 1 / -1; }
|
||||
.quick-nav { position:sticky; top:0; z-index:10; display:flex; gap:8px; overflow-x:auto; padding:10px 14px; background:rgba(16,21,28,.94); border-bottom:1px solid #2d3a47; backdrop-filter:blur(8px); }
|
||||
.quick-nav a { flex:0 0 auto; padding:10px 12px; border-radius:999px; background:#22303c; border:1px solid #31404d; color:#eaf1f8; text-decoration:none; font-weight:700; font-size:.92rem; }
|
||||
.quick-nav a.primary { background:#23715b; }
|
||||
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
|
||||
.step { background:#111a23; border:1px solid #31404d; border-radius:10px; padding:14px; }
|
||||
details.collapsible > summary,
|
||||
.manual-context > summary,
|
||||
.group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; }
|
||||
details.collapsible > summary { list-style:none; display:flex; justify-content:space-between; gap:10px; }
|
||||
details.collapsible > summary::-webkit-details-marker,
|
||||
.manual-context > summary::-webkit-details-marker,
|
||||
.group-panel > summary::-webkit-details-marker { display:none; }
|
||||
details.collapsible > summary::after,
|
||||
.manual-context > summary::after,
|
||||
.group-panel > summary::after { content:"aufklappen"; color:#9fb0be; font-weight:600; font-size:.86rem; }
|
||||
details[open].collapsible > summary::after,
|
||||
.manual-context[open] > summary::after,
|
||||
.group-panel[open] > summary::after { content:"zuklappen"; }
|
||||
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:10px; margin-top:12px; }
|
||||
.step { background:#111a23; border:1px solid #31404d; border-radius:8px; padding:10px; }
|
||||
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:#23715b; font-weight:700; margin-bottom:8px; }
|
||||
.step p { margin:5px 0; }
|
||||
.ok { color: #66dfa9; }
|
||||
.warn { color: #f3c969; }
|
||||
.bad { color: #ff8f8f; }
|
||||
label { display: block; margin: 9px 0 4px; color: #b9c9d6; }
|
||||
select,input,button { box-sizing: border-box; width: 100%; border-radius: 10px; border: 1px solid #3b4b5b; padding: 12px; background: #101820; color: #fff; font:inherit; }
|
||||
select[multiple] { min-height:190px; }
|
||||
select,input,button { box-sizing: border-box; width: 100%; border-radius: 8px; border: 1px solid #3b4b5b; padding: 10px; background: #101820; color: #fff; font:inherit; min-width:0; }
|
||||
select[multiple] { min-height:150px; }
|
||||
button { min-height:44px; margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
|
||||
button.secondary { background: #37495c; }
|
||||
button.danger { background: #7b3434; }
|
||||
@@ -39,18 +52,18 @@
|
||||
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
|
||||
.chip { padding:4px 8px; border-radius:999px; background:#22303c; border:1px solid #31404d; font-size:.85rem; }
|
||||
.muted { color:#9fb0be; }
|
||||
.card-list { display:grid; gap:12px; }
|
||||
.actuator-card { background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; }
|
||||
.card-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||
.actuator-card { background:#111a23; border:1px solid #31404d; border-radius:8px; padding:10px; min-width:0; }
|
||||
.actuator-card.selected { border-color:#66dfa9; box-shadow:0 0 0 1px rgba(102,223,169,.3); }
|
||||
.card-title { display:flex; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:8px; }
|
||||
.entity-id { overflow-wrap:anywhere; font-weight:800; }
|
||||
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:8px; margin:10px 0; }
|
||||
.metric { background:#18212b; border:1px solid #2d3a47; border-radius:10px; padding:10px; }
|
||||
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; margin:8px 0; }
|
||||
.metric { background:#18212b; border:1px solid #2d3a47; border-radius:8px; padding:8px; min-width:0; }
|
||||
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
|
||||
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
|
||||
.actions button { flex:1 1 180px; margin-top:0; }
|
||||
.detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; }
|
||||
.manual-context { margin-top:14px; background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; }
|
||||
.manual-context { margin-top:12px; background:#111a23; border:1px solid #31404d; border-radius:8px; padding:10px; }
|
||||
.inline-controls { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:8px; margin:8px 0; }
|
||||
.manual-entry { min-height:80px; resize:vertical; }
|
||||
textarea { box-sizing:border-box; width:100%; border-radius:10px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; }
|
||||
@@ -59,11 +72,12 @@
|
||||
@media (max-width: 760px) {
|
||||
header { padding:18px 14px; }
|
||||
header h1 { font-size:1.55rem; }
|
||||
main { display:block; padding:10px; }
|
||||
section { margin-bottom:12px; padding:14px; border-radius:14px; }
|
||||
main { display:block; padding:8px; }
|
||||
section { margin-bottom:10px; padding:10px; border-radius:8px; }
|
||||
.steps { grid-template-columns:1fr; }
|
||||
.grid-two { grid-template-columns:1fr; }
|
||||
.metric-grid { grid-template-columns:1fr 1fr; }
|
||||
.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; }
|
||||
@@ -90,7 +104,8 @@
|
||||
</nav>
|
||||
<main>
|
||||
<section class="wide" id="guide">
|
||||
<h2>So gehst du vor</h2>
|
||||
<details class="collapsible">
|
||||
<summary><span>So gehst du vor</span></summary>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<span class="step-number">1</span>
|
||||
@@ -111,6 +126,7 @@
|
||||
<p><strong>Was passiert?</strong> Erst dann darf SillyHome passende Vorhersagen automatisch ausführen. Die Freigabe kann jederzeit gestoppt werden.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="status-section">
|
||||
@@ -190,6 +206,7 @@ let cachedActuators = null;
|
||||
let cachedEntities = null;
|
||||
let cachedDiscovery = null;
|
||||
const ACTUATOR_RESULT_LIMIT = 50;
|
||||
const STATUS_TIMEOUT_MS = 2500;
|
||||
|
||||
function uniqueValues(values) {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
@@ -208,6 +225,16 @@ async function api(path, options = {}) {
|
||||
return body;
|
||||
}
|
||||
|
||||
async function apiWithTimeout(path, timeoutMs = STATUS_TIMEOUT_MS) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await api(path, {signal: controller.signal});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function lifecycleLabel(record) {
|
||||
const behaviorStatus = record.behavior_status || record.behavior?.status;
|
||||
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
|
||||
@@ -324,48 +351,83 @@ function optionGroups(entities, selectedIds = new Set()) {
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
|
||||
await loadSummaryData();
|
||||
renderConfiguredActuators();
|
||||
void loadStatus();
|
||||
void loadActuatorDiscovery();
|
||||
void loadActuatorSuggestions();
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
const status = document.getElementById("status");
|
||||
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.all([
|
||||
api("health"),
|
||||
api("health/websocket"),
|
||||
api("ml/health"),
|
||||
api("v1/actuators/reconciliation/state"),
|
||||
api("v1/actuators"),
|
||||
const [health, websocket, ml, reconciliation, actuators] = await Promise.allSettled([
|
||||
apiWithTimeout("health"),
|
||||
apiWithTimeout("health/websocket"),
|
||||
apiWithTimeout("ml/health"),
|
||||
apiWithTimeout("v1/actuators/reconciliation/state"),
|
||||
apiWithTimeout("v1/actuators/summary"),
|
||||
]);
|
||||
status.innerHTML = `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliation.last_completed_at || "noch nie")}</p>`;
|
||||
const values = [health, websocket, ml, reconciliation, actuators].map(result =>
|
||||
result.status === "fulfilled" ? result.value : null
|
||||
);
|
||||
const [healthValue, websocketValue, mlValue, reconciliationValue, actuatorValue] = values;
|
||||
const hasError = values.some(value => value === null);
|
||||
status.innerHTML = hasError
|
||||
? "<p class='warn'>Status teilweise verfügbar. Das Dashboard bleibt bedienbar.</p>"
|
||||
: `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliationValue.last_completed_at || "noch nie")}</p>`;
|
||||
chips.innerHTML = [
|
||||
`<span class="chip">API: ${escapeHtml(health.status)}</span>`,
|
||||
`<span class="chip">WebSocket: ${escapeHtml(websocket.status)}</span>`,
|
||||
`<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</span>`,
|
||||
`<span class="chip">Aktoren: ${actuators.length}</span>`,
|
||||
`<span class="chip">Lernbereite Geräte: ${reconciliation.trained_models}</span>`,
|
||||
`<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) {
|
||||
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
||||
chips.innerHTML = "";
|
||||
}
|
||||
await loadDashboardData();
|
||||
renderActuatorDiscovery();
|
||||
renderConfiguredActuators();
|
||||
void loadActuatorSuggestions();
|
||||
}
|
||||
|
||||
async function loadSummaryData() {
|
||||
const actuators = await api("v1/actuators/summary");
|
||||
cachedActuators = actuators;
|
||||
cachedEntities = [];
|
||||
}
|
||||
|
||||
async function loadDashboardData() {
|
||||
const [actuators, entities, discovery] = await Promise.all([
|
||||
await loadSummaryData();
|
||||
await loadDiscoveryData();
|
||||
}
|
||||
|
||||
async function loadDiscoveryData() {
|
||||
const discovery = await api("v1/actuators/discovery");
|
||||
cachedDiscovery = discovery;
|
||||
}
|
||||
|
||||
async function loadFullDashboardData() {
|
||||
const [actuators, discovery] = await Promise.all([
|
||||
api("v1/actuators/summary"),
|
||||
Promise.resolve([]),
|
||||
api("v1/actuators/discovery"),
|
||||
]);
|
||||
cachedActuators = actuators;
|
||||
cachedEntities = entities;
|
||||
cachedEntities = [];
|
||||
cachedDiscovery = discovery;
|
||||
}
|
||||
|
||||
async function loadActuatorDiscovery() {
|
||||
if (!cachedActuators || !cachedDiscovery) {
|
||||
await loadDashboardData();
|
||||
const select = document.getElementById("actuator-select");
|
||||
if (select && !cachedDiscovery) {
|
||||
select.innerHTML = `<option value="">Geräteliste lädt im Hintergrund ...</option>`;
|
||||
}
|
||||
if (!cachedActuators) {
|
||||
await loadSummaryData();
|
||||
}
|
||||
if (!cachedDiscovery) {
|
||||
await loadDiscoveryData();
|
||||
}
|
||||
renderActuatorDiscovery();
|
||||
}
|
||||
@@ -384,7 +446,7 @@ function renderActuatorDiscovery() {
|
||||
renderActuatorSelect();
|
||||
} catch (error) {
|
||||
options.innerHTML = "";
|
||||
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden</option>`;
|
||||
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden: ${escapeHtml(error.message)}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,8 +585,8 @@ async function configureActuator() {
|
||||
}
|
||||
|
||||
async function loadConfiguredActuators() {
|
||||
if (!cachedActuators || !cachedEntities) {
|
||||
await loadDashboardData();
|
||||
if (!cachedActuators) {
|
||||
await loadSummaryData();
|
||||
}
|
||||
renderConfiguredActuators();
|
||||
}
|
||||
@@ -533,25 +595,23 @@ function renderConfiguredActuators() {
|
||||
const box = document.getElementById("configured-actuators");
|
||||
try {
|
||||
const rows = cachedActuators || [];
|
||||
const entities = cachedEntities || [];
|
||||
const entityMap = new Map(entities.map(entity => [entity.entity_id, entity]));
|
||||
const groups = new Map();
|
||||
for (const record of rows) {
|
||||
const entity = entityMap.get(record.actuator_entity_id) || {};
|
||||
const group = entity.area_name || actuatorGroupLabel(record.actuator_entity_id.split(".", 1)[0]);
|
||||
const group = record.area_name || actuatorGroupLabel(record.domain || record.actuator_entity_id.split(".", 1)[0]);
|
||||
if (!groups.has(group)) groups.set(group, []);
|
||||
groups.get(group).push({record, entity});
|
||||
groups.get(group).push({record});
|
||||
}
|
||||
const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right));
|
||||
box.innerHTML = rows.length ? `
|
||||
${groupedRows.map(([group, items]) => `
|
||||
<h3>${escapeHtml(group)}</h3>
|
||||
<details class="group-panel" open>
|
||||
<summary>${escapeHtml(group)} (${items.length})</summary>
|
||||
<div class="card-list">
|
||||
${items.map(({record, entity}) => `
|
||||
${items.map(({record}) => `
|
||||
<article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}">
|
||||
<div class="card-title">
|
||||
<div>
|
||||
<div><strong>${escapeHtml(entity.friendly_name || record.actuator_entity_id)}</strong></div>
|
||||
<div><strong>${escapeHtml(record.friendly_name || record.device_name || record.actuator_entity_id)}</strong></div>
|
||||
<div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div>
|
||||
<div class="${(record.behavior_status || record.behavior?.status) === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div>
|
||||
</div>
|
||||
@@ -574,6 +634,7 @@ function renderConfiguredActuators() {
|
||||
</article>
|
||||
`).join("")}
|
||||
</div>
|
||||
</details>
|
||||
`).join("")}` : "<p>Noch keine Aktoren ausgewählt.</p>";
|
||||
} catch (error) {
|
||||
box.textContent = error.message;
|
||||
@@ -628,8 +689,8 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
selected: manualContextIds,
|
||||
};
|
||||
const manualAssignment = `
|
||||
<div class="manual-context">
|
||||
<h3>Kontext selbst festlegen</h3>
|
||||
<details class="manual-context" open>
|
||||
<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>
|
||||
<select id="manual-numeric-select">
|
||||
@@ -659,7 +720,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
const activationButton = record.behavior.mode === "active"
|
||||
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">SillyHome stoppen und pausierte HA-Automationen fortsetzen</button>
|
||||
|
||||
Reference in New Issue
Block a user