backlens 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/ai/fixes.js +43 -9
- package/ai/providers.js +6 -1
- package/analyze.js +21 -4
- package/package.json +1 -1
- package/register.cjs +111 -4
- package/server.js +72 -5
- package/ui.html +79 -6
package/README.md
CHANGED
|
@@ -47,7 +47,9 @@ backlens: 2 new issues. Fix them, or accept with --update-baseline.
|
|
|
47
47
|
|
|
48
48
|
## Missing indexes
|
|
49
49
|
|
|
50
|
-
For each distinct SELECT, backlens asks Postgres for its plan with `EXPLAIN (GENERIC_PLAN)` (Postgres 16+). Plain EXPLAIN never runs the query, and the generic plan needs no parameter values. When a table is read row by row while filtering on columns that no index starts with, the Issues tab names the table and columns and gives the `CREATE INDEX` to add. It checks whether an index exists, not how fast the query was, so it is right on a small dev database too.
|
|
50
|
+
For each distinct SELECT, backlens asks Postgres for its plan with `EXPLAIN (GENERIC_PLAN)` (Postgres 16+). Plain EXPLAIN never runs the query, and the generic plan needs no parameter values. When a table is read row by row while filtering on columns that no index starts with, the Issues tab names the table and columns and gives the `CREATE INDEX` to add. It checks whether an index exists, not how fast the query was, so it is right on a small dev database too. When the query also sorts (`ORDER BY … LIMIT`), the sort columns go after the filter columns, so Postgres reads rows already in order.
|
|
51
|
+
|
|
52
|
+
**Create on this database** builds the index on the database your app is connected to, through the app's own connection: backlens never sees a database password. It shows which database first, builds with `CONCURRENTLY` so writes are not blocked, checks that Postgres can use the index for the query, and **Undo** drops it again. Only an index on the columns backlens checked can be created this way, never arbitrary SQL. Use it on a development database, then add the SQL to a migration.
|
|
51
53
|
|
|
52
54
|
## Before and after
|
|
53
55
|
|
|
@@ -55,7 +57,7 @@ In the Compare tab, save a snapshot, change your code, restart, use the same pag
|
|
|
55
57
|
|
|
56
58
|
## Explain and fix, with proof
|
|
57
59
|
|
|
58
|
-
Every issue has **Explain** and **Suggest fix**. A fix goes: a preview of exactly what would be sent and to whom, the model's answer as a diff, **Apply and check** (
|
|
60
|
+
Every issue has **Explain** and **Suggest fix**. A fix goes: a preview of exactly what would be sent and to whom, the model's answer as a diff, **Apply and check** (with an exact copy of each file kept first, uncommitted changes included; Revert refuses to overwrite edits you made after the fix was applied), then backlens rebuilds if needed, restarts your app, replays the request that showed the issue and runs your tests. Only a fix that removes the issue and keeps the tests green is marked proven. Keep it, or revert to the exact files you had.
|
|
59
61
|
|
|
60
62
|
Who answers is set up in **AI settings**, all from the dashboard. The first-run setup asks too.
|
|
61
63
|
|
package/ai/fixes.js
CHANGED
|
@@ -14,8 +14,8 @@ import { promiseAll } from './templates.js';
|
|
|
14
14
|
/**
|
|
15
15
|
* Explain and Fix. A fix runs like a careful developer at a terminal, and every step is written to
|
|
16
16
|
* a live feed the dashboard shows: read the code, ask the model, check each edit fits the code
|
|
17
|
-
* (sending problems back, up to twice), apply
|
|
18
|
-
*
|
|
17
|
+
* (sending problems back, up to twice), apply (keeping an exact copy of each file as it was, uncommitted
|
|
18
|
+
* changes included), typecheck (sending type errors back, up to twice), rebuild if needed, restart, replay
|
|
19
19
|
* the request that showed the issue, run the tests. Only then is it called proven.
|
|
20
20
|
*/
|
|
21
21
|
|
|
@@ -139,7 +139,7 @@ export function createAI(deps) {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
const view = (job) => {
|
|
142
|
-
const { ctx, issue, backups, convo, abort, ...rest } = job;
|
|
142
|
+
const { ctx, issue, backups, convo, abort, afterTrace, written, ...rest } = job;
|
|
143
143
|
return { ...rest, now: Date.now(), issueTitle: issue.title, issueRoute: issue.route, spentTodayUsd: spentToday(), budgetUsd: settings.dailyBudgetUsd };
|
|
144
144
|
};
|
|
145
145
|
|
|
@@ -296,6 +296,14 @@ export function createAI(deps) {
|
|
|
296
296
|
}
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
function readSafe(file) {
|
|
300
|
+
try {
|
|
301
|
+
return readFileSync(file, 'utf8');
|
|
302
|
+
} catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
299
307
|
function restoreBackups(job) {
|
|
300
308
|
for (const b of job.backups || []) copyFileSync(b.backup, join(root, b.file));
|
|
301
309
|
}
|
|
@@ -387,6 +395,7 @@ export function createAI(deps) {
|
|
|
387
395
|
job.stage = ok.length ? 'proposed' : 'no-edits';
|
|
388
396
|
} catch (err) {
|
|
389
397
|
job.error = err.message;
|
|
398
|
+
job.errorCode = err.code || null;
|
|
390
399
|
job.stage = 'error';
|
|
391
400
|
}
|
|
392
401
|
})();
|
|
@@ -401,17 +410,17 @@ export function createAI(deps) {
|
|
|
401
410
|
return null; // not a git repository: backups are the only undo
|
|
402
411
|
}
|
|
403
412
|
const out = execFileSync('git', ['-C', root, 'status', '--porcelain', '--', ...files.map((f) => join(root, f))], { encoding: 'utf8' });
|
|
404
|
-
|
|
413
|
+
// Porcelain lines are 'XY path': the status columns can start with a space, so no trim before slicing.
|
|
414
|
+
return out.split('\n').filter((l) => l.trim()).map((l) => l.slice(3));
|
|
405
415
|
}
|
|
406
416
|
|
|
407
417
|
function apply(id) {
|
|
408
418
|
const job = jobs.get(id);
|
|
409
419
|
if (!job || job.stage !== 'proposed') throw new Error('There is no suggestion ready to apply.');
|
|
410
420
|
const files = [...new Set(job.edits.map((e) => e.file))];
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
}
|
|
421
|
+
// Files with uncommitted changes (an earlier kept fix, work in progress) are fine: the backup
|
|
422
|
+
// is an exact copy of the file as it is now, and Revert puts that copy back.
|
|
423
|
+
const dirty = gitDirty(files) || [];
|
|
415
424
|
const backupDir = join(dir, 'ai', job.id);
|
|
416
425
|
job.backups = [];
|
|
417
426
|
for (const f of files) {
|
|
@@ -421,7 +430,13 @@ export function createAI(deps) {
|
|
|
421
430
|
job.backups.push({ file: f, backup: dest });
|
|
422
431
|
}
|
|
423
432
|
writeEdits(job.edits);
|
|
424
|
-
|
|
433
|
+
// What backlens wrote, so Revert can tell whether someone edited the file afterwards.
|
|
434
|
+
job.written = Object.fromEntries(files.map((f) => [f, readFileSync(join(root, f), 'utf8')]));
|
|
435
|
+
ev(job, 'apply', `Applied ${job.edits.length} edit${job.edits.length === 1 ? '' : 's'} to ${files.join(', ')}`, {
|
|
436
|
+
detail: dirty.length
|
|
437
|
+
? `a copy of ${dirty.join(', ')} as it was, uncommitted changes included, is kept until you keep or revert`
|
|
438
|
+
: 'backup kept until you keep or revert',
|
|
439
|
+
});
|
|
425
440
|
job.stage = 'verifying';
|
|
426
441
|
deps.pauseWatch(true);
|
|
427
442
|
verify(job)
|
|
@@ -468,6 +483,12 @@ export function createAI(deps) {
|
|
|
468
483
|
return { ok: r.code === 0, errors: r.out };
|
|
469
484
|
}
|
|
470
485
|
|
|
486
|
+
/** What a request cost: the numbers shown before and after a fix. */
|
|
487
|
+
function stats(trace) {
|
|
488
|
+
const count = (kind) => trace.spans.filter((x) => x.kind === kind).length;
|
|
489
|
+
return { status: trace.status, ms: trace.duration, queries: count('db'), redis: count('redis'), http: count('http') };
|
|
490
|
+
}
|
|
491
|
+
|
|
471
492
|
function replay(job, payload, port) {
|
|
472
493
|
return new Promise((resolve) => {
|
|
473
494
|
const timer = setTimeout(() => {
|
|
@@ -501,6 +522,7 @@ export function createAI(deps) {
|
|
|
501
522
|
const ok = await propose(job, `Asking again with the type errors (${round + 1} of ${TYPE_REPAIRS})`);
|
|
502
523
|
if (!ok.length) return finish(job, false, 'The model could not produce a change that typechecks.');
|
|
503
524
|
writeEdits(ok);
|
|
525
|
+
job.written = Object.fromEntries(job.backups.map((b) => [b.file, readSafe(join(root, b.file))]));
|
|
504
526
|
ev(job, 'apply', `Applied ${ok.length} corrected edit${ok.length === 1 ? '' : 's'}`);
|
|
505
527
|
}
|
|
506
528
|
|
|
@@ -532,6 +554,9 @@ export function createAI(deps) {
|
|
|
532
554
|
r.status = 'skip';
|
|
533
555
|
r.detail = settings.replay ? 'the request that showed this issue is no longer in memory; use the page again to record a fresh one' : 'replay is off in AI settings';
|
|
534
556
|
} else {
|
|
557
|
+
// The app has just restarted, so its first request pays for cold caches and new connections.
|
|
558
|
+
// A read is sent once to warm up, and the second one is measured.
|
|
559
|
+
if (!isWrite) await replay(job, payload, deps.appPort());
|
|
535
560
|
const trace = await replay(job, payload, deps.appPort());
|
|
536
561
|
if (!trace) {
|
|
537
562
|
r.status = 'fail';
|
|
@@ -544,6 +569,8 @@ export function createAI(deps) {
|
|
|
544
569
|
const before = original ? `${original.status} in ${original.duration} ms, ${original.spans.length} calls` : '';
|
|
545
570
|
r.status = !still && sameClass ? 'done' : 'fail';
|
|
546
571
|
r.detail = `before: ${before}\nafter: ${trace.status} in ${trace.duration} ms, ${trace.spans.length} calls${still ? ', issue still there' : ', issue gone'}`;
|
|
572
|
+
job.compare = { before: original ? stats(original) : null, after: stats(trace), issueGone: !still, warmed: !isWrite };
|
|
573
|
+
job.afterTrace = trace;
|
|
547
574
|
if (trace.status === 401 && original && original.status !== 401) r.detail += '\nThe recorded login may have expired: sign in again and use the page once more.';
|
|
548
575
|
if (r.status === 'fail') return finish(job, false, still ? 'The issue is still there after the change.' : `The request now answers ${trace.status}.`);
|
|
549
576
|
}
|
|
@@ -585,6 +612,8 @@ export function createAI(deps) {
|
|
|
585
612
|
append(historyFile, { at: new Date().toISOString(), type: job.issue.type, tier: job.route.tier || job.route.kind, model: job.route.model, verified: Boolean(job.verified), kept: true });
|
|
586
613
|
cleanup(job);
|
|
587
614
|
job.stage = 'kept';
|
|
615
|
+
// The measured request joins the normal list, and the issue leaves it until a request shows it again.
|
|
616
|
+
if (deps.markFixed) deps.markFixed(job.issue.key, job.afterTrace && job.compare && job.compare.issueGone ? job.afterTrace : null);
|
|
588
617
|
ev(job, 'done', 'Kept. Review it in your editor and commit when ready.');
|
|
589
618
|
return view(job);
|
|
590
619
|
}
|
|
@@ -592,6 +621,11 @@ export function createAI(deps) {
|
|
|
592
621
|
async function revert(id) {
|
|
593
622
|
const job = jobs.get(id);
|
|
594
623
|
if (!job || !job.backups || !job.backups.length) throw new Error('Nothing to revert.');
|
|
624
|
+
// Never overwrite edits made after the fix was applied.
|
|
625
|
+
const changed = job.backups.filter((b) => job.written && job.written[b.file] !== undefined && readSafe(join(root, b.file)) !== job.written[b.file]);
|
|
626
|
+
if (changed.length) {
|
|
627
|
+
throw new Error(`${changed.map((b) => b.file).join(', ')} changed after the fix was applied, so backlens did not overwrite it. The version from before the fix is at ${changed.map((b) => relative(root, b.backup)).join(', ')}.`);
|
|
628
|
+
}
|
|
595
629
|
restoreBackups(job);
|
|
596
630
|
append(historyFile, { at: new Date().toISOString(), type: job.issue.type, tier: job.route.tier || job.route.kind, model: job.route.model, verified: Boolean(job.verified), kept: false });
|
|
597
631
|
cleanup(job);
|
package/ai/providers.js
CHANGED
|
@@ -362,7 +362,12 @@ async function anthropic(task, messages, routeInfo) {
|
|
|
362
362
|
} catch (err) {
|
|
363
363
|
if (err instanceof Anthropic.AuthenticationError) throw new Error('Claude rejected the key. Paste a new one in AI settings.');
|
|
364
364
|
if (err instanceof Anthropic.RateLimitError) throw new Error('Claude rate limit reached. Wait a minute and try again.');
|
|
365
|
-
|
|
365
|
+
// The SDK's message carries the raw JSON body; show the API's own sentence instead.
|
|
366
|
+
const said = err?.error?.error?.message || err.message;
|
|
367
|
+
if (err instanceof Anthropic.APIError && /credit balance/i.test(said)) {
|
|
368
|
+
throw Object.assign(new Error('Your Anthropic account has no credit left, so nothing was sent or charged. Add credits in the Anthropic Console, then try again.'), { code: 'billing' });
|
|
369
|
+
}
|
|
370
|
+
if (err instanceof Anthropic.APIError) throw new Error(`Claude error ${err.status}: ${said}`);
|
|
366
371
|
throw err;
|
|
367
372
|
}
|
|
368
373
|
const cost = costUsd(routeInfo.tier, response.usage);
|
package/analyze.js
CHANGED
|
@@ -236,7 +236,11 @@ function percentile(values, p) {
|
|
|
236
236
|
}
|
|
237
237
|
|
|
238
238
|
/** Routes and issues across every trace kept. */
|
|
239
|
-
|
|
239
|
+
/**
|
|
240
|
+
* fixed: issue key -> when a fix for it was kept. Such an issue is hidden until a request after
|
|
241
|
+
* that moment shows it again.
|
|
242
|
+
*/
|
|
243
|
+
export function summarize(traces, known = [], advice = [], fixed = new Map()) {
|
|
240
244
|
const routes = new Map();
|
|
241
245
|
const issues = new Map();
|
|
242
246
|
for (const trace of traces) {
|
|
@@ -259,6 +263,7 @@ export function summarize(traces, known = [], advice = []) {
|
|
|
259
263
|
agg.seen++;
|
|
260
264
|
agg.costTotal += issue.cost;
|
|
261
265
|
agg.example = trace.id;
|
|
266
|
+
agg.lastAt = Math.max(agg.lastAt || 0, trace.at || 0);
|
|
262
267
|
}
|
|
263
268
|
}
|
|
264
269
|
// Index advice: one issue per route that ran the query. Its location skips wrapper lines that
|
|
@@ -281,11 +286,15 @@ export function summarize(traces, known = [], advice = []) {
|
|
|
281
286
|
const span = trace.spans.find((s) => s.kind === 'db' && normalizeSql(s.name) === sql);
|
|
282
287
|
if (!span) continue;
|
|
283
288
|
const route = routeOf(trace.method, trace.url, trace.template);
|
|
284
|
-
if (!perRoute.has(route)) perRoute.set(route, { seen: 0, example: trace.id, span });
|
|
289
|
+
if (!perRoute.has(route)) perRoute.set(route, { seen: 0, example: trace.id, span, lastAt: 0 });
|
|
285
290
|
perRoute.get(route).seen++;
|
|
291
|
+
perRoute.get(route).lastAt = Math.max(perRoute.get(route).lastAt, trace.at || 0);
|
|
286
292
|
}
|
|
287
293
|
const big = a.rows >= 1000;
|
|
288
|
-
|
|
294
|
+
// Filter columns first, then the ORDER BY columns, so rows come out already sorted.
|
|
295
|
+
const cols = [...a.columns.map((c) => ({ name: c, desc: false })), ...(Array.isArray(a.sort) ? a.sort : []).filter((c) => c && typeof c.name === 'string')];
|
|
296
|
+
const name = `${a.table}_${cols.map((c) => c.name).join('_')}_idx`.slice(0, 63);
|
|
297
|
+
const colText = cols.map((c) => `${c.name}${c.desc ? ' DESC' : ''}`).join(', ');
|
|
289
298
|
for (const [route, info] of perRoute) {
|
|
290
299
|
const key = `index|${route}|${sql}|${a.table}`;
|
|
291
300
|
routes.get(route)?.issueKeys.add(key);
|
|
@@ -293,7 +302,8 @@ export function summarize(traces, known = [], advice = []) {
|
|
|
293
302
|
type: 'index',
|
|
294
303
|
severity: big ? 'warn' : 'hint',
|
|
295
304
|
title: `No index for ${a.table} filtered by ${a.columns.join(', ')}`,
|
|
296
|
-
fix: `Postgres reads every row of ${a.table} (${a.rows.toLocaleString('en')} now${big ? '' : ', fine while small, slow as it grows'}). Add: CREATE INDEX CONCURRENTLY ${name} ON ${a.schema === 'public' ? '' : `${a.schema}.`}${a.table} (${
|
|
305
|
+
fix: `Postgres reads every row of ${a.table} (${a.rows.toLocaleString('en')} now${big ? '' : ', fine while small, slow as it grows'}). Add: CREATE INDEX CONCURRENTLY ${name} ON ${a.schema === 'public' ? '' : `${a.schema}.`}${a.table} (${colText});`,
|
|
306
|
+
index: { schema: a.schema || 'public', table: a.table, name, columns: cols, sql: a.sql },
|
|
297
307
|
sql,
|
|
298
308
|
sites: appFrame(info.span.frames) ? [at(appFrame(info.span.frames))] : [],
|
|
299
309
|
frames: [info.span.frames],
|
|
@@ -302,6 +312,7 @@ export function summarize(traces, known = [], advice = []) {
|
|
|
302
312
|
route,
|
|
303
313
|
seen: info.seen,
|
|
304
314
|
example: info.example,
|
|
315
|
+
lastAt: info.lastAt,
|
|
305
316
|
costTotal: 0,
|
|
306
317
|
});
|
|
307
318
|
}
|
|
@@ -311,6 +322,12 @@ export function summarize(traces, known = [], advice = []) {
|
|
|
311
322
|
for (const route of known) {
|
|
312
323
|
if (!routes.has(route)) routes.set(route, { route, calls: 0, durations: [], queries: 0, redis: 0, http: 0, errors: 0, issueKeys: new Set(), last: 0 });
|
|
313
324
|
}
|
|
325
|
+
for (const [key, issue] of issues) {
|
|
326
|
+
if (fixed.has(key) && fixed.get(key) >= (issue.lastAt || 0)) {
|
|
327
|
+
issues.delete(key);
|
|
328
|
+
routes.get(issue.route)?.issueKeys.delete(key);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
314
331
|
const routeList = [...routes.values()]
|
|
315
332
|
.map((r) => ({
|
|
316
333
|
route: r.route,
|
package/package.json
CHANGED
package/register.cjs
CHANGED
|
@@ -232,6 +232,10 @@ if (TARGET && !globalThis.__backlens) {
|
|
|
232
232
|
if (address && typeof address === 'object') post('/listening', { port: address.port, pid: process.pid, source: SOURCE });
|
|
233
233
|
} catch {}
|
|
234
234
|
}
|
|
235
|
+
if (event === 'request' && req && res && req.url === '/__backlens/index' && req.method === 'POST' && tokenOk(req.headers['x-backlens-token'])) {
|
|
236
|
+
handleCommand(req, res);
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
235
239
|
if (event !== 'request' || !req || !res || req.headers['x-backlens'] === 'skip' || INTERNAL.test(req.url || '')) return emit.apply(this, arguments);
|
|
236
240
|
const trace = {
|
|
237
241
|
id: randomUUID(),
|
|
@@ -304,13 +308,25 @@ if (TARGET && !globalThis.__backlens) {
|
|
|
304
308
|
return cols;
|
|
305
309
|
}
|
|
306
310
|
|
|
307
|
-
|
|
311
|
+
/** 'reviews.updated_at DESC' -> { table: 'reviews', name: 'updated_at', desc: true } */
|
|
312
|
+
function sortKey(key) {
|
|
313
|
+
const m = String(key).match(/^(?:"?(\w+)"?\.)?"?([a-z_]\w*)"?(?:\s+(ASC|DESC))?(?:\s+NULLS\s+(?:FIRST|LAST))?$/i);
|
|
314
|
+
return m ? { table: m[1] || null, name: m[2], desc: /desc/i.test(m[3] || '') } : null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// A sort above the scan (ORDER BY … LIMIT) is worth putting in the index after the filter
|
|
318
|
+
// columns: Postgres then reads rows already in order and stops at the limit.
|
|
319
|
+
function seqScans(node, out = [], sort = []) {
|
|
308
320
|
if (!node) return out;
|
|
321
|
+
if (/Sort$/.test(node['Node Type']) && Array.isArray(node['Sort Key'])) sort = node['Sort Key'].map(sortKey);
|
|
309
322
|
if (node['Node Type'] === 'Seq Scan' && node['Relation Name'] && node.Filter) {
|
|
310
323
|
const columns = columnsCompared(node.Filter);
|
|
311
|
-
|
|
324
|
+
const alias = node.Alias || node['Relation Name'];
|
|
325
|
+
const order = sort.every((k) => k && (!k.table || k.table === alias)) ? sort.filter((k) => !columns.includes(k.name)) : [];
|
|
326
|
+
if (columns.length) out.push({ table: node['Relation Name'], schema: node.Schema || 'public', columns, sort: order.map(({ name, desc }) => ({ name, desc })), filter: node.Filter });
|
|
312
327
|
}
|
|
313
|
-
|
|
328
|
+
const keep = /^(Sort|Incremental Sort|Limit|Gather|Gather Merge|Result)$/.test(node['Node Type']) ? sort : [];
|
|
329
|
+
for (const child of node.Plans || []) seqScans(child, out, keep);
|
|
314
330
|
return out;
|
|
315
331
|
}
|
|
316
332
|
|
|
@@ -336,10 +352,101 @@ if (TARGET && !globalThis.__backlens) {
|
|
|
336
352
|
if (!rows[0]) continue;
|
|
337
353
|
const leading = rows[0].leading || [];
|
|
338
354
|
if (scan.columns.some((col) => leading.includes(col))) continue;
|
|
339
|
-
post('/advice', { sql: text, table: scan.table, schema: scan.schema, columns: scan.columns, rows: Math.max(0, Number(rows[0].rows)) });
|
|
355
|
+
post('/advice', { sql: text, table: scan.table, schema: scan.schema, columns: scan.columns, sort: scan.sort, rows: Math.max(0, Number(rows[0].rows)) });
|
|
340
356
|
}
|
|
341
357
|
}
|
|
342
358
|
|
|
359
|
+
// ---- creating the suggested index, when the user asks in the dashboard ----------------
|
|
360
|
+
// The dashboard sends one internal request to the app, with its private token. Only an index
|
|
361
|
+
// on named columns can be created or dropped, built here from checked names, never from SQL
|
|
362
|
+
// text; the plan check only re-explains a statement this process already explained.
|
|
363
|
+
const IDENT = /^[a-z_][a-z0-9_$]{0,62}$/i;
|
|
364
|
+
const q = (name) => `"${name}"`;
|
|
365
|
+
|
|
366
|
+
function tokenOk(value) {
|
|
367
|
+
if (!TOKEN || typeof value !== 'string' || value.length !== TOKEN.length) return false;
|
|
368
|
+
return require('node:crypto').timingSafeEqual(Buffer.from(value), Buffer.from(TOKEN));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function indexCommand(body) {
|
|
372
|
+
if (!advisorPool) throw new Error('The app has not used Postgres yet. Use the page once, then try again.');
|
|
373
|
+
const pool = advisorPool;
|
|
374
|
+
if (body.action === 'info') {
|
|
375
|
+
const { rows } = await pool.query('select current_database() as database, inet_server_addr()::text as address, inet_server_port() as port');
|
|
376
|
+
const o = pool.options || {};
|
|
377
|
+
let host = o.host;
|
|
378
|
+
try {
|
|
379
|
+
if (!host && o.connectionString) host = new URL(o.connectionString).hostname;
|
|
380
|
+
} catch {}
|
|
381
|
+
return { database: rows[0].database, host: host || rows[0].address || 'local socket', port: rows[0].port };
|
|
382
|
+
}
|
|
383
|
+
const { schema, table, name } = body;
|
|
384
|
+
const columns = Array.isArray(body.columns) ? body.columns : [];
|
|
385
|
+
if (![schema, table, name].every((x) => typeof x === 'string' && IDENT.test(x)) || !columns.length || columns.length > 8 || !columns.every((c) => c && typeof c.name === 'string' && IDENT.test(c.name))) {
|
|
386
|
+
throw new Error('That is not an index backlens suggested.');
|
|
387
|
+
}
|
|
388
|
+
if (body.action === 'create') {
|
|
389
|
+
const cols = columns.map((c) => `${q(c.name)}${c.desc ? ' DESC' : ''}`).join(', ');
|
|
390
|
+
// CONCURRENTLY builds without blocking writes; it cannot run inside a transaction, so a
|
|
391
|
+
// plain pool query (its own connection, autocommit) is used.
|
|
392
|
+
await pool.query(`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${q(name)} ON ${q(schema)}.${q(table)} (${cols})`);
|
|
393
|
+
const { rows } = await pool.query(
|
|
394
|
+
`select i.indisvalid as valid from pg_class c join pg_index i on i.indexrelid = c.oid join pg_namespace n on n.oid = c.relnamespace where c.relname = $1 and n.nspname = $2`,
|
|
395
|
+
[name, schema],
|
|
396
|
+
);
|
|
397
|
+
if (!rows[0] || !rows[0].valid) throw new Error('Postgres did not finish building the index. Drop it and try again.');
|
|
398
|
+
// Can Postgres use it for the query that showed the issue? On a small table it still
|
|
399
|
+
// prefers reading every row, so the check asks with sequential scans turned off.
|
|
400
|
+
let usable = null;
|
|
401
|
+
if (typeof body.sql === 'string' && explained.has(body.sql)) {
|
|
402
|
+
const client = await pool.connect();
|
|
403
|
+
try {
|
|
404
|
+
await client.query('BEGIN');
|
|
405
|
+
await client.query('SET LOCAL enable_seqscan = off');
|
|
406
|
+
const prefix = /\$\d/.test(body.sql) ? 'EXPLAIN (GENERIC_PLAN, FORMAT JSON) ' : 'EXPLAIN (FORMAT JSON) ';
|
|
407
|
+
const plan = JSON.stringify((await client.query(prefix + body.sql)).rows[0]['QUERY PLAN']);
|
|
408
|
+
usable = plan.includes(`"Index Name":"${name}"`);
|
|
409
|
+
} catch {
|
|
410
|
+
usable = null;
|
|
411
|
+
} finally {
|
|
412
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
413
|
+
client.release();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return { created: true, usable };
|
|
417
|
+
}
|
|
418
|
+
if (body.action === 'drop') {
|
|
419
|
+
await pool.query(`DROP INDEX CONCURRENTLY IF EXISTS ${q(schema)}.${q(name)}`);
|
|
420
|
+
return { dropped: true };
|
|
421
|
+
}
|
|
422
|
+
throw new Error('Unknown action.');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function handleCommand(req, res) {
|
|
426
|
+
let raw = '';
|
|
427
|
+
req.on('data', (c) => {
|
|
428
|
+
raw += c;
|
|
429
|
+
if (raw.length > 64 * 1024) req.destroy();
|
|
430
|
+
});
|
|
431
|
+
req.on('end', () => {
|
|
432
|
+
let body = null;
|
|
433
|
+
try {
|
|
434
|
+
body = JSON.parse(raw);
|
|
435
|
+
} catch {}
|
|
436
|
+
const send = (status, payload) => {
|
|
437
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
438
|
+
res.end(JSON.stringify(payload));
|
|
439
|
+
};
|
|
440
|
+
if (!body) return send(400, { error: 'Bad request.' });
|
|
441
|
+
storage.exit(() =>
|
|
442
|
+
indexCommand(body).then(
|
|
443
|
+
(out) => send(200, out),
|
|
444
|
+
(err) => send(400, { error: String((err && err.message) || err) }),
|
|
445
|
+
),
|
|
446
|
+
);
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
343
450
|
function maybeAdvise(text) {
|
|
344
451
|
if (!advisorPool || advisorOff || explained.has(text) || explained.size >= MAX_EXPLAINS) return;
|
|
345
452
|
if (!/^\s*(select|with)\b/i.test(text) || text.includes(';') || /\bpg_(class|index|attribute|namespace)\b/.test(text)) return;
|
package/server.js
CHANGED
|
@@ -30,6 +30,35 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
|
|
|
30
30
|
let config = manualArgv ? { app: null, test: null, autoRestart: false } : loadConfig(root);
|
|
31
31
|
|
|
32
32
|
const traces = [];
|
|
33
|
+
// Issue key -> when its fix was kept (see summarize).
|
|
34
|
+
const fixedAt = new Map();
|
|
35
|
+
// Issue key -> the index created for it from the dashboard, so Undo still knows it.
|
|
36
|
+
const createdIndexes = new Map();
|
|
37
|
+
|
|
38
|
+
/** Sends one command to the recorder inside the app (see register.cjs). */
|
|
39
|
+
function appCommand(port, body) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const payload = JSON.stringify(body);
|
|
42
|
+
const r = http.request(
|
|
43
|
+
{ host: '127.0.0.1', port, method: 'POST', path: '/__backlens/index', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload), 'x-backlens-token': token } },
|
|
44
|
+
(res) => {
|
|
45
|
+
let raw = '';
|
|
46
|
+
res.on('data', (c) => (raw += c));
|
|
47
|
+
res.on('end', () => {
|
|
48
|
+
let out = null;
|
|
49
|
+
try {
|
|
50
|
+
out = JSON.parse(raw);
|
|
51
|
+
} catch {}
|
|
52
|
+
if (res.statusCode === 200 && out) resolve(out);
|
|
53
|
+
else reject(new Error((out && out.error) || 'The app did not answer. Is it running with backlens?'));
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
r.setTimeout(10 * 60 * 1000, () => r.destroy(new Error('Creating the index took over 10 minutes.')));
|
|
58
|
+
r.on('error', (err) => reject(new Error(`Could not reach the app: ${err.message}`)));
|
|
59
|
+
r.end(payload);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
33
62
|
let received = 0;
|
|
34
63
|
const known = new Map();
|
|
35
64
|
const advice = new Map();
|
|
@@ -207,15 +236,15 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
|
|
|
207
236
|
|
|
208
237
|
// From the dashboard.
|
|
209
238
|
if (req.method === 'GET' && url.pathname === '/api/state') {
|
|
210
|
-
const { routes, issues } = summarize(traces, [...known.keys()], [...advice.values()]);
|
|
239
|
+
const { routes, issues } = summarize(traces, [...known.keys()], [...advice.values()], fixedAt);
|
|
211
240
|
const recent = traces
|
|
212
241
|
.slice(-300)
|
|
213
242
|
.reverse()
|
|
214
|
-
.map((t) => ({ id: t.id, at: t.at, method: t.method, url: t.url, route: routeOf(t.method, t.url, t.template), status: t.status, duration: t.duration, spans: t.spans.length, aborted: Boolean(t.aborted) }));
|
|
243
|
+
.map((t) => ({ id: t.id, at: t.at, method: t.method, url: t.url, route: routeOf(t.method, t.url, t.template), status: t.status, duration: t.duration, spans: t.spans.length, aborted: Boolean(t.aborted), afterFix: Boolean(t.afterFix) }));
|
|
215
244
|
return json(res, 200, { routes, issues, recent, total: traces.length, received });
|
|
216
245
|
}
|
|
217
246
|
if (req.method === 'GET' && url.pathname === '/api/map') {
|
|
218
|
-
const { issues } = summarize(traces, [...known.keys()], [...advice.values()]);
|
|
247
|
+
const { issues } = summarize(traces, [...known.keys()], [...advice.values()], fixedAt);
|
|
219
248
|
return json(res, 200, { ...mapOf(traces, issues), advice: [...advice.values()].map((a) => ({ table: a.table, columns: a.columns })) });
|
|
220
249
|
}
|
|
221
250
|
if (req.method === 'GET' && url.pathname.startsWith('/api/trace/')) {
|
|
@@ -228,12 +257,13 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
|
|
|
228
257
|
traces.length = 0;
|
|
229
258
|
received = 0;
|
|
230
259
|
advice.clear();
|
|
260
|
+
fixedAt.clear();
|
|
231
261
|
return json(res, 200, { ok: true });
|
|
232
262
|
}
|
|
233
263
|
if (req.method === 'POST' && url.pathname === '/api/snapshot') {
|
|
234
264
|
const name = (url.searchParams.get('name') || '').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
|
235
265
|
if (!name) return json(res, 400, { error: 'Give the snapshot a name.' });
|
|
236
|
-
const { routes, issues } = summarize(traces, [...known.keys()], [...advice.values()]);
|
|
266
|
+
const { routes, issues } = summarize(traces, [...known.keys()], [...advice.values()], fixedAt);
|
|
237
267
|
mkdirSync(snapshotDir, { recursive: true });
|
|
238
268
|
writeFileSync(join(snapshotDir, `${name}.json`), `${JSON.stringify({ name, at: Date.now(), requests: traces.length, routes: routes.filter((r) => r.calls), issues: issues.length }, null, 2)}\n`);
|
|
239
269
|
return json(res, 200, { ok: true, name });
|
|
@@ -300,6 +330,34 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
|
|
|
300
330
|
tests.start(scriptCommand(pm, config.test), root);
|
|
301
331
|
return json(res, 200, { ok: true });
|
|
302
332
|
}
|
|
333
|
+
// Creating a suggested index on the database the app is using, through the app itself.
|
|
334
|
+
if (req.method === 'POST' && url.pathname.startsWith('/api/index/')) {
|
|
335
|
+
const action = url.pathname.slice('/api/index/'.length);
|
|
336
|
+
if (!['info', 'create', 'drop'].includes(action)) return json(res, 404, { error: 'not found' });
|
|
337
|
+
if (!app || !app.port) return json(res, 409, { error: 'The app is not running. Start it first.' });
|
|
338
|
+
const body = (await readJson(req)) || {};
|
|
339
|
+
let index = null;
|
|
340
|
+
if (action !== 'info') {
|
|
341
|
+
const { issues } = summarize(traces, [...known.keys()], [...advice.values()]);
|
|
342
|
+
const issue = issues.find((i) => i.key === body.key);
|
|
343
|
+
index = issue && issue.index ? issue.index : createdIndexes.get(body.key);
|
|
344
|
+
if (!index) return json(res, 404, { error: 'That suggestion is gone. Refresh the list.' });
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
const out = await appCommand(app.port, { action, ...(index || {}) });
|
|
348
|
+
if (action === 'create') {
|
|
349
|
+
createdIndexes.set(body.key, index);
|
|
350
|
+
fixedAt.set(body.key, Date.now());
|
|
351
|
+
}
|
|
352
|
+
if (action === 'drop') {
|
|
353
|
+
createdIndexes.delete(body.key);
|
|
354
|
+
fixedAt.delete(body.key);
|
|
355
|
+
}
|
|
356
|
+
return json(res, 200, { ...out, index });
|
|
357
|
+
} catch (err) {
|
|
358
|
+
return json(res, 400, { error: err.message });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
303
361
|
if (url.pathname.startsWith('/api/ai/')) {
|
|
304
362
|
try {
|
|
305
363
|
const body = req.method === 'POST' ? (await readJson(req)) || {} : {};
|
|
@@ -360,10 +418,19 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
|
|
|
360
418
|
pauseWatch: (on) => (watchPaused = on),
|
|
361
419
|
testsTail: () => tests.lines.slice(-14).map((l) => l.text).join('\n'),
|
|
362
420
|
getIssue: (key) => {
|
|
363
|
-
const { issues } = summarize(traces, [...known.keys()], [...advice.values()]);
|
|
421
|
+
const { issues } = summarize(traces, [...known.keys()], [...advice.values()], fixedAt);
|
|
364
422
|
return issues.find((i) => i.key === key) || (testRun.report ? testRun.report.issues.find((i) => i.key === key) : null);
|
|
365
423
|
},
|
|
366
424
|
getTrace: (id) => traces.find((t) => t.id === id) || testRun.traces.find((t) => t.id === id),
|
|
425
|
+
markFixed: (key, after) => {
|
|
426
|
+
fixedAt.set(key, Date.now());
|
|
427
|
+
if (after) {
|
|
428
|
+
const { replayOf, ...trace } = after;
|
|
429
|
+
traces.push({ ...trace, afterFix: true });
|
|
430
|
+
received++;
|
|
431
|
+
if (traces.length > KEEP) replays.delete(traces.shift().id);
|
|
432
|
+
}
|
|
433
|
+
},
|
|
367
434
|
getReplay: (id) => replays.get(id),
|
|
368
435
|
restartApp: async () => {
|
|
369
436
|
app.port = null;
|
package/ui.html
CHANGED
|
@@ -154,7 +154,15 @@
|
|
|
154
154
|
.opt-card:has(input:checked) { border-color: var(--accent); background: #ffffff05; }
|
|
155
155
|
.opt-card { cursor: default; } .opt-card > label { cursor: pointer; }
|
|
156
156
|
.opt-card a { color: var(--accent); }
|
|
157
|
-
.
|
|
157
|
+
a.btn { text-decoration: none; display: inline-block; }
|
|
158
|
+
.compare-card { border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; margin: 4px 0 14px; }
|
|
159
|
+
.compare-card h4 { margin: 0 0 8px; font-size: 13px; }
|
|
160
|
+
.compare-card table { width: auto; border-collapse: collapse; font: 13px var(--mono); }
|
|
161
|
+
.compare-card th { text-align: left; font-weight: 400; color: var(--sub); padding: 3px 18px 3px 0; }
|
|
162
|
+
.compare-card td { padding: 3px 10px 3px 0; }
|
|
163
|
+
.compare-card .arrow { color: var(--sub); }
|
|
164
|
+
.compare-card .better { color: #4ade80; } .compare-card .worse { color: #f87171; } .compare-card .same { color: var(--text); }
|
|
165
|
+
.compare-card .delta { font-size: 12px; }
|
|
158
166
|
.field input[type=password] { flex: 1; font: 13px var(--mono); color: var(--text); background: var(--bg); border: 1px solid var(--line-2); border-radius: 6px; padding: 6px 8px; }
|
|
159
167
|
.progress { height: 6px; border-radius: 3px; background: var(--raise); overflow: hidden; margin: 10px 0 0; }
|
|
160
168
|
.progress > div { height: 100%; background: var(--accent); transition: width .4s; }
|
|
@@ -363,7 +371,7 @@ function renderRequests() {
|
|
|
363
371
|
return `<h2>${routeFilter ? `<button class="ghost" type="button" id="unfilter">${esc(routeFilter)} ✕</button>` : `latest ${list.length} requests`}</h2><table>
|
|
364
372
|
<thead><tr><th>when</th><th>request</th><th class="n">status</th><th class="n">time</th><th class="n hide-sm">calls</th></tr></thead>
|
|
365
373
|
<tbody>${list.map((t) => `<tr data-trace="${esc(t.id)}" class="${openTrace && openTrace.trace.id === t.id ? 'sel' : ''}">
|
|
366
|
-
<td class="muted">${ago(t.at)}</td><td><span class="method">${esc(t.method)}</span><span class="route">${esc(t.url)}</span
|
|
374
|
+
<td class="muted">${ago(t.at)}</td><td><span class="method">${esc(t.method)}</span><span class="route">${esc(t.url)}</span>${t.afterFix ? ' <span class="chip live">after fix</span>' : ''}</td>
|
|
367
375
|
<td class="n ${statusClass(t.status)}">${t.aborted ? 'cancelled' : t.status}</td><td class="n">${ms(t.duration)}</td><td class="n hide-sm muted">${t.spans}</td></tr>`).join('')}</tbody></table>`;
|
|
368
376
|
}
|
|
369
377
|
|
|
@@ -727,9 +735,10 @@ function renderFix() {
|
|
|
727
735
|
<div class="kicker2">fix${j.attempt ? ', second attempt' : ''}</div><h2>${esc(j.issueTitle)}</h2><div class="meta">${esc(j.issueRoute || '')}</div>
|
|
728
736
|
<div class="chips" style="margin-top:10px"><span class="chip">${esc(r.label)}</span><span class="chip">${r.kind === 'anthropic' ? (j.costUsd ? `spent ${usd(j.costUsd)}` : `about ${usd(r.estimateUsd)}`) : 'free'}</span>${busy ? '<span class="chip live"><span class="spin"></span> working</span>' : ''}</div></div>`;
|
|
729
737
|
if (j.stage === 'template') {
|
|
730
|
-
html += `<ol class="feed"><li class="fi">${'<span class="dot ok">●</span>'}<div><div class="bubble">${esc(j.explanation)}</div></div></li></ol><div class="editor"><div class="editor-head"><span class="dots"><i></i><i></i><i></i></span>migration</div><div class="editor-body"><div class="cl add"><span class="ln">1</span><span class="mk">+</span><span class="cx">${hl(j.sql)}</span></div></div></div><p class="note">${esc(j.notes)}</p
|
|
731
|
-
|
|
732
|
-
|
|
738
|
+
html += `<ol class="feed"><li class="fi">${'<span class="dot ok">●</span>'}<div><div class="bubble">${esc(j.explanation)}</div></div></li></ol><div class="editor"><div class="editor-head"><span class="dots"><i></i><i></i><i></i></span>migration</div><div class="editor-body"><div class="cl add"><span class="ln">1</span><span class="mk">+</span><span class="cx">${hl(j.sql)}</span></div></div></div><p class="note">${esc(j.notes)}</p>${indexPanel(j)}`;
|
|
739
|
+
el.innerHTML = html;
|
|
740
|
+
if (!idx.db && !idx.loading) loadIndexInfo();
|
|
741
|
+
return;
|
|
733
742
|
}
|
|
734
743
|
if (j.stage === 'preview') {
|
|
735
744
|
html += `<section class="plan"><h4>Why this model</h4><ul class="reasons">${r.reasons.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>
|
|
@@ -740,6 +749,7 @@ function renderFix() {
|
|
|
740
749
|
el.innerHTML = html; return;
|
|
741
750
|
}
|
|
742
751
|
html += `<ol class="feed">${j.events.map(feedItem).join('')}${j.stage === 'error' ? `<li class="fi"><span class="dot bad">●</span><div class="verdict bad">${esc(j.error)}</div></li>` : ''}</ol>`;
|
|
752
|
+
html += compareCard(j);
|
|
743
753
|
const act = [];
|
|
744
754
|
if (j.stage === 'proposed') act.push('<button class="primary" type="button" id="fix-apply">Apply and check</button><button class="ghost" type="button" id="fix-close-2">Discard</button>');
|
|
745
755
|
if (j.stage === 'no-edits') act.push(`<span class="note">No change was applied.</span>${escalateBtn(j)}`);
|
|
@@ -747,14 +757,76 @@ function renderFix() {
|
|
|
747
757
|
if (j.stage === 'failed') act.push('<button class="primary" type="button" id="fix-revert">Revert</button><button class="ghost" type="button" id="fix-keep">Keep anyway</button>');
|
|
748
758
|
if (j.stage === 'unverified') act.push('<button class="ghost" type="button" id="fix-keep">Keep</button><button class="ghost" type="button" id="fix-revert">Revert</button>');
|
|
749
759
|
if (j.stage === 'reverted' || j.stage === 'error') act.push(escalateBtn(j));
|
|
750
|
-
if (j.stage === 'proposed') act.push('<span class="note">
|
|
760
|
+
if (j.stage === 'proposed') act.push('<span class="note">backlens keeps an exact copy of each file first, uncommitted changes included. Revert puts it back.</span>');
|
|
751
761
|
if (act.length || fixError) html += `<div class="fix-actions">${act.join('')}<span class="error" role="alert">${esc(fixError)}</span></div>`;
|
|
752
762
|
el.innerHTML = html;
|
|
753
763
|
el.querySelectorAll('[data-autoscroll]').forEach((t) => (t.scrollTop = t.scrollHeight));
|
|
754
764
|
el.scrollTop = atBottom || busy ? el.scrollHeight : keepScroll;
|
|
755
765
|
}
|
|
756
766
|
|
|
767
|
+
/** The same request before and after the change, side by side. */
|
|
768
|
+
function compareCard(j) {
|
|
769
|
+
const c = j.compare;
|
|
770
|
+
if (!c || !c.before || !['verified', 'failed', 'unverified', 'kept', 'reverted'].includes(j.stage)) return '';
|
|
771
|
+
const pct = (a, b) => (a ? Math.round(((b - a) / a) * 100) : 0);
|
|
772
|
+
const row = (label, a, b, fmt = (x) => x, lowerIsBetter = true) => {
|
|
773
|
+
const d = pct(a, b);
|
|
774
|
+
const tone = a === b ? 'same' : (b < a) === lowerIsBetter ? 'better' : 'worse';
|
|
775
|
+
return `<tr><th>${label}</th><td>${fmt(a)}</td><td class="arrow">→</td><td class="${tone}">${fmt(b)}</td><td class="${tone} delta">${a === b ? '' : `${d > 0 ? '+' : ''}${d}%`}</td></tr>`;
|
|
776
|
+
};
|
|
777
|
+
const kind = esc((j.issueTitle || '').split(':')[0]);
|
|
778
|
+
return `<section class="compare-card"><h4>Same request, before and after the change</h4><table>
|
|
779
|
+
${row('time', c.before.ms, c.after.ms, (x) => ms(x))}
|
|
780
|
+
${row('database queries', c.before.queries, c.after.queries)}
|
|
781
|
+
${c.before.redis || c.after.redis ? row('redis calls', c.before.redis, c.after.redis) : ''}
|
|
782
|
+
${c.before.http || c.after.http ? row('outgoing http', c.before.http, c.after.http) : ''}
|
|
783
|
+
<tr><th>status</th><td>${c.before.status}</td><td class="arrow">→</td><td class="${c.before.status === c.after.status ? 'same' : 'worse'}">${c.after.status}</td><td></td></tr>
|
|
784
|
+
<tr><th>issue</th><td>${kind || 'found'}</td><td class="arrow">→</td><td class="${c.issueGone ? 'better' : 'worse'}">${c.issueGone ? 'gone' : 'still there'}</td><td></td></tr>
|
|
785
|
+
</table><p class="note">One request each${c.warmed ? ', the after one measured once the restarted app was warm' : ''}. Query counts are exact; times vary a few ms from run to run.</p></section>`;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ---- creating a suggested index from the drawer ----------------------------------------
|
|
789
|
+
let idx = {};
|
|
790
|
+
async function loadIndexInfo() {
|
|
791
|
+
idx.loading = true;
|
|
792
|
+
try { idx.db = await aiPost('/api/index/info', {}); } catch (err) { idx.dbError = err.message; }
|
|
793
|
+
idx.loading = false;
|
|
794
|
+
renderFix();
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function indexPanel(j) {
|
|
798
|
+
const where = idx.db ? `<b>${esc(idx.db.database)}</b> on ${esc(idx.db.host)}${idx.db.port ? `:${idx.db.port}` : ''}` : idx.dbError ? '' : 'checking…';
|
|
799
|
+
const steps = [];
|
|
800
|
+
if (idx.result && idx.result.created) {
|
|
801
|
+
steps.push(`<li class="fi"><span class="dot ok">●</span><div><div class="fh"><b>Created</b> ${esc(idx.result.index.name)}</div><div class="sub2">on ${where}, built without blocking writes</div></div></li>`);
|
|
802
|
+
const u = idx.result.usable;
|
|
803
|
+
steps.push(`<li class="fi"><span class="dot ${u ? 'ok' : u === false ? 'bad' : 'skip'}">●</span><div><div class="fh"><b>Plan check</b></div><div class="sub2">${u ? 'Postgres can answer this query from the new index.' : u === false ? 'Postgres would still not use it for this query.' : 'Could not check the plan for this query.'}</div></div></li>`);
|
|
804
|
+
}
|
|
805
|
+
if (idx.dropped) steps.push(`<li class="fi"><span class="dot skip">●</span><div><div class="fh"><b>Dropped</b> the index again</div></div></li>`);
|
|
806
|
+
const actions = idx.result && idx.result.created
|
|
807
|
+
? `<button class="ghost" type="button" id="idx-drop" ${idx.busy ? 'disabled' : ''}>Undo (drop it)</button><button class="ghost" type="button" data-copy="${esc(j.sql)}">Copy SQL for a migration</button>`
|
|
808
|
+
: `<button class="primary" type="button" id="idx-create" ${idx.busy || !idx.db ? 'disabled' : ''}>${idx.busy ? 'Creating…' : 'Create on this database'}</button><button class="ghost" type="button" data-copy="${esc(j.sql)}">Copy SQL</button>`;
|
|
809
|
+
return `${steps.length ? `<ol class="feed">${steps.join('')}</ol>` : ''}
|
|
810
|
+
<p class="note">${idx.dbError ? esc(idx.dbError) : `Runs on the database your app is connected to right now: ${where}. Only use it on a development database.`}${idx.result && idx.result.created ? ' This changed only this database: add the SQL to a migration so other environments get it too.' : ''}</p>
|
|
811
|
+
<div class="fix-actions">${actions}<span class="error" role="alert">${esc(idx.error || '')}</span></div>`;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
document.addEventListener('click', async (e) => {
|
|
815
|
+
const create = e.target.closest('#idx-create');
|
|
816
|
+
const drop = e.target.closest('#idx-drop');
|
|
817
|
+
if (!create && !drop) return;
|
|
818
|
+
idx.busy = true; idx.error = ''; renderFix();
|
|
819
|
+
try {
|
|
820
|
+
if (create) { idx.result = await aiPost('/api/index/create', { key: fixJob.issueKey }); idx.dropped = false; }
|
|
821
|
+
else { await aiPost('/api/index/drop', { key: fixJob.issueKey }); idx.result = null; idx.dropped = true; }
|
|
822
|
+
lastTotal = -1; poll();
|
|
823
|
+
} catch (err) { idx.error = err.message; }
|
|
824
|
+
idx.busy = false;
|
|
825
|
+
renderFix();
|
|
826
|
+
});
|
|
827
|
+
|
|
757
828
|
function escalateBtn(j) {
|
|
829
|
+
if (j.errorCode === 'billing') return `<a class="btn primary" href="https://console.anthropic.com/settings/billing" target="_blank" rel="noopener">Add credits</a>`;
|
|
758
830
|
return j.attempt === 0 && j.route.kind === 'anthropic' ? `<button class="ghost" type="button" id="fix-escalate">Try a stronger model</button>` : '';
|
|
759
831
|
}
|
|
760
832
|
|
|
@@ -884,6 +956,7 @@ document.addEventListener('click', async (e) => {
|
|
|
884
956
|
const issue = issueFrom(fx.dataset.aiFix);
|
|
885
957
|
if (!issue) return;
|
|
886
958
|
fixError = '';
|
|
959
|
+
idx = {};
|
|
887
960
|
try { fixJob = await aiPost('/api/ai/fix', { key: issue.key }); }
|
|
888
961
|
catch (err) { fixJob = null; fixError = err.message; }
|
|
889
962
|
renderFix();
|