@bakery-framework/plugin-dashboard 2.0.0-alpha.6 → 2.0.0-alpha.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/plugin-dashboard",
3
- "version": "2.0.0-alpha.6",
3
+ "version": "2.0.0-alpha.7",
4
4
  "description": "Bakery dashboard plugin.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -33,9 +33,9 @@
33
33
  "!src/tests"
34
34
  ],
35
35
  "dependencies": {
36
- "@bakery-framework/core": "^2.0.0-alpha.6",
37
- "@bakery-framework/orm": "^2.0.0-alpha.6",
38
- "@bakery-framework/plugin-analytics": "^2.0.0-alpha.6"
36
+ "@bakery-framework/core": "^2.0.0-alpha.7",
37
+ "@bakery-framework/orm": "^2.0.0-alpha.7",
38
+ "@bakery-framework/plugin-analytics": "^2.0.0-alpha.7"
39
39
  },
40
40
  "engines": {
41
41
  "bun": ">=1.3.14"
@@ -509,6 +509,14 @@ export function drawSparkline(
509
509
  )
510
510
  }
511
511
  })
512
+
513
+ // Last, so the marker sits above the fill rather than under it.
514
+ const hovered = resolveHoverPoint(canvasId, dataPoints, {
515
+ left: rect.left,
516
+ width,
517
+ height,
518
+ })
519
+ if (hovered) drawHoverMarker(ctx, hovered, height, colorStart)
512
520
  }
513
521
 
514
522
  interface SparklineHoverState {
@@ -519,6 +527,91 @@ interface SparklineHoverState {
519
527
 
520
528
  export const sparklineHoverStates: Record<string, SparklineHoverState> = {}
521
529
 
530
+ export interface HoverPoint {
531
+ index: number
532
+ value: number
533
+ /** Where the point sits on the canvas, in CSS pixels within its box. */
534
+ x: number
535
+ y: number
536
+ }
537
+
538
+ /**
539
+ * Which sample the pointer is over, and where that sample is drawn.
540
+ *
541
+ * Shared by the two things that must agree about it: the marker painted on the
542
+ * canvas and the tooltip positioned over the card. This arithmetic — the 50px
543
+ * reserved for the axis labels, the 24 and 12 of vertical padding, and the
544
+ * `L - M` offset for a series shorter than the window — used to live only in
545
+ * the tooltip. Copying it into the draw path would have worked exactly until
546
+ * one copy was adjusted, at which point the dot and its label would point at
547
+ * different samples and look like a rounding bug.
548
+ */
549
+ export function resolveHoverPoint(
550
+ canvasId: string,
551
+ data: number[],
552
+ rect: { left: number; width: number; height: number },
553
+ ): HoverPoint | null {
554
+ const state = sparklineHoverStates[canvasId]
555
+ if (!state?.visible || data.length === 0) return null
556
+
557
+ const { min, max, range } = getSparklineScale(data)
558
+ const graphWidth = Math.max(rect.width - 50, 1)
559
+ const graphHeight = Math.max(rect.height - 24, 1)
560
+ const localX = Math.min(Math.max(state.clientX - rect.left, 0), graphWidth)
561
+
562
+ const L = getTimescaleLimit(activeTimescale)
563
+ const M = data.length
564
+ const j = L === 1 ? 0 : Math.round((localX / graphWidth) * (L - 1))
565
+ const index = j - (L - M)
566
+ if (index < 0 || index >= M) return null
567
+
568
+ const value = data[index]
569
+ if (value === null || value === undefined || Number.isNaN(value)) return null
570
+
571
+ const safeValue = Math.max(min, Math.min(value, max))
572
+ return {
573
+ index,
574
+ value,
575
+ x: L === 1 ? 0 : (j / (L - 1)) * graphWidth,
576
+ y: rect.height - 12 - ((safeValue - min) / range) * graphHeight,
577
+ }
578
+ }
579
+
580
+ /**
581
+ * The hover marker: a guide line down the chart and a ringed dot on the sample.
582
+ *
583
+ * The ring is drawn in the card's own background rather than left transparent,
584
+ * so the dot reads as sitting *on* the line instead of merging into it wherever
585
+ * the series is dense.
586
+ */
587
+ function drawHoverMarker(
588
+ ctx: CanvasRenderingContext2D,
589
+ point: HoverPoint,
590
+ height: number,
591
+ color: string,
592
+ ) {
593
+ ctx.save()
594
+
595
+ ctx.beginPath()
596
+ ctx.moveTo(point.x, 8)
597
+ ctx.lineTo(point.x, height - 10)
598
+ ctx.strokeStyle = 'rgba(148, 163, 184, 0.35)'
599
+ ctx.lineWidth = 1
600
+ ctx.setLineDash([3, 3])
601
+ ctx.stroke()
602
+ ctx.setLineDash([])
603
+
604
+ ctx.beginPath()
605
+ ctx.arc(point.x, point.y, 4.5, 0, Math.PI * 2)
606
+ ctx.fillStyle = color
607
+ ctx.fill()
608
+ ctx.lineWidth = 2
609
+ ctx.strokeStyle = 'rgba(15, 17, 21, 0.9)'
610
+ ctx.stroke()
611
+
612
+ ctx.restore()
613
+ }
614
+
522
615
  function getSparklineScale(dataPoints: number[]) {
523
616
  const validPoints = dataPoints.filter(
524
617
  p =>
@@ -619,34 +712,17 @@ export function updateSparklineTooltip(config: Metric) {
619
712
  const rect = canvas.getBoundingClientRect()
620
713
  const chartCard = canvas.closest('.chart-card') as HTMLElement | null
621
714
  const chartRect = chartCard?.getBoundingClientRect() || rect
622
- const { min, max, range } = getSparklineScale(data)
623
- const graphWidth = Math.max(rect.width - 50, 1)
624
- const graphHeight = Math.max(rect.height - 24, 1)
625
- const localX = Math.min(Math.max(state.clientX - rect.left, 0), graphWidth)
626
-
627
- const L = getTimescaleLimit(activeTimescale)
628
- const M = data.length
629
- const j = L === 1 ? 0 : Math.round((localX / graphWidth) * (L - 1))
630
- const index = j - (L - M)
631
715
 
632
- if (index < 0 || index >= M) {
716
+ const point = resolveHoverPoint(config.canvas, data, rect)
717
+ if (!point) {
633
718
  tooltip.classList.remove('visible')
634
719
  return
635
720
  }
636
721
 
637
- const value = data[index]
638
- if (value === null || value === undefined || Number.isNaN(value)) {
639
- tooltip.classList.remove('visible')
640
- return
641
- }
642
- const safeValue = Math.max(min, Math.min(value, max))
643
- const pointX = L === 1 ? 0 : (j / (L - 1)) * graphWidth
644
- const pointY = rect.height - 12 - ((safeValue - min) / range) * graphHeight
645
-
646
- tooltip.textContent = `${formatSparklineTooltipValue(value, config.unit)} (${formatSparklineAge(index, data.length)})`
647
- tooltip.dataset.placement = pointY < 28 ? 'below' : 'above'
648
- tooltip.style.left = `${rect.left - chartRect.left + pointX}px`
649
- tooltip.style.top = `${rect.top - chartRect.top + pointY}px`
722
+ tooltip.textContent = `${formatSparklineTooltipValue(point.value, config.unit)} (${formatSparklineAge(point.index, data.length)})`
723
+ tooltip.dataset.placement = point.y < 28 ? 'below' : 'above'
724
+ tooltip.style.left = `${rect.left - chartRect.left + point.x}px`
725
+ tooltip.style.top = `${rect.top - chartRect.top + point.y}px`
650
726
  tooltip.classList.add('visible')
651
727
  }
652
728
 
@@ -677,12 +753,18 @@ export function bindSparklineTooltips() {
677
753
  state.clientX = event.clientX
678
754
  state.clientY = event.clientY
679
755
  updateSparklineTooltip(config)
756
+ // The marker is painted *into* the canvas, so it only moves when the
757
+ // canvas is repainted. Without this it would lag the pointer by up to a
758
+ // second — the polling redraw's interval — and read as a stuck dot.
759
+ drawSparkline(config.canvas, config.history, config.stroke, config.fill)
680
760
  })
681
761
 
682
762
  canvas.addEventListener('pointerleave', () => {
683
763
  state.visible = false
684
764
  const tooltip = ensureSparklineTooltip(canvas)
685
765
  if (tooltip) tooltip.classList.remove('visible')
766
+ // Repaint to clear the marker, for the same reason.
767
+ drawSparkline(config.canvas, config.history, config.stroke, config.fill)
686
768
  })
687
769
  }
688
770
 
@@ -8,6 +8,11 @@
8
8
  * access level per caller instead, so the console points at it rather than
9
9
  * shipping a second, weaker copy.
10
10
  *
11
+ * Rendered **only when nothing serves `/_db`**. With the explorer mounted the
12
+ * nav entry links straight to it and this panel never appears — a tab whose
13
+ * whole content is "go there" is a click of ceremony in front of going there.
14
+ * Without it, this is what explains where the editor went.
15
+ *
11
16
  * Deliberately static: no fetch, no client module, nothing to escape. It is
12
17
  * plain markup so that a tab which now only links somewhere cannot grow a
13
18
  * data path back by accident.
package/src/setup.ts CHANGED
@@ -26,7 +26,6 @@ import {
26
26
  // the tree, allow-listed by name in `tests/conventions.test.ts`.
27
27
  import { setupAnalytics } from '@bakery-framework/plugin-analytics/setup'
28
28
  import { isAnalyticsAuthorized } from '@bakery-framework/plugin-analytics/stats'
29
- import { handleSchema, handleTableData } from './endpoints/database'
30
29
  import {
31
30
  handleDeleteSession,
32
31
  handleGetSessions,
@@ -241,8 +240,6 @@ const dashboardRoutes = {
241
240
  '/api/_dashboard/sessions': (_req, url) => handleGetSessions(url),
242
241
  'POST /api/_dashboard/sessions/delete': req => handleDeleteSession(req),
243
242
  'POST /api/_dashboard/sessions/update': req => handleUpdateSession(req),
244
- '/api/_dashboard/schema': () => handleSchema(),
245
- '/api/_dashboard/table-data': (_req, url) => handleTableData(url),
246
243
  } satisfies PluginRouteTable
247
244
 
248
245
  const dispatchDashboardRoute = routeTable(dashboardRoutes)
package/src/shell.tsx CHANGED
@@ -1,3 +1,6 @@
1
+ import { getFrameworkVersion } from '@bakery-framework/core'
2
+ import { Bakery } from '@bakery-framework/core/core/bakery'
3
+ import { Try } from '@bakery-framework/core/utils/common'
1
4
  import { renderDatabaseBrowser } from './components/DBBrowser'
2
5
  import { renderLogsPanel } from './components/LogsPanel'
3
6
  import { renderSessionsPanel } from './components/SessionsPanel'
@@ -10,25 +13,89 @@ import { renderTopPagesPanel } from './components/TopPagesPanel'
10
13
  * Keeping that contract lets the chrome be replaced without touching the ~3k
11
14
  * lines of panel client code.
12
15
  */
13
- const NAV = [
14
- {
15
- group: 'Observability',
16
- items: [
17
- { id: 'stats', label: 'Overview' },
18
- { id: 'top-pages', label: 'Traffic' },
19
- { id: 'logs', label: 'Logs' },
20
- ],
21
- },
22
- {
23
- group: 'Data',
24
- items: [
25
- { id: 'database', label: 'Database' },
26
- { id: 'sessions', label: 'Sessions' },
27
- ],
28
- },
29
- ]
30
-
31
- function NavItem({ id, label }: { id: string; label: string }) {
16
+ /**
17
+ * A path nothing should claim, used as the control below.
18
+ */
19
+ const NOT_A_ROUTE = '/__bakery_probe_no_handler_serves_this__'
20
+
21
+ /**
22
+ * Is anything serving `/_db`?
23
+ *
24
+ * Asked **behaviourally**, never by importing db-explorer: a plugin-to-plugin
25
+ * import is a package-graph edge, and `tests/conventions.test.ts` allows
26
+ * exactly one of those (this package → analytics). It is also the better
27
+ * question — the console wants to know whether that path leads somewhere, not
28
+ * which package put it there, so an application serving its own explorer at
29
+ * `/_db` gets the link too.
30
+ *
31
+ * **The control probe is what makes this work.** `StaticHandler.canHandle()`
32
+ * returns `true` unconditionally — it is the priority-0 fallback and claims
33
+ * every path — so "does some handler claim `/_db`" is always yes. A handler
34
+ * that claims `/_db` *and declines a path nobody serves* is claiming a
35
+ * namespace rather than catching everything.
36
+ *
37
+ * `canHandle` signatures vary across handlers and some read the request, so a
38
+ * throw here means "not the one we are looking for" rather than an error.
39
+ */
40
+ function explorerIsMounted(): boolean {
41
+ for (const handler of Bakery.handlers.fetch.list()) {
42
+ const claims = (path: string) =>
43
+ Try.return(() => (handler as any).canHandle?.(path) === true, false)
44
+ if (claims('/_db') && !claims(NOT_A_ROUTE)) return true
45
+ }
46
+ return false
47
+ }
48
+
49
+ interface NavEntry {
50
+ id: string
51
+ label: string
52
+ /** Present when this entry leaves the console rather than switching a tab. */
53
+ href?: string
54
+ }
55
+
56
+ function navSections(explorerMounted: boolean): {
57
+ group: string
58
+ items: NavEntry[]
59
+ }[] {
60
+ return [
61
+ {
62
+ group: 'Observability',
63
+ items: [
64
+ { id: 'stats', label: 'Overview' },
65
+ { id: 'top-pages', label: 'Traffic' },
66
+ { id: 'logs', label: 'Logs' },
67
+ ],
68
+ },
69
+ {
70
+ group: 'Data',
71
+ items: [
72
+ // The console does not browse the database any more, so Database is a
73
+ // way *out* of it when the explorer is mounted — a link, not a tab.
74
+ // Without it the entry stays a tab showing the panel that explains
75
+ // where the editor went and how to get it back; an entry that silently
76
+ // navigates to a 404 would be worse than either.
77
+ explorerMounted
78
+ ? { id: 'database', label: 'Database', href: '/_db' }
79
+ : { id: 'database', label: 'Database' },
80
+ { id: 'sessions', label: 'Sessions' },
81
+ ],
82
+ },
83
+ ]
84
+ }
85
+
86
+ function NavItem({ id, label, href }: NavEntry) {
87
+ if (href) {
88
+ return (
89
+ <a class="tab-btn" href={href}>
90
+ <span class="nav-dot"></span>
91
+ <span>{label}</span>
92
+ <span class="nav-external" aria-hidden="true">
93
+
94
+ </span>
95
+ </a>
96
+ )
97
+ }
98
+
32
99
  return (
33
100
  <button
34
101
  type="button"
@@ -41,6 +108,9 @@ function NavItem({ id, label }: { id: string; label: string }) {
41
108
  }
42
109
 
43
110
  export default function Dashboard() {
111
+ const explorerMounted = explorerIsMounted()
112
+ const NAV = navSections(explorerMounted)
113
+
44
114
  return (
45
115
  <html lang="en">
46
116
  <head>
@@ -62,14 +132,20 @@ export default function Dashboard() {
62
132
  <nav class="rail-group">
63
133
  <div class="rail-group-label">{section.group}</div>
64
134
  {section.items.map(item => (
65
- <NavItem id={item.id} label={item.label} />
135
+ <NavItem id={item.id} label={item.label} href={item.href} />
66
136
  ))}
67
137
  </nav>
68
138
  ))}
69
139
 
70
140
  <div class="rail-foot">
71
141
  <span>Bakery</span>
72
- <span id="rail-version">v3</span>
142
+ {/* Was the literal `v3`, which was never any version of anything
143
+ — nothing filled the id, and the framework was on 1.x when it
144
+ was written. `getFrameworkVersion()` reads core's own
145
+ manifest, which is the number this label claims to be; the
146
+ app's version is a different question and `BAKERY_VERSION`
147
+ answers that one despite its name. */}
148
+ <span id="rail-version">v{getFrameworkVersion()}</span>
73
149
  </div>
74
150
  </aside>
75
151
 
@@ -125,7 +201,7 @@ export default function Dashboard() {
125
201
  {renderStatsPanel()}
126
202
  {renderTopPagesPanel()}
127
203
  {renderSessionsPanel()}
128
- {renderDatabaseBrowser()}
204
+ {explorerMounted ? null : renderDatabaseBrowser()}
129
205
  {renderLogsPanel()}
130
206
  </main>
131
207
  </div>
@@ -1,47 +0,0 @@
1
- import type { JsonResponseData } from '@bakery-framework/core/utils/common'
2
- import { Try } from '@bakery-framework/core/utils/common'
3
- import { response } from '@bakery-framework/core/utils/http'
4
- import { connection } from '@bakery-framework/orm/connection'
5
-
6
- /**
7
- * What is left of the console's database surface: two read-only endpoints.
8
- *
9
- * The grid editor and the SQL prompt that used to live here are retired —
10
- * `@bakery-framework/plugin-db-explorer` does the same work with an access
11
- * model instead of an environment flag, and the console's Database tab is now
12
- * a link to it. Gone with them: `handleQuery`, `handleExecuteAction`, the
13
- * statement classifier in `sql-classify.ts`, and `DASHBOARD_ALLOW_WRITES`.
14
- *
15
- * The flag is not deprecated, it is *absent*. Nothing here reads it, so
16
- * setting it has no effect at all — which is the honest state for a console
17
- * that can no longer write.
18
- */
19
-
20
- export async function handleSchema(): Promise<JsonResponseData<unknown>> {
21
- return await Try.return(
22
- async () => response.json.success('success', await connection.getSchema()),
23
- () => response.json.error(500, 'Failed to retrieve schema details'),
24
- )
25
- }
26
-
27
- export async function handleTableData(
28
- url: URL,
29
- ): Promise<JsonResponseData<unknown>> {
30
- const tableName = url.searchParams.get('tableName')
31
- if (!tableName || !/^[a-zA-Z0-9_]+$/.test(tableName))
32
- return response.json.error(400, 'Invalid table name')
33
-
34
- return await Try.return(
35
- async () => {
36
- const data = await connection.getData(tableName, {
37
- page: parseInt(url.searchParams.get('page') || '1', 10),
38
- pageSize: parseInt(url.searchParams.get('pageSize') || '50', 10),
39
- sortBy: url.searchParams.get('sortBy'),
40
- sortOrder: url.searchParams.get('sortOrder') || 'ASC',
41
- filters: JSON.parse(url.searchParams.get('filters') || '{}'),
42
- })
43
- return response.json.success('success', data)
44
- },
45
- (error: any) => response.json.error(400, error.message),
46
- )
47
- }