feat(services): traefik routing overrides via allowlisted middleware labels (#64)

services.traefik_labels (jsonb) holds operator-defined Traefik
middlewares keyed 'middlewares.<name>.<type>[.<field>]', validated
against an allowlist at write time. edgeLabels (extracted from
createReplica) scopes each definition to the service router as
<router>-<name> and auto-attaches it to the router's middleware chain
after builtins. Router/service/provider-level keys are rejected.

Surfaces: create/update API fields, services create/update
--traefik-label (repeatable, key=value), MCP arg docs, OpenAPI +
regenerated types, service-page Access editor (Routing overrides
card + textarea).

Also marks three stale ROADMAP2 items shipped: notification producers
round-out, build-on-node (PR #60), mobile env/domains/scaling (PR #61).

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-09 23:47:25 +02:00
1 parent 4071ed2991
commit a4bb7ad559
14 files changed
+445 -63

No files matched your search

@@ -485,7 +485,7 @@ export function ServiceDetailPage() {
const [volumesForm, setVolumesForm] = useState<ServiceVolume[] | null>(null);
const [domainInput, setDomainInput] = useState('');
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string; traefikLabels: string } | null>(null);
const [sleepDraft, setSleepDraft] = useState<{ id: string; enabled: boolean; minutes: string } | null>(null);
const [nodeDraft, setNodeDraft] = useState<string | null>(null);
const nodesQuery = useQuery({
@@ -2490,7 +2490,13 @@ export function ServiceDetailPage() {
<button
type="button"
onClick={() =>
setAccessForm({ maintenance: Boolean(service.maintenanceMode), basicAuth: '' })
setAccessForm({
maintenance: Boolean(service.maintenanceMode),
basicAuth: '',
traefikLabels: Object.entries(service.traefikLabels ?? {})
.map(([k, v]) => `${k}=${v}`)
.join('\n'),
})
}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
@@ -2511,6 +2517,14 @@ export function ServiceDetailPage() {
{(service.basicAuth ?? []).length > 0 ? (service.basicAuth ?? []).join(', ') : 'off'}
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Routing overrides</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{Object.keys(service.traefikLabels ?? {}).length > 0
? `${Object.keys(service.traefikLabels ?? {}).length} label(s)`
: 'off'}
</p>
</div>
</div>
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
@@ -2532,6 +2546,16 @@ export function ServiceDetailPage() {
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Routing overrides — Traefik middleware labels, key=value per line; empty clears</label>
<textarea
value={accessForm.traefikLabels}
onChange={(e) => setAccessForm({ ...accessForm, traefikLabels: e.target.value })}
rows={3}
placeholder={"middlewares.rl.ratelimit.average=100\nmiddlewares.sec.headers.customresponseheaders.X-Frame-Options=DENY"}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
@@ -2546,8 +2570,17 @@ export function ServiceDetailPage() {
return i > 0 ? { username: l.slice(0, i), password: l.slice(i + 1) } : null;
})
.filter((x): x is { username: string; password: string } => x !== null);
const labels: Record<string, string> = {};
accessForm.traefikLabels
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.forEach((l) => {
const i = l.indexOf('=');
if (i > 0) labels[l.slice(0, i).trim()] = l.slice(i + 1).trim();
});
updateServiceMutation.mutate(
{ maintenance_mode: accessForm.maintenance, basic_auth: creds },
{ maintenance_mode: accessForm.maintenance, basic_auth: creds, traefik_labels: labels },
{ onSuccess: () => setAccessForm(null) },
);
}}