Add web admin page to manage agents

GET /admin serves a self-contained HTML page (ADMIN_API_KEY protected)
that lists agents and lets you add/update/remove them via the admin API —
no redeploy needed to add an agent.
This commit is contained in:
hermes-agent
2026-09-10 06:59:08 +00:00
parent 490bd32322
commit e2be3f652e
2 changed files with 139 additions and 0 deletions
+119
View File
@@ -145,6 +145,18 @@ defmodule N8nOpenaiAdapter.Router do
# --- Admin API (separate ADMIN_API_KEY) ---
get "/admin" do
case authorize_admin!(conn) do
{:halt, conn} ->
conn
conn ->
conn
|> put_resp_content_type("text/html")
|> send_resp(200, admin_page())
end
end
get "/admin/agents" do
case authorize_admin!(conn) do
{:halt, conn} ->
@@ -255,4 +267,111 @@ defmodule N8nOpenaiAdapter.Router do
end
end
end
# Self-contained admin page: lists agents and lets you add/remove them via
# the admin API. Uses the ADMIN_API_KEY (entered in the page) as the Bearer
# token for the /admin/agents calls.
defp admin_page do
"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>n8n OpenAI Adapter — Agents</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
h1 { font-size: 1.4rem; }
input[type=text], input[type=password] { width: 100%; padding: .5rem; margin: .25rem 0 .75rem; box-sizing: border-box; }
button { padding: .5rem 1rem; cursor: pointer; }
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
th, td { text-align: left; padding: .5rem; border-bottom: 1px solid #ddd; }
.msg { margin-top: 1rem; padding: .5rem; border-radius: 4px; }
.ok { background: #e6f4ea; color: #1e7e34; }
.err { background: #fdecea; color: #c62828; }
.del { color: #c62828; background: none; border: none; cursor: pointer; }
</style>
</head>
<body>
<h1>n8n OpenAI Adapter — Agents</h1>
<p>Manage which n8n chat agents are exposed as OpenAI models.</p>
<label for="key">Admin API key</label>
<input type="password" id="key" placeholder="ADMIN_API_KEY" autocomplete="off">
<button onclick="loadAgents()">Load agents</button>
<h2>Add / update agent</h2>
<label for="model">Model name</label>
<input type="text" id="model" placeholder="e.g. scholar-agent">
<label for="webhook">n8n chat webhook URL</label>
<input type="text" id="webhook" placeholder="https://n8n.bueso.eu/webhook/<id>/chat">
<button onclick="saveAgent()">Save agent</button>
<div id="msg"></div>
<h2>Configured agents</h2>
<table id="agents"><thead><tr><th>Model</th><th>Webhook</th><th></th></tr></thead><tbody></tbody></table>
<script>
const key = () => document.getElementById('key').value.trim();
const msg = (text, ok) => {
const el = document.getElementById('msg');
el.className = 'msg ' + (ok ? 'ok' : 'err');
el.textContent = text;
};
const auth = () => ({ 'Authorization': 'Bearer ' + key(), 'Content-Type': 'application/json' });
async function loadAgents() {
if (!key()) return msg('Enter the admin API key first', false);
try {
const r = await fetch('/admin/agents', { headers: auth() });
if (!r.ok) return msg('Failed to load agents: HTTP ' + r.status, false);
const data = await r.json();
const tbody = document.querySelector('#agents tbody');
tbody.innerHTML = '';
for (const [model, webhook] of Object.entries(data.agents)) {
const tr = document.createElement('tr');
tr.innerHTML = '<td>' + model + '</td><td>' + webhook + '</td>' +
'<td><button class="del" onclick="deleteAgent(\\'' + model + '\\')">Delete</button></td>';
tbody.appendChild(tr);
}
msg('Loaded ' + Object.keys(data.agents).length + ' agent(s)', true);
} catch (e) { msg('Error: ' + e, false); }
}
async function saveAgent() {
const model = document.getElementById('model').value.trim();
const webhook = document.getElementById('webhook').value.trim();
if (!key()) return msg('Enter the admin API key first', false);
if (!model || !webhook) return msg('Model and webhook are required', false);
try {
const r = await fetch('/admin/agents', {
method: 'POST', headers: auth(),
body: JSON.stringify({ model, webhook })
});
if (!r.ok) return msg('Failed to save agent: HTTP ' + r.status, false);
document.getElementById('model').value = '';
document.getElementById('webhook').value = '';
msg('Saved agent "' + model + '"', true);
loadAgents();
} catch (e) { msg('Error: ' + e, false); }
}
async function deleteAgent(model) {
if (!key()) return msg('Enter the admin API key first', false);
if (!confirm('Delete agent "' + model + '"?')) return;
try {
const r = await fetch('/admin/agents/' + encodeURIComponent(model), {
method: 'DELETE', headers: auth()
});
if (!r.ok) return msg('Failed to delete: HTTP ' + r.status, false);
msg('Deleted agent "' + model + '"', true);
loadAgents();
} catch (e) { msg('Error: ' + e, false); }
}
</script>
</body>
</html>
"""
end
end
+20
View File
@@ -63,6 +63,26 @@ defmodule N8nOpenaiAdapter.RouterTest do
assert conn.status == 400
end
test "GET /admin serves the web admin page (admin key)" do
conn =
conn(:get, "/admin")
|> put_req_header("authorization", "Bearer test-admin-key")
|> Router.call(Router.init([]))
assert conn.status == 200
assert conn.resp_body =~ "n8n OpenAI Adapter"
assert conn.resp_body =~ "admin/agents"
end
test "GET /admin rejects a non-admin key" do
conn =
conn(:get, "/admin")
|> put_req_header("authorization", "Bearer test-key")
|> Router.call(Router.init([]))
assert conn.status == 401
end
# --- Admin API ---
test "GET /admin/agents lists agents (admin key)" do