@dunx/dashboard 2.5.0 → 3.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/README.md +46 -209
- package/dist/api/bounded.d.ts +6 -10
- package/dist/api/types.d.ts +1 -1
- package/dist/board.d.ts +3 -6
- package/dist/{chunk-xps6r8jv.js → chunk-66xm1dzm.js} +1 -1
- package/dist/contracts.d.ts +26 -72
- package/dist/index.js +3 -7
- package/dist/middleware.d.ts +8 -19
- package/dist/module.d.ts +5 -10
- package/dist/options.d.ts +29 -83
- package/dist/ui.d.ts +6 -12
- package/dist/ui.js +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,242 +1,79 @@
|
|
|
1
1
|
# @dunx/dashboard
|
|
2
2
|
|
|
3
|
-
One page over a running dunx app: the
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
One page over a running [dunx](https://github.com/petarzarkov/dunx) app: the
|
|
4
|
+
routes it serves, the container it built, the gateways it upgrades, Redis, the
|
|
5
|
+
config keys and the process itself, **with bull-board mounted for the queues**.
|
|
6
|
+
|
|
7
|
+
Every panel reads data dunx already computes, so the page is cheap.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
6
10
|
|
|
7
11
|
```bash
|
|
8
12
|
bun add @dunx/dashboard
|
|
13
|
+
# for the queues page
|
|
14
|
+
bun add @bull-board/api @bull-board/ui @bull-board/bun
|
|
9
15
|
```
|
|
10
16
|
|
|
11
|
-
|
|
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
|
|
17
|
+
## Usage
|
|
18
18
|
|
|
19
19
|
```ts
|
|
20
20
|
import { DashboardMiddleware, DashboardModule } from '@dunx/dashboard';
|
|
21
|
-
import { JobPublisher
|
|
22
|
-
import { RedisConnection
|
|
21
|
+
import { JobPublisher } from '@dunx/infra/queue';
|
|
22
|
+
import { RedisConnection } from '@dunx/infra/redis';
|
|
23
23
|
|
|
24
24
|
@Module({
|
|
25
25
|
imports: [
|
|
26
26
|
DashboardModule.forRootAsync({
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
imports: [QueueModule, RedisModule],
|
|
27
|
+
imports: [JobsModule, CacheModule],
|
|
28
|
+
inject: [JobPublisher, RedisConnection],
|
|
30
29
|
useFactory: (queues: JobPublisher, redis: RedisConnection) => ({
|
|
30
|
+
path: '/api/_dunx',
|
|
31
31
|
queues,
|
|
32
32
|
redis,
|
|
33
|
-
authorize: (req) => req
|
|
33
|
+
authorize: (req) => isOperator(req),
|
|
34
34
|
}),
|
|
35
|
-
inject: [JobPublisher, RedisConnection] as const,
|
|
36
35
|
}),
|
|
37
36
|
],
|
|
38
37
|
})
|
|
39
|
-
export class
|
|
40
|
-
```
|
|
38
|
+
export class OpsModule {}
|
|
41
39
|
|
|
42
|
-
```ts
|
|
43
40
|
const app = await HttpFactory.create(AppModule);
|
|
44
|
-
app.use(DashboardMiddleware
|
|
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 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 rather than 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
|
|
135
|
-
under the framework's first rule: never invent what a mature library already
|
|
136
|
-
solves.
|
|
137
|
-
|
|
138
|
-
The one thing that had ever justified hand-rolling it was that mounting
|
|
139
|
-
bull-board on `Bun.serve` meant writing a server adapter - which the deleted
|
|
140
|
-
`@dunx/queue-dashboard` did, and which was a liability. **bull-board 8.6.0
|
|
141
|
-
ships `@bull-board/bun`**, so that reason is gone and the integration is three
|
|
142
|
-
calls.
|
|
143
|
-
|
|
144
|
-
It also settled the question that started all this - see **Workers do show up**
|
|
145
|
-
below. The hand-rolled panel had planned to omit a worker column and explain why;
|
|
146
|
-
mounting bull-board asked the question honestly and exposed the real cause.
|
|
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: **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'],
|
|
41
|
+
app.use(DashboardMiddleware); // first, ahead of any session guard
|
|
176
42
|
```
|
|
177
43
|
|
|
178
|
-
|
|
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
|
|
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:
|
|
44
|
+
## The panels
|
|
206
45
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
```
|
|
46
|
+
Six panels, each with a JSON sibling under `{path}/api/*`, so `curl` is a real
|
|
47
|
+
way to read this on a box with no browser. The queues page is bull-board's.
|
|
210
48
|
|
|
211
|
-
|
|
49
|
+
| Panel | Shows |
|
|
50
|
+
| --------- | ------------------------------------------------------------ |
|
|
51
|
+
| Routes | Every route, its guards and metadata, linked to `/docs` |
|
|
52
|
+
| Providers | The container graph, by module |
|
|
53
|
+
| Gateways | Every WebSocket gateway and its events |
|
|
54
|
+
| Redis | Connection state and `INFO` |
|
|
55
|
+
| Config | Keys always, values only where `reveal` says so |
|
|
56
|
+
| Runtime | The process, its memory and its probes |
|
|
212
57
|
|
|
213
|
-
|
|
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.
|
|
58
|
+
## Three things that are decisions
|
|
217
59
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
},
|
|
226
|
-
},
|
|
227
|
-
],
|
|
228
|
-
```
|
|
60
|
+
- **`authorize` has no default**, and leaving it out serves the page to anyone
|
|
61
|
+
who can reach the port. Omitting it logs a warning naming the mount at boot.
|
|
62
|
+
- **A rejected request gets 404, not 403.** Register the middleware **ahead of
|
|
63
|
+
any session guard**: a guard running first answers 401 and tells a prober the
|
|
64
|
+
mount exists. `authorize` takes the raw `Request` so it can be self-sufficient.
|
|
65
|
+
- **Config values are redacted by default.** `reveal` is an opt-in allow-list; a
|
|
66
|
+
deny-list of the usual suspects leaks the first key nobody thought of.
|
|
229
67
|
|
|
230
|
-
|
|
231
|
-
flag says a socket is up and a round trip says the server is answering.
|
|
68
|
+
## Notes
|
|
232
69
|
|
|
233
|
-
|
|
70
|
+
- It depends on `@dunx/infra` and `bullmq` not at all. `QueueSource` and
|
|
71
|
+
`RedisProbe` restate structurally what `JobPublisher` and `RedisConnection`
|
|
72
|
+
already are, so `queues: publisher` is the whole wiring.
|
|
73
|
+
- The board is built on the first request for the queues page, never at boot, so
|
|
74
|
+
an app that never opens it holds no broker socket.
|
|
75
|
+
- `commands: false` maps onto bull-board's own `readOnlyMode`.
|
|
234
76
|
|
|
235
|
-
|
|
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.
|
|
77
|
+
## License
|
|
239
78
|
|
|
240
|
-
|
|
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`.
|
|
79
|
+
MIT
|
package/dist/api/bounded.d.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Every read the dashboard makes off-process is bounded
|
|
3
|
-
*
|
|
2
|
+
* Every read the dashboard makes off-process is bounded here. With Redis
|
|
3
|
+
* unreachable, `getJobCounts` waits out the 5 s connection timeout, so opening the
|
|
4
|
+
* dashboard on a broken broker hung the page for as long as the thing you opened
|
|
5
|
+
* it to look at was broken.
|
|
4
6
|
*
|
|
5
|
-
* The
|
|
6
|
-
* a
|
|
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.
|
|
7
|
+
* The fallback is a value rather than a rejection: an unreachable queue still gets
|
|
8
|
+
* a row saying so.
|
|
13
9
|
*/
|
|
14
10
|
export declare const bounded: <T>(work: () => Promise<T>, ms: number, onTimeout: () => T) => Promise<T>;
|
package/dist/api/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ModuleNode, ProviderNode } from '@dunx/core';
|
|
2
|
-
import type { GatewayNode, RouteNode } from '@dunx/http';
|
|
2
|
+
import type { GatewayNode, RouteNode } from '@dunx/http/internal';
|
|
3
3
|
import type { ProbeState } from '../contracts.js';
|
|
4
4
|
/**
|
|
5
5
|
* Everything the page reads, declared once.
|
package/dist/board.d.ts
CHANGED
|
@@ -18,12 +18,9 @@ export interface Board {
|
|
|
18
18
|
* socket for it.
|
|
19
19
|
*/
|
|
20
20
|
/**
|
|
21
|
-
* The names alone, answerable
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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.
|
|
21
|
+
* The names alone, answerable without opening anything - which is why
|
|
22
|
+
* `/_dunx/api/queues` and `/_dunx/queues` are different endpoints. The page polls
|
|
23
|
+
* this one to decide whether to offer the link, and must not open a socket for it.
|
|
27
24
|
*/
|
|
28
25
|
export declare const boardNames: (options: DashboardOptions) => {
|
|
29
26
|
readonly names: readonly string[];
|
|
@@ -72,7 +72,7 @@ var __require = import.meta.require;
|
|
|
72
72
|
|
|
73
73
|
// src/api/snapshot.ts
|
|
74
74
|
import { modulesOf, providersOf } from "@dunx/core";
|
|
75
|
-
import { gatewaysOf, isGateway, routesOf } from "@dunx/http";
|
|
75
|
+
import { gatewaysOf, isGateway, routesOf } from "@dunx/http/internal";
|
|
76
76
|
var typeOf = (value) => {
|
|
77
77
|
if (value === null)
|
|
78
78
|
return "null";
|
package/dist/contracts.d.ts
CHANGED
|
@@ -1,74 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What the dashboard needs from the things it reports on, restated structurally
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* bullmq's `Queue` and `Job` in enough detail to drive a queue table - signatures
|
|
12
|
-
* shaped to satisfy bullmq's own variance, with a paragraph explaining why. All of
|
|
13
|
-
* it went when bull-board took the queue UI back: the queue object is now passed
|
|
14
|
-
* straight through to `BullMQAdapter`, so there is nothing left to describe.
|
|
15
|
-
*
|
|
16
|
-
* Everything here is satisfied by an object an app already has:
|
|
17
|
-
*
|
|
18
|
-
* | This | Satisfied by |
|
|
19
|
-
* | ---------------- | ---------------------------------------- |
|
|
20
|
-
* | `QueueSource` | `JobPublisher` from `@dunx/infra/queue` |
|
|
21
|
-
* | `RedisProbe` | `RedisConnection` from `@dunx/infra/redis` |
|
|
22
|
-
* | `ConfigValues` | `ConfigService` from `@dunx/core` |
|
|
23
|
-
*
|
|
24
|
-
* No adapter, no wrapper - `queues: publisher` in the options is the whole wiring,
|
|
25
|
-
* which is what makes the restatement worth its lines rather than a tax.
|
|
2
|
+
* What the dashboard needs from the things it reports on, restated structurally
|
|
3
|
+
* so this package depends on `@dunx/infra` and `bullmq` not at all. Each is
|
|
4
|
+
* satisfied by an object an app already has:
|
|
5
|
+
*
|
|
6
|
+
* | This | Satisfied by |
|
|
7
|
+
* | -------------- | ------------------------------------------ |
|
|
8
|
+
* | `QueueSource` | `JobPublisher` from `@dunx/infra/queue` |
|
|
9
|
+
* | `RedisProbe` | `RedisConnection` from `@dunx/infra/redis` |
|
|
10
|
+
* | `ConfigValues` | `ConfigService` from `@dunx/core` |
|
|
26
11
|
*/
|
|
27
12
|
/**
|
|
28
|
-
* The validated configuration. `ConfigService` satisfies it as written.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* over. Mechanically, `inject()` only works inside a class the container builds and
|
|
32
|
-
* this middleware is built by a factory. But the better reason is that showing an
|
|
33
|
-
* app's configuration should be something the app **says yes to** - the same
|
|
34
|
-
* instinct behind `reveal` defaulting to revealing nothing.
|
|
13
|
+
* The validated configuration. `ConfigService` satisfies it as written. Passed in
|
|
14
|
+
* rather than resolved, so showing an app's configuration is something the app
|
|
15
|
+
* says yes to.
|
|
35
16
|
*/
|
|
36
17
|
export interface ConfigValues {
|
|
37
|
-
/**
|
|
38
|
-
*
|
|
39
|
-
* with no index signature, so the record type would reject the very
|
|
40
|
-
* `ConfigService` this exists to accept. It is enumerated, never read by a key
|
|
41
|
-
* this package knows.
|
|
42
|
-
*/
|
|
18
|
+
/** `object` rather than `Record<string, unknown>`: an app's `AppConfig` has no
|
|
19
|
+
* index signature, so the record type would reject it. */
|
|
43
20
|
readonly values: object;
|
|
44
21
|
}
|
|
45
22
|
/**
|
|
46
|
-
* Where queues come from. `JobPublisher` satisfies it as written.
|
|
47
|
-
*
|
|
48
|
-
* `
|
|
49
|
-
* same as "every queue this app has" - a queue is a key prefix opened on first use,
|
|
50
|
-
* so a web process that has published to none has opened none. That is why
|
|
51
|
-
* `DashboardOptions.queueNames` exists: a process that consumes a queue it never
|
|
52
|
-
* publishes to has to name it, and the panel says which of the two it is showing.
|
|
23
|
+
* Where queues come from. `JobPublisher` satisfies it as written. `opened` is what
|
|
24
|
+
* the publisher has opened so far, not every queue the app has - which is why
|
|
25
|
+
* `DashboardOptions.queueNames` exists for a consume-only process.
|
|
53
26
|
*/
|
|
54
27
|
export interface QueueSource {
|
|
55
28
|
readonly opened: readonly string[];
|
|
56
|
-
/**
|
|
57
|
-
*
|
|
58
|
-
* why the return type is `unknown` rather than a restatement. dunx reads nothing
|
|
59
|
-
* off it and calls nothing on it; matching bullmq's own signatures here was a
|
|
60
|
-
* whole file of variance notes existing only to describe a UI dunx no longer
|
|
61
|
-
* renders.
|
|
62
|
-
*/
|
|
29
|
+
/** bullmq's `Queue`, handed to `BullMQAdapter` untouched, so the return type is
|
|
30
|
+
* `unknown` rather than a restatement. */
|
|
63
31
|
queue(name: string): unknown;
|
|
64
32
|
}
|
|
65
33
|
/**
|
|
66
|
-
* Enough Redis to answer "is it up and what is it doing". `
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* `send` rather than a typed `info()`: `INFO` is one command whose reply is a text
|
|
70
|
-
* blob, and adding a method per Redis command to a restatement is how a
|
|
71
|
-
* restatement becomes a client library.
|
|
34
|
+
* Enough Redis to answer "is it up and what is it doing". `send` rather than a
|
|
35
|
+
* typed `info()`: a method per command is how a restatement becomes a client.
|
|
72
36
|
*/
|
|
73
37
|
export interface RedisProbe {
|
|
74
38
|
readonly connected: boolean;
|
|
@@ -76,25 +40,15 @@ export interface RedisProbe {
|
|
|
76
40
|
send(command: string, args?: readonly string[]): Promise<unknown>;
|
|
77
41
|
}
|
|
78
42
|
/**
|
|
79
|
-
* The state a probe reports, and its result. `unknown` is not `down
|
|
80
|
-
* `
|
|
81
|
-
*
|
|
82
|
-
* Declared in `@dunx/http` and re-exported here. They moved down when the health
|
|
83
|
-
* module became a second consumer: this package already peer-depends on
|
|
84
|
-
* `@dunx/http`, so the descent adds no edge, and two copies of a three-value union
|
|
85
|
-
* is how one of them gains a fourth value nobody else honours.
|
|
86
|
-
*
|
|
87
|
-
* `RedisProbe` below did **not** move with them. `@dunx/http`'s `PingProbe` is a
|
|
88
|
-
* `ping` and nothing else, while this needs `connected` and `send` for the `INFO`
|
|
89
|
-
* panel, so one shared contract would oblige a health check to supply two members
|
|
90
|
-
* it never calls.
|
|
43
|
+
* The state a probe reports, and its result. `unknown` is not `down`. Declared in
|
|
44
|
+
* `@dunx/http` and re-exported here, since this package already peer-depends on
|
|
45
|
+
* it. `RedisProbe` below did not move with them: `PingProbe` is a `ping` alone.
|
|
91
46
|
*/
|
|
92
47
|
export type { ProbeResult, ProbeState } from '@dunx/http';
|
|
93
48
|
import type { ProbeResult } from '@dunx/http';
|
|
94
49
|
/**
|
|
95
|
-
* Anything else worth a light on the page
|
|
96
|
-
*
|
|
97
|
-
* response, so a probe that hangs costs one panel rather than the page.
|
|
50
|
+
* Anything else worth a light on the page. Awaited with a timeout and never let
|
|
51
|
+
* to throw, so a probe that hangs costs one panel rather than the page.
|
|
98
52
|
*/
|
|
99
53
|
export interface DashboardProbe {
|
|
100
54
|
readonly name: string;
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
__require,
|
|
7
7
|
__runInitializers,
|
|
8
8
|
snapshotOf
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-66xm1dzm.js";
|
|
10
10
|
|
|
11
11
|
// src/module.ts
|
|
12
12
|
import {
|
|
@@ -167,9 +167,7 @@ class DashboardOptions {
|
|
|
167
167
|
this.commands = init.commands ?? true;
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
|
-
Object.defineProperty(DashboardOptions, Symbol.for("dunx.deps"), {
|
|
171
|
-
value: () => [{ unresolved: "init: DashboardOptionsInit = {}" }]
|
|
172
|
-
});
|
|
170
|
+
Object.defineProperty(DashboardOptions, Symbol.for("dunx.deps"), { value: () => [{ unresolved: "init: DashboardOptionsInit = {}", optional: true }] });
|
|
173
171
|
var normalizeMount = (path) => {
|
|
174
172
|
const trimmed = `/${path.split("/").filter(Boolean).join("/")}`;
|
|
175
173
|
if (trimmed === "/") {
|
|
@@ -416,9 +414,7 @@ class DashboardMiddleware {
|
|
|
416
414
|
return this.#page;
|
|
417
415
|
}
|
|
418
416
|
}
|
|
419
|
-
Object.defineProperty(DashboardMiddleware, Symbol.for("dunx.deps"), {
|
|
420
|
-
value: () => [DashboardOptions, { unresolved: "root: ModuleRef", typeOnly: "ModuleRef" }, Logger]
|
|
421
|
-
});
|
|
417
|
+
Object.defineProperty(DashboardMiddleware, Symbol.for("dunx.deps"), { value: () => [DashboardOptions, { unresolved: "root: ModuleRef", typeOnly: "ModuleRef" }, Logger] });
|
|
422
418
|
|
|
423
419
|
// src/module.ts
|
|
424
420
|
var middleware = () => provide(DashboardMiddleware, {
|
package/dist/middleware.d.ts
CHANGED
|
@@ -3,27 +3,16 @@ import type { Middleware, Next, RouteContext } from '@dunx/http';
|
|
|
3
3
|
import type { BunRequest } from 'bun';
|
|
4
4
|
import { DashboardOptions } from './options.js';
|
|
5
5
|
/**
|
|
6
|
-
* A
|
|
6
|
+
* A global middleware rather than a controller: `app.use` runs in front of the
|
|
7
|
+
* unmatched-path fallback, which is where the dashboard's paths land because the
|
|
8
|
+
* app declares none of them.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* its own `@Get`.
|
|
10
|
+
* Register it ahead of any session guard - with this last in the chain a guard
|
|
11
|
+
* answers `401` before `authorize` runs, defeating the 404 contract. That works
|
|
12
|
+
* only because `authorize` gets the raw `Request`, so keep it self-sufficient.
|
|
13
|
+
* Anything outside the mount falls through untouched.
|
|
13
14
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* - **Register it ahead of any session guard.** Measured in `dunx-template`: with
|
|
17
|
-
* this last in the chain, `SessionGuard` answered every dashboard request `401`
|
|
18
|
-
* before `authorize` ran, which defeats the 404 contract entirely. That works
|
|
19
|
-
* only because `authorize` gets the raw `Request` and can ask the auth library
|
|
20
|
-
* itself, so keep it self-sufficient.
|
|
21
|
-
* - **Anything outside the mount falls through untouched**, so the app's own
|
|
22
|
-
* routes and its 404 behave exactly as before.
|
|
23
|
-
*
|
|
24
|
-
* The page bundle is built on the **first request** and memoised on the promise,
|
|
25
|
-
* so two concurrent first requests build one page and importing this package pulls
|
|
26
|
-
* in none of its 400-odd KB.
|
|
15
|
+
* The page bundle is built on the first request and memoised on the promise.
|
|
27
16
|
*/
|
|
28
17
|
export declare class DashboardMiddleware implements Middleware {
|
|
29
18
|
#private;
|
package/dist/module.d.ts
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
import { type AsyncModuleConfig, type Deps, type DynamicModule } from '@dunx/core';
|
|
2
2
|
import { type DashboardOptionsInit } from './options.js';
|
|
3
3
|
/**
|
|
4
|
-
* Binds the options and the middleware
|
|
5
|
-
*
|
|
6
|
-
* position in the chain is the whole security property here.
|
|
4
|
+
* Binds the options and the middleware, and does not register it - the app does,
|
|
5
|
+
* because position in the chain is the security property here.
|
|
7
6
|
*
|
|
8
|
-
* `@Module({ middleware })` scopes
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* it has to be **ahead of any session guard** - measured in `dunx-template`, where a
|
|
12
|
-
* guard running first answered every dashboard request `401` before `authorize`
|
|
13
|
-
* ran, defeating the 404 contract. Two lines in the app, one of which is a
|
|
14
|
-
* decision:
|
|
7
|
+
* `@Module({ middleware })` scopes to that module's own controllers, of which this
|
|
8
|
+
* has none. Registering globally from inside would put it wherever the module was
|
|
9
|
+
* imported, and it has to sit ahead of any session guard:
|
|
15
10
|
*
|
|
16
11
|
* ```ts
|
|
17
12
|
* const app = await HttpFactory.create(AppModule);
|
package/dist/options.d.ts
CHANGED
|
@@ -1,131 +1,80 @@
|
|
|
1
1
|
import type { BunRequest } from 'bun';
|
|
2
2
|
import type { ConfigValues, DashboardProbe, QueueSource, RedisProbe } from './contracts.js';
|
|
3
3
|
/**
|
|
4
|
-
* Decides whether a request may see the dashboard at all.
|
|
4
|
+
* Decides whether a request may see the dashboard at all. It receives the raw
|
|
5
|
+
* `Request`: the middleware must be registered ahead of any session guard, so
|
|
6
|
+
* there is nothing upstream to have written a context and this has to ask the
|
|
7
|
+
* auth library itself.
|
|
5
8
|
*
|
|
6
|
-
*
|
|
7
|
-
* wrote, and that is load bearing rather than incidental. The dashboard middleware
|
|
8
|
-
* has to be registered ahead of any session guard - measured in `dunx-template`,
|
|
9
|
-
* where a guard running first answered every dashboard request `401` before
|
|
10
|
-
* `authorize` was reached, which defeats the 404 contract below entirely. Running
|
|
11
|
-
* first means there is nothing upstream to have written a context, so this has to
|
|
12
|
-
* be able to ask the auth library itself.
|
|
13
|
-
*
|
|
14
|
-
* A rejected request gets **404, not 403**. A dashboard that announces itself to
|
|
15
|
-
* an unauthenticated caller has told them where to keep knocking.
|
|
9
|
+
* A rejected request gets 404, not 403.
|
|
16
10
|
*/
|
|
17
11
|
export type Authorize = (req: BunRequest) => boolean | Promise<boolean>;
|
|
18
12
|
/**
|
|
19
|
-
* Whether a config value may be shown.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* design left. `ConfigService` holds whatever the app's `validate` returned, which
|
|
23
|
-
* includes every secret it has; a deny-list of the usual suspects - `SECRET`,
|
|
24
|
-
* `PASSWORD`, `TOKEN` - looks careful and leaks the first key nobody thought of.
|
|
25
|
-
* A deny-list that quietly misses one is worse than no config panel at all.
|
|
26
|
-
*
|
|
27
|
-
* So the panel shows **keys and types** by default, which is most of what it was
|
|
28
|
-
* wanted for ("is FEATURE_X actually set here"), and a value appears only when this
|
|
29
|
-
* predicate says so:
|
|
13
|
+
* Whether a config value may be shown. The default reveals nothing: a deny-list
|
|
14
|
+
* of the usual suspects leaks the first key nobody thought of. The panel shows
|
|
15
|
+
* keys and types, and a value appears only when this says so:
|
|
30
16
|
*
|
|
31
17
|
* ```ts
|
|
32
18
|
* reveal: (key) => key.startsWith('PUBLIC_') || key === 'NODE_ENV'
|
|
33
19
|
* ```
|
|
34
20
|
*
|
|
35
|
-
* There is no
|
|
36
|
-
* app, not per click by whoever reached the page.
|
|
21
|
+
* There is no reveal affordance on the page; redaction is decided at boot.
|
|
37
22
|
*/
|
|
38
23
|
export type Reveal = (key: string, value: unknown) => boolean;
|
|
39
24
|
export interface DashboardOptionsInit {
|
|
40
25
|
/**
|
|
41
|
-
* Where the page is mounted
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* **`app.setGlobalPrefix('api')` does not move it.** That prefixes the routes
|
|
46
|
-
* discovered from controllers, and this is not one of those - it is a middleware
|
|
47
|
-
* matching a path, which is the whole reason the dashboard needs no controllers
|
|
48
|
-
* for a table handed over at runtime. An app with a global prefix that wants the
|
|
49
|
-
* page beside its routes writes `path: '/api/_dunx'` here.
|
|
26
|
+
* Where the page is mounted, `/_dunx` by default. `app.setGlobalPrefix('api')`
|
|
27
|
+
* does not move it: that prefixes discovered routes, and this is a middleware
|
|
28
|
+
* matching a path. Write `path: '/api/_dunx'` to put it beside them.
|
|
50
29
|
*/
|
|
51
30
|
readonly path?: string;
|
|
52
31
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* else, so it is stated either way rather than guessed: omitting it logs a
|
|
56
|
-
* warning naming the mount path at boot.
|
|
32
|
+
* No default: leaving it out serves the page to anyone who can reach the port,
|
|
33
|
+
* and logs a warning naming the mount path at boot.
|
|
57
34
|
*/
|
|
58
35
|
readonly authorize?: Authorize;
|
|
59
36
|
/** Shown in the header and the `<title>`. @default 'dunx' */
|
|
60
37
|
readonly title?: string;
|
|
61
|
-
/**
|
|
62
|
-
* `JobPublisher` goes here. Absent means the queues panel says this process has
|
|
63
|
-
* no queue source rather than that it has no queues.
|
|
64
|
-
*/
|
|
38
|
+
/** `JobPublisher` goes here. Absent means the panel reports no queue source. */
|
|
65
39
|
readonly queues?: QueueSource;
|
|
66
|
-
/**
|
|
67
|
-
*
|
|
68
|
-
* **consumes** a queue never publishes to it, so the publisher has never opened
|
|
69
|
-
* it and it would otherwise be invisible on the page that exists to show it.
|
|
70
|
-
*/
|
|
40
|
+
/** Queues beyond the ones the source has opened. A consume-only process never
|
|
41
|
+
* publishes, so its queues would otherwise be invisible. */
|
|
71
42
|
readonly queueNames?: readonly string[];
|
|
72
43
|
/** `RedisConnection` goes here; it drives the Redis panel and one probe. */
|
|
73
44
|
readonly redis?: RedisProbe;
|
|
74
45
|
/** Anything else worth a light: a database, an upstream, a leader lease. */
|
|
75
46
|
readonly probes?: readonly DashboardProbe[];
|
|
76
|
-
/**
|
|
77
|
-
* `ConfigService` goes here, and the panel is absent without it - showing an
|
|
78
|
-
* app's configuration is something the app says yes to, not something this
|
|
79
|
-
* package reaches into the container for.
|
|
80
|
-
*/
|
|
47
|
+
/** `ConfigService` goes here; the panel is absent without it. */
|
|
81
48
|
readonly config?: ConfigValues;
|
|
82
49
|
/** See {@link Reveal}. The default reveals nothing, even with `config` set. */
|
|
83
50
|
readonly reveal?: Reveal;
|
|
84
|
-
/**
|
|
85
|
-
*
|
|
86
|
-
* to the operation that documents it. A string, not a dependency: the two
|
|
87
|
-
* packages describe the same routes for different audiences and a link is free,
|
|
88
|
-
* where importing one into the other is not.
|
|
89
|
-
*/
|
|
51
|
+
/** Where `@dunx/openapi` serves its explorer, so a routes row can link to the
|
|
52
|
+
* operation documenting it. A string rather than a dependency. */
|
|
90
53
|
readonly openApiPath?: string;
|
|
91
54
|
/**
|
|
92
|
-
* How often the live panels re-fetch, in milliseconds. `0` turns polling off
|
|
93
|
-
* leaves the refresh button.
|
|
94
|
-
*
|
|
95
|
-
* Polling rather than a websocket, deliberately: the page is stateless, a gateway
|
|
96
|
-
* would put the dashboard in the app's own upgrade table, and 5 s is well inside
|
|
97
|
-
* what "how many jobs are failing" needs.
|
|
55
|
+
* How often the live panels re-fetch, in milliseconds. `0` turns polling off
|
|
56
|
+
* and leaves the refresh button. Polling rather than a websocket, which would
|
|
57
|
+
* put the dashboard in the app's own upgrade table.
|
|
98
58
|
*
|
|
99
59
|
* @default 5000
|
|
100
60
|
*/
|
|
101
61
|
readonly pollMs?: number;
|
|
102
62
|
/**
|
|
103
|
-
* How long a probe may take before it is reported `unknown`.
|
|
104
|
-
* cost one light, not the page.
|
|
63
|
+
* How long a probe may take before it is reported `unknown`.
|
|
105
64
|
*
|
|
106
65
|
* @default 2000
|
|
107
66
|
*/
|
|
108
67
|
readonly probeTimeoutMs?: number;
|
|
109
68
|
/**
|
|
110
|
-
* Whether the queue board may change anything
|
|
111
|
-
*
|
|
112
|
-
* Passed through to **bull-board's own `readOnlyMode`** rather than enforced
|
|
113
|
-
* here: it already has the switch, and a second implementation would disagree
|
|
114
|
-
* with it the moment bull-board grew an operation dunx had not heard of.
|
|
115
|
-
*
|
|
116
|
-
* The rest of the dashboard is read-only regardless - it reports on the process
|
|
117
|
-
* and never acts on it - so this is entirely about the queues page. `authorize`
|
|
118
|
-
* gates who reaches the mount; this gates what they can do once there.
|
|
69
|
+
* Whether the queue board may change anything, passed through to bull-board's
|
|
70
|
+
* own `readOnlyMode`. The rest of the dashboard is read-only regardless.
|
|
119
71
|
*
|
|
120
72
|
* @default true
|
|
121
73
|
*/
|
|
122
74
|
readonly commands?: boolean;
|
|
123
75
|
}
|
|
124
|
-
/**
|
|
125
|
-
*
|
|
126
|
-
* constructor parameter type that `@dunx/transform` records - the same reason
|
|
127
|
-
* `QueueOptions` and `RedisOptions` are classes.
|
|
128
|
-
*/
|
|
76
|
+
/** A class rather than an interface, so it is a runtime value the transform can
|
|
77
|
+
* record as a constructor parameter type. */
|
|
129
78
|
export declare class DashboardOptions {
|
|
130
79
|
readonly path: string;
|
|
131
80
|
readonly authorize: Authorize | undefined;
|
|
@@ -144,9 +93,6 @@ export declare class DashboardOptions {
|
|
|
144
93
|
}
|
|
145
94
|
/**
|
|
146
95
|
* A leading slash and no trailing one, so `${path}/api/...` is never `//api`.
|
|
147
|
-
*
|
|
148
|
-
* `/` itself is rejected: mounting the dashboard at the root would swallow every
|
|
149
|
-
* unmatched path in the app, and the middleware's whole contract is that anything
|
|
150
|
-
* outside its mount falls through untouched.
|
|
96
|
+
* `/` is rejected: mounting at the root would swallow every unmatched path.
|
|
151
97
|
*/
|
|
152
98
|
export declare const normalizeMount: (path: string) => string;
|
package/dist/ui.d.ts
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
1
|
import type { DashboardOptions } from './options.js';
|
|
2
2
|
import { FAVICON, UI } from './ui-bundle.js';
|
|
3
3
|
/**
|
|
4
|
-
* The page, behind its own entrypoint.
|
|
4
|
+
* The page, behind its own entrypoint. `ui-bundle.ts` is the inlined Vite output,
|
|
5
|
+
* reached with `await import('./ui.js')` on the first request for the page, so an
|
|
6
|
+
* app that mounts the module and never opens it pays nothing at boot.
|
|
5
7
|
*
|
|
6
|
-
* `ui-bundle.ts
|
|
7
|
-
*
|
|
8
|
-
* this with `await import('./ui.js')` on the **first request for the page**, so an
|
|
9
|
-
* app that mounts the module and never opens it - a worker process, a service
|
|
10
|
-
* whose dashboard nobody visits this week - pays nothing at boot.
|
|
8
|
+
* `html.ts` must not import `ui-bundle.ts`, or the split silently reverts; it
|
|
9
|
+
* takes the bundle as an argument for that reason.
|
|
11
10
|
*
|
|
12
|
-
* `
|
|
13
|
-
* reverts. It takes the bundle as an argument for exactly that reason.
|
|
14
|
-
*
|
|
15
|
-
* This is `@dunx/dashboard/ui` in the manifest, and it is an entrypoint rather than
|
|
16
|
-
* a plain module so the build emits it as its own file - see
|
|
17
|
-
* `scripts/build-package.ts`, which derives entrypoints from `exports`.
|
|
11
|
+
* `@dunx/dashboard/ui` in the manifest, so the build emits it as its own file.
|
|
18
12
|
*/
|
|
19
13
|
export declare const renderPage: (options: DashboardOptions) => string;
|
|
20
14
|
export { FAVICON, UI };
|
package/dist/ui.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dunx/dashboard",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "An opt-in operations page for a running dunx app: routes, the provider graph, gateways, config and runtime health, with bull-board mounted for the queues",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bull-board",
|
|
@@ -66,8 +66,8 @@
|
|
|
66
66
|
"@bull-board/api": "^8.6.0",
|
|
67
67
|
"@bull-board/bun": "^8.6.0",
|
|
68
68
|
"@bull-board/ui": "^8.6.0",
|
|
69
|
-
"@dunx/core": "^
|
|
70
|
-
"@dunx/http": "^
|
|
69
|
+
"@dunx/core": "^3.0.0",
|
|
70
|
+
"@dunx/http": "^3.0.0",
|
|
71
71
|
"@types/bun": ">=1.3.0"
|
|
72
72
|
},
|
|
73
73
|
"peerDependenciesMeta": {
|