From 40e4633863c159bd3527f098fca7295073175237 Mon Sep 17 00:00:00 2001 From: kalevski Date: Tue, 7 Jul 2026 11:34:08 +0200 Subject: [PATCH 1/2] feat(quaykeeper): split log destinations into reusable endpoints + per-source bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the monolithic log-destination model (logs_feature.md) — the nginxpilot and client-go wire contract is unchanged; only quaykeeper's storage, services, routes, and UI change: - domain: extract shared endpoint/tunables validation; add DestinationEndpoint, LogShaping, parseShaping, and assembleSpec(dest, binding), which reassembles the two halves into the existing LogDestination wire shape - data: new log_binding table (UNIQUE(scope, target, destination_id)); log_destination repurposed as an endpoint-only registry (loki/http, no scope/target/shaping); new log-binding-repo + ID.logBinding - services: new log-bindings service owns daemon push/retract — realm bindings push to their own realm's client (never the active-realm cookie) with rollback on daemon reject; instance bindings are snapshot-delivered; per-realm reconcileRealm replaces the global reconcile; endpoint update re-pushes bound realms; delete blocked with 409 while bound; realm delete cascade-drops its bindings - api: realm binding routes (owner) + instance logs routes (maintainer, D3); status route accepts ?realm= for per-daemon stats - ui: endpoint-only /admin/log-destinations with "Used by" counts; RealmLogDestModal + logs badge on the Servers page; new Logs tab under /instances/[id] with shared shaping form; "Variables" section renamed to "Instances" - db: squash migrations v1–v21 into a single v1 creating the final schema (fresh-database semantics; deprecated log_destination scope/target/enabled columns dropped outright); migration runner now accepts function entries for future TS backfills --- .../api/admin/log-destinations/[id]/route.ts | 21 +- .../app/api/admin/log-destinations/route.ts | 20 +- .../admin/log-destinations/status/route.ts | 14 +- .../admin/realms/[id]/log-bindings/route.ts | 67 ++ .../instances/[id]/logs/[bindingId]/route.ts | 52 ++ .../app/api/instances/[id]/logs/route.ts | 56 ++ quaykeeper/app/instances/[id]/logs/page.tsx | 17 + quaykeeper/app/instances/page.tsx | 2 +- quaykeeper/components/AppShell.tsx | 2 +- quaykeeper/components/Breadcrumbs.tsx | 2 +- quaykeeper/components/CommandPalette.tsx | 2 +- quaykeeper/components/LogShapingFields.tsx | 232 +++++++ .../components/admin/AdminLogDestinations.tsx | 630 +++++------------- quaykeeper/components/admin/AdminRealms.tsx | 270 +++++++- .../components/config/InstanceDetail.tsx | 9 +- quaykeeper/components/config/InstanceLogs.tsx | 343 ++++++++++ quaykeeper/components/config/Instances.tsx | 2 +- quaykeeper/lib/action-icons.tsx | 2 + quaykeeper/server/data/db.ts | 426 +++++------- .../data/repositories/log-binding-repo.ts | 158 +++++ .../data/repositories/log-destination-repo.ts | 66 +- .../domain/nginxpilot-logdest-fragment.ts | 278 ++++++-- quaykeeper/server/infrastructure/ids.ts | 1 + quaykeeper/server/services/agent-fetch.ts | 27 +- quaykeeper/server/services/log-bindings.ts | 429 ++++++++++++ .../server/services/log-destinations.ts | 276 +++----- quaykeeper/server/services/realms.ts | 5 + quaykeeper/server/services/status-poll.ts | 12 +- 28 files changed, 2343 insertions(+), 1078 deletions(-) create mode 100644 quaykeeper/app/api/admin/realms/[id]/log-bindings/route.ts create mode 100644 quaykeeper/app/api/instances/[id]/logs/[bindingId]/route.ts create mode 100644 quaykeeper/app/api/instances/[id]/logs/route.ts create mode 100644 quaykeeper/app/instances/[id]/logs/page.tsx create mode 100644 quaykeeper/components/LogShapingFields.tsx create mode 100644 quaykeeper/components/config/InstanceLogs.tsx create mode 100644 quaykeeper/server/data/repositories/log-binding-repo.ts create mode 100644 quaykeeper/server/services/log-bindings.ts diff --git a/quaykeeper/app/api/admin/log-destinations/[id]/route.ts b/quaykeeper/app/api/admin/log-destinations/[id]/route.ts index 8dec33b9..195a5bb5 100644 --- a/quaykeeper/app/api/admin/log-destinations/[id]/route.ts +++ b/quaykeeper/app/api/admin/log-destinations/[id]/route.ts @@ -1,15 +1,16 @@ -// PATCH /api/admin/log-destinations/{id} — replace a destination (name is immutable; -// send the full LogDestination + { scope, target }). -// DELETE /api/admin/log-destinations/{id} — remove the row and (global scope) retract -// its daemon fragment. +// PATCH /api/admin/log-destinations/{id} — replace an endpoint (name is immutable; +// send the full DestinationEndpoint). Every enabled realm binding referencing +// it is re-pushed to its own realm; instance bindings refresh via the +// agent-snapshot version bump. +// DELETE /api/admin/log-destinations/{id} — remove the endpoint. Blocked with 409 +// `destination_in_use` while any binding references it. // -// Both guarded by `authorize('owner')` (G21). Validation + the daemon push/retract + -// audit live in `services/log-destinations.ts`. +// Both guarded by `authorize('owner')` (G21). Validation + the re-push + audit live +// in `services/log-destinations.ts`. import { NextResponse } from 'next/server' import { authorize } from '@/server/services/auth' import * as logDests from '@/server/services/log-destinations' -import * as realms from '@/server/services/realms' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -30,8 +31,7 @@ export async function PATCH(req: Request, ctx: Ctx) { const { id } = await ctx.params try { const actor = { githubId: authz.session.sub, login: authz.session.login } - const client = await realms.clientForActive(authz.session.sub, authz.role) - const { value, warnings } = await logDests.update(client, actor, id, body) + const { value, warnings } = await logDests.update(actor, id, body) return NextResponse.json({ ...value, warnings }) } catch (err) { const { status, code, detail } = logDests.httpErrorFor(err) @@ -46,8 +46,7 @@ export async function DELETE(_req: Request, ctx: Ctx) { const { id } = await ctx.params try { const actor = { githubId: authz.session.sub, login: authz.session.login } - const client = await realms.clientForActive(authz.session.sub, authz.role) - await logDests.remove(client, actor, id) + logDests.remove(actor, id) return new NextResponse(null, { status: 204 }) } catch (err) { const { status, code, detail } = logDests.httpErrorFor(err) diff --git a/quaykeeper/app/api/admin/log-destinations/route.ts b/quaykeeper/app/api/admin/log-destinations/route.ts index afbb7a96..c881744e 100644 --- a/quaykeeper/app/api/admin/log-destinations/route.ts +++ b/quaykeeper/app/api/admin/log-destinations/route.ts @@ -1,17 +1,16 @@ -// GET /api/admin/log-destinations — list configured log-shipping destinations -// (secrets as env/file refs only — safe to serialize). -// POST /api/admin/log-destinations — create a destination (JSON LogDestination body, -// plus optional { scope, target }). +// GET /api/admin/log-destinations — list the reusable destination endpoints +// (secrets as env/file refs only — safe to serialize) with their usedBy counts. +// POST /api/admin/log-destinations — create an endpoint (JSON DestinationEndpoint +// body: name/type/url/tenant/TLS/auth). Persist-only — a bare endpoint ships +// nothing until a realm or instance binds it, so no daemon is touched. // // Both guarded by `authorize('owner')` — every destination carries a URL and the -// pipeline egresses log data, so per G21 only the owner creates/edits. Validation, -// persistence, the daemon push (global scope) and the audit entry live in -// `services/log-destinations.ts`. +// pipeline egresses log data, so per G21 only the owner creates/edits endpoints. +// Validation, persistence and the audit entry live in `services/log-destinations.ts`. import { NextResponse } from 'next/server' import { authorize } from '@/server/services/auth' import * as logDests from '@/server/services/log-destinations' -import * as realms from '@/server/services/realms' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -36,9 +35,8 @@ export async function POST(req: Request) { try { const actor = { githubId: authz.session.sub, login: authz.session.login } - const client = await realms.clientForActive(authz.session.sub, authz.role) - const { value, warnings } = await logDests.create(client, actor, body) - return NextResponse.json({ ...value, warnings }, { status: 201 }) + const value = logDests.create(actor, body) + return NextResponse.json(value, { status: 201 }) } catch (err) { const { status, code, detail } = logDests.httpErrorFor(err) return NextResponse.json({ error: code, detail }, { status }) diff --git a/quaykeeper/app/api/admin/log-destinations/status/route.ts b/quaykeeper/app/api/admin/log-destinations/status/route.ts index ae0bd1c4..35aec369 100644 --- a/quaykeeper/app/api/admin/log-destinations/status/route.ts +++ b/quaykeeper/app/api/admin/log-destinations/status/route.ts @@ -1,5 +1,8 @@ -// GET /api/admin/log-destinations/status — per-destination shipping stats + intake -// health from the active realm's daemon (`GET /logs/status`), for the live table. +// GET /api/admin/log-destinations/status[?realm=] — per-destination shipping +// stats + intake health from one realm's daemon (`GET /logs/status`). With +// `?realm=` the stats come from that realm's client (the Servers-page binding +// modal, D4 — stats are per fragment name on a specific daemon); without it, +// from the caller's active realm. // // Guarded by `authorize('owner')`. @@ -11,12 +14,15 @@ import * as realms from '@/server/services/realms' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -export async function GET() { +export async function GET(req: Request) { const authz = await authorize('owner') if (!authz.ok) return NextResponse.json({ error: 'unauthorized' }, { status: authz.status }) try { - const client = await realms.clientForActive(authz.session.sub, authz.role) + const realmId = new URL(req.url).searchParams.get('realm') + const client = realmId + ? realms.clientFor(realmId) + : await realms.clientForActive(authz.session.sub, authz.role) return NextResponse.json(await logDests.logsStatus(client)) } catch (err) { const { status, code, detail } = logDests.httpErrorFor(err) diff --git a/quaykeeper/app/api/admin/realms/[id]/log-bindings/route.ts b/quaykeeper/app/api/admin/realms/[id]/log-bindings/route.ts new file mode 100644 index 00000000..ffc74876 --- /dev/null +++ b/quaykeeper/app/api/admin/realms/[id]/log-bindings/route.ts @@ -0,0 +1,67 @@ +// GET /api/admin/realms/{id}/log-bindings — this realm's log binding(s), joined +// with destination identity (the Servers-page modal shows the first — the UI +// is one-destination-per-realm). +// PUT /api/admin/realms/{id}/log-bindings — set the realm's binding +// ({ destinationId, enabled?, shaping? }). Pushes/retracts the fragment on +// THAT realm's own nginxpilot; rolls the row back if the daemon rejects it. +// DELETE /api/admin/realms/{id}/log-bindings — clear the realm's binding(s): +// retract the fragment (best-effort), then drop the row. +// +// All guarded by `authorize('owner')` (realm bindings drive the shared edge). +// Validation, the daemon push/retract and audit live in `services/log-bindings.ts`. + +import { NextResponse } from 'next/server' +import { authorize } from '@/server/services/auth' +import * as logBindings from '@/server/services/log-bindings' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type Ctx = { params: Promise<{ id: string }> } + +export async function GET(_req: Request, ctx: Ctx) { + const authz = await authorize('owner') + if (!authz.ok) return NextResponse.json({ error: 'unauthorized' }, { status: authz.status }) + + const { id } = await ctx.params + return NextResponse.json(logBindings.listForRealm(id)) +} + +export async function PUT(req: Request, ctx: Ctx) { + const authz = await authorize('owner') + if (!authz.ok) return NextResponse.json({ error: 'unauthorized' }, { status: authz.status }) + + let body: { destinationId?: unknown; enabled?: unknown; shaping?: unknown } + try { + body = (await req.json()) as { destinationId?: unknown; enabled?: unknown; shaping?: unknown } + } catch { + return NextResponse.json({ error: 'invalid_json' }, { status: 400 }) + } + + const { id } = await ctx.params + try { + const actor = { githubId: authz.session.sub, login: authz.session.login } + const value = await logBindings.setRealmBinding(actor, id, body) + return NextResponse.json(value) + } catch (err) { + const { status, code, detail } = logBindings.httpErrorFor(err) + return NextResponse.json({ error: code, detail }, { status }) + } +} + +export async function DELETE(_req: Request, ctx: Ctx) { + const authz = await authorize('owner') + if (!authz.ok) return NextResponse.json({ error: 'unauthorized' }, { status: authz.status }) + + const { id } = await ctx.params + try { + const actor = { githubId: authz.session.sub, login: authz.session.login } + for (const binding of logBindings.listForRealm(id)) { + await logBindings.removeRealmBinding(actor, binding.id) + } + return new NextResponse(null, { status: 204 }) + } catch (err) { + const { status, code, detail } = logBindings.httpErrorFor(err) + return NextResponse.json({ error: code, detail }, { status }) + } +} diff --git a/quaykeeper/app/api/instances/[id]/logs/[bindingId]/route.ts b/quaykeeper/app/api/instances/[id]/logs/[bindingId]/route.ts new file mode 100644 index 00000000..b6fc45d7 --- /dev/null +++ b/quaykeeper/app/api/instances/[id]/logs/[bindingId]/route.ts @@ -0,0 +1,52 @@ +// PATCH /api/instances/{id}/logs/{bindingId} — update a binding's enabled flag / +// shaping ({ enabled?, shaping? }). +// DELETE /api/instances/{id}/logs/{bindingId} — unbind the destination. +// +// Guarded by `authorize('maintainer')` (D3). Stored only — the agent snapshot's +// version bump propagates the change to quaykeeper-client on its next poll. + +import { NextResponse } from 'next/server' +import { authorize } from '@/server/services/auth' +import * as logBindings from '@/server/services/log-bindings' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type Ctx = { params: Promise<{ id: string; bindingId: string }> } + +export async function PATCH(req: Request, ctx: Ctx) { + const authz = await authorize('maintainer') + if (!authz.ok) return NextResponse.json({ error: 'unauthorized' }, { status: authz.status }) + + let body: { enabled?: unknown; shaping?: unknown } + try { + body = (await req.json()) as { enabled?: unknown; shaping?: unknown } + } catch { + return NextResponse.json({ error: 'invalid_json' }, { status: 400 }) + } + + const { id, bindingId } = await ctx.params + try { + const actor = { githubId: authz.session.sub, login: authz.session.login } + const value = logBindings.updateInstanceBinding(actor, id, bindingId, body) + return NextResponse.json(value) + } catch (err) { + const { status, code, detail } = logBindings.httpErrorFor(err) + return NextResponse.json({ error: code, detail }, { status }) + } +} + +export async function DELETE(_req: Request, ctx: Ctx) { + const authz = await authorize('maintainer') + if (!authz.ok) return NextResponse.json({ error: 'unauthorized' }, { status: authz.status }) + + const { id, bindingId } = await ctx.params + try { + const actor = { githubId: authz.session.sub, login: authz.session.login } + logBindings.removeInstanceBinding(actor, id, bindingId) + return new NextResponse(null, { status: 204 }) + } catch (err) { + const { status, code, detail } = logBindings.httpErrorFor(err) + return NextResponse.json({ error: code, detail }, { status }) + } +} diff --git a/quaykeeper/app/api/instances/[id]/logs/route.ts b/quaykeeper/app/api/instances/[id]/logs/route.ts new file mode 100644 index 00000000..27eb94c4 --- /dev/null +++ b/quaykeeper/app/api/instances/[id]/logs/route.ts @@ -0,0 +1,56 @@ +// GET /api/instances/{id}/logs — this instance's log bindings (joined with +// destination identity) plus the owner-defined destination options the +// binding modal's select offers (id/name/type/url only — refs, no auth). +// POST /api/instances/{id}/logs — bind a destination to this instance +// ({ destinationId, enabled?, shaping? }; upserts on the same destination). +// +// Guarded by `authorize('maintainer')` like the other instance routes (D3): +// maintainers *choose* from owner-defined destinations; only the owner creates +// endpoints. Stored only — no daemon call; quaykeeper-client picks the change up +// via the agent snapshot's version bump on its next poll. + +import { NextResponse } from 'next/server' +import { authorize } from '@/server/services/auth' +import * as instanceRepo from '@/server/data/repositories/instance-repo' +import * as logDests from '@/server/services/log-destinations' +import * as logBindings from '@/server/services/log-bindings' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type Ctx = { params: Promise<{ id: string }> } + +export async function GET(_req: Request, ctx: Ctx) { + const authz = await authorize('maintainer') + if (!authz.ok) return NextResponse.json({ error: 'unauthorized' }, { status: authz.status }) + + const { id } = await ctx.params + if (!instanceRepo.byId(id)) return NextResponse.json({ error: 'instance_not_found' }, { status: 404 }) + + const destinations = logDests + .list() + .map((d) => ({ id: d.id, name: d.name, type: d.type, url: d.spec.url })) + return NextResponse.json({ bindings: logBindings.listForInstance(id), destinations }) +} + +export async function POST(req: Request, ctx: Ctx) { + const authz = await authorize('maintainer') + if (!authz.ok) return NextResponse.json({ error: 'unauthorized' }, { status: authz.status }) + + let body: { destinationId?: unknown; enabled?: unknown; shaping?: unknown } + try { + body = (await req.json()) as { destinationId?: unknown; enabled?: unknown; shaping?: unknown } + } catch { + return NextResponse.json({ error: 'invalid_json' }, { status: 400 }) + } + + const { id } = await ctx.params + try { + const actor = { githubId: authz.session.sub, login: authz.session.login } + const value = logBindings.setInstanceBinding(actor, id, body) + return NextResponse.json(value, { status: 201 }) + } catch (err) { + const { status, code, detail } = logBindings.httpErrorFor(err) + return NextResponse.json({ error: code, detail }, { status }) + } +} diff --git a/quaykeeper/app/instances/[id]/logs/page.tsx b/quaykeeper/app/instances/[id]/logs/page.tsx new file mode 100644 index 00000000..f0d476b5 --- /dev/null +++ b/quaykeeper/app/instances/[id]/logs/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from 'next' +import { AuthGate } from '@/components/AuthGate' +import { InstanceDetail } from '@/components/config/InstanceDetail' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export const metadata: Metadata = { title: 'Logs · Instance' } + +export default async function InstanceLogsPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + return ( + + + + ) +} diff --git a/quaykeeper/app/instances/page.tsx b/quaykeeper/app/instances/page.tsx index 5c6e6254..11bda7b1 100644 --- a/quaykeeper/app/instances/page.tsx +++ b/quaykeeper/app/instances/page.tsx @@ -5,7 +5,7 @@ import { Instances } from '@/components/config/Instances' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -export const metadata: Metadata = { title: 'Variables' } +export const metadata: Metadata = { title: 'Instances' } export default function InstancesPage() { return ( diff --git a/quaykeeper/components/AppShell.tsx b/quaykeeper/components/AppShell.tsx index 1c8c6aae..c4d732e2 100644 --- a/quaykeeper/components/AppShell.tsx +++ b/quaykeeper/components/AppShell.tsx @@ -49,7 +49,7 @@ export function AppShell({ me, children }: { me: MeResponse; children: ReactNode ? [ { key: 'instances', - label: 'Variables', + label: 'Instances', icon: 'server', href: '/instances', active: pathname.startsWith('/instances'), diff --git a/quaykeeper/components/Breadcrumbs.tsx b/quaykeeper/components/Breadcrumbs.tsx index edf649ac..5d95011f 100644 --- a/quaykeeper/components/Breadcrumbs.tsx +++ b/quaykeeper/components/Breadcrumbs.tsx @@ -28,7 +28,7 @@ const ADMIN_LABELS: Record = { function trailFor(pathname: string): Crumb[] { if (pathname.startsWith('/sites/')) return [{ label: 'Static Sites', href: '/' }, { label: 'Site' }] - if (pathname.startsWith('/instances/')) return [{ label: 'Variables', href: '/instances' }, { label: 'Instance' }] + if (pathname.startsWith('/instances/')) return [{ label: 'Instances', href: '/instances' }, { label: 'Instance' }] if (pathname.startsWith('/databases/')) return [{ label: 'Databases', href: '/databases' }, { label: 'Server' }] if (pathname.startsWith('/proxies')) return [{ label: 'Routing' }, { label: 'Proxies' }] if (pathname.startsWith('/admin')) { diff --git a/quaykeeper/components/CommandPalette.tsx b/quaykeeper/components/CommandPalette.tsx index e3072a5f..ffed44b3 100644 --- a/quaykeeper/components/CommandPalette.tsx +++ b/quaykeeper/components/CommandPalette.tsx @@ -31,7 +31,7 @@ export function CommandPalette() { ] if (ROLE_RANK[me.role] >= ROLE_RANK.maintainer) { list.push( - { id: 'instances', label: 'Variables', group: 'Navigate', icon: 'server', href: '/instances' }, + { id: 'instances', label: 'Instances', group: 'Navigate', icon: 'server', href: '/instances' }, { id: 'db-servers', label: 'Databases', group: 'Navigate', icon: 'database', href: '/databases' }, { id: 'snippets', label: 'Docker snippets', group: 'Navigate', icon: 'container', href: '/snippets' }, { id: 'proxies', label: 'Proxies', group: 'Routing', icon: 'globe', href: '/proxies' }, diff --git a/quaykeeper/components/LogShapingFields.tsx b/quaykeeper/components/LogShapingFields.tsx new file mode 100644 index 00000000..1ca200bc --- /dev/null +++ b/quaykeeper/components/LogShapingFields.tsx @@ -0,0 +1,232 @@ +'use client' + +import { FormGroup } from '@/components/FormModal' +import { SelectField, TextAreaField, TextField } from '@/components/fields' +import type { LogShaping } from '@/server/domain/nginxpilot-logdest-fragment' + +// Shared shaping sub-form for log bindings (logs_feature.md §11/§12) — the +// per-source half of a destination assignment: Loki labels (loki destinations +// only), field filters, parse templates (instance scope only) and the shipping +// tunables. Used by the Servers-page RealmLogDestModal and the instance Logs +// tab; the endpoint half (URL/TLS/auth) lives on /admin/log-destinations. + +/** Everything the shaping sub-form holds, as form-friendly strings. */ +export interface ShapingDraft { + job: string + hostLabel: string + statusLabel: string + extraLabels: string + filterText: string + parseText: string + sample: string + batchSize: string + flushInterval: string + maxRetries: string + bufferSize: string +} + +export const emptyShapingDraft = (scope: 'realm' | 'instance'): ShapingDraft => ({ + job: 'nginx', + hostLabel: scope === 'realm' ? '$resource' : 'none', + statusLabel: scope === 'realm' ? '$status' : 'none', + extraLabels: '', + filterText: '', + parseText: '', + sample: '', + batchSize: '', + flushInterval: '', + maxRetries: '', + bufferSize: '', +}) + +/** Reconstruct an editing draft from a stored binding's shaping. */ +export function shapingDraftOf(s: LogShaping): ShapingDraft { + const labels = s.labels ?? {} + const extraLabels = Object.entries(labels) + .filter(([k]) => k !== 'job' && k !== 'host' && k !== 'status_code') + .map(([k, v]) => `${k}=${v}`) + .join('\n') + const filterText = Object.entries(s.filter ?? {}) + .map(([field, list]) => `${field}: ${list.join(', ')}`) + .join('\n') + return { + job: labels.job ?? 'nginx', + hostLabel: labels.host ?? 'none', + statusLabel: labels.status_code ?? 'none', + extraLabels, + filterText, + parseText: (s.parse ?? []).join('\n'), + sample: s.sample !== undefined ? String(s.sample) : '', + batchSize: s.batch_size !== undefined ? String(s.batch_size) : '', + flushInterval: s.flush_interval ?? '', + maxRetries: s.max_retries !== undefined ? String(s.max_retries) : '', + bufferSize: s.buffer_size !== undefined ? String(s.buffer_size) : '', + } +} + +const numOr = (raw: string): number | undefined => { + const t = raw.trim() + if (t === '') return undefined + const n = Number(t) + return Number.isFinite(n) ? n : undefined +} + +/** Assemble the API `shaping` payload from the draft, including only fields relevant to scope/type. */ +export function buildShapingPayload( + d: ShapingDraft, + opts: { scope: 'realm' | 'instance'; loki: boolean }, +): Record { + const shaping: Record = {} + + if (opts.loki) { + const labels: Record = {} + if (d.job.trim()) labels.job = d.job.trim() + if (d.hostLabel !== 'none') labels.host = d.hostLabel + if (d.statusLabel !== 'none') labels.status_code = d.statusLabel + for (const line of d.extraLabels.split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + const m = trimmed.match(/^([^=:]+)[=:]\s*(.+)$/) + if (m) labels[m[1].trim()] = m[2].trim() + } + if (Object.keys(labels).length) shaping.labels = labels + } + + const filter: Record = {} + for (const line of d.filterText.split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + const idx = trimmed.indexOf(':') + if (idx < 0) continue + const field = trimmed.slice(0, idx).trim() + const matchers = trimmed + .slice(idx + 1) + .split(',') + .map((m) => m.trim()) + .filter(Boolean) + if (field && matchers.length) filter[field] = matchers + } + if (Object.keys(filter).length) shaping.filter = filter + + if (opts.scope === 'instance') { + const templates = d.parseText + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + if (templates.length) shaping.parse = templates + } + + if (numOr(d.sample) !== undefined) shaping.sample = numOr(d.sample) + if (numOr(d.batchSize) !== undefined) shaping.batch_size = numOr(d.batchSize) + if (numOr(d.maxRetries) !== undefined) shaping.max_retries = numOr(d.maxRetries) + if (numOr(d.bufferSize) !== undefined) shaping.buffer_size = numOr(d.bufferSize) + if (d.flushInterval.trim()) shaping.flush_interval = d.flushInterval.trim() + + return shaping +} + +/** Live LogQL preview from the current draft's label config (loki only). */ +export function draftLogql(d: ShapingDraft): string { + const parts = [`job="${d.job.trim() || 'nginx'}"`] + if (d.hostLabel !== 'none') parts.push('host=~".+"') + if (d.statusLabel !== 'none') parts.push('status_code=~".+"') + for (const line of d.extraLabels.split('\n')) { + const m = line.trim().match(/^([^=:]+)[=:]\s*(.+)$/) + if (m) parts.push(`${m[1].trim()}="${m[2].trim()}"`) + } + return `{${parts.join(', ')}}` +} + +/** The shaping FormGroups — dropped into a FormModal after the destination select. */ +export function ShapingFields({ + draft, + onPatch, + scope, + loki, +}: { + draft: ShapingDraft + onPatch: (p: Partial) => void + scope: 'realm' | 'instance' + loki: boolean +}) { + return ( + <> + {loki && ( + + onPatch({ job: v })} + /> + onPatch({ hostLabel: v })} + /> + onPatch({ statusLabel: v })} + /> + onPatch({ extraLabels: v })} + /> +

+ Selector: {draftLogql(draft)} +

+
+ )} + + + onPatch({ filterText: v })} + /> + + + {scope === 'instance' && ( + + onPatch({ parseText: v })} + /> + + )} + + + onPatch({ sample: v })} /> + onPatch({ batchSize: v })} /> + onPatch({ flushInterval: v })} /> + onPatch({ maxRetries: v })} /> + onPatch({ bufferSize: v })} /> + + + ) +} diff --git a/quaykeeper/components/admin/AdminLogDestinations.tsx b/quaykeeper/components/admin/AdminLogDestinations.tsx index 2f7b8cff..423d0d60 100644 --- a/quaykeeper/components/admin/AdminLogDestinations.tsx +++ b/quaykeeper/components/admin/AdminLogDestinations.tsx @@ -1,21 +1,24 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import type { AdvancedTableColumn } from '@toolcase/web-components' import { iconBtnHtml } from '@/lib/action-icons' import { escapeHtml, useTc } from '@/lib/tc' import { AdminPage, json, useOwnerData } from './shared' import { ConfirmDialog } from '@/components/ConfirmDialog' import { FormModal, FormGroup } from '@/components/FormModal' -import { SelectField, SwitchField, TextAreaField, TextField } from '@/components/fields' +import { SelectField, SwitchField, TextField } from '@/components/fields' import { useToast } from '@/components/Toast' -// Owner-only log-shipping destinations (log_ides.md §4). Each row is one push/sink -// target for nginxpilot's structured access logs: Loki, a generic HTTP collector, a -// local NDJSON file, or the container's stdout. Owner-only because every destination -// carries a URL and the pipeline egresses log data (SSRF surface on test, G21). -// Secrets are BY REFERENCE ONLY — auth carries an env-var / file NAME the operator -// provisions on the nginxpilot host; Quaykeeper never holds the credential. +// Owner-only reusable log endpoints (logs_feature.md §10). Each row is one push +// target — Loki or a generic HTTP collector — defined ONCE and assigned to log +// sources elsewhere: an NGINX server binds it from the Servers page, an instance +// from its Logs tab. The binding carries the shaping (labels/filter/parse/ +// tunables); this page is purely the connection half. Owner-only because every +// destination carries a URL and the pipeline egresses log data (SSRF surface on +// test, G21). Secrets are BY REFERENCE ONLY — auth carries an env-var / file +// NAME the operator provisions on the shipping host; Quaykeeper never holds the +// credential. // ── local DTO shapes (mirror the /api/admin/log-destinations responses) ───────── @@ -28,103 +31,57 @@ interface LogAuthSpec { token_file?: string } -interface LogDestSpec { +interface EndpointSpec { name: string - type: 'loki' | 'http' | 'file' | 'stdout' - enabled?: boolean - url?: string + type: 'loki' | 'http' + url: string tenant?: string - allow_insecure?: boolean ca_file?: string + allow_insecure?: boolean insecure_skip_verify?: boolean auth?: LogAuthSpec - labels?: Record - filter?: Record - parse?: string[] - sample?: number - batch_size?: number - flush_interval?: string - max_retries?: number - buffer_size?: number - path?: string - max_size?: string - max_files?: number } interface LogDestDto { id: string name: string type: string - scope: 'global' | 'instance' - target?: string - enabled: boolean - spec: LogDestSpec - logql: string + spec: EndpointSpec + usedBy: { realms: number; instances: number } createdAt: string updatedAt: string } -interface LogDestLiveStat { - name: string - shipped: number - dropped: number - failed_batches: number - buffer_len: number - last_error?: string - last_flush?: string - oldest_buffered?: string -} - -interface LogsStatus { - enabled: boolean - syslog_listen: string - intake_error?: string - received: number - parse_errors: number - destinations: LogDestLiveStat[] -} - // ── table rendering ───────────────────────────────────────────────────────────── const DEST_COLUMNS: AdvancedTableColumn[] = [ { key: 'identity', label: 'Destination' }, - { key: 'shipping', label: 'Shipping' }, + { key: 'usedBy', label: 'Used by' }, { key: 'actions', label: '', align: 'right' }, ] -function healthDot(dto: LogDestDto, stat: LogDestLiveStat | undefined): { cls: string; title: string } { - if (!dto.enabled) return { cls: 'quaykeeper-realm-dot--unknown', title: 'Disabled' } - if (dto.scope === 'instance') return { cls: 'quaykeeper-realm-dot--unknown', title: 'Delivered to instances (client-side shipping)' } - if (!stat) return { cls: 'quaykeeper-realm-dot--unknown', title: 'No shipping stats yet' } - if (stat.last_error) return { cls: 'quaykeeper-realm-dot--down', title: stat.last_error } - return { cls: 'quaykeeper-realm-dot--ok', title: stat.last_flush ? `Last flush ${stat.last_flush}` : 'Healthy' } -} - -interface DestRow { - dto: LogDestDto - stat: LogDestLiveStat | undefined +/** "2 servers · 1 instance" — the at-a-glance reuse signal (D4). */ +function usedByText(dto: LogDestDto): string { + const parts: string[] = [] + if (dto.usedBy.realms > 0) parts.push(`${dto.usedBy.realms} server${dto.usedBy.realms === 1 ? '' : 's'}`) + if (dto.usedBy.instances > 0) parts.push(`${dto.usedBy.instances} instance${dto.usedBy.instances === 1 ? '' : 's'}`) + return parts.length ? parts.join(' · ') : 'unbound' } /** The injected `` HTML — every interpolated value is escaped. */ -function destRowsHtml(rows: DestRow[], busy: boolean): string { - return rows - .map(({ dto, stat }) => { - const { cls, title } = healthDot(dto, stat) - +function destRowsHtml(dests: LogDestDto[], busy: boolean): string { + return dests + .map((dto) => { const badges: string[] = [`${escapeHtml(dto.type)}`] - badges.push(`${escapeHtml(dto.scope)}`) - if (!dto.enabled) badges.push('disabled') - if (dto.target) badges.push(`${escapeHtml(dto.target)}`) - - const sub = dto.type === 'loki' && dto.logql - ? escapeHtml(dto.logql) - : escapeHtml(dto.spec.url || dto.spec.path || (dto.type === 'stdout' ? 'container stdout' : '')) + if (dto.spec.allow_insecure) badges.push('insecure') + if (dto.spec.auth?.method && dto.spec.auth.method !== 'none') { + badges.push(`${escapeHtml(dto.spec.auth.method)} auth`) + } - const shipping = stat - ? `↑ ${stat.shipped.toLocaleString()} · ✕ ${stat.dropped.toLocaleString()} · ⚠ ${stat.failed_batches.toLocaleString()}${stat.buffer_len ? ` · buf ${stat.buffer_len}` : ''}` - : dto.scope === 'instance' - ? 'client-side' - : '' + const bound = dto.usedBy.realms + dto.usedBy.instances > 0 + const usedBy = bound + ? `${escapeHtml(usedByText(dto))}` + : '' const controls = [ iconBtnHtml({ icon: 'test', label: `Test ${dto.name}`, data: { action: 'test', id: dto.id } }), @@ -141,12 +98,11 @@ function destRowsHtml(rows: DestRow[], busy: boolean): string { return ( `` + `` + - `` + `${escapeHtml(dto.name)}` + - `${sub}` + + `${escapeHtml(dto.spec.url)}` + `${badges.join('')}` + `` + - `${shipping}` + + `${usedBy}` + `${controls}` + `` ) @@ -167,7 +123,7 @@ export function AdminLogDestinations() { return ( ({ name: '', type: 'loki', - scope: 'global', - target: '', - enabled: true, url: '', tenant: '', allowInsecure: false, @@ -230,40 +166,14 @@ const emptyDraft = (): Draft => ({ passwordFile: '', tokenEnv: '', tokenFile: '', - job: 'nginx', - hostLabel: '$resource', - statusLabel: '$status', - extraLabels: '', - filterText: '', - parseText: '', - sample: '', - batchSize: '', - flushInterval: '', - maxRetries: '', - bufferSize: '', - path: '', - maxSize: '', - maxFiles: '', }) -/** Reconstruct an editing draft from a stored destination's spec. */ +/** Reconstruct an editing draft from a stored endpoint spec. */ function draftOf(dto: LogDestDto): Draft { const s = dto.spec - const labels = s.labels ?? {} - const extraLabels = Object.entries(labels) - .filter(([k]) => k !== 'job' && k !== 'host' && k !== 'status_code') - .map(([k, v]) => `${k}=${v}`) - .join('\n') - const filterText = Object.entries(s.filter ?? {}) - .map(([field, list]) => `${field}: ${list.join(', ')}`) - .join('\n') - const parseText = (s.parse ?? []).join('\n') return { name: s.name, type: s.type, - scope: dto.scope, - target: dto.target ?? '', - enabled: dto.enabled, url: s.url ?? '', tenant: s.tenant ?? '', allowInsecure: s.allow_insecure ?? false, @@ -275,133 +185,41 @@ function draftOf(dto: LogDestDto): Draft { passwordFile: s.auth?.password_file ?? '', tokenEnv: s.auth?.token_env ?? '', tokenFile: s.auth?.token_file ?? '', - job: labels.job ?? 'nginx', - hostLabel: labels.host ?? 'none', - statusLabel: labels.status_code ?? 'none', - extraLabels, - filterText, - parseText, - sample: s.sample !== undefined ? String(s.sample) : '', - batchSize: s.batch_size !== undefined ? String(s.batch_size) : '', - flushInterval: s.flush_interval ?? '', - maxRetries: s.max_retries !== undefined ? String(s.max_retries) : '', - bufferSize: s.buffer_size !== undefined ? String(s.buffer_size) : '', - path: s.path ?? '', - maxSize: s.max_size ?? '', - maxFiles: s.max_files !== undefined ? String(s.max_files) : '', } } -const numOr = (raw: string): number | undefined => { - const t = raw.trim() - if (t === '') return undefined - const n = Number(t) - return Number.isFinite(n) ? n : undefined -} - -/** Assemble the API payload from the draft, including only fields relevant to the type. */ +/** Assemble the endpoint API payload from the draft. */ function buildPayload(d: Draft): Record { - const isPush = d.type === 'loki' || d.type === 'http' const payload: Record = { name: d.name.trim(), type: d.type, - scope: d.scope, - enabled: d.enabled, - } - if (d.scope === 'instance' && d.target.trim()) payload.target = d.target.trim() - - // parse templates — instance scope only (nginxpilot access logs are already JSON). - if (d.scope === 'instance') { - const templates = d.parseText - .split('\n') - .map((l) => l.trim()) - .filter(Boolean) - if (templates.length) payload.parse = templates - } - - // filter (all types) - const filter: Record = {} - for (const line of d.filterText.split('\n')) { - const trimmed = line.trim() - if (!trimmed) continue - const idx = trimmed.indexOf(':') - if (idx < 0) continue - const field = trimmed.slice(0, idx).trim() - const matchers = trimmed - .slice(idx + 1) - .split(',') - .map((m) => m.trim()) - .filter(Boolean) - if (field && matchers.length) filter[field] = matchers + url: d.url.trim(), } - if (Object.keys(filter).length) payload.filter = filter - - // shipping tunables (all types) - if (numOr(d.sample) !== undefined) payload.sample = numOr(d.sample) - if (numOr(d.batchSize) !== undefined) payload.batch_size = numOr(d.batchSize) - if (numOr(d.maxRetries) !== undefined) payload.max_retries = numOr(d.maxRetries) - if (numOr(d.bufferSize) !== undefined) payload.buffer_size = numOr(d.bufferSize) - if (d.flushInterval.trim()) payload.flush_interval = d.flushInterval.trim() - - if (isPush) { - payload.url = d.url.trim() - if (d.allowInsecure) payload.allow_insecure = true - if (d.caFile.trim()) payload.ca_file = d.caFile.trim() - if (d.insecureSkipVerify) payload.insecure_skip_verify = true - if (d.authMethod !== 'none') { - const auth: Record = { method: d.authMethod } - if (d.authMethod === 'basic') { - auth.username = d.username.trim() - if (d.passwordEnv.trim()) auth.password_env = d.passwordEnv.trim() - if (d.passwordFile.trim()) auth.password_file = d.passwordFile.trim() - } else { - if (d.tokenEnv.trim()) auth.token_env = d.tokenEnv.trim() - if (d.tokenFile.trim()) auth.token_file = d.tokenFile.trim() - } - payload.auth = auth - } - if (d.type === 'loki') { - if (d.tenant.trim()) payload.tenant = d.tenant.trim() - const labels: Record = {} - if (d.job.trim()) labels.job = d.job.trim() - if (d.hostLabel !== 'none') labels.host = d.hostLabel - if (d.statusLabel !== 'none') labels.status_code = d.statusLabel - for (const line of d.extraLabels.split('\n')) { - const trimmed = line.trim() - if (!trimmed) continue - const m = trimmed.match(/^([^=:]+)[=:]\s*(.+)$/) - if (m) labels[m[1].trim()] = m[2].trim() - } - if (Object.keys(labels).length) payload.labels = labels + if (d.allowInsecure) payload.allow_insecure = true + if (d.caFile.trim()) payload.ca_file = d.caFile.trim() + if (d.insecureSkipVerify) payload.insecure_skip_verify = true + if (d.type === 'loki' && d.tenant.trim()) payload.tenant = d.tenant.trim() + if (d.authMethod !== 'none') { + const auth: Record = { method: d.authMethod } + if (d.authMethod === 'basic') { + auth.username = d.username.trim() + if (d.passwordEnv.trim()) auth.password_env = d.passwordEnv.trim() + if (d.passwordFile.trim()) auth.password_file = d.passwordFile.trim() + } else { + if (d.tokenEnv.trim()) auth.token_env = d.tokenEnv.trim() + if (d.tokenFile.trim()) auth.token_file = d.tokenFile.trim() } - } else if (d.type === 'file') { - payload.path = d.path.trim() - if (d.maxSize.trim()) payload.max_size = d.maxSize.trim() - if (numOr(d.maxFiles) !== undefined) payload.max_files = numOr(d.maxFiles) + payload.auth = auth } return payload } -/** Live LogQL preview from the current draft's label config (loki only). */ -function draftLogql(d: Draft): string { - if (d.type !== 'loki') return '' - const parts = [`job="${d.job.trim() || 'nginx'}"`] - if (d.hostLabel !== 'none') parts.push('host=~".+"') - if (d.statusLabel !== 'none') parts.push('status_code=~".+"') - for (const line of d.extraLabels.split('\n')) { - const m = line.trim().match(/^([^=:]+)[=:]\s*(.+)$/) - if (m) parts.push(`${m[1].trim()}="${m[2].trim()}"`) - } - return `{${parts.join(', ')}}` -} - function LogDestForm({ dests, onChanged }: { dests: LogDestDto[]; onChanged: () => void }) { const toast = useToast() const [form, setForm] = useState<{ id: string | null; draft: Draft } | null>(null) const [error, setError] = useState(null) const [busy, setBusy] = useState(false) const [pending, setPending] = useState(null) - const [stats, setStats] = useState>({}) const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null) const patchDraft = (p: Partial) => @@ -413,26 +231,6 @@ function LogDestForm({ dests, onChanged }: { dests: LogDestDto[]; onChanged: () setTestResult(null) }, []) - // Poll the daemon's per-destination shipping stats and index them by name. - const loadStats = useCallback(async () => { - try { - const res = await fetch('/api/admin/log-destinations/status', { cache: 'no-store' }) - if (!res.ok) return - const body = (await res.json()) as LogsStatus - const byName: Record = {} - for (const s of body.destinations ?? []) byName[s.name] = s - setStats(byName) - } catch { - // best-effort — the table still renders without live stats - } - }, []) - - useEffect(() => { - void loadStats() - const t = setInterval(() => void loadStats(), 5000) - return () => clearInterval(t) - }, [loadStats]) - const runTest = useCallback(async (draft: Draft): Promise => { setTestResult(null) try { @@ -457,6 +255,10 @@ function LogDestForm({ dests, onChanged }: { dests: LogDestDto[]; onChanged: () setError('A destination needs a name.') return } + if (!d.url.trim()) { + setError('A destination needs a URL.') + return + } setBusy(true) setError(null) try { @@ -504,7 +306,13 @@ function LogDestForm({ dests, onChanged }: { dests: LogDestDto[]; onChanged: () try { const res = await fetch(`/api/admin/log-destinations/${encodeURIComponent(dto.id)}`, { method: 'DELETE' }) if (!res.ok && res.status !== 204) { - setError(`Couldn’t remove ${dto.name} (error ${res.status}).`) + const body = (await res.json().catch(() => null)) as { error?: string } | null + const bindings = dto.usedBy.realms + dto.usedBy.instances + setError( + body?.error === 'destination_in_use' + ? `“${dto.name}” is in use by ${bindings} binding${bindings === 1 ? '' : 's'} — unbind first.` + : `Couldn’t remove ${dto.name} (error ${res.status}).`, + ) return } toast.show(`Destination “${dto.name}” removed.`, { variant: 'success' }) @@ -516,7 +324,6 @@ function LogDestForm({ dests, onChanged }: { dests: LogDestDto[]; onChanged: () } }, [pending, busy, onChanged, toast]) - const rows = useMemo(() => dests.map((dto) => ({ dto, stat: stats[dto.name] })), [dests, stats]) const editing = form?.id ? dests.find((s) => s.id === form.id) : undefined const onDelegated = useCallback( @@ -544,27 +351,26 @@ function LogDestForm({ dests, onChanged }: { dests: LogDestDto[]; onChanged: () const tableProps = useMemo( () => ({ columns: DEST_COLUMNS, - total: rows.length, - limit: rows.length || 10, + total: dests.length, + limit: dests.length || 10, offset: 0, - rows: destRowsHtml(rows, busy), + rows: destRowsHtml(dests, busy), }), - [rows, busy], + [dests, busy], ) const tableRef = useTc(tableProps, { click: onDelegated }) const d = form?.draft - const isPush = d?.type === 'loki' || d?.type === 'http' return ( <>

- Global destinations are pushed to the active NGINX server and ship its access logs directly to the - target (Quaykeeper only distributes the config, never the log bytes). Instance destinations are - delivered to quaykeeper-client for app-log shipping. Credentials are referenced by env-var / file name - and provisioned on the host — never stored here. + A destination is a reusable endpoint — URL, TLS and auth only. What gets shipped to it, and how, + is set where it’s assigned: on an NGINX server (Servers page → log destination) for access logs, + or on an instance’s Logs tab for app logs. Credentials are referenced by env-var / file name and + provisioned on the shipping host — never stored here.

{error && !form && {error}} @@ -608,224 +414,96 @@ function LogDestForm({ dests, onChanged }: { dests: LogDestDto[]; onChanged: () options={[ { value: 'loki', label: 'Loki (push API)' }, { value: 'http', label: 'HTTP collector (NDJSON)' }, - { value: 'file', label: 'Local file (NDJSON, self-rotating)' }, - { value: 'stdout', label: 'stdout (container log stream)' }, ]} onValue={(v) => patchDraft({ type: v })} /> - patchDraft({ scope: v })} + + + + patchDraft({ url: v })} /> - {d.scope === 'instance' && ( + {d.type === 'loki' && ( patchDraft({ target: v })} + label="Tenant" + placeholder="infra" + help="Optional Loki X-Scope-OrgID." + value={d.tenant} + onValue={(v) => patchDraft({ tenant: v })} + /> + )} + patchDraft({ caFile: v })} + /> + patchDraft({ allowInsecure: v })} + /> + {d.allowInsecure && ( + patchDraft({ insecureSkipVerify: v })} /> )} - patchDraft({ enabled: v })} /> - {isPush && ( - - patchDraft({ url: v })} - /> - {d.type === 'loki' && ( + + patchDraft({ authMethod: v })} + /> + {d.authMethod === 'basic' && ( + <> + patchDraft({ username: v })} /> patchDraft({ tenant: v })} + label="Password env var" + placeholder="LOKI_PASSWORD" + help="Name of the env var on the shipping host — never the value." + value={d.passwordEnv} + onValue={(v) => patchDraft({ passwordEnv: v })} /> - )} - patchDraft({ caFile: v })} - /> - patchDraft({ allowInsecure: v })} - /> - {d.allowInsecure && ( - patchDraft({ insecureSkipVerify: v })} + patchDraft({ passwordFile: v })} /> - )} - - )} - - {isPush && ( - - patchDraft({ authMethod: v })} - /> - {d.authMethod === 'basic' && ( - <> - patchDraft({ username: v })} /> - patchDraft({ passwordEnv: v })} - /> - patchDraft({ passwordFile: v })} - /> - - )} - {d.authMethod === 'bearer' && ( - <> - patchDraft({ tokenEnv: v })} - /> - patchDraft({ tokenFile: v })} - /> - - )} - - )} - - {d.type === 'loki' && ( - - patchDraft({ job: v })} - /> - patchDraft({ hostLabel: v })} - /> - patchDraft({ statusLabel: v })} - /> - patchDraft({ extraLabels: v })} - /> -

- Selector: {draftLogql(d)} -

-
- )} - - {d.type === 'file' && ( - - patchDraft({ path: v })} - /> - patchDraft({ maxSize: v })} - /> - patchDraft({ maxFiles: v })} - /> - - )} - - - patchDraft({ filterText: v })} - /> - - - {d.scope === 'instance' && ( - - patchDraft({ parseText: v })} - /> - - )} - - - patchDraft({ sample: v })} /> - patchDraft({ batchSize: v })} /> - patchDraft({ flushInterval: v })} /> - patchDraft({ maxRetries: v })} /> - patchDraft({ bufferSize: v })} /> + + )} + {d.authMethod === 'bearer' && ( + <> + patchDraft({ tokenEnv: v })} + /> + patchDraft({ tokenFile: v })} + /> + + )} @@ -844,7 +522,7 @@ function LogDestForm({ dests, onChanged }: { dests: LogDestDto[]; onChanged: () title="Remove log destination?" message={ pending - ? `Remove ${pending.name}. Shipping stops immediately${pending.scope === 'global' ? ' and its fragment is retracted from the NGINX server' : ''}. Buffered entries are flushed best-effort.` + ? `Remove ${pending.name}. Removal is blocked while any NGINX server or instance still binds it (currently: ${usedByText(pending)}).` : undefined } confirmLabel="Remove" diff --git a/quaykeeper/components/admin/AdminRealms.tsx b/quaykeeper/components/admin/AdminRealms.tsx index 5527c72d..444ce8c6 100644 --- a/quaykeeper/components/admin/AdminRealms.tsx +++ b/quaykeeper/components/admin/AdminRealms.tsx @@ -8,7 +8,15 @@ import type { Realm } from '@/server/domain/types' import { AdminPage, json, useOwnerData } from './shared' import { ConfirmDialog } from '@/components/ConfirmDialog' import { FormModal, FormGroup } from '@/components/FormModal' -import { TextField } from '@/components/fields' +import { SelectField, SwitchField, TextField } from '@/components/fields' +import { + ShapingFields, + buildShapingPayload, + emptyShapingDraft, + shapingDraftOf, + type ShapingDraft, +} from '@/components/LogShapingFields' +import type { LogShaping } from '@/server/domain/nginxpilot-logdest-fragment' import { useToast } from '@/components/Toast' // Owner-only realm registry (multiple_realms.md §C). A realm is one registered nginxpilot @@ -39,9 +47,42 @@ interface RealmTest { type RealmTestState = RealmTest | 'loading' | undefined +/** One realm log binding as `/api/admin/realms/{id}/log-bindings` returns it. */ +interface LogBindingDto { + id: string + destinationId: string + destinationName: string + destinationType: string + destinationUrl: string + enabled: boolean + shaping: LogShaping + logql: string +} + +/** Destination options for the binding select (`/api/admin/log-destinations`). */ +interface DestOption { + id: string + name: string + type: string + spec: { url: string } +} + +/** One destination's live shipping stats (`/api/admin/log-destinations/status?realm=`). */ +interface LogDestLiveStat { + name: string + shipped: number + dropped: number + failed_batches: number + buffer_len: number + last_error?: string + last_flush?: string +} + interface RealmRow extends Record { realm: Realm test: RealmTestState + /** The bound destination's name, when this realm ships its access logs somewhere. */ + logDest: string | undefined } /** Health-dot class + title: green = ok, amber = reachable-but-status-failed OR @@ -83,7 +124,7 @@ const REALM_COLUMNS: AdvancedTableColumn[] = [ /** The injected `` HTML — every interpolated value is escaped. */ function realmRowsHtml(rows: RealmRow[], busy: boolean): string { return rows - .map(({ realm: r, test }) => { + .map(({ realm: r, test, logDest }) => { const { cls, title } = healthDotMeta(test) const badges: string[] = [] @@ -91,6 +132,7 @@ function realmRowsHtml(rows: RealmRow[], busy: boolean): string { badges.push( `${r.hasToken ? 'token set' : 'no token'}`, ) + if (logDest) badges.push(`logs → ${escapeHtml(logDest)}`) if (test && test !== 'loading') { if (test.managed) badges.push('managed') if (test.apiVersion) @@ -115,6 +157,7 @@ function realmRowsHtml(rows: RealmRow[], busy: boolean): string { }), ]), iconBtnHtml({ icon: 'rotate', label: `Rotate token for ${r.name}`, data: { action: 'rotate', id: r.id } }), + iconBtnHtml({ icon: 'logs', label: `Log destination for ${r.name}`, data: { action: 'logdest', id: r.id } }), iconBtnHtml({ icon: 'remove', label: `Remove ${r.name}`, @@ -184,8 +227,12 @@ function RealmsForm({ realms, onChanged }: { realms: Realm[]; onChanged: () => v // The realm awaiting remove confirmation, and the realm whose token is being rotated. const [pending, setPending] = useState(null) const [rotating, setRotating] = useState(null) + // The realm whose log binding is being edited (the RealmLogDestModal). + const [logDestRealm, setLogDestRealm] = useState(null) // Live health results, keyed by realm id (lazily filled by the test effect / button). const [tests, setTests] = useState>({}) + // Each realm's log binding(s), keyed by realm id — drives the "logs → dest" badge. + const [bindings, setBindings] = useState>({}) const patchDraft = (p: Partial) => setForm((prev) => (prev ? { ...prev, ...p } : prev)) @@ -218,11 +265,26 @@ function RealmsForm({ realms, onChanged }: { realms: Realm[]; onChanged: () => v } }, []) - // Auto health-check every realm once on mount / when the set changes. + const loadBindings = useCallback(async (id: string) => { + try { + const res = await fetch(`/api/admin/realms/${encodeURIComponent(id)}/log-bindings`, { cache: 'no-store' }) + if (!res.ok) return + const list = (await res.json()) as LogBindingDto[] + setBindings((b) => ({ ...b, [id]: list })) + } catch { + // best-effort — the row simply renders without the logs badge + } + }, []) + + // Auto health-check every realm once on mount / when the set changes, and load + // its log binding for the "logs → dest" badge. useEffect(() => { - for (const r of realms) void runTest(r.id) + for (const r of realms) { + void runTest(r.id) + void loadBindings(r.id) + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [realms.map((r) => r.id).join(','), runTest]) + }, [realms.map((r) => r.id).join(','), runTest, loadBindings]) const add = useCallback(async () => { if (!form || busy) return @@ -329,10 +391,16 @@ function RealmsForm({ realms, onChanged }: { realms: Realm[]; onChanged: () => v }, [pending, busy, onChanged, toast]) const rows = useMemo( - () => realms.map((r) => ({ realm: r, test: tests[r.id] })), - [realms, tests], + () => + realms.map((r) => ({ + realm: r, + test: tests[r.id], + logDest: bindings[r.id]?.find((b) => b.enabled)?.destinationName ?? bindings[r.id]?.[0]?.destinationName, + })), + [realms, tests, bindings], ) const rotatingRealm = rotating ? realms.find((r) => r.id === rotating) : undefined + const logDestRealmObj = logDestRealm ? realms.find((r) => r.id === logDestRealm) : undefined // Row-action buttons live in the injected tbody HTML — one delegated host // listener routes their data-action clicks back to the React handlers. @@ -348,6 +416,7 @@ function RealmsForm({ realms, onChanged }: { realms: Realm[]; onChanged: () => v if (action === 'test') void runTest(id) else if (action === 'default') void setDefault(realm) else if (action === 'rotate') setRotating(id) + else if (action === 'logdest') setLogDestRealm(id) else if (action === 'remove') setPending(realm) }, [realms, runTest, setDefault], @@ -429,6 +498,16 @@ function RealmsForm({ realms, onChanged }: { realms: Realm[]; onChanged: () => v )} + {/* The per-realm log-destination binding editor (logs_feature.md §11). */} + {logDestRealmObj && ( + void loadBindings(logDestRealmObj.id)} + onClose={() => setLogDestRealm(null)} + /> + )} + {/* The write-only token-rotate editor is its own small modal (keyed per realm), so no React subtree is captured by tc-table. */} {rotatingRealm && ( @@ -465,6 +544,183 @@ function RealmsForm({ realms, onChanged }: { realms: Realm[]; onChanged: () => v ) } +/** The realm log-binding editor (logs_feature.md §11): pick an owner-defined + * destination (empty = unbound), shape the stream (labels when the destination is + * loki, filter, shipping tunables — no parse: edge access logs are already JSON), + * toggle enabled, and watch that daemon's live per-destination shipping stats. + * Save → PUT the binding (the fragment is pushed to THIS realm's nginxpilot); + * selecting the empty option and saving → DELETE (fragment retracted). */ +function RealmLogDestModal({ + realm, + onSaved, + onClose, +}: { + realm: Realm + onSaved: () => void + onClose: () => void +}) { + const toast = useToast() + const [loaded, setLoaded] = useState(false) + const [dests, setDests] = useState([]) + const [existing, setExisting] = useState(null) + const [destinationId, setDestinationId] = useState('') + const [enabled, setEnabled] = useState(true) + const [shaping, setShaping] = useState(() => emptyShapingDraft('realm')) + const [stats, setStats] = useState>({}) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + // Load the destination options + this realm's existing binding once. + useEffect(() => { + let cancelled = false + void (async () => { + try { + const [destList, bindingList] = await Promise.all([ + fetch('/api/admin/log-destinations', { cache: 'no-store' }).then((r) => json(r)), + fetch(`/api/admin/realms/${encodeURIComponent(realm.id)}/log-bindings`, { cache: 'no-store' }).then( + (r) => json(r), + ), + ]) + if (cancelled) return + setDests(destList) + const binding = bindingList[0] ?? null + setExisting(binding) + if (binding) { + setDestinationId(binding.destinationId) + setEnabled(binding.enabled) + setShaping(shapingDraftOf(binding.shaping)) + } + setLoaded(true) + } catch { + if (!cancelled) setError('Couldn’t load the destinations — close and retry.') + } + })() + return () => { + cancelled = true + } + }, [realm.id]) + + // Live per-destination shipping stats for THIS realm's daemon (D4). + useEffect(() => { + let cancelled = false + const load = async () => { + try { + const res = await fetch( + `/api/admin/log-destinations/status?realm=${encodeURIComponent(realm.id)}`, + { cache: 'no-store' }, + ) + if (!res.ok || cancelled) return + const body = (await res.json()) as { destinations?: LogDestLiveStat[] } + const byName: Record = {} + for (const s of body.destinations ?? []) byName[s.name] = s + if (!cancelled) setStats(byName) + } catch { + // best-effort — the modal renders without live stats + } + } + void load() + const t = setInterval(() => void load(), 5000) + return () => { + cancelled = true + clearInterval(t) + } + }, [realm.id]) + + const patchShaping = (p: Partial) => setShaping((prev) => ({ ...prev, ...p })) + + const chosen = dests.find((d) => d.id === destinationId) + const stat = chosen ? stats[chosen.name] : undefined + + const submit = async () => { + if (busy || !loaded) return + setBusy(true) + setError(null) + try { + if (!destinationId) { + // Empty select = unbound: clear the binding (retracts the fragment). + if (existing) { + const res = await fetch(`/api/admin/realms/${encodeURIComponent(realm.id)}/log-bindings`, { + method: 'DELETE', + }) + if (!res.ok && res.status !== 204) { + setError(`Couldn’t clear the log destination (error ${res.status}).`) + return + } + toast.show(`Log destination cleared for “${realm.name}”.`, { variant: 'success' }) + } + onSaved() + onClose() + return + } + const res = await fetch(`/api/admin/realms/${encodeURIComponent(realm.id)}/log-bindings`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + destinationId, + enabled, + shaping: buildShapingPayload(shaping, { scope: 'realm', loki: chosen?.type === 'loki' }), + }), + }) + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: string; detail?: string } | null + setError( + body?.detail + ? `Couldn’t save: ${body.detail}` + : body?.error + ? `Couldn’t save the binding: ${body.error}.` + : `Couldn’t save the binding (error ${res.status}).`, + ) + return + } + toast.show(`“${realm.name}” now ships its access logs to “${chosen?.name}”.`, { variant: 'success' }) + onSaved() + onClose() + } catch { + setError('Couldn’t save the binding — network error.') + } finally { + setBusy(false) + } + } + + return ( + void submit()} + onClose={onClose} + > + {error && {error}} + + ({ value: d.id, label: `${d.name} — ${d.spec.url}` })), + ]} + onValue={setDestinationId} + /> + {destinationId !== '' && ( + + )} + {stat && ( +

+ Shipping: ↑ {stat.shipped.toLocaleString()} · ✕ {stat.dropped.toLocaleString()} · ⚠{' '} + {stat.failed_batches.toLocaleString()} + {stat.buffer_len ? ` · buf ${stat.buffer_len}` : ''} + {stat.last_error ? ` — ${stat.last_error}` : ''} +

+ )} +
+ {destinationId !== '' && ( + + )} +
+ ) +} + /** Write-only token rotation for one realm, in a modal. An empty value clears the * token (→ the instance runs unauthenticated). `onSave` resolves to null on * success or an error message shown inside the modal (pilot error pattern). */ diff --git a/quaykeeper/components/config/InstanceDetail.tsx b/quaykeeper/components/config/InstanceDetail.tsx index 3728f2db..33e6c47e 100644 --- a/quaykeeper/components/config/InstanceDetail.tsx +++ b/quaykeeper/components/config/InstanceDetail.tsx @@ -8,13 +8,14 @@ import { ApiError, apiFetch, describeApiError } from '@/lib/fetcher' import type { Instance } from '@/server/domain/types' import { InstanceVars } from './InstanceVars' import { InstanceFlags } from './InstanceFlags' +import { InstanceLogs } from './InstanceLogs' import { InstanceSettings } from './InstanceSettings' -// Per-instance page (`/instances/{id}/{variables|flags|settings}`, +// Per-instance page (`/instances/{id}/{variables|flags|logs|settings}`, // move_wharf_to_perch.md §10). Header via tc-rich-page-header; a route-based -// SubTabBar (wharf-style sub-nav) switches between the three tabs. +// SubTabBar (wharf-style sub-nav) switches between the tabs. -export type InstanceTab = 'variables' | 'flags' | 'settings' +export type InstanceTab = 'variables' | 'flags' | 'logs' | 'settings' type LoadState = | { phase: 'loading' } @@ -88,6 +89,7 @@ export function InstanceDetail({ instanceId, tab }: { instanceId: string; tab: I const tabs: SubTab[] = [ { id: 'variables', label: 'Variables', icon: 'variable', href: `/instances/${instanceId}/variables` }, { id: 'flags', label: 'Flags', icon: 'flag', href: `/instances/${instanceId}/flags` }, + { id: 'logs', label: 'Logs', icon: 'scroll-text', href: `/instances/${instanceId}/logs` }, { id: 'settings', label: 'Settings', icon: 'settings', href: `/instances/${instanceId}/settings` }, ] @@ -104,6 +106,7 @@ export function InstanceDetail({ instanceId, tab }: { instanceId: string; tab: I
{tab === 'variables' && } {tab === 'flags' && } + {tab === 'logs' && } {tab === 'settings' && ( HTML — every interpolated value is escaped. The enabled + * cell is a real ; its bubbling `change` and the edit/delete buttons' + * clicks are caught by ONE delegated listener via `data-action` (the flags pattern). */ +function bindingRowsHtml(bindings: LogBindingDto[]): string { + return bindings + .map((b) => { + const id = escapeHtml(b.id) + const name = escapeHtml(b.destinationName) + const toggle = + `` + const sub = b.destinationType === 'loki' && b.logql ? b.logql : b.destinationUrl + const controls = [ + iconBtnHtml({ icon: 'edit', label: `Edit binding for ${b.destinationName}`, data: { action: 'edit', id: b.id } }), + iconBtnHtml({ + icon: 'remove', + label: `Remove binding for ${b.destinationName}`, + danger: true, + data: { action: 'delete', id: b.id, name: b.destinationName }, + }), + ].join('') + return ( + `` + + `` + + `${name}` + + `${escapeHtml(sub)}` + + `${escapeHtml(b.destinationType)}` + + `` + + `${toggle}` + + `${controls}` + + `` + ) + }) + .join('') +} + +interface BindingDraft { + /** null = creating; the binding id when editing (destination immutable then). */ + bindingId: string | null + destinationId: string + enabled: boolean + shaping: ShapingDraft +} + +export function InstanceLogs({ instanceId }: { instanceId: string }) { + const toast = useToast() + const [state, setState] = useState({ phase: 'loading' }) + const [form, setForm] = useState(null) + const [pending, setPending] = useState<{ id: string; name: string } | null>(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + const data = useMemo( + () => (state.phase === 'ready' ? state.data : { bindings: [], destinations: [] }), + [state], + ) + + const load = useCallback(async () => { + setState({ phase: 'loading' }) + try { + const next = await fetch(`/api/instances/${instanceId}/logs`, { cache: 'no-store' }).then((r) => + json(r), + ) + setState({ phase: 'ready', data: next }) + } catch { + setState({ phase: 'error' }) + } + }, [instanceId]) + + useEffect(() => { + void load() + }, [load]) + + const toggle = useCallback( + async (id: string, name: string, enabled: boolean) => { + const res = await callApi(`/api/instances/${instanceId}/logs/${id}`, 'PATCH', { enabled }) + if (!res.ok) { + toast.show(`Couldn’t update “${name}”: ${res.message}`, { variant: 'error' }) + } + // Reload either way: sync updatedAt on success, revert the switch on failure. + void load() + }, + [instanceId, load, toast], + ) + + // One delegated handler for everything inside the injected rows. + const onDelegated = useCallback( + (event: Event) => { + const el = (event.target as HTMLElement)?.closest?.('[data-action]') as HTMLElement | null + if (!el) return + const action = el.getAttribute('data-action') + const id = el.getAttribute('data-id') + const name = el.getAttribute('data-name') ?? '' + if (!action || !id) return + if (action === 'toggle' && event.type === 'change') { + const checked = (el as HTMLElement & { checked?: boolean }).checked === true + void toggle(id, name, checked) + } else if (action === 'edit' && event.type === 'click') { + const binding = data.bindings.find((b) => b.id === id) + if (!binding) return + setError(null) + setForm({ + bindingId: binding.id, + destinationId: binding.destinationId, + enabled: binding.enabled, + shaping: shapingDraftOf(binding.shaping), + }) + } else if (action === 'delete' && event.type === 'click') { + setPending({ id, name }) + } + }, + [toggle, data.bindings], + ) + + const tableProps = useMemo( + () => ({ + columns: LOG_COLUMNS, + total: data.bindings.length, + limit: Math.max(data.bindings.length, 1), + offset: 0, + rows: bindingRowsHtml(data.bindings), + }), + [data.bindings], + ) + const tableRef = useTc(tableProps, { click: onDelegated, change: onDelegated }) + + const openCreate = () => { + setError(null) + setForm({ + bindingId: null, + destinationId: data.destinations[0]?.id ?? '', + enabled: true, + shaping: emptyShapingDraft('instance'), + }) + } + const closeForm = () => { + setForm(null) + setError(null) + } + + const chosenDest = form ? data.destinations.find((d) => d.id === form.destinationId) : undefined + + const submit = useCallback(async () => { + if (!form || busy) return + if (!form.destinationId) { + setError('Pick a destination.') + return + } + setBusy(true) + setError(null) + const dest = data.destinations.find((d) => d.id === form.destinationId) + const shaping = buildShapingPayload(form.shaping, { scope: 'instance', loki: dest?.type === 'loki' }) + const res = form.bindingId + ? await callApi(`/api/instances/${instanceId}/logs/${form.bindingId}`, 'PATCH', { + enabled: form.enabled, + shaping, + }) + : await callApi(`/api/instances/${instanceId}/logs`, 'POST', { + destinationId: form.destinationId, + enabled: form.enabled, + shaping, + }) + setBusy(false) + if (!res.ok) { + setError(`Couldn’t save the log destination: ${res.message}`) + return + } + toast.show( + form.bindingId ? `Log binding for “${dest?.name}” updated.` : `Logs now ship to “${dest?.name}”.`, + { variant: 'success' }, + ) + setForm(null) + void load() + }, [form, busy, instanceId, data.destinations, load, toast]) + + const doDelete = useCallback(async () => { + if (!pending || busy) return + const { id, name } = pending + setPending(null) + setBusy(true) + const res = await callApi(`/api/instances/${instanceId}/logs/${id}`, 'DELETE') + setBusy(false) + if (!res.ok) { + toast.show(`Couldn’t remove “${name}”: ${res.message}`, { variant: 'error' }) + return + } + toast.show(`Log binding for “${name}” removed.`, { variant: 'success' }) + void load() + }, [pending, busy, instanceId, load, toast]) + + if (state.phase === 'loading') return + if (state.phase === 'error') { + return ( + void load()} + /> + ) + } + + const { bindings, destinations } = data + + return ( + <> + +
+

+ Ship this instance’s app logs to an owner-defined destination. Shipping happens client-side via + quaykeeper-client; changes are delivered on the next config fetch. Credentials are referenced by + env-var / file name — provision them as instance variables. +

+ {error && !form && {error}} +
+ + Add log destination + +
+ {bindings.length === 0 ? ( + + {destinations.length === 0 + ? 'No destinations exist yet — an owner defines them under Admin → Log destinations.' + : 'No log destinations bound yet.'} + + ) : ( + + )} +
+
+ + {form && ( + void submit()} + onClose={closeForm} + > + {error && {error}} + + ({ value: d.id, label: `${d.name} — ${d.url}` }))} + onValue={(v) => setForm((p) => (p ? { ...p, destinationId: v } : p))} + /> + setForm((p) => (p ? { ...p, enabled: c } : p))} + /> + + setForm((prev) => (prev ? { ...prev, shaping: { ...prev.shaping, ...p } } : prev))} + scope="instance" + loki={chosenDest?.type === 'loki'} + /> + + )} + + void doDelete()} + onCancel={() => setPending(null)} + /> + + ) +} diff --git a/quaykeeper/components/config/Instances.tsx b/quaykeeper/components/config/Instances.tsx index 7e69f55c..a276d776 100644 --- a/quaykeeper/components/config/Instances.tsx +++ b/quaykeeper/components/config/Instances.tsx @@ -96,7 +96,7 @@ export function Instances() { return ( void) + +const MIGRATIONS: Migration[] = [ + // v1 — the complete Quaykeeper schema. Formerly 21 incremental migrations + // (initial schema → realms → the Config subsystem → db servers → snippets → + // scheduled jobs → log destinations → the log endpoint/binding split), + // squashed into this single entry once the split landed. Squashing implies + // fresh-database semantics: a database that already ran the old sequence has + // version 1 recorded and skips this entry (its schema is equivalent, apart + // from log_destination's deprecated scope/target/enabled columns which this + // merged DDL drops outright and no code reads); a database stranded mid-way + // through the old sequence has no upgrade path and must be recreated. ` + -- Users + roles (static-hosting-app-design.md §12). CREATE TABLE app_user ( github_id INTEGER PRIMARY KEY, login TEXT NOT NULL, @@ -89,65 +102,8 @@ const MIGRATIONS: string[] = [ added_at TEXT NOT NULL ); - CREATE TABLE base_domain ( -- owner-managed subdomain pool - domain TEXT PRIMARY KEY, -- e.g. quaykeeper.dev - created_at TEXT NOT NULL - ); - - CREATE TABLE site ( - id TEXT PRIMARY KEY, -- short id; also the fragment filename suffix - owner_id INTEGER NOT NULL, -- app_user.github_id - repo_owner TEXT NOT NULL, - repo_name TEXT NOT NULL, - branch TEXT NOT NULL, - subdir TEXT, - hostname TEXT NOT NULL UNIQUE, -- alice.quaykeeper.dev | www.example.com - host_kind TEXT NOT NULL, -- subdomain | custom - status TEXT NOT NULL, -- draft|provisioning|live|failed|suspended|over_quota - bytes INTEGER, -- last measured deployed size - last_ref TEXT, -- last live git ref (from /status) - last_error TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE INDEX idx_site_owner ON site(owner_id); - - CREATE TABLE sponsorship ( - sponsor_login TEXT PRIMARY KEY, -- == app_user.login - tier_cents INTEGER NOT NULL, - status TEXT NOT NULL, -- active | pending_cancel | cancelled - effective_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - - CREATE TABLE plan_tier ( -- owner-editable $ → plan mapping - min_cents INTEGER PRIMARY KEY, - plan TEXT NOT NULL -- bronze | silver | gold - ); - - CREATE TABLE audit ( -- mirror TaskForge audit log - id INTEGER PRIMARY KEY AUTOINCREMENT, - at TEXT NOT NULL, - github_id INTEGER, - login TEXT, - action TEXT NOT NULL, - site TEXT, - detail TEXT - ); - CREATE INDEX idx_audit_id ON audit(id DESC); - `, - // v2 — base-domain audience tiers (§10). Each base domain is offered to one of - // three groups: free accounts, paid (sponsored) accounts, or staff (maintainer/ - // owner). Existing rows default to `free` so nothing a user could already pick - // disappears on upgrade. - ` - ALTER TABLE base_domain ADD COLUMN tier TEXT NOT NULL DEFAULT 'free'; -- free | paid | staff - `, - // v3 — per-user custom limit overrides (§11, §15). An owner can tweak any one - // user's quotas above/below their role/plan default. A row exists only when a - // user is customised; every column is nullable and a NULL field inherits the - // default. Cascades away if the user row is ever deleted. - ` + -- Per-user quota overrides (§11, §15). A row exists only when a user is + -- customised; every column is nullable and a NULL field inherits the default. CREATE TABLE user_limit ( github_id INTEGER PRIMARY KEY REFERENCES app_user(github_id) ON DELETE CASCADE, max_sites INTEGER, -- NULL = inherit role/plan default @@ -159,47 +115,32 @@ const MIGRATIONS: string[] = [ private_repos INTEGER, -- 0 | 1 | NULL (inherit) updated_at TEXT NOT NULL ); - `, - // v4 — global instance settings (owner-editable branding + custom-domain - // ingress). A generic key/value store: one row per setting, value is the raw - // string the UI persisted (validated in `domain/settings.ts` before it lands). - // A missing key falls through to its built-in default / env fallback, so the - // table is empty on a fresh instance and every setting is optional. - ` - CREATE TABLE app_setting ( - key TEXT PRIMARY KEY, -- app_name | tagline | theme | brand_color | ingress_ipv4 | ingress_ipv6 - value TEXT NOT NULL, + + -- Durable GitHub credential (private-repo support): the user's OAuth access + -- token, AES-256-GCM-encrypted via infrastructure/cipher.ts — never plaintext + -- at rest, never sent to the client. + CREATE TABLE user_github_token ( + github_id INTEGER PRIMARY KEY REFERENCES app_user(github_id) ON DELETE CASCADE, + token_enc TEXT NOT NULL, updated_at TEXT NOT NULL ); - `, - // v5 — per-base-domain subdomain TLS policy (§0/Phase D). One wildcard cert per base - // domain covers every `