From 4807eb321c486285c9b466907c09364000b5e370 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Sun, 1 Feb 2026 19:37:39 -0800 Subject: [PATCH 01/21] Queries view --- AGENTS.md | 3 +- apps/backend/scripts/db-migrations.ts | 9 +- .../analytics/queries/page-client.tsx | 1070 +++++++++++++++++ .../[projectId]/analytics/queries/page.tsx | 5 + .../analytics/tables/page-client.tsx | 50 +- .../src/components/commands/run-query.tsx | 32 +- apps/dashboard/src/lib/apps-frontend.tsx | 1 + .../endpoints/api/v1/analytics-config.test.ts | 636 ++++++++++ .../src/config/schema-fuzzer.test.ts | 15 + packages/stack-shared/src/config/schema.ts | 34 + packages/stack-shared/src/known-errors.tsx | 22 + .../apps/implementations/admin-app-impl.ts | 2 +- 12 files changed, 1826 insertions(+), 53 deletions(-) create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page.tsx create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts diff --git a/AGENTS.md b/AGENTS.md index fd74263505..ad2523ec4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ This file provides guidance to coding agents when working with code in this repo #### Extra commands These commands are usually already called by the user, but you can remind them to run it for you if they forgot to. -- **Build packages**: `pnpm build:packages` +- **Build packages**: `pnpm build:packages` (you should never call this yourself) - **Start dependencies**: `pnpm restart-deps` (resets & restarts Docker containers for DB, Inbucket, etc. Usually already called by the user) - **Run development**: Already called by the user in the background. You don't need to do this. This will also watch for changes and rebuild packages, codegen, etc. Do NOT call build:packages, dev, codegen, or anything like that yourself, as the dev is already running it. - **Run minimal dev**: `pnpm dev:basic` (only backend and dashboard for resource-limited systems) @@ -93,6 +93,7 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub - If there is an external browser tool connected, use it to test changes you make to the frontend when possible. - Whenever you update an SDK implementation in `sdks/implementations`, make sure to update the specs accordingly in `sdks/specs` such that if you reimplemented the entire SDK from the specs again, you would get the same implementation. (For example, if the specs are not precise enough to describe a change you made, make the specs more precise.) - When building internal tools for Stack Auth developers (eg. internal interfaces like the WAL info log etc.): Make the interfaces look very concise, assume the user is a pro-user. This only applies to internal tools that are used primarily by Stack Auth developers. +- The dev server already builds the packages in the background whenever you update a file. If you run into issues with typechecking or linting in a dependency after updating something in a package, just wait a few seconds, and then try again, and they will likely be resolved. ### Code-related - Use ES6 maps instead of records wherever you can. diff --git a/apps/backend/scripts/db-migrations.ts b/apps/backend/scripts/db-migrations.ts index 14733a6873..33bd4280d9 100644 --- a/apps/backend/scripts/db-migrations.ts +++ b/apps/backend/scripts/db-migrations.ts @@ -1,15 +1,14 @@ import { applyMigrations } from "@/auto-migrations"; import { MIGRATION_FILES_DIR, getMigrationFiles } from "@/auto-migrations/utils"; import { Prisma } from "@/generated/prisma/client"; +import { getClickhouseAdminClient } from "@/lib/clickhouse"; import { globalPrismaClient, globalPrismaSchema, sqlQuoteIdent } from "@/prisma-client"; import { spawnSync } from "child_process"; import fs from "fs"; import path from "path"; import * as readline from "readline"; import { seed } from "../prisma/seed"; -import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { runClickhouseMigrations } from "./clickhouse-migrations"; -import { getClickhouseAdminClient } from "@/lib/clickhouse"; const getClickhouseClient = () => getClickhouseAdminClient(); @@ -81,7 +80,6 @@ const generateMigrationFile = async () => { const folderName = `${timestampPrefix()}_${migrationName}`; const migrationDir = path.join(MIGRATION_FILES_DIR, folderName); const migrationSqlPath = path.join(migrationDir, 'migration.sql'); - const diffUrl = getEnvVariable('STACK_DATABASE_CONNECTION_STRING'); console.log(`Generating migration ${folderName}...`); const diffResult = spawnSync( @@ -91,9 +89,8 @@ const generateMigrationFile = async () => { 'prisma', 'migrate', 'diff', - '--from-url', - diffUrl, - '--to-schema-datamodel', + '--from-config-datasource', + '--to-schema', 'prisma/schema.prisma', '--script', ], diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx new file mode 100644 index 0000000000..84cc7abf03 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx @@ -0,0 +1,1070 @@ +"use client"; + +import { Button } from "@/components/ui"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { SimpleTooltip } from "@/components/ui/simple-tooltip"; +import { Textarea } from "@/components/ui/textarea"; +import { useUpdateConfig } from "@/lib/config-update"; +import { cn } from "@/lib/utils"; +import { + ArrowClockwiseIcon, + CaretDownIcon, + CaretRightIcon, + CheckCircleIcon, + FilePlusIcon, + FloppyDiskIcon, + FolderIcon, + FolderOpenIcon, + PlayIcon, + PlusIcon, + SpinnerGapIcon, + TrashIcon, + WarningCircleIcon +} from "@phosphor-icons/react"; +import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { AppEnabledGuard } from "../../app-enabled-guard"; +import { PageLayout } from "../../page-layout"; +import { useAdminApp } from "../../use-admin-app"; + +type RowData = Record; + +type ConfigFolder = { + displayName: string, + sortOrder?: number, + queries: Record, +}; + +type FolderWithId = { + id: string, + displayName: string, + sortOrder: number, + queries: Array<{ + id: string, + displayName: string, + sqlQuery: string, + description?: string, + }>, +}; + +// Detect if a value is a date string +function isDateValue(value: unknown): value is string { + if (typeof value !== "string") return false; + return /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}:\d{2})?/.test(value); +} + +// Detect if a value is JSON +function isJsonValue(value: unknown): boolean { + return typeof value === "object" && value !== null; +} + +// Parse ClickHouse date string as UTC +function parseClickHouseDate(value: string): Date { + const normalized = value.replace(" ", "T") + (value.includes("Z") || value.includes("+") ? "" : "Z"); + return new Date(normalized); +} + +// Component for displaying JSON values +function JsonValue({ value, truncate = true }: { value: unknown, truncate?: boolean }) { + const formatted = JSON.stringify(value, null, 2); + const preview = JSON.stringify(value); + + if (truncate && preview.length > 60) { + return ( + {formatted}}> + + {preview.slice(0, 57)}... + + + ); + } + + return {preview}; +} + +// Format a cell value for display +function CellValue({ value, truncate = true }: { value: unknown, truncate?: boolean }) { + if (value === null || value === undefined) { + return ; + } + + if (isDateValue(value)) { + const date = parseClickHouseDate(value); + return {date.toLocaleString()}; + } + + if (isJsonValue(value)) { + return ; + } + + const str = String(value); + if (truncate && str.length > 100) { + return ( + + {str.slice(0, 97)}... + + ); + } + + return {str}; +} + +// Row detail dialog +function RowDetailDialog({ + row, + columns, + open, + onOpenChange, +}: { + row: RowData | null, + columns: string[], + open: boolean, + onOpenChange: (open: boolean) => void, +}) { + if (!row) return null; + + return ( + + + + Row Details + + +
+ {columns.map((column) => ( +
+ +
+ {isJsonValue(row[column]) ? ( +
+                      {JSON.stringify(row[column], null, 2)}
+                    
+ ) : ( + + )} +
+
+ ))} +
+
+
+
+ ); +} + +// Virtualized flat table component +function VirtualizedFlatTable({ + columns, + rows, + onRowClick, +}: { + columns: string[], + rows: RowData[], + onRowClick: (row: RowData) => void, +}) { + const parentRef = useRef(null); + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 36, + overscan: 10, + }); + + // Column widths - distribute based on content type + const columnWidths = useMemo(() => { + const widths = new Map(); + columns.forEach((col) => { + if (col.includes("id") && col !== "project_id") { + widths.set(col, "minmax(200px, 1fr)"); + } else if (col.includes("_at") || col.includes("date")) { + widths.set(col, "minmax(100px, 140px)"); + } else if (col === "data" || col.includes("json")) { + widths.set(col, "minmax(180px, 2fr)"); + } else if (col === "event_type" || col === "type") { + widths.set(col, "minmax(100px, 160px)"); + } else { + widths.set(col, "minmax(80px, 1fr)"); + } + }); + return widths; + }, [columns]); + + const gridTemplateColumns = columns.map((col) => columnWidths.get(col) ?? "1fr").join(" "); + const minContentWidth = columns.length * 120; + + return ( +
+
+
+ {/* Sticky header */} +
+ {columns.map((column) => ( + + {column} + + ))} +
+ + {/* Virtualized rows container */} +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const row = rows[virtualRow.index]; + + return ( +
onRowClick(row)} + > + {columns.map((column) => ( +
+ +
+ ))} +
+ ); + })} +
+
+
+
+ ); +} + +// Error display component +function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () => void | Promise }) { + const message = error instanceof Error ? error.message : String(error); + + return ( +
+
+ +
+
+

Query Error

+

+ {message} +

+
+ +
+ ); +} + +// Empty state component +function EmptyQueryState() { + return ( +
+
+ +
+
+

Run Query

+

+ Enter a ClickHouse SQL query above and click Run to see results. +

+
+
+

+ SELECT * FROM default.events
+ ORDER BY event_at DESC
+ LIMIT 100 +

+
+
+ ); +} + +// No results state +function NoResultsState() { + return ( +
+
+ +
+
+

No Results

+

+ Query executed successfully but returned no rows. +

+
+
+ ); +} + +// Loading state component +function LoadingState() { + return ( +
+ +

Running query...

+
+ ); +} + +// Create folder dialog +function CreateFolderDialog({ + open, + onOpenChange, + onCreate, +}: { + open: boolean, + onOpenChange: (open: boolean) => void, + onCreate: (displayName: string) => Promise, +}) { + const [displayName, setDisplayName] = useState(""); + const [loading, setLoading] = useState(false); + + const handleCreate = async () => { + if (!displayName.trim()) return; + setLoading(true); + try { + await onCreate(displayName.trim()); + setDisplayName(""); + onOpenChange(false); + } finally { + setLoading(false); + } + }; + + return ( + + + + Create Folder + + +
+
+ + setDisplayName(e.target.value)} + placeholder="My Queries" + onKeyDown={(e) => { + if (e.key === "Enter") { + runAsynchronouslyWithAlert(handleCreate()); + } + }} + /> +
+
+
+ + + + +
+
+ ); +} + +// Save query dialog +function SaveQueryDialog({ + open, + onOpenChange, + folders, + sqlQuery, + onSave, +}: { + open: boolean, + onOpenChange: (open: boolean) => void, + folders: FolderWithId[], + sqlQuery: string, + onSave: (displayName: string, folderId: string, description: string | null) => Promise, +}) { + const [displayName, setDisplayName] = useState(""); + const [description, setDescription] = useState(""); + const [selectedFolderId, setSelectedFolderId] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSave = async () => { + if (!displayName.trim() || !sqlQuery.trim() || !selectedFolderId) return; + setLoading(true); + try { + await onSave(displayName.trim(), selectedFolderId, description.trim() || null); + setDisplayName(""); + setDescription(""); + setSelectedFolderId(""); + onOpenChange(false); + } finally { + setLoading(false); + } + }; + + const canSave = displayName.trim() && selectedFolderId; + + return ( + + + + Save Query + + +
+
+ + setDisplayName(e.target.value)} + placeholder="My Query" + /> +
+
+ + +
+
+ +