@dunx/dashboard 1.2.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 +21 -0
- package/README.md +242 -0
- package/dist/api/bounded.d.ts +14 -0
- package/dist/api/redis.d.ts +9 -0
- package/dist/api/runtime.d.ts +13 -0
- package/dist/api/snapshot.d.ts +13 -0
- package/dist/api/types.d.ts +110 -0
- package/dist/board.d.ts +42 -0
- package/dist/chunk-xg5554k6.js +107 -0
- package/dist/chunk-xg5554k6.js.map +10 -0
- package/dist/contracts.d.ts +93 -0
- package/dist/html.d.ts +4 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +460 -0
- package/dist/index.js.map +17 -0
- package/dist/middleware.d.ts +32 -0
- package/dist/module.d.ts +44 -0
- package/dist/options.d.ts +152 -0
- package/dist/router.d.ts +17 -0
- package/dist/ui-bundle.d.ts +4 -0
- package/dist/ui.d.ts +21 -0
- package/dist/ui.js +42 -0
- package/dist/ui.js.map +12 -0
- package/package.json +90 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Petar Zarkov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# @dunx/dashboard
|
|
2
|
+
|
|
3
|
+
One page over a running dunx app: the routes it serves, the container it built,
|
|
4
|
+
the gateways it upgrades, Redis, the config keys and the process itself - **with
|
|
5
|
+
bull-board mounted for the queues**. Opt in with one module and one `app.use`.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @dunx/dashboard
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
An operator looking at a running dunx service has had three surfaces over the same
|
|
12
|
+
data and none of them the one they wanted. `/docs` answers "what can a client
|
|
13
|
+
call". `@dunx/mcp` answers the same questions for an agent, over stdio. Nothing
|
|
14
|
+
answered **"what is this process actually doing"**. This is that page, and it is
|
|
15
|
+
cheap because every panel reads data dunx already computes.
|
|
16
|
+
|
|
17
|
+
## Mount it
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { DashboardMiddleware, DashboardModule } from '@dunx/dashboard';
|
|
21
|
+
import { JobPublisher, QueueModule } from '@dunx/infra/queue';
|
|
22
|
+
import { RedisConnection, RedisModule } from '@dunx/infra/redis';
|
|
23
|
+
|
|
24
|
+
@Module({
|
|
25
|
+
imports: [
|
|
26
|
+
DashboardModule.forRootAsync({
|
|
27
|
+
// This dynamic module is its own scope, so whatever exports the tokens the
|
|
28
|
+
// factory injects goes here.
|
|
29
|
+
imports: [QueueModule, RedisModule],
|
|
30
|
+
useFactory: (queues: JobPublisher, redis: RedisConnection) => ({
|
|
31
|
+
queues,
|
|
32
|
+
redis,
|
|
33
|
+
authorize: (req) => req.headers.get('x-ops-key') === process.env.OPS_KEY,
|
|
34
|
+
}),
|
|
35
|
+
inject: [JobPublisher, RedisConnection] as const,
|
|
36
|
+
}),
|
|
37
|
+
],
|
|
38
|
+
})
|
|
39
|
+
export class AppModule {}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
const app = await HttpFactory.create(AppModule);
|
|
44
|
+
app.use(DashboardMiddleware, SessionGuard); // the dashboard first - see below
|
|
45
|
+
await app.listen(3000);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
That is the whole wiring. `JobPublisher` and `RedisConnection` are accepted **as
|
|
49
|
+
they are**: this package restates what it needs from them structurally and depends
|
|
50
|
+
on `@dunx/infra`, `bullmq` and `ioredis` not at all, so an app with no queues does
|
|
51
|
+
not install a queue library to look at its routes.
|
|
52
|
+
|
|
53
|
+
## Six panels
|
|
54
|
+
|
|
55
|
+
| Panel | Reads | Lifetime |
|
|
56
|
+
| ----------------------- | ---------------------------------------------------------------------------------- | -------- |
|
|
57
|
+
| **Overview** | counts, uptime, heap, Bun version, dependency probes | polled |
|
|
58
|
+
| **Routes** | `routesOf` - method, path, controller, module, guards, `@Roles`/`@Public`, schemas | static |
|
|
59
|
+
| **Gateways** | `gatewaysOf` - upgrade path, the event each handler claims | static |
|
|
60
|
+
| **Modules & providers** | `providersOf`, `modulesOf` - what each module binds, exports and injects | static |
|
|
61
|
+
| **Queues & Redis** | queue names and a link to **bull-board**; Redis `PING` and `INFO` | polled |
|
|
62
|
+
| **Configuration** | keys and types, values only where you allow them | static |
|
|
63
|
+
|
|
64
|
+
The provider panel is the one that earns its place fastest. A missing-binding error
|
|
65
|
+
names one token; reconstructing which module bound what and why the graph did not
|
|
66
|
+
close is otherwise a grep across every `@Module`. An **unresolvable constructor
|
|
67
|
+
parameter** - an interface, a primitive, a union, a type-only import - is called out
|
|
68
|
+
in red on the overview, because each one is a boot error waiting to happen.
|
|
69
|
+
|
|
70
|
+
The static panels use the same readers `@dunx/mcp` answers with, and they construct
|
|
71
|
+
nothing. That is the deliberate inversion of MCP's rule: MCP refuses runtime
|
|
72
|
+
questions because booting an app to answer them would open databases and bind
|
|
73
|
+
sockets, and this package is *already inside* a booted app, so the reason does not
|
|
74
|
+
apply. Stating it per panel is what stops the dashboard growing a `boot()`.
|
|
75
|
+
|
|
76
|
+
## Every panel has a JSON sibling
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
curl -H 'x-ops-key: …' $APP/_dunx/api/snapshot
|
|
80
|
+
curl -H 'x-ops-key: …' $APP/_dunx/api/runtime
|
|
81
|
+
curl -H 'x-ops-key: …' $APP/_dunx/api/redis
|
|
82
|
+
curl -H 'x-ops-key: …' $APP/_dunx/api/queues # names only; the board is at /_dunx/queues
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
These are the endpoints the page itself uses, and they are supported rather than an
|
|
86
|
+
implementation detail - which is what makes the dashboard usable on a box with no
|
|
87
|
+
browser. Their types are exported (`Snapshot`, `RuntimeReport`, `RedisReport`,
|
|
88
|
+
`QueuesReport`), so a `fetch` of them is typed. Anything *about* a queue is
|
|
89
|
+
bull-board's own API, under `{path}/queues`.
|
|
90
|
+
|
|
91
|
+
## Security
|
|
92
|
+
|
|
93
|
+
**`authorize` has no default. Leaving it out serves the page to anyone who can
|
|
94
|
+
reach the port**, and the page is routes plus config plus the provider graph on one
|
|
95
|
+
screen - a reconnaissance gift. Omitting it logs a warning naming the mount at boot,
|
|
96
|
+
because "fine behind a private network" is a real answer and guessing is not.
|
|
97
|
+
|
|
98
|
+
Four things follow, and none is obvious:
|
|
99
|
+
|
|
100
|
+
- **A rejected request gets 404, not 403.** A dashboard that announces itself to an
|
|
101
|
+
unauthenticated caller has told them where to keep knocking.
|
|
102
|
+
- **Register it ahead of any session guard.** With the middleware last in the chain,
|
|
103
|
+
a `SessionGuard` answers every dashboard request `401` before `authorize` runs,
|
|
104
|
+
which defeats the 404 contract entirely.
|
|
105
|
+
- **So `authorize` must be self-sufficient.** It receives the raw `Request` and runs
|
|
106
|
+
before anything has written an `AuthContext` - ask your auth library directly.
|
|
107
|
+
- **`commands: false`** puts bull-board in its own read-only mode. Everything else
|
|
108
|
+
on the page only ever reports, so this is entirely about the queues. `authorize`
|
|
109
|
+
gates who reaches the mount; this gates what they can do once there.
|
|
110
|
+
|
|
111
|
+
### Configuration is redacted by default
|
|
112
|
+
|
|
113
|
+
`ConfigService` holds whatever your `validate` returned, which includes every secret
|
|
114
|
+
you have. A deny-list of the usual suspects - `SECRET`, `PASSWORD`, `TOKEN` - looks
|
|
115
|
+
careful and leaks the first key nobody thought of, so **the default reveals
|
|
116
|
+
nothing**: the panel shows keys and types, which is most of what it is wanted for,
|
|
117
|
+
and a value appears only where you say so.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
config: appConfig, // ConfigService satisfies this as written
|
|
121
|
+
reveal: (key) => key === 'NODE_ENV' || key.startsWith('PUBLIC_'),
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
There is no "reveal" control on the page. Redaction is decided at boot by the app,
|
|
125
|
+
not per click by whoever reached it.
|
|
126
|
+
|
|
127
|
+
## The queues are bull-board's
|
|
128
|
+
|
|
129
|
+
**dunx renders no queue UI.** `{path}/queues` is
|
|
130
|
+
[bull-board](https://github.com/felixmosh/bull-board), mounted - flows, job logs,
|
|
131
|
+
the repeatable-job editor, per-queue metrics, redis stats, retry/promote/clean, all
|
|
132
|
+
of it, and none of it dunx's to maintain.
|
|
133
|
+
|
|
134
|
+
This package briefly shipped its own queue table, and that was the wrong call under
|
|
135
|
+
the framework's first rule: never invent what a mature library already solves. The
|
|
136
|
+
one thing that had ever justified hand-rolling it was that mounting bull-board on
|
|
137
|
+
`Bun.serve` meant writing a server adapter - which the deleted
|
|
138
|
+
`@dunx/queue-dashboard` did, and which was a liability. **bull-board 8.6.0 ships
|
|
139
|
+
`@bull-board/bun`**, so that reason is gone and the integration is three calls.
|
|
140
|
+
|
|
141
|
+
It also disposes of the question that started all this: `Queue.getWorkers()` returns
|
|
142
|
+
`[]` on Bun, because bullmq matches workers by client name through `CLIENT LIST` and
|
|
143
|
+
its Bun adapter never names a connection. Whatever bullmq can report on Bun is
|
|
144
|
+
bull-board's to report. dunx is not in the business of papering over it, and a
|
|
145
|
+
dashboard that quietly worked around a library's limitation would be a worse place
|
|
146
|
+
to find out about it.
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
bun add @bull-board/api @bull-board/ui @bull-board/bun
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
All three are **optional peers**. Without them the queues panel says so and names
|
|
153
|
+
the install line; nothing else on the page is affected.
|
|
154
|
+
|
|
155
|
+
Two things dunx does contribute, and they are the two bull-board cannot know:
|
|
156
|
+
|
|
157
|
+
- **It is behind the same `authorize`** as the rest of the mount, and answers the
|
|
158
|
+
same 404 to a caller that fails it.
|
|
159
|
+
- **`commands: false` maps onto bull-board's own `readOnlyMode`** rather than dunx
|
|
160
|
+
refusing its POSTs. It already has the switch; a second implementation would
|
|
161
|
+
disagree the moment bull-board grew an operation dunx had not heard of.
|
|
162
|
+
|
|
163
|
+
One caveat worth knowing: **bull-board's page loads a webfont from Google Fonts.**
|
|
164
|
+
dunx's own page fetches nothing, and that guarantee does not extend across the
|
|
165
|
+
handoff.
|
|
166
|
+
|
|
167
|
+
### Naming a queue this process only consumes
|
|
168
|
+
|
|
169
|
+
A queue is a key prefix opened on first use, so `JobPublisher.opened` lists only what
|
|
170
|
+
this process has **published** to. A worker that drains `thumbnails` and publishes
|
|
171
|
+
nothing has opened nothing, and the queue would be invisible on the page that exists
|
|
172
|
+
to show it:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
queueNames: ['thumbnails'],
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
This is free. The board - and therefore any connection to the broker - is built on
|
|
179
|
+
the **first request for `{path}/queues`**, never at boot and never by the polling
|
|
180
|
+
`/api/queues` endpoint, which reads names straight off the options. An app that
|
|
181
|
+
mounts the dashboard and never opens the board holds no socket for it, which is what
|
|
182
|
+
lets a process still exit cleanly against an absent Redis.
|
|
183
|
+
|
|
184
|
+
## Options
|
|
185
|
+
|
|
186
|
+
| Option | Default | Notes |
|
|
187
|
+
| ----------------- | ---------- | -------------------------------------------------------------- |
|
|
188
|
+
| `path` | `/_dunx` | **`setGlobalPrefix` does not move it** - see below |
|
|
189
|
+
| `authorize` | *none* | No default. See Security |
|
|
190
|
+
| `title` | `'dunx'` | Header and `<title>` |
|
|
191
|
+
| `queues` | *none* | `JobPublisher` |
|
|
192
|
+
| `queueNames` | `[]` | Queues this process only consumes |
|
|
193
|
+
| `redis` | *none* | `RedisConnection` |
|
|
194
|
+
| `config` | *none* | `ConfigService`. Absent means no config panel |
|
|
195
|
+
| `reveal` | reveal none| Per-key opt in |
|
|
196
|
+
| `probes` | `[]` | Anything else worth a light |
|
|
197
|
+
| `openApiPath` | *none* | Links each route row into the explorer |
|
|
198
|
+
| `pollMs` | `5000` | `0` turns polling off and leaves the refresh button |
|
|
199
|
+
| `probeTimeoutMs` | `2000` | A hung probe costs one light, not the page |
|
|
200
|
+
| `commands` | `true` | `false` → bull-board's own `readOnlyMode` |
|
|
201
|
+
|
|
202
|
+
`app.setGlobalPrefix('api')` prefixes routes discovered from controllers. The
|
|
203
|
+
dashboard is a **middleware matching a path**, not one of those - which is exactly
|
|
204
|
+
what lets it serve a route table handed over at runtime without generating a
|
|
205
|
+
controller per panel. With a global prefix, say so:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
path: '/api/_dunx',
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Probes
|
|
212
|
+
|
|
213
|
+
Anything with a name and a `check()`. It is awaited with a timeout and never allowed
|
|
214
|
+
to throw into a response, so a hung dependency costs one light rather than the page -
|
|
215
|
+
and a probe that did not answer reads `unknown`, never `down`, because those are
|
|
216
|
+
different facts and one of them sends somebody to restart a healthy service.
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
probes: [
|
|
220
|
+
{
|
|
221
|
+
name: 'database',
|
|
222
|
+
check: async () => {
|
|
223
|
+
await db.execute(sql`select 1`);
|
|
224
|
+
return { state: 'up', detail: 'sqlite' };
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Passing `redis` adds one automatically, on `PING` rather than the connected flag: a
|
|
231
|
+
flag says a socket is up and a round trip says the server is answering.
|
|
232
|
+
|
|
233
|
+
## The page
|
|
234
|
+
|
|
235
|
+
Server-rendered shell, React + Mantine inside it, **inlined** - no CDN, no `src=`,
|
|
236
|
+
no `<link>`, so it opens on a host with no egress. It shares its theme and
|
|
237
|
+
components with the dunx documentation site and the API explorer, so the three look
|
|
238
|
+
like one product.
|
|
239
|
+
|
|
240
|
+
The bundle sits behind `@dunx/dashboard/ui` and is reached with `await import()` on
|
|
241
|
+
the first request for the page, so an app that mounts the module and never opens it
|
|
242
|
+
pays nothing at boot. It is built by `internal/dashboard-ui`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every read the dashboard makes off-process is bounded, and this is the one
|
|
3
|
+
* implementation of that.
|
|
4
|
+
*
|
|
5
|
+
* The failure it exists for is specific and was measured: with Redis unreachable,
|
|
6
|
+
* a queue's `getJobCounts` waits out the connection timeout - 5 s by default in
|
|
7
|
+
* `@dunx/infra/queue` - so opening the dashboard on a broken broker hung the page
|
|
8
|
+
* for exactly as long as the thing you opened it to look at was broken. A
|
|
9
|
+
* dependency being down must cost one panel, not the page.
|
|
10
|
+
*
|
|
11
|
+
* The fallback is a **value**, not a rejection: a queue that could not be reached
|
|
12
|
+
* still gets a row saying so, which is the whole point of looking.
|
|
13
|
+
*/
|
|
14
|
+
export declare const bounded: <T>(work: () => Promise<T>, ms: number, onTimeout: () => T) => Promise<T>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RedisProbe } from '../contracts.js';
|
|
2
|
+
import type { RedisReport } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* `INFO` replies as a text blob of `key:value` lines with `# Section` headers.
|
|
5
|
+
* Parsed here rather than adding a method per field to `RedisProbe`, which is how a
|
|
6
|
+
* structural restatement turns into a client library.
|
|
7
|
+
*/
|
|
8
|
+
export declare const parseInfo: (raw: string) => Readonly<Record<string, string>>;
|
|
9
|
+
export declare const redisReport: (redis: RedisProbe, timeoutMs: number) => Promise<RedisReport>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DashboardProbe } from '../contracts.js';
|
|
2
|
+
import type { DashboardOptions } from '../options.js';
|
|
3
|
+
import type { ProbeReport, RuntimeReport } from './types.js';
|
|
4
|
+
export declare const runProbe: (probe: DashboardProbe, timeoutMs: number) => Promise<ProbeReport>;
|
|
5
|
+
/**
|
|
6
|
+
* The Redis handle, if the app passed one, as a probe like any other - so the
|
|
7
|
+
* lights row has one shape and the panel does not special-case its own dependency.
|
|
8
|
+
*
|
|
9
|
+
* `ping` rather than `connected`: the flag says whether a socket is up, and a
|
|
10
|
+
* round trip says whether the server is answering, which is the question.
|
|
11
|
+
*/
|
|
12
|
+
export declare const redisProbe: (redis: NonNullable<DashboardOptions['redis']>) => DashboardProbe;
|
|
13
|
+
export declare const runtimeReport: (options: DashboardOptions, startedAt: number) => Promise<RuntimeReport>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ModuleRef } from '@dunx/core';
|
|
2
|
+
import type { DashboardOptions } from '../options.js';
|
|
3
|
+
import type { ConfigEntry, Meta, Snapshot } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Keys, types, and a value only where the app's `reveal` predicate said so.
|
|
6
|
+
*
|
|
7
|
+
* Sorted, because a config panel is read by scanning for a key rather than in
|
|
8
|
+
* declaration order, and `validate` returns whatever object literal order the app
|
|
9
|
+
* happened to write.
|
|
10
|
+
*/
|
|
11
|
+
export declare const configEntries: (values: object, reveal: DashboardOptions['reveal']) => readonly ConfigEntry[];
|
|
12
|
+
export declare const metaOf: (options: DashboardOptions) => Meta;
|
|
13
|
+
export declare const snapshotOf: (root: ModuleRef, options: DashboardOptions) => Snapshot;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { ModuleNode, ProviderNode } from '@dunx/core';
|
|
2
|
+
import type { GatewayNode, RouteNode } from '@dunx/http';
|
|
3
|
+
import type { ProbeState } from '../contracts.js';
|
|
4
|
+
/**
|
|
5
|
+
* Everything the page reads, declared once.
|
|
6
|
+
*
|
|
7
|
+
* `internal/dashboard-ui` imports these by relative path from this file, exactly
|
|
8
|
+
* as `internal/openapi-ui` imports `PageModel` from `@dunx/openapi`'s source - so
|
|
9
|
+
* the wire format has one declaration and the frontend cannot drift from the
|
|
10
|
+
* handler that fills it. The node types are re-exported rather than restated for
|
|
11
|
+
* the same reason: they are `@dunx/core`'s and `@dunx/http`'s, and a copy shaped
|
|
12
|
+
* for the browser would be a second thing to keep in step.
|
|
13
|
+
*/
|
|
14
|
+
export type { GatewayNode, ModuleNode, ProbeState, ProviderNode, RouteNode };
|
|
15
|
+
/** Split by lifetime, which is also the split by endpoint. */
|
|
16
|
+
export interface Meta {
|
|
17
|
+
readonly title: string;
|
|
18
|
+
/** The mount, so the bundle can build its own URLs without guessing. */
|
|
19
|
+
readonly basePath: string;
|
|
20
|
+
/** Where `@dunx/openapi` serves its explorer, if the app said. */
|
|
21
|
+
readonly openApiPath: string | undefined;
|
|
22
|
+
/** 0 disables polling. */
|
|
23
|
+
readonly pollMs: number;
|
|
24
|
+
/**
|
|
25
|
+
* Where bull-board is mounted, always `{basePath}/queues`. Carried rather than
|
|
26
|
+
* derived so the page never builds a URL the server did not agree to.
|
|
27
|
+
*/
|
|
28
|
+
readonly queuesPath: string;
|
|
29
|
+
}
|
|
30
|
+
export interface ConfigEntry {
|
|
31
|
+
readonly key: string;
|
|
32
|
+
/** `typeof`, or `array`/`null`, so a shape is visible without the value. */
|
|
33
|
+
readonly type: string;
|
|
34
|
+
/**
|
|
35
|
+
* Present only when the app's `reveal` predicate said so. Absent means
|
|
36
|
+
* redacted - there is no sentinel string, because a sentinel is indistinguishable
|
|
37
|
+
* from a value that happens to be `'***'`.
|
|
38
|
+
*/
|
|
39
|
+
readonly value?: unknown;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The half that cannot change while the process runs. One request, cached by the
|
|
43
|
+
* page for its lifetime.
|
|
44
|
+
*/
|
|
45
|
+
export interface Snapshot {
|
|
46
|
+
readonly meta: Meta;
|
|
47
|
+
readonly routes: readonly RouteNode[];
|
|
48
|
+
readonly gateways: readonly GatewayNode[];
|
|
49
|
+
readonly modules: readonly ModuleNode[];
|
|
50
|
+
readonly providers: readonly ProviderNode[];
|
|
51
|
+
/**
|
|
52
|
+
* Absent when no `ConfigService` is bound, which is a different fact from an
|
|
53
|
+
* empty config and is shown as one.
|
|
54
|
+
*/
|
|
55
|
+
readonly config: readonly ConfigEntry[] | undefined;
|
|
56
|
+
}
|
|
57
|
+
export interface ProbeReport {
|
|
58
|
+
readonly name: string;
|
|
59
|
+
readonly state: ProbeState;
|
|
60
|
+
readonly detail?: string;
|
|
61
|
+
/** How long the probe took, so a slow dependency is visible before it fails. */
|
|
62
|
+
readonly ms: number;
|
|
63
|
+
}
|
|
64
|
+
export interface MemoryReport {
|
|
65
|
+
readonly rss: number;
|
|
66
|
+
readonly heapUsed: number;
|
|
67
|
+
readonly heapTotal: number;
|
|
68
|
+
readonly external: number;
|
|
69
|
+
}
|
|
70
|
+
/** The half that changes. Polled. */
|
|
71
|
+
export interface RuntimeReport {
|
|
72
|
+
readonly pid: number;
|
|
73
|
+
readonly uptimeMs: number;
|
|
74
|
+
readonly bun: string;
|
|
75
|
+
readonly platform: string;
|
|
76
|
+
readonly arch: string;
|
|
77
|
+
readonly memory: MemoryReport;
|
|
78
|
+
readonly probes: readonly ProbeReport[];
|
|
79
|
+
/** Server clock, so the page can show ages rather than raw timestamps. */
|
|
80
|
+
readonly now: number;
|
|
81
|
+
}
|
|
82
|
+
export interface RedisReport {
|
|
83
|
+
readonly configured: true;
|
|
84
|
+
readonly connected: boolean;
|
|
85
|
+
readonly pingMs: number | undefined;
|
|
86
|
+
/**
|
|
87
|
+
* A curated handful from `INFO`: version, mode, uptime, connected clients, used
|
|
88
|
+
* memory, keyspace hits and misses. Not the whole blob - that is 200 lines and it
|
|
89
|
+
* is one `redis-cli INFO` away for anyone who wants it.
|
|
90
|
+
*/
|
|
91
|
+
readonly info: Readonly<Record<string, string>>;
|
|
92
|
+
readonly error?: string;
|
|
93
|
+
}
|
|
94
|
+
/** No `redis` handle was passed, which is different from a broker being down. */
|
|
95
|
+
export interface RedisAbsent {
|
|
96
|
+
readonly configured: false;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* What the queues endpoint answers: **names, and nothing else.**
|
|
100
|
+
*
|
|
101
|
+
* Everything *about* a queue - counts, jobs, retries, flows, metrics - is
|
|
102
|
+
* bull-board's, mounted at `{path}/queues`. dunx renders no queue UI, so this
|
|
103
|
+
* exists only so the page knows whether to offer the link and what to say when
|
|
104
|
+
* there is nothing behind it.
|
|
105
|
+
*/
|
|
106
|
+
export interface QueuesReport {
|
|
107
|
+
readonly queues: readonly string[];
|
|
108
|
+
/** Why there is no board: no source, no queues opened, or bull-board absent. */
|
|
109
|
+
readonly unavailable?: string;
|
|
110
|
+
}
|
package/dist/board.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { DashboardOptions } from './options.js';
|
|
2
|
+
/** The Bun route table `BunAdapter.getRoutes()` produces. */
|
|
3
|
+
export type BoardRoutes = Record<string, Record<string, (req: Request) => Response | Promise<Response>>>;
|
|
4
|
+
export interface Board {
|
|
5
|
+
/** Why there is no board, for the page to show. Absent when there is one. */
|
|
6
|
+
readonly unavailable?: string;
|
|
7
|
+
readonly routes?: BoardRoutes;
|
|
8
|
+
/** The entry route, so an unmatched client-side path serves the page. */
|
|
9
|
+
readonly entry?: (req: Request) => Response | Promise<Response>;
|
|
10
|
+
/** The queue names the board was built with, for the nav. */
|
|
11
|
+
readonly queues: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* bull-board wants **queue objects**, not names, and `QueueSource.queue(name)`
|
|
15
|
+
* opens one - so this is the one place the dashboard touches a broker. It happens
|
|
16
|
+
* on the first request for the board and is memoised by the caller, not at boot: an
|
|
17
|
+
* app that mounts the dashboard and never opens the queues page must not hold a
|
|
18
|
+
* socket for it.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* The names alone, answerable **without opening anything**.
|
|
22
|
+
*
|
|
23
|
+
* That separation is the whole reason `/_dunx/api/queues` and `/_dunx/queues` are
|
|
24
|
+
* different endpoints: the page asks this one on every poll to decide whether to
|
|
25
|
+
* offer the link, and it must not open a socket to answer. Only somebody actually
|
|
26
|
+
* opening the board does that.
|
|
27
|
+
*/
|
|
28
|
+
export declare const boardNames: (options: DashboardOptions) => {
|
|
29
|
+
readonly names: readonly string[];
|
|
30
|
+
readonly unavailable?: string;
|
|
31
|
+
};
|
|
32
|
+
export declare const buildBoard: (options: DashboardOptions, basePath: string, favicon: string) => Promise<Board>;
|
|
33
|
+
/**
|
|
34
|
+
* Dispatches against bull-board's **own** route table - an exact path match, plus
|
|
35
|
+
* the one `/*` prefix its static assets are served under.
|
|
36
|
+
*
|
|
37
|
+
* This is not the JavaScript router dunx bans. It never sees an app's routes: Bun
|
|
38
|
+
* still matches everything real, this runs only inside the dashboard mount, and the
|
|
39
|
+
* table it walks is the one bull-board handed over. Twenty lines here is the price
|
|
40
|
+
* of not asking `HttpFactory` to accept a foreign route table at boot.
|
|
41
|
+
*/
|
|
42
|
+
export declare const matchBoard: (routes: BoardRoutes, method: string, pathname: string) => ((req: Request) => Response | Promise<Response>) | undefined;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __name = (target, name) => {
|
|
6
|
+
Object.defineProperty(target, "name", {
|
|
7
|
+
value: name,
|
|
8
|
+
enumerable: false,
|
|
9
|
+
configurable: true
|
|
10
|
+
});
|
|
11
|
+
return target;
|
|
12
|
+
};
|
|
13
|
+
var __knownSymbol = (name, symbol) => (symbol = Symbol[name]) ? symbol : Symbol.for("Symbol." + name);
|
|
14
|
+
var __typeError = (msg) => {
|
|
15
|
+
throw TypeError(msg);
|
|
16
|
+
};
|
|
17
|
+
var __defNormalProp = (obj, key, value) => (key in obj) ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
18
|
+
var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
|
|
19
|
+
var __privateIn = (member, obj) => Object(obj) !== obj ? __typeError('Cannot use the "in" operator on this value') : member.has(obj);
|
|
20
|
+
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
|
|
21
|
+
var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
|
|
22
|
+
var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
|
|
23
|
+
var __decoratorStart = (base) => [, , , __create(base?.[__knownSymbol("metadata")] ?? null)];
|
|
24
|
+
var __decoratorStrings = ["class", "method", "getter", "setter", "accessor", "field", "value", "get", "set"];
|
|
25
|
+
var __expectFn = (fn) => fn !== undefined && typeof fn !== "function" ? __typeError("Function expected") : fn;
|
|
26
|
+
var __decoratorContext = (kind, name, done, metadata, fns) => ({
|
|
27
|
+
kind: __decoratorStrings[kind],
|
|
28
|
+
name,
|
|
29
|
+
metadata,
|
|
30
|
+
addInitializer: (fn) => done._ ? __typeError("Already initialized") : fns.push(__expectFn(fn || null))
|
|
31
|
+
});
|
|
32
|
+
var __decoratorMetadata = (array, target) => __defNormalProp(target, __knownSymbol("metadata"), array[3]);
|
|
33
|
+
var __runInitializers = (array, flags, self, value) => {
|
|
34
|
+
for (var i = 0, fns = array[flags >> 1], n = fns && fns.length;i < n; i++)
|
|
35
|
+
flags & 1 ? fns[i].call(self) : value = fns[i].call(self, value);
|
|
36
|
+
return value;
|
|
37
|
+
};
|
|
38
|
+
var __decorateElement = (array, flags, name, decorators, target, extra) => {
|
|
39
|
+
var fn, it, done, ctx, access, k = flags & 7, s = !!(flags & 8), p = !!(flags & 16);
|
|
40
|
+
var j = k > 3 ? array.length + 1 : k ? s ? 1 : 2 : 0, key = __decoratorStrings[k + 5];
|
|
41
|
+
var initializers = k > 3 && (array[j - 1] = []), extraInitializers = array[j] || (array[j] = []);
|
|
42
|
+
var desc = k && (!p && !s && (target = target.prototype), k < 5 && (k > 3 || !p) && __getOwnPropDesc(k < 4 ? target : {
|
|
43
|
+
get [name]() {
|
|
44
|
+
return __privateGet(this, extra);
|
|
45
|
+
},
|
|
46
|
+
set [name](x) {
|
|
47
|
+
__privateSet(this, extra, x);
|
|
48
|
+
}
|
|
49
|
+
}, name));
|
|
50
|
+
k ? p && k < 4 && __name(extra, (k > 2 ? "set " : k > 1 ? "get " : "") + name) : __name(target, name);
|
|
51
|
+
for (var i = decorators.length - 1;i >= 0; i--) {
|
|
52
|
+
ctx = __decoratorContext(k, name, done = {}, array[3], extraInitializers);
|
|
53
|
+
if (k) {
|
|
54
|
+
ctx.static = s, ctx.private = p, access = ctx.access = { has: p ? (x) => __privateIn(target, x) : (x) => (name in x) };
|
|
55
|
+
if (k ^ 3)
|
|
56
|
+
access.get = p ? (x) => (k ^ 1 ? __privateGet : __privateMethod)(x, target, k ^ 4 ? extra : desc.get) : (x) => x[name];
|
|
57
|
+
if (k > 2)
|
|
58
|
+
access.set = p ? (x, y) => __privateSet(x, target, y, k ^ 4 ? extra : desc.set) : (x, y) => x[name] = y;
|
|
59
|
+
}
|
|
60
|
+
it = (0, decorators[i])(k ? k < 4 ? p ? extra : desc[key] : k > 4 ? undefined : { get: desc.get, set: desc.set } : target, ctx);
|
|
61
|
+
done._ = 1;
|
|
62
|
+
if (k ^ 4 || it === undefined)
|
|
63
|
+
__expectFn(it) && (k > 4 ? initializers.unshift(it) : k ? p ? extra = it : desc[key] = it : target = it);
|
|
64
|
+
else if (typeof it !== "object" || it === null)
|
|
65
|
+
__typeError("Object expected");
|
|
66
|
+
else
|
|
67
|
+
__expectFn(fn = it.get) && (desc.get = fn), __expectFn(fn = it.set) && (desc.set = fn), __expectFn(fn = it.init) && initializers.unshift(fn);
|
|
68
|
+
}
|
|
69
|
+
return k || __decoratorMetadata(array, target), desc && __defProp(target, name, desc), p ? k ^ 4 ? extra : desc : target;
|
|
70
|
+
};
|
|
71
|
+
var __require = import.meta.require;
|
|
72
|
+
|
|
73
|
+
// src/api/snapshot.ts
|
|
74
|
+
import { modulesOf, providersOf } from "@dunx/core";
|
|
75
|
+
import { gatewaysOf, isGateway, routesOf } from "@dunx/http";
|
|
76
|
+
var typeOf = (value) => {
|
|
77
|
+
if (value === null)
|
|
78
|
+
return "null";
|
|
79
|
+
if (Array.isArray(value))
|
|
80
|
+
return "array";
|
|
81
|
+
return typeof value;
|
|
82
|
+
};
|
|
83
|
+
var configEntries = (values, reveal) => Object.entries(values).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ({
|
|
84
|
+
key,
|
|
85
|
+
type: typeOf(value),
|
|
86
|
+
...reveal(key, value) ? { value } : {}
|
|
87
|
+
}));
|
|
88
|
+
var metaOf = (options) => ({
|
|
89
|
+
title: options.title,
|
|
90
|
+
basePath: options.path,
|
|
91
|
+
openApiPath: options.openApiPath,
|
|
92
|
+
pollMs: options.pollMs,
|
|
93
|
+
queuesPath: `${options.path}/queues`
|
|
94
|
+
});
|
|
95
|
+
var snapshotOf = (root, options) => ({
|
|
96
|
+
meta: metaOf(options),
|
|
97
|
+
routes: routesOf(root),
|
|
98
|
+
gateways: gatewaysOf(root),
|
|
99
|
+
modules: modulesOf(root, { isGateway }),
|
|
100
|
+
providers: providersOf(root, { isGateway }),
|
|
101
|
+
config: options.config === undefined ? undefined : configEntries(options.config.values, options.reveal)
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export { __decoratorStart, __decoratorMetadata, __runInitializers, __decorateElement, __require, metaOf, snapshotOf };
|
|
105
|
+
|
|
106
|
+
//# debugId=1DCCAFB7DE81D21D64756E2164756E21
|
|
107
|
+
//# sourceMappingURL=chunk-xg5554k6.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/api/snapshot.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"import { modulesOf, providersOf, type ModuleRef } from '@dunx/core';\nimport { gatewaysOf, isGateway, routesOf } from '@dunx/http';\nimport type { DashboardOptions } from '../options.js';\nimport type { ConfigEntry, Meta, Snapshot } from './types.js';\n\n/**\n * The static half of the page, built from the same readers `@dunx/mcp` answers\n * with. Nothing here constructs anything: `providersOf` and `routesOf` walk\n * prototypes, so this would answer identically before the app booted.\n *\n * That is the deliberate inversion of MCP's rule. MCP refuses runtime questions\n * because booting an app to answer them would open databases and bind sockets;\n * this package is *already inside* a booted app, so the reason does not apply and\n * the live panels ask the container directly. Splitting it per panel rather than\n * per package is what stops this file growing a `boot()`.\n */\n\nconst typeOf = (value: unknown): string => {\n if (value === null) return 'null';\n if (Array.isArray(value)) return 'array';\n return typeof value;\n};\n\n/**\n * Keys, types, and a value only where the app's `reveal` predicate said so.\n *\n * Sorted, because a config panel is read by scanning for a key rather than in\n * declaration order, and `validate` returns whatever object literal order the app\n * happened to write.\n */\nexport const configEntries = (\n values: object,\n reveal: DashboardOptions['reveal'],\n): readonly ConfigEntry[] =>\n Object.entries(values)\n .sort(([a], [b]) => a.localeCompare(b))\n .map(([key, value]: [string, unknown]) => ({\n key,\n type: typeOf(value),\n ...(reveal(key, value) ? { value } : {}),\n }));\n\nexport const metaOf = (options: DashboardOptions): Meta => ({\n title: options.title,\n basePath: options.path,\n openApiPath: options.openApiPath,\n pollMs: options.pollMs,\n queuesPath: `${options.path}/queues`,\n});\n\nexport const snapshotOf = (\n root: ModuleRef,\n options: DashboardOptions,\n): Snapshot => ({\n meta: metaOf(options),\n routes: routesOf(root),\n gateways: gatewaysOf(root),\n // Core cannot import `@dunx/http`, so the gateway marker arrives as an option.\n // Without it a gateway would be listed as an ordinary provider here while the\n // gateways panel showed it as one, and the two panels would disagree.\n modules: modulesOf(root, { isGateway }),\n providers: providersOf(root, { isGateway }),\n // Absent when the app passed no `config`, which the panel reports as such - a\n // different fact from a configuration with no keys in it.\n config:\n options.config === undefined\n ? undefined\n : configEntries(options.config.values, options.reveal),\n});\n"
|
|
6
|
+
],
|
|
7
|
+
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AACA;AAgBA,IAAM,SAAS,CAAC,UAA2B;AAAA,EACzC,IAAI,UAAU;AAAA,IAAM,OAAO;AAAA,EAC3B,IAAI,MAAM,QAAQ,KAAK;AAAA,IAAG,OAAO;AAAA,EACjC,OAAO,OAAO;AAAA;AAUT,IAAM,gBAAgB,CAC3B,QACA,WAEA,OAAO,QAAQ,MAAM,EAClB,KAAK,EAAE,KAAK,OAAO,EAAE,cAAc,CAAC,CAAC,EACrC,IAAI,EAAE,KAAK,YAA+B;AAAA,EACzC;AAAA,EACA,MAAM,OAAO,KAAK;AAAA,KACd,OAAO,KAAK,KAAK,IAAI,EAAE,MAAM,IAAI,CAAC;AACxC,EAAE;AAEC,IAAM,SAAS,CAAC,aAAqC;AAAA,EAC1D,OAAO,QAAQ;AAAA,EACf,UAAU,QAAQ;AAAA,EAClB,aAAa,QAAQ;AAAA,EACrB,QAAQ,QAAQ;AAAA,EAChB,YAAY,GAAG,QAAQ;AACzB;AAEO,IAAM,aAAa,CACxB,MACA,aACc;AAAA,EACd,MAAM,OAAO,OAAO;AAAA,EACpB,QAAQ,SAAS,IAAI;AAAA,EACrB,UAAU,WAAW,IAAI;AAAA,EAIzB,SAAS,UAAU,MAAM,EAAE,UAAU,CAAC;AAAA,EACtC,WAAW,YAAY,MAAM,EAAE,UAAU,CAAC;AAAA,EAG1C,QACE,QAAQ,WAAW,YACf,YACA,cAAc,QAAQ,OAAO,QAAQ,QAAQ,MAAM;AAC3D;",
|
|
8
|
+
"debugId": "1DCCAFB7DE81D21D64756E2164756E21",
|
|
9
|
+
"names": []
|
|
10
|
+
}
|