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
+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 %}