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:
82
apps/web/src/app/projects/[key]/_sase.tsx
Normal file
82
apps/web/src/app/projects/[key]/_sase.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user