diff --git a/apps/web/src/lib/sql-format.test.ts b/apps/web/src/lib/sql-format.test.ts
new file mode 100644
index 00000000..0c8b1b7a
--- /dev/null
+++ b/apps/web/src/lib/sql-format.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest"
+import { formatSql } from "./sql-format"
+
+describe("formatSql", () => {
+ it("reflows the canonical alert query onto clause/column/condition lines", () => {
+ const input =
+ "SELECT quantile(0.95)(Duration / 1e6) AS value, count() AS samples FROM traces WHERE ServiceName = 'api-v2' AND SpanName = 'ChartService.getChartData' AND SpanAttributes['cache.fluxSeconds'] = '604800' AND $__timeFilter(Timestamp) AND $__orgFilter"
+
+ expect(formatSql(input)).toBe(
+ [
+ "SELECT",
+ " quantile(0.95)(Duration / 1e6) AS value,",
+ " count() AS samples",
+ "FROM traces",
+ "WHERE ServiceName = 'api-v2'",
+ " AND SpanName = 'ChartService.getChartData'",
+ " AND SpanAttributes['cache.fluxSeconds'] = '604800'",
+ " AND $__timeFilter(Timestamp)",
+ " AND $__orgFilter",
+ ].join("\n"),
+ )
+ })
+
+ it("never splits commas or parens inside a function call", () => {
+ const out = formatSql("SELECT quantile(0.95)(Duration / 1e6) AS v FROM t")
+ expect(out).toContain("quantile(0.95)(Duration / 1e6)")
+ // the only newline-introduced column line is the single SELECT item
+ expect(out).toBe(["SELECT", " quantile(0.95)(Duration / 1e6) AS v", "FROM t"].join("\n"))
+ })
+
+ it("keeps map access and string literals intact", () => {
+ const out = formatSql("SELECT a FROM t WHERE SpanAttributes['cache.fluxSeconds'] = '604800'")
+ expect(out).toContain("SpanAttributes['cache.fluxSeconds'] = '604800'")
+ })
+
+ it("keeps $__macros atomic and breaks the AND before them", () => {
+ const out = formatSql("SELECT a FROM t WHERE x = 1 AND $__timeFilter(Timestamp) AND $__orgFilter")
+ expect(out).toContain("$__timeFilter(Timestamp)")
+ expect(out).toContain("\n AND $__orgFilter")
+ })
+
+ it("never reformats inside string literals", () => {
+ const out = formatSql("SELECT 'a, from b WHERE c' AS lit FROM t")
+ expect(out).toContain("'a, from b WHERE c'")
+ // the literal's lowercase from/where must not be treated as clauses
+ expect(out).toBe(["SELECT", " 'a, from b WHERE c' AS lit", "FROM t"].join("\n"))
+ })
+
+ it("only breaks top-level AND, not those nested in parentheses", () => {
+ const out = formatSql("SELECT a FROM t WHERE (x AND y) AND z")
+ expect(out).toBe(["SELECT", " a", "FROM t", "WHERE (x AND y)", " AND z"].join("\n"))
+ })
+
+ it("does not break GROUP BY columns or the BY keyword", () => {
+ const out = formatSql("SELECT a, b FROM t GROUP BY a, b ORDER BY a")
+ expect(out).toContain("GROUP BY a, b")
+ expect(out).toContain("ORDER BY a")
+ })
+
+ it("is idempotent", () => {
+ const input =
+ "SELECT quantile(0.95)(Duration / 1e6) AS value, count() AS samples FROM traces WHERE a = 1 AND b = 2 AND $__orgFilter"
+ const once = formatSql(input)
+ expect(formatSql(once)).toBe(once)
+ })
+
+ it("returns empty for blank input and does not throw on odd input", () => {
+ expect(formatSql(" ")).toBe("")
+ expect(() => formatSql("SELECT (((")).not.toThrow()
+ expect(() => formatSql("@#$ %^&")).not.toThrow()
+ })
+})
diff --git a/apps/web/src/lib/sql-format.ts b/apps/web/src/lib/sql-format.ts
new file mode 100644
index 00000000..c5ef6f10
--- /dev/null
+++ b/apps/web/src/lib/sql-format.ts
@@ -0,0 +1,235 @@
+/**
+ * Conservative, dependency-free SQL pretty-printer tuned for the ClickHouse
+ * dialect Maple writes (Grafana-style `$__macros`, `Map['key']` access, the
+ * double-call `quantile(0.95)(x)` form). It reflows a single statement so the
+ * major clauses, SELECT columns, and WHERE conditions land on their own lines —
+ * nothing more. It never rewrites the query's meaning: string literals,
+ * comments, macros, and bracketed access are treated as atomic, and any failure
+ * falls back to the original text.
+ *
+ * Pairs with `tokenizeSql` (sql-highlight.ts): format first, then highlight the
+ * formatted string.
+ */
+
+const INDENT = " "
+
+/** Keywords that start a top-level clause and force a line break before them. */
+const CLAUSE_BREAK = new Set([
+ "SELECT",
+ "FROM",
+ "WHERE",
+ "PREWHERE",
+ "GROUP",
+ "ORDER",
+ "HAVING",
+ "LIMIT",
+ "UNION",
+ "SETTINGS",
+ "JOIN",
+ "INNER",
+ "LEFT",
+ "RIGHT",
+ "FULL",
+ "CROSS",
+])
+
+/** Join modifiers that chain into one `LEFT OUTER JOIN`-style line, not a break each. */
+const JOIN_CHAIN = new Set(["INNER", "LEFT", "RIGHT", "FULL", "CROSS", "OUTER", "JOIN"])
+
+/**
+ * Reserved words — used to tell `count(` (function, no space) from `IN (`
+ * (keyword, keep the space). Superset of CLAUSE_BREAK plus operators/modifiers.
+ */
+const KEYWORDS = new Set([
+ ...CLAUSE_BREAK,
+ "BY",
+ "ON",
+ "AS",
+ "AND",
+ "OR",
+ "NOT",
+ "NULL",
+ "IN",
+ "IS",
+ "LIKE",
+ "ILIKE",
+ "BETWEEN",
+ "INTERVAL",
+ "CASE",
+ "WHEN",
+ "THEN",
+ "ELSE",
+ "END",
+ "DISTINCT",
+ "ALL",
+ "ANY",
+ "ASC",
+ "DESC",
+ "USING",
+ "WITH",
+ "OUTER",
+ "ARRAY",
+ "TUPLE",
+ "ASOF",
+ "FINAL",
+ "SAMPLE",
+ "FORMAT",
+ "VALUES",
+ "OFFSET",
+ "SEMI",
+ "ANTI",
+ "TRUE",
+ "FALSE",
+])
+
+type TokType = "comment" | "string" | "macro" | "op" | "num" | "word" | "punct" | "other"
+
+interface Tok {
+ type: TokType
+ value: string
+ /** Uppercased value for word/op/punct comparisons; "" for the rest. */
+ up: string
+}
+
+const LEX =
+ /(\/\*[\s\S]*?\*\/|--[^\n]*)|('(?:''|\\.|[^'\\])*'|"(?:""|\\.|[^"\\])*")|(\$__[A-Za-z_][A-Za-z0-9_]*)|([<>=!]=|<>|->|::|\|\|)|(\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|([A-Za-z_][A-Za-z0-9_]*)|(\s+)|([(),[\].;])|([^\s])/g
+
+function lex(sql: string): Tok[] {
+ const toks: Tok[] = []
+ LEX.lastIndex = 0
+ let m: RegExpExecArray | null
+ while ((m = LEX.exec(sql)) !== null) {
+ const [, comment, str, macro, op, num, word, ws, punct, other] = m
+ if (ws !== undefined) continue
+ if (comment !== undefined) toks.push({ type: "comment", value: comment, up: "" })
+ else if (str !== undefined) toks.push({ type: "string", value: str, up: "" })
+ else if (macro !== undefined) toks.push({ type: "macro", value: macro, up: "" })
+ else if (op !== undefined) toks.push({ type: "op", value: op, up: op })
+ else if (num !== undefined) toks.push({ type: "num", value: num, up: "" })
+ else if (word !== undefined) toks.push({ type: "word", value: word, up: word.toUpperCase() })
+ else if (punct !== undefined) toks.push({ type: "punct", value: punct, up: punct })
+ else if (other !== undefined) toks.push({ type: "other", value: other, up: other })
+ }
+ return toks
+}
+
+/** A name that takes a call/index with no space before its `(` or `[`. */
+function isCallable(t: Tok | undefined): boolean {
+ if (!t) return false
+ if (t.type === "macro") return true
+ if (t.type === "word") return !KEYWORDS.has(t.up)
+ return false
+}
+
+function clauseFor(up: string): string {
+ switch (up) {
+ case "SELECT":
+ return "select"
+ case "WHERE":
+ case "PREWHERE":
+ return "where"
+ case "HAVING":
+ return "having"
+ case "FROM":
+ return "from"
+ case "GROUP":
+ return "group"
+ case "ORDER":
+ return "order"
+ case "INNER":
+ case "LEFT":
+ case "RIGHT":
+ case "FULL":
+ case "CROSS":
+ case "JOIN":
+ return "join"
+ case "UNION":
+ return "union"
+ case "LIMIT":
+ return "limit"
+ case "SETTINGS":
+ return "settings"
+ default:
+ return "other"
+ }
+}
+
+/** Inline spacing between two adjacent tokens (no line break involved). */
+function spacer(prev: Tok, cur: Tok): string {
+ const cv = cur.value
+ const pv = prev.value
+ if (cv === "," || cv === ";" || cv === ")" || cv === "]" || cv === ".") return ""
+ if (pv === "(" || pv === "[" || pv === ".") return ""
+ if (cur.type === "op" && cur.value === "::") return ""
+ if (prev.type === "op" && prev.value === "::") return ""
+ if ((cv === "(" || cv === "[") && (isCallable(prev) || pv === ")" || pv === "]")) return ""
+ return " "
+}
+
+export function formatSql(sql: string): string {
+ try {
+ const input = sql.trim()
+ if (!input) return input
+ const toks = lex(input)
+ if (toks.length === 0) return input
+
+ let out = ""
+ let depth = 0 // () nesting
+ let bracket = 0 // [] nesting
+ let clause = ""
+ let prev: Tok | undefined
+
+ for (const t of toks) {
+ const top = depth === 0 && bracket === 0
+
+ const isClauseBreak =
+ t.type === "word" &&
+ top &&
+ CLAUSE_BREAK.has(t.up) &&
+ !(JOIN_CHAIN.has(t.up) && prev?.type === "word" && JOIN_CHAIN.has(prev.up))
+
+ const isAndOrBreak =
+ t.type === "word" &&
+ top &&
+ (t.up === "AND" || t.up === "OR") &&
+ (clause === "where" || clause === "having" || clause === "on")
+
+ const afterSelectComma =
+ prev?.type === "punct" && prev.value === "," && clause === "select" && top
+
+ const firstSelectItem =
+ clause === "select" &&
+ prev?.type === "word" &&
+ (prev.up === "SELECT" || prev.up === "DISTINCT") &&
+ !(t.type === "word" && t.up === "DISTINCT")
+
+ let sep: string
+ if (!prev) sep = ""
+ else if (prev.type === "comment" && prev.value.startsWith("--")) sep = "\n"
+ else if (isClauseBreak) sep = "\n"
+ else if (isAndOrBreak || afterSelectComma || firstSelectItem) sep = `\n${INDENT}`
+ else sep = spacer(prev, t)
+
+ out += sep + t.value
+
+ if (t.type === "word" && top) {
+ if (isClauseBreak) clause = clauseFor(t.up)
+ else if (t.up === "ON") clause = "on"
+ }
+
+ if (t.type === "punct") {
+ if (t.value === "(") depth++
+ else if (t.value === ")") depth = Math.max(0, depth - 1)
+ else if (t.value === "[") bracket++
+ else if (t.value === "]") bracket = Math.max(0, bracket - 1)
+ }
+
+ prev = t
+ }
+
+ const result = out.trim()
+ return result.length > 0 ? result : input
+ } catch {
+ return sql.trim()
+ }
+}
diff --git a/apps/web/src/routes/alerts/$ruleId.tsx b/apps/web/src/routes/alerts/$ruleId.tsx
index 090a5ee6..9b791eb1 100644
--- a/apps/web/src/routes/alerts/$ruleId.tsx
+++ b/apps/web/src/routes/alerts/$ruleId.tsx
@@ -62,6 +62,8 @@ import {
DropdownMenuTrigger,
} from "@maple/ui/components/ui/dropdown-menu"
import { useAlertRuleChart } from "@/hooks/use-alert-rule-chart"
+import { tokenizeSql } from "@/lib/sql-highlight"
+import { formatSql } from "@/lib/sql-format"
const tabValues = ["overview", "history"] as const
type RuleDetailTab = (typeof tabValues)[number]
@@ -431,7 +433,10 @@ function RuleDetailContent() {
comparator={rule.comparator}
signalType={rule.signalType}
window={timelineRange}
- loading={chartLoading}
+ loading={
+ chartLoading ||
+ (rule.signalType === "raw_query" && Result.isInitial(checksResult))
+ }
chartError={chartError}
/>
@@ -543,11 +548,25 @@ function RuleDetailContent() {
>
)}
{rule.signalType === "raw_query" && rule.rawQuerySql && (
-
- {rule.rawQuerySql}
-
-
+
+ {tokenizeSql(formatSql(rule.rawQuerySql)).map(
+ (token) => (
+
+ {token.text}
+
+ ),
+ )}
+
+
+