Merge remote-tracking branch 'origin/dev' into external-db-sync

This commit is contained in:
Bilal Godil 2026-01-30 16:10:39 -08:00
commit f726f6165b
6 changed files with 289 additions and 122 deletions

View File

@ -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",

View File

@ -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<TableId | null>("events");
const [queryDialogOpen, setQueryDialogOpen] = useState(false);
return (
<AppEnabledGuard appId="analytics">
@ -647,7 +649,7 @@ export default function PageClient() {
</div>
<div className="py-4 px-4">
<button
onClick={() => window.dispatchEvent(new CustomEvent("spotlight-toggle"))}
onClick={() => setQueryDialogOpen(true)}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors hover:transition-none w-full"
>
<SparkleIcon className="h-4 w-4" />
@ -667,6 +669,23 @@ export default function PageClient() {
)}
</div>
</div>
{/* Query moved dialog */}
<Dialog open={queryDialogOpen} onOpenChange={setQueryDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Analytics Queries have moved to the Control Center</DialogTitle>
</DialogHeader>
<DialogBody>
<Typography variant="secondary">
You can now do analytics queries directly from the Control Center. To open the Control Center, press <kbd className="px-1.5 py-0.5 rounded bg-muted font-mono text-xs"></kbd> + <kbd className="px-1.5 py-0.5 rounded bg-muted font-mono text-xs">K</kbd>
</Typography>
</DialogBody>
<DialogFooter>
<Button onClick={() => setQueryDialogOpen(false)}>OK</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageLayout>
</AppEnabledGuard>
);

View File

@ -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 (
<div className="h-full flex flex-col items-center select-none px-6">
{/* Top spacer */}
<div className="flex-1" />
<div className="relative w-fit max-w-md">
{/* Header */}
<div className="relative text-center mb-6">
<h2 className="relative text-base font-semibold text-foreground mb-1 inline-block">
Analytics Query
</h2>
<p className="text-[11px] text-muted-foreground/50">
Query your data using English or ClickHouse SQL
</p>
</div>
{/* Tables section */}
<div className="mb-6">
<p className="text-[9px] text-muted-foreground/40 uppercase tracking-wider mb-3 text-center">Available Tables</p>
<div className="space-y-2">
{tables.map((table) => (
<button
key={table.id}
type="button"
onClick={() => onSelectQuery?.(table.query)}
className="w-full flex items-center gap-3 rounded-lg px-4 py-3 transition-colors hover:transition-none hover:bg-foreground/[0.04] border border-foreground/[0.06]"
>
<div className="w-8 h-8 rounded-lg bg-amber-500/10 flex items-center justify-center">
<PlayIcon className="h-4 w-4 text-amber-500" />
</div>
<div className="flex-1 min-w-0 text-left">
<h3 className="text-[12px] font-medium text-foreground font-mono">
{table.name}
</h3>
<p className="text-[10px] text-muted-foreground/50">
{table.description}
</p>
</div>
</button>
))}
</div>
</div>
{/* Example queries */}
<div>
<p className="text-[9px] text-muted-foreground/40 uppercase tracking-wider mb-3 text-center">Try something like</p>
<div className="space-y-1.5">
{exampleQueries.map((q, index) => (
<button
key={index}
type="button"
onClick={() => onSelectQuery?.(q)}
className="w-full text-left text-[11px] text-muted-foreground/60 hover:text-foreground px-3 py-2 rounded-md hover:bg-foreground/[0.04] transition-colors hover:transition-none font-mono truncate"
>
{q}
</button>
))}
</div>
</div>
</div>
{/* Bottom spacer */}
<div className="flex-1" />
{/* Footer */}
<div className="w-full shrink-0 -mx-6 px-6">
<div className="py-3 border-t border-foreground/[0.06] w-full flex items-center justify-center gap-5 text-[10px] text-muted-foreground/40">
<div className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 rounded bg-foreground/[0.06] font-mono"></kbd>
<span>run query</span>
</div>
<div className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 rounded bg-foreground/[0.06] font-mono">esc</kbd>
<span>close</span>
</div>
</div>
</div>
</div>
);
});
// 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<Map<number, HTMLButtonElement>>(new Map());
const hasResults = commands.length > 0;
@ -366,9 +267,6 @@ export const CmdKResultsList = memo(function CmdKResultsList({
}, [selectedIndex]);
if (!hasResults) {
if (analyticsQueryMode) {
return <AnalyticsQueryPlaceholder onSelectQuery={onSelectExampleQuery} />;
}
if (showCyclingPlaceholder) {
return <CyclingPlaceholder onSelectQuery={onSelectExampleQuery} />;
}
@ -505,8 +403,6 @@ export function CmdKSearch({
const [activeDepth, setActiveDepth] = useState(0); // Which column is active (0 = main list)
const [selectedIndices, setSelectedIndices] = useState<number[]>([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<HTMLInputElement>(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}
/>
</div>
@ -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)]"
)}
>
<div

View File

@ -0,0 +1,163 @@
import { describe, it, expect } from "vitest";
import { classifyClickHouseSqlVsPrompt } from "./classify-query";
describe("classifyClickHouseSqlVsPrompt", () => {
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();
});
});
});

View File

@ -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 } };
}

View File

@ -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",
},