From 6545ea1ab004ecc066a192a29b256f04bfcbf0ab Mon Sep 17 00:00:00 2001 From: Patrick Taylor <1963845+pstaylor-patrick@users.noreply.github.com> Date: Mon, 9 Feb 2026 09:47:19 -0600 Subject: [PATCH 1/4] Add latency analytics with Drizzle metadata DB and UI enhancements --- .env.example | 3 + CLAUDE.md | 19 +- apphosting.yaml | 16 ++ drizzle.config.ts | 6 +- next.config.ts | 2 +- package-lock.json | 9 +- package.json | 27 +- scripts/{db-push-schema.sh => db-migrate.sh} | 2 +- scripts/db-reset.ts | 125 ++++++++ scripts/{db-push-data.sh => db-seed.sh} | 2 +- src/app/api/analytics/latency/route.ts | 81 ++++++ src/app/api/cron/latency/route.ts | 53 ++++ src/app/globals.css | 26 +- src/app/layout.tsx | 12 +- src/components/comparison-panel.tsx | 41 ++- src/components/dashboard.tsx | 229 ++++++++++++--- src/components/health-indicator.tsx | 5 + src/components/latency-analytics.tsx | 286 +++++++++++++++++++ src/components/query-runner.tsx | 74 ++++- src/components/ui/tabs.tsx | 4 +- src/hooks/use-latency-analytics.ts | 84 ++++++ src/lib/db/index.ts | 25 ++ src/lib/db/schema.ts | 33 +++ 23 files changed, 1067 insertions(+), 97 deletions(-) rename scripts/{db-push-schema.sh => db-migrate.sh} (96%) create mode 100644 scripts/db-reset.ts rename scripts/{db-push-data.sh => db-seed.sh} (96%) create mode 100644 src/app/api/analytics/latency/route.ts create mode 100644 src/app/api/cron/latency/route.ts create mode 100644 src/components/latency-analytics.tsx create mode 100644 src/hooks/use-latency-analytics.ts create mode 100644 src/lib/db/index.ts create mode 100644 src/lib/db/schema.ts diff --git a/.env.example b/.env.example index a7c2b35..76bff2e 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,6 @@ DATABASE_URL_GCP=**** DATABASE_URL_LOCAL=postgresql://postgres:postgres@localhost:5433/f3_compare DATABASE_URL_NEON=**** DATABASE_URL_SUPABASE=**** +DATABASE_URL_METADATA=**** +APP_ENVIRONMENT=local +CRON_SECRET=**** diff --git a/CLAUDE.md b/CLAUDE.md index f43e5ae..7f7c9e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,22 +15,27 @@ npm run lint # ESLint (--max-warnings 0) npm run typecheck # TypeScript strict check npm run test # Vitest (9 tests) npm run test:coverage # Vitest with v8 coverage -npm run docker:up # Start local Postgres 16 on :5433 -npm run docker:down # Stop local Postgres +npm run db:up # Start local Postgres 16 on :5433 +npm run db:down # Stop local Postgres npm run db:pull:dump # tsx script: pull schema + data from GCP -npm run db:push:schema:local # Apply schema.sql to local Docker -npm run db:push:data:local # Apply data.sql to local Docker +npm run db:reset: # Drop all + re-migrate + seed (local/neon/supabase, NEVER gcp) +npm run db:migrate: # Apply schema.sql (local/neon/supabase) +npm run db:seed: # Apply data.sql (local/neon/supabase) npm run db:verify:local # Health check local Docker npm run db:verify:gcp # Health check GCP npm run db:verify:neon # Health check Neon npm run db:verify:supabase # Health check Supabase +npm run db:generate # Drizzle generate migrations +npm run db:migrate:metadata # Push schema to metadata DB +npm run db:studio # Drizzle Studio GUI ``` ## Stack - Next.js 15, React 19, App Router, TypeScript strict - shadcn/ui + Tailwind v3 + Recharts -- Raw `pg` Pool per platform (no ORM at runtime) +- Raw `pg` Pool per platform (no ORM for comparison queries) +- Drizzle ORM for metadata DB (latency analytics) - Docker Postgres 16 on :5433, app on :3002 ## Plugin System @@ -48,10 +53,12 @@ npm run db:verify:supabase # Health check Supabase - `POST /api/query` — {platformId, sql} -> QueryResult - `POST /api/compare` — {leftId, rightId, sql} -> side-by-side - `POST /api/compare/schema` — {leftId, rightId} -> schema diff +- `POST /api/cron/latency` — Collect health snapshots (QStash target) +- `GET /api/analytics/latency` — Query latency history + stats ## Env Variables -`DATABASE_URL_GCP`, `DATABASE_URL_LOCAL`, `DATABASE_URL_NEON`, `DATABASE_URL_SUPABASE` +`DATABASE_URL_GCP`, `DATABASE_URL_LOCAL`, `DATABASE_URL_NEON`, `DATABASE_URL_SUPABASE`, `DATABASE_URL_METADATA`, `APP_ENVIRONMENT`, `CRON_SECRET` ## Database Stats (GCP Source) diff --git a/apphosting.yaml b/apphosting.yaml index 140e002..24c4735 100644 --- a/apphosting.yaml +++ b/apphosting.yaml @@ -24,3 +24,19 @@ env: availability: - BUILD - RUNTIME + + - variable: DATABASE_URL_METADATA + secret: database-url-metadata + availability: + - BUILD + - RUNTIME + + - variable: APP_ENVIRONMENT + value: firebase + availability: + - RUNTIME + + - variable: CRON_SECRET + secret: cron-secret + availability: + - RUNTIME diff --git a/drizzle.config.ts b/drizzle.config.ts index 236bf72..962ee67 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,9 +1,13 @@ +import { config } from "dotenv"; import { defineConfig } from "drizzle-kit"; +config({ path: ".env.local" }); + export default defineConfig({ dialect: "postgresql", + schema: "./src/lib/db/schema.ts", dbCredentials: { - url: process.env.DATABASE_URL_GCP!, + url: process.env.DATABASE_URL_METADATA!, }, out: "./drizzle", }); diff --git a/next.config.ts b/next.config.ts index 7f20763..7968cc6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - serverExternalPackages: ["pg"], + serverExternalPackages: ["pg", "drizzle-orm"], }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 06a1979..4ceea3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^17.2.4", + "drizzle-orm": "^0.45.1", "lucide-react": "^0.563.0", "next": "16.1.6", "pg": "^8.18.0", @@ -33,7 +34,6 @@ "@types/react-dom": "^19", "@vitest/coverage-v8": "^4.0.18", "drizzle-kit": "^0.31.8", - "drizzle-orm": "^0.45.1", "eslint": "^9", "eslint-config-next": "16.1.6", "prettier": "^3.8.1", @@ -3768,7 +3768,7 @@ "version": "20.19.32", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.32.tgz", "integrity": "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3778,7 +3778,7 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -5400,7 +5400,6 @@ "version": "0.45.1", "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", @@ -10226,7 +10225,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unrs-resolver": { diff --git a/package.json b/package.json index f4d7819..0348f0d 100644 --- a/package.json +++ b/package.json @@ -12,22 +12,27 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:coverage": "vitest run --coverage", - "docker:up": "docker compose up -d", - "docker:down": "docker compose down", - "docker:reset": "docker compose down -v && docker compose up -d", + "db:up": "docker compose up -d", + "db:down": "docker compose down", + "db:reset:local": "tsx scripts/db-reset.ts local", + "db:reset:neon": "tsx scripts/db-reset.ts neon", + "db:reset:supabase": "tsx scripts/db-reset.ts supabase", "db:pull:schema": "tsx scripts/db-pull-schema.ts", "db:pull:dump": "tsx scripts/db-pull-dump.ts", - "db:push:schema:local": "bash scripts/db-push-schema.sh local", - "db:push:schema:neon": "bash scripts/db-push-schema.sh neon", - "db:push:schema:supabase": "bash scripts/db-push-schema.sh supabase", - "db:push:data:local": "bash scripts/db-push-data.sh local", - "db:push:data:neon": "bash scripts/db-push-data.sh neon", - "db:push:data:supabase": "bash scripts/db-push-data.sh supabase", + "db:migrate:local": "bash scripts/db-migrate.sh local", + "db:migrate:neon": "bash scripts/db-migrate.sh neon", + "db:migrate:supabase": "bash scripts/db-migrate.sh supabase", + "db:seed:local": "bash scripts/db-seed.sh local", + "db:seed:neon": "bash scripts/db-seed.sh neon", + "db:seed:supabase": "bash scripts/db-seed.sh supabase", "db:verify:local": "tsx scripts/db-verify.ts local", "db:verify:neon": "tsx scripts/db-verify.ts neon", "db:verify:supabase": "tsx scripts/db-verify.ts supabase", "db:verify:gcp": "tsx scripts/db-verify.ts gcp", - "firebase:env": "bash scripts/firebase-env.sh" + "firebase:env": "bash scripts/firebase-env.sh", + "db:generate": "drizzle-kit generate", + "db:migrate:metadata": "drizzle-kit push", + "db:studio": "drizzle-kit studio" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", @@ -39,6 +44,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^17.2.4", + "drizzle-orm": "^0.45.1", "lucide-react": "^0.563.0", "next": "16.1.6", "pg": "^8.18.0", @@ -55,7 +61,6 @@ "@types/react-dom": "^19", "@vitest/coverage-v8": "^4.0.18", "drizzle-kit": "^0.31.8", - "drizzle-orm": "^0.45.1", "eslint": "^9", "eslint-config-next": "16.1.6", "prettier": "^3.8.1", diff --git a/scripts/db-push-schema.sh b/scripts/db-migrate.sh similarity index 96% rename from scripts/db-push-schema.sh rename to scripts/db-migrate.sh index d0e96c7..9230dfa 100755 --- a/scripts/db-push-schema.sh +++ b/scripts/db-migrate.sh @@ -9,7 +9,7 @@ SCHEMA_FILE="$DUMPS_DIR/schema.sql" TARGET="${1:-}" if [[ -z "$TARGET" ]]; then - echo "Usage: db-push-schema.sh " + echo "Usage: db-migrate.sh " echo "Targets: local, neon, supabase" exit 1 fi diff --git a/scripts/db-reset.ts b/scripts/db-reset.ts new file mode 100644 index 0000000..22abacb --- /dev/null +++ b/scripts/db-reset.ts @@ -0,0 +1,125 @@ +import { Pool } from "pg"; +import { resolve } from "path"; +import { config } from "dotenv"; +import { execSync } from "child_process"; +import { createInterface } from "readline"; + +// Load .env.local +config({ path: resolve(__dirname, "../.env.local") }); + +const target = process.argv[2]; + +if (!target) { + console.error("Usage: db-reset.ts "); + console.error("Targets: local, neon, supabase"); + process.exit(1); +} + +if (target === "gcp") { + console.error( + "ERROR: Refusing to reset GCP — it is the read-only source of truth.", + ); + process.exit(1); +} + +const envKeyMap: Record = { + local: "DATABASE_URL_LOCAL", + neon: "DATABASE_URL_NEON", + supabase: "DATABASE_URL_SUPABASE", +}; + +const envKey = envKeyMap[target]; +if (!envKey) { + console.error(`Unknown target: ${target}. Use: local, neon, supabase`); + process.exit(1); +} + +const connectionString = process.env[envKey]; +if (!connectionString) { + console.error(`${envKey} is not set. Add it to .env.local`); + process.exit(1); +} + +function confirm(message: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(`${message} [y/N] `, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === "y"); + }); + }); +} + +function run(cmd: string) { + console.log(`$ ${cmd}`); + execSync(cmd, { stdio: "inherit", cwd: resolve(__dirname, "..") }); +} + +async function resetLocal() { + console.log("Tearing down Docker volumes and restarting..."); + run("docker compose down -v"); + run("docker compose up -d"); + + // Wait for pg_isready + console.log("Waiting for Postgres to be ready..."); + const maxAttempts = 30; + for (let i = 0; i < maxAttempts; i++) { + try { + execSync("docker compose exec -T postgres pg_isready -U postgres", { + stdio: "pipe", + cwd: resolve(__dirname, ".."), + }); + console.log("Postgres is ready."); + break; + } catch { + if (i === maxAttempts - 1) { + console.error("Postgres did not become ready in time."); + process.exit(1); + } + await new Promise((r) => setTimeout(r, 1000)); + } + } + + run("bash scripts/db-migrate.sh local"); + run("bash scripts/db-seed.sh local"); +} + +async function resetRemote() { + const pool = new Pool({ connectionString, connectionTimeoutMillis: 10000 }); + + try { + console.log(`Dropping schemas on ${target}...`); + await pool.query("DROP SCHEMA IF EXISTS public CASCADE"); + await pool.query("DROP SCHEMA IF EXISTS codex CASCADE"); + await pool.query("DROP SCHEMA IF EXISTS regionpages CASCADE"); + await pool.query("CREATE SCHEMA public"); + console.log("Schemas dropped and public recreated."); + } finally { + await pool.end(); + } + + run(`bash scripts/db-migrate.sh ${target}`); + run(`bash scripts/db-seed.sh ${target}`); +} + +async function main() { + const confirmed = await confirm( + `Reset ${target}? This will destroy ALL data.`, + ); + if (!confirmed) { + console.log("Aborted."); + process.exit(0); + } + + console.log(`\nResetting ${target}...`); + + if (target === "local") { + await resetLocal(); + } else { + await resetRemote(); + } + + console.log(`\n${target} reset complete.`); +} + +main(); diff --git a/scripts/db-push-data.sh b/scripts/db-seed.sh similarity index 96% rename from scripts/db-push-data.sh rename to scripts/db-seed.sh index 33146b2..ca59093 100755 --- a/scripts/db-push-data.sh +++ b/scripts/db-seed.sh @@ -9,7 +9,7 @@ DATA_FILE="$DUMPS_DIR/data.sql" TARGET="${1:-}" if [[ -z "$TARGET" ]]; then - echo "Usage: db-push-data.sh " + echo "Usage: db-seed.sh " echo "Targets: local, neon, supabase" exit 1 fi diff --git a/src/app/api/analytics/latency/route.ts b/src/app/api/analytics/latency/route.ts new file mode 100644 index 0000000..d654ede --- /dev/null +++ b/src/app/api/analytics/latency/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; +import { latencySnapshots } from "@/lib/db/schema"; +import { desc, and, gte, eq, type SQL } from "drizzle-orm"; + +const TIME_RANGES: Record = { + "1h": 60 * 60 * 1000, + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, + "30d": 30 * 24 * 60 * 60 * 1000, +}; + +function computeStats(values: number[]) { + if (values.length === 0) return { avg: 0, min: 0, max: 0, p95: 0 }; + const sorted = [...values].sort((a, b) => a - b); + const sum = sorted.reduce((a, b) => a + b, 0); + const p95Index = Math.ceil(sorted.length * 0.95) - 1; + return { + avg: Math.round(sum / sorted.length), + min: sorted[0], + max: sorted[sorted.length - 1], + p95: sorted[Math.max(0, p95Index)], + }; +} + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const timeRange = searchParams.get("timeRange") || "24h"; + const environment = searchParams.get("environment") || undefined; + const platformId = searchParams.get("platformId") || undefined; + + const rangeMs = TIME_RANGES[timeRange]; + if (!rangeMs) { + return NextResponse.json( + { error: `Invalid timeRange: ${timeRange}. Use 1h, 24h, 7d, or 30d` }, + { status: 400 }, + ); + } + + const since = new Date(Date.now() - rangeMs); + const conditions: SQL[] = [gte(latencySnapshots.createdAt, since)]; + + if (environment && environment !== "all") { + conditions.push(eq(latencySnapshots.environment, environment)); + } + if (platformId && platformId !== "all") { + conditions.push(eq(latencySnapshots.platformId, platformId)); + } + + const db = getDb(); + const rows = await db + .select() + .from(latencySnapshots) + .where(and(...conditions)) + .orderBy(desc(latencySnapshots.createdAt)) + .limit(2000); + + const byPlatform = new Map(); + for (const row of rows) { + const existing = byPlatform.get(row.platformId) || []; + existing.push(row.latencyMs); + byPlatform.set(row.platformId, existing); + } + + const stats: Record< + string, + ReturnType & { count: number } + > = {}; + for (const [pid, values] of byPlatform) { + stats[pid] = { ...computeStats(values), count: values.length }; + } + + return NextResponse.json({ + timeRange, + environment: environment || "all", + platformId: platformId || "all", + count: rows.length, + stats, + snapshots: rows, + }); +} diff --git a/src/app/api/cron/latency/route.ts b/src/app/api/cron/latency/route.ts new file mode 100644 index 0000000..1017889 --- /dev/null +++ b/src/app/api/cron/latency/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from "next/server"; +import "@/lib/platforms"; +import { getConfiguredPlatforms } from "@/lib/platforms/registry"; +import { getDb } from "@/lib/db"; +import { latencySnapshots } from "@/lib/db/schema"; + +export async function POST(request: Request) { + const cronSecret = process.env.CRON_SECRET; + if (cronSecret) { + const auth = request.headers.get("authorization"); + if (auth !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } + + const environment = process.env.APP_ENVIRONMENT || "local"; + const platforms = getConfiguredPlatforms(); + + const results = await Promise.all( + platforms.map(async (platform) => { + const health = await platform.healthCheck(); + return { + platformId: platform.id, + ok: health.ok, + latencyMs: Math.round(health.latencyMs), + error: health.error ?? null, + version: health.version ?? null, + }; + }), + ); + + const db = getDb(); + const values = results.map((r) => ({ + platformId: r.platformId, + environment, + latencyMs: r.latencyMs, + ok: r.ok, + error: r.error, + version: r.version, + })); + + await db.insert(latencySnapshots).values(values); + + return NextResponse.json({ + inserted: results.length, + environment, + snapshots: results.map((r) => ({ + platformId: r.platformId, + ok: r.ok, + latencyMs: r.latencyMs, + })), + }); +} diff --git a/src/app/globals.css b/src/app/globals.css index d501563..64aa38d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,7 +1,7 @@ @import "tailwindcss"; :root { - --background: #ffffff; + --background: #fafafa; --foreground: #222222; --card: #ffffff; --card-foreground: #222222; @@ -20,7 +20,9 @@ --border: #e5e5e5; --input: #e5e5e5; --ring: #222222; - --radius: 0.5rem; + --radius: 0.75rem; + --accent-brand: #0d9488; + --accent-brand-foreground: #ffffff; } @theme inline { @@ -43,18 +45,32 @@ --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --color-accent-brand: var(--accent-brand); + --color-accent-brand-foreground: var(--accent-brand-foreground); + --font-sans: var(--font-inter); + --font-mono: var(--font-jetbrains-mono); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); } +@keyframes pulse-dot { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.6; + transform: scale(1.3); + } +} + body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-inter), "Inter", ui-sans-serif, system-ui, sans-serif; } * { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7fefb4d..a331805 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,14 +1,14 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Inter, JetBrains_Mono } from "next/font/google"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const inter = Inter({ + variable: "--font-inter", subsets: ["latin"], }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const jetbrainsMono = JetBrains_Mono({ + variable: "--font-jetbrains-mono", subsets: ["latin"], }); @@ -25,7 +25,7 @@ export default function RootLayout({ return ( {children} diff --git a/src/components/comparison-panel.tsx b/src/components/comparison-panel.tsx index 43c8491..1fcf7de 100644 --- a/src/components/comparison-panel.tsx +++ b/src/components/comparison-panel.tsx @@ -4,10 +4,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { SchemaDiff } from "./schema-diff"; import { DataDiff } from "./data-diff"; import { PerformanceChart } from "./performance-chart"; +import { LatencyAnalytics } from "./latency-analytics"; import { ComparisonResult, SchemaComparisonResult, } from "@/hooks/use-comparison"; +import { Database, Layers, BarChart3 } from "lucide-react"; interface ComparisonPanelProps { schemaResult: SchemaComparisonResult | null; @@ -28,13 +30,18 @@ export function ComparisonPanel({ Data Compare Schema Compare Performance + Analytics {dataResult ? ( ) : ( - + } + message="Run a query to compare data between platforms" + hint="Select two platforms above and execute a preset or custom SQL query" + /> )} @@ -42,7 +49,11 @@ export function ComparisonPanel({ {schemaResult ? ( ) : ( - + } + message="Select two platforms to compare schemas" + hint='Click "Compare Schemas" to see structural differences' + /> )} @@ -53,17 +64,35 @@ export function ComparisonPanel({ queryNames={queryNames} /> ) : ( - + } + message="Run queries to see performance comparison" + hint="Each query you run will be added to the performance chart" + /> )} + + + + ); } -function EmptyState({ message }: { message: string }) { +function EmptyState({ + icon, + message, + hint, +}: { + icon: React.ReactNode; + message: string; + hint: string; +}) { return ( -
- {message} +
+ {icon} +

{message}

+

{hint}

); } diff --git a/src/components/dashboard.tsx b/src/components/dashboard.tsx index 9d9d008..e6b081a 100644 --- a/src/components/dashboard.tsx +++ b/src/components/dashboard.tsx @@ -1,9 +1,8 @@ "use client"; -import { useState, useCallback } from "react"; -import { usePlatforms } from "@/hooks/use-platforms"; +import { useState, useCallback, useEffect, useMemo } from "react"; +import { usePlatforms, PlatformStatus } from "@/hooks/use-platforms"; import { useComparison, ComparisonResult } from "@/hooks/use-comparison"; -import { PlatformSelector } from "./platform-selector"; import { QueryRunner } from "./query-runner"; import { ComparisonPanel } from "./comparison-panel"; import { HealthIndicator } from "./health-indicator"; @@ -14,6 +13,81 @@ import { formatMs } from "@/lib/utils"; import { RefreshCw } from "lucide-react"; import { PRESET_QUERIES } from "@/lib/queries"; +function useTickingCounter(resetKey: number) { + const [count, setCount] = useState(0); + + useEffect(() => { + let cancelled = false; + let ticks = 0; + const interval = setInterval(() => { + if (!cancelled) { + ticks += 1; + setCount(ticks); + } + }, 1000); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [resetKey]); + + // Reset to 0 synchronously when resetKey changes (render-time derivation) + const [prevKey, setPrevKey] = useState(resetKey); + if (prevKey !== resetKey) { + setPrevKey(resetKey); + setCount(0); + } + + return count; +} + +function HealthBanner({ + platforms, + refreshKey, +}: { + platforms: PlatformStatus[]; + refreshKey: number; +}) { + const elapsed = useTickingCounter(refreshKey); + const configured = platforms.filter((p) => p.configured); + const healthy = configured.filter((p) => p.health?.ok); + + let status: "all" | "partial" | "none"; + let label: string; + if (configured.length === 0) { + status = "none"; + label = "No platforms configured"; + } else if (healthy.length === configured.length) { + status = "all"; + label = "All Systems Operational"; + } else if (healthy.length > 0) { + status = "partial"; + label = `${healthy.length}/${configured.length} Systems Operational`; + } else { + status = "none"; + label = "All Systems Down"; + } + + const bgClass = + status === "all" + ? "bg-emerald-50 text-emerald-700 border-emerald-100" + : status === "partial" + ? "bg-amber-50 text-amber-700 border-amber-100" + : "bg-red-50 text-red-700 border-red-100"; + + return ( +
+ {label} · Last refresh: {elapsed}s ago +
+ ); +} + +function latencyColor(ms: number): string { + if (ms < 150) return "text-green-600"; + if (ms < 300) return "text-amber-600"; + return "text-red-600"; +} + export function Dashboard() { const { platforms, @@ -22,6 +96,7 @@ export function Dashboard() { refresh, } = usePlatforms(); const comparison = useComparison(); + const [refreshKey, setRefreshKey] = useState(0); const [leftId, setLeftId] = useState(""); const [rightId, setRightId] = useState(""); @@ -30,7 +105,12 @@ export function Dashboard() { >([]); const [queryNames, setQueryNames] = useState([]); - // Auto-select first two configured platforms + const handleRefresh = useCallback(() => { + setRefreshKey((k) => k + 1); + refresh(); + }, [refresh]); + + // Auto-select first two configured platforms (render-time, matching original pattern) const handlePlatformsReady = useCallback(() => { if (configuredPlatforms.length >= 2 && !leftId && !rightId) { setLeftId(configuredPlatforms[0].id); @@ -38,16 +118,37 @@ export function Dashboard() { } }, [configuredPlatforms, leftId, rightId]); - // Trigger auto-select when platforms load if (!platformsLoading && configuredPlatforms.length >= 2 && !leftId) { handlePlatformsReady(); } + // Compute fastest platform + const { fastestId, relativeLatency } = useMemo(() => { + const healthyPlatforms = platforms.filter( + (p) => p.configured && p.health?.ok && p.health.latencyMs, + ); + if (healthyPlatforms.length === 0) + return { fastestId: null, relativeLatency: {} as Record }; + + const sorted = [...healthyPlatforms].sort( + (a, b) => a.health!.latencyMs - b.health!.latencyMs, + ); + const fastest = sorted[0]; + const rl: Record = {}; + for (const p of healthyPlatforms) { + if (p.id !== fastest.id) { + rl[p.id] = + Math.round((p.health!.latencyMs / fastest.health!.latencyMs) * 10) / + 10; + } + } + return { fastestId: fastest.id, relativeLatency: rl }; + }, [platforms]); + const handleRunQuery = async (sql: string) => { if (!leftId || !rightId) return; await comparison.compareData(leftId, rightId, sql); - // Track for performance chart if (comparison.dataResult) { setPerformanceResults((prev) => [...prev, comparison.dataResult!]); const matchingPreset = PRESET_QUERIES.find((q) => q.sql === sql); @@ -63,28 +164,45 @@ export function Dashboard() { await comparison.compareSchema(leftId, rightId); }; + // Pill selector: tap to toggle selection + const handlePillClick = (platformId: string) => { + if (leftId === platformId) { + setLeftId(""); + } else if (rightId === platformId) { + setRightId(""); + } else if (!leftId) { + setLeftId(platformId); + } else if (!rightId) { + setRightId(platformId); + } else { + // Both selected: replace right + setRightId(platformId); + } + }; + const bothSelected = !!leftId && !!rightId; return (
{/* Header */} -
+
+
-

+

F3 Database Compare

-

+

Side-by-side PostgreSQL platform comparison

-
+
{/* Platform Status Cards */} -
+
{platforms.map((p) => ( - - + +
{p.name}
- + {!p.configured ? (

Not configured

) : p.health?.ok ? ( -
+
Connected -

+

{formatMs(p.health.latencyMs)}

+ {fastestId === p.id ? ( + + Fastest + + ) : relativeLatency[p.id] ? ( + + {relativeLatency[p.id]}x slower + + ) : null}
) : (
@@ -145,31 +277,52 @@ export function Dashboard() { ))}
- {/* Platform Selectors */} - + {/* Platform Pill Selector */} + -
- -
- vs -
- -
+
+ + Compare + + {platforms.map((p) => { + const isLeft = leftId === p.id; + const isRight = rightId === p.id; + const isSelected = isLeft || isRight; + return ( + + ); + })} +
@@ -179,7 +332,7 @@ export function Dashboard() { {/* Query Runner */} - + Query diff --git a/src/components/health-indicator.tsx b/src/components/health-indicator.tsx index 5efb58c..62db7e6 100644 --- a/src/components/health-indicator.tsx +++ b/src/components/health-indicator.tsx @@ -16,6 +16,11 @@ export function HealthIndicator({ status, className }: HealthIndicatorProps) { status === "loading" && "bg-yellow-400 animate-pulse", className, )} + style={ + status === "healthy" + ? { animation: "pulse-dot 2s ease-in-out infinite" } + : undefined + } /> ); } diff --git a/src/components/latency-analytics.tsx b/src/components/latency-analytics.tsx new file mode 100644 index 0000000..16021bc --- /dev/null +++ b/src/components/latency-analytics.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from "recharts"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + useLatencyAnalytics, + type TimeRange, + type EnvironmentFilter, + type LatencySnapshot, +} from "@/hooks/use-latency-analytics"; +import { Activity, Loader2 } from "lucide-react"; + +const PLATFORM_COLORS: Record = { + gcp: "#222222", + local: "#6366f1", + neon: "#10b981", + supabase: "#f59e0b", +}; + +const PLATFORM_LABELS: Record = { + gcp: "GCP", + local: "Local", + neon: "Neon", + supabase: "Supabase", +}; + +const TIME_RANGES: TimeRange[] = ["1h", "24h", "7d", "30d"]; +const ENVIRONMENTS: EnvironmentFilter[] = ["all", "local", "firebase"]; +const PLATFORMS = ["all", "gcp", "local", "neon", "supabase"]; + +function Pill({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function formatTime(dateStr: string, timeRange: TimeRange): string { + const d = new Date(dateStr); + if (timeRange === "1h" || timeRange === "24h") { + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + return d.toLocaleDateString([], { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function buildChartData(snapshots: LatencySnapshot[], timeRange: TimeRange) { + const byTime = new Map>(); + const sorted = [...snapshots].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + + for (const snap of sorted) { + const key = snap.createdAt; + const existing = byTime.get(key) || {}; + existing[snap.platformId] = snap.latencyMs; + byTime.set(key, existing); + } + + return Array.from(byTime.entries()).map(([time, values]) => ({ + time: formatTime(time, timeRange), + rawTime: time, + ...values, + })); +} + +export function LatencyAnalytics() { + const { + timeRange, + setTimeRange, + environment, + setEnvironment, + platformFilter, + setPlatformFilter, + data, + loading, + error, + } = useLatencyAnalytics(); + + const chartData = data ? buildChartData(data.snapshots, timeRange) : []; + const platformIds = data ? Object.keys(data.stats) : []; + + return ( +
+ {/* Filter Bar */} + + +
+
+ + Time + + {TIME_RANGES.map((tr) => ( + setTimeRange(tr)} + /> + ))} +
+
+ + Env + + {ENVIRONMENTS.map((env) => ( + setEnvironment(env)} + /> + ))} +
+
+ + Platform + + {PLATFORMS.map((p) => ( + setPlatformFilter(p)} + /> + ))} +
+
+
+
+ + {/* Loading State */} + {loading && ( +
+ +
+ )} + + {/* Error State */} + {error && ( + + +

{error}

+
+
+ )} + + {/* Empty State */} + {!loading && !error && data && data.count === 0 && ( +
+ +

No latency data yet

+

+ Data will appear after the cron job runs (POST /api/cron/latency) +

+
+ )} + + {/* Stats Cards */} + {!loading && data && data.count > 0 && ( + <> +
+ {platformIds.map((pid) => { + const s = data.stats[pid]; + return ( + + +
+
+ + {PLATFORM_LABELS[pid] || pid} + +
+ + +
+
+ Avg +

{s.avg}ms

+
+
+ P95 +

{s.p95}ms

+
+
+ Min +

{s.min}ms

+
+
+ Max +

{s.max}ms

+
+
+

+ {s.count} samples +

+
+ + ); + })} +
+ + {/* Chart */} + + + Latency Over Time (ms) + + + + + + + + + + {platformIds.map((pid) => ( + + ))} + + + + + + )} +
+ ); +} diff --git a/src/components/query-runner.tsx b/src/components/query-runner.tsx index 857fc24..d25b970 100644 --- a/src/components/query-runner.tsx +++ b/src/components/query-runner.tsx @@ -13,6 +13,37 @@ import { Badge } from "@/components/ui/badge"; import { PRESET_QUERIES } from "@/lib/queries"; import { Play } from "lucide-react"; +const SQL_KEYWORDS = + /\b(SELECT|FROM|WHERE|JOIN|GROUP BY|ORDER BY|LIMIT|COUNT|AS|ON|AND|OR|IN|BETWEEN|DISTINCT|HAVING|WITH|CASE|WHEN|THEN|ELSE|END|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|TABLE|INDEX|NOT|NULL|IS|LIKE|EXISTS|UNION|LEFT|RIGHT|INNER|OUTER|CROSS|DESC|ASC|BY)\b/g; + +function highlightSql(sql: string) { + const parts: Array<{ text: string; isKeyword: boolean }> = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + + const regex = new RegExp(SQL_KEYWORDS.source, "g"); + while ((match = regex.exec(sql)) !== null) { + if (match.index > lastIndex) { + parts.push({ text: sql.slice(lastIndex, match.index), isKeyword: false }); + } + parts.push({ text: match[0], isKeyword: true }); + lastIndex = regex.lastIndex; + } + if (lastIndex < sql.length) { + parts.push({ text: sql.slice(lastIndex), isKeyword: false }); + } + + return parts.map((part, i) => + part.isKeyword ? ( + + {part.text} + + ) : ( + {part.text} + ), + ); +} + interface QueryRunnerProps { onRun: (sql: string) => void; loading: boolean; @@ -34,21 +65,37 @@ export function QueryRunner({ onRun, loading, disabled }: QueryRunnerProps) { return (
-
- - +
{mode === "preset" && ( @@ -83,8 +130,7 @@ export function QueryRunner({ onRun, loading, disabled }: QueryRunnerProps) {
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 29de1b7..a66e0d0 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -13,7 +13,7 @@ const TabsList = React.forwardRef< ; + snapshots: LatencySnapshot[]; +} + +export function useLatencyAnalytics() { + const [timeRange, setTimeRange] = useState("24h"); + const [environment, setEnvironment] = useState("all"); + const [platformFilter, setPlatformFilter] = useState("all"); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetch_ = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ timeRange, environment }); + if (platformFilter !== "all") { + params.set("platformId", platformFilter); + } + const res = await fetch(`/api/analytics/latency?${params}`); + const json = await res.json(); + if (!res.ok) { + setError(json.error || "Failed to fetch analytics"); + return; + } + setData(json); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to fetch analytics", + ); + } finally { + setLoading(false); + } + }, [timeRange, environment, platformFilter]); + + useEffect(() => { + fetch_(); + }, [fetch_]); + + return { + timeRange, + setTimeRange, + environment, + setEnvironment, + platformFilter, + setPlatformFilter, + data, + loading, + error, + refresh: fetch_, + }; +} diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts new file mode 100644 index 0000000..60a8551 --- /dev/null +++ b/src/lib/db/index.ts @@ -0,0 +1,25 @@ +import { Pool } from "pg"; +import { drizzle } from "drizzle-orm/node-postgres"; +import * as schema from "./schema"; + +let pool: Pool | null = null; + +function getPool(): Pool { + if (!pool) { + const connectionString = process.env.DATABASE_URL_METADATA; + if (!connectionString) { + throw new Error("DATABASE_URL_METADATA is not configured"); + } + pool = new Pool({ + connectionString, + max: 3, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 10000, + }); + } + return pool; +} + +export function getDb() { + return drizzle(getPool(), { schema }); +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts new file mode 100644 index 0000000..e1459ac --- /dev/null +++ b/src/lib/db/schema.ts @@ -0,0 +1,33 @@ +import { + pgTable, + serial, + varchar, + integer, + boolean, + text, + timestamp, + index, +} from "drizzle-orm/pg-core"; + +export const latencySnapshots = pgTable( + "latency_snapshots", + { + id: serial("id").primaryKey(), + platformId: varchar("platform_id", { length: 20 }).notNull(), + environment: varchar("environment", { length: 20 }).notNull(), + latencyMs: integer("latency_ms").notNull(), + ok: boolean("ok").notNull(), + error: text("error"), + version: text("version"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("idx_platform_env").on(table.platformId, table.environment), + index("idx_created_at").on(table.createdAt), + ], +); + +export type LatencySnapshot = typeof latencySnapshots.$inferSelect; +export type NewLatencySnapshot = typeof latencySnapshots.$inferInsert; From 32a7e4097feb4fc6555c09dea5a57458974207d7 Mon Sep 17 00:00:00 2001 From: Patrick Taylor <1963845+pstaylor-patrick@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:18:18 -0600 Subject: [PATCH 2/4] add 3F0BD308-3F0B-473B-9462-5FD1DC53A9BA --- 3F0BD308-3F0B-473B-9462-5FD1DC53A9BA | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 3F0BD308-3F0B-473B-9462-5FD1DC53A9BA diff --git a/3F0BD308-3F0B-473B-9462-5FD1DC53A9BA b/3F0BD308-3F0B-473B-9462-5FD1DC53A9BA new file mode 100644 index 0000000..e69de29 From 04fefb624570ee3fe893e55d9209f30ea7b48de2 Mon Sep 17 00:00:00 2001 From: Patrick Taylor <1963845+pstaylor-patrick@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:18:18 -0600 Subject: [PATCH 3/4] Revert "add 3F0BD308-3F0B-473B-9462-5FD1DC53A9BA" This reverts commit 32a7e4097feb4fc6555c09dea5a57458974207d7. --- 3F0BD308-3F0B-473B-9462-5FD1DC53A9BA | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 3F0BD308-3F0B-473B-9462-5FD1DC53A9BA diff --git a/3F0BD308-3F0B-473B-9462-5FD1DC53A9BA b/3F0BD308-3F0B-473B-9462-5FD1DC53A9BA deleted file mode 100644 index e69de29..0000000 From e7d42b51f6a777aca3234db7375f1400563ad1a5 Mon Sep 17 00:00:00 2001 From: Patrick Taylor <1963845+pstaylor-patrick@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:24:42 -0600 Subject: [PATCH 4/4] Add metadata and cron secret to Firebase environment script --- scripts/firebase-env.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/firebase-env.sh b/scripts/firebase-env.sh index 51afca0..6614640 100644 --- a/scripts/firebase-env.sh +++ b/scripts/firebase-env.sh @@ -9,8 +9,8 @@ if [[ -f "$HOME/google-cloud-sdk/path.bash.inc" ]]; then fi # Configuration constants -SECRET_VARS=("DATABASE_URL_GCP" "DATABASE_URL_NEON" "DATABASE_URL_SUPABASE") -SECRET_IDS=("database-url-gcp" "database-url-neon" "database-url-supabase") +SECRET_VARS=("DATABASE_URL_GCP" "DATABASE_URL_NEON" "DATABASE_URL_SUPABASE" "DATABASE_URL_METADATA" "CRON_SECRET") +SECRET_IDS=("database-url-gcp" "database-url-neon" "database-url-supabase" "database-url-metadata" "cron-secret") ##################################### # MAIN EXECUTION FUNCTION