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:
80
apps/web/src/app/audit/page.tsx
Normal file
80
apps/web/src/app/audit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
apps/web/src/app/events/page.tsx
Normal file
15
apps/web/src/app/events/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
apps/web/src/app/operations/page.tsx
Normal file
27
apps/web/src/app/operations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
apps/web/src/app/projects/[key]/page.tsx
Normal file
51
apps/web/src/app/projects/[key]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/web/src/app/projects/page.tsx
Normal file
36
apps/web/src/app/projects/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
apps/web/src/app/settings/page.tsx
Normal file
35
apps/web/src/app/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user