@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 CHANGED
@@ -1,242 +1,79 @@
1
1
  # @dunx/dashboard
2
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`.
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
- 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
17
+ ## Usage
18
18
 
19
19
  ```ts
20
20
  import { DashboardMiddleware, DashboardModule } from '@dunx/dashboard';
21
- import { JobPublisher, QueueModule } from '@dunx/infra/queue';
22
- import { RedisConnection, RedisModule } from '@dunx/infra/redis';
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
- // This dynamic module is its own scope, so whatever exports the tokens the
28
- // factory injects goes here.
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.headers.get('x-ops-key') === process.env.OPS_KEY,
33
+ authorize: (req) => isOperator(req),
34
34
  }),
35
- inject: [JobPublisher, RedisConnection] as const,
36
35
  }),
37
36
  ],
38
37
  })
39
- export class AppModule {}
40
- ```
38
+ export class OpsModule {}
41
39
 
42
- ```ts
43
40
  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 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
- 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
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
- ```ts
208
- path: '/api/_dunx',
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
- ## Probes
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
- 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.
58
+ ## Three things that are decisions
217
59
 
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
- ```
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
- 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.
68
+ ## Notes
232
69
 
233
- ## The page
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
- 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.
77
+ ## License
239
78
 
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`.
79
+ MIT
@@ -1,14 +1,10 @@
1
1
  /**
2
- * Every read the dashboard makes off-process is bounded, and this is the one
3
- * implementation of that.
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 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.
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>;
@@ -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 **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.
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";
@@ -1,74 +1,38 @@
1
1
  /**
2
- * What the dashboard needs from the things it reports on, restated structurally.
3
- *
4
- * **This package depends on `@dunx/infra` not at all**, and on `bullmq` not at
5
- * all - the same choice `@dunx/auth` makes with `DrizzleSource` and `RedisStore`.
6
- * A dashboard that peer-depended on the queue library would oblige an app with no
7
- * queues to install it to see its routes, and would put a build-order edge between
8
- * two packages that never call each other.
9
- *
10
- * This list used to be twice as long. `DashboardQueue` and `DashboardJob` restated
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
- * Passed in rather than resolved from the container, and that is deliberate twice
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
- * `object`, not `Record<string, unknown>`: an app's `AppConfig` is an interface
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
- * `opened` is what the publisher has opened *so far*, which is deliberately not the
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
- * bullmq's `Queue`, handed to bull-board's `BullMQAdapter` untouched - which is
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". `RedisConnection`
67
- * satisfies it, and so does `Bun.RedisClient` with a `send`.
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`; see
80
- * `StatusDot`.
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 - a third-party API, a disk, a leader
96
- * election. The dashboard awaits it with a timeout and never lets it throw into a
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-xps6r8jv.js";
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, {
@@ -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 **global middleware**, not a controller, and that is not a stylistic choice.
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
- * Middleware registered with `app.use` runs in front of the unmatched-path
9
- * fallback, which is exactly where the dashboard's paths land because the app
10
- * declares none of them. Declaring them as dunx routes would mean generating
11
- * controllers for a route table handed over at runtime, and every panel would need
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
- * Two consequences of that, both learned the hard way and both worth keeping:
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. **It does not register the middleware** -
5
- * the app does, with `app.use(DashboardMiddleware)`, and that is deliberate:
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 middleware to that module's own controllers, of
9
- * which this module has none, so it would never run. Registering globally from
10
- * inside the module would put it wherever the module happened to be imported, and
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
- * It receives the **raw `Request`**, not an `AuthContext` some earlier middleware
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
- * **The default reveals nothing**, and that is the answer to the open question the
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 "reveal" affordance on the page. Redaction is decided at boot by the
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. `/_dunx` by default - the underscore keeps it
42
- * clear of an app's own routes, and the name is the framework's rather than
43
- * `/queues`, because queues are one panel of six.
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
- * **There is no default, and leaving it out serves the page to anyone who can
54
- * reach the port.** That is fine behind a private network and bad everywhere
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
- * Queues to show beyond the ones the source has opened. A process that
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
- * Where `@dunx/openapi` serves its explorer, so the routes panel can link a row
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 and
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`. A hung probe must
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
- * A class, not an interface, so it is a runtime value and can therefore be a
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` is the inlined Vite output, and importing it costs a few
7
- * milliseconds and its full size in parsed source. `DashboardMiddleware` reaches
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
- * `html.ts` therefore must not import `ui-bundle.ts`, or the split silently
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
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  import {
3
3
  metaOf
4
- } from "./chunk-xps6r8jv.js";
4
+ } from "./chunk-66xm1dzm.js";
5
5
 
6
6
  // src/html.ts
7
7
  var BOOT = `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dunx/dashboard",
3
- "version": "2.5.0",
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": "^2.5.0",
70
- "@dunx/http": "^2.5.0",
69
+ "@dunx/core": "^3.0.0",
70
+ "@dunx/http": "^3.0.0",
71
71
  "@types/bun": ">=1.3.0"
72
72
  },
73
73
  "peerDependenciesMeta": {