Add administrative portal with static assets and API key management features

This commit is contained in:
2026-05-31 23:38:40 +08:00
parent 32a4af9dcf
commit a9ae468817
7 changed files with 593 additions and 166 deletions
+2
View File
@@ -20,6 +20,7 @@ This repository now includes a lightweight, configurable API gateway framework b
- `src/gateway_framework/proxy.py`: request forwarding/proxy logic
- `src/gateway_framework/config.py`: config schema and loader
- `src/gateway_framework/main.py`: ASGI entrypoint
- `src/gateway_framework/static/admin/`: scalable portal frontend assets (`index.html`, `styles.css`, `app.js`)
- `config/gateway.yaml`: route and upstream configuration
- `openapi_json/lta_datamall_openapi_v0-1-1.json`: external OpenAPI contract
@@ -82,6 +83,7 @@ Notes:
## Administrative Portal and Dashboard
- Admin UI: `GET /admin/portal`
- Admin static assets: `GET /admin/portal/assets/*`
- Admin summary: `GET /admin/dashboard`
- Get config YAML: `GET /admin/config`
- Save config YAML: `PUT /admin/config`
+12 -165
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
from pathlib import Path
from fastapi import HTTPException, Request
ADMIN_STATIC_DIR = Path(__file__).resolve().parent / "static" / "admin"
def require_admin_access(request: Request) -> None:
expected_key = request.app.state.admin_api_key
@@ -13,169 +17,12 @@ def require_admin_access(request: Request) -> None:
raise HTTPException(status_code=401, detail="invalid_admin_api_key")
def get_admin_assets_dir() -> Path:
return ADMIN_STATIC_DIR
def render_admin_portal() -> str:
return """<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>Gateway Admin Portal</title>
<style>
:root {
--bg: #f5f7f2;
--panel: #ffffff;
--text: #1f2d24;
--muted: #5d6f63;
--accent: #0d7f5f;
--warn: #a23b3b;
--border: #d9e2d9;
}
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background: radial-gradient(circle at 0 0, #dceadf, var(--bg));
color: var(--text);
}
.wrap {
max-width: 1100px;
margin: 0 auto;
padding: 24px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.05);
}
h1, h2 { margin: 0 0 10px; }
textarea, input {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
font-family: "IBM Plex Mono", monospace;
}
textarea { min-height: 220px; }
button {
background: var(--accent);
color: #fff;
border: 0;
border-radius: 8px;
padding: 10px 14px;
cursor: pointer;
margin-right: 8px;
}
button.warn { background: var(--warn); }
.row { display: flex; gap: 8px; align-items: center; }
.hint { color: var(--muted); font-size: 0.92rem; }
code { background: #f0f5ef; padding: 2px 6px; border-radius: 4px; }
ul { padding-left: 18px; }
</style>
</head>
<body>
<div class=\"wrap\">
<h1>Gateway Admin Portal</h1>
<p class=\"hint\">Use header <code>x-admin-key</code> for protected actions when ADMIN_API_KEY is configured.</p>
<div class=\"card\">
<h2>Access</h2>
<input id=\"adminKey\" type=\"password\" placeholder=\"Admin API key (optional if server does not require it)\" />
</div>
<div class=\"card\">
<h2>Dashboard</h2>
<button onclick=\"refreshDashboard()\">Refresh</button>
<pre id=\"dashboard\" class=\"hint\">Loading...</pre>
</div>
<div class=\"card\">
<h2>Configuration Editor</h2>
<p class=\"hint\">Edits are validated and written to disk. Structural route changes may require app restart.</p>
<button onclick=\"loadConfig()\">Load</button>
<button onclick=\"saveConfig()\">Save</button>
<textarea id=\"configText\"></textarea>
</div>
<div class=\"card\">
<h2>API Key Management</h2>
<div class=\"row\">
<input id=\"apiKeyName\" placeholder=\"New key name (for example dashboard-client)\" />
<button onclick=\"createApiKey()\">Create Key</button>
</div>
<p class=\"hint\" id=\"newKey\"></p>
<ul id=\"apiKeys\"></ul>
</div>
</div>
<script>
function headers() {
const k = document.getElementById('adminKey').value.trim();
return k ? {'x-admin-key': k} : {};
}
async function refreshDashboard() {
const res = await fetch('/admin/dashboard', {headers: headers()});
const data = await res.json();
document.getElementById('dashboard').textContent = JSON.stringify(data, null, 2);
await listApiKeys();
}
async function loadConfig() {
const res = await fetch('/admin/config', {headers: headers()});
const data = await res.json();
document.getElementById('configText').value = data.yaml;
}
async function saveConfig() {
const body = {yaml: document.getElementById('configText').value};
const res = await fetch('/admin/config', {
method: 'PUT',
headers: {...headers(), 'content-type': 'application/json'},
body: JSON.stringify(body),
});
const data = await res.json();
alert(JSON.stringify(data));
await refreshDashboard();
}
async function listApiKeys() {
const res = await fetch('/admin/api-keys', {headers: headers()});
const data = await res.json();
const list = document.getElementById('apiKeys');
list.innerHTML = '';
for (const item of data.keys) {
const li = document.createElement('li');
li.textContent = `${item.name} (${item.prefix}) revoked=${item.revoked}`;
const btn = document.createElement('button');
btn.textContent = 'Revoke';
btn.className = 'warn';
btn.onclick = async () => {
await fetch(`/admin/api-keys/${item.id}`, {method: 'DELETE', headers: headers()});
await listApiKeys();
};
li.appendChild(btn);
list.appendChild(li);
}
}
async function createApiKey() {
const name = document.getElementById('apiKeyName').value.trim() || 'unnamed';
const res = await fetch('/admin/api-keys', {
method: 'POST',
headers: {...headers(), 'content-type': 'application/json'},
body: JSON.stringify({name}),
});
const data = await res.json();
document.getElementById('newKey').textContent = `New API key (shown once): ${data.api_key}`;
await listApiKeys();
}
loadConfig();
refreshDashboard();
</script>
</body>
</html>
"""
index_file = ADMIN_STATIC_DIR / "index.html"
if not index_file.exists():
return "<html><body><h1>Admin portal assets missing</h1></body></html>"
return index_file.read_text(encoding="utf-8")
+8 -1
View File
@@ -10,9 +10,10 @@ import httpx
import yaml
from fastapi import Body, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from .admin import render_admin_portal, require_admin_access
from .admin import get_admin_assets_dir, render_admin_portal, require_admin_access
from .api_keys import ApiKeyStore
from .config import GatewayConfig, RouteConfig, load_gateway_config
from .proxy import proxy_request
@@ -214,6 +215,12 @@ def create_app(config_path: str | None = None) -> FastAPI:
lifespan=lifespan,
)
app.mount(
"/admin/portal/assets",
StaticFiles(directory=get_admin_assets_dir()),
name="admin-portal-assets",
)
_register_management_routes(app)
_register_dynamic_routes(app, gateway_config)
return app
+197
View File
@@ -0,0 +1,197 @@
const state = {
adminKey: "",
};
function setStatus(id, message, isError = false) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = message;
el.style.color = isError ? "#a33838" : "#607567";
}
function adminHeaders() {
const headers = {};
if (state.adminKey) {
headers["x-admin-key"] = state.adminKey;
}
return headers;
}
async function requestJson(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
...adminHeaders(),
...(options.headers || {}),
},
});
let payload = {};
try {
payload = await response.json();
} catch {
payload = {};
}
if (!response.ok) {
throw new Error(payload.detail || payload.error || `request_failed_${response.status}`);
}
return payload;
}
async function refreshDashboard() {
const data = await requestJson("/admin/dashboard");
document.getElementById("metricRoutes").textContent = String(data.routes_count);
document.getElementById("metricUpstreams").textContent = String(data.upstreams.length);
document.getElementById("metricKeyGuard").textContent = data.require_api_key ? "Enabled" : "Disabled";
document.getElementById("metricAdminAuth").textContent = data.admin_api_key_required ? "Required" : "Open";
await refreshRoutes();
await refreshKeys();
}
async function refreshRoutes() {
const items = await requestJson("/admin/routes");
const list = document.getElementById("routesList");
list.innerHTML = "";
for (const route of items) {
const li = document.createElement("li");
const methods = Array.isArray(route.methods) ? route.methods.join(",") : "";
li.innerHTML = `<span>${route.path}</span><span class="pill">${methods}</span>`;
list.appendChild(li);
}
}
async function loadConfig() {
const data = await requestJson("/admin/config");
document.getElementById("configText").value = data.yaml || "";
setStatus("configStatus", "Configuration loaded.");
}
async function saveConfig() {
const yaml = document.getElementById("configText").value;
await requestJson("/admin/config", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ yaml }),
});
setStatus("configStatus", "Configuration saved and reloaded.");
await refreshDashboard();
}
function renderKeyItem(item) {
const li = document.createElement("li");
const left = document.createElement("span");
left.textContent = `${item.name} (${item.prefix})`;
const right = document.createElement("div");
right.className = "actions";
const statePill = document.createElement("span");
statePill.className = "pill";
statePill.textContent = item.revoked ? "revoked" : "active";
right.appendChild(statePill);
if (!item.revoked) {
const revoke = document.createElement("button");
revoke.className = "btn danger";
revoke.textContent = "Revoke";
revoke.addEventListener("click", async () => {
await requestJson(`/admin/api-keys/${item.id}`, { method: "DELETE" });
await refreshKeys();
});
right.appendChild(revoke);
}
li.appendChild(left);
li.appendChild(right);
return li;
}
async function refreshKeys() {
const data = await requestJson("/admin/api-keys");
const list = document.getElementById("keysList");
list.innerHTML = "";
for (const item of data.keys || []) {
list.appendChild(renderKeyItem(item));
}
}
async function createKey() {
const input = document.getElementById("apiKeyName");
const name = input.value.trim() || "unnamed";
const data = await requestJson("/admin/api-keys", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name }),
});
setStatus("newKeyValue", `New API key (shown once): ${data.api_key}`);
input.value = "";
await refreshKeys();
}
function bindEvents() {
document.getElementById("saveAdminKeyBtn").addEventListener("click", async () => {
state.adminKey = document.getElementById("adminKey").value.trim();
try {
await refreshDashboard();
setStatus("newKeyValue", "Admin key accepted.");
} catch (err) {
setStatus("newKeyValue", `Admin key check failed: ${err.message}`, true);
}
});
document.getElementById("loadConfigBtn").addEventListener("click", async () => {
try {
await loadConfig();
} catch (err) {
setStatus("configStatus", `Load failed: ${err.message}`, true);
}
});
document.getElementById("saveConfigBtn").addEventListener("click", async () => {
try {
await saveConfig();
} catch (err) {
setStatus("configStatus", `Save failed: ${err.message}`, true);
}
});
document.getElementById("createKeyBtn").addEventListener("click", async () => {
try {
await createKey();
} catch (err) {
setStatus("newKeyValue", `Create failed: ${err.message}`, true);
}
});
document.getElementById("refreshKeysBtn").addEventListener("click", async () => {
try {
await refreshKeys();
} catch (err) {
setStatus("newKeyValue", `Refresh failed: ${err.message}`, true);
}
});
document.getElementById("refreshRoutesBtn").addEventListener("click", async () => {
try {
await refreshRoutes();
} catch (err) {
setStatus("newKeyValue", `Routes failed: ${err.message}`, true);
}
});
}
async function bootstrap() {
bindEvents();
try {
await Promise.all([loadConfig(), refreshDashboard()]);
} catch (err) {
setStatus("configStatus", `Initial load failed: ${err.message}`, true);
}
}
bootstrap();
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gateway Control Plane</title>
<link rel="stylesheet" href="/admin/portal/assets/styles.css" />
</head>
<body>
<div class="backdrop"></div>
<main class="layout">
<header class="hero">
<div>
<p class="eyebrow">Control Plane</p>
<h1>Gateway Portal</h1>
<p class="subtitle">Operational dashboard, live config control, and API key lifecycle in one scalable interface.</p>
</div>
<div class="access-box">
<label for="adminKey">Admin Key</label>
<input id="adminKey" type="password" placeholder="x-admin-key value" />
<button id="saveAdminKeyBtn" class="btn">Save Session Key</button>
</div>
</header>
<section class="metrics-grid" id="metricsGrid">
<article class="metric-card">
<h2>Routes</h2>
<p id="metricRoutes">-</p>
</article>
<article class="metric-card">
<h2>Upstreams</h2>
<p id="metricUpstreams">-</p>
</article>
<article class="metric-card">
<h2>API Key Guard</h2>
<p id="metricKeyGuard">-</p>
</article>
<article class="metric-card">
<h2>Admin Auth</h2>
<p id="metricAdminAuth">-</p>
</article>
</section>
<section class="content-grid">
<article class="panel panel-wide">
<div class="panel-head">
<h2>Configuration Editor</h2>
<div class="actions">
<button id="loadConfigBtn" class="btn secondary">Load</button>
<button id="saveConfigBtn" class="btn">Save</button>
</div>
</div>
<p class="hint">YAML is validated before save. Structural route changes may require process restart.</p>
<textarea id="configText" spellcheck="false"></textarea>
<p id="configStatus" class="status"></p>
</article>
<article class="panel">
<div class="panel-head">
<h2>API Key Management</h2>
<button id="refreshKeysBtn" class="btn secondary">Refresh</button>
</div>
<div class="row">
<input id="apiKeyName" placeholder="Key label (for example mobile-client)" />
<button id="createKeyBtn" class="btn">Create</button>
</div>
<p id="newKeyValue" class="status"></p>
<ul id="keysList" class="list"></ul>
</article>
<article class="panel">
<div class="panel-head">
<h2>Gateway Routes</h2>
<button id="refreshRoutesBtn" class="btn secondary">Refresh</button>
</div>
<ul id="routesList" class="list list-tight"></ul>
</article>
</section>
</main>
<script src="/admin/portal/assets/app.js" defer></script>
</body>
</html>
@@ -0,0 +1,259 @@
:root {
--bg0: #f0f5eb;
--bg1: #dde9d9;
--panel: #ffffff;
--ink: #203126;
--muted: #607567;
--line: #cfdbcd;
--primary: #16724d;
--primary-strong: #0f5f40;
--danger: #a33838;
--shadow: 0 12px 28px rgba(19, 35, 25, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--ink);
background: linear-gradient(145deg, var(--bg0) 0%, #f7faf5 45%, var(--bg1) 100%);
font-family: "Space Grotesk", "Avenir Next", "Segoe UI", sans-serif;
min-height: 100vh;
}
.backdrop {
position: fixed;
inset: 0;
pointer-events: none;
background:
radial-gradient(60rem 25rem at -10% -5%, rgba(74, 145, 98, 0.25), transparent 65%),
radial-gradient(50rem 20rem at 120% 0%, rgba(22, 114, 77, 0.18), transparent 60%);
}
.layout {
position: relative;
max-width: 1220px;
margin: 0 auto;
padding: 2.25rem 1rem 2.5rem;
}
.hero {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 0.75rem;
margin: 0 0 0.5rem;
color: var(--muted);
}
h1 {
margin: 0;
font-size: clamp(1.7rem, 4vw, 2.6rem);
}
.subtitle {
margin-top: 0.5rem;
max-width: 62ch;
color: var(--muted);
}
.access-box,
.panel,
.metric-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: var(--shadow);
}
.access-box {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.access-box label {
font-size: 0.85rem;
color: var(--muted);
}
input,
textarea {
width: 100%;
border-radius: 10px;
border: 1px solid var(--line);
background: #fcfefb;
color: var(--ink);
padding: 0.7rem 0.8rem;
font-size: 0.95rem;
}
textarea {
min-height: 260px;
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
resize: vertical;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.8rem;
margin-bottom: 1rem;
}
.metric-card {
padding: 0.9rem 1rem;
}
.metric-card h2 {
margin: 0;
font-size: 0.82rem;
text-transform: uppercase;
color: var(--muted);
letter-spacing: 0.1em;
}
.metric-card p {
margin: 0.4rem 0 0;
font-size: 1.35rem;
font-weight: 600;
}
.content-grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 1rem;
align-items: start;
}
.panel {
padding: 1rem;
}
.panel-wide {
grid-row: span 2;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
margin-bottom: 0.5rem;
}
.panel h2 {
margin: 0;
font-size: 1rem;
}
.actions,
.row {
display: flex;
gap: 0.5rem;
}
.row {
margin-bottom: 0.6rem;
}
.btn {
border: 0;
border-radius: 10px;
background: var(--primary);
color: #fff;
padding: 0.58rem 0.85rem;
cursor: pointer;
font-weight: 600;
white-space: nowrap;
}
.btn:hover {
background: var(--primary-strong);
}
.btn.secondary {
background: #e8f2e7;
color: var(--ink);
border: 1px solid var(--line);
}
.btn.danger {
background: var(--danger);
}
.hint,
.status {
margin: 0.45rem 0;
color: var(--muted);
font-size: 0.85rem;
}
.list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.45rem;
}
.list li {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.55rem 0.6rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.9rem;
background: #fcfefb;
}
.list-tight li {
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
font-size: 0.82rem;
}
.pill {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.15rem 0.45rem;
font-size: 0.75rem;
color: var(--muted);
background: #f4faf2;
}
@media (max-width: 980px) {
.hero,
.content-grid {
grid-template-columns: 1fr;
}
.metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.panel-wide {
grid-row: auto;
}
}
@media (max-width: 560px) {
.metrics-grid {
grid-template-columns: 1fr;
}
.actions,
.row {
flex-wrap: wrap;
}
}
+32
View File
@@ -97,6 +97,38 @@ routes:
assert response.json()["error"] == "external_openapi_not_configured"
@pytest.mark.anyio
async def test_admin_portal_and_assets_are_served(tmp_path) -> None:
config_file = tmp_path / "gateway.yaml"
config_file.write_text(
"""
upstreams:
demo:
base_url: https://example.com/
routes:
- path: /api/v1/demo
methods: [GET]
upstream: demo
""".strip(),
encoding="utf-8",
)
app = create_app(str(config_file))
_prepare_state(app, config_file)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
portal = await client.get("/admin/portal")
css = await client.get("/admin/portal/assets/styles.css")
js = await client.get("/admin/portal/assets/app.js")
assert portal.status_code == 200
assert "Gateway Portal" in portal.text
assert css.status_code == 200
assert "metrics-grid" in css.text
assert js.status_code == 200
assert "bootstrap" in js.text
@pytest.mark.anyio
async def test_admin_api_key_lifecycle(tmp_path) -> None:
config_file = tmp_path / "gateway.yaml"