356 lines
15 KiB
HTML
356 lines
15 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: system-ui, sans-serif; background: #10151c; color: #eaf1f8; }
|
|
body { margin: 0; }
|
|
header { padding: 20px; background: linear-gradient(135deg,#142b3a,#193f36); }
|
|
h1,h2,h3 { margin: 0 0 12px; }
|
|
header p { margin: 4px 0; color: #b9c9d6; }
|
|
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; }
|
|
.wide { grid-column: 1 / -1; }
|
|
.ok { color: #66dfa9; }
|
|
.warn { color: #f3c969; }
|
|
.bad { color: #ff8f8f; }
|
|
label { display: block; margin: 9px 0 4px; color: #b9c9d6; }
|
|
input,select,textarea,button { box-sizing: border-box; width: 100%; border-radius: 7px; border: 1px solid #3b4b5b; padding: 9px; background: #101820; color: #fff; }
|
|
button { margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
|
|
button.secondary { background: #37495c; }
|
|
pre { white-space: pre-wrap; overflow: auto; background: #0d141b; padding: 10px; border-radius: 7px; }
|
|
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
|
|
td,th { padding: 7px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; }
|
|
ul { margin: 8px 0; padding-left: 18px; }
|
|
.notice { border-left: 4px solid #e8b34b; padding-left: 10px; }
|
|
.grid-two { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:8px; }
|
|
.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; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>SillyHome Next</h1>
|
|
<p>Aktuator-zentrierte Home-Assistant-Analyse mit nachvollziehbarer Sensorzuordnung und kontrolliertem Modell-Lebenszyklus.</p>
|
|
<p class="notice">Sicherheitsmodus: SillyHome führt niemals selbst Aktor-Services aus. Automationen bleiben manuell freizugebende YAML-Entwürfe.</p>
|
|
</header>
|
|
<main>
|
|
<section>
|
|
<h2>Systemstatus</h2>
|
|
<div id="status">Prüfung läuft ...</div>
|
|
<div class="chips" id="status-chips"></div>
|
|
<button class="secondary" onclick="loadOverview()">Neu laden</button>
|
|
<button onclick="runReconciliation()">Reconciliation ausführen</button>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Aktuator wählen</h2>
|
|
<label for="actuator-select">Home-Assistant-Aktor</label>
|
|
<select id="actuator-select"></select>
|
|
<button onclick="configureActuator()">Aktuator übernehmen</button>
|
|
<pre id="actuator-config-result">Noch kein Aktuator konfiguriert.</pre>
|
|
</section>
|
|
|
|
<section class="wide">
|
|
<h2>Konfigurierte Aktuatoren</h2>
|
|
<div id="configured-actuators">Noch nicht geladen.</div>
|
|
</section>
|
|
|
|
<section class="wide">
|
|
<h2>Zuordnung und Modellstatus</h2>
|
|
<div id="actuator-detail">Einen konfigurierten Aktuator auswählen.</div>
|
|
</section>
|
|
|
|
<section class="wide">
|
|
<h2>Automation-Entwurf</h2>
|
|
<p>Der Entwurf muss explizit freigegeben werden. Auch danach wird nur YAML exportiert, nichts geschaltet.</p>
|
|
<div class="grid-two">
|
|
<div><label for="alias">Name</label><input id="alias" value="Licht bei Dunkelheit"></div>
|
|
<div><label for="trigger">Trigger-Entity</label><input id="trigger" placeholder="sensor.flur_illuminance"></div>
|
|
<div><label for="below">Unter Grenzwert</label><input id="below" type="number" value="10"></div>
|
|
<div><label for="service">Dienst</label><select id="service"><option>light.turn_on</option><option>light.turn_off</option><option>switch.turn_on</option><option>switch.turn_off</option></select></div>
|
|
<div><label for="target">Ziel-Entity</label><input id="target" placeholder="light.flur"></div>
|
|
</div>
|
|
<button onclick="createProposal()">Entwurf speichern</button>
|
|
<button class="secondary" onclick="loadProposals()">Entwürfe aktualisieren</button>
|
|
<div id="proposals"></div>
|
|
</section>
|
|
</main>
|
|
<script>
|
|
const pretty = value => JSON.stringify(value, null, 2);
|
|
let currentActuatorId = null;
|
|
|
|
async function api(path, options = {}) {
|
|
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
|
|
const body = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(body.detail || `${response.status} ${response.statusText}`);
|
|
return body;
|
|
}
|
|
|
|
function statusClass(record) {
|
|
if (record.assignment.review_required) return "warn";
|
|
if (record.lifecycle.status === "trained") return "ok";
|
|
if (record.lifecycle.status === "review_required" || record.lifecycle.status === "invalid") return "warn";
|
|
return "bad";
|
|
}
|
|
|
|
function renderEvidence(evidence) {
|
|
return evidence.length ? `<ul>${evidence.map(item => `<li>${item}</li>`).join("")}</ul>` : "<span class='bad'>Keine Evidenz</span>";
|
|
}
|
|
|
|
async function loadOverview() {
|
|
const status = document.getElementById("status");
|
|
const chips = document.getElementById("status-chips");
|
|
try {
|
|
const [health, ml, reconciliation, actuators] = await Promise.all([
|
|
api("health"),
|
|
api("ml/health"),
|
|
api("v1/actuators/reconciliation/state"),
|
|
api("v1/actuators"),
|
|
]);
|
|
status.innerHTML = `<p class="ok">API und ML bereit</p><p>Letzte Reconciliation: ${reconciliation.last_completed_at || "noch nie"}</p><p>${reconciliation.last_summary}</p>`;
|
|
chips.innerHTML = [
|
|
`<span class="chip">Health: ${health.status}</span>`,
|
|
`<span class="chip">ML: ${ml.status}</span>`,
|
|
`<span class="chip">Aktuatoren: ${actuators.length}</span>`,
|
|
`<span class="chip">Trainierte Modelle: ${reconciliation.trained_models}</span>`,
|
|
].join("");
|
|
} catch (error) {
|
|
status.innerHTML = `<p class="bad">${error.message}</p>`;
|
|
chips.innerHTML = "";
|
|
}
|
|
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators(), loadProposals()]);
|
|
}
|
|
|
|
async function loadActuatorDiscovery() {
|
|
const select = document.getElementById("actuator-select");
|
|
try {
|
|
const actuators = await api("v1/actuators/discovery");
|
|
select.innerHTML = actuators.length
|
|
? actuators.map(entity => `<option value="${entity.entity_id}">${entity.friendly_name || entity.entity_id}${entity.area_name ? ` (${entity.area_name})` : ""}</option>`).join("")
|
|
: "<option value=''>Keine Aktuatoren gefunden</option>";
|
|
} catch (error) {
|
|
select.innerHTML = `<option value="">${error.message}</option>`;
|
|
}
|
|
}
|
|
|
|
async function configureActuator() {
|
|
const actuatorId = document.getElementById("actuator-select").value;
|
|
const box = document.getElementById("actuator-config-result");
|
|
if (!actuatorId) return;
|
|
try {
|
|
const record = await api("v1/actuators", {
|
|
method: "POST",
|
|
body: JSON.stringify({actuator_entity_id: actuatorId}),
|
|
});
|
|
currentActuatorId = record.actuator_entity_id;
|
|
box.textContent = pretty(record);
|
|
await loadOverview();
|
|
await showActuator(record.actuator_entity_id);
|
|
} catch (error) {
|
|
box.textContent = error.message;
|
|
}
|
|
}
|
|
|
|
async function runReconciliation() {
|
|
try {
|
|
await api("v1/actuators/reconciliation/run", {method: "POST"});
|
|
await loadOverview();
|
|
if (currentActuatorId) await showActuator(currentActuatorId);
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function loadConfiguredActuators() {
|
|
const box = document.getElementById("configured-actuators");
|
|
try {
|
|
const rows = await api("v1/actuators");
|
|
box.innerHTML = rows.length ? `
|
|
<table>
|
|
<tr><th>Aktuator</th><th>Numerischer Sensor</th><th>Review</th><th>Modellstatus</th><th>Letztes Training</th><th>Aktion</th></tr>
|
|
${rows.map(record => `
|
|
<tr>
|
|
<td>${record.actuator_entity_id}</td>
|
|
<td>${record.assignment.selected_numeric_entity_id || "-"}</td>
|
|
<td class="${record.assignment.review_required ? "warn" : "ok"}">${record.assignment.review_required ? "erforderlich" : "nein"}</td>
|
|
<td class="${statusClass(record)}">${record.lifecycle.status}</td>
|
|
<td>${record.lifecycle.last_trained_at || "-"}</td>
|
|
<td><button onclick="showActuator('${record.actuator_entity_id}')">Details</button></td>
|
|
</tr>
|
|
`).join("")}
|
|
</table>` : "<p>Keine konfigurierten Aktuatoren.</p>";
|
|
} catch (error) {
|
|
box.textContent = error.message;
|
|
}
|
|
}
|
|
|
|
async function showActuator(actuatorId) {
|
|
currentActuatorId = actuatorId;
|
|
const box = document.getElementById("actuator-detail");
|
|
try {
|
|
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
|
|
const numericRows = record.numeric_candidates.map(candidate => `
|
|
<tr>
|
|
<td>${candidate.entity_id}</td>
|
|
<td>${candidate.score.toFixed(3)} / ${candidate.confidence.toFixed(2)}</td>
|
|
<td>${candidate.auto_accepted ? "<span class='ok'>auto</span>" : "<span class='warn'>review</span>"}</td>
|
|
<td>${renderEvidence(candidate.evidence)}</td>
|
|
</tr>
|
|
`).join("");
|
|
const contextRows = record.context_candidates.map(candidate => `
|
|
<tr>
|
|
<td>${candidate.entity_id}</td>
|
|
<td>${candidate.score.toFixed(3)} / ${candidate.confidence.toFixed(2)}</td>
|
|
<td>${candidate.auto_accepted ? "<span class='ok'>auto</span>" : "<span class='warn'>optional</span>"}</td>
|
|
<td>${renderEvidence(candidate.evidence)}</td>
|
|
</tr>
|
|
`).join("");
|
|
box.innerHTML = `
|
|
<div class="grid-two">
|
|
<div>
|
|
<h3>Auswahl</h3>
|
|
<p><strong>Aktuator:</strong> ${record.actuator_entity_id}</p>
|
|
<p><strong>Numerischer Sensor:</strong> ${record.assignment.selected_numeric_entity_id || "-"}</p>
|
|
<p><strong>Kontext:</strong> ${record.assignment.selected_context_entity_ids.join(", ") || "-"}</p>
|
|
<p><strong>Quelle:</strong> ${record.assignment.source}</p>
|
|
<p><strong>Review:</strong> <span class="${record.assignment.review_required ? "warn" : "ok"}">${record.assignment.review_required ? "erforderlich" : "nicht erforderlich"}</span></p>
|
|
<p><strong>Begruendung:</strong> ${record.assignment.reason}</p>
|
|
</div>
|
|
<div>
|
|
<h3>Modell-Lebenszyklus</h3>
|
|
<p><strong>Status:</strong> <span class="${statusClass(record)}">${record.lifecycle.status}</span></p>
|
|
<p><strong>Letztes Training:</strong> ${record.lifecycle.last_trained_at || "-"}</p>
|
|
<p><strong>Messpunkte:</strong> ${record.lifecycle.last_history_point_count}</p>
|
|
<p><strong>Grund:</strong> ${record.lifecycle.reason}</p>
|
|
<p><strong>Nächste Aktion:</strong> ${record.lifecycle.next_action}</p>
|
|
<button onclick="reconcileActuator('${record.actuator_entity_id}')">Diesen Aktuator erneut prüfen</button>
|
|
</div>
|
|
</div>
|
|
<div class="grid-two">
|
|
<div>
|
|
<h3>Manuelle Overrides</h3>
|
|
<label for="override-numeric">Numerischer Sensor</label>
|
|
<input id="override-numeric" value="${record.manual_override?.numeric_entity_id || record.assignment.selected_numeric_entity_id || ""}">
|
|
<label for="override-context">Kontext-Entities (kommagetrennt)</label>
|
|
<textarea id="override-context">${(record.manual_override?.context_entity_ids || record.assignment.selected_context_entity_ids || []).join(", ")}</textarea>
|
|
<label for="override-note">Notiz</label>
|
|
<input id="override-note" value="${record.manual_override?.note || ""}">
|
|
<button onclick="saveOverride('${record.actuator_entity_id}')">Override speichern</button>
|
|
<button class="secondary" onclick="clearOverride('${record.actuator_entity_id}')">Override löschen</button>
|
|
</div>
|
|
<div>
|
|
<h3>Audit</h3>
|
|
<pre>${pretty(record.lifecycle.audit)}</pre>
|
|
</div>
|
|
</div>
|
|
<h3>Numerische Kandidaten</h3>
|
|
${numericRows ? `<table><tr><th>Entity</th><th>Score / Confidence</th><th>Auto</th><th>Evidenz</th></tr>${numericRows}</table>` : "<p>Keine Kandidaten.</p>"}
|
|
<h3>Kontext-Kandidaten</h3>
|
|
${contextRows ? `<table><tr><th>Entity</th><th>Score / Confidence</th><th>Auto</th><th>Evidenz</th></tr>${contextRows}</table>` : "<p>Keine Kandidaten.</p>"}
|
|
`;
|
|
} catch (error) {
|
|
box.textContent = error.message;
|
|
}
|
|
}
|
|
|
|
async function reconcileActuator(actuatorId) {
|
|
try {
|
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/reconcile`, {method: "POST"});
|
|
await loadOverview();
|
|
await showActuator(actuatorId);
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function saveOverride(actuatorId) {
|
|
const numeric = document.getElementById("override-numeric").value.trim() || null;
|
|
const contexts = document.getElementById("override-context").value
|
|
.split(",")
|
|
.map(item => item.trim())
|
|
.filter(Boolean);
|
|
const note = document.getElementById("override-note").value.trim() || null;
|
|
try {
|
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/override`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
numeric_entity_id: numeric,
|
|
context_entity_ids: contexts,
|
|
note,
|
|
}),
|
|
});
|
|
await loadOverview();
|
|
await showActuator(actuatorId);
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function clearOverride(actuatorId) {
|
|
try {
|
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/override`, {
|
|
method: "POST",
|
|
body: JSON.stringify({clear: true}),
|
|
});
|
|
await loadOverview();
|
|
await showActuator(actuatorId);
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function createProposal() {
|
|
try {
|
|
await api("v1/automations/proposals", {method: "POST", body: JSON.stringify({
|
|
alias: document.getElementById("alias").value,
|
|
description: "Manuell im SillyHome-Dashboard erstellter und nicht automatisch ausgeführter Entwurf.",
|
|
trigger: {entity_id: document.getElementById("trigger").value, below: Number(document.getElementById("below").value)},
|
|
action: {service: document.getElementById("service").value, entity_id: document.getElementById("target").value, data: {}}
|
|
})});
|
|
await loadProposals();
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function decide(id, revision, action) {
|
|
try {
|
|
await api(`v1/automations/proposals/${id}/${action}`, {method: "POST", body: JSON.stringify({expected_revision: revision})});
|
|
await loadProposals();
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function loadProposals() {
|
|
const box = document.getElementById("proposals");
|
|
try {
|
|
const rows = await api("v1/automations/proposals");
|
|
box.innerHTML = rows.length ? `
|
|
<table>
|
|
<tr><th>Name</th><th>Status</th><th>Aktion</th></tr>
|
|
${rows.map(item => `
|
|
<tr>
|
|
<td>${item.alias}</td>
|
|
<td>${item.status}</td>
|
|
<td>${item.status === "draft"
|
|
? `<button onclick="decide('${item.proposal_id}',${item.revision},'approve')">Freigeben</button><button class="secondary" onclick="decide('${item.proposal_id}',${item.revision},'reject')">Ablehnen</button>`
|
|
: item.status === "approved"
|
|
? `<a href="v1/automations/proposals/${item.proposal_id}/yaml">YAML laden</a>`
|
|
: "-"}</td>
|
|
</tr>
|
|
`).join("")}
|
|
</table>` : "<p>Keine Entwürfe.</p>";
|
|
} catch (error) {
|
|
box.textContent = error.message;
|
|
}
|
|
}
|
|
|
|
loadOverview();
|
|
</script>
|
|
</body>
|
|
</html>
|