@bakery-framework/plugin-dashboard 1.0.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/LICENSE +19 -0
- package/README.md +44 -0
- package/package.json +42 -0
- package/src/authorize.ts +76 -0
- package/src/client/dashboard.ts +165 -0
- package/src/client/parts/database.ts +1176 -0
- package/src/client/parts/effects.ts +327 -0
- package/src/client/parts/logs.ts +132 -0
- package/src/client/parts/sessions.ts +382 -0
- package/src/client/parts/stats.ts +957 -0
- package/src/client/parts/utils.ts +237 -0
- package/src/components/DBBrowser.tsx +365 -0
- package/src/components/LogsPanel.tsx +35 -0
- package/src/components/SessionsPanel.tsx +90 -0
- package/src/components/StatsPanel.tsx +265 -0
- package/src/components/TopPagesPanel.tsx +57 -0
- package/src/endpoints/database.ts +211 -0
- package/src/endpoints/sessions.ts +59 -0
- package/src/index.ts +42 -0
- package/src/paths.ts +14 -0
- package/src/setup.ts +268 -0
- package/src/shell.tsx +137 -0
- package/src/test-fixtures.ts +55 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { definePlugin } from '@bakery-framework/core/plugins'
|
|
2
|
+
import type { AuthorizeFn } from './authorize'
|
|
3
|
+
|
|
4
|
+
export type { AuthorizeFn } from './authorize'
|
|
5
|
+
|
|
6
|
+
export interface DashboardPluginOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Register the dashboard. Defaults to true. Set to false to keep it out of a
|
|
9
|
+
* build entirely — the documented way to disable it in production.
|
|
10
|
+
*/
|
|
11
|
+
enabled?: boolean
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Decide whether a request may use the console. Return true to allow.
|
|
15
|
+
*
|
|
16
|
+
* The dashboard does not authenticate anyone itself; the application does,
|
|
17
|
+
* because it is the thing that already knows who its users are:
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* dashboardPlugin({
|
|
21
|
+
* authorize: req => req.session.get('role') === 'admin',
|
|
22
|
+
* })
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Omitted, access is limited to loopback in development and denied in
|
|
26
|
+
* production, so an unconfigured console is never exposed.
|
|
27
|
+
*/
|
|
28
|
+
authorize?: AuthorizeFn
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function dashboardPlugin(options: DashboardPluginOptions = {}) {
|
|
32
|
+
const enabled = options.enabled ?? true
|
|
33
|
+
|
|
34
|
+
return definePlugin({
|
|
35
|
+
name: 'dashboard',
|
|
36
|
+
async setup() {
|
|
37
|
+
if (!enabled) return
|
|
38
|
+
const { setupDashboard } = await import('./setup')
|
|
39
|
+
await setupDashboard({ authorize: options.authorize })
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { fs } from '@bakery-framework/core/utils'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where this plugin's own files live.
|
|
5
|
+
*
|
|
6
|
+
* Not `frameworkPath()` — that anchors to `@bakery-framework/core`, so once the dashboard
|
|
7
|
+
* became its own package every asset lookup pointed into core and 404'd. Each
|
|
8
|
+
* package that ships files needs its own anchor, derived from its own location.
|
|
9
|
+
*/
|
|
10
|
+
export const pluginRoot: string = fs.resolve(import.meta.dir)
|
|
11
|
+
|
|
12
|
+
export function pluginPath(...segments: string[]): string {
|
|
13
|
+
return fs.resolve(pluginRoot, ...segments)
|
|
14
|
+
}
|
package/src/setup.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { bundleModule } from '@bakery-framework/core/compiler'
|
|
2
|
+
import { Bakery } from '@bakery-framework/core/core/bakery'
|
|
3
|
+
import { isDevWorker } from '@bakery-framework/core/core/init'
|
|
4
|
+
import { Handler, mountRoutes } from '@bakery-framework/core/handlers'
|
|
5
|
+
// LiveReloadHandler owns this registry — it adds and removes sockets from it.
|
|
6
|
+
import {
|
|
7
|
+
connectedLoggers,
|
|
8
|
+
errorMsg,
|
|
9
|
+
pluginLog,
|
|
10
|
+
setLogCallback,
|
|
11
|
+
} from '@bakery-framework/core/logger'
|
|
12
|
+
import {
|
|
13
|
+
type PluginRouteTable,
|
|
14
|
+
routeTable,
|
|
15
|
+
} from '@bakery-framework/core/plugins'
|
|
16
|
+
import type { JsonResponseData } from '@bakery-framework/core/utils/common'
|
|
17
|
+
import { Try } from '@bakery-framework/core/utils/common'
|
|
18
|
+
import {
|
|
19
|
+
checkCsrf,
|
|
20
|
+
injectIfHtml,
|
|
21
|
+
response,
|
|
22
|
+
} from '@bakery-framework/core/utils/http'
|
|
23
|
+
import {
|
|
24
|
+
type AuthorizeFn,
|
|
25
|
+
defaultAuthorize,
|
|
26
|
+
isAuthorized,
|
|
27
|
+
resolveAuthorize,
|
|
28
|
+
} from './authorize'
|
|
29
|
+
import {
|
|
30
|
+
handleExecuteAction,
|
|
31
|
+
handleQuery,
|
|
32
|
+
handleSchema,
|
|
33
|
+
handleTableData,
|
|
34
|
+
} from './endpoints/database'
|
|
35
|
+
import {
|
|
36
|
+
handleDeleteSession,
|
|
37
|
+
handleGetSessions,
|
|
38
|
+
handleUpdateSession,
|
|
39
|
+
} from './endpoints/sessions'
|
|
40
|
+
import { pluginPath } from './paths'
|
|
41
|
+
import renderDashboardShell from './shell'
|
|
42
|
+
|
|
43
|
+
export { connectedLoggers }
|
|
44
|
+
|
|
45
|
+
let cachedDashboardJsPath: string | null = null
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `/_dashboard` and `/api/_dashboard` are namespace *roots*, not prefixes.
|
|
49
|
+
*
|
|
50
|
+
* A bare `startsWith` also matches `/api/_dashboard-anything`, and this handler
|
|
51
|
+
* outranks every content handler at priority 120 — so the prefix form silently
|
|
52
|
+
* claimed, and then 404'd, any application route whose path merely began with
|
|
53
|
+
* those characters. Match the root itself or a segment below it, nothing else.
|
|
54
|
+
*/
|
|
55
|
+
function inNamespace(path: string, root: string): boolean {
|
|
56
|
+
return path === root || path.startsWith(`${root}/`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isDashboardPath(path: string): boolean {
|
|
60
|
+
return (
|
|
61
|
+
inNamespace(path, '/_dashboard') || inNamespace(path, '/api/_dashboard')
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class DashboardHandler extends Handler {
|
|
66
|
+
static canHandle(path: string) {
|
|
67
|
+
// Deliberately narrow. This handler outranks every content handler, so
|
|
68
|
+
// anything it claims can never reach the route mount below — assets must
|
|
69
|
+
// fall through.
|
|
70
|
+
if (inNamespace(path, '/api/_dashboard')) return true
|
|
71
|
+
return path === '/_dashboard' || path === '/_dashboard/dashboard.js'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
static resolveRoute(path: string): Handler.Route.Info | null {
|
|
75
|
+
if (isDashboardPath(path)) {
|
|
76
|
+
return new Handler.Route.Info(pluginPath('setup.tsx'), path)
|
|
77
|
+
}
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
static async handle(
|
|
82
|
+
_path: string,
|
|
83
|
+
req: Request,
|
|
84
|
+
): Promise<DashboardResponse | undefined> {
|
|
85
|
+
const res = await handleDashboardRequest(req)
|
|
86
|
+
return res || undefined
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let authorize: AuthorizeFn = defaultAuthorize
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Test seam for the module-level predicate, symmetric with `__setTestDb` in
|
|
94
|
+
* `@bakery-framework/orm` and `__setTestConfig` in core.
|
|
95
|
+
*
|
|
96
|
+
* `setupDashboard` is the only other way to set it, and it also mounts routes,
|
|
97
|
+
* registers the handler at priority 120 and installs a global log callback —
|
|
98
|
+
* three process-global mutations, none of them restorable. A test that only
|
|
99
|
+
* needs the request pipeline sets the predicate directly and puts it back in
|
|
100
|
+
* `afterAll`. Always pair with `__resetTestAuthorize()`.
|
|
101
|
+
*/
|
|
102
|
+
export function __setTestAuthorize(fn: AuthorizeFn): void {
|
|
103
|
+
authorize = fn
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function __resetTestAuthorize(): void {
|
|
107
|
+
authorize = defaultAuthorize
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function setupDashboard(options: { authorize?: AuthorizeFn } = {}) {
|
|
111
|
+
authorize = resolveAuthorize(options.authorize)
|
|
112
|
+
|
|
113
|
+
setLogCallback(entry => {
|
|
114
|
+
// Broadcast to the registry LiveReloadHandler actually populates. This used
|
|
115
|
+
// to target a Set nothing ever added to, so the Logs tab was always empty
|
|
116
|
+
// and every log line paid for a JSON.stringify that went nowhere.
|
|
117
|
+
if (!connectedLoggers.size) return
|
|
118
|
+
|
|
119
|
+
const message = JSON.stringify({
|
|
120
|
+
type: 'server_log',
|
|
121
|
+
level: entry.level,
|
|
122
|
+
by: entry.by,
|
|
123
|
+
payload: entry.msg,
|
|
124
|
+
timestamp: Date.now(),
|
|
125
|
+
})
|
|
126
|
+
connectedLoggers.forEach(loggerWs => {
|
|
127
|
+
Try(() => loggerWs.send(message))
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// The stylesheet is served by the normal static pipeline from this
|
|
132
|
+
// plugin's own directory — no bespoke asset route, no hand-rolled caching.
|
|
133
|
+
mountRoutes('/_dashboard', pluginPath('../public'))
|
|
134
|
+
|
|
135
|
+
Bakery.handlers.fetch.set(DashboardHandler, 120)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function handleDashboardView() {
|
|
139
|
+
const htmlContent = await renderDashboardShell()
|
|
140
|
+
const injected = await injectIfHtml(htmlContent)
|
|
141
|
+
|
|
142
|
+
return injected ? injected : response.html(htmlContent)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function handleJsAsset() {
|
|
146
|
+
if (cachedDashboardJsPath && !isDevWorker) {
|
|
147
|
+
const cachedFile = Bun.file(cachedDashboardJsPath)
|
|
148
|
+
if (cachedFile.size > 0) return response.type(cachedFile, 'text/javascript')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const bundleResult = await bundleModule(pluginPath('client/dashboard.ts'))
|
|
152
|
+
|
|
153
|
+
if (!bundleResult.success || !bundleResult.content) {
|
|
154
|
+
pluginLog.DASHBOARD_BUNDLE_ERR({
|
|
155
|
+
error: errorMsg(bundleResult.errors?.join('\n')),
|
|
156
|
+
})
|
|
157
|
+
return response.error(
|
|
158
|
+
`Failed to bundle dashboard.js: ${bundleResult.errors?.join('\n')}`,
|
|
159
|
+
500,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!isDevWorker) {
|
|
164
|
+
const tmpPath = `${Bakery.cacheDir}/_dashboard.js`
|
|
165
|
+
await Bun.write(tmpPath, bundleResult.content)
|
|
166
|
+
const writtenFile = Bun.file(tmpPath)
|
|
167
|
+
if (writtenFile.size > 0) {
|
|
168
|
+
cachedDashboardJsPath = tmpPath
|
|
169
|
+
return writtenFile
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return response.type(bundleResult.content, 'text/javascript')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function checkAuthMiddleware(req: Request, path: string) {
|
|
177
|
+
// Styling and script for the console are not secrets, and letting them
|
|
178
|
+
// through keeps an unauthorised response from rendering unstyled.
|
|
179
|
+
if (/\.(css|js)$/.test(path)) return null
|
|
180
|
+
|
|
181
|
+
if (await isAuthorized(authorize, req)) return null
|
|
182
|
+
|
|
183
|
+
return path.startsWith('/api/')
|
|
184
|
+
? response.error('Unauthorized', 401)
|
|
185
|
+
: response.error('Not Found', 404)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* `checkCsrf` has exactly one other call site — inside `ApiHandler`, at
|
|
190
|
+
* priority 70. This handler sits at 120 and claims the whole `/api/_dashboard`
|
|
191
|
+
* namespace, so `ApiHandler.handle` never ran for these paths and the check
|
|
192
|
+
* never happened: a cross-origin `<form method=post>` arrived with the
|
|
193
|
+
* operator's cookies attached and reached the database.
|
|
194
|
+
*
|
|
195
|
+
* Necessary but **not sufficient on its own**. `SAFE_METHODS` exempts GET, and
|
|
196
|
+
* `processBody` reads a GET's query string as the body, so a plain
|
|
197
|
+
* `<img src="/api/_dashboard/execute-action?action=truncate&tableName=…">`
|
|
198
|
+
* sails past this guard. The mutating keys in the table below are
|
|
199
|
+
* method-qualified for exactly that reason; neither half closes the hole alone.
|
|
200
|
+
*/
|
|
201
|
+
function checkCsrfMiddleware(req: Request, url: URL): DashboardResponse | null {
|
|
202
|
+
const reason = checkCsrf(req, url)
|
|
203
|
+
if (!reason) return null
|
|
204
|
+
|
|
205
|
+
return url.pathname.startsWith('/api/')
|
|
206
|
+
? response.json.error(403, reason)
|
|
207
|
+
: response.error(reason, 403)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Authenticated routes only — the logout/login cases below run before the auth
|
|
212
|
+
* check and are deliberately kept out of this table.
|
|
213
|
+
*
|
|
214
|
+
* Every mutating endpoint is method-qualified. A bare key matches **any**
|
|
215
|
+
* method, which made each of these reachable by GET — and a GET is a safe
|
|
216
|
+
* method as far as `checkCsrf` is concerned, so the CSRF guard above cannot
|
|
217
|
+
* see it. `POST /api/_analytics/reset` is the same shape.
|
|
218
|
+
*
|
|
219
|
+
* `satisfies` rather than an annotation: an annotation erases the literal type,
|
|
220
|
+
* which would collapse every endpoint's return type back into the wide
|
|
221
|
+
* `ValidResponses` (and its `object` member) before `routeTable` ever sees it.
|
|
222
|
+
*/
|
|
223
|
+
const dashboardRoutes = {
|
|
224
|
+
'/_dashboard': () => handleDashboardView(),
|
|
225
|
+
'/_dashboard/dashboard.js': () => handleJsAsset(),
|
|
226
|
+
'/api/_dashboard/sessions': (_req, url) => handleGetSessions(url),
|
|
227
|
+
'POST /api/_dashboard/sessions/delete': req => handleDeleteSession(req),
|
|
228
|
+
'POST /api/_dashboard/sessions/update': req => handleUpdateSession(req),
|
|
229
|
+
'/api/_dashboard/schema': () => handleSchema(),
|
|
230
|
+
'/api/_dashboard/table-data': (_req, url) => handleTableData(url),
|
|
231
|
+
'POST /api/_dashboard/query': req => handleQuery(req),
|
|
232
|
+
'POST /api/_dashboard/execute-action': req => handleExecuteAction(req),
|
|
233
|
+
} satisfies PluginRouteTable
|
|
234
|
+
|
|
235
|
+
const dispatchDashboardRoute = routeTable(dashboardRoutes)
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Three genuinely different shapes, so a union rather than a single type: the
|
|
239
|
+
* shell is a `Response`, `dashboard.js` is served straight off disk as a
|
|
240
|
+
* `Bun.BunFile` once it has been bundled and cached, and every `/api/` endpoint
|
|
241
|
+
* answers with the JSON envelope. `processResponse` in `router.ts` serialises
|
|
242
|
+
* all three — `Response` passes through, a `Blob` goes to `ETag.sendFile`, and
|
|
243
|
+
* a `JsonResponseData` is stamped with the elapsed time and JSON-encoded.
|
|
244
|
+
*/
|
|
245
|
+
export type DashboardResponse =
|
|
246
|
+
| Response
|
|
247
|
+
| Bun.BunFile
|
|
248
|
+
| JsonResponseData<unknown>
|
|
249
|
+
|
|
250
|
+
export async function handleDashboardRequest(
|
|
251
|
+
req: Request,
|
|
252
|
+
): Promise<DashboardResponse | null> {
|
|
253
|
+
const url = new URL(req.url)
|
|
254
|
+
const path = url.pathname
|
|
255
|
+
|
|
256
|
+
if (!isDashboardPath(path)) return null
|
|
257
|
+
|
|
258
|
+
// Auth first, so a cross-origin probe from an unauthenticated peer gets the
|
|
259
|
+
// same 404/401 as any other unauthenticated request rather than a 403 that
|
|
260
|
+
// confirms the console is mounted here.
|
|
261
|
+
const authBlock = await checkAuthMiddleware(req, path)
|
|
262
|
+
if (authBlock) return authBlock
|
|
263
|
+
|
|
264
|
+
const csrfBlock = checkCsrfMiddleware(req, url)
|
|
265
|
+
if (csrfBlock) return csrfBlock
|
|
266
|
+
|
|
267
|
+
return dispatchDashboardRoute(req, url)
|
|
268
|
+
}
|
package/src/shell.tsx
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { renderDatabaseBrowser } from './components/DBBrowser'
|
|
2
|
+
import { renderLogsPanel } from './components/LogsPanel'
|
|
3
|
+
import { renderSessionsPanel } from './components/SessionsPanel'
|
|
4
|
+
import { renderStatsPanel } from './components/StatsPanel'
|
|
5
|
+
import { renderTopPagesPanel } from './components/TopPagesPanel'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Nav items drive the existing client-side `switchTab`, which finds buttons by
|
|
9
|
+
* the `.tab-btn` class and reads the target id out of the onclick attribute.
|
|
10
|
+
* Keeping that contract lets the chrome be replaced without touching the ~3k
|
|
11
|
+
* lines of panel client code.
|
|
12
|
+
*/
|
|
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 }) {
|
|
32
|
+
return (
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
class={id === 'stats' ? 'tab-btn active' : 'tab-btn'}
|
|
36
|
+
onclick={`switchTab('${id}')`}>
|
|
37
|
+
<span class="nav-dot"></span>
|
|
38
|
+
<span>{label}</span>
|
|
39
|
+
</button>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function Dashboard() {
|
|
44
|
+
return (
|
|
45
|
+
<html lang="en">
|
|
46
|
+
<head>
|
|
47
|
+
<meta charSet="UTF-8" />
|
|
48
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
49
|
+
<meta name="color-scheme" content="light dark" />
|
|
50
|
+
<title>Bakery Console</title>
|
|
51
|
+
<link rel="stylesheet" href="/_dashboard/style.css" />
|
|
52
|
+
</head>
|
|
53
|
+
<body>
|
|
54
|
+
<div class="console">
|
|
55
|
+
<aside class="rail" id="rail">
|
|
56
|
+
<div class="rail-brand">
|
|
57
|
+
<div class="rail-mark">B</div>
|
|
58
|
+
<span class="rail-name">Bakery Console</span>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
{NAV.map(section => (
|
|
62
|
+
<nav class="rail-group">
|
|
63
|
+
<div class="rail-group-label">{section.group}</div>
|
|
64
|
+
{section.items.map(item => (
|
|
65
|
+
<NavItem id={item.id} label={item.label} />
|
|
66
|
+
))}
|
|
67
|
+
</nav>
|
|
68
|
+
))}
|
|
69
|
+
|
|
70
|
+
<div class="rail-foot">
|
|
71
|
+
<span>Bakery</span>
|
|
72
|
+
<span id="rail-version">v3</span>
|
|
73
|
+
</div>
|
|
74
|
+
</aside>
|
|
75
|
+
|
|
76
|
+
<header class="bar">
|
|
77
|
+
<div class="crumbs">
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
class="btn rail-toggle"
|
|
81
|
+
onclick="document.getElementById('rail').classList.toggle('open')"
|
|
82
|
+
aria-label="Toggle navigation">
|
|
83
|
+
☰
|
|
84
|
+
</button>
|
|
85
|
+
<span>Console</span>
|
|
86
|
+
<span class="sep">/</span>
|
|
87
|
+
<strong id="crumb-current">Overview</strong>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div class="bar-actions">
|
|
91
|
+
<div class="status-indicator" id="server-status-indicator">
|
|
92
|
+
<span class="status-dot" id="server-status-dot"></span>
|
|
93
|
+
<span id="server-status-text">Connecting…</span>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div
|
|
97
|
+
class="profile-dropdown-wrapper"
|
|
98
|
+
id="profile-dropdown-wrapper">
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
class="profile-trigger-btn"
|
|
102
|
+
onclick="toggleProfileDropdown(event)"
|
|
103
|
+
aria-label="Account menu">
|
|
104
|
+
<span class="profile-avatar">A</span>
|
|
105
|
+
<span aria-hidden="true">▾</span>
|
|
106
|
+
</button>
|
|
107
|
+
|
|
108
|
+
<div class="profile-menu" id="profile-menu">
|
|
109
|
+
<div class="profile-header-info">
|
|
110
|
+
<span class="profile-admin-name">Administrator</span>
|
|
111
|
+
</div>
|
|
112
|
+
{/* No sign-out: the console does not own sessions. The host
|
|
113
|
+
application authenticates, via the authorize predicate. */}
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
onclick="resetAnalytics(); toggleProfileDropdown(event);">
|
|
117
|
+
<span>Reset analytics</span>
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</header>
|
|
123
|
+
|
|
124
|
+
<main>
|
|
125
|
+
{renderStatsPanel()}
|
|
126
|
+
{renderTopPagesPanel()}
|
|
127
|
+
{renderSessionsPanel()}
|
|
128
|
+
{renderDatabaseBrowser()}
|
|
129
|
+
{renderLogsPanel()}
|
|
130
|
+
</main>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<script src="/_dashboard/dashboard.js"></script>
|
|
134
|
+
</body>
|
|
135
|
+
</html>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixtures shared by this plugin's test files.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not named `*.test.ts`: nothing here runs on its own, and Bun
|
|
5
|
+
* would collect it as an empty suite.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A database stand-in that records what it was asked to do and does none of it.
|
|
10
|
+
*
|
|
11
|
+
* The assertion that matters in both suites is that the recorded list stays
|
|
12
|
+
* *empty* — a rejection that arrives after the truncate is not a rejection — so
|
|
13
|
+
* the stub must reach no real database and must log every method the endpoints
|
|
14
|
+
* can call. `setup.test.ts` (routing and CSRF) and `endpoints/database.test.ts`
|
|
15
|
+
* (the write gate) each kept their own copy, and the copies had already
|
|
16
|
+
* diverged: one was missing `insert` and `query` entirely.
|
|
17
|
+
*
|
|
18
|
+
* A factory rather than a shared singleton. Bun loads every test file into one
|
|
19
|
+
* process, so a module-level array would be shared state between two suites
|
|
20
|
+
* that reset it on different hooks; each caller gets its own.
|
|
21
|
+
*/
|
|
22
|
+
export function createStubDb() {
|
|
23
|
+
const calls: string[] = []
|
|
24
|
+
|
|
25
|
+
const db = {
|
|
26
|
+
truncate: async (table: string) => {
|
|
27
|
+
calls.push(`truncate:${table}`)
|
|
28
|
+
},
|
|
29
|
+
remove: async (table: string, rowid: unknown) => {
|
|
30
|
+
calls.push(`remove:${table}:${rowid}`)
|
|
31
|
+
},
|
|
32
|
+
insert: async (table: string) => {
|
|
33
|
+
calls.push(`insert:${table}`)
|
|
34
|
+
},
|
|
35
|
+
query: (sql: string) => ({
|
|
36
|
+
all: async () => {
|
|
37
|
+
calls.push(`all:${sql}`)
|
|
38
|
+
return []
|
|
39
|
+
},
|
|
40
|
+
run: async () => {
|
|
41
|
+
calls.push(`run:${sql}`)
|
|
42
|
+
return { lastInsertRowid: 1, changes: 1 }
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
db,
|
|
49
|
+
calls,
|
|
50
|
+
/** Emptied in place, so the array identity a suite captured stays valid. */
|
|
51
|
+
reset: () => {
|
|
52
|
+
calls.length = 0
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|