panels.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  1. /**
  2. * The panel registry — what the dashboard shows, in the order it shows it.
  3. *
  4. * Every panel is data in, chart config out, with no DOM anywhere in this file:
  5. * app.js owns the page, this owns the mapping from an API response to a chart.
  6. * Keeping them apart is what lets scripts/render-check.mjs drive the real panel
  7. * definitions in a real browser and compare what each one plotted against what
  8. * the API returned.
  9. *
  10. * A panel is:
  11. * id stable key, also the DOM id and the anchor in a bug report
  12. * title sentence case, at a readable size — never a tracked-out caps label
  13. * note the honest footnote: what the number actually counts
  14. * span grid columns out of 12
  15. * source (query) => API path; panels sharing a path share one fetch
  16. * kind 'stat' | 'funnel' | 'chart'
  17. * figure optional headline shown under the title (pie totals)
  18. * empty (data) => is there nothing to draw
  19. * table (data) => the WCAG-clean twin every chart owes the reader
  20. */
  21. import {
  22. CATEGORICAL,
  23. INDEX_HOVER,
  24. NEUTRAL,
  25. SURFACE,
  26. categoryScale,
  27. compact,
  28. number,
  29. paletteFor,
  30. percent,
  31. shortDay,
  32. valueScale,
  33. } from './theme.js';
  34. // ---------------------------------------------------------------------------
  35. // Sources
  36. // ---------------------------------------------------------------------------
  37. const summary = (q) => `/api/summary?${q}`;
  38. const activation = (q) => `/api/activation?${q}`;
  39. const retention = (q) => `/api/retention?${q}`;
  40. const series = (metric) => (q) => `/api/timeseries?metric=${metric}&${q}`;
  41. const breakdown =
  42. (dim, extra = '') =>
  43. (q) =>
  44. `/api/breakdown?dim=${dim}${extra}&${q}`;
  45. // ---------------------------------------------------------------------------
  46. // Chart builders
  47. // ---------------------------------------------------------------------------
  48. const allZero = (data) => data.datasets.every((ds) => ds.data.every((v) => !v));
  49. const noRows = (data) => data.labels.length === 0 || data.datasets[0].data.every((v) => !v);
  50. /** Alpha-suffixed hex for the ~10% area wash under a single-series line. */
  51. const wash = (hex) => `${hex}1a`;
  52. /**
  53. * A line per series over days. One axis, always — two measures of different
  54. * scale get two panels rather than a second y-axis, which would invent a
  55. * correlation the data does not have.
  56. */
  57. function lineChart(data, { unit = 'count' } = {}) {
  58. const dense = data.labels.length > 21;
  59. const isPercent = unit === 'percent';
  60. // A wash under a single line reads well — but not across gaps, where the fill
  61. // would colour in days the series has no value for. Days with no cohort at
  62. // all are exactly that case, so a gapped series goes unfilled.
  63. const gapped = data.datasets.some((ds) => ds.data.some((v) => v === null));
  64. const single = data.datasets.length === 1 && !gapped;
  65. return {
  66. type: 'line',
  67. data: {
  68. labels: data.labels.map(shortDay),
  69. datasets: data.datasets.map((ds, i) => {
  70. const colour = CATEGORICAL[i] ?? NEUTRAL;
  71. return {
  72. label: ds.label,
  73. data: ds.data,
  74. borderColor: colour,
  75. backgroundColor: single ? wash(colour) : colour,
  76. fill: single,
  77. // Dots on a 90-day line are noise; the index-mode tooltip is how you
  78. // read a value, and the table view is how you read all of them.
  79. pointRadius: dense ? 0 : 3,
  80. pointHoverRadius: 5,
  81. pointBackgroundColor: colour,
  82. // 2px surface ring, so a marker stays legible where lines cross.
  83. pointBorderColor: SURFACE,
  84. pointBorderWidth: 2,
  85. spanGaps: false,
  86. };
  87. }),
  88. },
  89. options: {
  90. interaction: INDEX_HOVER,
  91. plugins: {
  92. // A single series needs no legend box — the panel title names it.
  93. legend: { display: data.datasets.length > 1 },
  94. tooltip: {
  95. callbacks: {
  96. label: (ctx) =>
  97. `${ctx.dataset.label}: ${
  98. ctx.parsed.y === null ? 'no data' : isPercent ? `${ctx.parsed.y}%` : number(ctx.parsed.y)
  99. }`,
  100. },
  101. },
  102. },
  103. scales: {
  104. x: categoryScale(),
  105. y: valueScale(
  106. isPercent
  107. ? { max: 100, ticks: { color: undefined, padding: 8, callback: (v) => `${v}%` } }
  108. : {},
  109. ),
  110. },
  111. },
  112. };
  113. }
  114. /**
  115. * Bands stacked to the day's total, for an ordered split of one measure.
  116. *
  117. * Four separate lines is the wrong form here: same-hue ordinal steps crossing
  118. * each other read as scribble, and the question ("how is run length shifting?")
  119. * is part-to-whole, not four independent trends. Stacked, the band heights are
  120. * the mix and the outline is the total. The 2px surface-coloured border is the
  121. * gap between touching fills — white doing the separating, not a stroke.
  122. */
  123. function stackedAreaChart(data) {
  124. const colours = paletteFor(
  125. data.datasets.map((ds) => ds.label),
  126. 'ordinal',
  127. );
  128. const config = lineChart(data);
  129. config.data.datasets.forEach((ds, i) => {
  130. ds.backgroundColor = colours[i];
  131. ds.borderColor = SURFACE;
  132. ds.borderWidth = 2;
  133. ds.pointRadius = 0;
  134. ds.pointHoverRadius = 4;
  135. ds.pointBackgroundColor = colours[i];
  136. ds.pointBorderColor = SURFACE;
  137. ds.fill = true;
  138. });
  139. config.options.scales.y.stacked = true;
  140. // The swatch has to be the band's colour; the line is surface-coloured here.
  141. config.options.plugins.legend = {
  142. display: true,
  143. labels: { generateLabels: () => data.datasets.map((ds, i) => ({
  144. text: ds.label,
  145. fillStyle: colours[i],
  146. strokeStyle: colours[i],
  147. pointStyle: 'circle',
  148. datasetIndex: i,
  149. })) },
  150. };
  151. return config;
  152. }
  153. /**
  154. * Horizontal bars. `scale: 'ordinal'` is for categories whose order is their
  155. * meaning (run length, codebase size) and takes the one-hue ramp; nominal
  156. * categories all take slot 1, because colouring them by value would spend the
  157. * identity channel re-encoding what bar length already says.
  158. */
  159. function barChart(data, { scale = 'nominal' } = {}) {
  160. const colours =
  161. scale === 'ordinal'
  162. ? paletteFor(data.labels, 'ordinal')
  163. : data.labels.map((label) => (label === 'Other' ? NEUTRAL : CATEGORICAL[0]));
  164. return {
  165. type: 'bar',
  166. data: {
  167. labels: data.labels,
  168. datasets: [
  169. {
  170. label: data.datasets[0].label,
  171. data: data.datasets[0].data,
  172. backgroundColor: colours,
  173. maxBarThickness: 24,
  174. // Rounded at the data end, square at the baseline (Chart.js skips the
  175. // 'start' edge by default, which is the baseline on a horizontal bar).
  176. borderRadius: 4,
  177. },
  178. ],
  179. },
  180. options: {
  181. indexAxis: 'y',
  182. plugins: { legend: { display: false } },
  183. scales: {
  184. x: valueScale(),
  185. y: categoryScale({ ticks: { color: undefined, padding: 6, autoSkip: false } }),
  186. },
  187. },
  188. };
  189. }
  190. /** Part-to-whole at a glance. Capped at a handful of slices by the API's `limit`. */
  191. function pieChart(data, { scale = 'categorical' } = {}) {
  192. const total = data.datasets[0].data.reduce((n, v) => n + v, 0);
  193. return {
  194. type: 'pie',
  195. data: {
  196. labels: data.labels,
  197. datasets: [
  198. {
  199. label: data.datasets[0].label,
  200. data: data.datasets[0].data,
  201. backgroundColor: paletteFor(data.labels, scale === 'ordinal' ? 'ordinal' : 'categorical'),
  202. },
  203. ],
  204. },
  205. options: {
  206. plugins: {
  207. legend: { display: true },
  208. tooltip: {
  209. callbacks: {
  210. label: (ctx) =>
  211. `${ctx.label}: ${number(ctx.parsed)} (${total > 0 ? percent(ctx.parsed / total, 1) : '—'})`,
  212. },
  213. },
  214. },
  215. },
  216. };
  217. }
  218. // ---------------------------------------------------------------------------
  219. // Table twins
  220. // ---------------------------------------------------------------------------
  221. /** Days down the side, one column per series. */
  222. const seriesTable = (data) => ({
  223. columns: ['Day', ...data.datasets.map((ds) => ds.label)],
  224. rows: data.labels.map((day, i) => [
  225. day,
  226. ...data.datasets.map((ds) => (ds.data[i] === null ? '—' : number(ds.data[i]))),
  227. ]),
  228. });
  229. /** Both numbers, always — the panel plots one of them, the table shows both. */
  230. const breakdownTable = (data) => ({
  231. columns: [data.title, 'Events', 'Machine-days'],
  232. rows: data.rows.map((r) => [r.value, number(r.count), number(r.machines)]),
  233. });
  234. // ---------------------------------------------------------------------------
  235. // The panels
  236. // ---------------------------------------------------------------------------
  237. export const PANELS = [
  238. {
  239. id: 'production-users',
  240. title: 'Production users',
  241. note: 'Distinct machines active in the range, excluding CI runners.',
  242. span: 3,
  243. kind: 'stat',
  244. source: summary,
  245. stat: (d) => ({ value: compact(d.production_users), caption: `${number(d.active_machines)} including CI` }),
  246. table: (d) => ({
  247. columns: ['Measure', 'Machines'],
  248. rows: [
  249. ['Production users', number(d.production_users)],
  250. ['All active machines', number(d.active_machines)],
  251. ['First seen in range', number(d.new_machines)],
  252. ],
  253. }),
  254. },
  255. {
  256. id: 'installs',
  257. title: 'Installs',
  258. note: 'Install events, including upgrades and reinstalls.',
  259. span: 3,
  260. kind: 'stat',
  261. source: summary,
  262. stat: (d) => ({ value: compact(d.installs), caption: `${number(d.new_machines)} from machines never seen before` }),
  263. table: (d) => ({
  264. columns: ['Measure', 'Events'],
  265. rows: [
  266. ['Installs', number(d.installs)],
  267. ['New machines', number(d.new_machines)],
  268. ],
  269. }),
  270. },
  271. {
  272. id: 'uninstalls',
  273. title: 'Uninstalls',
  274. note: 'Uninstall events in the range.',
  275. span: 3,
  276. kind: 'stat',
  277. source: summary,
  278. stat: (d) => ({
  279. value: compact(d.uninstalls),
  280. caption: d.installs > 0 ? `${percent(d.uninstalls / d.installs)} of installs` : 'No installs in range',
  281. }),
  282. table: (d) => ({
  283. columns: ['Measure', 'Events'],
  284. rows: [
  285. ['Uninstalls', number(d.uninstalls)],
  286. ['Installs', number(d.installs)],
  287. ],
  288. }),
  289. },
  290. {
  291. id: 'indexing-runs',
  292. title: 'Indexing runs',
  293. note: 'Index events in the range, across every machine.',
  294. span: 3,
  295. kind: 'stat',
  296. source: summary,
  297. stat: (d) => ({ value: compact(d.index_runs), caption: `${compact(d.tool_calls)} tool and command calls` }),
  298. table: (d) => ({
  299. columns: ['Measure', 'Events'],
  300. rows: [
  301. ['Indexing runs', number(d.index_runs)],
  302. ['Tool and command calls', number(d.tool_calls)],
  303. ],
  304. }),
  305. },
  306. {
  307. id: 'activation-funnel',
  308. title: 'Install to first use',
  309. note: 'Machines first seen in the range that ran an index within 7 days.',
  310. span: 4,
  311. kind: 'funnel',
  312. source: activation,
  313. empty: (d) => d.installs === 0,
  314. funnel: (d) => ({
  315. stages: [
  316. { label: 'Installed', value: d.installs, share: 1 },
  317. {
  318. label: `Indexed within ${d.window_days} days`,
  319. value: d.activated,
  320. share: d.installs > 0 ? d.activated / d.installs : 0,
  321. },
  322. ],
  323. rate: d.rate,
  324. dropped: d.dropped,
  325. }),
  326. table: (d) => ({
  327. columns: ['Stage', 'Machines', 'Share'],
  328. rows: [
  329. ['Installed', number(d.installs), '100%'],
  330. [`Indexed within ${d.window_days} days`, number(d.activated), percent(d.rate)],
  331. ['Dropped off', number(d.dropped), percent(d.installs > 0 ? d.dropped / d.installs : null)],
  332. ],
  333. }),
  334. },
  335. {
  336. id: 'activation-rate',
  337. title: 'Conversion rate over time',
  338. note: 'By the day a machine was first seen. Recent days are still converting, so their rate only rises.',
  339. span: 8,
  340. kind: 'chart',
  341. source: activation,
  342. empty: (d) => d.installs === 0,
  343. chart: (d) => lineChart(d, { unit: 'percent' }),
  344. table: (d) => ({
  345. columns: ['Day', 'Installs', 'Indexed', 'Rate', 'Window elapsed'],
  346. rows: d.rows.map((r) => [
  347. r.day,
  348. number(r.installs),
  349. number(r.activated),
  350. percent(r.rate),
  351. r.complete ? 'Yes' : 'Not yet',
  352. ]),
  353. }),
  354. },
  355. {
  356. id: 'os',
  357. title: 'Users by operating system',
  358. note: 'Share of machine-days: a machine active on several days counts once per day.',
  359. span: 4,
  360. kind: 'chart',
  361. // Three hues plus a neutral "Other" — the point past which categorical
  362. // colours stop being reliably distinguishable under colour-vision deficiency.
  363. source: breakdown('os', '&limit=3'),
  364. empty: noRows,
  365. figure: (d) => `${compact(d.total)} machine-days`,
  366. chart: (d) => pieChart(d),
  367. table: breakdownTable,
  368. },
  369. {
  370. id: 'run-length',
  371. title: 'Session run length',
  372. note: 'Indexing runs by how long they took.',
  373. span: 4,
  374. kind: 'chart',
  375. source: breakdown('duration_bucket'),
  376. empty: noRows,
  377. figure: (d) => `${compact(d.total)} runs`,
  378. chart: (d) => pieChart(d, { scale: 'ordinal' }),
  379. table: breakdownTable,
  380. },
  381. {
  382. id: 'codebase-size',
  383. title: 'Codebase size',
  384. note: 'Files per indexed project.',
  385. span: 4,
  386. kind: 'chart',
  387. source: breakdown('file_count_bucket'),
  388. empty: noRows,
  389. chart: (d) => barChart(d, { scale: 'ordinal' }),
  390. table: breakdownTable,
  391. },
  392. {
  393. id: 'installs-uninstalls',
  394. title: 'Installs and uninstalls over time',
  395. note: 'Install and uninstall events per day.',
  396. span: 6,
  397. kind: 'chart',
  398. source: series('installs_uninstalls'),
  399. empty: allZero,
  400. chart: (d) => lineChart(d),
  401. table: seriesTable,
  402. },
  403. {
  404. id: 'new-installs',
  405. title: 'New installs over time',
  406. note: 'Machines seen for the first time, by day.',
  407. span: 6,
  408. kind: 'chart',
  409. source: series('new_installs'),
  410. empty: allZero,
  411. chart: (d) => lineChart(d),
  412. table: seriesTable,
  413. },
  414. {
  415. id: 'indexing-activity',
  416. title: 'Daily indexing activity',
  417. note: 'Indexing runs and the machines that ran them.',
  418. span: 6,
  419. kind: 'chart',
  420. source: series('indexing_activity'),
  421. empty: allZero,
  422. chart: (d) => lineChart(d),
  423. table: seriesTable,
  424. },
  425. {
  426. id: 'daily-production-users',
  427. title: 'Daily production users',
  428. note: 'Distinct machines active each day, excluding CI runners.',
  429. span: 6,
  430. kind: 'chart',
  431. source: series('production_users'),
  432. empty: allZero,
  433. chart: (d) => lineChart(d),
  434. table: seriesTable,
  435. },
  436. {
  437. id: 'run-length-over-time',
  438. title: 'Run length over time',
  439. note: 'Indexing runs per day, split by how long they took.',
  440. span: 6,
  441. kind: 'chart',
  442. source: series('duration_buckets'),
  443. empty: allZero,
  444. // Ordered buckets, so the bands take the one-hue ramp rather than four
  445. // unrelated hues: the reader sees "longer" in the colour.
  446. chart: stackedAreaChart,
  447. table: seriesTable,
  448. },
  449. {
  450. id: 'retention',
  451. title: 'Daily retention cohorts',
  452. note: 'Machines first seen in the range, and the share still active k days later.',
  453. span: 6,
  454. kind: 'chart',
  455. source: retention,
  456. empty: (d) => d.cohort === 0,
  457. figure: (d) => `${compact(d.cohort)} machines in cohort`,
  458. chart: (d) => lineChart(d, { unit: 'percent' }),
  459. table: (d) => ({
  460. columns: ['Day', 'Machines old enough', 'Still active', 'Rate'],
  461. rows: d.rows.map((r) => [
  462. `Day ${r.day}`,
  463. number(r.eligible),
  464. number(r.retained),
  465. percent(r.rate),
  466. ]),
  467. }),
  468. },
  469. {
  470. id: 'languages',
  471. title: 'Most-indexed programming languages',
  472. note: 'One count per indexing run that found the language; a mixed repo counts under each.',
  473. span: 6,
  474. kind: 'chart',
  475. source: breakdown('language'),
  476. empty: noRows,
  477. chart: (d) => barChart(d),
  478. table: breakdownTable,
  479. },
  480. {
  481. id: 'indexing-speed',
  482. title: 'Indexing speed',
  483. note: 'Indexing runs by duration bucket.',
  484. span: 6,
  485. kind: 'chart',
  486. source: breakdown('duration_bucket'),
  487. empty: noRows,
  488. chart: (d) => barChart(d, { scale: 'ordinal' }),
  489. table: breakdownTable,
  490. },
  491. {
  492. id: 'versions',
  493. title: 'Users by app version',
  494. note: 'Machine-days per version, newest first.',
  495. span: 6,
  496. kind: 'chart',
  497. source: breakdown('codegraph_version'),
  498. empty: noRows,
  499. chart: (d) => barChart(d),
  500. table: breakdownTable,
  501. },
  502. {
  503. id: 'targets',
  504. title: 'AI agent targets',
  505. note: 'Agents wired up at install time. One install can configure several.',
  506. span: 6,
  507. kind: 'chart',
  508. source: breakdown('target'),
  509. empty: noRows,
  510. chart: (d) => barChart(d),
  511. table: breakdownTable,
  512. },
  513. ];