turbine-orm 0.91.0-next.e72e9b4 → 0.92.0

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/dist/cli/index.js CHANGED
@@ -2,23 +2,23 @@
2
2
  /**
3
3
  * turbine-orm CLI
4
4
  *
5
- * Commands:
6
- * turbine init , Initialize a Turbine project
7
- * turbine generate | pull , Introspect database and generate TypeScript types
8
- * turbine migrate-from-prisma - Parse a schema.prisma and emit a Prisma->Turbine name map + report
9
- * turbine push - Apply schema-builder definitions to database (--schema-file, destructive ops gated)
10
- * turbine migrate create <name> - Create a new SQL migration file (--auto | --from-diff | --recipe <name>)
11
- * turbine migrate up , Apply pending migrations
12
- * turbine migrate deploy , Apply pending migrations without prompts
13
- * turbine migrate down , Rollback last migration
14
- * turbine migrate status , Show migration status
15
- * turbine seed , Run seed file
16
- * turbine status , Show schema summary
17
- * turbine doctor - Index + cached-plan triage (--fix, --json, --no-concurrently, --unused, --audit, --no-plan-divergence, --allow-pooler)
18
- * turbine studio : Launch local read-only web UI (--demo for a seeded sample DB)
19
- * turbine mcp , Start read-only MCP server over JSON-RPC stdio
20
- * turbine observe , Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
21
- * turbine skill - Install the agent query skill (--print, --agents, --dir <path>)
5
+ * Commands (each has its own `--help`, which is the full flag list):
6
+ * turbine init Initialize a Turbine project
7
+ * turbine generate | pull Introspect the database and generate TypeScript types
8
+ * turbine migrate-from-prisma Parse a schema.prisma and emit a Prisma->Turbine name map + report
9
+ * turbine push Apply defineSchema() definitions to the database (destructive ops gated)
10
+ * turbine migrate create <name> Create a SQL migration file (--auto | --from-diff | --recipe <name>)
11
+ * turbine migrate up Apply pending migrations
12
+ * turbine migrate deploy Apply pending migrations without prompts
13
+ * turbine migrate down Roll back the last migration
14
+ * turbine migrate status Show migration status
15
+ * turbine seed Run the seed file
16
+ * turbine status Show a schema summary
17
+ * turbine doctor Index + cached-plan triage
18
+ * turbine studio Launch the local read-only web UI (--demo for a seeded sample DB)
19
+ * turbine mcp Start the read-only MCP server over JSON-RPC stdio
20
+ * turbine observe Launch the metrics dashboard (--metrics-url or TURBINE_OBSERVE_URL)
21
+ * turbine skill Install the agent query skill
22
22
  *
23
23
  * Usage:
24
24
  * DATABASE_URL=postgres://... npx turbine generate
@@ -43,7 +43,7 @@ import { closestName } from '../query/utils.js';
43
43
  import { snakeToCamel } from '../schema.js';
44
44
  import { DestructivePushRefusal, driftWarnings, schemaDiff, schemaPush } from '../schema-sql.js';
45
45
  import { applyCodeFirstColumnTags, extractCodeFirstColumnTags } from './code-first-tags.js';
46
- import { configTemplate, connectionStringHasPassword, DEFAULT_INIT_SEED_FILE, findConfigFile, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
46
+ import { configTemplate, connectionStringHasPassword, DEFAULT_INIT_SEED_FILE, findConfigFile, firstNonEmpty, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
47
47
  import { DESTRUCTIVE_KIND_LABEL } from './destructive.js';
48
48
  import { errorText } from './error-text.js';
49
49
  import { canResolveTsx, getTsLoaderError, needsTsLoader, registerTsLoader } from './loader.js';
@@ -2907,6 +2907,7 @@ async function cmdMigrateUp(args, config) {
2907
2907
  allowDestructive: args.allowDestructive,
2908
2908
  onNoTransaction,
2909
2909
  schema: config.schema,
2910
+ adapter: config.adapter,
2910
2911
  });
2911
2912
  }
2912
2913
  catch (err) {
@@ -2925,6 +2926,7 @@ async function cmdMigrateUp(args, config) {
2925
2926
  allowDestructive: true,
2926
2927
  onNoTransaction,
2927
2928
  schema: config.schema,
2929
+ adapter: config.adapter,
2928
2930
  });
2929
2931
  }
2930
2932
  if (result.applied.length > 0) {
@@ -3087,6 +3089,7 @@ async function cmdMigrateDeploy(args, config) {
3087
3089
  const result = await migrateDeploy(url, config.migrationsDir, {
3088
3090
  allowDrift: args.allowDrift,
3089
3091
  schema: config.schema,
3092
+ adapter: config.adapter,
3090
3093
  lockWaitMs: args.wait !== undefined ? args.wait * 1000 : undefined,
3091
3094
  onLockWait: () => {
3092
3095
  runSpinner.stop();
@@ -3198,6 +3201,7 @@ async function cmdMigrateDown(args, config) {
3198
3201
  allowDestructive: args.allowDestructive,
3199
3202
  allowDrift: args.allowDrift,
3200
3203
  schema: config.schema,
3204
+ adapter: config.adapter,
3201
3205
  });
3202
3206
  }
3203
3207
  catch (err) {
@@ -3215,6 +3219,7 @@ async function cmdMigrateDown(args, config) {
3215
3219
  allowDestructive: true,
3216
3220
  allowDrift: args.allowDrift,
3217
3221
  schema: config.schema,
3222
+ adapter: config.adapter,
3218
3223
  });
3219
3224
  }
3220
3225
  if (result.rolledBack.length > 0) {
@@ -4570,12 +4575,12 @@ async function cmdMcp(_args, config) {
4570
4575
  // ---------------------------------------------------------------------------
4571
4576
  async function cmdObserve(args) {
4572
4577
  banner();
4573
- const url = process.env.TURBINE_OBSERVE_URL;
4578
+ const url = firstNonEmpty(args.metricsUrl, process.env.TURBINE_OBSERVE_URL);
4574
4579
  if (!url) {
4575
- error('TURBINE_OBSERVE_URL environment variable is required for the observe command.');
4580
+ error('The observe command needs the metrics database: pass --metrics-url or set TURBINE_OBSERVE_URL.');
4576
4581
  errorLine();
4577
- errorLine(`${dim('Set it to the Postgres connection string where metrics are stored.')}`);
4578
- errorLine(`${dim('Example:')} ${cyan('TURBINE_OBSERVE_URL=postgres://... npx turbine observe')}`);
4582
+ errorLine(`${dim('Point it at the Postgres database where metrics are stored.')}`);
4583
+ errorLine(`${dim('Example:')} ${cyan('npx turbine observe --metrics-url postgres://...')}`);
4579
4584
  errorLine();
4580
4585
  process.exit(1);
4581
4586
  }
@@ -4891,7 +4896,7 @@ function showDoctorHelp() {
4891
4896
  console.log(` ${cyan('--allow-pooler')} Run against a connection pooler anyway ${dim('(refused by default)')}`);
4892
4897
  newline();
4893
4898
  console.log(` ${dim('doctor refuses a transaction-pooling endpoint (a')} ${cyan('-pooler')} ${dim('or')} ${cyan('pgbouncer')}`);
4894
- console.log(` ${dim('host, or port 6543). Point it at the direct endpoint instead: a pooler')}`);
4899
+ console.log(` ${dim('host, or port 6543 or 6432). Point it at the direct endpoint instead: a pooler')}`);
4895
4900
  console.log(` ${dim('shares one server backend between clients, so neither the session state')}`);
4896
4901
  console.log(` ${dim('doctor needs nor the session-scoped views it cites belong to it.')}`);
4897
4902
  newline();
@@ -4949,9 +4954,10 @@ function showMcpHelp() {
4949
4954
  console.log(` ${bold('Usage:')}`);
4950
4955
  console.log(` npx turbine mcp ${dim('[options]')}`);
4951
4956
  newline();
4952
- console.log(` Speaks newline-delimited JSON-RPC 2.0 on stdin/stdout and exposes ten`);
4953
- console.log(` read-only tools: schema, relation graph, join paths, migration status,`);
4954
- console.log(` doctor, EXPLAIN, table stats, sample rows, and error lookup.`);
4957
+ console.log(` Speaks newline-delimited JSON-RPC 2.0 on stdin/stdout and exposes eleven`);
4958
+ console.log(` read-only tools: schema overview, table detail, relation graph, join paths,`);
4959
+ console.log(` migration status, doctor, EXPLAIN, query compile, table stats, sample rows`);
4960
+ console.log(` and error lookup.`);
4955
4961
  newline();
4956
4962
  console.log(` ${bold('Options:')}`);
4957
4963
  console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
@@ -4965,16 +4971,17 @@ function showObserveHelp() {
4965
4971
  console.log(` ${bold('turbine observe')}, Query metrics dashboard`);
4966
4972
  newline();
4967
4973
  console.log(` ${bold('Usage:')}`);
4968
- console.log(` TURBINE_OBSERVE_URL=postgres://... npx turbine observe ${dim('[options]')}`);
4974
+ console.log(` npx turbine observe --metrics-url postgres://... ${dim('[options]')}`);
4969
4975
  newline();
4970
4976
  console.log(` Reads the ${cyan('_turbine_metrics')} table written by ${cyan('db.$observe()')} and serves`);
4971
4977
  console.log(` per-minute count / avg / p50 / p95 / p99 / error aggregates. Read-only,`);
4972
4978
  console.log(` loopback-bound, behind a random per-session token, same model as Studio.`);
4973
4979
  newline();
4974
- console.log(` ${dim('The connection string comes from')} ${cyan('TURBINE_OBSERVE_URL')}${dim(', not')} ${cyan('DATABASE_URL')}${dim(':')}`);
4980
+ console.log(` ${dim('The connection string comes from')} ${cyan('--metrics-url')} ${dim('or')} ${cyan('TURBINE_OBSERVE_URL')}${dim(', not')} ${cyan('DATABASE_URL')}${dim(':')}`);
4975
4981
  console.log(` ${dim('metrics are meant to live in a different database from the one they measure.')}`);
4976
4982
  newline();
4977
4983
  console.log(` ${bold('Options:')}`);
4984
+ console.log(` ${cyan('--metrics-url')} ${dim('<url>')} Metrics database ${dim('(default: TURBINE_OBSERVE_URL)')}`);
4978
4985
  console.log(` ${cyan('--port')} ${dim('<n>')} HTTP port ${dim('(default: 4984)')}`);
4979
4986
  console.log(` ${cyan('--host')} ${dim('<addr>')} Bind address ${dim('(default: 127.0.0.1)')}`);
4980
4987
  console.log(` ${cyan('--no-open')} Don't auto-open the browser`);
@@ -1 +1 @@
1
- export declare const OBSERVE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"color-scheme\" content=\"dark\" />\n <title>Turbine Observe</title>\n <style>\n :root {\n --bg: #0a0a0b;\n --bg-elev: #111113;\n --bg-hover: #1a1a1d;\n --border: #26262b;\n --text: #e6e6ea;\n --text-dim: #8a8a93;\n --accent: #60a5fa;\n --green: #4ade80;\n --red: #f87171;\n --orange: #fb923c;\n --purple: #a78bfa;\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n --sans: system-ui, -apple-system, sans-serif;\n --radius: 6px;\n }\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; padding: 24px; }\n h1 { font-size: 20px; margin-bottom: 4px; }\n .subtitle { color: var(--text-dim); margin-bottom: 24px; }\n .controls { display: flex; gap: 8px; margin-bottom: 24px; }\n .controls button {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n color: var(--text); padding: 6px 12px; cursor: pointer; font-size: 13px;\n }\n .controls button.active { border-color: var(--accent); color: var(--accent); }\n .card {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n padding: 16px; margin-bottom: 16px;\n }\n .card h2 { font-size: 14px; color: var(--text-dim); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }\n table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }\n th { text-align: left; padding: 6px 8px; color: var(--text-dim); border-bottom: 1px solid var(--border); }\n td { padding: 6px 8px; border-bottom: 1px solid var(--border); }\n .num { text-align: right; }\n .error-rate { color: var(--red); }\n .low-error { color: var(--green); }\n svg { width: 100%; height: 200px; }\n .chart-line { fill: none; stroke-width: 1.5; }\n .line-avg { stroke: var(--accent); }\n .line-p95 { stroke: var(--orange); }\n .line-p99 { stroke: var(--red); }\n .legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--text-dim); }\n .legend span::before { content: ''; display: inline-block; width: 12px; height: 2px; margin-right: 4px; vertical-align: middle; }\n .legend .l-avg::before { background: var(--accent); }\n .legend .l-p95::before { background: var(--orange); }\n .legend .l-p99::before { background: var(--red); }\n .empty { color: var(--text-dim); text-align: center; padding: 40px; }\n </style>\n</head>\n<body>\n <h1>Turbine Observe</h1>\n <p class=\"subtitle\">Query performance metrics</p>\n <div class=\"controls\">\n <button data-range=\"1h\" class=\"active\">1h</button>\n <button data-range=\"6h\">6h</button>\n <button data-range=\"24h\">24h</button>\n <button data-range=\"7d\">7d</button>\n </div>\n <div class=\"card\" id=\"latency-card\">\n <h2>Latency over time</h2>\n <div id=\"chart\"></div>\n <div class=\"legend\">\n <span class=\"l-avg\">avg</span>\n <span class=\"l-p95\">p95</span>\n <span class=\"l-p99\">p99</span>\n </div>\n </div>\n <div class=\"card\" id=\"models-card\">\n <h2>Top models</h2>\n <div id=\"models-table\"></div>\n </div>\n <div class=\"card\" id=\"errors-card\">\n <h2>Error rates</h2>\n <div id=\"errors-table\"></div>\n </div>\n <script nonce=\"__CSP_NONCE__\">\n let currentRange = '1h';\n // Anchored on a cookie boundary so a decoy cookie whose name ends with this\n // one cannot shadow the real token.\n const token = document.cookie.match(/(?:^|;\\s*)turbine_observe_token=([a-f0-9]+)/)?.[1] || '';\n const headers = { 'x-turbine-token': token };\n\n document.querySelector('.controls').addEventListener('click', e => {\n if (e.target.tagName !== 'BUTTON') return;\n document.querySelectorAll('.controls button').forEach(b => b.classList.remove('active'));\n e.target.classList.add('active');\n currentRange = e.target.dataset.range;\n refresh();\n });\n\n async function fetchJson(path) {\n const res = await fetch(path, { headers });\n if (!res.ok) return null;\n return res.json();\n }\n\n function buildSvgPath(points, width, height, maxY) {\n if (points.length === 0) return '';\n const xStep = width / Math.max(points.length - 1, 1);\n return points.map((y, i) => {\n const px = i * xStep;\n const py = height - (y / maxY) * height;\n return (i === 0 ? 'M' : 'L') + px.toFixed(1) + ',' + py.toFixed(1);\n }).join(' ');\n }\n\n function renderChart(data) {\n const el = document.getElementById('chart');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n const width = 800; const height = 180;\n const allVals = data.flatMap(d => [d.avg_ms, d.p95_ms, d.p99_ms]);\n const maxY = Math.max(...allVals, 1) * 1.1;\n const avgPath = buildSvgPath(data.map(d => d.avg_ms), width, height, maxY);\n const p95Path = buildSvgPath(data.map(d => d.p95_ms), width, height, maxY);\n const p99Path = buildSvgPath(data.map(d => d.p99_ms), width, height, maxY);\n el.innerHTML = '<svg viewBox=\"0 0 ' + width + ' ' + height + '\" preserveAspectRatio=\"none\">'\n + '<path class=\"chart-line line-avg\" d=\"' + avgPath + '\"/>'\n + '<path class=\"chart-line line-p95\" d=\"' + p95Path + '\"/>'\n + '<path class=\"chart-line line-p99\" d=\"' + p99Path + '\"/>'\n + '</svg>';\n }\n\n function escapeHtml(s) {\n if (s == null) return '';\n return String(s)\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;');\n }\n\n // Numeric sink: counts are integer aggregates, but they are still values\n // arriving over the wire, so coerce rather than interpolating them raw.\n function num(v) {\n const n = Number(v);\n return Number.isFinite(n) ? String(n) : '0';\n }\n\n function renderModels(data) {\n const el = document.getElementById('models-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Count</th><th class=\"num\">Avg (ms)</th><th class=\"num\">P95 (ms)</th><th class=\"num\">P99 (ms)</th></tr></thead><tbody>';\n for (const row of data) {\n html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'\n + '<td class=\"num\">' + num(row.count) + '</td>'\n + '<td class=\"num\">' + row.avg_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p95_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p99_ms.toFixed(1) + '</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n function renderErrors(data) {\n const el = document.getElementById('errors-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No errors</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Total</th><th class=\"num\">Errors</th><th class=\"num\">Rate</th></tr></thead><tbody>';\n for (const row of data) {\n const rate = row.count > 0 ? (row.error_count / row.count * 100).toFixed(1) : '0.0';\n const cls = parseFloat(rate) > 5 ? 'error-rate' : 'low-error';\n html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'\n + '<td class=\"num\">' + num(row.count) + '</td>'\n + '<td class=\"num\">' + num(row.error_count) + '</td>'\n + '<td class=\"num ' + cls + '\">' + rate + '%</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n async function refresh() {\n const [latency, models] = await Promise.all([\n fetchJson('/api/latency?range=' + currentRange),\n fetchJson('/api/models?range=' + currentRange),\n ]);\n renderChart(latency);\n renderModels(models);\n // Derive errors from models data\n const withErrors = (models || []).filter(m => m.error_count > 0);\n renderErrors(withErrors);\n }\n\n refresh();\n setInterval(refresh, 60000);\n </script>\n</body>\n</html>";
1
+ export declare const OBSERVE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"color-scheme\" content=\"dark\" />\n <title>Turbine Observe</title>\n <style>\n :root {\n --bg: #0a0a0b;\n --bg-elev: #111113;\n --bg-hover: #1a1a1d;\n --border: #26262b;\n --text: #e6e6ea;\n --text-dim: #8a8a93;\n --accent: #6f9bf7;\n --green: #4ade80;\n --red: #f87171;\n --orange: #fb923c;\n --purple: #a78bfa;\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n --sans: system-ui, -apple-system, sans-serif;\n --radius: 6px;\n }\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; padding: 24px; }\n h1 { font-size: 20px; margin-bottom: 4px; }\n .subtitle { color: var(--text-dim); margin-bottom: 24px; }\n .controls { display: flex; gap: 8px; margin-bottom: 24px; }\n .controls button {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n color: var(--text); padding: 6px 12px; cursor: pointer; font-size: 13px;\n }\n .controls button.active { border-color: var(--accent); color: var(--accent); }\n .card {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n padding: 16px; margin-bottom: 16px;\n }\n .card h2 { font-size: 14px; color: var(--text-dim); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }\n table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }\n th { text-align: left; padding: 6px 8px; color: var(--text-dim); border-bottom: 1px solid var(--border); }\n td { padding: 6px 8px; border-bottom: 1px solid var(--border); }\n .num { text-align: right; }\n .error-rate { color: var(--red); }\n .low-error { color: var(--green); }\n svg { width: 100%; height: 200px; }\n .chart-line { fill: none; stroke-width: 1.5; }\n .line-avg { stroke: var(--accent); }\n .line-p95 { stroke: var(--orange); }\n .line-p99 { stroke: var(--red); }\n .legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--text-dim); }\n .legend span::before { content: ''; display: inline-block; width: 12px; height: 2px; margin-right: 4px; vertical-align: middle; }\n .legend .l-avg::before { background: var(--accent); }\n .legend .l-p95::before { background: var(--orange); }\n .legend .l-p99::before { background: var(--red); }\n .empty { color: var(--text-dim); text-align: center; padding: 40px; }\n </style>\n</head>\n<body>\n <h1>Turbine Observe</h1>\n <p class=\"subtitle\">Query performance metrics</p>\n <div class=\"controls\">\n <button data-range=\"1h\" class=\"active\">1h</button>\n <button data-range=\"6h\">6h</button>\n <button data-range=\"24h\">24h</button>\n <button data-range=\"7d\">7d</button>\n </div>\n <div class=\"card\" id=\"latency-card\">\n <h2>Latency over time</h2>\n <div id=\"chart\"></div>\n <div class=\"legend\">\n <span class=\"l-avg\">avg</span>\n <span class=\"l-p95\">p95</span>\n <span class=\"l-p99\">p99</span>\n </div>\n </div>\n <div class=\"card\" id=\"models-card\">\n <h2>Top models</h2>\n <div id=\"models-table\"></div>\n </div>\n <div class=\"card\" id=\"errors-card\">\n <h2>Error rates</h2>\n <div id=\"errors-table\"></div>\n </div>\n <script nonce=\"__CSP_NONCE__\">\n let currentRange = '1h';\n // Anchored on a cookie boundary so a decoy cookie whose name ends with this\n // one cannot shadow the real token.\n const token = document.cookie.match(/(?:^|;\\s*)turbine_observe_token=([a-f0-9]+)/)?.[1] || '';\n const headers = { 'x-turbine-token': token };\n\n document.querySelector('.controls').addEventListener('click', e => {\n if (e.target.tagName !== 'BUTTON') return;\n document.querySelectorAll('.controls button').forEach(b => b.classList.remove('active'));\n e.target.classList.add('active');\n currentRange = e.target.dataset.range;\n refresh();\n });\n\n async function fetchJson(path) {\n const res = await fetch(path, { headers });\n if (!res.ok) return null;\n return res.json();\n }\n\n function buildSvgPath(points, width, height, maxY) {\n if (points.length === 0) return '';\n const xStep = width / Math.max(points.length - 1, 1);\n return points.map((y, i) => {\n const px = i * xStep;\n const py = height - (y / maxY) * height;\n return (i === 0 ? 'M' : 'L') + px.toFixed(1) + ',' + py.toFixed(1);\n }).join(' ');\n }\n\n function renderChart(data) {\n const el = document.getElementById('chart');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n const width = 800; const height = 180;\n const allVals = data.flatMap(d => [d.avg_ms, d.p95_ms, d.p99_ms]);\n const maxY = Math.max(...allVals, 1) * 1.1;\n const avgPath = buildSvgPath(data.map(d => d.avg_ms), width, height, maxY);\n const p95Path = buildSvgPath(data.map(d => d.p95_ms), width, height, maxY);\n const p99Path = buildSvgPath(data.map(d => d.p99_ms), width, height, maxY);\n el.innerHTML = '<svg viewBox=\"0 0 ' + width + ' ' + height + '\" preserveAspectRatio=\"none\">'\n + '<path class=\"chart-line line-avg\" d=\"' + avgPath + '\"/>'\n + '<path class=\"chart-line line-p95\" d=\"' + p95Path + '\"/>'\n + '<path class=\"chart-line line-p99\" d=\"' + p99Path + '\"/>'\n + '</svg>';\n }\n\n function escapeHtml(s) {\n if (s == null) return '';\n return String(s)\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;');\n }\n\n // Numeric sink: counts are integer aggregates, but they are still values\n // arriving over the wire, so coerce rather than interpolating them raw.\n function num(v) {\n const n = Number(v);\n return Number.isFinite(n) ? String(n) : '0';\n }\n\n function renderModels(data) {\n const el = document.getElementById('models-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Count</th><th class=\"num\">Avg (ms)</th><th class=\"num\">P95 (ms)</th><th class=\"num\">P99 (ms)</th></tr></thead><tbody>';\n for (const row of data) {\n html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'\n + '<td class=\"num\">' + num(row.count) + '</td>'\n + '<td class=\"num\">' + row.avg_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p95_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p99_ms.toFixed(1) + '</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n function renderErrors(data) {\n const el = document.getElementById('errors-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No errors</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Total</th><th class=\"num\">Errors</th><th class=\"num\">Rate</th></tr></thead><tbody>';\n for (const row of data) {\n const rate = row.count > 0 ? (row.error_count / row.count * 100).toFixed(1) : '0.0';\n const cls = parseFloat(rate) > 5 ? 'error-rate' : 'low-error';\n html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'\n + '<td class=\"num\">' + num(row.count) + '</td>'\n + '<td class=\"num\">' + num(row.error_count) + '</td>'\n + '<td class=\"num ' + cls + '\">' + rate + '%</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n async function refresh() {\n const [latency, models] = await Promise.all([\n fetchJson('/api/latency?range=' + currentRange),\n fetchJson('/api/models?range=' + currentRange),\n ]);\n renderChart(latency);\n renderModels(models);\n // Derive errors from models data\n const withErrors = (models || []).filter(m => m.error_count > 0);\n renderErrors(withErrors);\n }\n\n refresh();\n setInterval(refresh, 60000);\n </script>\n</body>\n</html>";
@@ -15,7 +15,7 @@ export const OBSERVE_HTML = `<!doctype html>
15
15
  --border: #26262b;
16
16
  --text: #e6e6ea;
17
17
  --text-dim: #8a8a93;
18
- --accent: #60a5fa;
18
+ --accent: #6f9bf7;
19
19
  --green: #4ade80;
20
20
  --red: #f87171;
21
21
  --orange: #fb923c;