<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SightingDB — management</title>
<style>
:root {
--bg: #ffffff; --fg: #1b1f24; --muted: #5c6570; --line: #d9dee4;
--panel: #f6f8fa; --accent: #0b5fff; --danger: #b3261e; --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #14171a; --fg: #e6e9ec; --muted: #9aa4af; --line: #2b3138;
--panel: #1b1f24; --accent: #6f9dff; --danger: #ff6b5e; }
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg);
font: 14px/1.5 system-ui, -apple-system, Segoe UI, sans-serif; }
header { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
padding: .7rem 1rem; border-bottom: 1px solid var(--line); background: var(--panel); }
header h1 { font-size: 1rem; margin: 0; font-weight: 600; }
header .spacer { flex: 1; }
main { padding: 1rem; max-width: 1200px; margin: 0 auto; }
a { color: var(--accent); }
button { font: inherit; padding: .35rem .7rem; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); border-radius: 6px; cursor: pointer; }
button:hover:not(:disabled) { border-color: var(--accent); }
button:disabled { opacity: .45; cursor: default; }
input { font: inherit; padding: .35rem .6rem; border: 1px solid var(--line);
border-radius: 6px; background: var(--bg); color: var(--fg); }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid var(--line);
vertical-align: top; }
th { color: var(--muted); font-weight: 600; white-space: nowrap; }
td.v, td.n { font-family: var(--mono); word-break: break-all; }
tbody tr:hover { background: var(--panel); }
.row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin-bottom: .8rem; }
.muted { color: var(--muted); }
.err { color: var(--danger); }
.crumbs { font-family: var(--mono); font-size: .9rem; }
.cards { display: grid; gap: .6rem; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
.card { border: 1px solid var(--line); border-radius: 8px; padding: .6rem .8rem; background: var(--panel); }
.card h3 { margin: 0 0 .3rem; font-size: .8rem; text-transform: uppercase;
letter-spacing: .04em; color: var(--muted); }
.card p { margin: 0; font-family: var(--mono); word-break: break-all; }
#chart { width: 100%; height: 320px; }
.scroll { overflow-x: auto; }
dialog { border: 1px solid var(--line); border-radius: 10px; background: var(--bg);
color: var(--fg); padding: 1.2rem; max-width: 420px; }
dialog::backdrop { background: rgba(0,0,0,.45); }
.hidden { display: none !important; }
.chip { display: inline-block; font-family: var(--mono); font-size: .82rem;
border: 1px solid var(--line); border-radius: 999px; padding: .05rem .5rem;
margin: 0 .2rem .2rem 0; background: var(--bg); }
.chip.all { border-style: dashed; }
.chip.none { color: var(--muted); border: none; padding-left: 0; }
td.actions { white-space: nowrap; }
td.actions button { padding: .2rem .5rem; margin-right: .3rem; }
button.danger { border-color: var(--danger); color: var(--danger); }
dialog.wide { max-width: 640px; }
.grant-row { display: flex; gap: .5rem; align-items: center; margin-bottom: .35rem; }
.grant-row input[type=text] { flex: 1; }
.grant-row label { font-size: .85rem; color: var(--muted); display: flex;
gap: .25rem; align-items: center; }
fieldset { border: 1px solid var(--line); border-radius: 8px; margin: .8rem 0; }
legend { color: var(--muted); font-size: .8rem; text-transform: uppercase;
letter-spacing: .04em; padding: 0 .3rem; }
</style>
</head>
<body>
<header>
<h1>SightingDB</h1>
<span class="crumbs" id="crumbs"></span>
<span class="spacer"></span>
<span class="muted" id="version"></span>
<button id="nav-browse">Browse</button>
<button id="nav-keys">Keys</button>
<button id="nav-config">Configuration</button>
<button id="signout">Sign out</button>
</header>
<main>
<div id="error" class="err"></div>
<section id="view-namespaces">
<div class="row">
<input id="ns-filter" placeholder="Filter namespaces" size="30">
<span class="muted" id="ns-count"></span>
<span class="spacer"></span>
<button id="ns-prev">Previous</button>
<button id="ns-next">Next</button>
</div>
<div class="scroll"><table>
<thead><tr><th>Namespace</th></tr></thead>
<tbody id="ns-rows"></tbody>
</table></div>
</section>
<section id="view-values" class="hidden">
<div class="row">
<button id="v-back">← Namespaces</button>
<input id="v-filter" placeholder="Filter values" size="30">
<span class="muted" id="v-count"></span>
<span class="spacer"></span>
<button id="v-prev">Previous</button>
<button id="v-next">Next</button>
</div>
<div class="scroll"><table>
<thead><tr>
<th>Value</th><th>Count</th><th>First seen</th><th>Last seen</th>
<th>Consensus</th><th>TTL</th>
</tr></thead>
<tbody id="v-rows"></tbody>
</table></div>
</section>
<section id="view-keys" class="hidden">
<div class="row">
<button id="k-new">New key</button>
<span class="muted" id="k-count"></span>
<span class="spacer"></span>
<span class="muted" id="k-readonly"></span>
</div>
<div class="scroll"><table>
<thead><tr>
<th>API key</th><th>Admin</th><th>Read</th><th>Write</th><th></th>
</tr></thead>
<tbody id="k-rows"></tbody>
</table></div>
</section>
<section id="view-config" class="hidden">
<p class="muted">Read from the configuration file at startup. Change it there
and restart; only API keys are editable here.</p>
<div class="scroll"><table><tbody id="c-rows"></tbody></table></div>
</section>
<section id="view-value" class="hidden">
<div class="row">
<button id="d-back">← Values</button>
<strong class="crumbs" id="d-value"></strong>
</div>
<div class="cards" id="d-cards"></div>
<h3>Sightings over time</h3>
<p class="muted" id="d-nostats"></p>
<div id="chart"></div>
</section>
</main>
<dialog id="login">
<form method="dialog" id="login-form">
<h2 style="margin-top:0;font-size:1.05rem">Management access</h2>
<p class="muted">Enter an API key holding the <code>admin</code> grant.
On a fresh install that is <code>changeme</code>.</p>
<p><input id="key" type="password" placeholder="API key" size="32" autofocus></p>
<p class="err" id="login-error"></p>
<button type="submit">Sign in</button>
</form>
</dialog>
<dialog id="key-editor" class="wide">
<form method="dialog" id="key-form">
<h2 style="margin-top:0;font-size:1.05rem" id="ke-title">New API key</h2>
<div class="row">
<input id="ke-key" type="text" placeholder="API key" size="42">
<button type="button" id="ke-generate">Generate</button>
</div>
<p class="muted" style="margin-top:0">
No spaces, quotes, <code>=</code>, <code>:</code> or <code>,</code>.
</p>
<fieldset>
<legend>Namespaces</legend>
<div id="ke-grants"></div>
<button type="button" id="ke-add">Add namespace</button>
<p class="muted">Leave a namespace blank to grant every namespace. Prefixes
match whole path segments, so <code>feeds/misp</code> covers
<code>feeds/misp/ips</code> but not <code>feeds/misp-internal</code>.</p>
</fieldset>
<fieldset>
<legend>Management</legend>
<label style="display:flex;gap:.4rem;align-items:center">
<input type="checkbox" id="ke-admin">
Can use this management interface
</label>
</fieldset>
<p class="err" id="ke-error"></p>
<div class="row">
<button type="submit">Save</button>
<button type="button" id="ke-cancel">Cancel</button>
</div>
</form>
</dialog>
<script src="/_management/echarts.min.js"></script>
<script>
"use strict";
const PAGE = 50;
const state = {
key: sessionStorage.getItem("sightingdb.key") || "",
namespace: null,
keys: [], editing: null,
nsOffset: 0, nsTotal: 0,
vOffset: 0, vTotal: 0,
chart: null,
};
const $ = (id) => document.getElementById(id);
const show = (id) => {
for (const v of ["view-namespaces", "view-values", "view-value", "view-keys", "view-config"]) {
$(v).classList.toggle("hidden", v !== id);
}
};
const fmtTime = (unix) =>
!unix ? "—" : new Date(unix * 1000).toISOString().replace("T", " ").replace(".000Z", "Z");
function setError(message) { $("error").textContent = message || ""; }
async function api(path, method = "GET", body) {
const options = { method, headers: { Authorization: state.key } };
if (body !== undefined) {
options.headers["Content-Type"] = "application/json";
options.body = JSON.stringify(body);
}
const res = await fetch(path, options);
if (res.status === 401 || res.status === 403) {
signOut("That key was not accepted.");
throw new Error("unauthorized");
}
if (!res.ok) {
const failure = await res.json().catch(() => ({}));
throw new Error(failure.message || `${res.status} ${res.statusText}`);
}
return res.json();
}
function namespaceFromPath() {
const path = decodeURIComponent(location.pathname);
const rest = path.replace(/^\/_management\/?/, "");
return rest.length ? rest : null;
}
function go(namespace, push = true) {
state.namespace = namespace;
state.vOffset = 0;
if (push) {
const url = namespace ? `/_management/${namespace}` : "/_management/";
history.pushState({ namespace }, "", url);
}
render();
}
window.addEventListener("popstate", () => {
state.namespace = namespaceFromPath();
render();
});
function render() {
setError("");
$("crumbs").textContent = state.namespace ? `/${state.namespace}` : "";
if (state.namespace) { loadValues(); } else { loadNamespaces(); }
}
async function loadNamespaces() {
show("view-namespaces");
try {
const q = encodeURIComponent($("ns-filter").value.trim());
const page = await api(`/_management/api/namespaces?q=${q}&offset=${state.nsOffset}&limit=${PAGE}`);
state.nsTotal = page.total;
$("ns-rows").replaceChildren(...page.items.map((name) => {
const tr = document.createElement("tr");
const td = document.createElement("td");
td.className = "n";
const a = document.createElement("a");
a.href = `/_management/${name}`;
a.textContent = name;
a.onclick = (e) => { e.preventDefault(); go(name); };
td.append(a);
tr.append(td);
return tr;
}));
const from = page.total ? page.offset + 1 : 0;
$("ns-count").textContent = `${from}–${page.offset + page.items.length} of ${page.total}`;
$("ns-prev").disabled = page.offset === 0;
$("ns-next").disabled = page.offset + page.items.length >= page.total;
} catch (e) { setError(e.message); }
}
async function loadValues() {
show("view-values");
try {
const ns = encodeURIComponent(state.namespace);
const q = encodeURIComponent($("v-filter").value.trim());
const page = await api(
`/_management/api/values?namespace=${ns}&q=${q}&offset=${state.vOffset}&limit=${PAGE}`);
state.vTotal = page.total;
$("v-rows").replaceChildren(...page.items.map((item) => {
const tr = document.createElement("tr");
const cells = [
["v", item.value, true],
["", String(item.count)],
["", fmtTime(item.first_seen)],
["", fmtTime(item.last_seen)],
["", String(item.consensus)],
["", item.ttl ? `${item.ttl}s` : "—"],
];
for (const [cls, text, link] of cells) {
const td = document.createElement("td");
if (cls) td.className = cls;
if (link) {
const a = document.createElement("a");
a.href = "#";
a.textContent = text;
a.onclick = (e) => { e.preventDefault(); loadValue(text); };
td.append(a);
} else {
td.textContent = text;
}
tr.append(td);
}
return tr;
}));
const from = page.total ? page.offset + 1 : 0;
$("v-count").textContent = `${from}–${page.offset + page.items.length} of ${page.total}`;
$("v-prev").disabled = page.offset === 0;
$("v-next").disabled = page.offset + page.items.length >= page.total;
} catch (e) { setError(e.message); }
}
async function loadValue(value) {
show("view-value");
$("d-value").textContent = value;
try {
const ns = encodeURIComponent(state.namespace);
const item = await api(
`/_management/api/value?namespace=${ns}&value=${encodeURIComponent(value)}`);
const cards = [
["Count", item.count],
["First seen", fmtTime(item.first_seen)],
["Last seen", fmtTime(item.last_seen)],
["Consensus", `${item.consensus} namespace(s)`],
["TTL", item.ttl ? `${item.ttl}s` : "none"],
["Tags", item.tags || "—"],
];
$("d-cards").replaceChildren(...cards.map(([label, text]) => {
const div = document.createElement("div");
div.className = "card";
const h = document.createElement("h3");
h.textContent = label;
const p = document.createElement("p");
p.textContent = String(text);
div.append(h, p);
return div;
}));
drawHistogram(item.stats || {});
} catch (e) { setError(e.message); }
}
function drawHistogram(stats) {
const buckets = Object.keys(stats).map(Number).sort((a, b) => a - b);
$("d-nostats").textContent = buckets.length ? "" : "No statistics recorded for this value.";
if (!state.chart) state.chart = echarts.init($("chart"), null, { renderer: "canvas" });
if (!buckets.length) { state.chart.clear(); return; }
const HOUR = 3600;
const data = [];
const span = (buckets[buckets.length - 1] - buckets[0]) / HOUR;
if (span <= 20000) {
for (let t = buckets[0]; t <= buckets[buckets.length - 1]; t += HOUR) {
data.push([t * 1000, stats[t] || 0]);
}
} else {
for (const t of buckets) data.push([t * 1000, stats[t]]);
}
const dark = matchMedia("(prefers-color-scheme: dark)").matches;
state.chart.setOption({
backgroundColor: "transparent",
textStyle: { color: dark ? "#e6e9ec" : "#1b1f24" },
tooltip: {
trigger: "axis",
formatter: (p) => {
const d = new Date(p[0].value[0]);
return `${d.toISOString().replace("T", " ").replace(".000Z", "Z")}<br>` +
`<b>${p[0].value[1]}</b> sighting(s) that hour`;
},
},
grid: { left: 56, right: 20, top: 24, bottom: 64 },
xAxis: { type: "time", axisLine: { lineStyle: { color: dark ? "#2b3138" : "#d9dee4" } } },
yAxis: {
type: "value", minInterval: 1, name: "sightings", nameGap: 34,
nameLocation: "middle", splitLine: { lineStyle: { color: dark ? "#2b3138" : "#eceff2" } },
},
dataZoom: [{ type: "inside" }, { type: "slider", height: 22, bottom: 16 }],
series: [{
type: "bar", data, barMaxWidth: 24, large: true,
itemStyle: { color: dark ? "#6f9dff" : "#0b5fff" },
}],
}, true);
state.chart.resize();
}
addEventListener("resize", () => state.chart && state.chart.resize());
function grantRows(entry) {
const byPrefix = new Map();
for (const prefix of entry.read) byPrefix.set(prefix, { prefix, read: true, write: false });
for (const prefix of entry.write) {
const row = byPrefix.get(prefix) || { prefix, read: false, write: false };
row.write = true;
byPrefix.set(prefix, row);
}
return [...byPrefix.values()];
}
function chips(prefixes) {
const span = document.createElement("span");
if (!prefixes.length) {
span.innerHTML = '<span class="chip none">—</span>';
return span;
}
for (const prefix of prefixes) {
const chip = document.createElement("span");
chip.className = prefix === "" ? "chip all" : "chip";
chip.textContent = prefix === "" ? "all namespaces" : prefix;
span.append(chip);
}
return span;
}
async function loadKeys() {
show("view-keys");
try {
const keys = await api("/_management/api/keys");
state.keys = keys;
$("k-rows").replaceChildren(...keys.map((entry) => {
const tr = document.createElement("tr");
const key = document.createElement("td");
key.className = "v";
key.textContent = entry.key;
const admin = document.createElement("td");
admin.textContent = entry.admin ? "yes" : "—";
const read = document.createElement("td");
read.append(chips(entry.read));
const write = document.createElement("td");
write.append(chips(entry.write));
const actions = document.createElement("td");
actions.className = "actions";
const edit = document.createElement("button");
edit.textContent = "Edit";
edit.onclick = () => openKeyEditor(entry);
const revoke = document.createElement("button");
revoke.textContent = "Revoke";
revoke.className = "danger";
revoke.onclick = () => deleteKey(entry.key);
actions.append(edit, revoke);
tr.append(key, admin, read, write, actions);
return tr;
}));
const admins = keys.filter((k) => k.admin).length;
$("k-count").textContent =
`${keys.length} key(s), ${admins} with management access`;
} catch (e) { setError(e.message); }
}
function openKeyEditor(entry) {
state.editing = entry ? entry.key : null;
$("ke-title").textContent = entry ? `Edit ${entry.key}` : "New API key";
$("ke-key").value = entry ? entry.key : "";
$("ke-key").disabled = Boolean(entry);
$("ke-generate").disabled = Boolean(entry);
$("ke-admin").checked = entry ? entry.admin : false;
$("ke-error").textContent = "";
const rows = entry ? grantRows(entry) : [{ prefix: "", read: true, write: false }];
$("ke-grants").replaceChildren();
for (const row of rows) addGrantRow(row);
$("key-editor").showModal();
}
function addGrantRow(row = { prefix: "", read: true, write: false }) {
const div = document.createElement("div");
div.className = "grant-row";
const prefix = document.createElement("input");
prefix.type = "text";
prefix.placeholder = "namespace prefix (blank = all)";
prefix.value = row.prefix;
const mk = (text, checked) => {
const label = document.createElement("label");
const box = document.createElement("input");
box.type = "checkbox";
box.checked = checked;
label.append(box, document.createTextNode(text));
return [label, box];
};
const [readLabel, readBox] = mk("read", row.read);
const [writeLabel, writeBox] = mk("write", row.write);
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "×";
remove.title = "Remove";
remove.onclick = () => div.remove();
div.append(prefix, readLabel, writeLabel, remove);
div._read = () => ({ prefix: prefix.value.trim(), read: readBox.checked, write: writeBox.checked });
$("ke-grants").append(div);
}
async function saveKey() {
const rows = [...$("ke-grants").children].map((div) => div._read());
const entry = {
key: $("ke-key").value.trim(),
admin: $("ke-admin").checked,
read: rows.filter((r) => r.read).map((r) => r.prefix),
write: rows.filter((r) => r.write).map((r) => r.prefix),
};
try {
await api("/_management/api/keys", "POST", entry);
$("key-editor").close();
loadKeys();
} catch (e) {
$("ke-error").textContent = e.message;
}
}
async function deleteKey(key) {
if (!confirm(`Revoke "${key}"? Anything using it stops working immediately.`)) return;
try {
await api(`/_management/api/keys/${encodeURIComponent(key)}`, "DELETE");
loadKeys();
} catch (e) { setError(e.message); }
}
async function showConfig() {
setError("");
try {
const info = await api("/_management/api/info");
const rows = [
["Version", info.version],
["Configuration file", info.config_path],
["Namespaces", info.namespaces],
["API keys", info.apikeys],
["API authentication", info.authenticate ? "on" : "off"],
["HTTP API", info.http_enabled ? "enabled" : "disabled"],
["Snapshot directory", info.dbdir || "not persisted"],
["Snapshot interval", info.snapshot_interval ? `${info.snapshot_interval}s` : "on shutdown only"],
["Sweep interval", info.sweep_interval ? `${info.sweep_interval}s` : "disabled"],
["Statistics retention", info.stats_retention ? `${info.stats_retention} buckets` : "unlimited"],
["Shadow TTL", info.shadow_ttl ? `${info.shadow_ttl}s` : "never expires"],
["DNS", info.dns ? `${info.dns.listen} for ${info.dns.zone}` : "disabled"],
["ZMQ ingest", info.zmq ? `${info.zmq.endpoint} (${info.zmq.format})` : "disabled"],
];
if (info.dns) {
for (const e of info.dns.exposed) {
rows.push([`DNS ${e.label}.${info.dns.zone}`, `${e.namespace} (${e.encoding})`]);
}
}
show("view-config");
$("c-rows").replaceChildren(...rows.map(([k, v]) => {
const tr = document.createElement("tr");
const th = document.createElement("th");
th.textContent = k;
const td = document.createElement("td");
td.className = "n";
td.textContent = String(v);
tr.append(th, td);
return tr;
}));
} catch (e) { setError(e.message); }
}
function signOut(message) {
sessionStorage.removeItem("sightingdb.key");
state.key = "";
$("login-error").textContent = message || "";
$("login").showModal();
}
async function start() {
try {
await api("/_management/api/session");
} catch { return; }
$("login").close();
try {
const info = await api("/_management/api/info");
$("version").textContent = `v${info.version}`;
} catch { }
state.namespace = namespaceFromPath();
render();
}
$("login-form").addEventListener("submit", async () => {
state.key = $("key").value;
sessionStorage.setItem("sightingdb.key", state.key);
await start();
});
$("signout").onclick = () => signOut("");
$("nav-browse").onclick = () => go(null);
$("nav-keys").onclick = () => { setError(""); loadKeys(); };
$("nav-config").onclick = showConfig;
$("k-new").onclick = () => openKeyEditor(null);
$("ke-add").onclick = () => addGrantRow();
$("ke-cancel").onclick = () => $("key-editor").close();
$("key-form").addEventListener("submit", (e) => { e.preventDefault(); saveKey(); });
$("ke-generate").onclick = async () => {
try {
const { key } = await api("/_management/api/keys/generate");
$("ke-key").value = key;
} catch (e) { $("ke-error").textContent = e.message; }
};
$("v-back").onclick = () => go(null);
$("d-back").onclick = () => { show("view-values"); };
$("ns-prev").onclick = () => { state.nsOffset = Math.max(0, state.nsOffset - PAGE); loadNamespaces(); };
$("ns-next").onclick = () => { state.nsOffset += PAGE; loadNamespaces(); };
$("v-prev").onclick = () => { state.vOffset = Math.max(0, state.vOffset - PAGE); loadValues(); };
$("v-next").onclick = () => { state.vOffset += PAGE; loadValues(); };
let debounce;
const refilter = (fn, reset) => () => {
clearTimeout(debounce);
debounce = setTimeout(() => { reset(); fn(); }, 200);
};
$("ns-filter").addEventListener("input", refilter(loadNamespaces, () => state.nsOffset = 0));
$("v-filter").addEventListener("input", refilter(loadValues, () => state.vOffset = 0));
if (state.key) { start(); } else { $("login").showModal(); }
</script>
</body>
</html>