feat(cockpit): Zahlungsausfall-Banner + past_due-Badge + Health-Check-Button

- Warnbanner oben wenn past_due-Abos vorhanden
- past_due in der Kundentabelle rot hervorgehoben
- 'Health-Check'-Button: prueft Instanzen, zeigt up/down/unknown

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 12:47:24 +02:00
parent d0af7d1d02
commit b79409a252
2 changed files with 50 additions and 2 deletions
+25 -1
View File
@@ -392,7 +392,9 @@
"<td><b>" + esc(a.email) + "</b>" + "<td><b>" + esc(a.email) + "</b>" +
(a.company ? '<div class="muted" style="font-size:12px">' + esc(a.company) + "</div>" : "") + "</td>" + (a.company ? '<div class="muted" style="font-size:12px">' + esc(a.company) + "</div>" : "") + "</td>" +
"<td>" + (a.plan ? '<span class="admin-badge">' + esc(a.plan) + "</span>" + "<td>" + (a.plan ? '<span class="admin-badge">' + esc(a.plan) + "</span>" +
(a.sub_status && a.sub_status !== "active" ? ' <span class="muted" style="font-size:11px">' + esc(a.sub_status) + "</span>" : "") (a.sub_status && a.sub_status !== "active"
? ' <span class="admin-badge ' + (a.sub_status === "past_due" ? "warn" : "") + '" style="font-size:10px">' + esc(a.sub_status) + "</span>"
: "")
: '<span class="muted">—</span>') + "</td>" + : '<span class="muted">—</span>') + "</td>" +
'<td style="text-align:center">' + esc(a.instance_count) + "</td>" + '<td style="text-align:center">' + esc(a.instance_count) + "</td>" +
'<td class="muted" style="font-size:12px">' + esc(new Date(a.created_at).toLocaleDateString("de-CH")) + "</td>" + '<td class="muted" style="font-size:12px">' + esc(new Date(a.created_at).toLocaleDateString("de-CH")) + "</td>" +
@@ -409,8 +411,14 @@
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">' + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">' +
'<div class="hosting-title" style="text-align:left;margin:0">Cockpit</div>' + '<div class="hosting-title" style="text-align:left;margin:0">Cockpit</div>' +
'<div style="display:flex;gap:14px;align-items:center">' + '<div style="display:flex;gap:14px;align-items:center">' +
'<button class="hosting-link" id="ahealth">Health-Check</button>' +
'<button class="hosting-link" id="aexport">CSV-Export</button>' + '<button class="hosting-link" id="aexport">CSV-Export</button>' +
'<button class="hosting-link" id="alogout">Abmelden</button></div></div>' + '<button class="hosting-link" id="alogout">Abmelden</button></div></div>' +
(stats.pastDueSubscriptions
? '<div class="hosting-msg err">⚠ ' + esc(stats.pastDueSubscriptions) +
" Abo(s) mit fehlgeschlagener Zahlung (past_due) — Kunden kontaktieren.</div>"
: "") +
'<div id="healthbar"></div>' +
'<div class="admin-stats">' + '<div class="admin-stats">' +
statCard("Kunden", stats.accounts, "+" + (stats.newAccounts30d || 0) + " (30 T.)") + statCard("Kunden", stats.accounts, "+" + (stats.newAccounts30d || 0) + " (30 T.)") +
statCard("Aktive Abos", stats.activeSubscriptions) + statCard("Aktive Abos", stats.activeSubscriptions) +
@@ -443,6 +451,22 @@
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
} catch (err) { alert(err.message); } } catch (err) { alert(err.message); }
}; };
root.querySelector("#ahealth").onclick = async () => {
const bar = root.querySelector("#healthbar");
bar.innerHTML = '<div class="hosting-msg">Prüfe Instanzen…</div>';
try {
const h = await adminApi("GET", "/admin/health");
if (h.mock) {
bar.innerHTML = '<div class="hosting-msg">Health-Check im Mock-Modus nicht aussagekräftig ' +
"(keine echten Instanzen). " + esc(h.instances.length) + " aktive Instanz(en).</div>";
} else if (h.down > 0) {
bar.innerHTML = '<div class="hosting-msg err">⚠ ' + esc(h.down) + " von " + esc(h.checked) +
" Instanzen nicht erreichbar (down).</div>";
} else {
bar.innerHTML = '<div class="hosting-msg ok">Alle ' + esc(h.checked) + " Instanzen erreichbar.</div>";
}
} catch (err) { bar.innerHTML = '<div class="hosting-msg err">' + esc(err.message) + "</div>"; }
};
const s = root.querySelector("#asearch"); const s = root.querySelector("#asearch");
s.oninput = () => { adminSearch = s.value; const pos = s.selectionStart; paintAdmin(); const n = root.querySelector("#asearch"); n.focus(); n.setSelectionRange(pos, pos); }; s.oninput = () => { adminSearch = s.value; const pos = s.selectionStart; paintAdmin(); const n = root.querySelector("#asearch"); n.focus(); n.setSelectionRange(pos, pos); };
root.querySelector("#afilter").onchange = (e) => { adminPlanFilter = e.target.value; paintAdmin(); }; root.querySelector("#afilter").onchange = (e) => { adminPlanFilter = e.target.value; paintAdmin(); };
+25 -1
View File
@@ -392,7 +392,9 @@
"<td><b>" + esc(a.email) + "</b>" + "<td><b>" + esc(a.email) + "</b>" +
(a.company ? '<div class="muted" style="font-size:12px">' + esc(a.company) + "</div>" : "") + "</td>" + (a.company ? '<div class="muted" style="font-size:12px">' + esc(a.company) + "</div>" : "") + "</td>" +
"<td>" + (a.plan ? '<span class="admin-badge">' + esc(a.plan) + "</span>" + "<td>" + (a.plan ? '<span class="admin-badge">' + esc(a.plan) + "</span>" +
(a.sub_status && a.sub_status !== "active" ? ' <span class="muted" style="font-size:11px">' + esc(a.sub_status) + "</span>" : "") (a.sub_status && a.sub_status !== "active"
? ' <span class="admin-badge ' + (a.sub_status === "past_due" ? "warn" : "") + '" style="font-size:10px">' + esc(a.sub_status) + "</span>"
: "")
: '<span class="muted">—</span>') + "</td>" + : '<span class="muted">—</span>') + "</td>" +
'<td style="text-align:center">' + esc(a.instance_count) + "</td>" + '<td style="text-align:center">' + esc(a.instance_count) + "</td>" +
'<td class="muted" style="font-size:12px">' + esc(new Date(a.created_at).toLocaleDateString("de-CH")) + "</td>" + '<td class="muted" style="font-size:12px">' + esc(new Date(a.created_at).toLocaleDateString("de-CH")) + "</td>" +
@@ -409,8 +411,14 @@
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">' + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">' +
'<div class="hosting-title" style="text-align:left;margin:0">Cockpit</div>' + '<div class="hosting-title" style="text-align:left;margin:0">Cockpit</div>' +
'<div style="display:flex;gap:14px;align-items:center">' + '<div style="display:flex;gap:14px;align-items:center">' +
'<button class="hosting-link" id="ahealth">Health-Check</button>' +
'<button class="hosting-link" id="aexport">CSV-Export</button>' + '<button class="hosting-link" id="aexport">CSV-Export</button>' +
'<button class="hosting-link" id="alogout">Abmelden</button></div></div>' + '<button class="hosting-link" id="alogout">Abmelden</button></div></div>' +
(stats.pastDueSubscriptions
? '<div class="hosting-msg err">⚠ ' + esc(stats.pastDueSubscriptions) +
" Abo(s) mit fehlgeschlagener Zahlung (past_due) — Kunden kontaktieren.</div>"
: "") +
'<div id="healthbar"></div>' +
'<div class="admin-stats">' + '<div class="admin-stats">' +
statCard("Kunden", stats.accounts, "+" + (stats.newAccounts30d || 0) + " (30 T.)") + statCard("Kunden", stats.accounts, "+" + (stats.newAccounts30d || 0) + " (30 T.)") +
statCard("Aktive Abos", stats.activeSubscriptions) + statCard("Aktive Abos", stats.activeSubscriptions) +
@@ -443,6 +451,22 @@
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
} catch (err) { alert(err.message); } } catch (err) { alert(err.message); }
}; };
root.querySelector("#ahealth").onclick = async () => {
const bar = root.querySelector("#healthbar");
bar.innerHTML = '<div class="hosting-msg">Prüfe Instanzen…</div>';
try {
const h = await adminApi("GET", "/admin/health");
if (h.mock) {
bar.innerHTML = '<div class="hosting-msg">Health-Check im Mock-Modus nicht aussagekräftig ' +
"(keine echten Instanzen). " + esc(h.instances.length) + " aktive Instanz(en).</div>";
} else if (h.down > 0) {
bar.innerHTML = '<div class="hosting-msg err">⚠ ' + esc(h.down) + " von " + esc(h.checked) +
" Instanzen nicht erreichbar (down).</div>";
} else {
bar.innerHTML = '<div class="hosting-msg ok">Alle ' + esc(h.checked) + " Instanzen erreichbar.</div>";
}
} catch (err) { bar.innerHTML = '<div class="hosting-msg err">' + esc(err.message) + "</div>"; }
};
const s = root.querySelector("#asearch"); const s = root.querySelector("#asearch");
s.oninput = () => { adminSearch = s.value; const pos = s.selectionStart; paintAdmin(); const n = root.querySelector("#asearch"); n.focus(); n.setSelectionRange(pos, pos); }; s.oninput = () => { adminSearch = s.value; const pos = s.selectionStart; paintAdmin(); const n = root.querySelector("#asearch"); n.focus(); n.setSelectionRange(pos, pos); };
root.querySelector("#afilter").onchange = (e) => { adminPlanFilter = e.target.value; paintAdmin(); }; root.querySelector("#afilter").onchange = (e) => { adminPlanFilter = e.target.value; paintAdmin(); };