diff --git a/apps/backend/src/app/health/email/route.tsx b/apps/backend/src/app/health/email/route.tsx index 52b29356b..6e4809989 100644 --- a/apps/backend/src/app/health/email/route.tsx +++ b/apps/backend/src/app/health/email/route.tsx @@ -120,7 +120,7 @@ const waitForVerificationEmail = async (testEmail: string, useInbucket: boolean) throw new StackAssertionError(`Couldn't find verification email in time limit`, { recipient_email: testEmail, max_poll_attempts: MAX_POLL_ATTEMPTS, poll_interval_ms: POLL_INTERVAL_MS }); }; -export const GET = createSmartRouteHandler({ +export const POST = createSmartRouteHandler({ metadata: { hidden: true, summary: "Email Health Monitor", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx index f594a2e95..7099cbc1e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx @@ -5,6 +5,7 @@ import { Dialog, DialogBody, DialogContent, + DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; @@ -619,6 +620,7 @@ function TableContent({ tableId }: { tableId: TableId }) { export default function PageClient() { const [selectedTable, setSelectedTable] = useState("events"); + const [queryDialogOpen, setQueryDialogOpen] = useState(false); return ( @@ -647,7 +649,7 @@ export default function PageClient() {
+ + {/* Query moved dialog */} + + + + Analytics Queries have moved to the Control Center + + + + You can now do analytics queries directly from the Control Center. To open the Control Center, press + K + + + + + + +
); diff --git a/apps/dashboard/src/components/cmdk-search.tsx b/apps/dashboard/src/components/cmdk-search.tsx index ca9decab4..8331438fd 100644 --- a/apps/dashboard/src/components/cmdk-search.tsx +++ b/apps/dashboard/src/components/cmdk-search.tsx @@ -230,102 +230,6 @@ const CyclingPlaceholder = memo(function CyclingPlaceholder({ ); }); -// Analytics query mode placeholder component -const AnalyticsQueryPlaceholder = memo(function AnalyticsQueryPlaceholder({ - onSelectQuery, -}: { - onSelectQuery?: (query: string) => void, -}) { - const tables = [ - { id: "events", name: "Events", description: "User events and actions", query: "SELECT * FROM events ORDER BY event_at DESC LIMIT 100" }, - ]; - - const exampleQueries = [ - "Show me all events from the last hour", - "Count events by type", - "SELECT event_type, COUNT(*) FROM events GROUP BY event_type", - ]; - - return ( -
- {/* Top spacer */} -
- -
- {/* Header */} -
-

- Analytics Query -

-

- Query your data using English or ClickHouse SQL -

-
- - {/* Tables section */} -
-

Available Tables

-
- {tables.map((table) => ( - - ))} -
-
- - {/* Example queries */} -
-

Try something like

-
- {exampleQueries.map((q, index) => ( - - ))} -
-
-
- - {/* Bottom spacer */} -
- - {/* Footer */} -
-
-
- - run query -
-
- esc - close -
-
-
-
- ); -}); // Reusable Results List Component export const CmdKResultsList = memo(function CmdKResultsList({ @@ -337,7 +241,6 @@ export const CmdKResultsList = memo(function CmdKResultsList({ showCyclingPlaceholder = false, onSelectExampleQuery, isParentColumn = false, - analyticsQueryMode = false, }: { commands: CmdKCommand[], selectedIndex: number, @@ -351,8 +254,6 @@ export const CmdKResultsList = memo(function CmdKResultsList({ onSelectExampleQuery?: (query: string) => void, /** When true, selection shows as outline only (for parent columns) */ isParentColumn?: boolean, - /** When true, show analytics query placeholder instead of cycling placeholder */ - analyticsQueryMode?: boolean, }) { const itemRefs = useRef>(new Map()); const hasResults = commands.length > 0; @@ -366,9 +267,6 @@ export const CmdKResultsList = memo(function CmdKResultsList({ }, [selectedIndex]); if (!hasResults) { - if (analyticsQueryMode) { - return ; - } if (showCyclingPlaceholder) { return ; } @@ -505,8 +403,6 @@ export function CmdKSearch({ const [activeDepth, setActiveDepth] = useState(0); // Which column is active (0 = main list) const [selectedIndices, setSelectedIndices] = useState([0]); // Selected index in each column const [nestedBlurHandlers, setNestedBlurHandlers] = useState<(() => void)[]>([]); // onBlur handlers for each depth - // Analytics query mode state - const [analyticsQueryMode, setAnalyticsQueryMode] = useState(false); const router = useRouter(); const pathname = usePathname(); const inputRef = useRef(null); @@ -528,23 +424,15 @@ export function CmdKSearch({ setOpen((prev) => !prev); }; - const handleAnalyticsQuery = () => { - setAnalyticsQueryMode(true); - setQuery(""); - setOpen(true); - }; - document.addEventListener("keydown", down); window.addEventListener("spotlight-toggle", handleToggle); - window.addEventListener("spotlight-analytics-query", handleAnalyticsQuery); return () => { document.removeEventListener("keydown", down); window.removeEventListener("spotlight-toggle", handleToggle); - window.removeEventListener("spotlight-analytics-query", handleAnalyticsQuery); }; }, []); - // Focus and select input when opening, reset analytics mode when closing + // Focus and select input when opening useEffect(() => { if (open) { setSelectedIndex(0); @@ -553,16 +441,13 @@ export function CmdKSearch({ inputRef.current?.focus(); inputRef.current?.select(); }); - } else { - // Reset analytics mode when closing - setAnalyticsQueryMode(false); } }, [open]); // Get commands from the hook const commands = useCmdKCommands({ projectId, enabledApps, query, onEnableApp }); - // Filter commands based on query + // Filter and sort commands based on query const filteredCommands = useMemo(() => { if (!query.trim()) return []; @@ -872,7 +757,7 @@ export function CmdKSearch({ value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={handleKeyDown} - placeholder={analyticsQueryMode ? "Type your query... (English or ClickHouse SQL)" : "Search or ask AI..."} + placeholder="Search or ask AI..." className="flex-1 bg-transparent text-base outline-none placeholder:text-muted-foreground/50" autoComplete="off" autoCorrect="off" @@ -934,7 +819,6 @@ export function CmdKSearch({ showCyclingPlaceholder={true} onSelectExampleQuery={setQuery} isParentColumn={activeDepth > 0} - analyticsQueryMode={analyticsQueryMode} />
@@ -1097,7 +981,7 @@ export function CmdKTrigger() { "rounded-[12px]", "ring-2 ring-inset ring-foreground/[0.06]", "transition-all duration-300 hover:transition-none", - "hover:ring-blue-500/15 hover:shadow-[0_0_16px_rgba(59,130,246,0.08),inset_0_1px_0_rgba(255,255,255,0.03)]" + "hover:ring-[#9196F4]/20 hover:shadow-[0_0_20px_rgba(145,150,244,0.12),inset_0_1px_0_rgba(255,255,255,0.03)]" )} >
{ + describe("SQL queries", () => { + it("should classify basic SELECT query as SQL", () => { + const result = classifyClickHouseSqlVsPrompt("SELECT * FROM events WHERE user_id = 1 LIMIT 10"); + expect(result.kind).toBe("sql"); + expect(result.confidence).toBeGreaterThan(0.7); + expect(result.reasons).toContain("select-from-structure"); + }); + + it("should classify SELECT with GROUP BY as SQL", () => { + const result = classifyClickHouseSqlVsPrompt("SELECT event_type, COUNT(*) FROM events GROUP BY event_type"); + expect(result.kind).toBe("sql"); + expect(result.reasons).toContain("select-from-structure"); + }); + + it("should classify INSERT statement as SQL", () => { + const result = classifyClickHouseSqlVsPrompt("INSERT INTO events (user_id, event_type) VALUES (1, 'click')"); + expect(result.kind).toBe("sql"); + expect(result.reasons).toContain("insert-into-structure"); + }); + + it("should classify CREATE TABLE as SQL", () => { + const result = classifyClickHouseSqlVsPrompt("CREATE TABLE users (id UInt64, name String) ENGINE = MergeTree()"); + expect(result.kind).toBe("sql"); + expect(result.reasons).toContain("starts-with-sql-keyword"); + }); + + it("should classify query with ClickHouse-specific keywords", () => { + const result = classifyClickHouseSqlVsPrompt("SELECT * FROM events FINAL PREWHERE event_at > now() - INTERVAL 1 DAY"); + expect(result.kind).toBe("sql"); + expect(result.reasons).toContain("clickhouse-ish:prewhere,final"); + }); + + it("should classify query with quoted identifiers", () => { + const result = classifyClickHouseSqlVsPrompt("SELECT `user_id`, `event_type` FROM `events`"); + expect(result.kind).toBe("sql"); + expect(result.reasons).toContain("quoted-identifiers"); + }); + + it("should classify query with dot notation", () => { + const result = classifyClickHouseSqlVsPrompt("SELECT e.user_id FROM events e"); + expect(result.kind).toBe("sql"); + expect(result.reasons).toContain("dot-identifiers"); + }); + + it("should classify SHOW TABLES as SQL", () => { + const result = classifyClickHouseSqlVsPrompt("SHOW TABLES"); + expect(result.kind).toBe("sql"); + expect(result.reasons).toContain("starts-with-sql-keyword"); + }); + + it("should classify DESCRIBE as SQL", () => { + const result = classifyClickHouseSqlVsPrompt("DESCRIBE events"); + expect(result.kind).toBe("sql"); + expect(result.reasons).toContain("starts-with-sql-keyword"); + }); + + it("should classify query with SQL comments as SQL", () => { + const result = classifyClickHouseSqlVsPrompt("SELECT * FROM events -- get all events"); + expect(result.kind).toBe("sql"); + expect(result.reasons).toContain("sql-comments"); + }); + }); + + describe("Natural language prompts", () => { + it("should classify question about writing query as prompt", () => { + const result = classifyClickHouseSqlVsPrompt("can you write me a clickhouse query to count events by day?"); + expect(result.kind).toBe("prompt"); + expect(result.reasons).toContain("ends-with-question-mark"); + expect(result.reasons).toContain("prompt-words"); + }); + + it("should classify 'select the best option please' as prompt (false positive guard)", () => { + const result = classifyClickHouseSqlVsPrompt("select the best option please"); + expect(result.kind).toBe("prompt"); + expect(result.reasons).toContain("english-select-false-positive-guard"); + expect(result.reasons).toContain("prompt-words"); + }); + + it("should classify help request as prompt", () => { + const result = classifyClickHouseSqlVsPrompt("help me understand how to query events"); + expect(result.kind).toBe("prompt"); + expect(result.reasons).toContain("prompt-words"); + }); + + it("should classify 'what is' question as prompt", () => { + const result = classifyClickHouseSqlVsPrompt("what is the schema of the events table?"); + expect(result.kind).toBe("prompt"); + expect(result.reasons).toContain("prompt-words"); + expect(result.reasons).toContain("ends-with-question-mark"); + }); + + it("should classify simple text without operators as prompt", () => { + const result = classifyClickHouseSqlVsPrompt("show me user activity"); + expect(result.kind).toBe("prompt"); + }); + + it("should classify request with 'please' as prompt", () => { + const result = classifyClickHouseSqlVsPrompt("please get me the latest events"); + expect(result.kind).toBe("prompt"); + expect(result.reasons).toContain("prompt-words"); + }); + + it("should classify 'how to' question as prompt", () => { + const result = classifyClickHouseSqlVsPrompt("how do I join two tables in ClickHouse?"); + expect(result.kind).toBe("prompt"); + expect(result.reasons).toContain("prompt-words"); + }); + }); + + describe("Edge cases", () => { + it("should handle empty input", () => { + const result = classifyClickHouseSqlVsPrompt(""); + expect(result.kind).toBe("prompt"); + expect(result.confidence).toBe(0.5); + expect(result.reasons).toContain("empty"); + }); + + it("should handle null input", () => { + const result = classifyClickHouseSqlVsPrompt(null); + expect(result.kind).toBe("prompt"); + expect(result.reasons).toContain("empty"); + }); + + it("should handle undefined input", () => { + const result = classifyClickHouseSqlVsPrompt(undefined); + expect(result.kind).toBe("prompt"); + expect(result.reasons).toContain("empty"); + }); + + it("should strip markdown code fences", () => { + const result = classifyClickHouseSqlVsPrompt("```sql\nSELECT * FROM events\n```"); + expect(result.kind).toBe("sql"); + expect(result.reasons).toContain("select-from-structure"); + }); + + it("should handle whitespace-only input", () => { + const result = classifyClickHouseSqlVsPrompt(" "); + expect(result.kind).toBe("prompt"); + expect(result.reasons).toContain("empty"); + }); + }); + + describe("Confidence scoring", () => { + it("should have higher confidence for clear SQL", () => { + const sqlResult = classifyClickHouseSqlVsPrompt("SELECT * FROM events WHERE user_id = 1 LIMIT 10"); + const promptResult = classifyClickHouseSqlVsPrompt("can you write me a query?"); + + expect(sqlResult.confidence).toBeGreaterThan(0.7); + expect(promptResult.confidence).toBeGreaterThan(0.5); + }); + + it("should return score breakdown", () => { + const result = classifyClickHouseSqlVsPrompt("SELECT * FROM events"); + expect(result.score).toBeDefined(); + expect(result.score?.sql).toBeGreaterThan(0); + expect(result.score?.margin).toBeDefined(); + }); + }); +}); diff --git a/apps/dashboard/src/lib/classify-query.ts b/apps/dashboard/src/lib/classify-query.ts new file mode 100644 index 000000000..c1a6ec9bc --- /dev/null +++ b/apps/dashboard/src/lib/classify-query.ts @@ -0,0 +1,99 @@ +/** + * Classifies whether user input is a ClickHouse SQL query or a natural language prompt. + * Used to prioritize "Run Query" vs "Ask AI" in the command center. + */ +export function classifyClickHouseSqlVsPrompt(input: unknown): { + kind: "sql" | "prompt", + confidence: number, + reasons: string[], + score?: { sql: number, prompt: number, margin: number }, +} { + const raw = (input ?? "").toString(); + const s = raw.trim(); + + if (!s) return { kind: "prompt", confidence: 0.5, reasons: ["empty"] }; + + // Strip code fences if someone pasted markdown + const unfenced = s.replace(/^```[\w-]*\n([\s\S]*?)\n```$/m, "$1").trim(); + + const lower = unfenced.toLowerCase(); + const words = lower.match(/[a-z_]+/g) ?? []; + const wordSet = new Set(words); + + let sql = 0; + let prompt = 0; + const reasons: string[] = []; + + // 1) Strong starts (high signal) + const startsWithSql = /^(with|select|insert|update|delete|alter|create|drop|truncate|show|describe|desc|explain|use|set)\b/i.test(unfenced); + if (startsWithSql) { sql += 3; reasons.push("starts-with-sql-keyword"); } + + // 2) Structural patterns (very strong) + const hasSelectFrom = /\bselect\b[\s\S]{0,300}\bfrom\b/i.test(unfenced); + if (hasSelectFrom) { sql += 4; reasons.push("select-from-structure"); } + + const hasInsertInto = /\binsert\b[\s\S]{0,80}\binto\b/i.test(unfenced); + if (hasInsertInto) { sql += 4; reasons.push("insert-into-structure"); } + + // 3) Common SQL clauses + const clauseHits = [ + "where", "group", "order", "limit", "having", "join", "on", "union", "distinct", "values", "format", "settings" + ].filter(k => wordSet.has(k)); + if (clauseHits.length) { + sql += Math.min(3, clauseHits.length); + reasons.push("sql-clauses:" + clauseHits.join(",")); + } + + // 4) ClickHouse-ish tokens (extra signal) + const chHits = [ + "prewhere", "final", "sample", "array", "engine", "partition", "ttl", "distributed", "merge", "replacing", "collapsing", + "materialized", "view", "database", "table", "cluster" + ].filter(k => wordSet.has(k)); + if (chHits.length) { sql += 2; reasons.push("clickhouse-ish:" + chHits.join(",")); } + + // 5) Operator / punctuation density + const opCount = (unfenced.match(/(<=|>=|!=|=|<|>|\b(in|like|ilike|between|and|or)\b)/gi) ?? []).length; + if (opCount >= 2) { sql += 2; reasons.push("many-operators"); } + else if (opCount === 1) { sql += 1; reasons.push("some-operators"); } + + const punct = (unfenced.match(/[(),;*]/g) ?? []).length; + const punctRatio = punct / Math.max(1, unfenced.length); + if (punctRatio > 0.03) { sql += 1; reasons.push("sql-punctuation-density"); } + + // 6) Identifier-ish things + if (/`[^`]+`/.test(unfenced) || /"[^"]+"\."[^"]+"/.test(unfenced)) { + sql += 1; reasons.push("quoted-identifiers"); + } + if (/\b[a-z_]+\.[a-z_]+\b/i.test(unfenced)) { + sql += 1; reasons.push("dot-identifiers"); + } + if (/--|\/\*/.test(unfenced)) { + sql += 1; reasons.push("sql-comments"); + } + + // Prompt-ish features + if (/[?]\s*$/.test(unfenced)) { prompt += 2; reasons.push("ends-with-question-mark"); } + if (/\b(please|could you|can you|what|why|how|explain|help)\b/i.test(unfenced)) { + prompt += 2; reasons.push("prompt-words"); + } + + // If it's mostly letters/spaces and barely any operators, lean prompt. + const symbolChars = (unfenced.match(/[^a-z0-9_\s]/gi) ?? []).length; + const symbolRatio = symbolChars / Math.max(1, unfenced.length); + if (symbolRatio < 0.06 && opCount === 0 && !startsWithSql) { + prompt += 2; reasons.push("low-symbol-low-operator"); + } + + // Avoid the classic false positive: "select ..." in English without any SQL structure + if (/^select\b/i.test(unfenced) && !hasSelectFrom && clauseHits.length === 0 && opCount === 0) { + prompt += 3; reasons.push("english-select-false-positive-guard"); + } + + const margin = sql - prompt; + const kind = margin >= 2 ? "sql" : "prompt"; + + // Confidence: squish margin into [0.5..0.99] + const confidence = Math.max(0.5, Math.min(0.99, 0.5 + Math.abs(margin) * 0.12)); + + return { kind, confidence, reasons, score: { sql, prompt, margin } }; +} diff --git a/apps/e2e/tests/backend/endpoints/health/email-monitor.test.ts b/apps/e2e/tests/backend/endpoints/health/email-monitor.test.ts index d37f16f5c..0e14619be 100644 --- a/apps/e2e/tests/backend/endpoints/health/email-monitor.test.ts +++ b/apps/e2e/tests/backend/endpoints/health/email-monitor.test.ts @@ -4,6 +4,7 @@ import { niceBackendFetch } from "../../backend-helpers"; it("should return ok when email health check succeeds", async ({ expect }) => { const response = await niceBackendFetch("/health/email", { + method: "POST", headers: { "authorization": `Bearer ${getEnvVariable("STACK_EMAIL_MONITOR_SECRET_TOKEN")}`, }, @@ -19,6 +20,7 @@ it("should return ok when email health check succeeds", async ({ expect }) => { it("should reject requests with invalid token", async ({ expect }) => { const response = await niceBackendFetch("/health/email", { + method: "POST", headers: { "authorization": "Bearer invalid-token", },