Files
beaver-gateway/src/beaver_gateway/frontends/admin/templates/dashboard.html
T

491 lines
17 KiB
HTML

{% extends "_layout.html" %}
{% set active = "dashboard" %}
{% block title %}beaver-gateway · dashboard{% endblock %}
{% block content %}
<h2>Agents</h2>
<div class="card">
{% if agents %}
<table>
<thead><tr><th>Name</th><th>Type</th><th>Model</th><th>Exposed MCPs</th></tr></thead>
<tbody>
{% for a in agents %}
<tr>
<td><code>{{ a.name }}</code></td>
<td><span class="pill">{{ a.__class__.__name__ }}</span></td>
<td><code>{{ a.model }}</code></td>
<td>
{% for em in a.expose_mcps %}<code>{{ em.name }}</code>{% if not loop.last %}, {% endif %}{% else %}<span class="muted">—</span>{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No agents configured.</p>
{% endif %}
</div>
<h2>MCP namespaces</h2>
<div class="card">
{% if mcps %}
<table>
<thead><tr><th>Name</th><th>Kind</th></tr></thead>
<tbody>
{% for m in mcps %}
<tr>
<td><code>{{ m.name }}</code></td>
<td><span class="pill">{{ m.kind }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No MCP servers configured.</p>
{% endif %}
</div>
<h2>Endpoints</h2>
<div class="card endpoints">
{% if endpoints.agents or endpoints.mcps %}
<div class="ep-controls">
<label class="ep-token">
<span>Token (hint)</span>
<select id="ep-token-select">
<option value="" data-scope="">— select a known token —</option>
{% for t in tokens %}
<option value="{{ t.name }}" data-scope="{{ t.scope }}">{{ t.name }} · scope {{ t.scope }}</option>
{% endfor %}
</select>
</label>
<label class="ep-secret">
<span>Bearer secret</span>
<input type="password" id="ep-token-secret" autocomplete="off" spellcheck="false"
placeholder="paste plaintext (we only store the Argon2 hash)">
</label>
<label class="ep-show">
<input type="checkbox" id="ep-show-secret"> <span>show</span>
</label>
</div>
<p class="muted ep-note">
Beaver stores only an Argon2 hash of each token, so the plaintext can't be reconstructed.
Paste the value you saved at creation; if you've lost it, <a href="{{ p }}/tokens">mint a new one</a>.
Below: pick a token to see which endpoints its scope covers, paste the secret to fill it
into URL / curl, then click Copy.
</p>
{% if endpoints.agents %}
<h3 class="ep-h3">Agents — <code>POST /v1/messages</code></h3>
<table class="ep-table" data-required-scope="messages">
<thead><tr><th>Agent</th><th>Model</th><th>URL</th><th class="ep-actions-th"></th></tr></thead>
<tbody>
{% for ep in endpoints.agents %}
<tr class="ep-row" data-kind="messages" data-agent="{{ ep.agent }}" data-url="{{ ep.url }}">
<td>
<code>{{ ep.agent }}</code>
<span class="pill">{{ ep.agent_type }}</span>
<span class="ep-scope-warn" hidden>scope mismatch</span>
</td>
<td><code>{{ ep.model }}</code></td>
<td><code class="ep-url">{{ ep.url }}</code></td>
<td class="ep-actions">
<button type="button" data-action="copy-url">Copy URL</button>
<button type="button" data-action="copy-curl">Copy curl</button>
<button type="button" data-action="toggle-curl" aria-expanded="false">▸ curl</button>
</td>
</tr>
<tr class="ep-curl-row" hidden><td colspan="4"><pre class="ep-curl"></pre></td></tr>
{% endfor %}
</tbody>
</table>
{% elif endpoints.anthropic_base is none and agents %}
<p class="muted">
Agents are declared but no <code>AnthropicMessagesFrontend</code> is configured —
add one to <code>Gateway(frontends=[...])</code> to expose them over HTTP.
</p>
{% endif %}
{% if endpoints.mcps %}
<h3 class="ep-h3">MCP — streamable HTTP</h3>
<table class="ep-table" data-required-scope="mcp">
<thead><tr><th>Namespace</th><th>Kind</th><th>URL</th><th class="ep-actions-th"></th></tr></thead>
<tbody>
{% for ep in endpoints.mcps %}
<tr class="ep-row" data-kind="mcp" data-namespace="{{ ep.namespace }}" data-url="{{ ep.url }}">
<td>
<code>{{ ep.namespace }}</code>
<span class="ep-scope-warn" hidden>scope mismatch</span>
</td>
<td><span class="pill">{{ ep.kind }}</span></td>
<td><code class="ep-url">{{ ep.url }}</code></td>
<td class="ep-actions">
<button type="button" data-action="copy-url">Copy URL</button>
<button type="button" data-action="copy-url-token">Copy URL+token</button>
<button type="button" data-action="copy-curl">Copy curl</button>
<button type="button" data-action="toggle-curl" aria-expanded="false">▸ curl</button>
</td>
</tr>
<tr class="ep-curl-row" hidden><td colspan="4"><pre class="ep-curl"></pre></td></tr>
{% endfor %}
</tbody>
</table>
{% elif endpoints.mcp_base is none and mcps %}
<p class="muted">
MCP servers are declared but no <code>McpServerFrontend</code> is configured —
add one to <code>Gateway(frontends=[...])</code> to expose them over HTTP.
</p>
{% endif %}
{% if endpoints.markdown %}
<h3 class="ep-h3">Markdown — <code>POST /chat</code></h3>
<table class="ep-table" data-required-scope="messages">
<thead><tr><th>Vault</th><th>Default agent</th><th>URL</th><th class="ep-actions-th"></th></tr></thead>
<tbody>
<tr class="ep-row"
data-kind="markdown"
data-url="{{ endpoints.markdown.url }}"
data-base="{{ endpoints.markdown.base }}"
data-sample-agent="{{ endpoints.markdown.sample_agent }}">
<td>
<code>{{ endpoints.markdown.vault_path }}</code>
<span class="ep-scope-warn" hidden>scope mismatch</span>
{% if endpoints.markdown.log_all_chats %}
<span class="pill">log_all_chats · {{ endpoints.markdown.logged_subdir }}/</span>
{% endif %}
</td>
<td>
{% if endpoints.markdown.default_agent %}
<code>{{ endpoints.markdown.default_agent }}</code>
{% else %}
<span class="muted">— (request must set <code>agent</code> or frontmatter)</span>
{% endif %}
</td>
<td>
<code class="ep-url">{{ endpoints.markdown.url }}</code>
<div class="muted" style="font-size:0.85em; margin-top:0.25rem;">
plugin base: <code>{{ endpoints.markdown.base }}</code>
</div>
</td>
<td class="ep-actions">
<button type="button" data-action="copy-url">Copy URL</button>
<button type="button" data-action="copy-base">Copy plugin base</button>
<button type="button" data-action="copy-curl">Copy curl</button>
<button type="button" data-action="toggle-curl" aria-expanded="false">▸ curl</button>
</td>
</tr>
<tr class="ep-curl-row" hidden><td colspan="4"><pre class="ep-curl"></pre></td></tr>
</tbody>
</table>
{% endif %}
{% else %}
<p class="muted">
Nothing exposed yet — declare agents / MCPs and the matching frontends
(<code>AnthropicMessagesFrontend</code>, <code>McpServerFrontend</code>) in your config.
</p>
{% endif %}
</div>
<h2>Recent activity</h2>
<div class="card">
{% if audit %}
<table>
<thead><tr><th>Time</th><th>Actor</th><th>Kind</th><th>Agent</th><th>Detail</th></tr></thead>
<tbody>
{% for row in audit %}
<tr>
<td><code>{{ row.ts | fmt_dt }}</code></td>
<td>{{ row.actor }}</td>
<td><span class="pill">{{ row.kind }}</span></td>
<td>{{ row.agent_name or "—" }}</td>
<td><code>{{ row.detail_json | fmt_detail }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="muted" style="margin-top:0.85rem;"><a href="{{ p }}/audit">Full log →</a></p>
{% else %}
<p class="muted">Nothing logged yet.</p>
{% endif %}
</div>
<style>
.endpoints .ep-controls {
display: grid;
grid-template-columns: minmax(220px, 1fr) 2fr auto;
gap: 0.85rem;
align-items: end;
margin-bottom: 0.5rem;
}
.endpoints .ep-controls label > span {
display: block;
font-size: 0.8em; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 0.3rem;
}
.endpoints .ep-show {
display: flex; align-items: center; gap: 0.4rem;
padding-bottom: 0.6rem; color: var(--muted); font-size: 0.9em;
}
.endpoints .ep-note {
font-size: 0.85em; margin: 0.25rem 0 1.2rem;
}
.endpoints .ep-h3 {
font-size: 0.95rem; font-weight: 600;
margin: 1.5rem 0 0.55rem;
}
.endpoints .ep-table { table-layout: auto; }
.endpoints .ep-table th.ep-actions-th { width: 1%; }
.endpoints .ep-url {
word-break: break-all;
}
.endpoints .ep-actions {
white-space: nowrap;
text-align: right;
}
.endpoints .ep-actions button {
padding: 0.32rem 0.7rem;
font-size: 0.85em;
margin-left: 0.3rem;
}
.endpoints .ep-scope-warn {
display: inline-block;
margin-left: 0.5rem;
padding: 0.05rem 0.45rem;
background: #fff7e6;
border: 1px solid #fadf9c;
color: #8a5a00;
border-radius: 999px;
font-size: 0.72em;
}
.endpoints .ep-row.scope-mismatch td:not(.ep-actions) { opacity: 0.55; }
.endpoints .ep-row.scope-mismatch .ep-actions button { opacity: 0.7; }
.endpoints .ep-curl-row td { padding-top: 0; padding-bottom: 0; }
.endpoints .ep-curl {
margin: 0.4rem 0 1rem;
font-size: 0.82em;
max-height: 280px;
}
.endpoints button[data-copied="1"] {
background: #ecfdf3 !important;
border-color: #b6e6cb !important;
color: #027a48;
}
@media (max-width: 760px) {
.endpoints .ep-controls { grid-template-columns: 1fr; }
.endpoints .ep-actions { text-align: left; padding-top: 0.4rem; }
}
</style>
<script>
(() => {
const card = document.querySelector(".endpoints");
if (!card) return;
const sel = card.querySelector("#ep-token-select");
const secret = card.querySelector("#ep-token-secret");
const showCb = card.querySelector("#ep-show-secret");
if (!sel || !secret || !showCb) return;
const PLACEHOLDER = "<YOUR_TOKEN>";
function currentToken() {
const v = secret.value;
return v && v.length > 0 ? v : null;
}
function selectedScope() {
const o = sel.options[sel.selectedIndex];
return o ? (o.getAttribute("data-scope") || "") : "";
}
function scopeCovers(have, need) {
if (!have) return true; // no token selected — don't grey anything out
if (have === "*") return true;
return have === need;
}
function escSh(s) {
// single-quote shell escape: close, escape, reopen.
return "'" + String(s).replace(/'/g, "'\\''") + "'";
}
function buildCurl(row) {
const kind = row.getAttribute("data-kind");
const url = row.getAttribute("data-url");
const tok = currentToken() || PLACEHOLDER;
if (kind === "messages") {
const agent = row.getAttribute("data-agent");
const body = JSON.stringify({
model: agent,
messages: [{ role: "user", content: "hello" }],
stream: true,
});
return [
"curl -N \\",
" -H " + escSh("x-api-key: " + tok) + " \\",
" -H 'content-type: application/json' \\",
" -d " + escSh(body) + " \\",
" " + url,
].join("\n");
}
if (kind === "markdown") {
const agent = row.getAttribute("data-sample-agent") || "";
const body = JSON.stringify({
filename: "example.md",
content: "### User:\n\nhello\n",
agent: agent,
});
return [
"curl \\",
" -H " + escSh("Authorization: Bearer " + tok) + " \\",
" -H 'content-type: application/json' \\",
" -d " + escSh(body) + " \\",
" " + url,
].join("\n");
}
if (kind === "mcp") {
// Streamable-HTTP MCP wants `initialize` first — the response
// carries the `Mcp-Session-Id` header you must echo back on
// every subsequent call (tools/list, tools/call, ...). We use
// `-i` so the session-id is visible in the response, and ship
// the proper handshake body so a fresh paste actually works.
const body = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "beaver-admin-curl", version: "0" },
},
});
return [
"# 1) initialize — grab Mcp-Session-Id from the response headers",
"curl -N -i \\",
" -H " + escSh("Authorization: Bearer " + tok) + " \\",
" -H 'content-type: application/json' \\",
" -H 'accept: application/json, text/event-stream' \\",
" -d " + escSh(body) + " \\",
" " + url,
"",
"# 2) reuse that id on follow-up calls, e.g. tools/list:",
"# curl -N \\",
"# -H " + escSh("Authorization: Bearer " + tok) + " \\",
"# -H 'Mcp-Session-Id: <paste-from-step-1>' \\",
"# -H 'content-type: application/json' \\",
"# -H 'accept: application/json, text/event-stream' \\",
"# -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}' \\",
"# " + url,
].join("\n");
}
return "";
}
function refreshScopeMarks() {
const have = selectedScope();
card.querySelectorAll(".ep-table").forEach((tbl) => {
const need = tbl.getAttribute("data-required-scope") || "";
const ok = scopeCovers(have, need);
tbl.querySelectorAll("tr.ep-row").forEach((row) => {
const warn = row.querySelector(".ep-scope-warn");
if (!ok) {
row.classList.add("scope-mismatch");
if (warn) warn.hidden = false;
} else {
row.classList.remove("scope-mismatch");
if (warn) warn.hidden = true;
}
});
});
}
function refreshOpenCurls() {
card.querySelectorAll("tr.ep-curl-row").forEach((cr) => {
if (cr.hidden) return;
const row = cr.previousElementSibling;
const pre = cr.querySelector(".ep-curl");
if (row && pre) pre.textContent = buildCurl(row);
});
}
function copyText(btn, text) {
const done = () => {
btn.setAttribute("data-copied", "1");
const prev = btn.textContent;
btn.textContent = "✓ copied";
setTimeout(() => {
btn.removeAttribute("data-copied");
btn.textContent = prev;
}, 1200);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, () => {
// Fallback for non-secure contexts.
fallbackCopy(text);
done();
});
} else {
fallbackCopy(text);
done();
}
}
function fallbackCopy(text) {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch {}
document.body.removeChild(ta);
}
sel.addEventListener("change", () => {
const name = sel.value;
secret.placeholder = name
? "paste plaintext for '" + name + "' (we only store the hash)"
: "paste plaintext (we only store the Argon2 hash)";
refreshScopeMarks();
});
secret.addEventListener("input", refreshOpenCurls);
showCb.addEventListener("change", () => {
secret.type = showCb.checked ? "text" : "password";
});
card.addEventListener("click", (ev) => {
const btn = ev.target.closest("button[data-action]");
if (!btn) return;
const row = btn.closest("tr.ep-row");
if (!row) return;
const action = btn.getAttribute("data-action");
const url = row.getAttribute("data-url");
const tok = currentToken() || PLACEHOLDER;
if (action === "copy-url") {
copyText(btn, url);
} else if (action === "copy-base") {
const base = row.getAttribute("data-base");
if (base) copyText(btn, base);
} else if (action === "copy-url-token") {
const sep = url.indexOf("?") >= 0 ? "&" : "?";
copyText(btn, url + sep + "token=" + encodeURIComponent(tok));
} else if (action === "copy-curl") {
copyText(btn, buildCurl(row));
} else if (action === "toggle-curl") {
const curlRow = row.nextElementSibling;
if (!curlRow || !curlRow.classList.contains("ep-curl-row")) return;
const pre = curlRow.querySelector(".ep-curl");
if (curlRow.hidden) {
if (pre) pre.textContent = buildCurl(row);
curlRow.hidden = false;
btn.textContent = "▾ curl";
btn.setAttribute("aria-expanded", "true");
} else {
curlRow.hidden = true;
btn.textContent = "▸ curl";
btn.setAttribute("aria-expanded", "false");
}
}
});
refreshScopeMarks();
})();
</script>
{% endblock %}