<!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, select { 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; }
.crumbs a { text-decoration: none; }
.crumbs a:hover { text-decoration: underline; }
textarea { font: inherit; padding: .35rem .6rem; border: 1px solid var(--line);
border-radius: 6px; background: var(--bg); color: var(--fg); }
.kind { color: var(--muted); font-size: .85rem; }
.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; }
#graph { width: 100%; height: 420px; }
.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">
<button id="ns-up" title="Up one level">↑ Up</button>
<input id="ns-filter" placeholder="Filter this level" size="24">
<label class="muted" style="display:flex;gap:.3rem;align-items:center">
<input type="checkbox" id="ns-deep"> search everywhere
</label>
<span class="muted" id="ns-count"></span>
<span class="spacer"></span>
<button id="ns-new">New namespace</button>
<button id="ns-add">Add values</button>
<button id="ns-export" title="Download this namespace as a STIX 2.1 bundle">Export STIX</button>
<button id="ns-prev">Previous</button>
<button id="ns-next">Next</button>
</div>
<div class="scroll"><table>
<thead><tr>
<th>Name</th><th>Holds</th><th>In memory</th><th>Tier</th>
</tr></thead>
<tbody id="ns-rows"></tbody>
</table></div>
<p class="muted" id="ns-empty"></p>
<p class="muted">A namespace is a path, so <code>feeds/misp/ips</code> browses
like folders: <b>folder</b> means other namespaces sit underneath it,
<b>namespace</b> means it holds values of its own, and a path can be both.
A tier applies to the whole top-level namespace, so changing it for
<code>myorg/one</code> changes <code>myorg</code> and everything under it.
<b>hot</b> stays in memory; <b>warm</b> is dropped once untouched for the
configured window; <b>cold</b> is dropped at the next sweep. Evicted data
is read back automatically when it is next used.</p>
</section>
<section id="view-values" class="hidden">
<h3 id="v-title">Values</h3>
<div class="row">
<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>
<p class="muted" id="v-empty"></p>
</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">← Back</button>
<strong class="crumbs" id="d-value"></strong>
</div>
<div class="cards" id="d-cards"></div>
<div class="row" style="margin-top:.8rem">
<label class="muted" style="flex:1">Tags<br>
<input id="d-tags" type="text" style="width:100%"
placeholder="stix-type:ipv4-addr, tlp:amber">
</label>
<button id="d-save-tags" style="align-self:flex-end">Save tags</button>
</div>
<p class="muted">Comma separated, and replacing rather than merging: this is
where a wrong tag comes off. What each one means to the STIX export is in
the README.</p>
<h3>Sightings over time</h3>
<p class="muted" id="d-nostats"></p>
<div id="chart"></div>
<h3>Where else this value has been seen</h3>
<p class="muted" id="d-relations"></p>
<div id="graph"></div>
<p class="muted">Each namespace holding this value is joined to it; the
folders above them are drawn too, so namespaces sharing a path sit
together. Colour is the top-level namespace, ◆ is the value itself,
▭ a folder and ● a namespace. Drag a node to pull the graph about,
scroll to zoom, and click a namespace to browse it.</p>
</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="ns-editor">
<form method="dialog" id="ns-form">
<h2 style="margin-top:0;font-size:1.05rem">New namespace</h2>
<p class="muted" id="nn-where"></p>
<p><input id="nn-name" type="text" placeholder="name" size="32" autofocus></p>
<p class="muted">Use <code>/</code> for subfolders: <code>misp/ips</code>
creates the whole path at once. It starts out empty; add values to it
whenever you like.</p>
<p class="crumbs" id="nn-preview"></p>
<p class="err" id="nn-error"></p>
<div class="row">
<button type="submit">Create</button>
<button type="button" id="nn-cancel">Cancel</button>
</div>
</form>
</dialog>
<dialog id="value-editor" class="wide">
<form method="dialog" id="value-form">
<h2 style="margin-top:0;font-size:1.05rem">Add values</h2>
<p><label class="muted">Namespace<br>
<input id="av-namespace" type="text" size="42" placeholder="feeds/misp/ips">
</label></p>
<p><label class="muted">Values, one per line<br>
<textarea id="av-values" rows="8" style="width:100%;font-family:var(--mono)"
placeholder="8.8.8.8 1.1.1.1"></textarea>
</label></p>
<p><label class="muted">Tags, comma separated<br>
<input id="av-tags" type="text" size="42"
placeholder="stix-type:ipv4-addr, tlp:amber, confidence:80">
</label></p>
<div class="row">
<label class="muted">TTL in seconds
<input id="av-ttl" type="number" min="0" step="1" size="8" placeholder="none">
</label>
<label class="muted">Seen at
<input id="av-when" type="datetime-local">
</label>
</div>
<p class="muted">Leave the TTL blank to keep whatever expiry a value already
had, or set 0 to clear it. Leave the time blank to record the values as
seen now. Writing a namespace that does not exist creates it. Tags are
merged with whatever each value already carried, and are what the STIX
export uses to say what a value <em>is</em> — see the tag vocabulary in
the README.</p>
<p class="err" id="av-error"></p>
<div class="row">
<button type="submit" id="av-save">Add</button>
<button type="button" id="av-cancel">Cancel</button>
</div>
</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, graph: null,
value: null,
};
const VIEWS = ["view-namespaces", "view-values", "view-value", "view-keys", "view-config"];
const $ = (id) => document.getElementById(id);
const show = (...ids) => {
for (const v of VIEWS) $(v).classList.toggle("hidden", !ids.includes(v));
};
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(() => ({}));
const error = new Error(failure.message || `${res.status} ${res.statusText}`);
error.status = res.status;
error.body = failure;
throw error;
}
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 || null;
state.vOffset = 0;
state.nsOffset = 0;
$("ns-filter").value = "";
$("v-filter").value = "";
if (push) {
const url = namespace ? `/_management/${namespace}` : "/_management/";
history.pushState({ namespace }, "", url);
}
render();
}
window.addEventListener("popstate", () => {
state.namespace = namespaceFromPath();
render();
});
function render() {
setError("");
drawCrumbs();
$("ns-up").disabled = !state.namespace;
show(...(state.namespace ? ["view-namespaces", "view-values"] : ["view-namespaces"]));
loadListing();
if (state.namespace) loadValues();
}
function drawCrumbs() {
const crumbs = $("crumbs");
crumbs.replaceChildren();
const root = document.createElement("a");
root.href = "/_management/";
root.textContent = "/";
root.onclick = (e) => { e.preventDefault(); go(null); };
crumbs.append(root);
if (!state.namespace) return;
const segments = state.namespace.split("/").filter(Boolean);
segments.forEach((segment, i) => {
const path = segments.slice(0, i + 1).join("/");
const a = document.createElement("a");
a.href = `/_management/${path}`;
a.textContent = segment;
a.onclick = (e) => { e.preventDefault(); go(path); };
crumbs.append(a, i < segments.length - 1 ? "/" : "");
});
}
function parentOf(namespace) {
const segments = (namespace || "").split("/").filter(Boolean);
segments.pop();
return segments.length ? segments.join("/") : null;
}
function searching() {
return $("ns-deep").checked && $("ns-filter").value.trim() !== "";
}
function loadListing() {
return searching() ? loadSearch() : loadTree();
}
async function loadTree() {
try {
const path = encodeURIComponent(state.namespace || "");
const q = encodeURIComponent($("ns-filter").value.trim());
const page = await api(
`/_management/api/tree?path=${path}&q=${q}&offset=${state.nsOffset}&limit=${PAGE}`);
state.nsTotal = page.total;
$("ns-rows").replaceChildren(...page.items.map((item) => {
const holds = [];
if (item.namespace) holds.push("namespace");
if (item.descendants) holds.push(`${item.descendants} below`);
return namespaceRow(item, item.name + (item.descendants ? "/" : ""),
holds.join(", ") || "folder", item.path);
}));
$("ns-empty").textContent = page.total ? "" : (
$("ns-filter").value.trim()
? "Nothing here matches that filter."
: state.namespace
? "Nothing below this namespace."
: "No namespaces yet. Create one, or write a sighting to /w/<namespace>.");
paging("ns", page);
} catch (e) { setError(e.message); }
}
async function loadSearch() {
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((item) =>
namespaceRow(item, item.namespace, "namespace", item.namespace)));
$("ns-empty").textContent = page.total ? "" : "No namespace matches that.";
paging("ns", page);
} catch (e) { setError(e.message); }
}
function namespaceRow(item, label, holds, path) {
const tr = document.createElement("tr");
const name = document.createElement("td");
name.className = "n";
const a = document.createElement("a");
a.href = `/_management/${path}`;
a.textContent = label;
a.onclick = (e) => { e.preventDefault(); go(path); };
name.append(a);
const kind = document.createElement("td");
kind.className = "kind";
kind.textContent = holds;
const resident = document.createElement("td");
resident.textContent = item.resident ? "yes" : "on disk";
if (!item.resident) resident.className = "muted";
const tier = document.createElement("td");
const select = document.createElement("select");
for (const option of ["hot", "warm", "cold"]) {
const o = document.createElement("option");
o.value = o.textContent = option;
o.selected = option === item.tier;
select.append(o);
}
select.title = `applies to ${item.shard}`;
select.onchange = () => setTier(item.shard, select.value, select);
const shard = document.createElement("span");
shard.className = "muted";
shard.textContent = ` ${item.shard}`;
tier.append(select, shard);
tr.append(name, kind, resident, tier);
return tr;
}
function paging(prefix, page) {
const from = page.total ? page.offset + 1 : 0;
$(`${prefix}-count`).textContent =
`${from}\u2013${page.offset + page.items.length} of ${page.total}`;
$(`${prefix}-prev`).disabled = page.offset === 0;
$(`${prefix}-next`).disabled = page.offset + page.items.length >= page.total;
}
async function setTier(shard, tier, control) {
control.disabled = true;
try {
await api("/_management/api/tier", "POST", { shard, tier });
await loadListing();
} catch (e) {
setError(e.message);
control.disabled = false;
loadListing();
}
}
function openNamespaceEditor() {
$("nn-name").value = "";
$("nn-error").textContent = "";
$("nn-where").textContent = state.namespace
? `Created under /${state.namespace}.`
: "Created at the top level.";
previewNamespace();
$("ns-editor").showModal();
}
function previewNamespace() {
const name = $("nn-name").value.trim().replace(/^\/+|\/+$/g, "");
const path = [state.namespace, name].filter(Boolean).join("/");
$("nn-preview").textContent = path ? `/${path}` : "";
}
async function createNamespace() {
const name = $("nn-name").value.trim();
const namespace = [state.namespace, name].filter(Boolean).join("/");
try {
const created = await api("/_management/api/namespaces", "POST", { namespace });
$("ns-editor").close();
go(created.namespace);
} catch (e) {
$("nn-error").textContent = e.message;
}
}
function openValueEditor() {
$("av-namespace").value = state.namespace || "";
$("av-values").value = "";
$("av-tags").value = "";
$("av-ttl").value = "";
$("av-when").value = "";
$("av-error").textContent = "";
$("value-editor").showModal();
}
async function addValues() {
const namespace = $("av-namespace").value.trim();
const values = $("av-values").value.split(/\r?\n/);
const body = { namespace, values, tags: $("av-tags").value.trim() };
const ttl = $("av-ttl").value.trim();
if (ttl !== "") body.ttl = Number(ttl);
const when = $("av-when").value;
if (when) {
const parsed = Date.parse(when);
if (Number.isNaN(parsed)) {
$("av-error").textContent = "That is not a time I can read.";
return;
}
body.timestamp = Math.floor(parsed / 1000);
}
$("av-save").disabled = true;
try {
const report = await api("/_management/api/values", "POST", body);
$("value-editor").close();
if (report.namespace === state.namespace) {
state.vOffset = 0;
loadValues();
loadListing();
} else {
go(report.namespace);
}
setError(report.errors && report.errors.length
? `Added ${report.written}; ${report.errors.length} rejected — ` +
report.errors.slice(0, 3).map((e) => `"${e.value}": ${e.error}`).join("; ")
: "");
} catch (e) {
const rejected = (e.body && e.body.errors) || [];
$("av-error").textContent = rejected.length
? rejected.slice(0, 3).map((r) => `"${r.value}": ${r.error}`).join("; ")
: e.message;
} finally {
$("av-save").disabled = false;
}
}
async function loadValues() {
$("v-title").textContent = `Values in /${state.namespace}`;
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;
}));
$("v-empty").textContent = page.total ? "" : (
$("v-filter").value.trim() ? "No value here matches that filter."
: "This namespace holds no values yet.");
paging("v", page);
} catch (e) {
if (e.status === 404) {
$("v-rows").replaceChildren();
$("v-count").textContent = "";
$("v-empty").textContent =
"Nothing is stored at this path itself — it only holds other namespaces.";
$("v-prev").disabled = $("v-next").disabled = true;
return;
}
setError(e.message);
}
}
async function loadValue(value) {
show("view-value");
setError("");
$("d-value").textContent = value;
try {
const ns = encodeURIComponent(state.namespace);
const item = await api(
`/_management/api/value?namespace=${ns}&value=${encodeURIComponent(value)}`);
state.value = item.value;
$("d-tags").value = item.tags || "";
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"],
["Observable", stixTypeOf(item) || "not recognised"],
];
$("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 || {});
loadRelations(value);
} catch (e) { setError(e.message); }
}
async function loadRelations(value) {
try {
const found = await api(`/_management/api/sightings?value=${encodeURIComponent(value)}`);
const shown = found.items.length;
const notes = [`Seen in ${shown} namespace(s)`];
if (found.consensus > shown) {
notes.push(`${found.consensus - shown} more hold it that this key cannot read`);
}
if (found.truncated) notes.push("more were found than are drawn");
$("d-relations").textContent = notes.join(" — ") + ".";
drawRelations(value, found.items);
} catch (e) { setError(e.message); }
}
function drawRelations(value, items) {
if (!state.graph) state.graph = echarts.init($("graph"), null, { renderer: "canvas" });
if (!items.length) { state.graph.clear(); return; }
const dark = matchMedia("(prefers-color-scheme: dark)").matches;
const fg = dark ? "#e6e9ec" : "#1b1f24";
const accent = dark ? "#6f9dff" : "#0b5fff";
const categories = [{ name: "this value" }];
const categoryOf = new Map();
for (const item of items) {
if (!categoryOf.has(item.shard)) {
categoryOf.set(item.shard, categories.length);
categories.push({ name: item.shard });
}
}
const nodes = new Map();
const links = [];
const node = (id, extra) => {
if (!nodes.has(id)) nodes.set(id, { id, ...extra });
return nodes.get(id);
};
node("\u0000value", {
name: value.length > 40 ? `${value.slice(0, 39)}…` : value,
category: 0,
symbol: "diamond",
symbolSize: 46,
label: { fontWeight: "bold" },
kind: "value",
});
const counts = items.map((item) => item.count);
const busiest = Math.max(...counts, 1);
for (const item of items) {
const segments = item.namespace.split("/").filter(Boolean);
const category = categoryOf.get(item.shard);
let parent = null;
for (let i = 0; i < segments.length; i++) {
const path = segments.slice(0, i + 1).join("/");
const leaf = i === segments.length - 1;
const existing = nodes.get(path);
if (!existing || (leaf && existing.kind === "folder")) {
const size = leaf ? 18 + 26 * (item.count / busiest) : 14;
nodes.set(path, {
id: path,
name: segments[i],
category,
symbol: leaf ? "circle" : "roundRect",
symbolSize: size,
kind: leaf ? "namespace" : "folder",
path,
count: leaf ? item.count : undefined,
first_seen: leaf ? item.first_seen : undefined,
last_seen: leaf ? item.last_seen : undefined,
itemStyle: leaf && path === state.namespace
? { borderColor: accent, borderWidth: 3 }
: undefined,
});
}
if (parent) links.push({ source: parent, target: path, kind: "path" });
parent = path;
}
links.push({
source: "\u0000value",
target: item.namespace,
kind: "sighting",
lineStyle: { type: "dashed", opacity: 0.65 },
});
}
const seenNames = new Map();
for (const n of nodes.values()) {
seenNames.set(n.name, (seenNames.get(n.name) || 0) + 1);
}
for (const n of nodes.values()) {
if (n.path && seenNames.get(n.name) > 1) n.name = n.path;
}
state.graph.setOption({
backgroundColor: "transparent",
textStyle: { color: fg },
legend: [{ data: categories.map((c) => c.name), textStyle: { color: fg }, top: 0 }],
tooltip: {
formatter: (p) => {
if (p.dataType === "edge") return "";
const d = p.data;
if (d.kind === "value") return `<b>${escapeHtml(value)}</b>`;
if (d.kind === "folder") return `${escapeHtml(d.path)}<br>folder`;
return `${escapeHtml(d.path)}<br><b>${d.count}</b> sighting(s)<br>` +
`first ${fmtTime(d.first_seen)}<br>last ${fmtTime(d.last_seen)}`;
},
},
series: [{
type: "graph",
layout: "force",
roam: true,
draggable: true,
categories,
data: [...nodes.values()],
links,
force: { repulsion: 320, edgeLength: [70, 160], gravity: 0.08, friction: 0.2 },
emphasis: { focus: "adjacency", scale: 1.1, label: { fontWeight: "bold" } },
label: { show: true, position: "right", color: fg, formatter: (p) => p.data.name },
labelLayout: { hideOverlap: true },
lineStyle: { color: "source", width: 1.5, curveness: 0.05, opacity: 0.8 },
scaleLimit: { min: 0.4, max: 4 },
}],
}, true);
state.graph.resize();
state.graph.off("click");
state.graph.on("click", (p) => {
if (p.dataType === "node" && p.data.path) go(p.data.path);
});
}
const escapeHtml = (text) =>
String(text).replace(/[&<>"]/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
function stixTypeOf(item) {
const tagged = tagValue(item.tags, "stix-type");
if (tagged) return tagged;
const value = (item.value || "").trim();
if (value.includes("://")) return "url";
if (/^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/.test(value)) return "ipv4-addr";
if (value.includes(":") && /^[0-9a-f:]+(\/\d{1,3})?$/i.test(value)) return "ipv6-addr";
if (/^[^@\s]+@[^@\s]+$/.test(value)) return "email-addr";
const hash = { 32: "file.MD5", 40: "file.SHA-1", 64: "file.SHA-256", 128: "file.SHA-512" };
if (/^[0-9a-f]+$/i.test(value) && hash[value.length]) return hash[value.length];
if (/^[^\s]+\.[a-z]{2,}$/i.test(value)) return "domain-name";
return null;
}
function tagValue(tags, key) {
for (const tag of (tags || "").split(",")) {
const at = tag.indexOf(":");
if (at > 0 && tag.slice(0, at).trim().toLowerCase() === key) {
return tag.slice(at + 1).trim();
}
}
return null;
}
async function saveTags() {
if (!state.namespace || !state.value) return;
$("d-save-tags").disabled = true;
try {
const item = await api("/_management/api/tags", "POST", {
namespace: state.namespace,
value: state.value,
tags: $("d-tags").value,
});
setError("");
loadValue(item.value || state.value);
} catch (e) {
setError(e.message);
} finally {
$("d-save-tags").disabled = false;
}
}
async function exportStix() {
if (!state.namespace) {
setError("Open a namespace first; the export is per namespace.");
return;
}
const button = $("ns-export");
button.disabled = true;
try {
const res = await fetch("/_api/stix", {
method: "POST",
headers: { Authorization: state.key, "Content-Type": "application/json" },
body: JSON.stringify({ namespace: state.namespace }),
});
if (res.status === 401 || res.status === 403) {
signOut("That key was not accepted.");
return;
}
if (!res.ok) {
const failure = await res.json().catch(() => ({}));
throw new Error(failure.message || `${res.status} ${res.statusText}`);
}
const exported = res.headers.get("X-SightingDB-Exported");
const skipped = Number(res.headers.get("X-SightingDB-Skipped") || 0);
const truncated = res.headers.get("X-SightingDB-Truncated") === "true";
const blob = new Blob([await res.text()], { type: "application/json" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `${state.namespace.replace(/\//g, "-")}-stix.json`;
link.click();
URL.revokeObjectURL(link.href);
const notes = [`Exported ${exported} value(s).`];
if (skipped) {
notes.push(`${skipped} skipped: no observable type, so no pattern — tag them with stix-type:`);
}
if (truncated) notes.push("the namespace holds more than one export can carry.");
setError(notes.length > 1 ? notes.join(" ") : "");
} catch (e) {
setError(e.message);
} finally {
button.disabled = false;
}
}
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", () => {
if (state.chart) state.chart.resize();
if (state.graph) state.graph.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() {
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 = () => render();
$("nav-keys").onclick = () => { setError(""); show("view-keys"); loadKeys(); };
$("nav-config").onclick = showConfig;
$("ns-up").onclick = () => go(parentOf(state.namespace));
$("ns-new").onclick = openNamespaceEditor;
$("ns-add").onclick = openValueEditor;
$("ns-export").onclick = exportStix;
$("d-save-tags").onclick = saveTags;
$("nn-cancel").onclick = () => $("ns-editor").close();
$("nn-name").addEventListener("input", previewNamespace);
$("ns-form").addEventListener("submit", (e) => { e.preventDefault(); createNamespace(); });
$("av-cancel").onclick = () => $("value-editor").close();
$("value-form").addEventListener("submit", (e) => { e.preventDefault(); addValues(); });
$("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; }
};
$("d-back").onclick = () => render();
$("ns-prev").onclick = () => { state.nsOffset = Math.max(0, state.nsOffset - PAGE); loadListing(); };
$("ns-next").onclick = () => { state.nsOffset += PAGE; loadListing(); };
$("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(loadListing, () => state.nsOffset = 0));
$("ns-deep").addEventListener("change", () => { state.nsOffset = 0; loadListing(); });
$("v-filter").addEventListener("input", refilter(loadValues, () => state.vOffset = 0));
if (state.key) { start(); } else { $("login").showModal(); }
</script>
</body>
</html>