@bakery-framework/plugin-dashboard 2.0.0-alpha.5 → 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 +4 -4
- package/src/client/dashboard.ts +2 -65
- package/src/client/parts/effects.ts +2 -2
- package/src/client/parts/stats.ts +105 -23
- package/src/client/parts/utils.ts +13 -40
- package/src/components/DBBrowser.tsx +41 -352
- package/src/setup.ts +28 -14
- package/src/shell.tsx +98 -22
- package/src/client/parts/database.ts +0 -1176
- package/src/endpoints/database.ts +0 -211
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.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.
|
|
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.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"
|
package/src/client/dashboard.ts
CHANGED
|
@@ -1,35 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
addActiveFilter,
|
|
3
|
-
changePageSize,
|
|
4
|
-
clearActiveFilters,
|
|
5
|
-
closeEditModal,
|
|
6
|
-
closeExportMenuIfOutside,
|
|
7
|
-
closeImportModal,
|
|
8
|
-
closeInsertModal,
|
|
9
|
-
deleteTableRow,
|
|
10
|
-
exportToCSV,
|
|
11
|
-
exportToJSON,
|
|
12
|
-
fetchTableData,
|
|
13
|
-
filterTablesList,
|
|
14
|
-
handleCsvFileSelect,
|
|
15
|
-
inspectTable,
|
|
16
|
-
loadSchema,
|
|
17
|
-
nextPage,
|
|
18
|
-
openEditModal,
|
|
19
|
-
openImportModal,
|
|
20
|
-
openInsertModal,
|
|
21
|
-
prevPage,
|
|
22
|
-
removeActiveFilter,
|
|
23
|
-
runQuery,
|
|
24
|
-
selectDatabaseTable,
|
|
25
|
-
startInlineEdit,
|
|
26
|
-
submitEditRow,
|
|
27
|
-
submitImportCsv,
|
|
28
|
-
submitInsertRow,
|
|
29
|
-
toggleExportMenu,
|
|
30
|
-
toggleGridSort,
|
|
31
|
-
truncateCurrentTable,
|
|
32
|
-
} from './parts/database'
|
|
33
1
|
import { refreshShimmerCache } from './parts/effects'
|
|
34
2
|
import { clearLogs, initLogsWebSocket, toggleLogsPlay } from './parts/logs'
|
|
35
3
|
import {
|
|
@@ -83,9 +51,10 @@ function switchTab(tabId: string) {
|
|
|
83
51
|
const label = document.querySelector(`.tab-btn.active span:last-child`)
|
|
84
52
|
if (crumb && label) crumb.textContent = label.textContent
|
|
85
53
|
|
|
54
|
+
// No `database` entry: that panel is static markup now — a link to the
|
|
55
|
+
// explorer at `/_db` — so there is nothing to fetch when it is shown.
|
|
86
56
|
match(tabId, {
|
|
87
57
|
sessions: loadSessions,
|
|
88
|
-
database: loadSchema,
|
|
89
58
|
logs: initLogsWebSocket,
|
|
90
59
|
'top-pages': () => loadStats(true),
|
|
91
60
|
})
|
|
@@ -93,8 +62,6 @@ function switchTab(tabId: string) {
|
|
|
93
62
|
refreshShimmerCache()
|
|
94
63
|
}
|
|
95
64
|
|
|
96
|
-
window.addEventListener('click', closeExportMenuIfOutside)
|
|
97
|
-
|
|
98
65
|
window.addEventListener('click', e => {
|
|
99
66
|
const menu = document.getElementById('profile-menu')
|
|
100
67
|
const trigger = document.querySelector('.profile-trigger-btn')
|
|
@@ -125,36 +92,6 @@ w.changeSessionPageSize = changeSessionPageSize
|
|
|
125
92
|
w.sessionKeyAction = sessionKeyAction
|
|
126
93
|
w.openSessionKeyEditor = openSessionKeyEditor
|
|
127
94
|
|
|
128
|
-
w.loadSchema = loadSchema
|
|
129
|
-
w.filterTablesList = filterTablesList
|
|
130
|
-
w.selectDatabaseTable = selectDatabaseTable
|
|
131
|
-
w.fetchTableData = fetchTableData
|
|
132
|
-
w.toggleGridSort = toggleGridSort
|
|
133
|
-
w.prevPage = prevPage
|
|
134
|
-
w.nextPage = nextPage
|
|
135
|
-
w.changePageSize = changePageSize
|
|
136
|
-
w.startInlineEdit = startInlineEdit
|
|
137
|
-
w.addActiveFilter = addActiveFilter
|
|
138
|
-
w.removeActiveFilter = removeActiveFilter
|
|
139
|
-
w.clearActiveFilters = clearActiveFilters
|
|
140
|
-
w.openInsertModal = openInsertModal
|
|
141
|
-
w.closeInsertModal = closeInsertModal
|
|
142
|
-
w.submitInsertRow = submitInsertRow
|
|
143
|
-
w.openEditModal = openEditModal
|
|
144
|
-
w.closeEditModal = closeEditModal
|
|
145
|
-
w.submitEditRow = submitEditRow
|
|
146
|
-
w.openImportModal = openImportModal
|
|
147
|
-
w.closeImportModal = closeImportModal
|
|
148
|
-
w.handleCsvFileSelect = handleCsvFileSelect
|
|
149
|
-
w.submitImportCsv = submitImportCsv
|
|
150
|
-
w.toggleExportMenu = toggleExportMenu
|
|
151
|
-
w.exportToCSV = exportToCSV
|
|
152
|
-
w.exportToJSON = exportToJSON
|
|
153
|
-
w.truncateCurrentTable = truncateCurrentTable
|
|
154
|
-
w.deleteTableRow = deleteTableRow
|
|
155
|
-
w.inspectTable = inspectTable
|
|
156
|
-
w.runQuery = runQuery
|
|
157
|
-
|
|
158
95
|
w.initLogsWebSocket = initLogsWebSocket
|
|
159
96
|
w.toggleLogsPlay = toggleLogsPlay
|
|
160
97
|
w.clearLogs = clearLogs
|
|
@@ -4,8 +4,8 @@ export let refreshShimmerCache = () => {}
|
|
|
4
4
|
* Both effects below run on import. Without this guard, importing anything that
|
|
5
5
|
* transitively reaches this file outside a browser throws on the first `window`
|
|
6
6
|
* access — which made every module in `client/parts/` untestable, including the
|
|
7
|
-
* hand-built HTML in `database.ts` that
|
|
8
|
-
* repo. A DOM effect with no DOM is a no-op, not an error.
|
|
7
|
+
* hand-built HTML in the since-retired `database.ts` that produced every XSS
|
|
8
|
+
* found in this repo. A DOM effect with no DOM is a no-op, not an error.
|
|
9
9
|
*/
|
|
10
10
|
const HAS_DOM = typeof window !== 'undefined' && typeof document !== 'undefined'
|
|
11
11
|
|
|
@@ -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
|
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared browser-side helpers for the dashboard panels.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* The panels were built independently and each grew its own copy of the same
|
|
5
|
+
* four things: an element-text setter, a `results-empty` block, a
|
|
6
|
+
* fetch/`.json()` pair, and a prev/next pager. They are declared once here
|
|
7
|
+
* because this file is compiled into the shipped bundle — a duplicated string
|
|
8
|
+
* literal is duplicated bytes, the minifier does not merge them.
|
|
9
|
+
*
|
|
10
|
+
* Shrank when the database editor was retired: `executeAction`, `getJson`,
|
|
11
|
+
* `errorBox` and three icon paths had no caller left once `database.ts` went,
|
|
12
|
+
* and dead code in a browser bundle is bytes every operator downloads.
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
15
|
/** `innerText` on an element that may not be in the DOM yet. */
|
|
@@ -22,9 +25,10 @@ export function setText(id: string, value: string) {
|
|
|
22
25
|
* These helpers previously took a string straight into `innerHTML`: every
|
|
23
26
|
* caller happened to pass a literal or escape first, which is exactly the state
|
|
24
27
|
* a codebase is in right before it stops being true. Every XSS found in this
|
|
25
|
-
* repo came from hand-built DOM strings in these
|
|
26
|
-
* stored-XSS to arbitrary-SQL chain
|
|
27
|
-
*
|
|
28
|
+
* repo came from hand-built DOM strings in these panel files, including a
|
|
29
|
+
* stored-XSS to arbitrary-SQL chain through the database grid — which is gone
|
|
30
|
+
* now, but the escaping default it argued for is not, because the sessions and
|
|
31
|
+
* stats panels build markup the same way from data they did not write.
|
|
28
32
|
*/
|
|
29
33
|
export function emptyBox(message: string, isError = false): string {
|
|
30
34
|
const style = isError ? ' style="color: var(--accent-red);"' : ''
|
|
@@ -53,36 +57,10 @@ export function icon(d: string, size: string): string {
|
|
|
53
57
|
)
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
export const ICON_TABLE =
|
|
57
|
-
'M19 21H5q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21M5 8h14V5H5zm2.5 2H5v9h2.5zm9 0v9H19v-9zm-2 0h-5v9h5z'
|
|
58
|
-
export const ICON_EYE =
|
|
59
|
-
'M15.188 14.688Q16.5 13.375 16.5 11.5t-1.312-3.187T12 7T8.813 8.313T7.5 11.5t1.313 3.188T12 16t3.188-1.312m-5.1-1.276Q9.3 12.625 9.3 11.5t.788-1.912T12 8.8t1.913.788t.787 1.912t-.787 1.913T12 14.2t-1.912-.787m-4.738 3.55Q2.35 14.925 1 11.5q1.35-3.425 4.35-5.462T12 4t6.65 2.038T23 11.5q-1.35 3.425-4.35 5.463T12 19t-6.65-2.037m11.838-1.45Q19.55 14.025 20.8 11.5q-1.25-2.525-3.613-4.012T12 6T6.813 7.488T3.2 11.5q1.25 2.525 3.613 4.013T12 17t5.188-1.487'
|
|
60
60
|
export const ICON_EDIT =
|
|
61
61
|
'M5 19h1.425L16.2 9.225L14.775 7.8L5 17.575zm-2 2v-4.25L16.2 3.575q.3-.275.663-.425t.762-.15t.775.15t.65.45L20.425 5q.3.275.438.65T21 6.4q0 .4-.137.763t-.438.662L7.25 21zM19 6.4L17.6 5zm-3.525 2.125l-.7-.725L16.2 9.225z'
|
|
62
62
|
export const ICON_DELETE =
|
|
63
63
|
'M7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21zM17 6H7v13h10zM9 17h2V8H9zm4 0h2V8h-2zM7 6v13z'
|
|
64
|
-
export const ICON_WARN =
|
|
65
|
-
'M1 21L12 2l11 19zm3.45-2h15.1L12 6zm8.263-1.287Q13 17.425 13 17t-.288-.712T12 16t-.712.288T11 17t.288.713T12 18t.713-.288M11 15h2v-5h-2zm1-2.5'
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* An error block with the warning glyph, as the fetch failure paths render it.
|
|
69
|
-
* `message` is escaped — see `emptyBox`. The two dynamic callers pass a driver
|
|
70
|
-
* error string, which quotes the caller's own SQL back at them.
|
|
71
|
-
*/
|
|
72
|
-
export function errorBox(message: string): string {
|
|
73
|
-
return (
|
|
74
|
-
`<div class="results-empty" style="color: var(--accent-red);">` +
|
|
75
|
-
`<span style="display: inline-flex; align-items: center; gap: 0.25rem;">` +
|
|
76
|
-
`${icon(ICON_WARN, '1.1rem')}${escapeHTML(message)}</span></div>`
|
|
77
|
-
)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** GET a dashboard endpoint and unwrap the JSON envelope. */
|
|
81
|
-
export async function getJson(url: string): Promise<any> {
|
|
82
|
-
const res = await fetch(url)
|
|
83
|
-
return await res.json()
|
|
84
|
-
}
|
|
85
|
-
|
|
86
64
|
/** POST a JSON body to a dashboard endpoint and unwrap the envelope. */
|
|
87
65
|
export async function postJson(url: string, body: unknown): Promise<any> {
|
|
88
66
|
const res = await fetch(url, {
|
|
@@ -93,11 +71,6 @@ export async function postJson(url: string, body: unknown): Promise<any> {
|
|
|
93
71
|
return await res.json()
|
|
94
72
|
}
|
|
95
73
|
|
|
96
|
-
/** The mutating half of the database panel; every action shares one endpoint. */
|
|
97
|
-
export function executeAction(body: Record<string, unknown>): Promise<any> {
|
|
98
|
-
return postJson('/api/_dashboard/execute-action', body)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
74
|
/** Page N of M, with the prev/next buttons disabled at the ends. */
|
|
102
75
|
export function setPager(
|
|
103
76
|
ids: { info: string; prev: string; next: string },
|