Skip to main content

mentedb_server/
console.rs

1//! A tiny, dependency-free management console bundled into the server binary and
2//! served at `GET /console`. It has two tabs: Health (polls the public `/metrics`)
3//! and Memories (browses and deletes memories via the admin-key-gated
4//! `/v1/admin/memories` endpoints). Self-hosters get a real DB admin UI with no
5//! extra deploy and no build step.
6
7use axum::response::Html;
8
9const PAGE: &str = r##"<!doctype html>
10<html lang="en">
11<head>
12<meta charset="utf-8" />
13<meta name="viewport" content="width=device-width, initial-scale=1" />
14<title>MenteDB console</title>
15<style>
16  :root { color-scheme: dark; }
17  * { box-sizing: border-box; }
18  body { margin: 0; background: #0a0a0b; color: #e4e4e7; font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, sans-serif; }
19  header { padding: 18px 28px; border-bottom: 1px solid #27272a; display: flex; align-items: center; gap: 12px; }
20  header h1 { font-size: 16px; font-weight: 600; margin: 0; }
21  .dot { width: 10px; height: 10px; border-radius: 50%; background: #52525b; }
22  .dot.up { background: #34d399; box-shadow: 0 0 10px rgba(52,211,153,.6); }
23  .dot.down { background: #f87171; }
24  nav { display: flex; gap: 4px; padding: 0 22px; border-bottom: 1px solid #27272a; }
25  nav button { background: none; border: none; color: #a1a1aa; padding: 12px 14px; font: inherit; cursor: pointer; border-bottom: 2px solid transparent; }
26  nav button.active { color: #e4e4e7; border-bottom-color: #34d399; }
27  main { padding: 24px 28px; max-width: 1200px; margin: 0 auto; }
28  .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; }
29  .card { border: 1px solid #27272a; background: #131316; border-radius: 12px; padding: 16px 18px; }
30  .card .label { color: #a1a1aa; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
31  .card .value { font-size: 26px; font-weight: 600; margin-top: 6px; font-variant-numeric: tabular-nums; }
32  .card .value.ok { color: #34d399; }
33  .muted { color: #71717a; font-size: 12px; margin-top: 20px; }
34  a { color: #34d399; text-decoration: none; }
35  .sub { color: #71717a; font-size: 12px; }
36  .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; }
37  input, select, button.act { background: #131316; border: 1px solid #27272a; color: #e4e4e7; border-radius: 8px; padding: 8px 10px; font: inherit; }
38  button.act { cursor: pointer; }
39  button.act:hover { border-color: #3f3f46; }
40  table { width: 100%; border-collapse: collapse; font-size: 13px; }
41  th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #1f1f23; vertical-align: top; }
42  th { color: #a1a1aa; font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: .03em; }
43  td.content { max-width: 520px; }
44  .pill { font-size: 11px; padding: 1px 7px; border-radius: 999px; background: #1f1f23; color: #a1a1aa; }
45  .mono { font-family: ui-monospace, SFMono-Regular, monospace; color: #71717a; font-size: 12px; }
46  .del { color: #f87171; cursor: pointer; background: none; border: none; font: inherit; }
47  .hidden { display: none; }
48</style>
49</head>
50<body>
51<header>
52  <span id="dot" class="dot"></span>
53  <h1>MenteDB console</h1>
54  <span id="uptime" class="sub"></span>
55</header>
56<nav>
57  <button id="tab-health" class="active" onclick="showTab('health')">Health</button>
58  <button id="tab-memories" onclick="showTab('memories')">Memories</button>
59  <button id="tab-query" onclick="showTab('query')">Query</button>
60</nav>
61<main>
62  <section id="health">
63    <div class="grid" id="grid"></div>
64    <p class="muted">Live from <a href="/metrics">/metrics</a>, refreshed every 2s. Aggregate only, no per-account data. For history, scrape with Prometheus and import the Grafana dashboard.</p>
65  </section>
66  <section id="memories" class="hidden">
67    <div class="row">
68      <input id="key" type="password" placeholder="admin key (x-api-key)" style="width:220px" />
69      <input id="q" placeholder="search content" style="width:200px" />
70      <select id="type">
71        <option value="">any type</option>
72        <option>episodic</option><option>semantic</option><option>procedural</option>
73        <option>antipattern</option><option>reasoning</option><option>correction</option>
74      </select>
75      <input id="agent" placeholder="agent uuid (optional)" style="width:200px" />
76      <button class="act" onclick="loadMem(0)">Load</button>
77      <span id="memmsg" class="sub"></span>
78    </div>
79    <table><thead><tr><th>Content</th><th>Type</th><th>Agent</th><th>Created</th><th></th></tr></thead>
80    <tbody id="rows"></tbody></table>
81    <div class="row" style="margin-top:14px">
82      <button class="act" id="prev" onclick="pageBy(-1)">Prev</button>
83      <button class="act" id="next" onclick="pageBy(1)">Next</button>
84      <span id="pager" class="sub"></span>
85    </div>
86  </section>
87  <section id="query" class="hidden">
88    <div class="row">
89      <input id="qkey" type="password" placeholder="admin key (x-api-key)" style="width:220px" />
90      <button class="act" onclick="runMql()">Run</button>
91      <span id="qmsg" class="sub"></span>
92    </div>
93    <textarea id="mql" spellcheck="false"
94      style="width:100%;height:110px;margin-top:10px;box-sizing:border-box;background:#0b0b0d;color:#e4e4e7;border:1px solid #1f1f23;border-radius:8px;padding:10px;font-family:ui-monospace,SFMono-Regular,monospace;font-size:13px"
95      placeholder="RECALL WHERE memory_type = &quot;semantic&quot; LIMIT 20"></textarea>
96    <p class="muted" style="margin-top:6px">Runs raw MQL through the engine. Read only browsing, no context assembly. Results ordered by score.</p>
97    <table><thead><tr><th>Content</th><th>Type</th><th>Agent</th><th>Score</th></tr></thead>
98    <tbody id="qrows"></tbody></table>
99  </section>
100</main>
101<script>
102function showTab(t) {
103  for (const n of ["health","memories","query"]) {
104    document.getElementById(n).classList.toggle("hidden", n !== t);
105    document.getElementById("tab-"+n).classList.toggle("active", n === t);
106  }
107}
108// ---- Health ----
109function parse(text) {
110  const m = {};
111  for (const line of text.split("\n")) {
112    if (!line || line[0] === "#") continue;
113    const sp = line.lastIndexOf(" ");
114    if (sp < 0) continue;
115    const key = line.slice(0, sp).trim();
116    const val = parseFloat(line.slice(sp + 1));
117    if (!isFinite(val)) continue;
118    const name = key.indexOf("{") >= 0 ? key.slice(0, key.indexOf("{")) : key;
119    m[name] = (m[name] || 0) + val;
120  }
121  return m;
122}
123function fmtDur(s){s=Math.floor(s);const d=Math.floor(s/86400);s%=86400;const h=Math.floor(s/3600);s%=3600;const mi=Math.floor(s/60);return (d?d+"d ":"")+(h?h+"h ":"")+mi+"m";}
124function fmtBytes(b){if(!b)return "-";const u=["B","KB","MB","GB","TB"];let i=0;while(b>=1024&&i<u.length-1){b/=1024;i++;}return b.toFixed(1)+" "+u[i];}
125let prev=null, prevT=0;
126function card(l,v,c){return `<div class="card"><div class="label">${l}</div><div class="value ${c||""}">${v}</div></div>`;}
127async function tick() {
128  let m, ok = true;
129  try { m = parse(await (await fetch("/metrics",{cache:"no-store"})).text()); } catch { ok=false; m={}; }
130  const up = ok && m["mentedb_up"] === 1;
131  document.getElementById("dot").className = "dot " + (up?"up":"down");
132  document.getElementById("uptime").textContent = up ? "up "+fmtDur(m["mentedb_uptime_seconds"]||0) : "unreachable";
133  const now = Date.now()/1000; let reqRate=null, cpu=null;
134  if (prev && now>prevT){const dt=now-prevT;
135    if(m["mentedb_http_requests_total"]!=null&&prev["mentedb_http_requests_total"]!=null) reqRate=Math.max(0,(m["mentedb_http_requests_total"]-prev["mentedb_http_requests_total"])/dt);
136    if(m["process_cpu_seconds_total"]!=null&&prev["process_cpu_seconds_total"]!=null) cpu=Math.max(0,(m["process_cpu_seconds_total"]-prev["process_cpu_seconds_total"])/dt);
137  }
138  prev=m; prevT=now;
139  document.getElementById("grid").innerHTML = [
140    card("Status", up?"Up":"Down", up?"ok":""),
141    card("Memories stored",(m["mentedb_memory_count"]||0).toLocaleString()),
142    card("Live cluster nodes", m["mentedb_cluster_live_nodes"]!=null?m["mentedb_cluster_live_nodes"]:"-"),
143    card("Requests / sec", reqRate==null?"…":reqRate.toFixed(1)),
144    card("CPU (cores)", cpu==null?(isFinite(m["process_cpu_seconds_total"])?"…":"n/a"):cpu.toFixed(2)),
145    card("Resident memory", isFinite(m["process_resident_memory_bytes"])?fmtBytes(m["process_resident_memory_bytes"]):"n/a"),
146  ].join("");
147}
148tick(); setInterval(tick, 2000);
149// ---- Memories ----
150let offset = 0, limit = 50, total = 0;
151function esc(s){return (s||"").replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));}
152function key(){ return document.getElementById("key").value.trim(); }
153async function loadMem(off) {
154  const k = key();
155  if (!k) { document.getElementById("memmsg").textContent = "enter the admin key"; return; }
156  sessionStorage.setItem("mdb_key", k);
157  offset = off;
158  const p = new URLSearchParams({ limit, offset });
159  const q = document.getElementById("q").value.trim(); if (q) p.set("q", q);
160  const t = document.getElementById("type").value; if (t) p.set("type", t);
161  const a = document.getElementById("agent").value.trim(); if (a) p.set("agent", a);
162  document.getElementById("memmsg").textContent = "loading…";
163  let r;
164  try { r = await fetch("/v1/admin/memories?"+p, { headers: { "x-api-key": k } }); }
165  catch { document.getElementById("memmsg").textContent = "request failed"; return; }
166  if (!r.ok) { document.getElementById("memmsg").textContent = "error "+r.status+" (check the admin key)"; return; }
167  const d = await r.json();
168  total = d.total;
169  document.getElementById("rows").innerHTML = (d.memories||[]).map(mem => {
170    const dt = mem.created_at ? new Date(mem.created_at/1000).toISOString().slice(0,16).replace("T"," ") : "";
171    const ag = (mem.agent_id||"").slice(0,8);
172    return `<tr><td class="content">${esc(mem.content)}</td><td><span class="pill">${esc(mem.memory_type)}</span></td><td class="mono">${ag}</td><td class="mono">${dt}</td><td><button class="del" onclick="del('${mem.id}')">delete</button></td></tr>`;
173  }).join("");
174  document.getElementById("memmsg").textContent = "";
175  document.getElementById("pager").textContent = `${offset+1}–${Math.min(offset+limit,total)} of ${total.toLocaleString()}`;
176  document.getElementById("prev").disabled = offset <= 0;
177  document.getElementById("next").disabled = offset+limit >= total;
178}
179function pageBy(dir){ const n = offset + dir*limit; if (n>=0 && n<total) loadMem(n); }
180async function del(id) {
181  if (!confirm("Delete this memory?")) return;
182  const r = await fetch("/v1/admin/memories/"+id, { method:"DELETE", headers: { "x-api-key": key() } });
183  if (r.ok) loadMem(offset); else alert("delete failed: "+r.status);
184}
185// ---- Query ----
186async function runMql() {
187  const k = document.getElementById("qkey").value.trim();
188  if (!k) { document.getElementById("qmsg").textContent = "enter the admin key"; return; }
189  sessionStorage.setItem("mdb_key", k);
190  const mql = document.getElementById("mql").value.trim();
191  if (!mql) { document.getElementById("qmsg").textContent = "enter a query"; return; }
192  document.getElementById("qmsg").textContent = "running…";
193  let r;
194  try {
195    r = await fetch("/v1/admin/mql", { method:"POST",
196      headers: { "x-api-key": k, "content-type": "application/json" },
197      body: JSON.stringify({ mql }) });
198  } catch { document.getElementById("qmsg").textContent = "request failed"; return; }
199  if (!r.ok) {
200    const e = await r.json().catch(()=>null);
201    document.getElementById("qmsg").textContent = (e && e.error) ? e.error : ("error "+r.status);
202    return;
203  }
204  const d = await r.json();
205  document.getElementById("qrows").innerHTML = (d.memories||[]).map(mem => {
206    const ag = (mem.agent_id||"").slice(0,8);
207    const sc = typeof mem.score === "number" ? mem.score.toFixed(3) : "";
208    return `<tr><td class="content">${esc(mem.content)}</td><td><span class="pill">${esc(mem.memory_type)}</span></td><td class="mono">${ag}</td><td class="mono">${sc}</td></tr>`;
209  }).join("");
210  document.getElementById("qmsg").textContent = (d.count||0)+" result"+((d.count===1)?"":"s");
211}
212window.addEventListener("load", () => {
213  const s = sessionStorage.getItem("mdb_key");
214  if (s) { document.getElementById("key").value = s; document.getElementById("qkey").value = s; }
215});
216</script>
217</body>
218</html>
219"##;
220
221/// `GET /console`: the bundled management console.
222pub async fn handler() -> Html<&'static str> {
223    Html(PAGE)
224}