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:
@@ -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 %}
|
||||
Reference in New Issue
Block a user