Add dashboard theme settings and contrast tests

This commit is contained in:
Luna 2026-06-16 02:29:45 -07:00
parent 7f4d5b42fd
commit bfef23fc4a
7 changed files with 424 additions and 53 deletions

View file

@ -51,7 +51,9 @@ Implemented investigation/response state:
exposes `incident list/show/export`.
- The dashboard is HTTPS-only, token-protected off loopback, and read-only. It
shows status, incidents, timelines, alerts, posture findings, event-rule
metadata, event tail, and dry-run response plans.
metadata, event tail, dry-run response plans, and a local Settings menu with
persistent themes. Web tests cover the theme registry and core text/status
contrast checks.
- `respond plan <incident-id>` builds read-only containment/recovery plans,
persists CLI-generated plans under `response-plans/`, and appends
`response-audit.log`. Dashboard/API plan previews do not write artifacts.

View file

@ -295,6 +295,10 @@ sudo systemctl enable --now enodia-sentinel-web
API at `/api/status`, `/api/incidents`, `/api/respond/plan/<id>`,
`/api/posture`, `/api/rules`, `/api/alerts`, `/api/alerts/<id>`,
`/api/events`.
- **Operator settings**: the self-contained dashboard includes a Settings menu
with persistent local themes: Console, Paper, Contrast, LGBTQ, Trans, Dracula,
Solarized Dark, Solarized Light, and Twilight. Static dashboard tests guard
the theme registry and core text/status color contrast across every palette.
CLI-generated response plans are saved for handoff/review under
`<log_dir>/response-plans/`, with a JSONL trail in

View file

@ -327,7 +327,11 @@ that certificate or replace it with a private/local CA certificate.
The console exposes incidents, alert snapshots, posture findings, event-rule
metadata, event tail, and dry-run response plans. It remains read-only; response
commands are displayed for review but not executed from the browser.
commands are displayed for review but not executed from the browser. Dashboard
settings are local to the browser and include persistent themes: Console, Paper,
Contrast, LGBTQ, Trans, Dracula, Solarized Dark, Solarized Light, and Twilight.
The theme registry and core text/status contrast pairs are covered by the web
test suite.
Expected use: `enodia-sentinel-web.service`.

View file

@ -97,6 +97,11 @@ Purpose: define a stable single-host product.
- Add signed release artifacts and checksums.
- Improve dashboard from alert browser to local console:
incidents, posture, integrity state, response plans, and watchdog status.
- ✅ Add a local dashboard Settings menu with persistent themes for dark
console, light evidence review, high-contrast operation, LGBTQ, Trans,
Dracula, Solarized Dark/Light, and Twilight palettes.
- ✅ Add dashboard theme contrast/visual smoke checks so readable text and
meaningful status colors are preserved across every palette.
- Keep the dashboard read-only unless a separate authenticated write path is
designed and reviewed.
@ -232,7 +237,6 @@ Useful ideas that need design before implementation:
- Support for non-pacman package managers in signed-package verification.
- Dedicated Debian/RPM packaging.
- Installer preflight checks and health diagnostics.
- Rule documentation generated from source defaults.
## Roadmap Rules

View file

@ -5,45 +5,190 @@
<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;
--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(rgba(255,255,255,.06) 1px,transparent 1px),
linear-gradient(90deg,rgba(255,255,255,.05) 1px,transparent 1px);
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(--panel2);border:1px solid var(--line);
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:#fff}
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:rgba(13,17,23,.96);position:relative;z-index:2}
h1{font-size:15px;margin:0;color:#fff;letter-spacing:.16em;text-transform:uppercase}
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:rgba(13,17,23,.9);border-right:1px solid var(--line)}
.work{background:linear-gradient(180deg,#0f1319,#0b0d11);padding:18px}
.plan{background:rgba(17,21,27,.92);border-left:1px solid var(--line);padding:16px}
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(5,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:#fff}
.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)}
@ -53,7 +198,7 @@
.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:#fff;word-break:break-word}
.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)}
@ -63,40 +208,40 @@
.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:#00110e}
.card{background:rgba(17,21,27,.9);border:1px solid var(--line);border-radius:8px;
.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:#fff;letter-spacing:0}
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:rgba(255,255,255,.03)}
.event b{display:block;color:#fff}
.event code,.cmd{display:block;margin-top:6px;padding:8px;background:#080a0d;
border:1px solid var(--line);border-radius:6px;color:#cbd5e1;white-space:pre-wrap;word-break:break-word}
.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:linear-gradient(135deg,rgba(17,21,27,.98),rgba(10,12,15,.96));
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:#fff}
.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 rgba(255,255,255,.06);padding-top:7px}
.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}
.action{border:1px solid var(--line);border-radius:8px;background:#0d1117;padding:11px;margin-bottom:10px}
.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:#c8d1df;font-size:12px}
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){
@ -105,6 +250,8 @@
}
@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)}
@ -119,6 +266,20 @@
<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>
@ -156,6 +317,14 @@
const TOKEN = new URLSearchParams(location.search).get("token") || "";
const HDR = TOKEN ? {Authorization:"Bearer "+TOKEN} : {};
const TABS = new Set(["incident","alerts","posture","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";
@ -173,6 +342,42 @@ function el(tag, cls, txt){const e=document.createElement(tag); if(cls)e.classNa
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{
@ -388,8 +593,21 @@ function showError(msg){
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>

View file

@ -316,63 +316,71 @@ class _Handler(BaseHTTPRequestHandler):
supplied = parse_qs(urlparse(self.path).query).get("token", [""])[0]
return hmac.compare_digest(supplied, token)
def _json(self, obj, status=200):
body = json.dumps(obj).encode("utf-8")
def _send_body(self, body: bytes, ctype: str, status=200, send_body=True):
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if send_body:
self.wfile.write(body)
def _json(self, obj, status=200, send_body=True):
body = json.dumps(obj).encode("utf-8")
self._send_body(body, "application/json", status, send_body)
def _json_or_not_found(self, obj, send_body=True):
self._json(obj if obj else {"error": "not found"},
200 if obj else 404, send_body=send_body)
def do_GET(self):
self._handle_get(send_body=True)
def do_HEAD(self):
self._handle_get(send_body=False)
def _handle_get(self, send_body=True):
path = urlparse(self.path).path
if not self._authorized():
self._json({"error": "unauthorized"}, 401)
self._json({"error": "unauthorized"}, 401, send_body=send_body)
return
cfg: Config = self.server.cfg # type: ignore[attr-defined]
if path in ("/", "/index.html", "/dashboard.html"):
self._serve_static("dashboard.html", "text/html; charset=utf-8")
self._serve_static("dashboard.html", "text/html; charset=utf-8",
send_body=send_body)
elif path == "/api/status":
self._json(daemon_status(cfg))
self._json(daemon_status(cfg), send_body=send_body)
elif path == "/api/alerts":
self._json(list_alerts(cfg))
self._json(list_alerts(cfg), send_body=send_body)
elif path.startswith("/api/alerts/"):
alert = get_alert(cfg, path.rsplit("/", 1)[-1])
self._json(alert if alert else {"error": "not found"},
200 if alert else 404)
self._json_or_not_found(alert, send_body=send_body)
elif path == "/api/events":
self._json({"events": tail_events(cfg)})
self._json({"events": tail_events(cfg)}, send_body=send_body)
elif path == "/api/posture":
self._json(posture_report(cfg))
self._json(posture_report(cfg), send_body=send_body)
elif path == "/api/rules":
self._json(rules_catalog(cfg))
self._json(rules_catalog(cfg), send_body=send_body)
elif path == "/api/incidents":
self._json(list_incidents(cfg))
self._json(list_incidents(cfg), send_body=send_body)
elif path.startswith("/api/incidents/"):
iid = unquote(path.rsplit("/", 1)[-1])
inc = get_incident(cfg, iid)
self._json(inc if inc else {"error": "not found"},
200 if inc else 404)
self._json_or_not_found(inc, send_body=send_body)
elif path.startswith("/api/respond/plan/"):
iid = unquote(path.rsplit("/", 1)[-1])
plan = response_plan(cfg, iid)
self._json(plan if plan else {"error": "not found"},
200 if plan else 404)
self._json_or_not_found(plan, send_body=send_body)
else:
self._json({"error": "not found"}, 404)
self._json({"error": "not found"}, 404, send_body=send_body)
def _serve_static(self, name, ctype):
def _serve_static(self, name, ctype, send_body=True):
try:
body = (_STATIC / name).read_bytes()
except OSError:
self._json({"error": "missing asset"}, 500)
self._json({"error": "missing asset"}, 500, send_body=send_body)
return
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
self._send_body(body, ctype, send_body=send_body)
def build_server(cfg: Config) -> tuple[ThreadingHTTPServer, str, str]:

View file

@ -1,6 +1,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for the read-only web dashboard: data layer + auth (real server)."""
import json
import re
import ssl
import tempfile
import threading
@ -15,6 +16,12 @@ from enodia_sentinel.alert import Alert, Severity
from enodia_sentinel.config import Config
THEME_IDS = (
"console", "paper", "contrast", "pride", "trans", "dracula",
"solarized-dark", "solarized-light", "twilight",
)
def _make_cfg(tmp: Path) -> Config:
c = Config()
c.log_dir = tmp
@ -35,6 +42,55 @@ def _incident_alert(sig: str, pids=()):
tuple(pids), sid=100010, classtype="test")
def _theme_blocks(html: str):
blocks = {}
root = re.search(r":root\{(?P<body>.*?)\n \}", html, re.S)
if root:
blocks["console"] = _css_vars(root.group("body"))
for name, body in re.findall(r':root\[data-theme="([^"]+)"\]\{(.*?)\n \}', html, re.S):
blocks[name] = _css_vars(body)
return blocks
def _css_vars(block: str):
return {
name: value.strip()
for name, value in re.findall(r"--([\w-]+):([^;]+);", block)
}
def _hex_rgb(value: str):
value = value.strip()
if not value.startswith("#"):
return None
if len(value) == 4:
value = "#" + "".join(ch * 2 for ch in value[1:])
if not re.fullmatch(r"#[0-9A-Fa-f]{6}", value):
return None
return tuple(int(value[i:i + 2], 16) / 255.0 for i in (1, 3, 5))
def _linear_channel(value: float) -> float:
if value <= 0.04045:
return value / 12.92
return ((value + 0.055) / 1.055) ** 2.4
def _relative_luminance(rgb) -> float:
r, g, b = (_linear_channel(part) for part in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def _contrast(foreground: str, background: str) -> float:
fg = _hex_rgb(foreground)
bg = _hex_rgb(background)
if fg is None or bg is None:
raise AssertionError(f"Expected hex colors, got {foreground!r} / {background!r}")
high = max(_relative_luminance(fg), _relative_luminance(bg))
low = min(_relative_luminance(fg), _relative_luminance(bg))
return (high + 0.05) / (low + 0.05)
class TestDataLayer(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
@ -116,6 +172,68 @@ class TestDataLayer(unittest.TestCase):
self.assertEqual(rule["event"], "exec")
self.assertIn("conditions", rule)
def test_dashboard_has_persistent_theme_controls(self):
html = (web._STATIC / "dashboard.html").read_text()
self.assertIn('id="settingsMenu"', html)
self.assertIn('id="settingsBtn"', html)
self.assertIn('aria-label="Theme"', html)
for theme in THEME_IDS:
self.assertIn(f'data-theme="{theme}"', html)
self.assertIn("sentinel.theme", html)
self.assertIn("#55CDFC", html)
self.assertIn("#F7A8B8", html)
self.assertGreaterEqual(html.count("#55CDFC"), 8)
self.assertGreaterEqual(html.count("#F7A8B8"), 8)
trans_block = html.split(':root[data-theme="trans"]{', 1)[1].split("}", 1)[0]
self.assertIn("--ink:#000000", trans_block)
self.assertIn("--muted:#000000", trans_block)
self.assertIn("--soft:#000000", trans_block)
self.assertIn("--white:#000000", trans_block)
self.assertIn("--panel:#FFFFFF", trans_block)
self.assertIn("--panel2:#F7A8B8", trans_block)
self.assertIn("--rail:#55CDFC", trans_block)
self.assertNotIn("--amber:#FFFFFF", trans_block)
self.assertNotIn("--red:#F7A8B8", trans_block)
self.assertNotIn("--blue:#55CDFC", trans_block)
def test_dashboard_theme_registry_matches_css(self):
html = (web._STATIC / "dashboard.html").read_text()
blocks = _theme_blocks(html)
self.assertEqual(set(blocks), set(THEME_IDS))
options = re.search(r"const THEME_OPTIONS = \[(.*?)\];", html, re.S)
self.assertIsNotNone(options)
registered = tuple(re.findall(r'\["([^"]+)","[^"]+"\]', options.group(1)))
self.assertEqual(registered, THEME_IDS)
def test_dashboard_theme_text_contrast(self):
html = (web._STATIC / "dashboard.html").read_text()
blocks = _theme_blocks(html)
token_pairs = (
("ink", "bg"),
("ink", "panel"),
("muted", "panel"),
("soft", "panel2"),
("white", "panel"),
("active-ink", "accent"),
("red", "panel"),
("amber", "panel"),
("blue", "panel"),
("ok", "panel"),
)
for theme, tokens in blocks.items():
with self.subTest(theme=theme):
for token in (
"bg", "ink", "muted", "soft", "panel", "panel2", "accent",
"amber", "red", "blue", "ok", "white", "active-ink",
):
self.assertIn(token, tokens)
for foreground, background in token_pairs:
ratio = _contrast(tokens[foreground], tokens[background])
self.assertGreaterEqual(
ratio, 4.5,
f"{theme} {foreground} on {background} contrast is {ratio:.2f}",
)
class TestNetworkHelpers(unittest.TestCase):
def test_is_loopback(self):
@ -173,6 +291,13 @@ class TestAuth(unittest.TestCase):
req.add_header("Authorization", f"Bearer {token}")
return urllib.request.urlopen(req, timeout=4, context=self.ctx)
def _head(self, path, token=None):
url = f"https://127.0.0.1:{self.port}{path}"
req = urllib.request.Request(url, method="HEAD")
if token:
req.add_header("Authorization", f"Bearer {token}")
return urllib.request.urlopen(req, timeout=4, context=self.ctx)
def test_unauthorized_without_token(self):
with self.assertRaises(urllib.error.HTTPError) as cm:
self._get("/api/status")
@ -183,6 +308,12 @@ class TestAuth(unittest.TestCase):
data = json.loads(resp.read())
self.assertEqual(data["total_alerts"], 1)
def test_head_dashboard_with_token(self):
resp = self._head("/", token="secret-token")
self.assertEqual(resp.status, 200)
self.assertEqual(resp.headers.get_content_type(), "text/html")
self.assertEqual(resp.read(), b"")
def test_token_via_query_param(self):
resp = self._get("/api/alerts?token=secret-token")
self.assertEqual(resp.status, 200)