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