288 lines
13 KiB
HTML
288 lines
13 KiB
HTML
|
|
<!doctype html>
|
||
|
|
<html lang="cs">
|
||
|
|
<head>
|
||
|
|
<meta charset="utf-8">
|
||
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
|
|
<meta name="robots" content="noindex,nofollow">
|
||
|
|
<title>Amber — stav služeb</title>
|
||
|
|
<style>
|
||
|
|
/* Amber's palette, matching the other pb_public pages. */
|
||
|
|
:root{
|
||
|
|
--bg:#0e0f13; --panel:#191b21; --panel2:#14161b; --line:#2a2d36;
|
||
|
|
--text:#f2e9d8; --muted:#9aa0aa; --accent:#f0a63c; --accent2:#c9791b;
|
||
|
|
--good:#5fd08a; --bad:#ff6b6b; --warn:#f0a63c; --goldbg:#1b1206;
|
||
|
|
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||
|
|
}
|
||
|
|
*{box-sizing:border-box}
|
||
|
|
body{margin:0;background:var(--bg);color:var(--text);
|
||
|
|
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
|
||
|
|
font-size:15px;line-height:1.5}
|
||
|
|
header{position:sticky;top:0;z-index:5;background:rgba(14,15,19,.94);
|
||
|
|
border-bottom:1px solid var(--line);padding:14px 16px;
|
||
|
|
display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||
|
|
h1{margin:0;font-size:17px;letter-spacing:.02em}
|
||
|
|
h1 span{color:var(--accent)}
|
||
|
|
.grow{flex:1}
|
||
|
|
.stamp{color:var(--muted);font-size:12.5px;font-variant-numeric:tabular-nums}
|
||
|
|
main{padding:16px;max-width:1100px;margin:0 auto;
|
||
|
|
display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}
|
||
|
|
section{background:var(--panel);border:1px solid var(--line);border-radius:12px;
|
||
|
|
padding:14px 16px;min-width:0}
|
||
|
|
section.wide{grid-column:1/-1}
|
||
|
|
h2{margin:0 0 10px;font-size:11.5px;letter-spacing:.10em;text-transform:uppercase;
|
||
|
|
color:var(--muted);font-weight:600}
|
||
|
|
table{width:100%;border-collapse:collapse;font-size:13.5px}
|
||
|
|
th,td{text-align:left;padding:6px 8px 6px 0;border-bottom:1px solid var(--panel2)}
|
||
|
|
th{color:var(--muted);font-weight:500;font-size:11.5px;text-transform:uppercase;
|
||
|
|
letter-spacing:.06em}
|
||
|
|
tr:last-child td{border-bottom:none}
|
||
|
|
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--mono)}
|
||
|
|
code,.mono{font-family:var(--mono);font-size:12.5px}
|
||
|
|
.pill{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11.5px;
|
||
|
|
font-weight:600;white-space:nowrap}
|
||
|
|
.ok{background:rgba(95,208,138,.14);color:var(--good)}
|
||
|
|
.un{background:rgba(154,160,170,.16);color:var(--muted)}
|
||
|
|
.no{background:rgba(255,107,107,.16);color:var(--bad)}
|
||
|
|
.big{font-size:26px;font-weight:600;font-variant-numeric:tabular-nums}
|
||
|
|
.kv{display:flex;gap:18px;flex-wrap:wrap}
|
||
|
|
.kv div{min-width:78px}
|
||
|
|
.kv .lbl{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.06em}
|
||
|
|
button{background:var(--panel2);color:var(--text);border:1px solid var(--line);
|
||
|
|
border-radius:8px;padding:7px 12px;font:inherit;font-size:13px;cursor:pointer}
|
||
|
|
button:hover{border-color:var(--accent2)}
|
||
|
|
button.primary{background:var(--accent);color:var(--goldbg);border-color:var(--accent);font-weight:600}
|
||
|
|
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||
|
|
input{background:var(--panel2);color:var(--text);border:1px solid var(--line);
|
||
|
|
border-radius:8px;padding:9px 11px;font:inherit;width:100%}
|
||
|
|
.note{color:var(--muted);font-size:12.5px;margin:8px 0 0}
|
||
|
|
.warnbox{background:rgba(240,166,60,.10);border:1px solid rgba(240,166,60,.35);
|
||
|
|
border-radius:8px;padding:10px 12px;color:var(--text);font-size:13px;margin-top:8px}
|
||
|
|
pre{margin:8px 0 0;background:#0b0c10;border:1px solid var(--line);border-radius:8px;
|
||
|
|
padding:10px;max-height:340px;overflow:auto;font-family:var(--mono);font-size:11.5px;
|
||
|
|
white-space:pre-wrap;word-break:break-word}
|
||
|
|
.login{max-width:340px;margin:14vh auto;padding:0 16px}
|
||
|
|
.login section{padding:18px}
|
||
|
|
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||
|
|
.svc{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--panel2)}
|
||
|
|
.svc:last-child{border-bottom:none}
|
||
|
|
.svc .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
|
|
.svc a{color:var(--muted);font-size:12px;text-decoration:none}
|
||
|
|
.svc a:hover{color:var(--accent)}
|
||
|
|
.tag{color:var(--accent);font-size:11px;white-space:nowrap}
|
||
|
|
@media (max-width:560px){ main{padding:12px;gap:12px} section{padding:12px} }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
|
||
|
|
<div id="login" class="login" hidden>
|
||
|
|
<section>
|
||
|
|
<h2>Amber — stav služeb</h2>
|
||
|
|
<p class="note" style="margin-bottom:12px">
|
||
|
|
Přihlaš se superuživatelem PocketBase. Tenhle přehled je jen pro správu.
|
||
|
|
</p>
|
||
|
|
<div style="display:grid;gap:8px">
|
||
|
|
<input id="email" type="email" placeholder="e-mail" autocomplete="username">
|
||
|
|
<input id="pass" type="password" placeholder="heslo" autocomplete="current-password">
|
||
|
|
<button class="primary" id="loginBtn">Přihlásit</button>
|
||
|
|
</div>
|
||
|
|
<p class="note" id="loginErr" style="color:var(--bad)"></p>
|
||
|
|
</section>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div id="app" hidden>
|
||
|
|
<header>
|
||
|
|
<h1>Amber <span>·</span> stav služeb</h1>
|
||
|
|
<div class="grow"></div>
|
||
|
|
<span class="stamp" id="stamp"></span>
|
||
|
|
<button id="refresh">Obnovit</button>
|
||
|
|
<button id="logout">Odhlásit</button>
|
||
|
|
</header>
|
||
|
|
<main id="main"></main>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
// Superuser token lives in sessionStorage only: it dies with the tab, and this
|
||
|
|
// page is the one place it is used. The COOLIFY and amber-api secrets never come
|
||
|
|
// near the browser -- that is the whole reason /api/status exists server-side.
|
||
|
|
var KEY = "amber.status.token";
|
||
|
|
var tok = sessionStorage.getItem(KEY) || "";
|
||
|
|
var timer = null;
|
||
|
|
|
||
|
|
function el(id){ return document.getElementById(id); }
|
||
|
|
function esc(s){ return String(s == null ? "" : s).replace(/[&<>"']/g, function(c){
|
||
|
|
return ({"&":"&","<":"<",">":">",'"':""","'":"'"})[c]; }); }
|
||
|
|
|
||
|
|
function bytes(n){
|
||
|
|
if (!n) return "—";
|
||
|
|
var u = ["B","kB","MB","GB"], i = 0, v = Number(n);
|
||
|
|
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
|
||
|
|
return v.toFixed(i ? 1 : 0) + " " + u[i];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Coolify reports "running:healthy" / "running:unknown" / "exited:unhealthy".
|
||
|
|
// "unknown" means no healthcheck is configured, NOT that something is wrong --
|
||
|
|
// worth distinguishing, or every glance looks like a half-broken system.
|
||
|
|
function statusPill(s){
|
||
|
|
var t = String(s || "?");
|
||
|
|
if (t.indexOf("running") === 0) {
|
||
|
|
return t.indexOf("healthy") > 0
|
||
|
|
? '<span class="pill ok">běží</span>'
|
||
|
|
: '<span class="pill un" title="běží, ale bez healthchecku">běží</span>';
|
||
|
|
}
|
||
|
|
return '<span class="pill no">' + esc(t) + "</span>";
|
||
|
|
}
|
||
|
|
|
||
|
|
async function api(path){
|
||
|
|
var r = await fetch(path, { headers: { Authorization: tok } });
|
||
|
|
if (r.status === 401) { logout(); throw new Error("401"); }
|
||
|
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||
|
|
return r.json();
|
||
|
|
}
|
||
|
|
|
||
|
|
function logout(){
|
||
|
|
sessionStorage.removeItem(KEY); tok = "";
|
||
|
|
if (timer) { clearInterval(timer); timer = null; }
|
||
|
|
el("app").hidden = true; el("login").hidden = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
el("loginBtn").onclick = async function(){
|
||
|
|
el("loginErr").textContent = "";
|
||
|
|
try {
|
||
|
|
var r = await fetch("/api/collections/_superusers/auth-with-password", {
|
||
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({ identity: el("email").value, password: el("pass").value }),
|
||
|
|
});
|
||
|
|
if (!r.ok) throw new Error("přihlášení selhalo");
|
||
|
|
tok = (await r.json()).token;
|
||
|
|
sessionStorage.setItem(KEY, tok);
|
||
|
|
el("pass").value = "";
|
||
|
|
start();
|
||
|
|
} catch (e) { el("loginErr").textContent = e.message; }
|
||
|
|
};
|
||
|
|
el("logout").onclick = logout;
|
||
|
|
el("refresh").onclick = function(){ load(); };
|
||
|
|
|
||
|
|
function panelServices(c){
|
||
|
|
if (!c || c.configured === false) {
|
||
|
|
return '<section><h2>Služby</h2><div class="warnbox">' + esc(c && c.why || "nenastaveno") +
|
||
|
|
"</div></section>";
|
||
|
|
}
|
||
|
|
if (c.error) {
|
||
|
|
return '<section><h2>Služby</h2><div class="warnbox">Coolify neodpovědělo: ' +
|
||
|
|
esc(c.error) + "</div></section>";
|
||
|
|
}
|
||
|
|
var rows = (c.apps || []).map(function(a){
|
||
|
|
return '<div class="svc">' + statusPill(a.status) +
|
||
|
|
'<span class="nm">' + esc(a.name) + "</span>" +
|
||
|
|
(a.note ? '<span class="tag">' + esc(a.note) + "</span>" : "") +
|
||
|
|
(a.fqdn ? '<a href="' + esc(a.fqdn.split(",")[0]) + '" target="_blank" rel="noopener">otevřít</a>' : "") +
|
||
|
|
'<button data-log="' + esc(a.uuid) + '">log</button></div>';
|
||
|
|
}).join("");
|
||
|
|
return '<section class="wide"><h2>Služby</h2>' + (rows || '<p class="note">nic</p>') +
|
||
|
|
'<div id="logbox"></div></section>';
|
||
|
|
}
|
||
|
|
|
||
|
|
function panelReleases(rs){
|
||
|
|
if (!rs || rs.error) return '<section><h2>Vydání</h2><p class="note">' +
|
||
|
|
esc(rs && rs.error || "—") + "</p></section>";
|
||
|
|
if (!rs.length) return '<section><h2>Vydání</h2><p class="note">nic zveřejněno</p></section>';
|
||
|
|
var body = rs.map(function(r){
|
||
|
|
return "<tr><td>" + esc(r.platform) + "</td><td>" + esc(r.variant) +
|
||
|
|
"</td><td class='mono'>" + esc(r.version) + "+" + r.build +
|
||
|
|
"</td><td class='mono'>" + esc(r.sha8) + "</td><td class='num'>" + bytes(r.size) + "</td></tr>";
|
||
|
|
}).join("");
|
||
|
|
return '<section class="wide"><h2>Vydání — co se rozdává</h2><table>' +
|
||
|
|
"<tr><th>platforma</th><th>varianta</th><th>verze</th><th>sha256</th><th class='num'>velikost</th></tr>" +
|
||
|
|
body + "</table></section>";
|
||
|
|
}
|
||
|
|
|
||
|
|
function panelClients(c){
|
||
|
|
if (!c || c.error) return '<section><h2>Klienti</h2><p class="note">' +
|
||
|
|
esc(c && c.error || "—") + "</p></section>";
|
||
|
|
var ev = Object.keys(c.byEvent || {}).sort(function(a, b){ return c.byEvent[b] - c.byEvent[a]; })
|
||
|
|
.slice(0, 6).map(function(k){
|
||
|
|
return "<tr><td>" + esc(k) + "</td><td class='num'>" + c.byEvent[k] + "</td></tr>"; }).join("");
|
||
|
|
return '<section><h2>Klienti — 24 h</h2><div class="kv">' +
|
||
|
|
'<div><div class="lbl">záznamů</div><div class="big">' + c.total + "</div></div>" +
|
||
|
|
'<div><div class="lbl">chyb</div><div class="big" style="color:' +
|
||
|
|
(c.errors ? "var(--bad)" : "var(--good)") + '">' + c.errors + "</div></div>" +
|
||
|
|
'<div><div class="lbl">„sekalo se"</div><div class="big" style="color:' +
|
||
|
|
(c.stutterReports ? "var(--warn)" : "var(--muted)") + '">' + c.stutterReports + "</div></div>" +
|
||
|
|
"</div>" + (ev ? "<table style='margin-top:10px'>" + ev + "</table>" : "") +
|
||
|
|
'<p class="note">Hodnocení „sekalo se" při nulových stallech je slepé místo, ' +
|
||
|
|
"které počítadla nevidí — proto je zvlášť.</p></section>";
|
||
|
|
}
|
||
|
|
|
||
|
|
function panelApi(a){
|
||
|
|
if (!a || a.configured === false) {
|
||
|
|
return '<section><h2>amber-api</h2><div class="warnbox">' +
|
||
|
|
esc(a && a.why || "nenastaveno") + "</div></section>";
|
||
|
|
}
|
||
|
|
if (a.error) return '<section><h2>amber-api</h2><div class="warnbox">' +
|
||
|
|
esc(a.error) + "</div></section>";
|
||
|
|
var p = a.probe || {}, q = a.queue || {}, m = a.metadata || {}, h = a.health || {};
|
||
|
|
return '<section><h2>amber-api</h2><div class="kv">' +
|
||
|
|
'<div><div class="lbl">hotové sondy</div><div class="big">' + (p.ready || 0) + "</div></div>" +
|
||
|
|
'<div><div class="lbl">ve frontě</div><div class="big">' + (q.queued || 0) + "</div></div>" +
|
||
|
|
'<div><div class="lbl">selhalo</div><div class="big" style="color:' +
|
||
|
|
((p.failed || p.permafail) ? "var(--warn)" : "var(--muted)") + '">' +
|
||
|
|
((p.failed || 0) + (p.permafail || 0)) + "</div></div>" +
|
||
|
|
"</div><table style='margin-top:10px'>" +
|
||
|
|
"<tr><td>metadata (řádků / zásahů)</td><td class='num'>" + (m.rows || 0) + " / " + (m.hits || 0) + "</td></tr>" +
|
||
|
|
"<tr><td>zdraví zdrojů (klíčů)</td><td class='num'>" + (h.scoredKeys || 0) + "</td></tr>" +
|
||
|
|
"<tr><td>hostů v ochlazení</td><td class='num'>" + (q.hostsCoolingDown || 0) + "</td></tr>" +
|
||
|
|
"</table></section>";
|
||
|
|
}
|
||
|
|
|
||
|
|
function panelAccounts(a){
|
||
|
|
if (!a || a.error) return "";
|
||
|
|
return '<section><h2>Účty</h2><div class="kv">' +
|
||
|
|
'<div><div class="lbl">celkem</div><div class="big">' + a.total + "</div></div>" +
|
||
|
|
'<div><div class="lbl">clean</div><div class="big">' + a.clean + "</div></div>" +
|
||
|
|
'<div><div class="lbl">18+</div><div class="big">' + a.adult + "</div></div>" +
|
||
|
|
"</div></section>";
|
||
|
|
}
|
||
|
|
|
||
|
|
async function showLog(uuid, btn){
|
||
|
|
var box = el("logbox");
|
||
|
|
box.innerHTML = '<p class="note">načítám…</p>';
|
||
|
|
try {
|
||
|
|
var d = await api("/api/status/logs?app=" + encodeURIComponent(uuid) + "&lines=250");
|
||
|
|
var txt = (d.logs || "").trim() || "(prázdné)";
|
||
|
|
box.innerHTML = "<pre>" + esc(txt) + "</pre>";
|
||
|
|
box.querySelector("pre").scrollTop = box.querySelector("pre").scrollHeight;
|
||
|
|
} catch (e) {
|
||
|
|
box.innerHTML = '<div class="warnbox">log se nepodařilo načíst: ' + esc(e.message) + "</div>";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function load(){
|
||
|
|
try {
|
||
|
|
var d = await api("/api/status");
|
||
|
|
el("stamp").textContent = "aktualizováno " + new Date().toLocaleTimeString("cs-CZ");
|
||
|
|
el("main").innerHTML =
|
||
|
|
panelServices(d.coolify) + panelReleases(d.releases) +
|
||
|
|
panelClients(d.clients) + panelApi(d.amberApi) + panelAccounts(d.accounts);
|
||
|
|
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-log]"), function(b){
|
||
|
|
b.onclick = function(){ showLog(b.getAttribute("data-log"), b); };
|
||
|
|
});
|
||
|
|
} catch (e) {
|
||
|
|
if (e.message !== "401") {
|
||
|
|
el("main").innerHTML = '<section class="wide"><div class="warnbox">' +
|
||
|
|
esc(e.message) + "</div></section>";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function start(){
|
||
|
|
el("login").hidden = true; el("app").hidden = false;
|
||
|
|
load();
|
||
|
|
if (timer) clearInterval(timer);
|
||
|
|
timer = setInterval(load, 60000); // a minute is plenty; this is not a metrics system
|
||
|
|
}
|
||
|
|
|
||
|
|
if (tok) start(); else el("login").hidden = false;
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|