Surface integrity and watchdog state in the read-only dashboard via a new /api/integrity endpoint and integrity_report(): watchdog/heartbeat verdict, FIM baseline and package-DB anchor freshness, pacman keyring and SigLevel posture, and Sentinel's own self-integrity footprint. The endpoint summarizes existing anchors and heartbeats only — it runs no live FIM or package verification from the request path, keeping the dashboard read-only. Add the enodia.integrity.v1 schema (schemas.py + docs/SCHEMAS.md) with a contract test, a new "Integrity" dashboard tab and summary metric, and web tests for the report shape, schema contract, endpoint, and console wiring. Docs updated; completes the v1.0 "dashboard to local console" roadmap item. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
729 lines
37 KiB
HTML
729 lines
37 KiB
HTML
<!DOCTYPE html>
|
|
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Enodia Sentinel</title>
|
|
<script>
|
|
(function(){
|
|
try{
|
|
var theme = localStorage.getItem("sentinel.theme") || "console";
|
|
document.documentElement.dataset.theme = theme;
|
|
}catch(_e){ document.documentElement.dataset.theme = "console"; }
|
|
})();
|
|
</script>
|
|
<style>
|
|
:root{
|
|
--bg:#0a0c0f; --ink:#d8dee7; --muted:#8590a3; --soft:#aeb7c7;
|
|
--panel:#11151b; --panel2:#151b23; --rail:#0d1117; --line:#26313f;
|
|
--accent:#22c7a9; --amber:#f2b84b; --red:#f06476; --blue:#61a8ff;
|
|
--violet:#b68cff; --copper:#d8925b; --ok:#56d364; --white:#fff;
|
|
--header-bg:rgba(13,17,23,.96); --aside-bg:rgba(13,17,23,.9);
|
|
--work-bg:linear-gradient(180deg,#0f1319,#0b0d11);
|
|
--plan-bg:rgba(17,21,27,.92); --code-bg:#080a0d;
|
|
--button-bg:var(--panel2); --active-ink:#00110e; --focus:#f7d86a;
|
|
--grid-a:rgba(255,255,255,.06); --grid-b:rgba(255,255,255,.05);
|
|
--signal:linear-gradient(90deg,var(--accent),var(--blue),var(--copper),var(--red));
|
|
--shadow:0 18px 48px rgba(0,0,0,.28);
|
|
}
|
|
:root[data-theme="paper"]{
|
|
--bg:#edf2f7; --ink:#1b2633; --muted:#627084; --soft:#33445a;
|
|
--panel:#ffffff; --panel2:#f7fafc; --rail:#e4ebf3; --line:#becada;
|
|
--accent:#167b83; --amber:#a65f13; --red:#b43449; --blue:#2764ba;
|
|
--violet:#6f56b8; --copper:#8b542c; --ok:#247c45; --white:#07111c;
|
|
--header-bg:rgba(248,251,254,.98); --aside-bg:rgba(232,239,247,.94);
|
|
--work-bg:linear-gradient(180deg,#f8fbfe,#edf2f7);
|
|
--plan-bg:rgba(245,248,252,.96); --code-bg:#e8eef5;
|
|
--button-bg:#ffffff; --active-ink:#ffffff; --focus:#7b3ff3;
|
|
--grid-a:rgba(11,31,52,.08); --grid-b:rgba(11,31,52,.06);
|
|
--signal:linear-gradient(90deg,#167b83,#2764ba,#8b542c,#b43449);
|
|
--shadow:0 18px 34px rgba(30,46,64,.12);
|
|
}
|
|
:root[data-theme="contrast"]{
|
|
--bg:#000; --ink:#f4f7fb; --muted:#c3ccd8; --soft:#e1e7ef;
|
|
--panel:#101010; --panel2:#181818; --rail:#060606; --line:#f4f7fb;
|
|
--accent:#00e5ff; --amber:#ffd84d; --red:#ff4f69; --blue:#7db3ff;
|
|
--violet:#d5a3ff; --copper:#ffb15f; --ok:#74ff91; --white:#fff;
|
|
--header-bg:#000; --aside-bg:#050505; --work-bg:#000; --plan-bg:#090909;
|
|
--code-bg:#000; --button-bg:#121212; --active-ink:#000; --focus:#ffd84d;
|
|
--grid-a:rgba(255,255,255,.11); --grid-b:rgba(255,255,255,.09);
|
|
--signal:linear-gradient(90deg,#00e5ff,#f4f7fb,#ffd84d,#ff4f69);
|
|
--shadow:none;
|
|
}
|
|
:root[data-theme="pride"]{
|
|
--bg:#111018; --ink:#faf8ff; --muted:#b8b1c9; --soft:#e0d9ef;
|
|
--panel:#1b1924; --panel2:#252232; --rail:#16131f; --line:#3d3651;
|
|
--accent:#ff8c00; --amber:#ffed00; --red:#ff5f6d; --blue:#73b7ff;
|
|
--violet:#b98cff; --copper:#ff8c00; --ok:#00c853; --white:#fff;
|
|
--header-bg:rgba(17,16,24,.97); --aside-bg:rgba(22,19,31,.94);
|
|
--work-bg:linear-gradient(180deg,#1a1724,#111018);
|
|
--plan-bg:rgba(27,25,36,.95); --code-bg:#0d0c12;
|
|
--button-bg:#252232; --active-ink:#111018; --focus:#ffed00;
|
|
--grid-a:rgba(255,255,255,.07); --grid-b:rgba(255,255,255,.05);
|
|
--signal:linear-gradient(90deg,#e40303,#ff8c00,#ffed00,#008026,#24408e,#732982);
|
|
--shadow:0 18px 46px rgba(0,0,0,.32);
|
|
}
|
|
:root[data-theme="trans"]{
|
|
--bg:#55CDFC; --ink:#000000; --muted:#000000; --soft:#000000;
|
|
--panel:#FFFFFF; --panel2:#F7A8B8; --rail:#55CDFC; --line:#F7A8B8;
|
|
--accent:#55CDFC; --amber:#000000; --red:#000000; --blue:#000000;
|
|
--violet:#F7A8B8; --copper:#F7A8B8; --ok:#000000; --white:#000000;
|
|
--header-bg:linear-gradient(90deg,#55CDFC 0 20%,#F7A8B8 20% 40%,#FFFFFF 40% 60%,#F7A8B8 60% 80%,#55CDFC 80%);
|
|
--aside-bg:linear-gradient(180deg,#FFFFFF,#dff6ff 48%,#ffe7ee);
|
|
--work-bg:linear-gradient(180deg,#FFFFFF,#dff6ff 52%,#ffe7ee);
|
|
--plan-bg:linear-gradient(180deg,#FFFFFF,#ffe7ee);
|
|
--code-bg:#FFFFFF;
|
|
--button-bg:#FFFFFF; --active-ink:#000000; --focus:#F7A8B8;
|
|
--grid-a:rgba(23,37,54,.13); --grid-b:rgba(23,37,54,.09);
|
|
--signal:linear-gradient(90deg,#55CDFC,#F7A8B8,#FFFFFF,#F7A8B8,#55CDFC);
|
|
--shadow:0 18px 38px rgba(85,205,252,.22);
|
|
}
|
|
:root[data-theme="dracula"]{
|
|
--bg:#282a36; --ink:#f8f8f2; --muted:#b6adc8; --soft:#e6dcff;
|
|
--panel:#303241; --panel2:#3b3d4d; --rail:#21222c; --line:#44475a;
|
|
--accent:#bd93f9; --amber:#f1fa8c; --red:#ff6e6e; --blue:#8be9fd;
|
|
--violet:#ff79c6; --copper:#ffb86c; --ok:#50fa7b; --white:#fff;
|
|
--header-bg:rgba(33,34,44,.97); --aside-bg:rgba(33,34,44,.94);
|
|
--work-bg:linear-gradient(180deg,#303241,#282a36);
|
|
--plan-bg:rgba(48,50,65,.95); --code-bg:#1f2029;
|
|
--button-bg:#3b3d4d; --active-ink:#211f2a; --focus:#f1fa8c;
|
|
--grid-a:rgba(248,248,242,.07); --grid-b:rgba(189,147,249,.06);
|
|
--signal:linear-gradient(90deg,#bd93f9,#ff79c6,#ff5555,#ffb86c,#50fa7b,#8be9fd);
|
|
--shadow:0 18px 46px rgba(18,19,25,.38);
|
|
}
|
|
:root[data-theme="solarized-dark"]{
|
|
--bg:#002b36; --ink:#93a1a1; --muted:#93a1a1; --soft:#eee8d5;
|
|
--panel:#073642; --panel2:#0b3d49; --rail:#00212a; --line:#586e75;
|
|
--accent:#2aa198; --amber:#d2a600; --red:#ff6b61; --blue:#5fb3e8;
|
|
--violet:#9aa0ff; --copper:#e27a3f; --ok:#a8b400; --white:#fdf6e3;
|
|
--header-bg:rgba(0,43,54,.97); --aside-bg:rgba(0,33,42,.94);
|
|
--work-bg:linear-gradient(180deg,#073642,#002b36);
|
|
--plan-bg:rgba(7,54,66,.95); --code-bg:#00212a;
|
|
--button-bg:#073642; --active-ink:#002b36; --focus:#b58900;
|
|
--grid-a:rgba(147,161,161,.09); --grid-b:rgba(42,161,152,.06);
|
|
--signal:linear-gradient(90deg,#2aa198,#268bd2,#6c71c4,#b58900,#cb4b16,#dc322f);
|
|
--shadow:0 18px 46px rgba(0,24,30,.36);
|
|
}
|
|
:root[data-theme="solarized-light"]{
|
|
--bg:#fdf6e3; --ink:#586e75; --muted:#586e75; --soft:#073642;
|
|
--panel:#fffdf4; --panel2:#eee8d5; --rail:#f4efdc; --line:#93a1a1;
|
|
--accent:#2aa198; --amber:#a15f00; --red:#dc322f; --blue:#1769aa;
|
|
--violet:#5f5fb2; --copper:#7c4a12; --ok:#597000; --white:#002b36;
|
|
--header-bg:rgba(253,246,227,.98); --aside-bg:rgba(244,239,220,.96);
|
|
--work-bg:linear-gradient(180deg,#fffdf4,#fdf6e3);
|
|
--plan-bg:rgba(238,232,213,.96); --code-bg:#eee8d5;
|
|
--button-bg:#fffdf4; --active-ink:#002b36; --focus:#6c71c4;
|
|
--grid-a:rgba(88,110,117,.1); --grid-b:rgba(42,161,152,.07);
|
|
--signal:linear-gradient(90deg,#2aa198,#268bd2,#6c71c4,#b58900,#cb4b16,#dc322f);
|
|
--shadow:0 18px 34px rgba(88,110,117,.14);
|
|
}
|
|
:root[data-theme="twilight"]{
|
|
--bg:#141414; --ink:#f8f8f8; --muted:#9b9b9b; --soft:#d7d7d7;
|
|
--panel:#1e1e1e; --panel2:#272727; --rail:#111; --line:#3f3a32;
|
|
--accent:#cda869; --amber:#f9ee98; --red:#cf6a4c; --blue:#7587a6;
|
|
--violet:#9b859d; --copper:#cf7d34; --ok:#8f9d6a; --white:#fff;
|
|
--header-bg:rgba(20,20,20,.98); --aside-bg:rgba(17,17,17,.95);
|
|
--work-bg:linear-gradient(180deg,#1e1e1e,#141414);
|
|
--plan-bg:rgba(30,30,30,.96); --code-bg:#0e0e0e;
|
|
--button-bg:#272727; --active-ink:#141414; --focus:#f9ee98;
|
|
--grid-a:rgba(248,248,248,.07); --grid-b:rgba(205,168,105,.06);
|
|
--signal:linear-gradient(90deg,#cda869,#8f9d6a,#7587a6,#9b859d,#cf6a4c);
|
|
--shadow:0 18px 46px rgba(0,0,0,.4);
|
|
}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;background:var(--bg);color:var(--ink);
|
|
font:14px/1.45 "Aptos Mono","IBM Plex Mono",ui-monospace,Menlo,Consolas,monospace}
|
|
body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.18;
|
|
background-image:linear-gradient(var(--grid-a) 1px,transparent 1px),
|
|
linear-gradient(90deg,var(--grid-b) 1px,transparent 1px);
|
|
background-size:28px 28px}
|
|
button{font:inherit;color:inherit;background:var(--button-bg);border:1px solid var(--line);
|
|
border-radius:6px;padding:8px 10px;cursor:pointer}
|
|
button:hover{border-color:var(--accent);color:var(--white)}
|
|
button:focus-visible{outline:2px solid var(--focus);outline-offset:2px}
|
|
header{height:58px;display:flex;align-items:center;gap:16px;padding:0 18px;
|
|
border-bottom:1px solid var(--line);background:var(--header-bg);position:relative;z-index:2}
|
|
header:after{content:"";position:absolute;left:0;right:0;bottom:-1px;height:2px;background:var(--signal)}
|
|
h1{font-size:15px;margin:0;color:var(--white);letter-spacing:.16em;text-transform:uppercase}
|
|
.pill{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);
|
|
border-radius:999px;padding:4px 9px;color:var(--soft);font-size:12px}
|
|
.dot{width:9px;height:9px;border-radius:50%;background:var(--red);box-shadow:0 0 12px var(--red)}
|
|
.dot.up{background:var(--ok);box-shadow:0 0 12px var(--ok)}
|
|
.spacer{flex:1}
|
|
.tls{color:var(--accent)}
|
|
.settings-wrap{position:relative}
|
|
.settings-menu{position:absolute;right:0;top:calc(100% + 10px);width:min(430px,calc(100vw - 24px));
|
|
max-height:calc(100vh - 84px);overflow:auto;z-index:8;background:var(--panel);
|
|
border:1px solid var(--line);border-radius:8px;box-shadow:var(--shadow);padding:12px}
|
|
.settings-menu[hidden]{display:none}
|
|
.settings-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:10px}
|
|
.settings-title{font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
|
|
.settings-current{margin-top:4px;color:var(--white)}
|
|
.settings-close{padding:4px 8px;line-height:1}
|
|
.theme-switch{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
|
|
.theme-btn{display:grid;grid-template-columns:18px 1fr;align-items:center;gap:8px;min-width:0;
|
|
padding:9px;border-color:var(--line);background:var(--panel2);color:var(--soft);text-align:left}
|
|
.theme-btn.active{background:var(--panel2);border-color:var(--accent);color:var(--white);
|
|
box-shadow:inset 3px 0 0 var(--accent)}
|
|
.swatch{width:16px;height:16px;border-radius:4px;border:1px solid var(--line);background:var(--signal)}
|
|
.theme-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.theme-btn[data-theme="console"] .swatch{background:linear-gradient(135deg,#0a0c0f 0 45%,#22c7a9 45% 65%,#d8925b 65%)}
|
|
.theme-btn[data-theme="paper"] .swatch{background:linear-gradient(135deg,#edf2f7 0 45%,#167b83 45% 65%,#b43449 65%)}
|
|
.theme-btn[data-theme="contrast"] .swatch{background:linear-gradient(135deg,#000 0 45%,#00e5ff 45% 65%,#ffd84d 65%)}
|
|
.theme-btn[data-theme="pride"] .swatch{background:linear-gradient(135deg,#e40303 0 17%,#ff8c00 17% 34%,#ffed00 34% 51%,#008026 51% 68%,#24408e 68% 84%,#732982 84%)}
|
|
.theme-btn[data-theme="trans"] .swatch{background:linear-gradient(135deg,#55CDFC 0 20%,#F7A8B8 20% 40%,#FFFFFF 40% 60%,#F7A8B8 60% 80%,#55CDFC 80%)}
|
|
.theme-btn[data-theme="dracula"] .swatch{background:linear-gradient(135deg,#282a36 0 40%,#bd93f9 40% 58%,#ff79c6 58% 74%,#50fa7b 74%)}
|
|
.theme-btn[data-theme="solarized-dark"] .swatch{background:linear-gradient(135deg,#002b36 0 44%,#2aa198 44% 62%,#b58900 62% 78%,#dc322f 78%)}
|
|
.theme-btn[data-theme="solarized-light"] .swatch{background:linear-gradient(135deg,#fdf6e3 0 44%,#2aa198 44% 62%,#b58900 62% 78%,#dc322f 78%)}
|
|
.theme-btn[data-theme="twilight"] .swatch{background:linear-gradient(135deg,#141414 0 44%,#cda869 44% 62%,#8f9d6a 62% 78%,#cf6a4c 78%)}
|
|
.layout{display:grid;grid-template-columns:330px minmax(420px,1fr) 390px;
|
|
height:calc(100vh - 58px);position:relative;z-index:1}
|
|
aside,.work,.plan{min-height:0;overflow:auto}
|
|
aside{background:var(--aside-bg);border-right:1px solid var(--line)}
|
|
.work{background:var(--work-bg);padding:18px}
|
|
.plan{background:var(--plan-bg);border-left:1px solid var(--line);padding:16px}
|
|
.section-title{display:flex;align-items:center;justify-content:space-between;
|
|
padding:13px 14px 8px;color:var(--muted);font-size:11px;letter-spacing:.12em;
|
|
text-transform:uppercase}
|
|
.metrics{display:grid;grid-template-columns:repeat(6,1fr);gap:10px;margin-bottom:16px}
|
|
.metric{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px}
|
|
.metric b{display:block;font-size:24px;line-height:1;color:var(--white)}
|
|
.metric span{display:block;color:var(--muted);font-size:11px;margin-top:8px;text-transform:uppercase}
|
|
.metric.crit b{color:var(--red)} .metric.high b{color:var(--amber)} .metric.med b{color:var(--blue)}
|
|
.metric.posture b{color:var(--accent)} .metric.integrity b{color:var(--violet);
|
|
font-size:16px;text-transform:uppercase}
|
|
.list{padding:0 8px 14px}
|
|
.item{border:1px solid transparent;border-bottom-color:var(--line);padding:10px;
|
|
cursor:pointer;border-radius:7px;margin-bottom:4px}
|
|
.item:hover{background:var(--panel)}
|
|
.item.sel{background:var(--panel2);border-color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}
|
|
.item .top{display:flex;align-items:center;gap:8px;justify-content:space-between}
|
|
.id{font-size:12px;color:var(--white);word-break:break-word}
|
|
.time,.fine{color:var(--muted);font-size:11px}
|
|
.tags{margin-top:7px;display:flex;gap:6px;flex-wrap:wrap}
|
|
.tag{font-size:11px;border:1px solid var(--line);border-radius:999px;padding:2px 7px;color:var(--soft)}
|
|
.sev{font-weight:700;font-size:11px;border-radius:4px;padding:2px 7px;border:1px solid var(--line)}
|
|
.sev.CRITICAL{color:var(--red);border-color:var(--red)}
|
|
.sev.HIGH{color:var(--amber);border-color:var(--amber)}
|
|
.sev.MEDIUM{color:var(--blue);border-color:var(--blue)}
|
|
.tabs{display:flex;gap:6px;margin-bottom:14px}
|
|
.tabs button{padding:7px 11px}
|
|
.tabs button.active{background:var(--accent);border-color:var(--accent);color:var(--active-ink)}
|
|
.card{background:var(--panel);border:1px solid var(--line);border-radius:8px;
|
|
padding:14px;margin-bottom:14px;box-shadow:var(--shadow)}
|
|
h2{font-size:18px;margin:0 0 8px;color:var(--white);letter-spacing:0}
|
|
h3{font-size:12px;margin:0 0 10px;color:var(--muted);letter-spacing:.12em;text-transform:uppercase}
|
|
.kv{display:grid;grid-template-columns:120px 1fr;gap:8px;margin-top:10px}
|
|
.kv div:nth-child(odd){color:var(--muted)}
|
|
.timeline{display:grid;gap:8px}
|
|
.event{border-left:2px solid var(--accent);padding:8px 10px;background:var(--panel2)}
|
|
.event b{display:block;color:var(--white)}
|
|
.event code,.cmd{display:block;margin-top:6px;padding:8px;background:var(--code-bg);
|
|
border:1px solid var(--line);border-radius:6px;color:var(--ink);white-space:pre-wrap;word-break:break-word}
|
|
.rule-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:12px}
|
|
.rule-counts{display:flex;gap:7px;flex-wrap:wrap}
|
|
.rule-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px}
|
|
.rule-card{position:relative;overflow:hidden;border:1px solid var(--line);
|
|
border-radius:8px;background:var(--panel);
|
|
padding:12px 12px 12px 16px}
|
|
.rule-card:before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--accent)}
|
|
.rule-card.syscall:before{background:var(--violet)}
|
|
.rule-card.exec:before{background:var(--copper)}
|
|
.rule-meta{display:flex;align-items:center;gap:8px;justify-content:space-between;margin-bottom:8px}
|
|
.rule-sid{font-size:12px;color:var(--white)}
|
|
.rule-msg{color:var(--ink);margin:8px 0 10px}
|
|
.cond{display:grid;grid-template-columns:98px 1fr;gap:6px;border-top:1px solid var(--line);padding-top:7px}
|
|
.cond dt{color:var(--muted);font-size:11px}
|
|
.cond dd{margin:0;color:var(--soft);font-size:11px;word-break:break-word}
|
|
.integrity-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}
|
|
.state-card{background:var(--panel);border:1px solid var(--line);border-radius:8px;
|
|
padding:13px;box-shadow:var(--shadow)}
|
|
.state-card.ok{border-color:var(--ok)} .state-card.review{border-color:var(--amber)}
|
|
.state-card.critical{border-color:var(--red)}
|
|
.state-card h3{margin-bottom:8px}
|
|
.state-line{display:grid;grid-template-columns:120px minmax(0,1fr);gap:7px;
|
|
border-top:1px solid var(--line);padding-top:7px;margin-top:7px}
|
|
.state-line span:first-child{color:var(--muted);font-size:11px;text-transform:uppercase}
|
|
.state-line span:last-child{word-break:break-word;color:var(--soft)}
|
|
.path-list{display:grid;gap:6px;margin-top:8px;max-height:230px;overflow:auto}
|
|
.path-row{display:grid;grid-template-columns:58px 1fr;gap:8px;font-size:11px}
|
|
.path-row b{color:var(--accent);font-weight:700}
|
|
.path-row.missing b{color:var(--muted)}
|
|
.action{border:1px solid var(--line);border-radius:8px;background:var(--panel);padding:11px;margin-bottom:10px}
|
|
.action .head{display:flex;gap:8px;align-items:center;justify-content:space-between}
|
|
.risk{font-size:10px;text-transform:uppercase;border:1px solid var(--line);border-radius:999px;padding:2px 7px;color:var(--muted)}
|
|
.risk.high{color:var(--red);border-color:var(--red)}
|
|
.risk.medium{color:var(--amber);border-color:var(--amber)}
|
|
.risk.low{color:var(--accent);border-color:var(--accent)}
|
|
pre{margin:0;white-space:pre-wrap;word-break:break-word;color:var(--ink);font-size:12px}
|
|
.empty,.err{padding:30px;color:var(--muted);text-align:center}
|
|
.err{color:var(--red)}
|
|
@media (max-width:1100px){
|
|
.layout{grid-template-columns:300px 1fr}
|
|
.plan{grid-column:1 / -1;border-left:0;border-top:1px solid var(--line);height:44vh}
|
|
}
|
|
@media (max-width:760px){
|
|
header{height:auto;min-height:58px;flex-wrap:wrap;padding:12px}
|
|
.settings-wrap{order:2}
|
|
.settings-menu{position:fixed;left:12px;right:12px;top:72px;width:auto}
|
|
.layout{display:block;height:auto}
|
|
aside,.work,.plan{height:auto;max-height:none}
|
|
.metrics{grid-template-columns:repeat(2,1fr)}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Enodia Sentinel</h1>
|
|
<span class="pill"><span class="dot" id="dot"></span><span id="daemon">connecting</span></span>
|
|
<span class="pill tls">HTTPS self-signed</span>
|
|
<span class="pill" id="host">host unknown</span>
|
|
<span class="spacer"></span>
|
|
<span class="pill" id="ebpf">eBPF unknown</span>
|
|
<div class="settings-wrap">
|
|
<button id="settingsBtn" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="settingsMenu">Settings</button>
|
|
<div id="settingsMenu" class="settings-menu" hidden>
|
|
<div class="settings-head">
|
|
<div>
|
|
<div class="settings-title">Settings</div>
|
|
<div class="settings-current" id="themeCurrent">Theme: Console</div>
|
|
</div>
|
|
<button class="settings-close" id="settingsClose" type="button" title="Close settings">Close</button>
|
|
</div>
|
|
<div class="section-title"><span>Theme</span><span>local</span></div>
|
|
<div class="theme-switch" id="themeOptions" role="group" aria-label="Theme"></div>
|
|
</div>
|
|
</div>
|
|
<button id="refreshBtn" title="Refresh console">Refresh</button>
|
|
</header>
|
|
|
|
<div class="layout">
|
|
<aside>
|
|
<div class="section-title"><span>Incidents</span><span id="incidentCount">0</span></div>
|
|
<div class="list" id="incidents"><div class="empty">loading incidents</div></div>
|
|
<div class="section-title"><span>Recent Alerts</span><span id="alertCount">0</span></div>
|
|
<div class="list" id="alerts"></div>
|
|
</aside>
|
|
|
|
<main class="work">
|
|
<div class="metrics" id="metrics"></div>
|
|
<div class="tabs">
|
|
<button class="active" data-tab="incident">Incident</button>
|
|
<button data-tab="alerts">Alerts</button>
|
|
<button data-tab="posture">Posture</button>
|
|
<button data-tab="integrity">Integrity</button>
|
|
<button data-tab="rules">Rules</button>
|
|
<button data-tab="events">Events</button>
|
|
</div>
|
|
<section id="incidentTab"></section>
|
|
<section id="alertsTab" hidden></section>
|
|
<section id="postureTab" hidden></section>
|
|
<section id="integrityTab" hidden></section>
|
|
<section id="rulesTab" hidden></section>
|
|
<section id="eventsTab" hidden></section>
|
|
</main>
|
|
|
|
<section class="plan">
|
|
<div class="section-title"><span>Dry-run Response Plan</span><span id="planMode">read-only</span></div>
|
|
<div id="plan"><div class="empty">Select an incident to generate a plan.</div></div>
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
const TOKEN = new URLSearchParams(location.search).get("token") || "";
|
|
const HDR = TOKEN ? {Authorization:"Bearer "+TOKEN} : {};
|
|
const TABS = new Set(["incident","alerts","posture","integrity","rules","events"]);
|
|
const THEME_OPTIONS = [
|
|
["console","Console"],["paper","Paper"],["contrast","Contrast"],
|
|
["pride","LGBTQ"],["trans","Trans"],["dracula","Dracula"],
|
|
["solarized-dark","Solarized Dark"],["solarized-light","Solarized Light"],
|
|
["twilight","Twilight"]
|
|
];
|
|
const THEMES = new Set(THEME_OPTIONS.map(t=>t[0]));
|
|
const THEME_LABELS = Object.fromEntries(THEME_OPTIONS);
|
|
function tabFromHash(){
|
|
const name = location.hash.replace(/^#/,"");
|
|
return TABS.has(name) ? name : "incident";
|
|
}
|
|
const state = {incidents:[], alerts:[], posture:null, integrity:null, rules:null, selected:null, incident:null, plan:null, tab:tabFromHash()};
|
|
|
|
async function api(path){
|
|
const r = await fetch(path,{headers:HDR});
|
|
if(r.status===401) throw new Error("unauthorized - open with ?token=YOUR_TOKEN");
|
|
if(!r.ok) throw new Error("HTTP "+r.status);
|
|
return r.json();
|
|
}
|
|
function el(tag, cls, txt){const e=document.createElement(tag); if(cls)e.className=cls;
|
|
if(txt!==undefined)e.textContent=txt; return e}
|
|
function sev(s){return el("span","sev "+(s||""),s||"?")}
|
|
function tags(xs){const box=el("div","tags"); (xs||[]).forEach(x=>box.append(el("span","tag",x))); return box}
|
|
function fmt(t){return (t||"?").replace("T"," ").slice(0,19)}
|
|
function themeName(){
|
|
const current = document.documentElement.dataset.theme || "console";
|
|
return THEMES.has(current) ? current : "console";
|
|
}
|
|
function setTheme(name){
|
|
const theme = THEMES.has(name) ? name : "console";
|
|
document.documentElement.dataset.theme = theme;
|
|
try{ localStorage.setItem("sentinel.theme", theme); }catch(_e){}
|
|
const current = document.getElementById("themeCurrent");
|
|
if(current) current.textContent = "Theme: " + THEME_LABELS[theme];
|
|
document.querySelectorAll(".theme-btn").forEach(b=>{
|
|
const active = b.dataset.theme === theme;
|
|
b.classList.toggle("active", active);
|
|
b.setAttribute("aria-pressed", active ? "true" : "false");
|
|
});
|
|
}
|
|
function renderThemeButtons(){
|
|
const box = document.getElementById("themeOptions");
|
|
if(!box) return;
|
|
box.innerHTML = "";
|
|
THEME_OPTIONS.forEach(([name,label])=>{
|
|
const b = el("button","theme-btn");
|
|
b.type = "button"; b.dataset.theme = name; b.title = label + " theme";
|
|
b.append(el("span","swatch"));
|
|
b.append(el("span","theme-name",label));
|
|
b.onclick = ()=>setTheme(name);
|
|
box.append(b);
|
|
});
|
|
}
|
|
function setSettings(open){
|
|
const menu = document.getElementById("settingsMenu");
|
|
const btn = document.getElementById("settingsBtn");
|
|
if(!menu || !btn) return;
|
|
menu.hidden = !open;
|
|
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
|
}
|
|
|
|
async function refresh(){
|
|
try{
|
|
const [status, incidents, alerts, posture, integrity, rules, events] = await Promise.all([
|
|
api("/api/status"), api("/api/incidents"), api("/api/alerts"), api("/api/posture"), api("/api/integrity"), api("/api/rules"), api("/api/events")
|
|
]);
|
|
state.incidents = incidents; state.alerts = alerts; state.posture = posture; state.integrity = integrity; state.rules = rules; state.events = events.events || [];
|
|
renderStatus(status); renderLists();
|
|
if(!state.selected && incidents.length) await openIncident(incidents[0].id);
|
|
else if(state.selected) await openIncident(state.selected, false);
|
|
renderTab(); renderEvents();
|
|
}catch(e){ showError(e.message); }
|
|
}
|
|
|
|
function renderStatus(st){
|
|
document.getElementById("dot").className = "dot "+(st.running && !st.heartbeat_stale ? "up" : "");
|
|
let hb = st.heartbeat_age == null ? "no heartbeat" : Math.round(st.heartbeat_age)+"s heartbeat";
|
|
document.getElementById("daemon").textContent = (st.running ? "running" : "not running")+" / "+hb;
|
|
document.getElementById("host").textContent = st.host+" / v"+st.version;
|
|
const exec = st.ebpf_exec || st.ebpf || "unknown";
|
|
const syscall = st.ebpf_syscall || "unknown";
|
|
document.getElementById("ebpf").textContent = "eBPF exec "+exec+" / syscall "+syscall;
|
|
const counts = st.counts || {};
|
|
const metrics = document.getElementById("metrics"); metrics.innerHTML = "";
|
|
[["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"],["TOTAL",""],["POSTURE","posture"],["INTEGRITY","integrity"]].forEach(([k,c])=>{
|
|
const value = k==="TOTAL" ? st.total_alerts||0 : k==="POSTURE" ? state.posture?.count||0 : k==="INTEGRITY" ? integrityMetricStatus(state.integrity?.status) : counts[k]||0;
|
|
const label = k==="TOTAL" ? "alerts" : k==="POSTURE" ? "findings" : k==="INTEGRITY" ? "state" : k;
|
|
const m=el("div","metric "+c); m.append(el("b",null,String(value)));
|
|
m.append(el("span",null,label)); metrics.append(m);
|
|
});
|
|
}
|
|
function integrityMetricStatus(status){
|
|
if(status === "ok") return "OK";
|
|
if(status === "critical") return "CRIT";
|
|
if(status === "review") return "REVIEW";
|
|
return "UNK";
|
|
}
|
|
|
|
function renderLists(){
|
|
document.getElementById("incidentCount").textContent = state.incidents.length;
|
|
document.getElementById("alertCount").textContent = state.alerts.length;
|
|
const incBox = document.getElementById("incidents"); incBox.innerHTML = "";
|
|
if(!state.incidents.length) incBox.append(el("div","empty","No incidents recorded."));
|
|
state.incidents.forEach(i=>{
|
|
const row=el("div","item"); row.classList.toggle("sel", i.id===state.selected);
|
|
row.onclick=()=>openIncident(i.id);
|
|
const top=el("div","top"); top.append(el("span","id",i.id)); top.append(sev(i.severity));
|
|
row.append(top); row.append(el("div","time",fmt(i.last_seen)));
|
|
row.append(tags(i.signatures)); incBox.append(row);
|
|
});
|
|
const alertBox = document.getElementById("alerts"); alertBox.innerHTML = "";
|
|
state.alerts.slice(0,12).forEach(a=>{
|
|
const row=el("div","item"); row.onclick=()=>openAlert(a.name);
|
|
const top=el("div","top"); top.append(el("span","id",a.name)); top.append(sev(a.severity));
|
|
row.append(top); row.append(el("div","time",fmt(a.time))); row.append(tags(a.signatures));
|
|
alertBox.append(row);
|
|
});
|
|
}
|
|
|
|
async function openIncident(id, mark=true){
|
|
state.selected = id;
|
|
if(mark) renderLists();
|
|
try{
|
|
const [inc, plan] = await Promise.all([
|
|
api("/api/incidents/"+encodeURIComponent(id)),
|
|
api("/api/respond/plan/"+encodeURIComponent(id))
|
|
]);
|
|
state.incident = inc; state.plan = plan;
|
|
renderIncident(); renderPlan();
|
|
}catch(e){ document.getElementById("incidentTab").innerHTML='<div class="err">'+e.message+'</div>'; }
|
|
}
|
|
|
|
async function openAlert(name){
|
|
state.tab = "alerts"; history.replaceState(null,"","#alerts"); setTabButtons();
|
|
const box = document.getElementById("alertsTab");
|
|
box.hidden = false; document.getElementById("incidentTab").hidden = true;
|
|
document.getElementById("postureTab").hidden = true; document.getElementById("rulesTab").hidden = true;
|
|
document.getElementById("integrityTab").hidden = true;
|
|
document.getElementById("eventsTab").hidden = true;
|
|
box.innerHTML='<div class="empty">loading snapshot</div>';
|
|
try{
|
|
const a = await api("/api/alerts/"+encodeURIComponent(name));
|
|
box.innerHTML = '<div class="card"><h3>'+name+'</h3><pre></pre></div>';
|
|
box.querySelector("pre").textContent = a.text || JSON.stringify(a.json,null,2);
|
|
}catch(e){ box.innerHTML='<div class="err">'+e.message+'</div>'; }
|
|
}
|
|
|
|
function renderIncident(){
|
|
const box = document.getElementById("incidentTab"); const data = state.incident;
|
|
if(!data){ box.innerHTML='<div class="empty">No incident selected.</div>'; return; }
|
|
const i = data.incident;
|
|
box.innerHTML = "";
|
|
const card = el("div","card");
|
|
card.append(el("h2",null,i.id)); card.append(tags(i.signatures));
|
|
const kv = el("div","kv");
|
|
[["Severity",i.severity],["First seen",fmt(i.first_seen)],["Last seen",fmt(i.last_seen)],
|
|
["Alert count",String(i.alert_count||0)],["PIDs",(i.pids||[]).join(", ")||"-"],["SIDs",(i.sids||[]).join(", ")||"-"]]
|
|
.forEach(([k,v])=>{kv.append(el("div",null,k)); kv.append(el("div",null,v));});
|
|
card.append(kv); box.append(card);
|
|
const tl = el("div","card"); tl.append(el("h3",null,"Timeline"));
|
|
const list = el("div","timeline");
|
|
(data.timeline||[]).forEach(r=>{
|
|
const ev = el("div","event"); ev.append(el("b",null,fmt(r.time)+" ["+(r.severity||"?")+"]"));
|
|
ev.append(el("div","fine",(r.signatures||[]).join(", ") || r.snapshot));
|
|
ev.append(el("code",null,r.snapshot)); list.append(ev);
|
|
});
|
|
tl.append(list); box.append(tl);
|
|
}
|
|
|
|
function renderPlan(){
|
|
const box = document.getElementById("plan"); const plan = state.plan;
|
|
if(!plan){ box.innerHTML='<div class="empty">No response plan available.</div>'; return; }
|
|
document.getElementById("planMode").textContent = plan.mode + " / no apply";
|
|
box.innerHTML = "";
|
|
(plan.actions||[]).forEach(a=>{
|
|
const row = el("div","action");
|
|
const head = el("div","head"); head.append(el("b",null,a.id+" "+a.title));
|
|
head.append(el("span","risk "+a.risk,a.risk)); row.append(head);
|
|
row.append(el("code","cmd",(a.command||[]).join(" ")));
|
|
row.append(el("div","fine",a.reason)); box.append(row);
|
|
});
|
|
}
|
|
|
|
function renderTab(){
|
|
setTabButtons();
|
|
document.getElementById("incidentTab").hidden = state.tab !== "incident";
|
|
document.getElementById("alertsTab").hidden = state.tab !== "alerts";
|
|
document.getElementById("postureTab").hidden = state.tab !== "posture";
|
|
document.getElementById("integrityTab").hidden = state.tab !== "integrity";
|
|
document.getElementById("rulesTab").hidden = state.tab !== "rules";
|
|
document.getElementById("eventsTab").hidden = state.tab !== "events";
|
|
if(state.tab === "incident") renderIncident();
|
|
if(state.tab === "posture") renderPosture();
|
|
if(state.tab === "integrity") renderIntegrity();
|
|
if(state.tab === "rules") renderRules();
|
|
if(state.tab === "events") renderEvents();
|
|
}
|
|
function setTabButtons(){
|
|
document.querySelectorAll(".tabs button").forEach(b=>b.classList.toggle("active",b.dataset.tab===state.tab));
|
|
}
|
|
function renderEvents(){
|
|
const box = document.getElementById("eventsTab");
|
|
box.innerHTML = '<div class="card"><h3>Event tail</h3><pre></pre></div>';
|
|
box.querySelector("pre").textContent = (state.events||[]).join("\n") || "No events.";
|
|
}
|
|
function renderPosture(){
|
|
const box = document.getElementById("postureTab");
|
|
const report = state.posture || {findings:[], counts:{}};
|
|
box.innerHTML = "";
|
|
const summary = el("div","card");
|
|
summary.append(el("h2",null,"Host posture"));
|
|
const counts = report.counts || {};
|
|
summary.append(tags([
|
|
"critical "+(counts.CRITICAL||0),
|
|
"high "+(counts.HIGH||0),
|
|
"medium "+(counts.MEDIUM||0)
|
|
]));
|
|
if(!report.findings || !report.findings.length){
|
|
summary.append(el("div","fine","No SSH, sudo, PATH, sensitive-file, or package-signature posture findings."));
|
|
box.append(summary);
|
|
return;
|
|
}
|
|
summary.append(el("div","fine",String(report.count)+" advisory finding(s). These are read-only hygiene checks, not live containment actions."));
|
|
box.append(summary);
|
|
report.findings.forEach(f=>{
|
|
const row = el("div","card");
|
|
const top = el("div","top"); top.append(sev(f.severity)); top.append(el("span","id",f.signature));
|
|
row.append(top);
|
|
row.append(tags(["sid "+(f.sid||0), f.classtype||"host-posture"]));
|
|
row.append(el("code","cmd",f.detail || ""));
|
|
box.append(row);
|
|
});
|
|
}
|
|
function humanAge(seconds){
|
|
if(seconds === null || seconds === undefined) return "-";
|
|
const n = Math.round(seconds);
|
|
if(n < 90) return n+"s";
|
|
if(n < 7200) return Math.round(n/60)+"m";
|
|
return Math.round(n/3600)+"h";
|
|
}
|
|
function addStateLine(card, key, value){
|
|
const row = el("div","state-line");
|
|
row.append(el("span",null,key));
|
|
row.append(el("span",null,value === null || value === undefined || value === "" ? "-" : String(value)));
|
|
card.append(row);
|
|
}
|
|
function renderIntegrity(){
|
|
const box = document.getElementById("integrityTab");
|
|
const report = state.integrity || {status:"unknown", checks:{}, watchdog:{}, anchors:{}, sentinel_footprint:{paths:[]}};
|
|
box.innerHTML = "";
|
|
const summary = el("div","card");
|
|
summary.append(el("h2",null,"Integrity state"));
|
|
const checks = report.checks || {};
|
|
summary.append(tags([
|
|
"overall "+(report.status||"unknown"),
|
|
"watchdog "+(checks.watchdog||"unknown"),
|
|
"fim "+(checks.fim_baseline||"unknown"),
|
|
"pkgdb "+(checks.pkgdb_anchor||"unknown"),
|
|
"siglevel "+(checks.pacman_siglevel||"unknown")
|
|
]));
|
|
summary.append(el("div","fine","Generated "+new Date((report.generated_at||0)*1000).toLocaleString()));
|
|
box.append(summary);
|
|
|
|
const grid = el("div","integrity-grid");
|
|
const wd = report.watchdog || {};
|
|
const watchdog = el("article","state-card "+(wd.ok ? "ok" : "critical"));
|
|
watchdog.append(el("h3",null,"Watchdog"));
|
|
watchdog.append(sev(wd.ok ? "OK" : "CRITICAL"));
|
|
addStateLine(watchdog,"message",wd.message);
|
|
addStateLine(watchdog,"daemon",wd.daemon_running ? "running" : "not running");
|
|
addStateLine(watchdog,"heartbeat",humanAge(wd.heartbeat_age));
|
|
addStateLine(watchdog,"max age",humanAge(wd.max_age));
|
|
addStateLine(watchdog,"path",wd.heartbeat_path);
|
|
grid.append(watchdog);
|
|
|
|
const anchors = report.anchors || {};
|
|
const fim = anchors.fim_baseline || {};
|
|
const fimCard = el("article","state-card "+(fim.status === "ok" ? "ok" : "review"));
|
|
fimCard.append(el("h3",null,"FIM baseline"));
|
|
addStateLine(fimCard,"status",fim.status);
|
|
addStateLine(fimCard,"entries",fim.count);
|
|
addStateLine(fimCard,"age",humanAge(fim.age));
|
|
addStateLine(fimCard,"path",fim.path);
|
|
grid.append(fimCard);
|
|
|
|
const pkg = anchors.pkgdb || {};
|
|
const pkgCard = el("article","state-card "+(pkg.status === "ok" ? "ok" : "review"));
|
|
pkgCard.append(el("h3",null,"Package DB anchor"));
|
|
addStateLine(pkgCard,"status",pkg.status);
|
|
addStateLine(pkgCard,"age",humanAge(pkg.age));
|
|
addStateLine(pkgCard,"fingerprint",pkg.fingerprint_prefix);
|
|
addStateLine(pkgCard,"path",pkg.path);
|
|
grid.append(pkgCard);
|
|
|
|
const pacman = anchors.pacman || {};
|
|
const pacCard = el("article","state-card "+(pacman.siglevel_status === "ok" && pacman.keyring_present ? "ok" : "review"));
|
|
pacCard.append(el("h3",null,"Pacman trust"));
|
|
addStateLine(pacCard,"siglevel",pacman.siglevel_status || "unknown");
|
|
addStateLine(pacCard,"keyring",pacman.keyring_present ? "present" : "missing");
|
|
if(pacman.siglevel_alert) addStateLine(pacCard,"finding",pacman.siglevel_alert.detail);
|
|
grid.append(pacCard);
|
|
|
|
const footprint = report.sentinel_footprint || {paths:[]};
|
|
const footCard = el("article","state-card ok");
|
|
footCard.append(el("h3",null,"Sentinel footprint"));
|
|
addStateLine(footCard,"configured",footprint.configured);
|
|
addStateLine(footCard,"present",footprint.present);
|
|
addStateLine(footCard,"not present",footprint.missing);
|
|
const paths = el("div","path-list");
|
|
(footprint.paths||[]).forEach(p=>{
|
|
const row = el("div","path-row "+(p.exists ? "" : "missing"));
|
|
row.append(el("b",null,p.exists ? "present" : "absent"));
|
|
row.append(el("span",null,p.path));
|
|
paths.append(row);
|
|
});
|
|
footCard.append(paths);
|
|
grid.append(footCard);
|
|
|
|
box.append(grid);
|
|
}
|
|
function renderRules(){
|
|
const box = document.getElementById("rulesTab");
|
|
const catalog = state.rules || {rules:[], by_event:{}, by_severity:{}, by_origin:{}};
|
|
box.innerHTML = "";
|
|
const summary = el("div","card");
|
|
const head = el("div","rule-head");
|
|
const left = el("div");
|
|
left.append(el("h2",null,"Rule atlas"));
|
|
left.append(el("div","fine","Active exec and syscall rules. This console displays rule metadata; it does not change detection behavior."));
|
|
head.append(left);
|
|
const counts = el("div","rule-counts");
|
|
const eventCounts = catalog.by_event || {};
|
|
const severityCounts = catalog.by_severity || {};
|
|
const originCounts = catalog.by_origin || {};
|
|
[
|
|
"rules "+(catalog.count||0),
|
|
"exec "+(eventCounts.exec||0),
|
|
"syscall "+(eventCounts.syscall||0),
|
|
"critical "+(severityCounts.CRITICAL||0),
|
|
"configured "+(originCounts.configured||0)
|
|
].forEach(x=>counts.append(el("span","tag",x)));
|
|
head.append(counts);
|
|
summary.append(head);
|
|
box.append(summary);
|
|
const grid = el("div","rule-grid");
|
|
(catalog.rules||[]).forEach(r=>{
|
|
const card = el("article","rule-card "+r.event);
|
|
const meta = el("div","rule-meta");
|
|
meta.append(el("span","rule-sid","SID "+r.sid+" / "+r.event));
|
|
meta.append(sev(r.severity));
|
|
card.append(meta);
|
|
card.append(tags([r.origin, r.classtype, r.signature]));
|
|
card.append(el("div","rule-msg",r.msg));
|
|
const dl = el("dl","cond");
|
|
Object.entries(r.conditions||{}).forEach(([k,v])=>{
|
|
dl.append(el("dt",null,k));
|
|
dl.append(el("dd",null,Array.isArray(v) ? v.join(", ") : String(v)));
|
|
});
|
|
card.append(dl);
|
|
grid.append(card);
|
|
});
|
|
if(!(catalog.rules||[]).length) grid.append(el("div","empty","No rules loaded."));
|
|
box.append(grid);
|
|
}
|
|
function showError(msg){
|
|
document.getElementById("incidentTab").innerHTML = '<div class="err">'+msg+'</div>';
|
|
document.getElementById("postureTab").innerHTML = '<div class="err">'+msg+'</div>';
|
|
document.getElementById("integrityTab").innerHTML = '<div class="err">'+msg+'</div>';
|
|
document.getElementById("rulesTab").innerHTML = '<div class="err">'+msg+'</div>';
|
|
document.getElementById("plan").innerHTML = '<div class="err">'+msg+'</div>';
|
|
}
|
|
document.querySelectorAll(".tabs button").forEach(b=>b.onclick=()=>{state.tab=b.dataset.tab; location.hash=state.tab; renderTab();});
|
|
document.getElementById("settingsBtn").onclick=e=>{
|
|
e.stopPropagation();
|
|
const menu = document.getElementById("settingsMenu");
|
|
setSettings(menu ? menu.hidden : true);
|
|
};
|
|
document.getElementById("settingsClose").onclick=()=>setSettings(false);
|
|
document.getElementById("settingsMenu").onclick=e=>e.stopPropagation();
|
|
document.addEventListener("click",()=>setSettings(false));
|
|
document.addEventListener("keydown",e=>{
|
|
if(e.key === "Escape") setSettings(false);
|
|
});
|
|
window.addEventListener("hashchange",()=>{state.tab=tabFromHash(); renderTab();});
|
|
document.getElementById("refreshBtn").onclick=refresh;
|
|
renderThemeButtons();
|
|
setTheme(themeName());
|
|
refresh();
|
|
setInterval(refresh, 10000);
|
|
</script>
|
|
</body>
|
|
</html>
|