feat(phase1a): admin-sdk base, audit helper, project seed, panel pages

- src/lib/audit.ts + src/lib/admin-sdk/base.ts (typed HTTP + auto audit)
- prisma/seed.ts seeds 5 projects (all inactive)
- pages: /projects, /projects/[key], /audit, /operations, /events, /settings
- components/panel-shell.tsx shared layout
- entrypoint runs seed after db push
This commit is contained in:
Semih
2026-05-13 10:33:18 +00:00
parent 5aca3934ca
commit d189206f06
14 changed files with 712 additions and 3 deletions

View File

@@ -0,0 +1,80 @@
import { prisma } from "@/lib/db";
import { PanelShell } from "@/components/panel-shell";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
export const dynamic = "force-dynamic";
export default async function AuditPage() {
const rows = await prisma.auditLog.findMany({
orderBy: { createdAt: "desc" },
take: 100,
});
return (
<PanelShell title="Audit">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">When</TableHead>
<TableHead>Project</TableHead>
<TableHead>Endpoint</TableHead>
<TableHead className="w-[70px]">Method</TableHead>
<TableHead className="w-[90px]">Status</TableHead>
<TableHead className="w-[80px] text-right">Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">
{r.createdAt.toISOString().replace("T", " ").slice(0, 19)}
</TableCell>
<TableCell>
{r.projectKey ? <Badge variant="outline">{r.projectKey}</Badge> : "—"}
</TableCell>
<TableCell className="font-mono text-xs">{r.endpoint}</TableCell>
<TableCell className="font-mono text-xs">{r.method}</TableCell>
<TableCell>
{r.responseStatus ? (
<Badge
variant={
r.responseStatus < 400
? "default"
: r.responseStatus < 500
? "secondary"
: "destructive"
}
>
{r.responseStatus}
</Badge>
) : (
"—"
)}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{r.durationMs ? `${r.durationMs}ms` : "—"}
</TableCell>
</TableRow>
))}
{rows.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
No audit entries yet.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</PanelShell>
);
}

View File

@@ -0,0 +1,15 @@
import { PanelShell } from "@/components/panel-shell";
export default function EventsPage() {
return (
<PanelShell title="Events">
<div className="rounded-lg border p-8">
<h2 className="text-lg font-semibold">Cross-project event timeline</h2>
<p className="mt-1 text-sm text-muted-foreground">
Phase 3 Redis Streams consumer + SSE push. Spokes publish to their own stream
(e.g. <code>sase:events</code>); panel worker persists and broadcasts here.
</p>
</div>
</PanelShell>
);
}

View File

@@ -0,0 +1,27 @@
import { PanelShell } from "@/components/panel-shell";
export default function OperationsPage() {
return (
<PanelShell title="Operations">
<ComingSoon
title="Operations"
items={["Coolify deploy triggers", "Backup status", "Migration runner"]}
phase="Phase 5"
/>
</PanelShell>
);
}
function ComingSoon({ title, items, phase }: { title: string; items: string[]; phase: string }) {
return (
<div className="rounded-lg border p-8">
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">Planned for {phase}. Will include:</p>
<ul className="mt-3 list-inside list-disc text-sm text-muted-foreground">
{items.map((i) => (
<li key={i}>{i}</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
import { PanelShell } from "@/components/panel-shell";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
export default async function ProjectDetail({ params }: { params: Promise<{ key: string }> }) {
const { key } = await params;
const project = await prisma.project.findUnique({ where: { key } });
if (!project) notFound();
return (
<PanelShell title={project.name}>
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold">{project.name}</h2>
<Badge variant={project.active ? "default" : "outline"}>
{project.active ? "wired" : "not wired"}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{project.description}</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardDescription>Connection</CardDescription>
<CardTitle className="text-base">
{project.active ? "Read-only DB attached" : "No DB connection configured"}
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{project.active
? "DATABASE_URL is wired. See live metrics below."
: "Configure DATABASE_URL_<KEY>_RO in Coolify env."}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>Admin SDK</CardDescription>
<CardTitle className="text-base">0 endpoints wired</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Spoke needs <code>/internal/admin/*</code> endpoints exposed.
</CardContent>
</Card>
</div>
</PanelShell>
);
}

View File

@@ -0,0 +1,36 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
import { PanelShell } from "@/components/panel-shell";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
export default async function ProjectsPage() {
const projects = await prisma.project.findMany({ orderBy: { name: "asc" } });
return (
<PanelShell title="Projects">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => (
<Link key={p.id} href={`/projects/${p.key}`} className="block">
<Card className="transition-colors hover:border-foreground/40">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">{p.name}</CardTitle>
<Badge variant={p.active ? "default" : "outline"}>
{p.active ? "wired" : "not wired"}
</Badge>
</div>
<CardDescription>{p.description ?? "—"}</CardDescription>
</CardHeader>
</Card>
</Link>
))}
{projects.length === 0 && (
<p className="text-sm text-muted-foreground">No projects yet run prisma seed.</p>
)}
</div>
</PanelShell>
);
}

View File

@@ -0,0 +1,35 @@
import { headers } from "next/headers";
import { PanelShell } from "@/components/panel-shell";
import { auth } from "@/lib/auth";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export const dynamic = "force-dynamic";
export default async function SettingsPage() {
const session = await auth.api.getSession({ headers: await headers() });
return (
<PanelShell title="Settings">
<Card>
<CardHeader>
<CardDescription>Account</CardDescription>
<CardTitle className="text-base">{session?.user.email}</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm text-muted-foreground">
<div>Name: {session?.user.name}</div>
<div>Session expires: {session?.session.expiresAt.toISOString()}</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>Panel</CardDescription>
<CardTitle className="text-base">Tailscale-only · Phase 0/1</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
See <code>super-panel-prd.md</code> for roadmap.
</CardContent>
</Card>
</PanelShell>
);
}

View File

@@ -0,0 +1,33 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { AppSidebar } from "@/components/app-sidebar";
import { SiteHeader } from "@/components/site-header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
export async function PanelShell({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
return (
<SidebarProvider
style={
{
"--sidebar-width": "calc(var(--spacing) * 72)",
"--header-height": "calc(var(--spacing) * 12)",
} as React.CSSProperties
}
>
<AppSidebar variant="inset" user={{ name: session.user.name, email: session.user.email }} />
<SidebarInset>
<SiteHeader title={title} />
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">{children}</div>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -1,7 +1,7 @@
import { Separator } from "@/components/ui/separator"
import { SidebarTrigger } from "@/components/ui/sidebar"
export function SiteHeader() {
export function SiteHeader({ title = "Overview" }: { title?: string }) {
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
@@ -10,7 +10,7 @@ export function SiteHeader() {
orientation="vertical"
className="mx-2 h-4 data-vertical:self-auto"
/>
<h1 className="text-base font-medium">Overview</h1>
<h1 className="text-base font-medium">{title}</h1>
</div>
</header>
)

View File

@@ -0,0 +1,65 @@
import { writeAudit } from "../audit";
export class AdminApiError extends Error {
constructor(public readonly status: number, public readonly endpoint: string, message: string) {
super(message);
this.name = "AdminApiError";
}
}
type ClientOptions = {
projectKey: string;
baseUrl: string;
token: string;
timeoutMs?: number;
};
export class AdminClient {
constructor(private readonly opts: ClientOptions) {}
async call<T>(
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE",
path: string,
body?: unknown,
): Promise<T> {
const url = `${this.opts.baseUrl.replace(/\/$/, "")}${path}`;
const started = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.opts.timeoutMs ?? 15_000);
let status = 0;
let payload: unknown = null;
try {
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
"X-Internal-Token": this.opts.token,
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
cache: "no-store",
});
status = res.status;
payload = await res.json().catch(() => null);
if (!res.ok) {
throw new AdminApiError(
status,
path,
(payload as { message?: string })?.message ?? `Upstream ${status}`,
);
}
return payload as T;
} finally {
clearTimeout(timer);
void writeAudit({
projectKey: this.opts.projectKey,
endpoint: path,
method,
requestPayload: body,
responseStatus: status,
durationMs: Date.now() - started,
}).catch(() => {});
}
}
}

38
apps/web/src/lib/audit.ts Normal file
View File

@@ -0,0 +1,38 @@
import { headers } from "next/headers";
import { createHash } from "node:crypto";
import { prisma } from "./db";
import { auth } from "./auth";
type AuditInput = {
projectKey?: string;
endpoint: string;
method: string;
requestPayload?: unknown;
responseStatus?: number;
durationMs?: number;
};
export async function writeAudit(input: AuditInput) {
const h = await headers();
const session = await auth.api.getSession({ headers: h });
const sourceIp =
h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? null;
const userAgent = h.get("user-agent") ?? null;
const requestHash = input.requestPayload
? createHash("sha256").update(JSON.stringify(input.requestPayload)).digest("hex").slice(0, 16)
: null;
await prisma.auditLog.create({
data: {
actorUserId: session?.user.id ?? null,
projectKey: input.projectKey ?? null,
endpoint: input.endpoint,
method: input.method,
requestHash,
responseStatus: input.responseStatus ?? null,
durationMs: input.durationMs ?? null,
sourceIp,
userAgent,
},
});
}