- 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>
188 lines
7.3 KiB
HTML
188 lines
7.3 KiB
HTML
{% 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 %}
|