Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 10 additions & 11 deletions quaykeeper/app/api/admin/log-destinations/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)
Expand All @@ -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)
Expand Down
20 changes: 9 additions & 11 deletions quaykeeper/app/api/admin/log-destinations/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 })
Expand Down
14 changes: 10 additions & 4 deletions quaykeeper/app/api/admin/log-destinations/status/route.ts
Original file line number Diff line number Diff line change
@@ -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=<id>] — 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')`.

Expand All @@ -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)
Expand Down
67 changes: 67 additions & 0 deletions quaykeeper/app/api/admin/realms/[id]/log-bindings/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
52 changes: 52 additions & 0 deletions quaykeeper/app/api/instances/[id]/logs/[bindingId]/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
56 changes: 56 additions & 0 deletions quaykeeper/app/api/instances/[id]/logs/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
17 changes: 17 additions & 0 deletions quaykeeper/app/instances/[id]/logs/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AuthGate>
<InstanceDetail instanceId={id} tab="logs" />
</AuthGate>
)
}
2 changes: 1 addition & 1 deletion quaykeeper/app/instances/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion quaykeeper/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
2 changes: 1 addition & 1 deletion quaykeeper/components/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const ADMIN_LABELS: Record<string, string> = {

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')) {
Expand Down
2 changes: 1 addition & 1 deletion quaykeeper/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Loading
Loading