- 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>
148 lines
7.1 KiB
HTML
148 lines
7.1 KiB
HTML
{% 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>/<slug>/…</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 %}
|