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