@bakery-framework/plugin-dashboard 2.0.0-alpha.6 → 2.0.0-alpha.8
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 +4 -4
- package/src/client/parts/stats.ts +105 -23
- package/src/components/DBBrowser.tsx +5 -0
- package/src/setup.ts +0 -3
- package/src/shell.tsx +98 -22
- package/src/endpoints/database.ts +0 -47
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/plugin-dashboard",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.8",
|
|
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.
|
|
37
|
-
"@bakery-framework/orm": "^2.0.0-alpha.
|
|
38
|
-
"@bakery-framework/plugin-analytics": "^2.0.0-alpha.
|
|
36
|
+
"@bakery-framework/core": "^2.0.0-alpha.8",
|
|
37
|
+
"@bakery-framework/orm": "^2.0.0-alpha.8",
|
|
38
|
+
"@bakery-framework/plugin-analytics": "^2.0.0-alpha.8"
|
|
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
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
}
|