diff --git a/api/fix-query-prompt.ts b/api/fix-query-prompt.ts new file mode 100644 index 0000000..4b5d203 --- /dev/null +++ b/api/fix-query-prompt.ts @@ -0,0 +1,127 @@ +export const promptTemplate = `You are analyzing SQLite query performance. + +You will receive one or more query metric objects. For each query, use: + +- SQL text +- cumulative timing metrics +- max single-run latency +- EXPLAIN QUERY PLAN rows +- sqlite3_stmt_status counters + +Your job is to identify the most likely causes of slowness and propose concrete +fixes. + +Important SQLite interpretation rules: + +1. EXPLAIN QUERY PLAN is a tree encoded as rows with: + - id: node id + - parent: parent node id + - detail: human-readable plan detail +2. Treat the EQP detail text as advisory, not as a perfectly stable machine + interface. +3. Meanings to use: + - SCAN usually means a full scan or full traversal + - SEARCH usually means indexed subset access + - USING COVERING INDEX is generally better than non-covering index access + - USE TEMP B-TREE FOR ORDER BY / GROUP BY / DISTINCT usually means extra + sorting or temp work + - SCALAR SUBQUERY / CORRELATED SCALAR SUBQUERY / MATERIALIZE / CO-ROUTINE can + indicate extra execution layers or repeated work + - MULTI-INDEX OR can be valid, but may still be expensive depending on + cardinality and repetitions +4. Meanings of sqlite3_stmt_status counters: + - fullscanStep: number of forward steps in full table scans; high values + often suggest missing or ineffective indexes + - sort: number of sort operations; non-zero may suggest missing index support + for ORDER BY / GROUP BY / DISTINCT + - autoindex: rows inserted into transient automatic indexes; non-zero may + suggest a permanent index should exist + - vmStep: proxy for total work done by the prepared statement + - reprepare: statement was automatically regenerated due to schema changes or + parameter changes that affect the plan + - run: number of statement runs + - filterHit / filterMiss: Bloom filter counters for joins; usually secondary + diagnostics, not primary optimization targets +5. Be careful: + - Do not claim certainty from EQP text alone + - Do not recommend indexes that duplicate an obviously existing useful index + unless you explain why + - Distinguish between “high total cost because it runs often” and “high + single-run latency” + - Prefer practical, minimal index suggestions over speculative rewrites + - If the query already appears well-indexed, say that and focus on workload + frequency, result size, or query shape + +For each query, produce: + +1. Summary + - one sentence on why this query matters + - classify it as primarily: + - high frequency + - high single-run latency + - heavy scan work + - sort/temp-btree heavy + - join/index issue + - subquery/correlation issue +2. Evidence + - cite the most relevant metrics and EQP nodes + - explicitly mention which plan rows matter +3. Likely causes + - explain the top 1 to 3 causes +4. Suggested fixes + - propose concrete indexes in SQL when justified + - propose query rewrites when justified + - propose schema/query-shape changes only when supported by evidence +5. Confidence + - High / Medium / Low + - explain what additional info would confirm the diagnosis +6. Priority + - rank the query as P1 / P2 / P3 for optimization effort + +Index recommendation rules: + +- If EQP shows SCAN on a filtered table and fullscanStep is high, consider an + index on the WHERE columns in filter order. +- If EQP shows USE TEMP B-TREE FOR ORDER BY and the query filters first, + consider an index starting with selective WHERE columns followed by ORDER BY + columns. +- If EQP shows USE TEMP B-TREE FOR GROUP BY or DISTINCT, consider an index that + matches the grouping/distinct keys if it fits the query shape. +- If autoindex is non-zero for joins, suggest a permanent index on the join key + columns. +- If a correlated subquery appears and the query runs many times or max latency + is high, consider rewriting to JOIN / EXISTS / pre-aggregation when + semantically safe. +- Always mention tradeoffs: extra indexes improve reads but increase write cost + and storage. + +Output format: Return valid markdown with these sections: + +## Top optimization opportunities + +A short ranked list across all queries. + +## Query analysis + +For each query: + +### Query N + +- Summary: +- Evidence: +- Likely causes: +- Suggested fixes: +- Example index SQL: +- Example rewrite: +- Confidence: +- Priority: + +When you suggest an index, use a concrete CREATE INDEX statement if the target +columns are inferable. When the columns are not inferable with confidence, +describe the desired index pattern instead of inventing names. When no strong +optimization is justified, say so. + +Now analyze this data: + +{{QUERY_DETAILS_JSON}} +` diff --git a/api/fix-query.ts b/api/fix-query.ts new file mode 100644 index 0000000..646b282 --- /dev/null +++ b/api/fix-query.ts @@ -0,0 +1,40 @@ +import { render } from '@deno/gfm' +import { promptTemplate } from '/api/fix-query-prompt.ts' +import { GEMINI_API_KEY, GEMINI_MODEL } from '/api/lib/env.ts' + +const GEMINI_URL = + `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:streamGenerateContent?alt=json&key=${GEMINI_API_KEY}` + +export async function analyzeQueryWithAI(metric: unknown, schema: unknown) { + const payload = JSON.stringify({ schema, metrics: [metric] }, null, 2) + const prompt = promptTemplate.replace('{{QUERY_DETAILS_JSON}}', payload) + + const res = await fetch(GEMINI_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ role: 'user', parts: [{ text: prompt }] }], + generationConfig: { + thinkingConfig: { thinkingLevel: 'MINIMAL' }, + }, + }), + }) + + if (!res.ok) { + const body = await res.text() + throw new Error(`Gemini API error ${res.status}: ${body}`) + } + + // streamGenerateContent with alt=json returns a JSON array of response chunks + const chunks: { + candidates?: { content?: { parts?: { text?: string }[] } }[] + }[] = await res.json() + + const markdown = chunks + .flatMap((chunk) => chunk.candidates ?? []) + .flatMap((candidate) => candidate.content?.parts ?? []) + .map((part) => part.text ?? '') + .join('') + + return render(markdown) +} diff --git a/api/lib/env.ts b/api/lib/env.ts index 40f8f07..12eff48 100644 --- a/api/lib/env.ts +++ b/api/lib/env.ts @@ -23,3 +23,10 @@ export const DB_SCHEMA_REFRESH_MS = Number( export const STORE_URL = ENV('STORE_URL') export const STORE_SECRET = ENV('STORE_SECRET') + +export const GEMINI_API_KEY = ENV('GEMINI_API_KEY') + +export const GEMINI_MODEL = ENV( + 'GEMINI_MODEL', + 'gemini-3.1-flash-lite-preview', +) diff --git a/api/routes.ts b/api/routes.ts index 843d516..a2ffb1c 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -41,6 +41,33 @@ import { } from '/api/sql.ts' import { Log } from '@01edu/api/log' import { get, getOne } from './lmdb-store.ts' +import { analyzeQueryWithAI } from '/api/fix-query.ts' + +const MetricSchema = OBJ({ + query: STR('The SQL query text'), + count: NUM('How many times the query has run'), + duration: NUM('Total time spent running the query in milliseconds'), + max: NUM('Longest single query execution in milliseconds'), + explain: ARR( + OBJ({ + id: NUM('Query plan node id'), + parent: NUM('Parent query plan node id'), + detail: STR('Human-readable query plan detail'), + }), + 'SQLite EXPLAIN QUERY PLAN rows', + ), + status: OBJ({ + fullscanStep: NUM('Number of full table scan steps'), + sort: NUM('Number of sort operations'), + autoindex: NUM('Rows inserted into transient auto-indices'), + vmStep: NUM('Number of virtual machine operations'), + reprepare: NUM('Number of automatic statement reprepares'), + run: NUM('Number of statement runs'), + filterHit: NUM('Bloom filter bypass hits'), + filterMiss: NUM('Bloom filter misses'), + memused: NUM('Peak memory usage in bytes'), + }, 'SQLite sqlite3_stmt_status counters'), +}) const withUserSession = async ({ cookies }: RequestContext) => { const session = await decodeSession(cookies.session) @@ -693,36 +720,35 @@ const defs = { input: OBJ({ deployment: STR("The deployment's URL"), }), - output: ARR( - OBJ({ - query: STR('The SQL query text'), - count: NUM('How many times the query has run'), - duration: NUM('Total time spent running the query in milliseconds'), - max: NUM('Longest single query execution in milliseconds'), - explain: ARR( - OBJ({ - id: NUM('Query plan node id'), - parent: NUM('Parent query plan node id'), - detail: STR('Human-readable query plan detail'), - }), - 'SQLite EXPLAIN QUERY PLAN rows', - ), - status: OBJ({ - fullscanStep: NUM('Number of full table scan steps'), - sort: NUM('Number of sort operations'), - autoindex: NUM('Rows inserted into transient auto-indices'), - vmStep: NUM('Number of virtual machine operations'), - reprepare: NUM('Number of automatic statement reprepares'), - run: NUM('Number of statement runs'), - filterHit: NUM('Bloom filter bypass hits'), - filterMiss: NUM('Bloom filter misses'), - memused: NUM('Peak memory usage in bytes'), - }, 'SQLite sqlite3_stmt_status counters'), - }), - 'Collected query metrics', - ), + output: ARR(MetricSchema, 'Collected query metrics'), description: 'Get SQL metrics from the deployment', }), + 'POST/api/deployment/fix-query': route({ + authorize: withUserSession, + fn: async (ctx, { id, deployment, metric }) => { + await withDeploymentTableAccess(ctx, deployment) + const schema = DatabaseSchemasCollection.get(deployment) + try { + const analysis = await analyzeQueryWithAI(metric, schema) + return { id, analysis } + } catch (err) { + throw respond.InternalServerError({ + message: err instanceof Error ? err.message : String(err), + }) + } + }, + input: OBJ({ + id: STR('The metric ID'), + deployment: STR("The deployment's URL"), + metric: MetricSchema, + }), + output: OBJ({ + id: STR('The metric ID'), + analysis: STR('AI-generated markdown analysis of the query'), + }), + description: + 'Analyze a SQL query metric with Gemini AI and suggest optimizations', + }), } as const export type RouteDefinitions = typeof defs diff --git a/deno.json b/deno.json index b131e1e..911c8f3 100644 --- a/deno.json +++ b/deno.json @@ -53,7 +53,8 @@ "@tailwindcss/vite": "npm:@tailwindcss/vite@^4.1.17", "tailwindcss": "npm:tailwindcss@^4.1.17", "daisyui": "npm:daisyui@^5.5.8", - "lucide-preact": "npm:lucide-preact@^0.525.0" + "lucide-preact": "npm:lucide-preact@^0.525.0", + "@deno/gfm": "jsr:@deno/gfm@0.12.0" }, "fmt": { "useTabs": false, diff --git a/deno.lock b/deno.lock index f179502..3b352bd 100644 --- a/deno.lock +++ b/deno.lock @@ -7,6 +7,8 @@ "jsr:@01edu/signal-router@~0.1.6": "0.1.6", "jsr:@01edu/time@0.1": "0.1.0", "jsr:@01edu/types@~0.1.2": "0.1.2", + "jsr:@deno/gfm@0.12.0": "0.12.0", + "jsr:@denosaurs/emoji@~0.3.1": "0.3.1", "jsr:@std/assert@^1.0.15": "1.0.16", "jsr:@std/assert@^1.0.16": "1.0.16", "jsr:@std/async@^1.0.15": "1.0.15", @@ -32,9 +34,18 @@ "npm:@preact/signals@^2.5.1": "2.5.1_preact@10.28.0", "npm:@tailwindcss/vite@^4.1.17": "4.1.18_vite@7.3.0__picomatch@4.0.3", "npm:daisyui@^5.5.8": "5.5.14", + "npm:github-slugger@2": "2.0.0", + "npm:he@^1.2.0": "1.2.0", + "npm:katex@0.16": "0.16.45", "npm:lucide-preact@0.525": "0.525.0_preact@10.28.0", + "npm:marked-alert@^2.1.2": "2.1.2_marked@17.0.6", + "npm:marked-footnote@^1.4.0": "1.4.0_marked@17.0.6", + "npm:marked-gfm-heading-id@^4.1.3": "4.1.4_marked@17.0.6", + "npm:marked@^17.0.1": "17.0.6", "npm:preact@^10.27.2": "10.28.0", "npm:preact@^10.28.0": "10.28.0", + "npm:prismjs@^1.30.0": "1.30.0", + "npm:sanitize-html@^2.17.0": "2.17.2", "npm:tailwindcss@^4.1.17": "4.1.18", "npm:vite@^7.2.4": "7.3.0_picomatch@4.0.3", "npm:vite@^7.3.0": "7.3.0_picomatch@4.0.3" @@ -75,6 +86,24 @@ "@01edu/types@0.1.2": { "integrity": "58c6925af51586a33bb8a42a2c191c760044aec7f85fb00e8824781176d7b6e2" }, + "@deno/gfm@0.12.0": { + "integrity": "9b2d8f3e3d5673da5b2e8613d36cf38619ac5e3bdeb895d35472a16870fb147a", + "dependencies": [ + "jsr:@denosaurs/emoji", + "npm:github-slugger", + "npm:he", + "npm:katex", + "npm:marked", + "npm:marked-alert", + "npm:marked-footnote", + "npm:marked-gfm-heading-id", + "npm:prismjs", + "npm:sanitize-html" + ] + }, + "@denosaurs/emoji@0.3.1": { + "integrity": "b0aed5f55dec99e83da7c9637fe0a36d1d6252b7c99deaaa3fc5dea3fcf3da8b" + }, "@std/assert@1.0.16": { "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532", "dependencies": [ @@ -773,6 +802,9 @@ "caniuse-lite@1.0.30001760": { "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==" }, + "commander@8.3.0": { + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==" + }, "convert-source-map@2.0.0": { "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, @@ -798,6 +830,9 @@ "ms" ] }, + "deepmerge@4.3.1": { + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" + }, "detect-libc@2.1.2": { "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" }, @@ -806,7 +841,7 @@ "dependencies": [ "domelementtype", "domhandler", - "entities" + "entities@4.5.0" ] }, "domelementtype@2.3.0": { @@ -839,6 +874,9 @@ "entities@4.5.0": { "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" }, + "entities@7.0.1": { + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==" + }, "esbuild@0.27.2": { "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "optionalDependencies": [ @@ -875,6 +913,9 @@ "escalade@3.2.0": { "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" }, + "escape-string-regexp@4.0.0": { + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + }, "estree-walker@2.0.2": { "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" }, @@ -895,6 +936,9 @@ "gensync@1.0.0-beta.2": { "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" }, + "github-slugger@2.0.0": { + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==" + }, "graceful-fs@4.2.11": { "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, @@ -902,6 +946,18 @@ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "bin": true }, + "htmlparser2@10.1.0": { + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dependencies": [ + "domelementtype", + "domhandler", + "domutils", + "entities@7.0.1" + ] + }, + "is-plain-object@5.0.0": { + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==" + }, "jiti@2.6.1": { "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "bin": true @@ -917,6 +973,13 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "bin": true }, + "katex@0.16.45": { + "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", + "dependencies": [ + "commander" + ], + "bin": true + }, "kolorist@1.8.0": { "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==" }, @@ -1012,6 +1075,29 @@ "@jridgewell/sourcemap-codec" ] }, + "marked-alert@2.1.2_marked@17.0.6": { + "integrity": "sha512-EFNRZ08d8L/iEIPLTlQMDjvwIsj03gxWCczYTht6DCiHJIZhMk4NK5gtPY9UqAYb09eV5VGT+jD4lp396E0I+w==", + "dependencies": [ + "marked" + ] + }, + "marked-footnote@1.4.0_marked@17.0.6": { + "integrity": "sha512-fZTxAhI1TcLEs5UOjCfYfTHpyKGaWQevbxaGTEA68B51l7i87SctPFtHETYqPkEN0ka5opvy4Dy1l/yXVC+hmg==", + "dependencies": [ + "marked" + ] + }, + "marked-gfm-heading-id@4.1.4_marked@17.0.6": { + "integrity": "sha512-CspnvVfHSkb/znqdPS4jUR8HtCjq3M/DnrsJCrfLBLvdrgbemmoINKpeWKQYkBiXAoBGejw0cV7xzqrPdup3WA==", + "dependencies": [ + "github-slugger", + "marked" + ] + }, + "marked@17.0.6": { + "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==", + "bin": true + }, "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, @@ -1035,6 +1121,9 @@ "boolbase" ] }, + "parse-srcset@1.0.2": { + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" + }, "picocolors@1.1.1": { "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, @@ -1055,6 +1144,9 @@ "preact@10.28.0": { "integrity": "sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==" }, + "prismjs@1.30.0": { + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==" + }, "rollup@4.53.5": { "integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==", "dependencies": [ @@ -1087,6 +1179,17 @@ ], "bin": true }, + "sanitize-html@2.17.2": { + "integrity": "sha512-EnffJUl46VE9uvZ0XeWzObHLurClLlT12gsOk1cHyP2Ol1P0BnBnsXmShlBmWVJM+dKieQI68R0tsPY5m/B+Jg==", + "dependencies": [ + "deepmerge", + "escape-string-regexp", + "htmlparser2", + "is-plain-object", + "parse-srcset", + "postcss" + ] + }, "semver@6.3.1": { "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "bin": true @@ -1169,6 +1272,7 @@ "jsr:@01edu/api@~0.1.3", "jsr:@01edu/signal-router@~0.1.6", "jsr:@01edu/time@0.1", + "jsr:@deno/gfm@0.12.0", "jsr:@std/assert@^1.0.16", "jsr:@std/crypto@^1.0.5", "jsr:@std/encoding@^1.0.10", diff --git a/web/pages/DeploymentPage.tsx b/web/pages/DeploymentPage.tsx index 748747f..6cdbc67 100644 --- a/web/pages/DeploymentPage.tsx +++ b/web/pages/DeploymentPage.tsx @@ -24,6 +24,7 @@ import { RefreshCw, Save, Search, + Sparkles, Table, Timer, XCircle, @@ -38,6 +39,7 @@ import { import { computed, effect, Signal, untracked } from '@preact/signals' import { api, type ApiOutput } from '../lib/api.ts' import { QueryHistory } from '../components/QueryHistory.tsx' +import { DialogModal } from '../components/Dialog.tsx' import type { ComponentChildren } from 'preact' import { @@ -1482,6 +1484,75 @@ function buildExplainTree(rows: MetricExplain): ExplainNode[] { return roots } +// ─── Fix with AI components ────────────────────────────────────────────────── + +const fixQueryApi = api['POST/api/deployment/fix-query'].signal() +const analysisCache = new Signal(new Map()) + +// Fetch when opening dialog for an uncached metric +effect(() => { + const { expanded, dep, tab, dialog } = url.params + if (!dep || tab !== 'metrics' || dialog !== 'fix-with-ai') return + if (!expanded || analysisCache.value.has(expanded)) return + const sorted = sortedMetrics.value + const metric = sorted.find((m) => m.id === expanded) + if (!metric) return + fixQueryApi.fetch({ id: metric.id, deployment: dep, metric }) +}) + +// Populate cache using fetchingFor — not url.params.expanded which may have changed +effect(() => { + const data = fixQueryApi.data + if (!data) return + const cashe = analysisCache.peek() + + if (!cashe.has(data.id)) { + analysisCache.value = new Map(cashe).set( + data.id, + data.analysis, + ) + } +}) + +function AiAnalysisDialog() { + const expanded = url.params.expanded + const cached = expanded ? analysisCache.value.get(expanded) : undefined + return ( + +
+

+ + AI Query Optimization +

+ {!cached && fixQueryApi.pending && ( +
+ + Analyzing query… +
+ )} + {!cached && fixQueryApi.error && ( +
+ + {fixQueryApi.error.message} +
+ )} + {cached && ( +
+ )} +
+ + ) +} + // ─── Metrics sub-components ───────────────────────────────────────────────── function ExplainTreeNode( @@ -1613,6 +1684,15 @@ function MetricDetail() { {metric.status && } {metric.explain?.length > 0 && }
+ ) } @@ -1747,6 +1827,7 @@ function MetricsViewer() { {sorted.map((metric) => )} {sorted.length === 0 && !isPending && } + ) }