|
|
|
|
@@ -6,27 +6,40 @@
|
|
|
|
|
<title>SillyHome Next</title>
|
|
|
|
|
<style>
|
|
|
|
|
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; scroll-behavior:smooth; }
|
|
|
|
|
body { margin: 0; font-size:16px; }
|
|
|
|
|
header { padding: 22px; background: linear-gradient(135deg,#142b3a,#193f36); }
|
|
|
|
|
body { margin: 0; font-size:15px; }
|
|
|
|
|
header { padding: 18px 20px; background: linear-gradient(135deg,#142b3a,#193f36); }
|
|
|
|
|
h1,h2,h3 { margin: 0 0 12px; }
|
|
|
|
|
header p { margin: 5px 0; color: #c3d1dc; }
|
|
|
|
|
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap: 14px; padding: 14px; }
|
|
|
|
|
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; }
|
|
|
|
|
main { display: grid; grid-template-columns: minmax(280px,.85fr) minmax(0,1.35fr); gap: 12px; padding: 12px; max-width:1380px; margin:0 auto; }
|
|
|
|
|
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 8px; padding: 12px; min-width:0; }
|
|
|
|
|
section:target { outline:2px solid #66dfa9; outline-offset:2px; }
|
|
|
|
|
.wide { grid-column: 1 / -1; }
|
|
|
|
|
.quick-nav { position:sticky; top:0; z-index:10; display:flex; gap:8px; overflow-x:auto; padding:10px 14px; background:rgba(16,21,28,.94); border-bottom:1px solid #2d3a47; backdrop-filter:blur(8px); }
|
|
|
|
|
.quick-nav a { flex:0 0 auto; padding:10px 12px; border-radius:999px; background:#22303c; border:1px solid #31404d; color:#eaf1f8; text-decoration:none; font-weight:700; font-size:.92rem; }
|
|
|
|
|
.quick-nav a.primary { background:#23715b; }
|
|
|
|
|
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
|
|
|
|
|
.step { background:#111a23; border:1px solid #31404d; border-radius:10px; padding:14px; }
|
|
|
|
|
details.collapsible > summary,
|
|
|
|
|
.manual-context > summary,
|
|
|
|
|
.group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; }
|
|
|
|
|
details.collapsible > summary { list-style:none; display:flex; justify-content:space-between; gap:10px; }
|
|
|
|
|
details.collapsible > summary::-webkit-details-marker,
|
|
|
|
|
.manual-context > summary::-webkit-details-marker,
|
|
|
|
|
.group-panel > summary::-webkit-details-marker { display:none; }
|
|
|
|
|
details.collapsible > summary::after,
|
|
|
|
|
.manual-context > summary::after,
|
|
|
|
|
.group-panel > summary::after { content:"aufklappen"; color:#9fb0be; font-weight:600; font-size:.86rem; }
|
|
|
|
|
details[open].collapsible > summary::after,
|
|
|
|
|
.manual-context[open] > summary::after,
|
|
|
|
|
.group-panel[open] > summary::after { content:"zuklappen"; }
|
|
|
|
|
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:10px; margin-top:12px; }
|
|
|
|
|
.step { background:#111a23; border:1px solid #31404d; border-radius:8px; padding:10px; }
|
|
|
|
|
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:#23715b; font-weight:700; margin-bottom:8px; }
|
|
|
|
|
.step p { margin:5px 0; }
|
|
|
|
|
.ok { color: #66dfa9; }
|
|
|
|
|
.warn { color: #f3c969; }
|
|
|
|
|
.bad { color: #ff8f8f; }
|
|
|
|
|
label { display: block; margin: 9px 0 4px; color: #b9c9d6; }
|
|
|
|
|
select,input,button { box-sizing: border-box; width: 100%; border-radius: 10px; border: 1px solid #3b4b5b; padding: 12px; background: #101820; color: #fff; font:inherit; }
|
|
|
|
|
select[multiple] { min-height:190px; }
|
|
|
|
|
select,input,button { box-sizing: border-box; width: 100%; border-radius: 8px; border: 1px solid #3b4b5b; padding: 10px; background: #101820; color: #fff; font:inherit; min-width:0; }
|
|
|
|
|
select[multiple] { min-height:150px; }
|
|
|
|
|
button { min-height:44px; margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
|
|
|
|
|
button.secondary { background: #37495c; }
|
|
|
|
|
button.danger { background: #7b3434; }
|
|
|
|
|
@@ -39,18 +52,18 @@
|
|
|
|
|
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
|
|
|
|
|
.chip { padding:4px 8px; border-radius:999px; background:#22303c; border:1px solid #31404d; font-size:.85rem; }
|
|
|
|
|
.muted { color:#9fb0be; }
|
|
|
|
|
.card-list { display:grid; gap:12px; }
|
|
|
|
|
.actuator-card { background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; }
|
|
|
|
|
.card-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
|
|
|
|
.actuator-card { background:#111a23; border:1px solid #31404d; border-radius:8px; padding:10px; min-width:0; }
|
|
|
|
|
.actuator-card.selected { border-color:#66dfa9; box-shadow:0 0 0 1px rgba(102,223,169,.3); }
|
|
|
|
|
.card-title { display:flex; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:8px; }
|
|
|
|
|
.entity-id { overflow-wrap:anywhere; font-weight:800; }
|
|
|
|
|
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:8px; margin:10px 0; }
|
|
|
|
|
.metric { background:#18212b; border:1px solid #2d3a47; border-radius:10px; padding:10px; }
|
|
|
|
|
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; margin:8px 0; }
|
|
|
|
|
.metric { background:#18212b; border:1px solid #2d3a47; border-radius:8px; padding:8px; min-width:0; }
|
|
|
|
|
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
|
|
|
|
|
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
|
|
|
|
|
.actions button { flex:1 1 180px; margin-top:0; }
|
|
|
|
|
.detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; }
|
|
|
|
|
.manual-context { margin-top:14px; background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; }
|
|
|
|
|
.manual-context { margin-top:12px; background:#111a23; border:1px solid #31404d; border-radius:8px; padding:10px; }
|
|
|
|
|
.inline-controls { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:8px; margin:8px 0; }
|
|
|
|
|
.manual-entry { min-height:80px; resize:vertical; }
|
|
|
|
|
textarea { box-sizing:border-box; width:100%; border-radius:10px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; }
|
|
|
|
|
@@ -59,11 +72,12 @@
|
|
|
|
|
@media (max-width: 760px) {
|
|
|
|
|
header { padding:18px 14px; }
|
|
|
|
|
header h1 { font-size:1.55rem; }
|
|
|
|
|
main { display:block; padding:10px; }
|
|
|
|
|
section { margin-bottom:12px; padding:14px; border-radius:14px; }
|
|
|
|
|
main { display:block; padding:8px; }
|
|
|
|
|
section { margin-bottom:10px; padding:10px; border-radius:8px; }
|
|
|
|
|
.steps { grid-template-columns:1fr; }
|
|
|
|
|
.grid-two { grid-template-columns:1fr; }
|
|
|
|
|
.metric-grid { grid-template-columns:1fr 1fr; }
|
|
|
|
|
.card-list { grid-template-columns:1fr; }
|
|
|
|
|
.actions { display:grid; grid-template-columns:1fr; }
|
|
|
|
|
.actions button, button.compact { width:100%; min-width:0; margin-right:0; }
|
|
|
|
|
.quick-nav { padding:8px 10px; }
|
|
|
|
|
@@ -90,7 +104,8 @@
|
|
|
|
|
</nav>
|
|
|
|
|
<main>
|
|
|
|
|
<section class="wide" id="guide">
|
|
|
|
|
<h2>So gehst du vor</h2>
|
|
|
|
|
<details class="collapsible">
|
|
|
|
|
<summary><span>So gehst du vor</span></summary>
|
|
|
|
|
<div class="steps">
|
|
|
|
|
<div class="step">
|
|
|
|
|
<span class="step-number">1</span>
|
|
|
|
|
@@ -111,6 +126,7 @@
|
|
|
|
|
<p><strong>Was passiert?</strong> Erst dann darf SillyHome passende Vorhersagen automatisch ausführen. Die Freigabe kann jederzeit gestoppt werden.</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</details>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section id="status-section">
|
|
|
|
|
@@ -190,11 +206,18 @@ let cachedActuators = null;
|
|
|
|
|
let cachedEntities = null;
|
|
|
|
|
let cachedDiscovery = null;
|
|
|
|
|
const ACTUATOR_RESULT_LIMIT = 50;
|
|
|
|
|
const STATUS_TIMEOUT_MS = 2500;
|
|
|
|
|
|
|
|
|
|
function uniqueValues(values) {
|
|
|
|
|
return [...new Set(values.filter(Boolean))];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function invalidateDashboardCache() {
|
|
|
|
|
cachedActuators = null;
|
|
|
|
|
cachedEntities = null;
|
|
|
|
|
cachedDiscovery = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(() => ({}));
|
|
|
|
|
@@ -202,8 +225,20 @@ async function api(path, options = {}) {
|
|
|
|
|
return body;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function apiWithTimeout(path, timeoutMs = STATUS_TIMEOUT_MS) {
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
|
|
|
try {
|
|
|
|
|
return await api(path, {signal: controller.signal});
|
|
|
|
|
} finally {
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function lifecycleLabel(record) {
|
|
|
|
|
if (record.behavior.status === "trained") return "Kontext erkannt";
|
|
|
|
|
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: "lernt",
|
|
|
|
|
pending_history: "sammelt Historie",
|
|
|
|
|
@@ -212,26 +247,32 @@ function lifecycleLabel(record) {
|
|
|
|
|
archived: "wartet auf Kontext",
|
|
|
|
|
orphaned: "Aktor nicht gefunden",
|
|
|
|
|
};
|
|
|
|
|
return labels[record.lifecycle.status] || record.lifecycle.status;
|
|
|
|
|
return labels[lifecycleStatus] || lifecycleStatus;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function statusClass(record) {
|
|
|
|
|
if (record.behavior.status === "trained") return "ok";
|
|
|
|
|
if (record.lifecycle.status === "trained") return "ok";
|
|
|
|
|
if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn";
|
|
|
|
|
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) {
|
|
|
|
|
if (record.behavior.mode === "active") return "aktiv freigegeben";
|
|
|
|
|
if (record.behavior.status === "trained") return "Shadow-Vorhersage";
|
|
|
|
|
if (record.behavior.status === "blocked") return "Lernen blockiert";
|
|
|
|
|
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 "Shadow-Vorhersage";
|
|
|
|
|
if (status === "blocked") return "Lernen blockiert";
|
|
|
|
|
return "sammelt Handlungen";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function predictionLabel(record) {
|
|
|
|
|
return record.behavior.prediction
|
|
|
|
|
? `${record.behavior.prediction.target_state} (${Math.round(record.behavior.prediction.confidence * 100)} %)`
|
|
|
|
|
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";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -310,48 +351,83 @@ function optionGroups(entities, selectedIds = new Set()) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadOverview() {
|
|
|
|
|
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
|
|
|
|
|
await loadSummaryData();
|
|
|
|
|
renderConfiguredActuators();
|
|
|
|
|
void loadStatus();
|
|
|
|
|
void loadActuatorDiscovery();
|
|
|
|
|
void loadActuatorSuggestions();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadStatus() {
|
|
|
|
|
const status = document.getElementById("status");
|
|
|
|
|
const chips = document.getElementById("status-chips");
|
|
|
|
|
status.innerHTML = "<p class='muted'>Status wird geprüft ...</p>";
|
|
|
|
|
try {
|
|
|
|
|
const [health, websocket, ml, reconciliation, actuators] = await Promise.all([
|
|
|
|
|
api("health"),
|
|
|
|
|
api("health/websocket"),
|
|
|
|
|
api("ml/health"),
|
|
|
|
|
api("v1/actuators/reconciliation/state"),
|
|
|
|
|
api("v1/actuators"),
|
|
|
|
|
const [health, websocket, ml, reconciliation, actuators] = await Promise.allSettled([
|
|
|
|
|
apiWithTimeout("health"),
|
|
|
|
|
apiWithTimeout("health/websocket"),
|
|
|
|
|
apiWithTimeout("ml/health"),
|
|
|
|
|
apiWithTimeout("v1/actuators/reconciliation/state"),
|
|
|
|
|
apiWithTimeout("v1/actuators/summary"),
|
|
|
|
|
]);
|
|
|
|
|
status.innerHTML = `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliation.last_completed_at || "noch nie")}</p>`;
|
|
|
|
|
const values = [health, websocket, ml, reconciliation, actuators].map(result =>
|
|
|
|
|
result.status === "fulfilled" ? result.value : null
|
|
|
|
|
);
|
|
|
|
|
const [healthValue, websocketValue, mlValue, reconciliationValue, actuatorValue] = values;
|
|
|
|
|
const hasError = values.some(value => value === null);
|
|
|
|
|
status.innerHTML = hasError
|
|
|
|
|
? "<p class='warn'>Status teilweise verfügbar. Das Dashboard bleibt bedienbar.</p>"
|
|
|
|
|
: `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliationValue.last_completed_at || "noch nie")}</p>`;
|
|
|
|
|
chips.innerHTML = [
|
|
|
|
|
`<span class="chip">API: ${escapeHtml(health.status)}</span>`,
|
|
|
|
|
`<span class="chip">WebSocket: ${escapeHtml(websocket.status)}</span>`,
|
|
|
|
|
`<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</span>`,
|
|
|
|
|
`<span class="chip">Aktoren: ${actuators.length}</span>`,
|
|
|
|
|
`<span class="chip">Lernbereite Geräte: ${reconciliation.trained_models}</span>`,
|
|
|
|
|
`<span class="chip">API: ${escapeHtml(healthValue?.status || "offen")}</span>`,
|
|
|
|
|
`<span class="chip">WebSocket: ${escapeHtml(websocketValue?.status || "offen")}</span>`,
|
|
|
|
|
`<span class="chip">Lernsystem: ${escapeHtml(mlValue?.status || "offen")}</span>`,
|
|
|
|
|
`<span class="chip">Aktoren: ${Array.isArray(actuatorValue) ? actuatorValue.length : "offen"}</span>`,
|
|
|
|
|
`<span class="chip">Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}</span>`,
|
|
|
|
|
].join("");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
|
|
|
|
chips.innerHTML = "";
|
|
|
|
|
}
|
|
|
|
|
await loadDashboardData();
|
|
|
|
|
renderActuatorDiscovery();
|
|
|
|
|
renderConfiguredActuators();
|
|
|
|
|
void loadActuatorSuggestions();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadSummaryData() {
|
|
|
|
|
const actuators = await api("v1/actuators/summary");
|
|
|
|
|
cachedActuators = actuators;
|
|
|
|
|
cachedEntities = [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadDashboardData() {
|
|
|
|
|
const [actuators, entities, discovery] = await Promise.all([
|
|
|
|
|
api("v1/actuators"),
|
|
|
|
|
api("v1/entities"),
|
|
|
|
|
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 = entities;
|
|
|
|
|
cachedEntities = [];
|
|
|
|
|
cachedDiscovery = discovery;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadActuatorDiscovery() {
|
|
|
|
|
if (!cachedActuators || !cachedDiscovery) {
|
|
|
|
|
await loadDashboardData();
|
|
|
|
|
const select = document.getElementById("actuator-select");
|
|
|
|
|
if (select && !cachedDiscovery) {
|
|
|
|
|
select.innerHTML = `<option value="">Geräteliste lädt im Hintergrund ...</option>`;
|
|
|
|
|
}
|
|
|
|
|
if (!cachedActuators) {
|
|
|
|
|
await loadSummaryData();
|
|
|
|
|
}
|
|
|
|
|
if (!cachedDiscovery) {
|
|
|
|
|
await loadDiscoveryData();
|
|
|
|
|
}
|
|
|
|
|
renderActuatorDiscovery();
|
|
|
|
|
}
|
|
|
|
|
@@ -370,7 +446,7 @@ function renderActuatorDiscovery() {
|
|
|
|
|
renderActuatorSelect();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
options.innerHTML = "";
|
|
|
|
|
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden</option>`;
|
|
|
|
|
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden: ${escapeHtml(error.message)}</option>`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -509,8 +585,8 @@ async function configureActuator() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadConfiguredActuators() {
|
|
|
|
|
if (!cachedActuators || !cachedEntities) {
|
|
|
|
|
await loadDashboardData();
|
|
|
|
|
if (!cachedActuators) {
|
|
|
|
|
await loadSummaryData();
|
|
|
|
|
}
|
|
|
|
|
renderConfiguredActuators();
|
|
|
|
|
}
|
|
|
|
|
@@ -519,40 +595,38 @@ function renderConfiguredActuators() {
|
|
|
|
|
const box = document.getElementById("configured-actuators");
|
|
|
|
|
try {
|
|
|
|
|
const rows = cachedActuators || [];
|
|
|
|
|
const entities = cachedEntities || [];
|
|
|
|
|
const entityMap = new Map(entities.map(entity => [entity.entity_id, entity]));
|
|
|
|
|
const groups = new Map();
|
|
|
|
|
for (const record of rows) {
|
|
|
|
|
const entity = entityMap.get(record.actuator_entity_id) || {};
|
|
|
|
|
const group = entity.area_name || actuatorGroupLabel(record.actuator_entity_id.split(".", 1)[0]);
|
|
|
|
|
const group = record.area_name || actuatorGroupLabel(record.domain || record.actuator_entity_id.split(".", 1)[0]);
|
|
|
|
|
if (!groups.has(group)) groups.set(group, []);
|
|
|
|
|
groups.get(group).push({record, entity});
|
|
|
|
|
groups.get(group).push({record});
|
|
|
|
|
}
|
|
|
|
|
const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right));
|
|
|
|
|
box.innerHTML = rows.length ? `
|
|
|
|
|
${groupedRows.map(([group, items]) => `
|
|
|
|
|
<h3>${escapeHtml(group)}</h3>
|
|
|
|
|
<details class="group-panel" open>
|
|
|
|
|
<summary>${escapeHtml(group)} (${items.length})</summary>
|
|
|
|
|
<div class="card-list">
|
|
|
|
|
${items.map(({record, entity}) => `
|
|
|
|
|
${items.map(({record}) => `
|
|
|
|
|
<article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}">
|
|
|
|
|
<div class="card-title">
|
|
|
|
|
<div>
|
|
|
|
|
<div><strong>${escapeHtml(entity.friendly_name || record.actuator_entity_id)}</strong></div>
|
|
|
|
|
<div><strong>${escapeHtml(record.friendly_name || record.device_name || record.actuator_entity_id)}</strong></div>
|
|
|
|
|
<div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div>
|
|
|
|
|
<div class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</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.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)}</span></div>
|
|
|
|
|
<div class="metric"><strong>Handlungen</strong>${record.behavior.sample_count}</div>
|
|
|
|
|
<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"
|
|
|
|
|
${record.behavior_mode === "active"
|
|
|
|
|
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>`
|
|
|
|
|
: record.behavior.activation_ready
|
|
|
|
|
: 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>
|
|
|
|
|
@@ -560,6 +634,7 @@ function renderConfiguredActuators() {
|
|
|
|
|
</article>
|
|
|
|
|
`).join("")}
|
|
|
|
|
</div>
|
|
|
|
|
</details>
|
|
|
|
|
`).join("")}` : "<p>Noch keine Aktoren ausgewählt.</p>";
|
|
|
|
|
} catch (error) {
|
|
|
|
|
box.textContent = error.message;
|
|
|
|
|
@@ -614,8 +689,8 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|
|
|
|
selected: manualContextIds,
|
|
|
|
|
};
|
|
|
|
|
const manualAssignment = `
|
|
|
|
|
<div class="manual-context">
|
|
|
|
|
<h3>Kontext selbst festlegen</h3>
|
|
|
|
|
<details class="manual-context" open>
|
|
|
|
|
<summary>Kontext selbst festlegen</summary>
|
|
|
|
|
<p class="muted">Die Vorschläge sind aktorbezogen vorsortiert. Wenn etwas fehlt, trage die Entity-ID unten manuell ein, z. B. PIR, Helligkeit außen, Luftfeuchtigkeit oder Lichtzustände.</p>
|
|
|
|
|
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
|
|
|
|
|
<select id="manual-numeric-select">
|
|
|
|
|
@@ -645,7 +720,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|
|
|
|
<button onclick="saveManualAssignment('${escapeHtml(record.actuator_entity_id)}')">Diese Kontext-Auswahl speichern</button>
|
|
|
|
|
<button class="secondary" onclick="loadContextOptions('${escapeHtml(record.actuator_entity_id)}').then(() => showActuator('${escapeHtml(record.actuator_entity_id)}'))">Vorschläge neu laden</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</details>
|
|
|
|
|
`;
|
|
|
|
|
const activationButton = record.behavior.mode === "active"
|
|
|
|
|
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">SillyHome stoppen und pausierte HA-Automationen fortsetzen</button>
|
|
|
|
|
@@ -737,6 +812,7 @@ async function saveManualAssignment(actuatorId) {
|
|
|
|
|
note: "Manuell im Dashboard gesetzt",
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
invalidateDashboardCache();
|
|
|
|
|
await loadConfiguredActuators();
|
|
|
|
|
await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert.");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
@@ -760,6 +836,7 @@ async function removeContextEntity(actuatorId, entityId) {
|
|
|
|
|
note: `Entity ${entityId} entfernt`,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
invalidateDashboardCache();
|
|
|
|
|
await loadConfiguredActuators();
|
|
|
|
|
await showActuator(actuatorId, "Kontext-Entity entfernt.");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
@@ -784,6 +861,7 @@ async function evaluateActuator(actuatorId) {
|
|
|
|
|
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) {
|
|
|
|
|
@@ -801,6 +879,7 @@ async function sendFeedback(actuatorId, correct) {
|
|
|
|
|
expected_state: expectedState || null,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
invalidateDashboardCache();
|
|
|
|
|
await loadConfiguredActuators();
|
|
|
|
|
await showActuator(actuatorId, correct ? "Vorhersage als korrekt gelernt." : "Vorhersage als falsch markiert.");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
@@ -826,6 +905,7 @@ async function setActivation(actuatorId, active, pauseMatchingAutomations, resto
|
|
|
|
|
restore_paused_automations: restorePausedAutomations,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
invalidateDashboardCache();
|
|
|
|
|
await loadConfiguredActuators();
|
|
|
|
|
await showActuator(actuatorId);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
@@ -844,6 +924,7 @@ async function setRelatedAutomation(actuatorId, automationEntityId, enabled) {
|
|
|
|
|
enabled,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
invalidateDashboardCache();
|
|
|
|
|
await loadConfiguredActuators();
|
|
|
|
|
await showActuator(actuatorId);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|