feat(phase1b): sase.tr read-only connection

- prisma/sase/schema.prisma with User + UserSubscription (partial view)
- lib/db-sase.ts exports typed RO client
- /projects/sase shows live metrics (users count, new users 7d, active subs)
- seed marks sase as active

DATABASE_URL_SASE_RO wired to super_panel_reader (SELECT-only)
This commit is contained in:
Semih
2026-05-13 10:38:03 +00:00
parent d189206f06
commit f72dcc05ba
6 changed files with 165 additions and 30 deletions

View File

@@ -0,0 +1,82 @@
import { saseDb } from "@/lib/db-sase";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
async function getSaseStats() {
try {
const [users, recentUsers, activeSubs, totalSubs] = await Promise.all([
saseDb.user.count(),
saseDb.user.count({
where: { createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } },
}),
saseDb.userSubscription.count({ where: { status: "active" } }),
saseDb.userSubscription.count(),
]);
return { ok: true as const, users, recentUsers, activeSubs, totalSubs };
} catch (err) {
return { ok: false as const, error: err instanceof Error ? err.message : String(err) };
}
}
export async function SaseHealth() {
const stats = await getSaseStats();
if (!stats.ok) {
return (
<Card>
<CardHeader>
<CardDescription>Connection</CardDescription>
<CardTitle className="text-base">
<Badge variant="destructive">unreachable</Badge>
</CardTitle>
</CardHeader>
<CardContent className="font-mono text-xs text-muted-foreground">
{stats.error}
</CardContent>
</Card>
);
}
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<Stat label="Connection" value="healthy" badge="ok" />
<Stat label="Total users" value={stats.users.toString()} hint="public.users" />
<Stat
label="New users (7d)"
value={stats.recentUsers.toString()}
hint="created_at last 7 days"
/>
<Stat
label="Active subscriptions"
value={`${stats.activeSubs} / ${stats.totalSubs}`}
hint="status='active'"
/>
</div>
);
}
function Stat({
label,
value,
hint,
badge,
}: {
label: string;
value: string;
hint?: string;
badge?: string;
}) {
return (
<Card>
<CardHeader>
<CardDescription>{label}</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums">
{badge ? <Badge>{badge}</Badge> : value}
</CardTitle>
</CardHeader>
{hint && (
<CardContent className="text-xs text-muted-foreground">{hint}</CardContent>
)}
</Card>
);
}

View File

@@ -1,10 +1,11 @@
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";
import { SaseHealth } from "./_sase";
export const dynamic = "force-dynamic";
export default async function ProjectDetail({ params }: { params: Promise<{ key: string }> }) {
const { key } = await params;
@@ -21,31 +22,30 @@ export default async function ProjectDetail({ params }: { params: Promise<{ key:
</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>
{key === "sase" ? (
<SaseHealth />
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardDescription>Connection</CardDescription>
<CardTitle className="text-base">No DB connection configured</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Configure DATABASE_URL_{key.toUpperCase()}_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,11 @@
import { PrismaClient } from ".prisma/client-sase";
const globalForSase = globalThis as unknown as { saseDb?: PrismaClient };
export const saseDb =
globalForSase.saseDb ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForSase.saseDb = saseDb;