ui/ health checklist
This commit is contained in:
126
src/app/api/diagnostics/route.ts
Normal file
126
src/app/api/diagnostics/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
type StatusLevel = "ok" | "warn" | "error";
|
||||
|
||||
interface DiagnosticsResponse {
|
||||
timestamp: string;
|
||||
env: {
|
||||
DATABASE_URL: boolean;
|
||||
BETTER_AUTH_SECRET: boolean;
|
||||
GOOGLE_CLIENT_ID: boolean;
|
||||
GOOGLE_CLIENT_SECRET: boolean;
|
||||
OPENAI_API_KEY: boolean;
|
||||
NEXT_PUBLIC_APP_URL: boolean;
|
||||
};
|
||||
database: {
|
||||
connected: boolean;
|
||||
schemaApplied: boolean;
|
||||
error?: string;
|
||||
};
|
||||
auth: {
|
||||
configured: boolean;
|
||||
routeResponding: boolean | null;
|
||||
};
|
||||
ai: {
|
||||
configured: boolean;
|
||||
};
|
||||
overallStatus: StatusLevel;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const env = {
|
||||
DATABASE_URL: Boolean(process.env.DATABASE_URL),
|
||||
BETTER_AUTH_SECRET: Boolean(process.env.BETTER_AUTH_SECRET),
|
||||
GOOGLE_CLIENT_ID: Boolean(process.env.GOOGLE_CLIENT_ID),
|
||||
GOOGLE_CLIENT_SECRET: Boolean(process.env.GOOGLE_CLIENT_SECRET),
|
||||
OPENAI_API_KEY: Boolean(process.env.OPENAI_API_KEY),
|
||||
NEXT_PUBLIC_APP_URL: Boolean(process.env.NEXT_PUBLIC_APP_URL),
|
||||
} as const;
|
||||
|
||||
// Database checks
|
||||
let dbConnected = false;
|
||||
let schemaApplied = false;
|
||||
let dbError: string | undefined;
|
||||
if (env.DATABASE_URL) {
|
||||
try {
|
||||
const [{ db }, { sql }, schema] = await Promise.all([
|
||||
import("@/lib/db"),
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/schema"),
|
||||
]);
|
||||
// Ping DB
|
||||
await db.execute(sql`select 1`);
|
||||
dbConnected = true;
|
||||
try {
|
||||
// Touch a known table to verify migrations
|
||||
await db.select().from(schema.user).limit(1);
|
||||
schemaApplied = true;
|
||||
} catch {
|
||||
schemaApplied = false;
|
||||
}
|
||||
} catch (err) {
|
||||
dbConnected = false;
|
||||
dbError = err instanceof Error ? err.message : "Unknown database error";
|
||||
}
|
||||
} else {
|
||||
dbConnected = false;
|
||||
schemaApplied = false;
|
||||
dbError = "DATABASE_URL is not set";
|
||||
}
|
||||
|
||||
// Auth route check: we consider the route responding if it returns any HTTP response
|
||||
// for /api/auth/session (status codes in the 2xx-4xx range are acceptable for readiness)
|
||||
const origin = (() => {
|
||||
try {
|
||||
return new URL(req.url).origin;
|
||||
} catch {
|
||||
return process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
||||
}
|
||||
})();
|
||||
|
||||
let authRouteResponding: boolean | null = null;
|
||||
try {
|
||||
const res = await fetch(`${origin}/api/auth/session`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
authRouteResponding = res.status >= 200 && res.status < 500;
|
||||
} catch {
|
||||
authRouteResponding = false;
|
||||
}
|
||||
|
||||
const authConfigured =
|
||||
env.BETTER_AUTH_SECRET && env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET;
|
||||
const aiConfigured = env.OPENAI_API_KEY; // We avoid live-calling the AI provider here
|
||||
|
||||
const overallStatus: StatusLevel = (() => {
|
||||
if (!env.DATABASE_URL || !dbConnected || !schemaApplied) return "error";
|
||||
if (!authConfigured) return "error";
|
||||
// AI is optional; warn if not configured
|
||||
if (!aiConfigured) return "warn";
|
||||
return "ok";
|
||||
})();
|
||||
|
||||
const body: DiagnosticsResponse = {
|
||||
timestamp: new Date().toISOString(),
|
||||
env,
|
||||
database: {
|
||||
connected: dbConnected,
|
||||
schemaApplied,
|
||||
error: dbError,
|
||||
},
|
||||
auth: {
|
||||
configured: authConfigured,
|
||||
routeResponding: authRouteResponding,
|
||||
},
|
||||
ai: {
|
||||
configured: aiConfigured,
|
||||
},
|
||||
overallStatus,
|
||||
};
|
||||
|
||||
return NextResponse.json(body, {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { useSession } from "@/lib/auth-client";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import type { Components } from "react-markdown";
|
||||
import type { CodeComponent } from "react-markdown/lib/ast-to-react";
|
||||
|
||||
const H1: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = (props) => (
|
||||
<h1 className="mt-2 mb-3 text-2xl font-bold" {...props} />
|
||||
@@ -35,6 +34,7 @@ const Anchor: React.FC<React.AnchorHTMLAttributes<HTMLAnchorElement>> = (
|
||||
) => (
|
||||
<a
|
||||
className="underline underline-offset-2 text-primary hover:opacity-90"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
{...props}
|
||||
/>
|
||||
@@ -47,8 +47,11 @@ const Blockquote: React.FC<React.BlockquoteHTMLAttributes<HTMLElement>> = (
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
const Code: CodeComponent = ({ inline, children, ...props }) => {
|
||||
if (inline) {
|
||||
const Code: Components["code"] = ({ children, className, ...props }) => {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
const isInline = !match;
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xs" {...props}>
|
||||
{children}
|
||||
@@ -116,11 +119,7 @@ function renderMessageContent(message: MaybePartsMessage): ReactNode {
|
||||
: [];
|
||||
return parts.map((p, idx) =>
|
||||
p?.type === "text" && p.text ? (
|
||||
<ReactMarkdown
|
||||
key={idx}
|
||||
linkTarget="_blank"
|
||||
components={markdownComponents}
|
||||
>
|
||||
<ReactMarkdown key={idx} components={markdownComponents}>
|
||||
{p.text}
|
||||
</ReactMarkdown>
|
||||
) : null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SetupChecklist } from "@/components/setup-checklist";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
@@ -44,6 +45,8 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 mt-12">
|
||||
<SetupChecklist />
|
||||
|
||||
<h3 className="text-2xl font-semibold">Next Steps</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-left">
|
||||
<div className="p-4 border rounded-lg">
|
||||
|
||||
147
src/components/setup-checklist.tsx
Normal file
147
src/components/setup-checklist.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type DiagnosticsResponse = {
|
||||
timestamp: string;
|
||||
env: {
|
||||
DATABASE_URL: boolean;
|
||||
BETTER_AUTH_SECRET: boolean;
|
||||
GOOGLE_CLIENT_ID: boolean;
|
||||
GOOGLE_CLIENT_SECRET: boolean;
|
||||
OPENAI_API_KEY: boolean;
|
||||
NEXT_PUBLIC_APP_URL: boolean;
|
||||
};
|
||||
database: {
|
||||
connected: boolean;
|
||||
schemaApplied: boolean;
|
||||
error?: string;
|
||||
};
|
||||
auth: {
|
||||
configured: boolean;
|
||||
routeResponding: boolean | null;
|
||||
};
|
||||
ai: {
|
||||
configured: boolean;
|
||||
};
|
||||
overallStatus: "ok" | "warn" | "error";
|
||||
};
|
||||
|
||||
function StatusIcon({ ok }: { ok: boolean }) {
|
||||
return ok ? (
|
||||
<span aria-label="ok" title="ok">
|
||||
✅
|
||||
</span>
|
||||
) : (
|
||||
<span aria-label="not-ok" title="not ok">
|
||||
❌
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupChecklist() {
|
||||
const [data, setData] = useState<DiagnosticsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/diagnostics", { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = (await res.json()) as DiagnosticsResponse;
|
||||
setData(json);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load diagnostics");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
key: "env",
|
||||
label: "Environment variables",
|
||||
ok:
|
||||
!!data?.env.DATABASE_URL &&
|
||||
!!data?.env.BETTER_AUTH_SECRET &&
|
||||
!!data?.env.GOOGLE_CLIENT_ID &&
|
||||
!!data?.env.GOOGLE_CLIENT_SECRET,
|
||||
detail:
|
||||
"Requires DATABASE_URL, BETTER_AUTH_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET",
|
||||
},
|
||||
{
|
||||
key: "db",
|
||||
label: "Database connected & schema",
|
||||
ok: !!data?.database.connected && !!data?.database.schemaApplied,
|
||||
detail: data?.database.error
|
||||
? `Error: ${data.database.error}`
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: "auth",
|
||||
label: "Auth configured",
|
||||
ok: !!data?.auth.configured,
|
||||
detail:
|
||||
data?.auth.routeResponding === false
|
||||
? "Auth route not responding"
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: "ai",
|
||||
label: "AI integration (optional)",
|
||||
ok: !!data?.ai.configured,
|
||||
detail: !data?.ai.configured
|
||||
? "Set OPENAI_API_KEY for AI chat"
|
||||
: undefined,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const completed = steps.filter((s) => s.ok).length;
|
||||
|
||||
return (
|
||||
<div className="p-6 border rounded-lg text-left">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold">Setup checklist</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{completed}/{steps.length} completed
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={load} disabled={loading} className="glow">
|
||||
{loading ? "Checking..." : "Re-check"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? <div className="text-sm text-red-500">{error}</div> : null}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{steps.map((s) => (
|
||||
<li key={s.key} className="flex items-start gap-2">
|
||||
<div className="mt-0.5">
|
||||
<StatusIcon ok={Boolean(s.ok)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{s.label}</div>
|
||||
{s.detail ? (
|
||||
<div className="text-sm text-muted-foreground">{s.detail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{data ? (
|
||||
<div className="mt-4 text-xs text-muted-foreground">
|
||||
Last checked: {new Date(data.timestamp).toLocaleString()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,35 @@
|
||||
import { pgTable, text, timestamp, boolean, primaryKey } from "drizzle-orm/pg-core"
|
||||
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
|
||||
|
||||
export const user = pgTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name"),
|
||||
email: text("email").unique(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("emailVerified"),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("createdAt").defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow(),
|
||||
})
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const session = pgTable("session", {
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expiresAt"),
|
||||
token: text("token").unique(),
|
||||
createdAt: timestamp("createdAt").defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow(),
|
||||
expiresAt: timestamp("expiresAt").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
ipAddress: text("ipAddress"),
|
||||
userAgent: text("userAgent"),
|
||||
userId: text("userId").references(() => user.id, { onDelete: "cascade" }),
|
||||
})
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const account = pgTable("account", {
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("accountId"),
|
||||
providerId: text("providerId"),
|
||||
userId: text("userId").references(() => user.id, { onDelete: "cascade" }),
|
||||
accountId: text("accountId").notNull(),
|
||||
providerId: text("providerId").notNull(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("accessToken"),
|
||||
refreshToken: text("refreshToken"),
|
||||
idToken: text("idToken"),
|
||||
@@ -33,15 +37,15 @@ export const account = pgTable("account", {
|
||||
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("createdAt").defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow(),
|
||||
})
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const verification = pgTable("verification", {
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier"),
|
||||
value: text("value"),
|
||||
expiresAt: timestamp("expiresAt"),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expiresAt").notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow(),
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user