331 lines
15 KiB
TypeScript
331 lines
15 KiB
TypeScript
import Link from 'next/link';
|
|
export const dynamic = 'force-dynamic';
|
|
import { prisma } from '@/lib/prisma';
|
|
import { getServiceAddress } from '@/lib/service-address';
|
|
import Redis from 'ioredis';
|
|
import { S3Client, ListBucketsCommand } from '@aws-sdk/client-s3';
|
|
import { createDemoTenantAction, clearDemoDataAction } from './actions';
|
|
|
|
// Dynamic rendering handled via async data
|
|
|
|
async function checkPostgres() {
|
|
try {
|
|
const raw = await prisma.$queryRaw<Array<{ status: number }>>`SELECT 1 as status;`;
|
|
return { ok: true, message: `Connected (SELECT 1 -> ${raw[0]?.status ?? 1})` };
|
|
} catch (err: any) {
|
|
return { ok: false, message: err.message };
|
|
}
|
|
}
|
|
|
|
async function checkRedis() {
|
|
const redisUrl = process.env.REDIS_URL;
|
|
if (!redisUrl) return { ok: false, message: 'REDIS_URL not configured' };
|
|
|
|
const client = new Redis(redisUrl, {
|
|
lazyConnect: true,
|
|
maxRetriesPerRequest: 1,
|
|
connectTimeout: 2000,
|
|
commandTimeout: 2000,
|
|
retryStrategy: () => null,
|
|
});
|
|
|
|
// Register before ping starts the connection. Surface failures in the health
|
|
// card instead of ioredis logging an unhandled error to the dev overlay.
|
|
let connectionError: Error | undefined;
|
|
client.on('error', (error: Error) => {
|
|
connectionError = error;
|
|
});
|
|
|
|
try {
|
|
const pong = await client.ping();
|
|
return { ok: pong === 'PONG', message: `Connected (PING -> ${pong})` };
|
|
} catch (err: unknown) {
|
|
const message = connectionError?.message ?? (err instanceof Error ? err.message : 'Redis connection failed');
|
|
return { ok: false, message };
|
|
} finally {
|
|
client.disconnect();
|
|
}
|
|
}
|
|
|
|
async function checkS3() {
|
|
const endpoint = process.env.S3_ENDPOINT;
|
|
const accessKeyId = process.env.S3_ACCESS_KEY;
|
|
const secretAccessKey = process.env.S3_SECRET_KEY;
|
|
|
|
if (!endpoint || !accessKeyId || !secretAccessKey) {
|
|
return { ok: false, message: 'MinIO credentials missing' };
|
|
}
|
|
|
|
const s3 = new S3Client({
|
|
endpoint,
|
|
region: 'us-east-1',
|
|
credentials: { accessKeyId, secretAccessKey },
|
|
forcePathStyle: true,
|
|
});
|
|
|
|
try {
|
|
const res = await s3.send(new ListBucketsCommand({}));
|
|
const count = res.Buckets?.length ?? 0;
|
|
return { ok: true, message: `Connected (${count} buckets listed)` };
|
|
} catch (err: any) {
|
|
return { ok: false, message: err.message };
|
|
}
|
|
}
|
|
|
|
export default async function Home() {
|
|
const [pgStatus, redisStatus, s3Status] = await Promise.all([
|
|
checkPostgres(),
|
|
checkRedis(),
|
|
checkS3(),
|
|
]);
|
|
|
|
const [tenants, userCount, sessionCount, bookingCount, waiverCount] = await Promise.all([
|
|
prisma.tenant.findMany({
|
|
include: {
|
|
users: true,
|
|
classSessions: true,
|
|
bookings: true,
|
|
eWaivers: true,
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 5,
|
|
}),
|
|
prisma.user.count(),
|
|
prisma.classSession.count(),
|
|
prisma.booking.count(),
|
|
prisma.eWaiver.count(),
|
|
]);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-neutral-950 text-neutral-100 font-sans p-6 md:p-12">
|
|
<div className="max-w-6xl mx-auto space-y-8">
|
|
{/* Header */}
|
|
<header className="border-b border-neutral-800 pb-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
<div>
|
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-semibold uppercase tracking-wider mb-2">
|
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
|
Milestone 1 Verified
|
|
</div>
|
|
<h1 className="text-3xl font-bold tracking-tight text-white">Fitness App POC & Multi-Tenant Engine</h1>
|
|
<p className="text-sm text-neutral-400 mt-1">
|
|
Next.js 16 (App Router) • Prisma ORM • PostgreSQL • Redis • MinIO S3
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<form action={createDemoTenantAction}>
|
|
<button
|
|
type="submit"
|
|
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium rounded-lg shadow transition"
|
|
>
|
|
+ Seed Demo Tenant
|
|
</button>
|
|
</form>
|
|
{tenants.length > 0 && (
|
|
<form action={clearDemoDataAction}>
|
|
<button
|
|
type="submit"
|
|
className="px-4 py-2 bg-neutral-800 hover:bg-red-900/60 text-neutral-300 hover:text-red-300 text-sm font-medium rounded-lg border border-neutral-700 transition"
|
|
>
|
|
Clear Data
|
|
</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{/* Live Service Health Grid */}
|
|
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{/* PostgreSQL Card */}
|
|
<div className="bg-neutral-900/80 border border-neutral-800 rounded-xl p-5 flex flex-col justify-between">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Database</span>
|
|
<span className={`inline-block w-2.5 h-2.5 rounded-full ${pgStatus.ok ? 'bg-emerald-400' : 'bg-red-500'}`} />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-white">PostgreSQL</h3>
|
|
<p className="text-xs text-neutral-400 mt-1">{getServiceAddress(process.env.DATABASE_URL)}</p>
|
|
<p className={`text-xs mt-2 truncate ${pgStatus.ok ? 'text-emerald-400 font-mono' : 'text-red-400'}`}>
|
|
{pgStatus.message}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Redis Card */}
|
|
<div className="bg-neutral-900/80 border border-neutral-800 rounded-xl p-5 flex flex-col justify-between">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Cache / Queue</span>
|
|
<span className={`inline-block w-2.5 h-2.5 rounded-full ${redisStatus.ok ? 'bg-emerald-400' : 'bg-red-500'}`} />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-white">Redis 7</h3>
|
|
<p className="text-xs text-neutral-400 mt-1">{getServiceAddress(process.env.REDIS_URL)}</p>
|
|
<p className={`text-xs mt-2 truncate ${redisStatus.ok ? 'text-emerald-400 font-mono' : 'text-red-400'}`}>
|
|
{redisStatus.message}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* MinIO S3 Card */}
|
|
<div className="bg-neutral-900/80 border border-neutral-800 rounded-xl p-5 flex flex-col justify-between">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Object Storage</span>
|
|
<span className={`inline-block w-2.5 h-2.5 rounded-full ${s3Status.ok ? 'bg-emerald-400' : 'bg-red-500'}`} />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-white">MinIO S3</h3>
|
|
<p className="text-xs text-neutral-400 mt-1">{getServiceAddress(process.env.S3_ENDPOINT)}</p>
|
|
<p className={`text-xs mt-2 truncate ${s3Status.ok ? 'text-emerald-400 font-mono' : 'text-red-400'}`}>
|
|
{s3Status.message}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Multi-Tenant Extension Card */}
|
|
<div className="bg-neutral-900/80 border border-neutral-800 rounded-xl p-5 flex flex-col justify-between">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Prisma Client</span>
|
|
<span className="inline-block w-2.5 h-2.5 rounded-full bg-emerald-400" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-white">$extends Filter</h3>
|
|
<p className="text-xs text-neutral-400 mt-1">lib/prisma.ts</p>
|
|
<p className="text-xs mt-2 text-emerald-400 font-mono">
|
|
tenantId auto-scoping active
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Database Metric Stats */}
|
|
<section className="bg-neutral-900/40 border border-neutral-800 rounded-xl p-6">
|
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-neutral-400 mb-4">
|
|
Live Schema Metrics
|
|
</h2>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
|
<div className="bg-neutral-900 border border-neutral-800/80 rounded-lg p-4">
|
|
<div className="text-2xl font-bold text-white">{tenants.length}</div>
|
|
<div className="text-xs text-neutral-400 mt-1">Tenants (Gyms)</div>
|
|
</div>
|
|
<div className="bg-neutral-900 border border-neutral-800/80 rounded-lg p-4">
|
|
<div className="text-2xl font-bold text-white">{userCount}</div>
|
|
<div className="text-xs text-neutral-400 mt-1">Users</div>
|
|
</div>
|
|
<div className="bg-neutral-900 border border-neutral-800/80 rounded-lg p-4">
|
|
<div className="text-2xl font-bold text-white">{sessionCount}</div>
|
|
<div className="text-xs text-neutral-400 mt-1">Class Sessions</div>
|
|
</div>
|
|
<div className="bg-neutral-900 border border-neutral-800/80 rounded-lg p-4">
|
|
<div className="text-2xl font-bold text-white">{bookingCount}</div>
|
|
<div className="text-xs text-neutral-400 mt-1">Bookings</div>
|
|
</div>
|
|
<div className="bg-neutral-900 border border-neutral-800/80 rounded-lg p-4">
|
|
<div className="text-2xl font-bold text-white">{waiverCount}</div>
|
|
<div className="text-xs text-neutral-400 mt-1">E-Waivers</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Tenants Section */}
|
|
<section className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold text-white">Registered Tenants & Scoped Records</h2>
|
|
<span className="text-xs text-neutral-400">Showing recent tenants</span>
|
|
</div>
|
|
|
|
{tenants.length === 0 ? (
|
|
<div className="border border-dashed border-neutral-800 rounded-xl p-12 text-center">
|
|
<p className="text-neutral-400 text-sm">No tenants registered in the database yet.</p>
|
|
<p className="text-neutral-500 text-xs mt-1">
|
|
Click <span className="text-emerald-400 font-semibold">"+ Seed Demo Tenant"</span> in the header to create an isolated gym tenant with sample users and classes.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{tenants.map((t) => (
|
|
<div
|
|
key={t.id}
|
|
className="bg-neutral-900 border border-neutral-800 rounded-xl p-5 hover:border-neutral-700 transition"
|
|
>
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-neutral-800 pb-3">
|
|
<div>
|
|
<h3 className="text-base font-semibold text-white">{t.name}</h3>
|
|
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-400 font-mono">
|
|
<span>slug: {t.slug}</span>
|
|
<span>•</span>
|
|
<span>domain: {t.domain ?? 'n/a'}</span>
|
|
<span>•</span>
|
|
<span className="text-neutral-500">ID: {t.id}</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span className="px-2 py-0.5 rounded bg-neutral-800 text-neutral-300">
|
|
{t.users.length} Users
|
|
</span>
|
|
<span className="px-2 py-0.5 rounded bg-neutral-800 text-neutral-300">
|
|
{t.classSessions.length} Classes
|
|
</span>
|
|
<span className="px-2 py-0.5 rounded bg-neutral-800 text-neutral-300">
|
|
{t.bookings.length} Bookings
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Users breakdown */}
|
|
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
|
|
<div>
|
|
<span className="text-neutral-400 font-medium">Tenant Users:</span>
|
|
<div className="mt-1 space-y-1">
|
|
{t.users.map((u) => (
|
|
<div key={u.id} className="flex items-center justify-between bg-neutral-950 px-2.5 py-1.5 rounded border border-neutral-800/60">
|
|
<span className="text-neutral-200">{u.name} ({u.email})</span>
|
|
<span className="px-1.5 py-0.5 rounded text-[10px] bg-neutral-800 text-neutral-400 font-mono">
|
|
{u.role}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<span className="text-neutral-400 font-medium">Class Sessions:</span>
|
|
<div className="mt-1 space-y-1">
|
|
{t.classSessions.map((cs) => (
|
|
<div key={cs.id} className="bg-neutral-950 px-2.5 py-1.5 rounded border border-neutral-800/60 flex items-center justify-between">
|
|
<span className="text-neutral-200">{cs.title}</span>
|
|
<span className="text-neutral-400 text-[10px] font-mono">Cap: {cs.capacity}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 pt-3 border-t border-neutral-800 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
|
<div className="text-xs text-neutral-400">
|
|
Live Virtual Classroom:
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Link
|
|
href={`/${t.slug}/classes`}
|
|
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold rounded-lg shadow transition flex items-center gap-1.5"
|
|
>
|
|
<span className="w-1.5 h-1.5 rounded-full bg-white animate-pulse" />
|
|
Student Schedule & Live Room →
|
|
</Link>
|
|
<Link
|
|
href={`/${t.slug}/instructor/classes`}
|
|
className="px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 text-xs font-medium rounded-lg border border-neutral-700 transition"
|
|
>
|
|
Instructor Portal →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|