1929 lines
87 KiB
HTML
1929 lines
87 KiB
HTML
<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>SillyHome Next</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
|
background:#0e1218;
|
|
color:#f4f7fb;
|
|
scroll-behavior:smooth;
|
|
--accent:#ff8a1c;
|
|
--accent-strong:#ffb45b;
|
|
--complement:#1cc7ff;
|
|
--complement-soft:#123447;
|
|
--panel:#171d25;
|
|
--panel-soft:#202833;
|
|
--panel-quiet:#111720;
|
|
--border:#314050;
|
|
--text-soft:#aebdcc;
|
|
--ok:#53e0a5;
|
|
--warn:#f3c969;
|
|
--bad:#8fb8ff;
|
|
}
|
|
* { box-sizing:border-box; }
|
|
html, body { max-width:100%; overflow-x:hidden; }
|
|
body { margin:0; font-size:15px; background:var(--panel-quiet); }
|
|
h1,h2,h3 { margin:0 0 10px; letter-spacing:0; }
|
|
p { margin:6px 0; }
|
|
header { padding:18px 18px 14px; border-bottom:1px solid var(--border); background:#121821; }
|
|
.topbar { max-width:1480px; margin:0 auto; display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }
|
|
.brand { display:grid; gap:5px; min-width:0; }
|
|
.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); }
|
|
main { display:block; 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; }
|
|
.app-view { display:none; }
|
|
.app-view.active { display:block; }
|
|
section:target { outline:2px solid var(--complement); outline-offset:2px; }
|
|
.control-panel { grid-area:control; align-self:start; position:sticky; top:58px; }
|
|
.board-panel { grid-area:board; }
|
|
.detail-panel { grid-area:detail; }
|
|
.status-panel { grid-area:status; }
|
|
.guide-panel { grid-area:guide; }
|
|
.panel-title { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; }
|
|
.toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin:10px 0; }
|
|
.toolbar button { margin-top:0; }
|
|
.detail-tools { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:8px; margin:10px 0; align-items:end; }
|
|
.detail-tools button { margin-top:0; }
|
|
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; }
|
|
.manual-context > summary,
|
|
.group-panel > summary { display:flex; justify-content:space-between; gap:10px; align-items:center; }
|
|
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:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; }
|
|
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:8px; background:var(--complement); color:#03151d; font-weight:900; margin-bottom:8px; }
|
|
.step p { margin:5px 0; }
|
|
.ok { color: #73e0a9; }
|
|
.warn { color: #f3c969; }
|
|
.bad { color: var(--bad); }
|
|
label { display:block; margin:9px 0 4px; color:#c3d2df; font-weight:700; }
|
|
select,input,button { width:100%; border-radius:8px; border:1px solid #3b4b5b; padding:10px; background:#111821; color:#fff; font:inherit; min-width:0; }
|
|
input[type="checkbox"] { width:auto; min-width:0; vertical-align:middle; margin-right:8px; }
|
|
select[multiple] { min-height:150px; }
|
|
button { min-height:42px; margin-top:10px; background:var(--accent); color:#211204; border:0; font-weight:850; cursor:pointer; }
|
|
button.secondary { background:var(--complement-soft); color:#dff6ff; border:1px solid #22607c; }
|
|
button.danger { background:#2a3441; color:#f2f6fb; border:1px solid #536273; }
|
|
button.compact { width:auto; min-width:112px; margin-right:8px; padding:8px 10px; min-height:36px; }
|
|
table { width: 100%; border-collapse: collapse; table-layout:fixed; font-size: .92rem; }
|
|
td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; overflow-wrap:anywhere; word-break:break-word; }
|
|
ul { margin: 8px 0; padding-left: 18px; }
|
|
.notice { border-left:4px solid var(--complement); padding-left:10px; }
|
|
.grid-two { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:12px; }
|
|
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
|
|
.chip { padding:4px 8px; border-radius:8px; background:#222b36; border:1px solid var(--border); font-size:.85rem; max-width:100%; overflow-wrap:anywhere; word-break:break-word; }
|
|
.muted { color:var(--text-soft); }
|
|
.card-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); gap:10px; }
|
|
.actuator-card { background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; min-width:0; }
|
|
.actuator-card.selected { border-color:var(--complement); box-shadow:0 0 0 1px rgba(28,199,255,.35); }
|
|
.card-title { display:flex; flex-wrap:wrap; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:8px; min-width:0; }
|
|
.card-title > div { min-width:0; flex:1 1 160px; overflow-wrap:anywhere; word-break:break-word; }
|
|
.card-title .chip { flex:0 1 auto; white-space:normal; text-align:center; }
|
|
.actuator-card strong { overflow-wrap:anywhere; word-break:break-word; }
|
|
.entity-id { overflow-wrap:anywhere; word-break:break-word; font-weight:800; }
|
|
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; margin:8px 0; }
|
|
.metric { background:var(--panel-soft); border:1px solid var(--border); border-radius:8px; padding:8px; min-width:0; overflow-wrap:anywhere; word-break:break-word; }
|
|
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
|
|
.decision-list { display:grid; gap:8px; margin:10px 0; }
|
|
.decision-row { background:#121922; border:1px solid var(--border); border-radius:8px; padding:9px; min-width:0; overflow-wrap:anywhere; }
|
|
.decision-row.slow,
|
|
.decision-row.critical { border-color:var(--warn); box-shadow:0 0 0 1px rgba(243,201,105,.25); }
|
|
.decision-row header { padding:0; border:0; background:transparent; display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
|
.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:12px; background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; min-width:0; overflow-wrap:anywhere; word-break:break-word; }
|
|
.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 { width:100%; border-radius:8px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; }
|
|
optgroup { color:#cfe0ec; background:#101820; }
|
|
code { color:#cfe0ec; overflow-wrap:anywhere; }
|
|
.empty-state { min-height:140px; display:grid; place-items:center; text-align:center; border:1px dashed var(--border); border-radius:8px; background:#111821; color:var(--text-soft); padding:16px; }
|
|
@media (max-width: 760px) {
|
|
header { padding:16px 12px; }
|
|
header h1 { font-size:1.55rem; }
|
|
.topbar { display:grid; }
|
|
.header-actions { min-width:0; }
|
|
.status-pill { width:max-content; max-width:100%; white-space:normal; }
|
|
main { padding:8px; }
|
|
.control-panel { position:static; }
|
|
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; }
|
|
}
|
|
@media (max-width: 430px) {
|
|
.metric-grid { grid-template-columns:1fr; }
|
|
body { font-size:15px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div class="topbar">
|
|
<div class="brand">
|
|
<div class="brand-row">
|
|
<span class="brand-mark">SH</span>
|
|
<h1>SillyHome</h1>
|
|
</div>
|
|
<p>Geräte, Lernen, Freigaben und Systemzustand.</p>
|
|
</div>
|
|
<div class="header-actions">
|
|
<label for="section-jump">Menü</label>
|
|
<select id="section-jump" onchange="showView(this.value)">
|
|
<option value="status-section">Startseite / System</option>
|
|
<option value="observed">Lernen</option>
|
|
<option value="choose">Discovery & Einrichtung</option>
|
|
<option value="settings">Einstellungen</option>
|
|
</select>
|
|
<div class="status-pill"><span class="dot"></span><span id="load-budget">Seite bereit, Status folgt ...</span></div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<main>
|
|
<section class="control-panel app-view" id="choose">
|
|
<div class="panel-title">
|
|
<h2>Discovery & Einrichtung</h2>
|
|
</div>
|
|
<label for="actuator-input">Entity-ID</label>
|
|
<input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off">
|
|
<datalist id="actuator-options"></datalist>
|
|
<div class="inline-controls">
|
|
<div>
|
|
<label for="actuator-domain-filter">Typ</label>
|
|
<select id="actuator-domain-filter" onchange="renderActuatorSelect()">
|
|
<option value="">Alle steuerbaren Typen</option>
|
|
<option value="light">Lichter</option>
|
|
<option value="switch">Schalter / Helper</option>
|
|
<option value="button">Buttons</option>
|
|
<option value="input_button">Helper-Buttons</option>
|
|
<option value="input_boolean">Helper-Schalter</option>
|
|
<option value="cover">Rollläden / Cover</option>
|
|
<option value="climate">Heizungen / Klima</option>
|
|
<option value="lock">Schlösser</option>
|
|
<option value="fan">Lüftung / Ventilatoren</option>
|
|
<option value="humidifier">Befeuchter / Entfeuchter</option>
|
|
<option value="media_player">TV / Medien</option>
|
|
<option value="remote">Fernbedienungen</option>
|
|
<option value="scene">Szenen</option>
|
|
<option value="number">Numerische Helper</option>
|
|
<option value="valve">Ventile</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="actuator-search">Liste durchsuchen</label>
|
|
<input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" onfocus="ensureActuatorDiscovery()" autocomplete="off">
|
|
</div>
|
|
</div>
|
|
<label for="actuator-select">Geräteliste</label>
|
|
<select id="actuator-select" onchange="selectActuatorFromList()" onfocus="ensureActuatorDiscovery()">
|
|
<option value="">Geräteliste bei Bedarf laden</option>
|
|
</select>
|
|
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
|
|
<button class="secondary" onclick="ensureActuatorDiscovery()">Geräteliste laden</button>
|
|
<p id="actuator-config-result" class="muted">Bereit.</p>
|
|
<details class="manual-context">
|
|
<summary>Vorschläge anzeigen</summary>
|
|
<button class="secondary" onclick="loadActuatorSuggestions()">Vorschläge laden</button>
|
|
</details>
|
|
<div id="actuator-suggestions" class="card-list"></div>
|
|
</section>
|
|
|
|
<section class="board-panel app-view" id="observed">
|
|
<div class="panel-title">
|
|
<div>
|
|
<h2>Beobachtete Geräte</h2>
|
|
</div>
|
|
<button class="secondary compact" onclick="loadOverview()">Aktualisieren</button>
|
|
</div>
|
|
<div id="configured-actuators">Noch nicht geladen.</div>
|
|
</section>
|
|
|
|
<section class="detail-panel app-view" id="detail">
|
|
<div class="panel-title">
|
|
<h2>Details</h2>
|
|
<button class="secondary compact" onclick="showView('observed')">Zurück</button>
|
|
</div>
|
|
<div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div>
|
|
</section>
|
|
|
|
<section class="status-panel app-view active" id="status-section">
|
|
<div class="panel-title">
|
|
<div>
|
|
<h2>System & Cache</h2>
|
|
</div>
|
|
<button class="secondary compact" onclick="loadStatus()">Status prüfen</button>
|
|
</div>
|
|
<div id="status">Prüfung läuft ...</div>
|
|
<div class="chips" id="status-chips"></div>
|
|
<div id="dashboard-stats" class="metric-grid"></div>
|
|
<div id="job-queue" class="decision-list"></div>
|
|
</section>
|
|
|
|
<section class="guide-panel app-view" id="settings">
|
|
<div class="panel-title">
|
|
<div>
|
|
<h2>Einstellungen</h2>
|
|
<p class="muted">Sprache und Standardwerte für die Bedienoberfläche.</p>
|
|
</div>
|
|
</div>
|
|
<div class="grid-two">
|
|
<div>
|
|
<label for="language-select">Sprache</label>
|
|
<select id="language-select" onchange="setLanguage(this.value)">
|
|
<option value="de">Deutsch</option>
|
|
<option value="en">English</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
<script>
|
|
const escapeHtml = value => String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
let currentActuatorId = null;
|
|
let actuatorChoices = [];
|
|
let contextOptions = [];
|
|
let manualContextState = {options: [], selected: new Set()};
|
|
let cachedActuators = null;
|
|
let cachedEntities = null;
|
|
let cachedDiscovery = null;
|
|
let cachedSystemOverview = null;
|
|
let cachedDashboardOverview = null;
|
|
let cachedDetailHtml = new Map();
|
|
let discoveryLoadPromise = null;
|
|
let overviewLoadPromise = null;
|
|
let systemLoadPromise = null;
|
|
let currentSensorWeightGroups = [];
|
|
let visibleActuatorLimit = 24;
|
|
const ACTUATOR_RESULT_LIMIT = 50;
|
|
const STATUS_TIMEOUT_MS = 2000;
|
|
const DASHBOARD_TIMEOUT_MS = 3000;
|
|
const I18N = {
|
|
de: {
|
|
safety_stage: {
|
|
observe: "Nur beobachten",
|
|
suggest: "Vorschläge anzeigen",
|
|
shadow: "Prüfmodus ohne Schalten",
|
|
partial: "Teilfreigabe",
|
|
active: "Aktiv freigegeben",
|
|
},
|
|
behavior_mode: {
|
|
shadow: "Prüfmodus",
|
|
active: "Aktiv",
|
|
paused: "Pausiert",
|
|
},
|
|
behavior_status: {
|
|
collecting: "Sammelt Lernbeispiele",
|
|
trained: "Gelernt",
|
|
blocked: "Blockiert",
|
|
},
|
|
lifecycle_status: {
|
|
trained: "gelernt",
|
|
pending_history: "sammelt Historie",
|
|
pending_assignment: "sucht Kontext",
|
|
review_required: "bitte prüfen",
|
|
archived: "wartet",
|
|
orphaned: "Aktor fehlt",
|
|
stale: "Training veraltet",
|
|
invalid: "ungültig",
|
|
},
|
|
job_status: {
|
|
pending: "wartet",
|
|
running: "läuft",
|
|
completed: "abgeschlossen",
|
|
failed: "fehlgeschlagen",
|
|
},
|
|
job_kind: {
|
|
discovery: "Geräte-Erkennung",
|
|
reconciliation: "Abgleich",
|
|
training: "Training",
|
|
evaluation: "Auswertung",
|
|
automation_refresh: "Automation-Prüfung",
|
|
},
|
|
severity: {
|
|
info: "Hinweis",
|
|
warning: "Warnung",
|
|
critical: "Kritisch",
|
|
},
|
|
anomaly_category: {
|
|
missing_context: "fehlender Kontext",
|
|
low_samples: "zu wenig Lernbeispiele",
|
|
unclear_sources: "unklare Quellen",
|
|
stale_training: "veraltetes Training",
|
|
low_confidence_prediction: "geringe Sicherheit",
|
|
manual_block: "manuelle Sperre",
|
|
safety_blockers: "Sicherheitsblocker",
|
|
automation_conflict: "Automation-Konflikt",
|
|
feedback_error_rate: "Feedback-Fehlerquote",
|
|
},
|
|
performance_status: {
|
|
ok: "schnell",
|
|
slow: "zu langsam",
|
|
running: "läuft",
|
|
unknown: "noch offen",
|
|
},
|
|
connection_status: {
|
|
connected: "verbunden",
|
|
disconnected: "getrennt",
|
|
unavailable: "nicht verfügbar",
|
|
error: "Fehler",
|
|
},
|
|
},
|
|
en: {
|
|
safety_stage: {
|
|
observe: "Observe only",
|
|
suggest: "Show suggestions",
|
|
shadow: "Review mode without switching",
|
|
partial: "Partial approval",
|
|
active: "Active approval",
|
|
},
|
|
behavior_mode: {
|
|
shadow: "Review mode",
|
|
active: "Active",
|
|
paused: "Paused",
|
|
},
|
|
behavior_status: {
|
|
collecting: "Collecting examples",
|
|
trained: "Learned",
|
|
blocked: "Blocked",
|
|
},
|
|
lifecycle_status: {
|
|
trained: "learned",
|
|
pending_history: "collecting history",
|
|
pending_assignment: "finding context",
|
|
review_required: "review required",
|
|
archived: "waiting",
|
|
orphaned: "actuator missing",
|
|
stale: "training stale",
|
|
invalid: "invalid",
|
|
},
|
|
job_status: {
|
|
pending: "waiting",
|
|
running: "running",
|
|
completed: "completed",
|
|
failed: "failed",
|
|
},
|
|
job_kind: {
|
|
discovery: "Discovery",
|
|
reconciliation: "Reconciliation",
|
|
training: "Training",
|
|
evaluation: "Evaluation",
|
|
automation_refresh: "Automation check",
|
|
},
|
|
severity: {
|
|
info: "Info",
|
|
warning: "Warning",
|
|
critical: "Critical",
|
|
},
|
|
anomaly_category: {
|
|
missing_context: "missing context",
|
|
low_samples: "not enough samples",
|
|
unclear_sources: "unclear sources",
|
|
stale_training: "stale training",
|
|
low_confidence_prediction: "low confidence",
|
|
manual_block: "manual block",
|
|
safety_blockers: "safety blockers",
|
|
automation_conflict: "automation conflict",
|
|
feedback_error_rate: "feedback error rate",
|
|
},
|
|
performance_status: {
|
|
ok: "fast",
|
|
slow: "too slow",
|
|
running: "running",
|
|
unknown: "unknown",
|
|
},
|
|
connection_status: {
|
|
connected: "connected",
|
|
disconnected: "disconnected",
|
|
unavailable: "unavailable",
|
|
error: "error",
|
|
},
|
|
},
|
|
};
|
|
let uiLang = localStorage.getItem("sillyhome.ui.language") || "de";
|
|
|
|
function jumpToSection(target) {
|
|
if (!target) return;
|
|
document.querySelector(target)?.scrollIntoView({behavior: "smooth", block: "start"});
|
|
}
|
|
|
|
function showView(viewId) {
|
|
if (viewId === "detail" && !currentActuatorId) {
|
|
viewId = "observed";
|
|
}
|
|
for (const section of document.querySelectorAll(".app-view")) {
|
|
section.classList.toggle("active", section.id === viewId);
|
|
}
|
|
localStorage.setItem("sillyhome.ui.view", viewId);
|
|
if (viewId === "status-section") {
|
|
if (cachedSystemOverview) {
|
|
renderDashboardStatus(cachedSystemOverview);
|
|
refreshSystemOverviewInBackground();
|
|
} else {
|
|
void loadSystemOverview();
|
|
}
|
|
} else if (viewId === "observed") {
|
|
if (cachedActuators) {
|
|
renderConfiguredActuators();
|
|
refreshOverviewInBackground();
|
|
} else {
|
|
void loadOverview();
|
|
}
|
|
} else if (viewId === "choose") {
|
|
if (cachedDiscovery) renderActuatorDiscovery();
|
|
} else if (viewId === "settings") {
|
|
syncSettingsView();
|
|
}
|
|
document.getElementById(viewId)?.scrollIntoView({behavior: "smooth", block: "start"});
|
|
}
|
|
|
|
function setLanguage(language) {
|
|
uiLang = I18N[language] ? language : "de";
|
|
localStorage.setItem("sillyhome.ui.language", uiLang);
|
|
syncSettingsView();
|
|
if (cachedActuators) renderConfiguredActuators();
|
|
if (cachedSystemOverview) renderDashboardStatus(cachedSystemOverview);
|
|
if (currentActuatorId && cachedDetailHtml.has(currentActuatorId)) {
|
|
document.getElementById("actuator-detail").innerHTML = cachedDetailHtml.get(currentActuatorId);
|
|
}
|
|
}
|
|
|
|
function syncSettingsView() {
|
|
const select = document.getElementById("language-select");
|
|
if (select) select.value = uiLang;
|
|
}
|
|
|
|
function translate(group, value, fallback = "") {
|
|
if (value == null || value === "") return fallback || "offen";
|
|
return I18N[uiLang]?.[group]?.[value] || fallback || String(value);
|
|
}
|
|
|
|
function formatDateTime(value) {
|
|
if (!value) return "noch offen";
|
|
const parsed = new Date(value);
|
|
return Number.isNaN(parsed.getTime())
|
|
? String(value)
|
|
: parsed.toLocaleString("de-DE");
|
|
}
|
|
|
|
function uniqueValues(values) {
|
|
return [...new Set(values.filter(Boolean))];
|
|
}
|
|
|
|
function invalidateDashboardCache() {
|
|
cachedActuators = null;
|
|
cachedEntities = null;
|
|
cachedDiscovery = null;
|
|
cachedDashboardOverview = null;
|
|
cachedSystemOverview = null;
|
|
cachedDetailHtml.clear();
|
|
}
|
|
|
|
async function api(path, options = {}) {
|
|
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
|
|
const body = response.status === 204 ? null : await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(body?.detail || `${response.status} ${response.statusText}`);
|
|
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});
|
|
} catch (error) {
|
|
if (error?.name === "AbortError") {
|
|
throw new Error("Zeitlimit erreicht; Daten laden im Hintergrund weiter.");
|
|
}
|
|
throw error;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function lifecycleLabel(record) {
|
|
const behaviorStatus = record.behavior_status || record.behavior?.status;
|
|
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
|
|
if (behaviorStatus === "trained") return "Kontext erkannt";
|
|
const labels = {
|
|
trained: translate("lifecycle_status", "trained"),
|
|
pending_history: translate("lifecycle_status", "pending_history"),
|
|
pending_assignment: translate("lifecycle_status", "pending_assignment"),
|
|
review_required: translate("lifecycle_status", "review_required"),
|
|
archived: translate("lifecycle_status", "archived"),
|
|
orphaned: translate("lifecycle_status", "orphaned"),
|
|
};
|
|
return labels[lifecycleStatus] || lifecycleStatus;
|
|
}
|
|
|
|
function statusClass(record) {
|
|
const behaviorStatus = record.behavior_status || record.behavior?.status;
|
|
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
|
|
if (behaviorStatus === "trained") return "ok";
|
|
if (lifecycleStatus === "trained") return "ok";
|
|
if (["pending_history", "pending_assignment", "archived"].includes(lifecycleStatus)) return "warn";
|
|
return "bad";
|
|
}
|
|
|
|
function behaviorLabel(record) {
|
|
const mode = record.behavior_mode || record.behavior?.mode;
|
|
const status = record.behavior_status || record.behavior?.status;
|
|
if (mode === "active") return "aktiv freigegeben";
|
|
if (status === "trained") return "Prüfmodus mit Vorhersage";
|
|
if (status === "blocked") return "Lernen blockiert";
|
|
return "sammelt Handlungen";
|
|
}
|
|
|
|
function predictionLabel(record) {
|
|
const target = record.prediction_target_state || record.behavior?.prediction?.target_state;
|
|
const confidence = record.prediction_confidence ?? record.behavior?.prediction?.confidence;
|
|
return target
|
|
? `${target} (${Math.round(confidence * 100)} %)`
|
|
: "Keine fällige Aktion";
|
|
}
|
|
|
|
function entityLabel(entity) {
|
|
const area = entity.area_name || "Ohne Bereich";
|
|
const name = entity.friendly_name || entity.entity_id;
|
|
return `${area} - ${name} (${entity.entity_id})`;
|
|
}
|
|
|
|
function normalizedSearch(value) {
|
|
return String(value || "").toLowerCase().replaceAll("_", " ");
|
|
}
|
|
|
|
function matchesSearch(entity, query) {
|
|
if (!query) return true;
|
|
return normalizedSearch([
|
|
entity.entity_id,
|
|
entity.friendly_name,
|
|
entity.area_name,
|
|
entity.device_name,
|
|
entity.device_class,
|
|
entity.domain,
|
|
].filter(Boolean).join(" ")).includes(query);
|
|
}
|
|
|
|
function categoryForEntity(entity) {
|
|
const cls = entity.device_class || "";
|
|
const text = normalizedSearch([
|
|
entity.entity_id,
|
|
entity.friendly_name,
|
|
entity.area_name,
|
|
entity.device_name,
|
|
].filter(Boolean).join(" "));
|
|
if (["pv", "solar", "akku", "batterie", "battery", "einspeisung", "wechselrichter"].some(token => text.includes(token))) {
|
|
return "PV / Akku / Einspeisung";
|
|
}
|
|
if (entity.domain === "fan") return "Lüftung / Ventilatoren";
|
|
if (entity.domain === "climate") return "Heizung / Klima";
|
|
if (entity.domain === "weather") return "Wetter";
|
|
if (entity.domain === "person" || entity.domain === "device_tracker") return "Anwesenheit / Personen";
|
|
if (entity.domain === "cover") return "Rollläden / Cover";
|
|
if (entity.domain === "light") return "Lichtzustände";
|
|
if (entity.domain === "switch") return "Schalter / Steckdosen";
|
|
if (entity.domain.startsWith("input_")) return "Helper";
|
|
if (entity.domain === "scene") return "Szenen";
|
|
if (entity.domain === "media_player" || entity.domain === "remote") return "TV / Medien";
|
|
if (["motion", "occupancy", "presence"].includes(cls)) return "PIR / Präsenz";
|
|
if (["illuminance"].includes(cls)) return "Helligkeit";
|
|
if (["door", "garage_door", "opening", "window"].includes(cls)) return "Tür / Fenster";
|
|
if (["smoke", "safety", "problem"].includes(cls)) return "Sicherheit / Diagnose";
|
|
if (["humidity", "moisture"].includes(cls)) return "Luftfeuchtigkeit";
|
|
if (["temperature"].includes(cls)) return "Temperatur";
|
|
if (["power", "energy", "current", "voltage"].includes(cls)) return "Strom / Energie";
|
|
if (["battery", "signal_strength"].includes(cls)) return "Batterie / Signal";
|
|
if (entity.domain === "binary_sensor") return "Binäre Sensoren";
|
|
if (entity.domain === "sensor") return "Weitere Messsensoren";
|
|
return "Weitere Zustände";
|
|
}
|
|
|
|
function optionGroups(entities, selectedIds = new Set()) {
|
|
const groups = new Map();
|
|
for (const entity of entities) {
|
|
const category = categoryForEntity(entity);
|
|
if (!groups.has(category)) groups.set(category, []);
|
|
groups.get(category).push(entity);
|
|
}
|
|
return Array.from(groups.entries()).map(([label, items]) => `
|
|
<optgroup label="${escapeHtml(label)}">
|
|
${items.map(entity => `
|
|
<option value="${escapeHtml(entity.entity_id)}" ${selectedIds.has(entity.entity_id) ? "selected" : ""}>
|
|
${escapeHtml(entityLabel(entity))}
|
|
</option>
|
|
`).join("")}
|
|
</optgroup>
|
|
`).join("");
|
|
}
|
|
|
|
async function loadOverview() {
|
|
if (overviewLoadPromise) return overviewLoadPromise;
|
|
overviewLoadPromise = doLoadOverview().finally(() => {
|
|
overviewLoadPromise = null;
|
|
});
|
|
return overviewLoadPromise;
|
|
}
|
|
|
|
async function doLoadOverview() {
|
|
const startedAt = performance.now();
|
|
const budget = document.getElementById("load-budget");
|
|
if (budget) budget.textContent = "Startdaten laden ...";
|
|
if (!cachedActuators) {
|
|
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
|
|
}
|
|
try {
|
|
const dashboard = await api("v1/actuators/dashboard/start");
|
|
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
|
|
cachedDashboardOverview = dashboard;
|
|
cachedActuators = dashboard.actuators || [];
|
|
cachedEntities = [];
|
|
renderDashboardStatus(dashboard);
|
|
renderConfiguredActuators();
|
|
if (budget) {
|
|
const loadMs = dashboard._load_elapsed_ms;
|
|
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
|
|
? `Bereit in ${loadMs} ms`
|
|
: `Langsam: ${loadMs} ms`;
|
|
}
|
|
} catch (error) {
|
|
document.getElementById("configured-actuators").innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
|
if (budget) budget.textContent = "Startdaten verzögert";
|
|
try {
|
|
await loadSummaryData();
|
|
renderConfiguredActuators();
|
|
} catch (_) {
|
|
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Startdaten sind gerade nicht verfügbar.</div>";
|
|
}
|
|
}
|
|
scheduleDashboardExtras();
|
|
}
|
|
|
|
async function loadSystemOverview() {
|
|
if (systemLoadPromise) return systemLoadPromise;
|
|
systemLoadPromise = doLoadSystemOverview().finally(() => {
|
|
systemLoadPromise = null;
|
|
});
|
|
return systemLoadPromise;
|
|
}
|
|
|
|
async function doLoadSystemOverview() {
|
|
const startedAt = performance.now();
|
|
const budget = document.getElementById("load-budget");
|
|
if (budget) budget.textContent = "Systemübersicht lädt ...";
|
|
try {
|
|
const dashboard = await api("v1/actuators/dashboard/system");
|
|
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
|
|
cachedSystemOverview = dashboard;
|
|
cachedActuators = dashboard.actuators || cachedActuators;
|
|
renderDashboardStatus(dashboard);
|
|
if (budget) {
|
|
const loadMs = dashboard._load_elapsed_ms;
|
|
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
|
|
? `Systemübersicht bereit in ${loadMs} ms`
|
|
: `Systemübersicht langsam: ${loadMs} ms`;
|
|
}
|
|
scheduleDashboardExtras();
|
|
} catch (error) {
|
|
document.getElementById("status").innerHTML = `<p class="warn">Systemübersicht verzögert: ${escapeHtml(error.message)}</p>`;
|
|
if (budget) budget.textContent = "Systemübersicht verzögert";
|
|
}
|
|
}
|
|
|
|
function refreshOverviewInBackground() {
|
|
if (!overviewLoadPromise) {
|
|
overviewLoadPromise = doLoadOverview().finally(() => {
|
|
overviewLoadPromise = null;
|
|
});
|
|
}
|
|
}
|
|
|
|
function refreshSystemOverviewInBackground() {
|
|
if (!systemLoadPromise) {
|
|
systemLoadPromise = doLoadSystemOverview().finally(() => {
|
|
systemLoadPromise = null;
|
|
});
|
|
}
|
|
}
|
|
|
|
function scheduleDashboardExtras() {
|
|
const run = () => {
|
|
void loadDashboardExtras();
|
|
};
|
|
if ("requestIdleCallback" in window) {
|
|
window.requestIdleCallback(run, {timeout: 1800});
|
|
} else {
|
|
setTimeout(run, 250);
|
|
}
|
|
}
|
|
|
|
async function loadDashboardExtras() {
|
|
try {
|
|
const [jobs, reconciliation] = await Promise.allSettled([
|
|
apiWithTimeout("v1/actuators/job-queue/state", STATUS_TIMEOUT_MS),
|
|
apiWithTimeout("v1/actuators/reconciliation/state", STATUS_TIMEOUT_MS),
|
|
]);
|
|
if (jobs.status === "fulfilled") {
|
|
renderJobQueue(jobs.value.jobs || []);
|
|
}
|
|
if (reconciliation.status === "fulfilled") {
|
|
const text = document.getElementById("reconciliation-status");
|
|
if (text) {
|
|
text.textContent = `Letzte automatische Prüfung: ${formatDateTime(reconciliation.value.last_completed_at)}`;
|
|
}
|
|
}
|
|
} catch (_) {
|
|
// Die Startansicht bleibt auch ohne Hintergrunddaten bedienbar.
|
|
}
|
|
}
|
|
|
|
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] = await Promise.allSettled([
|
|
apiWithTimeout("health"),
|
|
apiWithTimeout("health/websocket"),
|
|
apiWithTimeout("ml/health"),
|
|
apiWithTimeout("v1/actuators/reconciliation/state"),
|
|
]);
|
|
const values = [health, websocket, ml, reconciliation].map(result =>
|
|
result.status === "fulfilled" ? result.value : null
|
|
);
|
|
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>"
|
|
: `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(formatDateTime(reconciliationValue.last_completed_at))}</p>`;
|
|
chips.innerHTML = [
|
|
`<span class="chip">API: ${escapeHtml(healthValue?.status || "offen")}</span>`,
|
|
`<span class="chip">WebSocket: ${escapeHtml(translate("connection_status", websocketValue?.status, websocketValue?.status || "offen"))}</span>`,
|
|
`<span class="chip">Lernsystem: ${escapeHtml(mlValue?.status || "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 = "";
|
|
}
|
|
}
|
|
|
|
function renderDashboardStatus(dashboard) {
|
|
const status = document.getElementById("status");
|
|
const chips = document.getElementById("status-chips");
|
|
const stats = document.getElementById("dashboard-stats");
|
|
const system = dashboard.system || {};
|
|
const cache = dashboard.cache || {};
|
|
const actuators = dashboard.actuators || [];
|
|
const discoveryGroups = dashboard.discovery_groups || [];
|
|
const activeCount = actuators.filter(record => record.behavior_mode === "active").length;
|
|
const shadowCount = actuators.filter(record => record.behavior_mode !== "active").length;
|
|
const readyCount = actuators.filter(record => record.activation_ready).length;
|
|
const pendingCount = actuators.filter(record =>
|
|
["pending_history", "pending_assignment", "archived"].includes(record.lifecycle_status)
|
|
).length;
|
|
const trainedCount = actuators.filter(record => record.behavior_status === "trained").length;
|
|
const sampleTotal = actuators.reduce((sum, record) => sum + Number(record.sample_count || 0), 0);
|
|
const anomalyTotal = Number(system.anomaly_count || 0);
|
|
const criticalAnomalyTotal = Number(system.critical_anomaly_count || 0);
|
|
const loadMs = Number(dashboard._load_elapsed_ms || 0);
|
|
const jobs = dashboard.jobs?.jobs || [];
|
|
const runningJobs = jobs.filter(job => job.status === "running").length;
|
|
const slowJobs = Number(system.slow_job_count || 0);
|
|
const p95 = system.job_p95_duration_ms == null ? "offen" : `${system.job_p95_duration_ms} ms`;
|
|
const performanceClass = (
|
|
loadMs > DASHBOARD_TIMEOUT_MS
|
|
|| slowJobs > 0
|
|
|| system.performance_status === "slow"
|
|
) ? "warn" : "ok";
|
|
const cacheLabel = cache.available
|
|
? `Cache aktuell mit ${cache.entity_count} Entities`
|
|
: "Cache wird nach Discovery aufgebaut";
|
|
status.innerHTML = `
|
|
<p class="${performanceClass}">
|
|
Dashboard bereit in ${escapeHtml(loadMs || "offen")} ms. Budget: ${escapeHtml(system.performance_budget_ms || DASHBOARD_TIMEOUT_MS)} ms.
|
|
</p>
|
|
<p class="${system.websocket_status === "connected" ? "ok" : "warn"}">WebSocket: ${escapeHtml(translate("connection_status", system.websocket_status, system.websocket_status || "unbekannt"))}</p>
|
|
<p class="muted" id="reconciliation-status">Letzte automatische Prüfung: ${escapeHtml(formatDateTime(system.reconciliation_last_completed_at))}</p>
|
|
`;
|
|
chips.innerHTML = [
|
|
`<span class="chip">API: ${escapeHtml(system.api_status || "ok")}</span>`,
|
|
`<span class="chip">Cache: ${escapeHtml(cacheLabel)}</span>`,
|
|
`<span class="chip">Aktoren: ${escapeHtml(system.configured_actuators ?? 0)}</span>`,
|
|
`<span class="chip">Lernbereit: ${escapeHtml(system.trained_models ?? 0)}</span>`,
|
|
`<span class="chip">Prüfen: ${escapeHtml(system.review_required ?? 0)}</span>`,
|
|
`<span class="chip">Jobs aktiv: ${escapeHtml(runningJobs)}</span>`,
|
|
`<span class="chip">Anomalien: ${escapeHtml(anomalyTotal)}</span>`,
|
|
`<span class="chip">Kritisch: ${escapeHtml(criticalAnomalyTotal)}</span>`,
|
|
].join("");
|
|
stats.innerHTML = [
|
|
`<div class="metric"><strong>Geladene Startdaten</strong>${escapeHtml(actuators.length)} Geräte</div>`,
|
|
`<div class="metric"><strong>Freigabebereit</strong>${escapeHtml(readyCount)} Geräte</div>`,
|
|
`<div class="metric"><strong>Aktiv / Prüfmodus</strong>${escapeHtml(activeCount)} / ${escapeHtml(shadowCount)}</div>`,
|
|
`<div class="metric"><strong>Gelernt / Wartet</strong>${escapeHtml(trainedCount)} / ${escapeHtml(pendingCount)}</div>`,
|
|
`<div class="metric"><strong>Gelernte Handlungen</strong>${escapeHtml(sampleTotal)}</div>`,
|
|
`<div class="metric"><strong>Performance-Budget</strong>${escapeHtml(system.performance_budget_ms || 3000)} ms</div>`,
|
|
`<div class="metric"><strong>Job p95</strong>${escapeHtml(p95)}</div>`,
|
|
`<div class="metric"><strong>Langsame Jobs</strong>${escapeHtml(slowJobs)}</div>`,
|
|
`<div class="metric"><strong>Anomalien</strong>${escapeHtml(anomalyTotal)} offen</div>`,
|
|
`<div class="metric"><strong>Discovery-Gruppen</strong>${escapeHtml(discoveryGroups.length)} Kategorien</div>`,
|
|
`<div class="metric"><strong>Cache-Zeitpunkt</strong>${escapeHtml(cache.updated_at || "noch offen")}</div>`,
|
|
].join("");
|
|
renderJobQueue(jobs);
|
|
}
|
|
|
|
function renderJobQueue(jobs) {
|
|
const jobsBox = document.getElementById("job-queue");
|
|
if (!jobsBox) return;
|
|
jobsBox.innerHTML = jobs.length ? `
|
|
<h3>Aufgabenliste</h3>
|
|
${jobs.slice(-6).reverse().map(job => `
|
|
<div class="decision-row ${Number(job.duration_ms || 0) >= DASHBOARD_TIMEOUT_MS ? "slow" : ""}">
|
|
<header>
|
|
<strong>${escapeHtml(translate("job_kind", job.kind, job.kind))}${job.target ? `: ${escapeHtml(job.target)}` : ""}</strong>
|
|
<span class="chip">${escapeHtml(translate("job_status", job.status, job.status))}${Number(job.duration_ms || 0) >= DASHBOARD_TIMEOUT_MS ? " · langsam" : ""}</span>
|
|
</header>
|
|
<p class="muted">${escapeHtml(job.summary || "Keine Zusammenfassung")}</p>
|
|
<p class="muted">Start: ${escapeHtml(formatDateTime(job.started_at))} · Dauer: ${escapeHtml(job.duration_ms == null ? "läuft/offen" : `${job.duration_ms} ms`)}</p>
|
|
${job.error ? `<p class="bad">${escapeHtml(job.error)}</p>` : ""}
|
|
${job.status === "failed" ? "<p class='warn'>Erneut versuchen: Aktion im Dashboard noch einmal starten; der nächste Lauf schreibt einen neuen Eintrag.</p>" : ""}
|
|
</div>
|
|
`).join("")}
|
|
` : "";
|
|
}
|
|
|
|
async function loadSummaryData() {
|
|
const actuators = await api("v1/actuators/summary");
|
|
cachedActuators = actuators;
|
|
cachedEntities = [];
|
|
}
|
|
|
|
async function loadDashboardData() {
|
|
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"),
|
|
api("v1/actuators/discovery"),
|
|
]);
|
|
cachedActuators = actuators;
|
|
cachedEntities = [];
|
|
cachedDiscovery = discovery;
|
|
}
|
|
|
|
async function loadActuatorDiscovery() {
|
|
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();
|
|
}
|
|
|
|
async function ensureActuatorDiscovery() {
|
|
const select = document.getElementById("actuator-select");
|
|
if (cachedDiscovery) {
|
|
renderActuatorDiscovery();
|
|
return;
|
|
}
|
|
if (!discoveryLoadPromise) {
|
|
if (select) select.innerHTML = `<option value="">Geräteliste wird geladen ...</option>`;
|
|
discoveryLoadPromise = loadActuatorDiscovery().finally(() => {
|
|
discoveryLoadPromise = null;
|
|
});
|
|
}
|
|
await discoveryLoadPromise;
|
|
}
|
|
|
|
function renderActuatorDiscovery() {
|
|
const options = document.getElementById("actuator-options");
|
|
const select = document.getElementById("actuator-select");
|
|
try {
|
|
const available = cachedDiscovery || [];
|
|
const configured = cachedActuators || [];
|
|
const configuredIds = new Set(configured.map(record => record.actuator_entity_id));
|
|
actuatorChoices = available.filter(entity => !configuredIds.has(entity.entity_id));
|
|
options.innerHTML = actuatorChoices.slice(0, 120).map(entity =>
|
|
`<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entity.friendly_name || entity.entity_id)}${entity.area_name ? ` (${escapeHtml(entity.area_name)})` : ""}</option>`
|
|
).join("");
|
|
renderActuatorSelect();
|
|
} catch (error) {
|
|
options.innerHTML = "";
|
|
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden: ${escapeHtml(error.message)}</option>`;
|
|
}
|
|
}
|
|
|
|
async function loadActuatorSuggestions() {
|
|
const box = document.getElementById("actuator-suggestions");
|
|
if (!box) return;
|
|
try {
|
|
const suggestions = await api("v1/actuators/suggestions");
|
|
box.innerHTML = suggestions.length ? `
|
|
<h3>Vorschläge aus bestehenden Zusammenhängen</h3>
|
|
${suggestions.slice(0, 8).map(item => `
|
|
<article class="actuator-card">
|
|
<div class="card-title">
|
|
<div>
|
|
<div class="entity-id">${escapeHtml(item.entity_id)}</div>
|
|
<div class="muted">${escapeHtml(item.area_name || item.device_name || item.domain)}</div>
|
|
</div>
|
|
<span class="chip">${Math.round(item.confidence * 100)} %</span>
|
|
</div>
|
|
<p class="muted">${escapeHtml(item.reason)}</p>
|
|
<button class="secondary" onclick="configureSuggestedActuator('${escapeHtml(item.entity_id)}')">Vorschlag übernehmen</button>
|
|
</article>
|
|
`).join("")}
|
|
` : "";
|
|
} catch (_) {
|
|
box.innerHTML = "";
|
|
}
|
|
}
|
|
|
|
function actuatorGroupLabel(domain) {
|
|
const labels = {
|
|
button: "Buttons",
|
|
climate: "Heizungen / Klima",
|
|
light: "Lichter",
|
|
input_boolean: "Helper-Schalter",
|
|
input_button: "Helper-Buttons",
|
|
lock: "Schlösser",
|
|
media_player: "TV / Medien",
|
|
number: "Numerische Helper",
|
|
remote: "Fernbedienungen",
|
|
scene: "Szenen",
|
|
switch: "Schalter / Steckdosen",
|
|
cover: "Rollläden / Cover",
|
|
fan: "Lüftung / Ventilatoren",
|
|
humidifier: "Befeuchter / Entfeuchter",
|
|
valve: "Ventile",
|
|
};
|
|
return labels[domain] || domain;
|
|
}
|
|
|
|
function renderActuatorSelect() {
|
|
const select = document.getElementById("actuator-select");
|
|
if (!select) return;
|
|
const domain = document.getElementById("actuator-domain-filter")?.value || "";
|
|
const query = normalizedSearch(document.getElementById("actuator-search")?.value || "");
|
|
const filtered = actuatorChoices
|
|
.filter(entity => !domain || entity.domain === domain)
|
|
.filter(entity => matchesSearch(entity, query));
|
|
const visible = filtered.slice(0, ACTUATOR_RESULT_LIMIT);
|
|
const domains = [...new Set(visible.map(entity => entity.domain))].sort();
|
|
const limitLabel = filtered.length > visible.length
|
|
? ` - ${visible.length} von ${filtered.length}; Suche oder Typ weiter eingrenzen`
|
|
: "";
|
|
select.innerHTML = [
|
|
`<option value="">${filtered.length ? `Gerät auswählen${limitLabel}` : "Keine passenden Geräte gefunden"}</option>`,
|
|
...domains.map(group => `
|
|
<optgroup label="${escapeHtml(actuatorGroupLabel(group))}">
|
|
${visible
|
|
.filter(entity => entity.domain === group)
|
|
.map(entity => `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entityLabel(entity))}</option>`)
|
|
.join("")}
|
|
</optgroup>
|
|
`),
|
|
].join("");
|
|
}
|
|
|
|
async function loadContextOptions(actuatorId) {
|
|
try {
|
|
contextOptions = await api(`v1/actuators/context-options?actuator_entity_id=${encodeURIComponent(actuatorId)}`);
|
|
} catch (error) {
|
|
contextOptions = [];
|
|
}
|
|
}
|
|
|
|
function selectActuatorFromList() {
|
|
const value = document.getElementById("actuator-select").value;
|
|
if (value) document.getElementById("actuator-input").value = value;
|
|
}
|
|
|
|
function selectDetailActuator() {
|
|
const value = document.getElementById("detail-actuator-select")?.value;
|
|
if (value) void showActuator(value);
|
|
}
|
|
|
|
function detailActuatorOptions(selectedId) {
|
|
const rows = cachedActuators || [];
|
|
return rows.map(record => `
|
|
<option value="${escapeHtml(record.actuator_entity_id)}" ${record.actuator_entity_id === selectedId ? "selected" : ""}>
|
|
${escapeHtml(record.friendly_name || record.device_name || record.actuator_entity_id)}
|
|
</option>
|
|
`).join("");
|
|
}
|
|
|
|
function renderManualContextSelect() {
|
|
const select = document.getElementById("manual-context-select");
|
|
if (!select) return;
|
|
const category = document.getElementById("manual-context-category")?.value || "";
|
|
const query = normalizedSearch(document.getElementById("manual-context-filter")?.value || "");
|
|
const selectedNow = new Set([
|
|
...manualContextState.selected,
|
|
...Array.from(select.selectedOptions).map(option => option.value),
|
|
]);
|
|
const filtered = manualContextState.options
|
|
.filter(entity => !category || categoryForEntity(entity) === category)
|
|
.filter(entity => matchesSearch(entity, query))
|
|
.slice(0, 80);
|
|
select.innerHTML = filtered.length
|
|
? optionGroups(filtered, selectedNow)
|
|
: `<option value="">Keine passenden Vorschläge</option>`;
|
|
}
|
|
|
|
function parseEntityIds(value) {
|
|
return String(value || "")
|
|
.split(/[\s,;]+/)
|
|
.map(item => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function configureActuator() {
|
|
const actuatorId = (
|
|
document.getElementById("actuator-input").value.trim()
|
|
|| document.getElementById("actuator-select").value.trim()
|
|
);
|
|
const result = document.getElementById("actuator-config-result");
|
|
if (!actuatorId) {
|
|
await ensureActuatorDiscovery();
|
|
result.textContent = "Wähle ein Gerät aus der geladenen Liste oder trage eine Entity-ID ein.";
|
|
return;
|
|
}
|
|
result.textContent = "Kontext wird automatisch analysiert ...";
|
|
try {
|
|
const record = await api("v1/actuators", {
|
|
method: "POST",
|
|
body: JSON.stringify({actuator_entity_id: actuatorId}),
|
|
});
|
|
currentActuatorId = record.actuator_entity_id;
|
|
result.textContent = `${record.actuator_entity_id}: ${lifecycleLabel(record)}.`;
|
|
await loadOverview();
|
|
await showActuator(record.actuator_entity_id);
|
|
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
|
} catch (error) {
|
|
result.textContent = error.message;
|
|
}
|
|
}
|
|
|
|
async function loadConfiguredActuators() {
|
|
if (!cachedActuators) {
|
|
await loadSummaryData();
|
|
}
|
|
renderConfiguredActuators();
|
|
}
|
|
|
|
function renderConfiguredActuators() {
|
|
const box = document.getElementById("configured-actuators");
|
|
try {
|
|
const rows = cachedActuators || [];
|
|
const visibleRows = rows.slice(0, visibleActuatorLimit);
|
|
const groups = new Map();
|
|
for (const record of visibleRows) {
|
|
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});
|
|
}
|
|
const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right));
|
|
const moreButton = rows.length > visibleRows.length
|
|
? `<button class="secondary" onclick="visibleActuatorLimit += 24; renderConfiguredActuators()">Weitere ${Math.min(24, rows.length - visibleRows.length)} Geräte anzeigen</button>`
|
|
: "";
|
|
box.innerHTML = rows.length ? `
|
|
${groupedRows.map(([group, items]) => `
|
|
<details class="group-panel">
|
|
<summary>${escapeHtml(group)} (${items.length})</summary>
|
|
<div class="card-list">
|
|
${items.map(({record}) => `
|
|
<article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}">
|
|
<div class="card-title">
|
|
<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>
|
|
<span class="chip">${escapeHtml(lifecycleLabel(record))}</span>
|
|
</div>
|
|
<div class="metric-grid">
|
|
<div class="metric"><strong>Freigabe</strong><span class="${record.activation_ready ? "ok" : "warn"}">${escapeHtml(record.activation_ready ? "bereit" : record.activation_reason)}</span></div>
|
|
<div class="metric"><strong>Handlungen</strong>${record.sample_count}</div>
|
|
<div class="metric"><strong>Vorhersage</strong>${escapeHtml(predictionLabel(record))}</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details öffnen</button>
|
|
${record.behavior_mode === "active"
|
|
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>`
|
|
: record.activation_ready
|
|
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen</button>`
|
|
: ""}
|
|
<button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button>
|
|
</div>
|
|
</article>
|
|
`).join("")}
|
|
</div>
|
|
</details>
|
|
`).join("")}
|
|
${moreButton}
|
|
` : "<p>Noch keine Aktoren ausgewählt.</p>";
|
|
} catch (error) {
|
|
box.textContent = error.message;
|
|
}
|
|
}
|
|
|
|
async function showActuator(actuatorId, evaluationMessage = "") {
|
|
currentActuatorId = actuatorId;
|
|
for (const section of document.querySelectorAll(".app-view")) {
|
|
section.classList.toggle("active", section.id === "detail");
|
|
}
|
|
document.getElementById("section-jump").value = "observed";
|
|
localStorage.setItem("sillyhome.ui.view", "detail");
|
|
const box = document.getElementById("actuator-detail");
|
|
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 {
|
|
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/detail`);
|
|
contextOptions = [];
|
|
const contexts = [
|
|
record.assignment.selected_numeric_entity_id,
|
|
...record.assignment.selected_context_entity_ids,
|
|
].filter(Boolean);
|
|
const evidence = [...record.numeric_candidates, ...record.context_candidates]
|
|
.filter(candidate => contexts.includes(candidate.entity_id))
|
|
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${uniqueValues(candidate.evidence).map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
|
|
.join("");
|
|
const weightedCandidates = [...record.numeric_candidates, ...record.context_candidates]
|
|
.filter(candidate => contexts.includes(candidate.entity_id));
|
|
const weightGroups = record.manual_override?.sensor_weight_groups || [];
|
|
currentSensorWeightGroups = weightGroups;
|
|
const weightControls = weightedCandidates.length ? `
|
|
<div class="card-list">
|
|
${weightedCandidates.map(candidate => {
|
|
const relevance = Math.round((candidate.confidence ?? 0) * 100);
|
|
const effective = Math.round((candidate.effective_weight ?? 1) * 100);
|
|
const manual = candidate.manual_weight == null ? effective : Math.round(candidate.manual_weight * 100);
|
|
return `
|
|
<article class="actuator-card">
|
|
<div class="card-title">
|
|
<div>
|
|
<div><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong></div>
|
|
<div class="entity-id">${escapeHtml(candidate.entity_id)}</div>
|
|
</div>
|
|
<span class="chip">${relevance} % relevant</span>
|
|
</div>
|
|
<div class="metric-grid">
|
|
<div class="metric"><strong>Automatische Relevanz</strong>${relevance} %</div>
|
|
<div class="metric"><strong>Aktive Gewichtung</strong>${effective} %</div>
|
|
<div class="metric"><strong>Score</strong>${escapeHtml(candidate.score)}</div>
|
|
</div>
|
|
<label for="weight-${escapeHtml(candidate.entity_id)}">Gewichtung korrigieren</label>
|
|
<input id="weight-${escapeHtml(candidate.entity_id)}" data-weight-entity="${escapeHtml(candidate.entity_id)}" type="number" min="0" max="100" step="5" value="${manual}">
|
|
</article>
|
|
`;
|
|
}).join("")}
|
|
</div>
|
|
<div class="actions">
|
|
<button onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}')">Gewichtungen speichern</button>
|
|
</div>
|
|
` : "<p class='muted'>Noch keine verwendeten Sensoren oder Zustände für eine Gewichtung ausgewählt.</p>";
|
|
const weightGroupControls = `
|
|
<details class="manual-context">
|
|
<summary>Gruppen-Gewichtung</summary>
|
|
${weightGroups.length ? `<ul>${weightGroups.map(group => `
|
|
<li><strong>${escapeHtml(group.name)}</strong>: ${Math.round(group.weight * 100)} %
|
|
<span class="muted">${group.entity_ids.map(escapeHtml).join(", ")}</span></li>
|
|
`).join("")}</ul>` : "<p class='muted'>Noch keine Gruppe gespeichert.</p>"}
|
|
<div class="inline-controls">
|
|
<div>
|
|
<label for="weight-group-name">Gruppenname</label>
|
|
<input id="weight-group-name" placeholder="z. B. Flur Bewegung + Helligkeit">
|
|
</div>
|
|
<div>
|
|
<label for="weight-group-value">Gruppen-Gewicht in %</label>
|
|
<input id="weight-group-value" type="number" min="0" max="100" step="5" value="100">
|
|
</div>
|
|
</div>
|
|
<label for="weight-group-entities">Entity-IDs der Gruppe</label>
|
|
<textarea id="weight-group-entities" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs">${escapeHtml(contexts.join("\n"))}</textarea>
|
|
<button class="secondary" onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}', true)">Als Gruppe speichern</button>
|
|
</details>
|
|
`;
|
|
const simulationControls = weightedCandidates.length ? `
|
|
<details class="manual-context" open>
|
|
<summary>Aktor-Simulation</summary>
|
|
<p class="muted">Teste Sensorzustände und Gewichtungen, ohne Home Assistant zu schalten.</p>
|
|
<div class="card-list">
|
|
${weightedCandidates.map(candidate => {
|
|
const effective = Math.round((candidate.effective_weight ?? 1) * 100);
|
|
const currentState = candidate.state || "";
|
|
const stateOptions = candidate.domain === "binary_sensor"
|
|
? "off,on"
|
|
: currentState;
|
|
return `
|
|
<article class="actuator-card">
|
|
<div class="card-title">
|
|
<div>
|
|
<strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>
|
|
<div class="entity-id">${escapeHtml(candidate.entity_id)}</div>
|
|
</div>
|
|
<span class="chip">Simulation</span>
|
|
</div>
|
|
<label for="sim-state-${escapeHtml(candidate.entity_id)}">Simulierter Zustand</label>
|
|
<input id="sim-state-${escapeHtml(candidate.entity_id)}" data-sim-state-entity="${escapeHtml(candidate.entity_id)}" value="${escapeHtml(currentState)}" placeholder="on, off, 12 ...">
|
|
<label for="sim-options-${escapeHtml(candidate.entity_id)}">Zustände vergleichen</label>
|
|
<input id="sim-options-${escapeHtml(candidate.entity_id)}" data-sim-options-entity="${escapeHtml(candidate.entity_id)}" value="${escapeHtml(stateOptions)}" placeholder="on,off">
|
|
<label for="sim-weight-${escapeHtml(candidate.entity_id)}">Simulierte Gewichtung in %</label>
|
|
<input id="sim-weight-${escapeHtml(candidate.entity_id)}" data-sim-weight-entity="${escapeHtml(candidate.entity_id)}" type="number" min="0" max="100" step="5" value="${effective}">
|
|
</article>
|
|
`;
|
|
}).join("")}
|
|
</div>
|
|
<div class="actions">
|
|
<button class="secondary" onclick="simulateActuator('${escapeHtml(record.actuator_entity_id)}')">Bestes Szenario berechnen</button>
|
|
</div>
|
|
<div id="simulation-result" class="decision-list"></div>
|
|
</details>
|
|
` : "<p class='muted'>Für die Simulation müssen zuerst Kontextsensoren ausgewählt sein.</p>";
|
|
const currentContextControls = contexts.length
|
|
? `<ul>${contexts.map(entityId => `
|
|
<li>
|
|
<code>${escapeHtml(entityId)}</code>
|
|
<button class="secondary compact" onclick="removeContextEntity('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(entityId)}')">Entfernen</button>
|
|
</li>
|
|
`).join("")}</ul>`
|
|
: "<p class='muted'>Noch keine Kontext-Entity ausgewählt.</p>";
|
|
const prediction = record.behavior.prediction;
|
|
const safety = record.behavior.safety || {};
|
|
const blockers = record.behavior.safety_blockers || [];
|
|
const decisionFactors = record.behavior.decision_factors || [];
|
|
const knowledge = record.behavior.knowledge || [];
|
|
const assumptions = record.behavior.assumptions || [];
|
|
const uncertainties = record.behavior.uncertainties || [];
|
|
const snapshots = record.behavior.model_snapshots || [];
|
|
const activeModelVersion = record.behavior.active_model_version || "";
|
|
const adaptiveUpdates = record.behavior.adaptive_weight_updates || [];
|
|
const automationConflicts = record.behavior.automation_conflicts || [];
|
|
const timeProfiles = record.behavior.time_profiles || [];
|
|
const anomalies = (record.behavior.anomalies || []).filter(item => !item.resolved);
|
|
const safetyControls = `
|
|
<details class="manual-context" open>
|
|
<summary>Sicherheit und manuelles Gegensteuern</summary>
|
|
<div class="inline-controls">
|
|
<div>
|
|
<label for="safety-stage">Freigabestufe</label>
|
|
<select id="safety-stage">
|
|
${["observe", "suggest", "shadow", "partial", "active"].map(stage => `
|
|
<option value="${stage}" ${safety.stage === stage ? "selected" : ""}>${escapeHtml(translate("safety_stage", stage))}</option>
|
|
`).join("")}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="safety-confidence">Mindest-Sicherheit in %</label>
|
|
<input id="safety-confidence" type="number" min="0" max="100" step="1" value="${Math.round((safety.min_confidence ?? 0.82) * 100)}">
|
|
</div>
|
|
<div>
|
|
<label for="safety-cooldown">Cooldown Sekunden</label>
|
|
<input id="safety-cooldown" type="number" min="0" step="10" value="${safety.cooldown_seconds ?? ""}" placeholder="Standard">
|
|
</div>
|
|
</div>
|
|
<label>
|
|
<input id="safety-manual-block" type="checkbox" ${safety.manual_block ? "checked" : ""}>
|
|
Manuelle Sicherheitssperre aktiv
|
|
</label>
|
|
${blockers.length ? `<p class="warn">Aktuelle Blocker: ${blockers.map(escapeHtml).join(" ")}</p>` : "<p class='ok'>Keine lokalen Sicherheitsblocker für die aktuelle Vorhersage.</p>"}
|
|
<button class="secondary" onclick="saveSafetyProfile('${escapeHtml(record.actuator_entity_id)}')">Sicherheitsprofil speichern</button>
|
|
</details>
|
|
`;
|
|
const decisionArchive = `
|
|
<details class="manual-context" open>
|
|
<summary>Entscheidungsakte</summary>
|
|
<div class="grid-two">
|
|
<div>
|
|
<h3>Wissen</h3>
|
|
<ul>${knowledge.map(item => `<li>${escapeHtml(item)}</li>`).join("") || "<li>Keine gesicherten Punkte gespeichert.</li>"}</ul>
|
|
</div>
|
|
<div>
|
|
<h3>Annahmen</h3>
|
|
<ul>${assumptions.map(item => `<li>${escapeHtml(item)}</li>`).join("") || "<li>Keine Annahmen gespeichert.</li>"}</ul>
|
|
</div>
|
|
</div>
|
|
<h3>Unsicherheit</h3>
|
|
<ul>${uncertainties.map(item => `<li>${escapeHtml(item)}</li>`).join("") || "<li>Keine Unsicherheit gespeichert.</li>"}</ul>
|
|
<h3>Beitragsfaktoren</h3>
|
|
<div class="decision-list">
|
|
${decisionFactors.length ? decisionFactors.map(factor => `
|
|
<div class="decision-row">
|
|
<header>
|
|
<strong>${escapeHtml(factor.label)}</strong>
|
|
<span class="chip">${Math.round((factor.contribution || 0) * 100)} % Beitrag</span>
|
|
</header>
|
|
<p class="muted">${escapeHtml(factor.entity_id || factor.factor_type)} · Zustand: ${escapeHtml(factor.state || "offen")} · Gewicht: ${Math.round((factor.weight || 0) * 100)} %</p>
|
|
<p>${(factor.evidence || []).map(escapeHtml).join(" ")}</p>
|
|
</div>
|
|
`).join("") : "<p class='muted'>Noch keine aktuelle Entscheidungsfaktoren berechnet.</p>"}
|
|
</div>
|
|
</details>
|
|
`;
|
|
const adaptivePanel = `
|
|
<details class="manual-context">
|
|
<summary>v1.2 Lernen, Rollback und Konflikte</summary>
|
|
<h3>Zeitprofile</h3>
|
|
<div class="metric-grid">
|
|
${timeProfiles.length ? timeProfiles.map(profile => `
|
|
<div class="metric">
|
|
<strong>${escapeHtml(profile.label)}</strong>
|
|
${escapeHtml(profile.sample_count)} Beispiele · ${escapeHtml(profile.dominant_state || "offen")}
|
|
<p class="muted">${Math.round((profile.confidence || 0) * 100)} % Profilklarheit</p>
|
|
</div>
|
|
`).join("") : "<div class='metric'><strong>Zeitprofile</strong>Noch keine Daten</div>"}
|
|
</div>
|
|
<h3>Modell-Snapshots</h3>
|
|
<div class="decision-list">
|
|
${snapshots.length ? snapshots.slice(-5).reverse().map(snapshot => `
|
|
<div class="decision-row">
|
|
<header>
|
|
<strong>${escapeHtml(snapshot.version_id)}</strong>
|
|
<span class="chip">${snapshot.version_id === activeModelVersion ? "aktiv" : "Rollback möglich"}</span>
|
|
</header>
|
|
<p class="muted">${escapeHtml(snapshot.sample_count)} Beispiele · ${escapeHtml(snapshot.high_confidence_sample_count)} eindeutig · Ø ${Math.round((snapshot.average_confidence || 0) * 100)} %</p>
|
|
<p>${escapeHtml(snapshot.reason || "Kein Kommentar")}</p>
|
|
${snapshot.version_id !== activeModelVersion ? `<button class="secondary compact" onclick="rollbackModel('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(snapshot.version_id)}')">Rollback</button>` : ""}
|
|
</div>
|
|
`).join("") : "<p class='muted'>Noch kein Modell-Snapshot gespeichert.</p>"}
|
|
</div>
|
|
<h3>Automatische Gewichtsanpassungen</h3>
|
|
<ul>${adaptiveUpdates.length ? adaptiveUpdates.slice(-8).reverse().map(update => `
|
|
<li><code>${escapeHtml(update.entity_id)}</code>: ${Math.round(update.previous_weight * 100)} % → ${Math.round(update.new_weight * 100)} %. ${escapeHtml(update.reason)}</li>
|
|
`).join("") : "<li>Noch keine automatische Gewichtsanpassung.</li>"}</ul>
|
|
<h3>Automation-Konflikte</h3>
|
|
<ul>${automationConflicts.length ? automationConflicts.map(conflict => `
|
|
<li><code>${escapeHtml(conflict.automation_entity_id)}</code>: <span class="${conflict.severity === "warning" ? "warn" : "muted"}">${escapeHtml(translate("severity", conflict.severity, conflict.status))}</span> ${escapeHtml(conflict.reason)}</li>
|
|
`).join("") : "<li>Keine aktiven Automation-Konflikte erkannt.</li>"}</ul>
|
|
</details>
|
|
`;
|
|
const anomalyPanel = `
|
|
<details class="manual-context" ${anomalies.length ? "open" : ""}>
|
|
<summary>v1.3 Anomalie- und Performance-Hinweise</summary>
|
|
<div class="decision-list">
|
|
${anomalies.length ? anomalies.map(anomaly => `
|
|
<div class="decision-row ${anomaly.severity === "critical" ? "critical" : ""}">
|
|
<header>
|
|
<strong>${escapeHtml(anomaly.title)}</strong>
|
|
<span class="chip">${escapeHtml(translate("severity", anomaly.severity))} · ${escapeHtml(translate("anomaly_category", anomaly.category, anomaly.category))}</span>
|
|
</header>
|
|
<p>${escapeHtml(anomaly.detail)}</p>
|
|
<p class="muted">Erkannt: ${escapeHtml(formatDateTime(anomaly.detected_at))}</p>
|
|
</div>
|
|
`).join("") : "<p class='ok'>Keine offenen Anomalien fuer diesen Aktor.</p>"}
|
|
</div>
|
|
</details>
|
|
`;
|
|
const learnedAutomationActions = "wird bei Bedarf im Training ausgewertet";
|
|
const relatedAutomations = record.behavior.related_automations || [];
|
|
const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []);
|
|
const numericOptions = contextOptions.filter(entity => entity.domain === "sensor");
|
|
const suggestedIds = new Set(contextOptions.map(entity => entity.entity_id));
|
|
const manualOnlyIds = [
|
|
record.assignment.selected_numeric_entity_id,
|
|
...manualContextIds,
|
|
].filter(entityId => entityId && !suggestedIds.has(entityId));
|
|
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,
|
|
};
|
|
const manualAssignment = `
|
|
<details class="manual-context">
|
|
<summary>Kontext selbst festlegen</summary>
|
|
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
|
|
<select id="manual-numeric-select">
|
|
<option value="">Keinen numerischen Hauptsensor verwenden</option>
|
|
${optionGroups(numericOptions, new Set([record.assignment.selected_numeric_entity_id].filter(Boolean)))}
|
|
</select>
|
|
<div class="inline-controls">
|
|
<div>
|
|
<label for="manual-context-category">Kategorie</label>
|
|
<select id="manual-context-category" onchange="renderManualContextSelect()">
|
|
<option value="">Alle relevanten Vorschläge</option>
|
|
${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="manual-context-filter">Vorschläge durchsuchen</label>
|
|
<input id="manual-context-filter" placeholder="z. B. treppe, bewegung, lux" oninput="renderManualContextSelect()" autocomplete="off">
|
|
</div>
|
|
</div>
|
|
<label for="manual-context-select">Zusätzliche Kontext-Entities aus Vorschlägen</label>
|
|
<select id="manual-context-select" multiple>
|
|
${optionGroups(manualContextState.options.slice(0, 80), manualContextIds)}
|
|
</select>
|
|
<label for="manual-context-freeform">Entity-IDs manuell ergänzen</label>
|
|
<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="hydrateCurrentContextOptions('${escapeHtml(record.actuator_entity_id)}')">Vorschläge neu laden</button>
|
|
</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>
|
|
<button class="secondary" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, false)">SillyHome stoppen; HA-Automationen pausiert lassen</button>`
|
|
: record.behavior.activation_ready
|
|
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen und passende HA-Automationen pausieren</button>
|
|
<button class="secondary" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, false, false)">SillyHome parallel aktivieren</button>`
|
|
: `<p class='warn'>${escapeHtml(record.behavior.activation_reason)}</p>`;
|
|
const automationControls = relatedAutomations.length
|
|
? `<ul>${relatedAutomations.map(automation => `
|
|
<li>
|
|
<strong>${escapeHtml(automation.friendly_name)}</strong>
|
|
<code>${escapeHtml(automation.entity_id)}</code>:
|
|
<span class="${automation.enabled ? "ok" : "warn"}">${automation.enabled ? "aktiv" : "pausiert"}</span>
|
|
<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>`
|
|
: "<p class='muted'>Keine eindeutig passende HA-Automation gefunden.</p>";
|
|
const detailHtml = `
|
|
<div class="detail-header">
|
|
<div>
|
|
<h3>${escapeHtml(record.actuator_entity_id)}</h3>
|
|
</div>
|
|
</div>
|
|
<div class="detail-tools">
|
|
<button class="secondary" onclick="showView('observed')">Zurück zur Übersicht</button>
|
|
<div>
|
|
<label for="detail-actuator-select">Anderes Gerät</label>
|
|
<select id="detail-actuator-select" onchange="selectDetailActuator()">
|
|
${detailActuatorOptions(record.actuator_entity_id)}
|
|
</select>
|
|
</div>
|
|
<button class="secondary" onclick="showActuator('${escapeHtml(record.actuator_entity_id)}', 'Aktualisiert.')">Aktualisieren</button>
|
|
</div>
|
|
<div class="grid-two">
|
|
<div>
|
|
<h3>Zuordnung</h3>
|
|
<p><strong>Status:</strong> <span class="${statusClass(record)}">${escapeHtml(lifecycleLabel(record))}</span></p>
|
|
<p><strong>Kontextzuordnung:</strong> automatisch erledigt</p>
|
|
<p><strong>Zuordnungssicherheit:</strong> ${Math.round(record.assignment.confidence * 100)} %</p>
|
|
<p><strong>Ergebnis:</strong> ${escapeHtml(record.assignment.reason)}</p>
|
|
</div>
|
|
<div>
|
|
<h3>Lernfortschritt</h3>
|
|
<p><strong>Betriebsart:</strong> ${escapeHtml(behaviorLabel(record))}</p>
|
|
<p><strong>Gelernte Handlungen:</strong> ${record.behavior.sample_count}</p>
|
|
<p><strong>Davon eindeutig geregelt:</strong> ${record.behavior.high_confidence_sample_count}</p>
|
|
<p><strong>Erkannte HA-Automationen:</strong> ${escapeHtml(learnedAutomationActions)}</p>
|
|
<p><strong>Letztes Training:</strong> ${escapeHtml(formatDateTime(record.behavior.last_trained_at))}</p>
|
|
<p><strong>Statusgrund:</strong> ${escapeHtml(record.behavior.reason)}</p>
|
|
<p><strong>Freigabestatus:</strong> <span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_reason)}</span></p>
|
|
<div class="actions">${activationButton}</div>
|
|
<button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Aktuelle Situation auswerten</button>
|
|
${evaluationMessage ? `<p class="ok">${escapeHtml(evaluationMessage)}</p>` : ""}
|
|
</div>
|
|
</div>
|
|
<h3>Was SillyHome aktuell vorhersagt</h3>
|
|
${prediction
|
|
? `<p><strong>${escapeHtml(prediction.target_state)}</strong> mit ${Math.round(prediction.confidence * 100)} % Sicherheit. ${escapeHtml(prediction.reason)} <span class="${prediction.executed ? "ok" : "muted"}">${escapeHtml(prediction.execution_reason)}</span></p>`
|
|
: "<p class='muted'>Aktuell ist kein gelerntes Handlungsmuster fällig.</p>"}
|
|
<div class="actions">
|
|
<button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', true)">Vorhersage korrekt</button>
|
|
<button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', false)">Vorhersage falsch</button>
|
|
</div>
|
|
${safetyControls}
|
|
${decisionArchive}
|
|
${adaptivePanel}
|
|
${anomalyPanel}
|
|
<h3>Passende Home-Assistant-Automationen</h3>
|
|
<button class="secondary compact" onclick="refreshRelatedAutomations('${escapeHtml(record.actuator_entity_id)}')">Automationen neu suchen</button>
|
|
${automationControls}
|
|
<h3>Welche Zusammenhänge automatisch verwendet werden</h3>
|
|
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
|
|
<h3>Sensor-Gewichtung</h3>
|
|
${weightControls}
|
|
${weightGroupControls}
|
|
${simulationControls}
|
|
<h3>Verwendete Sensoren/Zustände ändern</h3>
|
|
${currentContextControls}
|
|
${manualAssignment}
|
|
`;
|
|
box.innerHTML = detailHtml;
|
|
cachedDetailHtml.set(actuatorId, detailHtml);
|
|
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
|
renderConfiguredActuators();
|
|
} 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">Lädt ...</p>
|
|
</div>
|
|
</div>
|
|
<div class="metric-grid">
|
|
<div class="metric"><strong>Status</strong>...</div>
|
|
<div class="metric"><strong>Lernen</strong>...</div>
|
|
<div class="metric"><strong>Kontext</strong>...</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 saveWeightOverrides(actuatorId, includeNewGroup = false) {
|
|
const sensorWeights = {};
|
|
for (const input of document.querySelectorAll("[data-weight-entity]")) {
|
|
const value = Number(input.value);
|
|
if (Number.isFinite(value)) {
|
|
sensorWeights[input.dataset.weightEntity] = Math.max(0, Math.min(100, value)) / 100;
|
|
}
|
|
}
|
|
const groups = [...currentSensorWeightGroups];
|
|
if (includeNewGroup) {
|
|
const name = document.getElementById("weight-group-name")?.value.trim();
|
|
const value = Number(document.getElementById("weight-group-value")?.value || 100);
|
|
const entityIds = parseEntityIds(document.getElementById("weight-group-entities")?.value || "");
|
|
if (name && entityIds.length) {
|
|
groups.push({
|
|
group_id: name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "gruppe",
|
|
name,
|
|
entity_ids: entityIds,
|
|
weight: Math.max(0, Math.min(100, Number.isFinite(value) ? value : 100)) / 100,
|
|
});
|
|
}
|
|
}
|
|
try {
|
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/weights`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
sensor_weights: sensorWeights,
|
|
sensor_weight_groups: groups,
|
|
note: "Gewichtung im Dashboard korrigiert",
|
|
}),
|
|
});
|
|
invalidateDashboardCache();
|
|
await loadConfiguredActuators();
|
|
await showActuator(actuatorId, "Sensor-Gewichtung gespeichert.");
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function simulateActuator(actuatorId) {
|
|
const sensorStates = {};
|
|
const sensorWeights = {};
|
|
const stateOptions = {};
|
|
for (const input of document.querySelectorAll("[data-sim-state-entity]")) {
|
|
const value = input.value.trim();
|
|
if (value) sensorStates[input.dataset.simStateEntity] = value;
|
|
}
|
|
for (const input of document.querySelectorAll("[data-sim-weight-entity]")) {
|
|
const value = Number(input.value);
|
|
if (Number.isFinite(value)) {
|
|
sensorWeights[input.dataset.simWeightEntity] = Math.max(0, Math.min(100, value)) / 100;
|
|
}
|
|
}
|
|
for (const input of document.querySelectorAll("[data-sim-options-entity]")) {
|
|
const values = input.value.split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
|
if (values.length) stateOptions[input.dataset.simOptionsEntity] = values;
|
|
}
|
|
const box = document.getElementById("simulation-result");
|
|
box.innerHTML = "<p class='muted'>Simulation läuft ...</p>";
|
|
try {
|
|
const results = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/simulate`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
sensor_states: sensorStates,
|
|
sensor_weights: sensorWeights,
|
|
state_options: stateOptions,
|
|
max_results: 6,
|
|
}),
|
|
});
|
|
box.innerHTML = results.length ? results.map((result, index) => {
|
|
const prediction = result.prediction;
|
|
const factors = result.decision_factors || [];
|
|
return `
|
|
<div class="decision-row">
|
|
<header>
|
|
<strong>${index === 0 ? "Bestes Szenario" : `Szenario ${index + 1}`}</strong>
|
|
<span class="chip">${prediction ? `${Math.round(prediction.confidence * 100)} % · ${escapeHtml(prediction.target_state)}` : "keine Vorhersage"}</span>
|
|
</header>
|
|
<p>${escapeHtml(result.recommendation || "")}</p>
|
|
<p class="muted">Zustände: ${Object.entries(result.sensor_states || {}).map(([entity, state]) => `${escapeHtml(entity)}=${escapeHtml(state)}`).join(", ") || "keine"}</p>
|
|
<p class="muted">Gewichtung: ${Object.entries(result.sensor_weights || {}).map(([entity, weight]) => `${escapeHtml(entity)}=${Math.round(weight * 100)} %`).join(", ") || "Standard"}</p>
|
|
${result.blockers?.length ? `<p class="warn">${result.blockers.map(escapeHtml).join(" ")}</p>` : "<p class='ok'>Würde nach Sicherheitsprüfung schalten.</p>"}
|
|
${factors.length ? `<ul>${factors.slice(0, 4).map(factor => `<li>${escapeHtml(factor.label)}: ${Math.round((factor.contribution || 0) * 100)} % Beitrag</li>`).join("")}</ul>` : ""}
|
|
</div>
|
|
`;
|
|
}).join("") : "<p class='muted'>Keine Simulationsergebnisse.</p>";
|
|
} catch (error) {
|
|
box.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
async function saveManualAssignment(actuatorId) {
|
|
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
|
|
const selectedContextIds = Array.from(
|
|
document.getElementById("manual-context-select").selectedOptions,
|
|
).map(option => option.value).filter(value => value.includes("."));
|
|
const freeformContextIds = parseEntityIds(
|
|
document.getElementById("manual-context-freeform").value,
|
|
);
|
|
const contextEntityIds = [...new Set([...selectedContextIds, ...freeformContextIds])]
|
|
.filter(entityId => entityId !== numericEntityId);
|
|
try {
|
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
numeric_entity_id: numericEntityId,
|
|
context_entity_ids: contextEntityIds,
|
|
note: "Manuell im Dashboard gesetzt",
|
|
}),
|
|
});
|
|
invalidateDashboardCache();
|
|
await loadConfiguredActuators();
|
|
await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert.");
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function removeContextEntity(actuatorId, entityId) {
|
|
try {
|
|
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
|
|
const numericEntityId = record.assignment.selected_numeric_entity_id === entityId
|
|
? null
|
|
: record.assignment.selected_numeric_entity_id;
|
|
const contextEntityIds = (record.assignment.selected_context_entity_ids || [])
|
|
.filter(id => id !== entityId);
|
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
numeric_entity_id: numericEntityId,
|
|
context_entity_ids: contextEntityIds,
|
|
note: `Entity ${entityId} entfernt`,
|
|
}),
|
|
});
|
|
invalidateDashboardCache();
|
|
await loadConfiguredActuators();
|
|
await showActuator(actuatorId, "Kontext-Entity entfernt.");
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function configureSuggestedActuator(actuatorId) {
|
|
document.getElementById("actuator-input").value = actuatorId;
|
|
await configureActuator();
|
|
}
|
|
|
|
async function evaluateActuator(actuatorId) {
|
|
try {
|
|
const record = await api(
|
|
`v1/actuators/${encodeURIComponent(actuatorId)}/evaluate`,
|
|
{method: "POST"},
|
|
);
|
|
const checkedAt = new Date(
|
|
record.behavior.last_evaluated_at || Date.now(),
|
|
).toLocaleString("de-DE");
|
|
const message = record.behavior.prediction
|
|
? `Prüfung ${checkedAt}: ${record.behavior.prediction.target_state} mit ${Math.round(record.behavior.prediction.confidence * 100)} % vorhergesagt.`
|
|
: `Prüfung ${checkedAt}: Kein frischer passender Sensorwechsel erkannt; aktuell ist keine Aktion fällig.`;
|
|
invalidateDashboardCache();
|
|
await loadConfiguredActuators();
|
|
await showActuator(actuatorId, message);
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function sendFeedback(actuatorId, correct) {
|
|
const expectedState = correct ? null : prompt("Welcher Zustand wäre korrekt gewesen? Leer lassen, wenn nur abwerten.");
|
|
try {
|
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/feedback`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
correct,
|
|
expected_state: expectedState || null,
|
|
}),
|
|
});
|
|
invalidateDashboardCache();
|
|
await loadConfiguredActuators();
|
|
await showActuator(actuatorId, correct ? "Vorhersage als korrekt gelernt." : "Vorhersage als falsch markiert.");
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function saveSafetyProfile(actuatorId) {
|
|
const confidence = Number(document.getElementById("safety-confidence")?.value || 82);
|
|
const cooldownRaw = document.getElementById("safety-cooldown")?.value || "";
|
|
const cooldown = cooldownRaw === "" ? null : Math.max(0, Number(cooldownRaw));
|
|
const profile = {
|
|
stage: document.getElementById("safety-stage")?.value || "shadow",
|
|
manual_block: Boolean(document.getElementById("safety-manual-block")?.checked),
|
|
min_confidence: Math.max(0, Math.min(100, Number.isFinite(confidence) ? confidence : 82)) / 100,
|
|
cooldown_seconds: Number.isFinite(cooldown) ? cooldown : null,
|
|
rules: [
|
|
{rule_id: "activation_ready", label: "Nur nach Lernfreigabe aktiv schalten", enabled: true, blocking: true, reason: "Der Aktor muss genug eindeutiges Verhalten gelernt haben."},
|
|
{rule_id: "confidence_threshold", label: "Mindest-Sicherheit einhalten", enabled: true, blocking: true, reason: "Vorhersagen unter der Schaltschwelle bleiben im Prüfmodus."},
|
|
{rule_id: "cooldown", label: "Sicherheits-Cooldown gegen Hin-und-her-Schalten", enabled: true, blocking: true, reason: "Gleiche Zielzustände werden nicht zu schnell wiederholt."},
|
|
{rule_id: "manual_block", label: "Manuelle Sperre respektieren", enabled: true, blocking: true, reason: "Nutzer können jeden Aktor sofort blockieren."},
|
|
],
|
|
note: "Sicherheitsprofil im Dashboard gespeichert",
|
|
};
|
|
try {
|
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/safety`, {
|
|
method: "POST",
|
|
body: JSON.stringify({safety: profile}),
|
|
});
|
|
invalidateDashboardCache();
|
|
await loadConfiguredActuators();
|
|
await showActuator(actuatorId, "Sicherheitsprofil gespeichert.");
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function rollbackModel(actuatorId, versionId) {
|
|
if (!confirm(`${actuatorId}: wirklich auf Modell ${versionId} zurückrollen?`)) return;
|
|
try {
|
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/model/rollback`, {
|
|
method: "POST",
|
|
body: JSON.stringify({version_id: versionId}),
|
|
});
|
|
invalidateDashboardCache();
|
|
await loadConfiguredActuators();
|
|
await showActuator(actuatorId, `Rollback auf ${versionId} ausgeführt.`);
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) {
|
|
const question = active
|
|
? pauseMatchingAutomations
|
|
? `${actuatorId}: SillyHome aktivieren und passende HA-Automationen pausieren?`
|
|
: `${actuatorId}: SillyHome parallel zu den HA-Automationen aktivieren?`
|
|
: restorePausedAutomations
|
|
? `${actuatorId}: SillyHome stoppen und pausierte HA-Automationen fortsetzen?`
|
|
: `${actuatorId}: SillyHome stoppen und HA-Automationen pausiert lassen?`;
|
|
if (!confirm(question)) return;
|
|
try {
|
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/activation`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
active,
|
|
pause_matching_automations: pauseMatchingAutomations,
|
|
restore_paused_automations: restorePausedAutomations,
|
|
}),
|
|
});
|
|
invalidateDashboardCache();
|
|
await loadConfiguredActuators();
|
|
await showActuator(actuatorId);
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function setRelatedAutomation(actuatorId, automationEntityId, enabled) {
|
|
const action = enabled ? "fortsetzen" : "pausieren";
|
|
if (!confirm(`${automationEntityId} wirklich ${action}?`)) return;
|
|
try {
|
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/related-automations/control`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
automation_entity_id: automationEntityId,
|
|
enabled,
|
|
}),
|
|
});
|
|
invalidateDashboardCache();
|
|
await loadConfiguredActuators();
|
|
await showActuator(actuatorId);
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function refreshRelatedAutomations(actuatorId) {
|
|
try {
|
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/related-automations/refresh`, {method: "POST"});
|
|
invalidateDashboardCache();
|
|
await loadConfiguredActuators();
|
|
await showActuator(actuatorId, "Passende Home-Assistant-Automationen neu geprüft.");
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function removeActuator(actuatorId) {
|
|
if (!confirm(`${actuatorId} aus SillyHome entfernen?`)) return;
|
|
try {
|
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}`, {method: "DELETE"});
|
|
if (currentActuatorId === actuatorId) {
|
|
currentActuatorId = null;
|
|
document.getElementById("actuator-detail").textContent = "Öffne bei einem beobachteten Gerät die Details.";
|
|
}
|
|
await loadOverview();
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function startDashboard() {
|
|
document.getElementById("status").innerHTML = "<p class='muted'>Status lädt nach ...</p>";
|
|
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>";
|
|
syncSettingsView();
|
|
const initialView = localStorage.getItem("sillyhome.ui.view") === "detail"
|
|
? "observed"
|
|
: (localStorage.getItem("sillyhome.ui.view") || "status-section");
|
|
document.getElementById("section-jump").value = initialView;
|
|
showView(initialView);
|
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
setTimeout(() => void loadStatus(), 100);
|
|
}
|
|
|
|
void startDashboard();
|
|
</script>
|
|
</body>
|
|
</html>
|