ACT-001: actuator-first sensor lifecycle
This commit is contained in:
@@ -8,63 +8,65 @@
|
||||
: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 { margin: 0 0 12px; }
|
||||
h1,h2,h3 { margin: 0 0 12px; }
|
||||
header p { margin: 4px 0; color: #b9c9d6; }
|
||||
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(310px,1fr)); gap: 14px; padding: 14px; }
|
||||
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; } .bad { color: #ff8f8f; }
|
||||
.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; max-height: 310px; overflow: auto; background: #0d141b; padding: 10px; border-radius: 7px; }
|
||||
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; }
|
||||
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>Lokale Home-Assistant-Analyse, Vorhersagen und kontrollierte Automation-Entwürfe.</p>
|
||||
<p class="notice">Sicherheitsmodus: Entwürfe werden niemals automatisch in Home Assistant ausgeführt.</p>
|
||||
<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>
|
||||
<button class="secondary" onclick="loadStatus()">Neu laden</button>
|
||||
<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>Entity Discovery</h2>
|
||||
<label for="domain">Domain (optional)</label>
|
||||
<input id="domain" placeholder="sensor">
|
||||
<button onclick="discover()">HA-Entities analysieren</button>
|
||||
<pre id="discovery">Noch nicht geladen.</pre>
|
||||
<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>
|
||||
<h2>Modell trainieren</h2>
|
||||
<label for="train-model">Modell-ID</label><input id="train-model" value="home-model">
|
||||
<label for="train-sensor">Sensor</label><input id="train-sensor" placeholder="sensor.temperatur">
|
||||
<label for="train-feature">Merkmal</label><input id="train-feature" value="value">
|
||||
<label for="train-values">Messwerte, komma-getrennt</label><input id="train-values" placeholder="19,20,21">
|
||||
<button onclick="train()">Trainieren</button>
|
||||
<pre id="training">Bereit.</pre>
|
||||
|
||||
<section class="wide">
|
||||
<h2>Konfigurierte Aktuatoren</h2>
|
||||
<div id="configured-actuators">Noch nicht geladen.</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Vorhersage</h2>
|
||||
<label for="predict-model">Modell-ID</label><input id="predict-model" value="home-model">
|
||||
<label for="predict-sensor">Sensor</label><input id="predict-sensor" placeholder="sensor.temperatur">
|
||||
<label for="predict-feature">Merkmal</label><input id="predict-feature" value="value">
|
||||
<label for="predict-value">Aktueller Wert</label><input id="predict-value" type="number" step="any">
|
||||
<button onclick="predict()">Vorhersagen und erklären</button>
|
||||
<pre id="prediction">Bereit.</pre>
|
||||
|
||||
<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 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">
|
||||
<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>
|
||||
@@ -78,72 +80,276 @@
|
||||
</main>
|
||||
<script>
|
||||
const pretty = value => JSON.stringify(value, null, 2);
|
||||
async function api(path, options={}) {
|
||||
const response = await fetch(path, {headers: {"Content-Type":"application/json"}, ...options});
|
||||
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;
|
||||
}
|
||||
async function loadStatus() {
|
||||
const box=document.getElementById("status");
|
||||
try {
|
||||
const [health, ml, models]=await Promise.all([api("health"),api("ml/health"),api("ml/models")]);
|
||||
box.innerHTML=`<p class="ok">API und ML bereit</p><p>Modelle: ${models.models.length}</p>`;
|
||||
} catch(e) { box.innerHTML=`<p class="bad">${e.message}</p>`; }
|
||||
|
||||
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";
|
||||
}
|
||||
async function discover() {
|
||||
const out=document.getElementById("discovery"), domain=document.getElementById("domain").value.trim();
|
||||
out.textContent="Lade ...";
|
||||
try {
|
||||
const rows=await api(`v1/discovery?learnable=true${domain?`&domain=${encodeURIComponent(domain)}`:""}`);
|
||||
out.textContent=pretty({learnable_entities:rows.length, entities:rows.slice(0,100)});
|
||||
} catch(e) { out.textContent=e.message; }
|
||||
|
||||
function renderEvidence(evidence) {
|
||||
return evidence.length ? `<ul>${evidence.map(item => `<li>${item}</li>`).join("")}</ul>` : "<span class='bad'>Keine Evidenz</span>";
|
||||
}
|
||||
async function train() {
|
||||
const out=document.getElementById("training");
|
||||
|
||||
async function loadOverview() {
|
||||
const status = document.getElementById("status");
|
||||
const chips = document.getElementById("status-chips");
|
||||
try {
|
||||
const values=document.getElementById("train-values").value.split(",").map(Number).filter(Number.isFinite);
|
||||
if (!values.length) throw new Error("Mindestens einen Messwert eingeben.");
|
||||
const sensor=document.getElementById("train-sensor").value.trim(), feature=document.getElementById("train-feature").value.trim();
|
||||
const samples=values.map(value=>({sensor_id:sensor,values:{[feature]:value}}));
|
||||
out.textContent=pretty(await api("ml/retrain",{method:"POST",body:JSON.stringify({modelId:document.getElementById("train-model").value,samples})}));
|
||||
await loadStatus();
|
||||
} catch(e) { out.textContent=e.message; }
|
||||
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 predict() {
|
||||
const out=document.getElementById("prediction");
|
||||
|
||||
async function loadActuatorDiscovery() {
|
||||
const select = document.getElementById("actuator-select");
|
||||
try {
|
||||
const feature=document.getElementById("predict-feature").value.trim();
|
||||
out.textContent=pretty(await api("ml/predict",{method:"POST",body:JSON.stringify({
|
||||
modelId:document.getElementById("predict-model").value,
|
||||
sensor_id:document.getElementById("predict-sensor").value.trim(),
|
||||
values:{[feature]:Number(document.getElementById("predict-value").value)}
|
||||
})}));
|
||||
} catch(e) { out.textContent=e.message; }
|
||||
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 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(e) { alert(e.message); }
|
||||
} 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(e) { alert(e.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(x=>`<tr><td>${x.alias}</td><td>${x.status}</td><td>${x.status==="draft"?`<button onclick="decide('${x.proposal_id}',${x.revision},'approve')">Freigeben</button><button class="secondary" onclick="decide('${x.proposal_id}',${x.revision},'reject')">Ablehnen</button>`:`${x.status==="approved"?`<a href="v1/automations/proposals/${x.proposal_id}/yaml">YAML laden</a>`:"-"}`}</td></tr>`).join("")}</table>`:"<p>Keine Entwürfe.</p>";
|
||||
} catch(e) { box.textContent=e.message; }
|
||||
await api(`v1/automations/proposals/${id}/${action}`, {method: "POST", body: JSON.stringify({expected_revision: revision})});
|
||||
await loadProposals();
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
loadStatus(); loadProposals();
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user