491 lines
17 KiB
HTML
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 %}
|