Initial commit: API gateway with admin console

- FastAPI async gateway with httpx proxying to multiple upstreams
- SQLite database with SQLAlchemy ORM
- Admin console: manage services, users, API keys, endpoint access
- Per-key, per-endpoint granular access control
- OpenAPI document sync and caching (5-minute TTL)
- Request/response logging with full transaction inspection
- In-memory rate limiting (per-key, fixed-window)
- Tiered log retention (7d payloads, 90d rows, incremental vacuum)
- TLS verification toggle per service (for self-signed certificates)
- Service connectivity validation with automatic endpoint refresh
- Request browser with filters and deep-link inspection
- Docker setup with persistent volume
- Modal forms for create/edit flows

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Samuel Amar
2026-07-29 14:00:22 +02:00
co-authored by Claude Haiku 4.5
commit 77d7a50fa9
31 changed files with 2927 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}API Gateway{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="layout">
<nav class="sidebar">
<div class="brand">API <span>Gateway</span></div>
<a class="nav-item {% if active == 'dashboard' %}active{% endif %}" href="/admin">Dashboard</a>
<a class="nav-item {% if active == 'services' %}active{% endif %}" href="/admin/services">Services</a>
<a class="nav-item {% if active == 'users' %}active{% endif %}" href="/admin/users">Users</a>
<a class="nav-item {% if active == 'keys' %}active{% endif %}" href="/admin/keys">API Keys</a>
<a class="nav-item {% if active == 'requests' %}active{% endif %}" href="/admin/requests">Requests</a>
<a class="nav-item {% if active == 'monitoring' %}active{% endif %}" href="/admin/monitoring">Monitoring</a>
<div class="spacer"></div>
{% if user %}
<div class="whoami">{{ user.username }}</div>
<a class="nav-item" href="/admin/logout">Log out</a>
{% endif %}
</nav>
<main class="main">
{% block content %}{% endblock %}
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script>
if (window.Chart) {
Chart.defaults.color = '#898781';
Chart.defaults.borderColor = '#2c2c2a';
Chart.defaults.font.family = 'system-ui, -apple-system, "Segoe UI", sans-serif';
Chart.defaults.plugins.legend.labels.boxWidth = 12;
Chart.defaults.plugins.legend.labels.boxHeight = 12;
Chart.defaults.animation.duration = 300;
}
const GW = {
blue: '#3987e5',
red: '#e66767',
ink2: '#c3c2b7',
grid: '#2c2c2a',
async fetchJSON(url) { const r = await fetch(url); return r.json(); },
};
// Destructive forms carry data-confirm. First click arms the button
// ("Confirm?"), a second click within 5s submits. No native dialogs —
// confirm() is unreliable in embedded browsers.
document.addEventListener('submit', e => {
const form = e.target;
if (!form.dataset.confirm) return;
if (form.dataset.armed === '1') return; // second click: let it through
e.preventDefault();
const btn = form.querySelector('button');
form.dataset.armed = '1';
if (!btn.dataset.orig) btn.dataset.orig = btn.innerHTML;
btn.innerHTML = 'Confirm?';
btn.classList.add('armed');
btn.title = form.dataset.confirm;
setTimeout(() => {
form.dataset.armed = '';
btn.innerHTML = btn.dataset.orig;
btn.classList.remove('armed');
}, 5000);
}, true);
// Modal overlays: [data-modal] opens the overlay with that id; the × button,
// a click on the backdrop, or Escape closes it.
document.addEventListener('click', e => {
const opener = e.target.closest('[data-modal]');
if (opener) {
e.preventDefault();
document.getElementById(opener.dataset.modal).classList.add('open');
return;
}
if (e.target.closest('.modal-close')) {
e.target.closest('.modal-overlay').classList.remove('open');
return;
}
if (e.target.classList.contains('modal-overlay')) e.target.classList.remove('open');
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape')
document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open'));
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+95
View File
@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}Dashboard · API Gateway{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<div class="tiles" id="tiles">
<div class="tile"><div class="label">Requests (24h)</div><div class="value" id="t-total"></div></div>
<div class="tile"><div class="label">Error rate (24h)</div><div class="value" id="t-errors"></div></div>
<div class="tile"><div class="label">Avg latency (24h)</div><div class="value" id="t-latency"></div></div>
<div class="tile"><div class="label">Active services</div><div class="value" id="t-services"></div></div>
<div class="tile"><div class="label">Active API keys</div><div class="value" id="t-keys"></div></div>
</div>
<div class="card">
<h2>Traffic — last 24 hours</h2>
<div class="chart-box"><canvas id="chart-traffic"></canvas></div>
</div>
<div class="grid cols-2">
<div class="card">
<h2>Requests by service (24h)</h2>
<div class="chart-box"><canvas id="chart-services"></canvas></div>
</div>
<div class="card">
<h2>Recent requests</h2>
<table>
<thead><tr><th>Time (UTC)</th><th>Service</th><th>Path</th><th>Status</th><th>ms</th></tr></thead>
<tbody>
{% for log in recent %}
<tr>
<td>{{ log.timestamp.strftime("%H:%M:%S") }}</td>
<td class="strong">{{ log.service.name if log.service else "" }}</td>
<td>{{ log.method }} {{ log.path[:40] }}</td>
<td class="status-{{ log.status_code // 100 }}xx">{{ log.status_code }}</td>
<td>{{ "%.0f" | format(log.latency_ms) }}</td>
</tr>
{% else %}
<tr><td colspan="5">No traffic yet. Send a request through <code>/gw/&lt;service&gt;/&lt;path&gt;</code>.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(async () => {
const s = await GW.fetchJSON('/admin/api/stats/summary?hours=24');
document.getElementById('t-total').textContent = s.total_requests.toLocaleString();
document.getElementById('t-errors').textContent = s.error_rate + '%';
document.getElementById('t-latency').innerHTML = s.avg_latency_ms + '<small> ms</small>';
document.getElementById('t-services').textContent = s.active_services;
document.getElementById('t-keys').textContent = s.active_keys;
const ts = await GW.fetchJSON('/admin/api/stats/timeseries?hours=24');
new Chart(document.getElementById('chart-traffic'), {
type: 'line',
data: {
labels: ts.labels.map(l => l.slice(11)),
datasets: [
{ label: 'Succeeded', data: ts.ok, borderColor: GW.blue, backgroundColor: GW.blue,
borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, tension: 0.3 },
{ label: 'Errors (5xx)', data: ts.errors, borderColor: GW.red, backgroundColor: GW.red,
borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, tension: 0.3 },
],
},
options: {
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: {
x: { grid: { display: false }, ticks: { maxTicksLimit: 12 } },
y: { beginAtZero: true, ticks: { precision: 0 } },
},
},
});
const bs = await GW.fetchJSON('/admin/api/stats/by-service?hours=24');
new Chart(document.getElementById('chart-services'), {
type: 'bar',
data: {
labels: bs.labels,
datasets: [{ label: 'Requests', data: bs.counts, backgroundColor: GW.blue,
borderRadius: 4, maxBarThickness: 26 }],
},
options: {
maintainAspectRatio: false,
indexAxis: 'y',
plugins: { legend: { display: false } },
scales: { x: { beginAtZero: true, ticks: { precision: 0 } }, y: { grid: { display: false } } },
},
});
})();
</script>
{% endblock %}
+239
View File
@@ -0,0 +1,239 @@
{% extends "base.html" %}
{% block title %}API Keys · API Gateway{% endblock %}
{% macro trash(label) %}
<button class="icon" title="{{ label }}" aria-label="{{ label }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
</button>
{% endmacro %}
{% macro pencil(label, modal) %}
<button class="icon edit" data-modal="{{ modal }}" title="{{ label }}" aria-label="{{ label }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
</button>
{% endmacro %}
{% macro close_button() %}
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
{% endmacro %}
{% macro tree_node(node, granted) %}
{% if node.children %}
<details open>
<summary>
<label onclick="event.stopPropagation()">
<input type="checkbox" class="group-box"> <span class="seg">{{ node.name }}</span>
</label>
</summary>
<div class="tree-children">
{% for e in node.endpoints | sort(attribute='method') %}
<label>
<input type="checkbox" name="endpoint_ids" value="{{ e.id }}" {% if e.id in granted %}checked{% endif %}>
<span class="mchip">{{ 'ANY' if e.method == '*' else e.method }}</span> {{ node.name }}
{% if e.description %}<span class="ep-desc">— {{ e.description }}</span>{% endif %}
</label>
{% endfor %}
{% for name, child in node.children | dictsort %}
{{ tree_node(child, granted) }}
{% endfor %}
</div>
</details>
{% else %}
{% for e in node.endpoints | sort(attribute='method') %}
<label>
<input type="checkbox" name="endpoint_ids" value="{{ e.id }}" {% if e.id in granted %}checked{% endif %}>
<span class="mchip">{{ 'ANY' if e.method == '*' else e.method }}</span> {{ node.name }}
{% if e.description %}<span class="ep-desc">— {{ e.description }}</span>{% endif %}
</label>
{% endfor %}
{% endif %}
{% endmacro %}
{% macro endpoint_picker(services, trees, synced, granted) %}
<div class="tree">
{% for s in services %}
<details open>
<summary>
<label onclick="event.stopPropagation()">
<input type="checkbox" class="group-box">
<span class="seg">{{ s.name }}</span>
<code>/{{ s.slug }}</code>
{% if synced.get(s.id) is sameas false %}<span class="ep-desc">(no OpenAPI document — catalog not auto-synced)</span>{% endif %}
</label>
</summary>
<div class="tree-children">
{% for e in trees[s.id].endpoints | sort(attribute='method') %}
<label>
<input type="checkbox" name="endpoint_ids" value="{{ e.id }}" {% if e.id in granted %}checked{% endif %}>
<span class="mchip">{{ 'ANY' if e.method == '*' else e.method }}</span> /
{% if e.description %}<span class="ep-desc">— {{ e.description }}</span>{% endif %}
</label>
{% endfor %}
{% for name, child in trees[s.id].children | dictsort %}
{{ tree_node(child, granted) }}
{% endfor %}
{% if not trees[s.id].children and not trees[s.id].endpoints %}
<span class="hint">No endpoints known for this service.</span>
{% endif %}
</div>
</details>
{% else %}
<span class="hint">Register a service first.</span>
{% endfor %}
</div>
{% endmacro %}
{% block content %}
<div class="row" style="margin-bottom:20px">
<h1 style="margin:0">API Keys &amp; Access</h1>
<button class="right" data-modal="modal-new-key">+ Issue key</button>
</div>
{% if new_key %}
<div class="alert success">
New API key created — copy it now, it will not be shown again:<br>
<code style="font-size:14px">{{ new_key }}</code>
</div>
{% endif %}
<div class="card">
<table>
<thead><tr><th>Name</th><th>Owner</th><th>Key</th><th>Access</th><th>Rate limit</th><th>Last used</th><th>On</th><th></th></tr></thead>
<tbody>
{% for k in keys %}
<tr>
<td class="strong">{{ k.name }}</td>
<td>{{ k.user.username }}</td>
<td><code>{{ k.prefix }}…</code></td>
<td>
{% if k.endpoints %}{{ k.endpoints | length }} endpoint{{ '' if k.endpoints | length == 1 else 's' }}
{% else %}<span class="badge off">none</span>{% endif %}
</td>
<td>{{ k.rate_limit_per_minute if k.rate_limit_per_minute else '∞' }}/min</td>
<td>{{ k.last_used_at.strftime("%Y-%m-%d %H:%M") if k.last_used_at else 'never' }}</td>
<td>
<form class="inline" method="post" action="/admin/keys/{{ k.id }}/toggle">
<label class="switch" title="{{ 'Revoke' if k.is_active else 'Restore' }}">
<input type="checkbox" {% if k.is_active %}checked{% endif %} onchange="this.form.submit()">
<span class="slider"></span>
</label>
</form>
</td>
<td style="white-space:nowrap">
{{ pencil('Edit access for ' ~ k.name, 'modal-key-' ~ k.id) }}
<form class="inline" method="post" action="/admin/keys/{{ k.id }}/delete"
data-confirm="Permanently deletes key {{ k.name }}">
{{ trash('Delete key ' ~ k.name) }}
</form>
</td>
</tr>
{% else %}
<tr><td colspan="8">No API keys yet.</td></tr>
{% endfor %}
</tbody>
</table>
<div class="hint">Endpoint catalogs are mirrored from each service's OpenAPI document
(refreshed at most every 5 minutes; grants on removed endpoints are cleaned up).
<a href="#" id="sync-now">Refresh now</a>
<span id="sync-status"></span></div>
</div>
<div class="card">
<h2>How consumers call the gateway</h2>
<p style="color:var(--ink-2)">Send requests to <code>/&lt;service-slug&gt;/&lt;path&gt;</code> with the header <code>X-API-Key: &lt;key&gt;</code>. The request must match one of the service's endpoints and the key must hold a grant on it.</p>
</div>
{% for k in keys %}
<div class="modal-overlay" id="modal-key-{{ k.id }}" role="dialog" aria-modal="true" aria-label="Edit access for {{ k.name }}">
<div class="modal">
<h2>Access for {{ k.name }} <span class="hint">owned by {{ k.user.username }}</span></h2>
{{ close_button() }}
<form method="post" action="/admin/keys/{{ k.id }}/access">
{% set granted = k.endpoints | map(attribute='id') | list %}
{{ endpoint_picker(services, trees, synced, granted) }}
<div class="row" style="margin-top:14px">
<label style="margin:0">Rate limit/min (0 = unlimited)</label>
<input type="number" name="rate_limit_per_minute" value="{{ k.rate_limit_per_minute }}" min="0" style="max-width:110px">
<button>Save</button>
</div>
</form>
</div>
</div>
{% endfor %}
<div class="modal-overlay" id="modal-new-key" role="dialog" aria-modal="true" aria-label="Issue a new key">
<div class="modal">
<h2>Issue a new key</h2>
{{ close_button() }}
<form method="post" action="/admin/keys">
<div class="grid cols-3">
<div><label>Key name</label><input type="text" name="name" placeholder="mobile-app-prod" required></div>
<div><label>Owner</label>
<select name="user_id">
{% for u in users %}<option value="{{ u.id }}">{{ u.username }}</option>{% endfor %}
</select>
</div>
<div><label>Rate limit/min (0 = unlimited)</label>
<input type="number" name="rate_limit_per_minute" value="60" min="0"></div>
</div>
<label>Endpoint access</label>
{{ endpoint_picker(services, trees, synced, []) }}
<div style="margin-top:14px"><button>Create key</button></div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// The page renders instantly from the stored catalog; the OpenAPI sync runs
// here in the background. Spinners sit on the endpoint trees while it's
// pending, and the page reloads only if the catalog actually changed.
async function syncCatalogs(force = false) {
const trees = document.querySelectorAll('.tree');
const status = document.getElementById('sync-status');
trees.forEach(t => t.classList.add('syncing'));
status.innerHTML = '<span class="spinner"></span> refreshing catalogs…';
try {
const r = await GW.fetchJSON('/admin/api/endpoints/sync' + (force ? '?force=1' : ''));
if (r.changed) { location.reload(); return; }
status.textContent = 'up to date';
} catch {
status.textContent = 'refresh failed';
} finally {
trees.forEach(t => t.classList.remove('syncing'));
}
}
syncCatalogs();
document.getElementById('sync-now').addEventListener('click', e => {
e.preventDefault();
syncCatalogs(true);
});
// Hierarchical picker: a group checkbox selects everything beneath it;
// leaf changes roll their state back up (checked / indeterminate).
function refreshGroups(scope) {
[...scope.querySelectorAll('.tree details')].reverse().forEach(d => {
const group = d.querySelector(':scope > summary .group-box');
const leaves = d.querySelectorAll('input[name=endpoint_ids]');
if (!group || !leaves.length) return;
const checked = [...leaves].filter(b => b.checked).length;
group.checked = checked === leaves.length;
group.indeterminate = checked > 0 && checked < leaves.length;
});
}
document.querySelectorAll('.tree').forEach(tree => {
tree.addEventListener('change', e => {
if (e.target.classList.contains('group-box')) {
const details = e.target.closest('details');
details.querySelectorAll('input[name=endpoint_ids]').forEach(b => { b.checked = e.target.checked; });
}
refreshGroups(tree);
});
refreshGroups(tree);
});
</script>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · API Gateway</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="login-wrap">
<div class="card login-card">
<div class="brand" style="font-size:18px;font-weight:700;margin-bottom:14px;">
API <span style="color:var(--series-1)">Gateway</span>
</div>
{% if error %}<div class="alert error">{{ error }}</div>{% endif %}
<form method="post" action="/admin/login">
<label for="username">Username</label>
<input id="username" type="text" name="username" required autofocus>
<label for="password">Password</label>
<input id="password" type="password" name="password" required>
<div style="margin-top:16px"><button type="submit" style="width:100%">Sign in</button></div>
</form>
</div>
</div>
</body>
</html>
+187
View File
@@ -0,0 +1,187 @@
{% extends "base.html" %}
{% block title %}Monitoring · API Gateway{% endblock %}
{% block content %}
<h1>Usage Monitoring</h1>
<div class="row" style="margin-bottom:16px">
<div class="range-picker" role="group" aria-label="Time range" style="margin:0">
<button class="ghost" data-hours="1">1 h</button>
<button class="ghost" data-hours="6">6 h</button>
<button class="ghost" data-hours="24">24 h</button>
<button class="ghost" data-hours="72">3 days</button>
<button class="ghost" data-hours="168">7 days</button>
<button class="ghost" data-hours="720">30 days</button>
</div>
<select id="f-service" class="right" style="max-width:170px">
<option value="">All services</option>
{% for s in services %}<option value="{{ s.id }}">{{ s.name }}</option>{% endfor %}
</select>
<select id="f-user" style="max-width:150px">
<option value="">All users</option>
{% for u in users %}<option value="{{ u.id }}">{{ u.username }}</option>{% endfor %}
</select>
<select id="f-key" style="max-width:150px">
<option value="">All keys</option>
{% for k in keys %}<option value="{{ k.id }}">{{ k.name }}</option>{% endfor %}
</select>
</div>
<div class="tiles">
<div class="tile"><div class="label">Requests</div><div class="value" id="t-total"></div></div>
<div class="tile"><div class="label">Errors (5xx)</div><div class="value" id="t-errors"></div></div>
<div class="tile"><div class="label">Error rate</div><div class="value" id="t-rate"></div></div>
<div class="tile"><div class="label">Avg latency</div><div class="value" id="t-latency"></div></div>
</div>
<div class="grid cols-2">
<div class="card">
<h2>Requests over time</h2>
<div class="chart-box"><canvas id="chart-traffic"></canvas></div>
</div>
<div class="card">
<h2>Average latency over time (ms)</h2>
<div class="chart-box"><canvas id="chart-latency-ts"></canvas></div>
</div>
<div class="card">
<h2>Requests by service</h2>
<div class="chart-box"><canvas id="chart-services"></canvas></div>
</div>
<div class="card">
<h2>Responses by status code</h2>
<div class="chart-box"><canvas id="chart-status"></canvas></div>
</div>
<div class="card">
<h2>Requests by user</h2>
<div class="chart-box"><canvas id="chart-users"></canvas></div>
</div>
<div class="card">
<h2>Requests by endpoint</h2>
<div class="chart-box"><canvas id="chart-endpoints"></canvas></div>
</div>
<div class="card">
<h2>Average latency by service (ms)</h2>
<div class="chart-box"><canvas id="chart-latency"></canvas></div>
</div>
<div class="card">
<h2>Top API keys by usage</h2>
<div class="chart-box"><canvas id="chart-keys"></canvas></div>
</div>
</div>
<div class="card">
<h2>Latest 100 requests</h2>
<table>
<thead><tr><th>Time (UTC)</th><th>Service</th><th>Key</th><th>User</th><th>Endpoint</th><th>Request</th><th>Status</th><th>Latency</th><th>Client IP</th></tr></thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}</td>
<td class="strong">{{ log.service.name if log.service else "" }}</td>
<td>{{ log.api_key.name if log.api_key else "" }}</td>
<td>{{ log.api_key.user.username if log.api_key else "" }}</td>
<td>{% if log.endpoint %}<code>{{ log.endpoint.path }}</code>{% else %}{% endif %}</td>
<td><a href="/admin/requests?inspect={{ log.id }}">{{ log.method }} {{ log.path[:50] }}</a></td>
<td class="status-{{ log.status_code // 100 }}xx">{{ log.status_code }}</td>
<td>{{ "%.1f" | format(log.latency_ms) }} ms</td>
<td>{{ log.client_ip }}</td>
</tr>
{% else %}
<tr><td colspan="9">No requests logged yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
{% block scripts %}
<script>
const charts = {};
let hours = 24;
function qs() {
const p = new URLSearchParams({ hours });
for (const [param, id] of [['service_id', 'f-service'], ['user_id', 'f-user'], ['key_id', 'f-key']]) {
const v = document.getElementById(id).value;
if (v) p.set(param, v);
}
return p.toString();
}
function barChart(id, labels, data, horizontal = false) {
if (charts[id]) charts[id].destroy();
charts[id] = new Chart(document.getElementById(id), {
type: 'bar',
data: { labels, datasets: [{ data, backgroundColor: GW.blue, borderRadius: 4, maxBarThickness: 26 }] },
options: {
maintainAspectRatio: false,
indexAxis: horizontal ? 'y' : 'x',
plugins: { legend: { display: false } },
scales: horizontal
? { x: { beginAtZero: true }, y: { grid: { display: false } } }
: { x: { grid: { display: false } }, y: { beginAtZero: true, ticks: { precision: 0 } } },
},
});
}
function lineChart(id, labels, datasets, showLegend) {
if (charts[id]) charts[id].destroy();
charts[id] = new Chart(document.getElementById(id), {
type: 'line',
data: { labels, datasets },
options: {
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { display: showLegend } },
scales: {
x: { grid: { display: false }, ticks: { maxTicksLimit: 14 } },
y: { beginAtZero: true },
},
},
});
}
const lineStyle = { borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, tension: 0.3, spanGaps: true };
async function load() {
const query = qs();
const [sum, ts, lts, bs, st, lat, keys, byUser, byEndpoint] = await Promise.all([
'summary', 'timeseries', 'latency-timeseries', 'by-service', 'by-status',
'latency-by-service', 'top-keys', 'by-user', 'by-endpoint',
].map(ep => GW.fetchJSON(`/admin/api/stats/${ep}?${query}`)));
document.getElementById('t-total').textContent = sum.total_requests.toLocaleString();
document.getElementById('t-errors').textContent = sum.error_count.toLocaleString();
document.getElementById('t-rate').textContent = sum.error_rate + '%';
document.getElementById('t-latency').innerHTML = sum.avg_latency_ms + '<small> ms</small>';
lineChart('chart-traffic', ts.labels, [
{ label: 'Succeeded', data: ts.ok, borderColor: GW.blue, backgroundColor: GW.blue, ...lineStyle },
{ label: 'Errors (5xx)', data: ts.errors, borderColor: GW.red, backgroundColor: GW.red, ...lineStyle },
], true);
lineChart('chart-latency-ts', lts.labels, [
{ label: 'Avg latency (ms)', data: lts.avg_ms, borderColor: GW.blue, backgroundColor: GW.blue, ...lineStyle },
], false);
barChart('chart-services', bs.labels, bs.counts, true);
barChart('chart-status', st.labels, st.counts);
barChart('chart-users', byUser.labels, byUser.counts, true);
barChart('chart-endpoints', byEndpoint.labels, byEndpoint.counts, true);
barChart('chart-latency', lat.labels, lat.avg_ms, true);
barChart('chart-keys', keys.labels, keys.counts, true);
}
document.querySelectorAll('.range-picker button').forEach(b =>
b.addEventListener('click', () => {
hours = +b.dataset.hours;
document.querySelectorAll('.range-picker button').forEach(x =>
x.classList.toggle('active', x === b));
load();
}));
['f-service', 'f-user', 'f-key'].forEach(id =>
document.getElementById(id).addEventListener('change', load));
document.querySelector('.range-picker button[data-hours="24"]').classList.add('active');
load();
</script>
{% endblock %}
+173
View File
@@ -0,0 +1,173 @@
{% extends "base.html" %}
{% block title %}Requests · API Gateway{% endblock %}
{% block content %}
<h1>Requests</h1>
<div class="req-split">
<div class="card">
<form method="get" action="/admin/requests" class="row" id="filter-form">
<select name="service_id" style="max-width:160px">
<option value="">All services</option>
{% for s in services %}<option value="{{ s.id }}" {% if f.service_id == s.id %}selected{% endif %}>{{ s.name }}</option>{% endfor %}
</select>
<select name="user_id" style="max-width:140px">
<option value="">All users</option>
{% for u in users %}<option value="{{ u.id }}" {% if f.user_id == u.id %}selected{% endif %}>{{ u.username }}</option>{% endfor %}
</select>
<select name="key_id" style="max-width:150px">
<option value="">All keys</option>
{% for k in keys %}<option value="{{ k.id }}" {% if f.key_id == k.id %}selected{% endif %}>{{ k.name }}</option>{% endfor %}
</select>
<select name="status_class" style="max-width:120px">
<option value="">All statuses</option>
{% for c in ['2', '3', '4', '5'] %}
<option value="{{ c }}" {% if f.status_class == c %}selected{% endif %}>{{ c }}xx</option>
{% endfor %}
</select>
<input type="text" name="q" value="{{ f.q }}" placeholder="Path contains…" style="max-width:200px">
<span class="right hint">{{ total }} request{{ '' if total == 1 else 's' }}</span>
</form>
</div>
<div class="card req-list">
<div class="table-scroll">
<table id="req-table">
<thead><tr><th>Time (UTC)</th><th>Service</th><th>Key</th><th>Request</th><th>Status</th><th>Latency</th></tr></thead>
<tbody>
{% for log in logs %}
<tr class="req-row" data-id="{{ log.id }}" tabindex="0" role="button"
aria-label="Inspect request {{ log.id }}">
<td>{{ log.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}</td>
<td class="strong">{{ log.service.name if log.service else "" }}</td>
<td>{{ log.api_key.name if log.api_key else "" }}</td>
<td>{{ log.method }} {{ log.path[:60] }}{% if log.query_string %}?{{ log.query_string[:30] }}{% endif %}</td>
<td class="status-{{ log.status_code // 100 }}xx">{{ log.status_code }}</td>
<td>{{ "%.1f" | format(log.latency_ms) }} ms</td>
</tr>
{% else %}
<tr><td colspan="6">No requests match these filters.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pages > 1 %}
{% set base = '/admin/requests?service_id=' ~ (f.service_id or '') ~ '&user_id=' ~ (f.user_id or '') ~ '&key_id=' ~ (f.key_id or '') ~ '&status_class=' ~ f.status_class ~ '&q=' ~ f.q %}
<div class="row" style="margin-top:12px">
{% if page > 1 %}<a class="btn ghost" href="{{ base }}&page={{ page - 1 }}" style="padding:5px 10px">← Newer</a>{% endif %}
<span class="hint">Page {{ page }} of {{ pages }}</span>
{% if page < pages %}<a class="btn ghost" href="{{ base }}&page={{ page + 1 }}" style="padding:5px 10px">Older →</a>{% endif %}
</div>
{% endif %}
</div>
<div class="card" id="inspector" hidden>
<div class="row" style="margin-bottom:12px">
<h2 style="margin:0">Request <span id="i-id"></span></h2>
<span id="i-status" class="badge"></span>
<span class="hint" id="i-latency"></span>
<span class="hint" id="i-time"></span>
<button type="button" class="icon right" id="i-close" title="Close inspector" aria-label="Close inspector">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="inspector-body">
<table style="margin-bottom:16px">
<tbody>
<tr><td class="strong" style="width:160px">Gateway request</td><td><code id="i-gwreq"></code></td></tr>
<tr><td class="strong">Forwarded to</td><td><code id="i-fwd"></code></td></tr>
<tr><td class="strong">Service</td><td id="i-service"></td></tr>
<tr><td class="strong">Matched endpoint</td><td id="i-endpoint"></td></tr>
<tr><td class="strong">API key</td><td id="i-key"></td></tr>
<tr><td class="strong">User</td><td id="i-user"></td></tr>
<tr><td class="strong">Client IP</td><td id="i-ip"></td></tr>
</tbody>
</table>
<div class="grid cols-2">
<div>
<h2>Request payload</h2>
<pre id="i-reqbody"></pre>
</div>
<div>
<h2>Response payload</h2>
<pre id="i-resbody"></pre>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const inspector = document.getElementById('inspector');
const $ = id => document.getElementById(id);
async function inspect(id) {
const r = await fetch(`/admin/requests/${id}/data`);
if (!r.ok) return;
const d = await r.json();
$('i-id').textContent = '#' + d.id;
$('i-status').textContent = d.status;
$('i-status').className = `badge status-${Math.floor(d.status / 100)}xx`;
$('i-latency').textContent = d.latency_ms + ' ms';
$('i-time').textContent = d.time + ' UTC';
const qs = d.query_string ? '?' + d.query_string : '';
$('i-gwreq').textContent = `${d.method} /${d.slug ?? '?'}${d.path}${qs}`;
$('i-fwd').textContent = d.forwarded_to ?? '';
$('i-service').textContent = d.service ?? '';
$('i-endpoint').textContent = d.endpoint
? d.endpoint + (d.endpoint_description ? ' — ' + d.endpoint_description : '') : '';
$('i-key').textContent = d.key ? `${d.key} (${d.key_prefix}…)` : '';
$('i-user').textContent = d.user ?? '';
$('i-ip').textContent = d.client_ip || '';
$('i-reqbody').textContent = d.request_body || 'No request body.';
$('i-resbody').textContent = d.response_body || 'No response body captured.';
document.querySelectorAll('.req-row').forEach(row =>
row.classList.toggle('selected', +row.dataset.id === d.id));
inspector.hidden = false;
inspector.querySelector('.inspector-body').scrollTop = 0;
const url = new URL(location);
url.searchParams.set('inspect', d.id);
history.replaceState(null, '', url);
}
document.querySelectorAll('.req-row').forEach(row => {
row.addEventListener('click', () => inspect(+row.dataset.id));
row.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); inspect(+row.dataset.id); }
});
});
document.getElementById('i-close').addEventListener('click', () => {
inspector.hidden = true;
document.querySelectorAll('.req-row.selected').forEach(r => r.classList.remove('selected'));
const url = new URL(location);
url.searchParams.delete('inspect');
history.replaceState(null, '', url);
});
const preselected = new URLSearchParams(location.search).get('inspect');
if (preselected) inspect(+preselected);
// Filters apply immediately; empty fields are omitted from the URL.
const filterForm = document.getElementById('filter-form');
function applyFilters() {
const p = new URLSearchParams();
for (const el of filterForm.elements) {
if (el.name && el.value) p.set(el.name, el.value);
}
location.href = '/admin/requests' + (p.size ? '?' + p.toString() : '');
}
filterForm.addEventListener('submit', e => { e.preventDefault(); applyFilters(); });
filterForm.querySelectorAll('select').forEach(s => s.addEventListener('change', applyFilters));
let debounce;
filterForm.querySelector('input[name=q]').addEventListener('input', () => {
clearTimeout(debounce);
debounce = setTimeout(applyFilters, 500);
});
</script>
{% endblock %}
+147
View File
@@ -0,0 +1,147 @@
{% extends "base.html" %}
{% block title %}Services · API Gateway{% endblock %}
{% macro trash(label) %}
<button class="icon" title="{{ label }}" aria-label="{{ label }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
</button>
{% endmacro %}
{% macro pencil(label, modal) %}
<button class="icon edit" data-modal="{{ modal }}" title="{{ label }}" aria-label="{{ label }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
</button>
{% endmacro %}
{% block content %}
<div class="row" style="margin-bottom:20px">
<h1 style="margin:0">Connected APIs</h1>
<button class="right" data-modal="modal-new-service">+ Add service</button>
</div>
<div class="card">
<table>
<thead>
<tr><th>Name</th><th>Gateway route</th><th>Upstream</th><th>Timeout</th><th>Requests</th><th>Connectivity</th><th>On</th><th></th></tr>
</thead>
<tbody>
{% for s in services %}
<tr>
<td class="strong">{{ s.name }}</td>
<td><code>/{{ s.slug }}/…</code></td>
<td>{{ s.base_url }}</td>
<td>{{ s.timeout_seconds }}s</td>
<td>{{ counts.get(s.id, 0) }}</td>
<td style="white-space:nowrap">
<button type="button" class="ghost validate-btn" data-id="{{ s.id }}">Validate</button>
<span class="vresult" id="vresult-{{ s.id }}"></span>
</td>
<td>
<form class="inline" method="post" action="/admin/services/{{ s.id }}/toggle">
<label class="switch" title="{{ 'Turn off' if s.is_active else 'Turn on' }}">
<input type="checkbox" {% if s.is_active %}checked{% endif %} onchange="this.form.submit()">
<span class="slider"></span>
</label>
</form>
</td>
<td style="white-space:nowrap">
{{ pencil('Edit ' ~ s.name, 'modal-service-' ~ s.id) }}
<form class="inline" method="post" action="/admin/services/{{ s.id }}/delete"
data-confirm="Deletes {{ s.name }} and its endpoints">
{{ trash('Delete ' ~ s.name) }}
</form>
</td>
</tr>
{% else %}
<tr><td colspan="8">No services registered yet.</td></tr>
{% endfor %}
</tbody>
</table>
<div class="hint">Endpoints are managed on the <a href="/admin/keys">API Keys</a> page,
where each service's catalog is kept in sync with its OpenAPI document.</div>
</div>
{% for s in services %}
<div class="modal-overlay" id="modal-service-{{ s.id }}" role="dialog" aria-modal="true" aria-label="Edit {{ s.name }}">
<div class="modal">
<h2>Edit {{ s.name }}</h2>
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<form method="post" action="/admin/services/{{ s.id }}/update" class="grid cols-2">
<div><label>Name</label><input type="text" name="name" value="{{ s.name }}" required></div>
<div><label>Base URL</label><input type="url" name="base_url" value="{{ s.base_url }}" required></div>
<div><label>Timeout (seconds)</label><input type="number" name="timeout_seconds" value="{{ s.timeout_seconds }}" step="0.5" min="1"></div>
<div><label>Description</label><input type="text" name="description" value="{{ s.description }}"></div>
<div style="grid-column:1/-1" class="checks">
<label><input type="checkbox" name="verify_tls" {% if s.verify_tls %}checked{% endif %}>
Verify TLS certificate <span class="hint" style="margin:0">(untick for self-signed / internal-CA upstreams)</span></label>
</div>
<div><button>Save changes</button></div>
</form>
</div>
</div>
{% endfor %}
<div class="modal-overlay" id="modal-new-service" role="dialog" aria-modal="true" aria-label="Register a new API">
<div class="modal">
<h2>Register a new API</h2>
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<form method="post" action="/admin/services" class="grid cols-2">
<div><label>Name</label><input type="text" name="name" placeholder="Weather API" required></div>
<div><label>Slug (route segment)</label><input type="text" name="slug" placeholder="weather" pattern="[a-z0-9\-]+" required>
<div class="hint">Consumers will call <code>/&lt;slug&gt;/…</code></div></div>
<div><label>Base URL</label><input type="url" name="base_url" placeholder="https://api.example.com/v1" required></div>
<div><label>Timeout (seconds)</label><input type="number" name="timeout_seconds" value="30" step="0.5" min="1"></div>
<div style="grid-column:1/-1"><label>Description</label><input type="text" name="description" placeholder="Optional"></div>
<div style="grid-column:1/-1" class="checks">
<label><input type="checkbox" name="verify_tls" checked>
Verify TLS certificate <span class="hint" style="margin:0">(untick for self-signed / internal-CA upstreams)</span></label>
</div>
<div><button>Add service</button></div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Connectivity probe. Finding an OpenAPI document also refreshes the
// endpoint cache, so a validation doubles as an endpoint import.
async function validateService(id) {
const out = document.getElementById('vresult-' + id);
if (!out) return;
out.className = 'vresult';
out.innerHTML = '<span class="spinner"></span>';
try {
const resp = await fetch(`/admin/services/${id}/validate`, { method: 'POST' });
const d = await resp.json();
if (d.ok && d.spec_found) {
out.className = 'vresult ok';
out.textContent = `✓ reachable · ${d.endpoints} endpoints cached · ${d.latency_ms} ms`;
} else if (d.ok) {
out.className = 'vresult warn';
out.textContent = `✓ reachable (HTTP ${d.status_code}) · no OpenAPI document · ${d.latency_ms} ms`;
} else {
out.className = 'vresult err';
out.textContent = `✗ unreachable — ${d.error}`;
}
} catch {
out.className = 'vresult err';
out.textContent = '✗ validation failed';
}
}
document.querySelectorAll('.validate-btn').forEach(btn =>
btn.addEventListener('click', () => validateService(btn.dataset.id)));
// A freshly registered service is validated automatically.
const pending = new URLSearchParams(location.search).get('validate');
if (pending) {
validateService(pending);
history.replaceState(null, '', '/admin/services');
}
</script>
{% endblock %}
+91
View File
@@ -0,0 +1,91 @@
{% extends "base.html" %}
{% block title %}Users · API Gateway{% endblock %}
{% macro trash(label) %}
<button class="icon" title="{{ label }}" aria-label="{{ label }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
</button>
{% endmacro %}
{% macro pencil(label, modal) %}
<button class="icon edit" data-modal="{{ modal }}" title="{{ label }}" aria-label="{{ label }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
</button>
{% endmacro %}
{% block content %}
<div class="row" style="margin-bottom:20px">
<h1 style="margin:0">Users</h1>
<button class="right" data-modal="modal-new-user">+ Add user</button>
</div>
<div class="card">
<table>
<thead><tr><th>Username</th><th>API keys</th><th>Created</th><th>On</th><th></th></tr></thead>
<tbody>
{% for u in users %}
<tr>
<td class="strong">{{ u.username }}</td>
<td>{{ u.api_keys | length }}</td>
<td>{{ u.created_at.strftime("%Y-%m-%d") }}</td>
<td>
{% if u.id != user.id %}
<form class="inline" method="post" action="/admin/users/{{ u.id }}/toggle">
<label class="switch" title="{{ 'Disable' if u.is_active else 'Enable' }}">
<input type="checkbox" {% if u.is_active %}checked{% endif %} onchange="this.form.submit()">
<span class="slider"></span>
</label>
</form>
{% else %}
<label class="switch" title="You cannot deactivate your own account">
<input type="checkbox" checked disabled><span class="slider" style="opacity:.5"></span>
</label>
{% endif %}
</td>
<td style="white-space:nowrap">
{{ pencil('Edit ' ~ u.username, 'modal-user-' ~ u.id) }}
{% if u.id != user.id %}
<form class="inline" method="post" action="/admin/users/{{ u.id }}/delete"
data-confirm="Deletes {{ u.username }} and all their API keys">
{{ trash('Delete ' ~ u.username) }}
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="hint">Every user can sign in to this console and own API keys.
Gateway access itself is granted per key on the <a href="/admin/keys">API Keys</a> page.</div>
</div>
{% for u in users %}
<div class="modal-overlay" id="modal-user-{{ u.id }}" role="dialog" aria-modal="true" aria-label="Edit {{ u.username }}">
<div class="modal">
<h2>Edit {{ u.username }}</h2>
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<form method="post" action="/admin/users/{{ u.id }}/password">
<label>New password</label>
<input type="password" name="password" required>
<div style="margin-top:14px"><button>Reset password</button></div>
</form>
</div>
</div>
{% endfor %}
<div class="modal-overlay" id="modal-new-user" role="dialog" aria-modal="true" aria-label="Add a user">
<div class="modal">
<h2>Add a user</h2>
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<form method="post" action="/admin/users" class="grid cols-2">
<div><label>Username</label><input type="text" name="username" required></div>
<div><label>Password</label><input type="password" name="password" required></div>
<div><button>Create user</button></div>
</form>
</div>
</div>
{% endblock %}