mirror of
https://github.com/furyhawk/api_gateway.git
synced 2026-07-21 02:06:50 +00:00
Add administrative portal with static assets and API key management features
This commit is contained in:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user