adonisjs-server-stats 1.16.0 → 1.17.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.
Files changed (91) hide show
  1. package/README.md +14 -2
  2. package/dist/core/core/dashboard-api.d.ts +6 -4
  3. package/dist/core/index.js +9 -6
  4. package/dist/core/types.d.ts +24 -1
  5. package/dist/react/{CacheSection-DkLtukWz.js → CacheSection-C-X2Tnca.js} +48 -44
  6. package/dist/react/{CacheTab-DWeXnEBI.js → CacheTab-D55mfmeE.js} +1 -1
  7. package/dist/react/{ConfigSection-DaVbFPDg.js → ConfigSection-C-HGnZfd.js} +1 -1
  8. package/dist/react/{ConfigTab-D4640WJZ.js → ConfigTab-CNnsOXBh.js} +1 -1
  9. package/dist/react/{CustomPaneTab-DglmAVMC.js → CustomPaneTab-D-267UVd.js} +1 -1
  10. package/dist/react/{EmailsSection-vYx6ExTb.js → EmailsSection-Bqw4DI4r.js} +65 -57
  11. package/dist/react/{EmailsTab-DKDrRmRH.js → EmailsTab-CkHOhj2F.js} +1 -1
  12. package/dist/react/{EventsSection-CapH5xut.js → EventsSection-CMeyjzVC.js} +1 -1
  13. package/dist/react/{EventsTab-BNVEySAm.js → EventsTab-DgS2v1HX.js} +1 -1
  14. package/dist/react/{JobsSection-ZIQsJWid.js → JobsSection-oWH6pbrG.js} +1 -1
  15. package/dist/react/{JobsTab-DXQzXrDt.js → JobsTab-zdxX_as-.js} +1 -1
  16. package/dist/react/{LogEntryRow-BWkHE51-.js → LogEntryRow-Cn8Ee_X0.js} +1 -1
  17. package/dist/react/{LogsSection-ConXdBkL.js → LogsSection-XmiI2MMs.js} +2 -2
  18. package/dist/react/{LogsTab-CJM47LPn.js → LogsTab-ixusY4v7.js} +2 -2
  19. package/dist/react/{OverviewSection-DOMu2qvl.js → OverviewSection-CvhObTLP.js} +119 -111
  20. package/dist/react/{QueriesSection-DFsaOSJI.js → QueriesSection-BvledwBI.js} +1 -1
  21. package/dist/react/{QueriesTab-CY9CG_7L.js → QueriesTab-smQQJvJa.js} +1 -1
  22. package/dist/react/{RequestsSection-CC-eVHsl.js → RequestsSection-DKuYpViv.js} +2 -2
  23. package/dist/react/{RoutesSection-p1DMq41y.js → RoutesSection-5RNVurQ6.js} +1 -1
  24. package/dist/react/{RoutesTab-Cnqy8UcK.js → RoutesTab-CpXNO-Rx.js} +1 -1
  25. package/dist/react/{SplitPaneWrapper-XgkA0QxE.js → SplitPaneWrapper-DQvJnsuk.js} +1 -1
  26. package/dist/react/{TimelineTab-1YOERxe5.js → TimelineTab-LHQHHKkO.js} +2 -2
  27. package/dist/react/components/Dashboard/overview-data-context.d.ts +15 -0
  28. package/dist/react/components/Dashboard/sections/OverviewSection.d.ts +9 -0
  29. package/dist/react/{index-DOSlCpZ9.js → index-LlFvooUN.js} +148 -141
  30. package/dist/react/index.js +1 -1
  31. package/dist/src/collectors/app_collector.d.ts +11 -0
  32. package/dist/src/collectors/app_collector.js +11 -0
  33. package/dist/src/collectors/redis_collector.js +13 -13
  34. package/dist/src/dashboard/cache_handlers.d.ts +17 -3
  35. package/dist/src/dashboard/cache_handlers.js +14 -19
  36. package/dist/src/dashboard/coalesce_cache.d.ts +2 -8
  37. package/dist/src/dashboard/coalesce_cache.js +25 -0
  38. package/dist/src/dashboard/dashboard_controller.d.ts +1 -0
  39. package/dist/src/dashboard/dashboard_controller.js +8 -3
  40. package/dist/src/dashboard/dashboard_store.d.ts +1 -3
  41. package/dist/src/dashboard/dashboard_store.js +14 -9
  42. package/dist/src/dashboard/dashboard_types.d.ts +2 -0
  43. package/dist/src/dashboard/filter_handlers.js +26 -4
  44. package/dist/src/dashboard/flush_manager.d.ts +2 -6
  45. package/dist/src/dashboard/flush_manager.js +9 -11
  46. package/dist/src/dashboard/format_helpers.d.ts +0 -4
  47. package/dist/src/dashboard/format_helpers.js +0 -19
  48. package/dist/src/dashboard/integrations/config_inspector.js +15 -52
  49. package/dist/src/dashboard/migrator_tables.js +4 -0
  50. package/dist/src/dashboard/paginate_helper.js +21 -11
  51. package/dist/src/dashboard/sensitive_patterns.d.ts +76 -0
  52. package/dist/src/dashboard/sensitive_patterns.js +259 -0
  53. package/dist/src/dashboard/write_queue.d.ts +27 -16
  54. package/dist/src/dashboard/write_queue.js +50 -47
  55. package/dist/src/debug/event_collector.d.ts +8 -0
  56. package/dist/src/debug/event_collector.js +12 -0
  57. package/dist/src/debug/query_collector.js +6 -1
  58. package/dist/src/debug/trace_collector.js +26 -13
  59. package/dist/src/edge/client/dashboard.js +2 -2
  60. package/dist/src/edge/client-vue/dashboard.js +5 -5
  61. package/dist/src/edge/views/dashboard.edge +1 -1
  62. package/dist/src/edge/views/debug-panel.edge +1 -1
  63. package/dist/src/edge/views/stats-bar.edge +1 -1
  64. package/dist/src/middleware/request_tracking_middleware.js +7 -2
  65. package/dist/src/provider/dashboard_init.d.ts +9 -1
  66. package/dist/src/provider/dashboard_init.js +19 -0
  67. package/dist/src/provider/email_helpers.js +20 -5
  68. package/dist/src/provider/server_stats_provider.d.ts +9 -0
  69. package/dist/src/provider/server_stats_provider.js +26 -7
  70. package/dist/src/provider/shutdown_helpers.js +3 -1
  71. package/dist/src/provider/toolbar_setup.js +21 -15
  72. package/dist/src/types.d.ts +24 -1
  73. package/dist/vue/CacheSection-Bm0goqX6.js +161 -0
  74. package/dist/vue/{ConfigSection-BiRAiaHj.js → ConfigSection-BrYslIMN.js} +1 -1
  75. package/dist/vue/{EmailsSection-Dl44qyqY.js → EmailsSection-B8N5vaWA.js} +89 -85
  76. package/dist/vue/{EventsSection-CWjeitjU.js → EventsSection-D2Y_xt0m.js} +1 -1
  77. package/dist/vue/{JobsSection-DOBb4LjZ.js → JobsSection-cEKqELLC.js} +1 -1
  78. package/dist/vue/{LogsSection-CXx-HOWJ.js → LogsSection-C3H2c0S3.js} +1 -1
  79. package/dist/vue/{OverviewSection-CyfNQ8uV.js → OverviewSection-I2swuHDa.js} +1 -1
  80. package/dist/vue/{QueriesSection-CXBsFp-y.js → QueriesSection-CF1IE3SN.js} +1 -1
  81. package/dist/vue/{RequestsSection-YTIaZGZd.js → RequestsSection-CuwgXzmU.js} +1 -1
  82. package/dist/vue/{RoutesSection-CDKMey49.js → RoutesSection-BhJCF7Rn.js} +1 -1
  83. package/dist/vue/composables/useDashboardData.d.ts +1 -1
  84. package/dist/vue/{index-14x39RI_.js → index-DUsImuIN.js} +95 -95
  85. package/dist/vue/index.js +1 -1
  86. package/package.json +12 -3
  87. package/dist/src/dashboard/explain_query.d.ts +0 -8
  88. package/dist/src/dashboard/explain_query.js +0 -22
  89. package/dist/src/provider/boot_initializer.d.ts +0 -28
  90. package/dist/src/provider/boot_initializer.js +0 -35
  91. package/dist/vue/CacheSection-CxEBVVkF.js +0 -156
@@ -8,7 +8,7 @@
8
8
  </head>
9
9
  <body>
10
10
  <div id="ss-dash"></div>
11
- <script type="application/json" id="ss-dash-config">{{{ JSON.stringify(dashConfig) }}}</script>
11
+ <script type="application/json" id="ss-dash-config">{{{ JSON.stringify(dashConfig).replace(/</g, '\\u003c') }}}</script>
12
12
 
13
13
  @if(transmitClient)
14
14
  <script>
@@ -1,2 +1,2 @@
1
1
  <div id="ss-dbg-panel"></div>
2
- <script type="application/json" id="ss-dbg-config">{{{ JSON.stringify(debugConfig) }}}</script>
2
+ <script type="application/json" id="ss-dbg-config">{{{ JSON.stringify(debugConfig).replace(/</g, '\\u003c') }}}</script>
@@ -3,7 +3,7 @@
3
3
  </style>
4
4
 
5
5
  <div id="ss-bar"></div>
6
- <script type="application/json" id="ss-bar-config">{{{ JSON.stringify(barConfig) }}}</script>
6
+ <script type="application/json" id="ss-bar-config">{{{ JSON.stringify(barConfig).replace(/</g, '\\u003c') }}}</script>
7
7
 
8
8
  @if(showDebug)
9
9
  <style>{{{ debugCss }}}</style>
@@ -1,6 +1,7 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { performance } from 'node:perf_hooks';
3
3
  import { getRequestMetrics } from '../collectors/http_collector.js';
4
+ import { sanitizeUrlQuery } from '../dashboard/sensitive_patterns.js';
4
5
  import { log } from '../utils/logger.js';
5
6
  const excludedRequestAls = new AsyncLocalStorage();
6
7
  /** Returns true if the current async context is inside an excluded request. */
@@ -71,10 +72,14 @@ function recordRequestCompletion(opts) {
71
72
  if (opts.skipTracing)
72
73
  return;
73
74
  const reqId = typeof opts.ctx.request.id === 'function' ? String(opts.ctx.request.id()) : undefined;
74
- const traceRecord = traceCollector?.finishTrace(opts.ctx.request.method(), opts.ctx.request.url(true), opts.ctx.response.getStatus(), reqId);
75
+ // Redact credential-bearing query params (reset tokens, signed URLs) before
76
+ // the URL enters any store — this is the single choke point for both the
77
+ // trace buffer and the persisted request row.
78
+ const storedUrl = sanitizeUrlQuery(opts.ctx.request.url(true));
79
+ const traceRecord = traceCollector?.finishTrace(opts.ctx.request.method(), storedUrl, opts.ctx.response.getStatus(), reqId);
75
80
  onRequestCompleteFn?.({
76
81
  method: opts.ctx.request.method(),
77
- url: opts.ctx.request.url(true),
82
+ url: storedUrl,
78
83
  statusCode: opts.ctx.response.getStatus(),
79
84
  duration,
80
85
  trace: traceRecord ?? undefined,
@@ -9,7 +9,7 @@ import type { DebugStore } from '../debug/debug_store.js';
9
9
  import type { DevToolbarConfig } from '../debug/types.js';
10
10
  import type { StatsEngine } from '../engine/stats_engine.js';
11
11
  import type { ApplicationService } from '@adonisjs/core/types';
12
- interface DashboardResult {
12
+ export interface DashboardResult {
13
13
  dashboardStore: DashboardStore | null;
14
14
  dashboardController: DashboardController | null;
15
15
  dashboardLogStream: LogStreamService | null;
@@ -27,4 +27,12 @@ interface DashboardStoreOptions {
27
27
  }
28
28
  /** Initialize the full-page dashboard: SQLite store, controller, log piping, broadcasting. */
29
29
  export declare function initDashboardStore(opts: DashboardStoreOptions): Promise<void>;
30
+ /**
31
+ * Forget the installed request pipe. Must be called on shutdown: the handler
32
+ * slot is nulled there via `setOnRequestComplete(null)`, and without this
33
+ * reset a later re-init in the same process (test harness, programmatic
34
+ * restart) would see the flag, decline to reinstall, and leave dashboard
35
+ * persistence silently dead for the rest of the process's life.
36
+ */
37
+ export declare function resetDashRequestPipe(): void;
30
38
  export {};
@@ -110,6 +110,16 @@ function pipeDashLogs(pinoHookActive, dashboardStore, makePath) {
110
110
  * dashboard request pipe, so accumulating handlers would just double-persist.
111
111
  */
112
112
  let dashRequestPipeInstalled = false;
113
+ /**
114
+ * Forget the installed request pipe. Must be called on shutdown: the handler
115
+ * slot is nulled there via `setOnRequestComplete(null)`, and without this
116
+ * reset a later re-init in the same process (test harness, programmatic
117
+ * restart) would see the flag, decline to reinstall, and leave dashboard
118
+ * persistence silently dead for the rest of the process's life.
119
+ */
120
+ export function resetDashRequestPipe() {
121
+ dashRequestPipeInstalled = false;
122
+ }
113
123
  function pipeDashRequests(debugStore, dashboardStore) {
114
124
  if (dashRequestPipeInstalled) {
115
125
  log.warn('dashboard: request pipe already installed — ignoring duplicate registration ' +
@@ -118,18 +128,27 @@ function pipeDashRequests(debugStore, dashboardStore) {
118
128
  }
119
129
  dashRequestPipeInstalled = true;
120
130
  let lastQueryId = 0;
131
+ let lastEventId = 0;
121
132
  setOnRequestComplete(({ method, url, statusCode, duration, trace, httpRequestId }) => {
122
133
  if (!dashboardStore.isReady())
123
134
  return;
124
135
  const q = debugStore.queries.getQueriesSince(lastQueryId);
125
136
  if (q.length > 0)
126
137
  lastQueryId = q[q.length - 1].id;
138
+ // Events are collected globally rather than per-request, so — exactly like
139
+ // queries above — anything emitted outside a request is attributed to
140
+ // whichever request finishes next. Imprecise, but it keeps events linked to
141
+ // a request row, which is what retention prunes on.
142
+ const events = debugStore.capture.events ? debugStore.events.getEventsSince(lastEventId) : [];
143
+ if (events.length > 0)
144
+ lastEventId = events[events.length - 1].id;
127
145
  dashboardStore.persistRequest({
128
146
  method,
129
147
  url,
130
148
  statusCode,
131
149
  duration,
132
150
  queries: q,
151
+ events,
133
152
  trace: trace ?? null,
134
153
  httpRequestId: httpRequestId ?? null,
135
154
  });
@@ -3,13 +3,26 @@ const MAX_HTML = 50_000;
3
3
  /**
4
4
  * Sensitive query-parameter names whose values are stripped from captured
5
5
  * URLs. These carry one-time login/reset/verify credentials.
6
+ *
7
+ * The name may carry a prefix (`reset_token=`, `resetToken=`, `id_token=`):
8
+ * a bare `\b` boundary cannot see those, because `_` is a word character and
9
+ * camelCase has no boundary at all. Over-matching a benign param that merely
10
+ * ends in a keyword (`postcode=`) costs a little readability; missing
11
+ * `reset_token=` costs a live credential.
6
12
  */
7
- const SENSITIVE_QUERY_PARAMS = /\b(token|code|access_token|refresh_token|api_key|apikey|key|secret|signature|sig|password|pwd|auth|otp|nonce|state)=[^\s&"'<>]*/gi;
13
+ const SENSITIVE_QUERY_PARAMS = /[\w-]*(?:token|code|api_key|apikey|key|secret|signature|sig|password|pwd|auth|otp|nonce|state)=[^\s&"'<>]*/gi;
8
14
  /**
9
15
  * URL path segments that indicate a credential-bearing link even when the
10
16
  * secret is in the path rather than the query string (reset/verify/magic).
11
17
  */
12
- const SENSITIVE_URL_PATHS = /(https?:\/\/[^\s"'<>]*?\/(?:reset|verify|verification|confirm|magic|magic-link|activate|invite|unsubscribe)[^\s"'<>]*)/gi;
18
+ const SENSITIVE_URL_PATHS = /(https?:\/\/[^\s"'<>]*?\/(?:reset|verify|verification|confirm|magic|magic-link|activate|invite|unsubscribe|(?:set-|new-)?password)[^\s"'<>]*)/gi;
19
+ const SENSITIVE_PATH_MARKER = /\/(reset|verify|verification|confirm|magic|magic-link|activate|invite|unsubscribe|(?:set-|new-)?password)/i;
20
+ /**
21
+ * A long opaque path segment (24+ unhyphenated word chars) inside a URL —
22
+ * the shape of a bare token in the path. Hyphens are excluded so ordinary
23
+ * hyphenated slugs in newsletters survive.
24
+ */
25
+ const OPAQUE_PATH_SEGMENT = /(https?:\/\/[^\s"'<>]*?\/)([A-Za-z0-9_]{24,})(?=[/?#"'\s<>&]|$)/g;
13
26
  /**
14
27
  * Redact token-bearing links from an email body before persistence.
15
28
  *
@@ -19,13 +32,15 @@ const SENSITIVE_URL_PATHS = /(https?:\/\/[^\s"'<>]*?\/(?:reset|verify|verificati
19
32
  */
20
33
  export function redactTokenLinks(body) {
21
34
  return (body
22
- // ?token=… / &code=… → keep the key, drop the value
35
+ // ?token=… / &reset_token=… → keep the key, drop the value
23
36
  .replace(SENSITIVE_QUERY_PARAMS, (m) => m.slice(0, m.indexOf('=') + 1) + '[redacted]')
24
37
  // reset/verify/magic URLs → redact everything after the sensitive segment
25
38
  .replace(SENSITIVE_URL_PATHS, (_m, url) => {
26
- const marker = url.search(/\/(reset|verify|verification|confirm|magic|magic-link|activate|invite|unsubscribe)/i);
39
+ const marker = url.search(SENSITIVE_PATH_MARKER);
27
40
  return marker === -1 ? url : url.slice(0, marker) + '/[redacted]';
28
- }));
41
+ })
42
+ // a bare token as a path segment → redact just that segment
43
+ .replace(OPAQUE_PATH_SEGMENT, (_m, head) => head + '[redacted]'));
29
44
  }
30
45
  /**
31
46
  * Cap an HTML/text body to a maximum size, stripping token-bearing links first.
@@ -50,6 +50,15 @@ export default class ServerStatsProvider {
50
50
  * built unless `production.enabled` opts in.
51
51
  */
52
52
  private isEnabledHere;
53
+ /**
54
+ * Publisher-only email bridge for non-web processes (queue workers).
55
+ *
56
+ * The bridge publishes full mail bodies to Redis. A production worker must
57
+ * stay silent unless production mode AND email capture are both opted in —
58
+ * same rules the web process applies.
59
+ */
60
+ private setupNonWebEmailBridge;
61
+ /** Returns true only when the HTTP routes were actually registered. */
53
62
  private registerRoutes;
54
63
  ready(): Promise<void>;
55
64
  private initStats;
@@ -72,10 +72,13 @@ export default class ServerStatsProvider {
72
72
  setAppRoot(this.app.makePath(''));
73
73
  if (config.shouldShow)
74
74
  setShouldShow(config.shouldShow);
75
- await this.registerRoutes(config);
75
+ const routesRegistered = await this.registerRoutes(config);
76
76
  // Only register the Edge tag when the routes it polls actually exist —
77
- // otherwise the bar renders in production and 404s on every tick.
78
- this.edgePluginActive = this.isEnabledHere(config)
77
+ // not merely when the environment allows them. The fail-closed guard in
78
+ // registerAllRoutes can refuse registration (no authorize callback), and
79
+ // rendering the bar then would advertise endpoints that 404 — or, in
80
+ // production, hand every anonymous visitor a pointer at the stats API.
81
+ this.edgePluginActive = routesRegistered
79
82
  ? await registerEdgePluginHelper(this.app, config)
80
83
  : false;
81
84
  }
@@ -88,10 +91,25 @@ export default class ServerStatsProvider {
88
91
  isEnabledHere(config) {
89
92
  return !this.app.inProduction || config.production?.enabled === true;
90
93
  }
94
+ /**
95
+ * Publisher-only email bridge for non-web processes (queue workers).
96
+ *
97
+ * The bridge publishes full mail bodies to Redis. A production worker must
98
+ * stay silent unless production mode AND email capture are both opted in —
99
+ * same rules the web process applies.
100
+ */
101
+ async setupNonWebEmailBridge(config) {
102
+ const emailCaptureOn = !this.app.inProduction || config.production?.capture?.emails === true;
103
+ if (!this.isEnabledHere(config) || !emailCaptureOn)
104
+ return;
105
+ const em = await this.resolve('emitter');
106
+ await setupNonWebBridgeHelper(em, this.emailBridgeChannel);
107
+ }
108
+ /** Returns true only when the HTTP routes were actually registered. */
91
109
  async registerRoutes(config) {
92
110
  const router = await this.resolve('router');
93
111
  if (!router || !this.isEnabledHere(config))
94
- return;
112
+ return false;
95
113
  this.dashboardDepsAvailable = await checkDashboardDepsHelper(config, this.app);
96
114
  const { statsEndpoint, debugEndpoint } = deriveEndpointPaths(config.endpoint, config.devToolbar);
97
115
  const dashboardPath = computeDashboardPath(config.devToolbar, this.dashboardDepsAvailable);
@@ -114,21 +132,22 @@ export default class ServerStatsProvider {
114
132
  });
115
133
  const paths = collectRegisteredPaths(statsEndpoint, debugEndpoint, dashboardPath, config.domain);
116
134
  if (!registered || paths.length === 0)
117
- return;
135
+ return false;
118
136
  log.list('routes auto-registered (no manual setup needed):', paths);
119
137
  warnAboutAuthMiddleware(config, this.app.makePath.bind(this.app));
120
138
  warnAboutSessionMiddleware(this.app.makePath.bind(this.app));
121
139
  warnAboutDomainWithToolbar(config);
122
140
  if (this.app.inProduction)
123
141
  announceProductionMode(config, paths);
142
+ return true;
124
143
  }
125
144
  async ready() {
126
145
  const config = this.app.config.get('server_stats');
127
146
  if (!config || (this.app.inTest && config.skipInTest !== false))
128
147
  return;
148
+ this.emailBridgeChannel = config.advanced?.emailBridgeChannel ?? this.emailBridgeChannel;
129
149
  if (this.app.getEnvironment() !== 'web') {
130
- const em = await this.resolve('emitter');
131
- await setupNonWebBridgeHelper(em, this.emailBridgeChannel);
150
+ await this.setupNonWebEmailBridge(config);
132
151
  return;
133
152
  }
134
153
  this.initPromise = this.initStats(config).catch((err) => {
@@ -2,8 +2,9 @@
2
2
  * Shutdown helper functions extracted from ServerStatsProvider.
3
3
  */
4
4
  import { setOnRequestComplete, setDashboardPath, setExcludedPrefixes, } from '../middleware/request_tracking_middleware.js';
5
- import { unhookPinoFromLogStream } from './provider_helpers_extra.js';
6
5
  import { log } from '../utils/logger.js';
6
+ import { resetDashRequestPipe } from './dashboard_init.js';
7
+ import { unhookPinoFromLogStream } from './provider_helpers_extra.js';
7
8
  /**
8
9
  * Clear all active timers and null out the references.
9
10
  * Returns the (now-nulled) timer refs for convenience.
@@ -64,6 +65,7 @@ export async function cleanupResources(deps) {
64
65
  deps.dashboardLogStream?.stop();
65
66
  unhookPinoFromLogStream();
66
67
  setOnRequestComplete(null);
68
+ resetDashRequestPipe();
67
69
  setDashboardPath(null);
68
70
  setExcludedPrefixes([]);
69
71
  await deps.dashboardStore?.stop();
@@ -18,7 +18,11 @@ export async function setupDevToolbarCore(opts) {
18
18
  if (!em)
19
19
  log.warn('emitter not available — query/event collection disabled');
20
20
  await debugStore.start(em, await resolve('router'));
21
- const emailBridgeRedis = await setupBridgeInternal(em, debugStore, app);
21
+ // The bridge publishes full mail bodies to Redis — a shared-Redis exposure —
22
+ // so it must obey the same capture flag as local email collection.
23
+ const emailBridgeRedis = debugStore.capture.emails
24
+ ? await setupBridgeInternal(em, debugStore, app, config.advanced?.emailBridgeChannel ?? 'adonisjs-server-stats:emails')
25
+ : null;
22
26
  const debugController = await createDebugController(debugStore, config, getDiagnostics, app);
23
27
  // Also gated on capture: installing the collector is what makes the middleware
24
28
  // wrap every request in AsyncLocalStorage and write a trace row per request.
@@ -70,7 +74,7 @@ function createFlushTimer(debugStore, persistPath) {
70
74
  catch { }
71
75
  }, 30_000);
72
76
  }
73
- async function setupBridgeInternal(emitter, debugStore, app) {
77
+ async function setupBridgeInternal(emitter, debugStore, app, channel) {
74
78
  if (!emitter)
75
79
  return null;
76
80
  try {
@@ -89,7 +93,7 @@ async function setupBridgeInternal(emitter, debugStore, app) {
89
93
  return null;
90
94
  }
91
95
  };
92
- return await setupFullEmailBridge(emitter, redis, 'adonisjs-server-stats:emails', { debugEmails: debugStore.emails ?? null, dashboardStore: getDashboardStore });
96
+ return await setupFullEmailBridge(emitter, redis, channel, { debugEmails: debugStore.emails ?? null, dashboardStore: getDashboardStore });
93
97
  }
94
98
  catch {
95
99
  return null;
@@ -122,6 +126,19 @@ async function setupDebugBroadcastInternal(debugStore, resolve) {
122
126
  });
123
127
  return { timer, transmitAvailable: true, channels: [ch] };
124
128
  }
129
+ /** Copy dashboard-init results onto the provider once async init finishes. */
130
+ function applyDashboardResult(provider, r) {
131
+ provider.dashboardStore = r.dashboardStore;
132
+ provider.dashboardController = r.dashboardController;
133
+ provider.dashboardLogStream = r.dashboardLogStream;
134
+ provider.dashboardBroadcastTimer = r.dashboardBroadcastTimer;
135
+ if (r.dashboardController) {
136
+ log.info('dashboard: controller ready — API endpoints are now live');
137
+ }
138
+ else {
139
+ log.warn('dashboard: init completed but controller is null — dashboard API will return 503');
140
+ }
141
+ }
125
142
  /** Apply the toolbar core result back onto the provider instance. */
126
143
  export function applyToolbarResult(result, tc, provider) {
127
144
  provider.debugStore = result.debugStore;
@@ -154,18 +171,7 @@ export function applyToolbarResult(result, tc, provider) {
154
171
  engine: provider.engine,
155
172
  pinoHookActive: provider.pinoHookActive,
156
173
  transmitChannels: provider.transmitChannels,
157
- onResult: (r) => {
158
- provider.dashboardStore = r.dashboardStore;
159
- provider.dashboardController = r.dashboardController;
160
- provider.dashboardLogStream = r.dashboardLogStream;
161
- provider.dashboardBroadcastTimer = r.dashboardBroadcastTimer;
162
- if (r.dashboardController) {
163
- log.info('dashboard: controller ready — API endpoints are now live');
164
- }
165
- else {
166
- log.warn('dashboard: init completed but controller is null — dashboard API will return 503');
167
- }
168
- },
174
+ onResult: (r) => applyDashboardResult(provider, r),
169
175
  }).catch((e) => {
170
176
  log.warn(`dashboard: setup failed: ${e?.message ?? e}`);
171
177
  if (e?.stack)
@@ -461,7 +461,7 @@ export type AccessGuard = (ctx: import('@adonisjs/core/http').HttpContext) => bo
461
461
  export interface CaptureConfig {
462
462
  /** SQL text, bindings, and timings for every query. */
463
463
  queries?: boolean;
464
- /** Application events (in-memory only — never persisted to SQLite). */
464
+ /** Application events, linked to the request that follows them and persisted with it. */
465
465
  events?: boolean;
466
466
  /** Sent mail, including subject and body. */
467
467
  emails?: boolean;
@@ -622,6 +622,29 @@ export interface AdvancedConfig {
622
622
  * @default 200
623
623
  */
624
624
  maxTraces?: number;
625
+ /**
626
+ * Allow-list prefix for the dashboard's cache inspector.
627
+ *
628
+ * Scopes which Redis keys the Cache tab may list, read, and delete
629
+ * (e.g. `'myapp:cache:'`). Unset keeps full access in development for
630
+ * back-compat, but in production single-key reads and deletes are
631
+ * refused until a prefix is configured — a production dashboard must
632
+ * not be able to read session keys or delete rate-limit counters.
633
+ *
634
+ * The `SERVER_STATS_CACHE_KEY_PREFIX` env var is used as a fallback.
635
+ */
636
+ cacheKeyPrefix?: string;
637
+ /**
638
+ * Redis pub/sub channel for the cross-process email bridge.
639
+ *
640
+ * Queue workers publish captured mail on this channel and the web
641
+ * process persists it. On a Redis instance shared between apps or
642
+ * tenants, namespace this (e.g. `myapp:server-stats:emails`) so
643
+ * other subscribers cannot read your outbound mail.
644
+ *
645
+ * @default 'adonisjs-server-stats:emails'
646
+ */
647
+ emailBridgeChannel?: string;
625
648
  }
626
649
  /**
627
650
  * Top-level configuration for `adonisjs-server-stats`.
@@ -0,0 +1,161 @@
1
+ import { defineComponent as L, inject as y, ref as h, onMounted as x, computed as w, onUnmounted as F, openBlock as l, createElementBlock as n, createElementVNode as e, toDisplayString as c, createCommentVNode as z, createVNode as N, unref as p, Fragment as U, renderList as B, withModifiers as M, createBlock as H } from "vue";
2
+ import { u as I } from "./index-DUsImuIN.js";
3
+ import { u as j } from "./useResizableTable-BoivAevK.js";
4
+ import { DashboardApi as q, formatCacheSize as P, formatTtl as G } from "adonisjs-server-stats/core";
5
+ import { u as J } from "./useApiClient-BQQ9CF-q.js";
6
+ import { _ as O } from "./JsonViewer.vue_vue_type_script_setup_true_lang-Bid05zpm.js";
7
+ import { _ as Q } from "./FilterBar.vue_vue_type_script_setup_true_lang-ClJ37hhT.js";
8
+ const W = {
9
+ key: 0,
10
+ class: "ss-dash-cache-stats"
11
+ }, X = { class: "ss-dash-cache-stat" }, Y = { class: "ss-dash-cache-stat-value" }, Z = { class: "ss-dash-cache-stat" }, ee = { class: "ss-dash-cache-stat-value" }, se = { class: "ss-dash-cache-stat" }, ae = { class: "ss-dash-cache-stat-value" }, te = { class: "ss-dash-cache-stat" }, le = { class: "ss-dash-cache-stat-value" }, ne = {
12
+ key: 1,
13
+ class: "ss-dash-empty"
14
+ }, oe = {
15
+ key: 2,
16
+ class: "ss-dash-empty"
17
+ }, ce = {
18
+ key: 3,
19
+ class: "ss-dash-table-wrap"
20
+ }, ue = ["onClick"], ie = ["title"], re = { style: { color: "var(--ss-muted)" } }, de = ["onClick"], he = {
21
+ key: 1,
22
+ class: "ss-dash-empty"
23
+ }, ve = {
24
+ key: 4,
25
+ class: "ss-dash-cache-detail"
26
+ }, ye = {
27
+ key: 0,
28
+ class: "ss-dash-empty"
29
+ }, pe = {
30
+ key: 1,
31
+ class: "ss-dash-empty",
32
+ style: { color: "var(--ss-red-fg)" }
33
+ }, ze = /* @__PURE__ */ L({
34
+ __name: "CacheSection",
35
+ setup(_e) {
36
+ const D = y("ss-refresh-key", h(0)), m = y("ss-dashboard-endpoint", "/__stats/api"), b = y("ss-auth-token", void 0), k = y("ss-base-url", ""), { data: C, loading: K, setSearch: A, mutate: E } = I(() => "cache", {
37
+ baseUrl: k,
38
+ dashboardEndpoint: m,
39
+ authToken: b,
40
+ refreshKey: D
41
+ });
42
+ let _ = null;
43
+ x(() => {
44
+ const t = J(k || "", b);
45
+ _ = new q(t(), m || "/__stats/api");
46
+ });
47
+ const g = h(""), u = h(null), d = h(null), f = h(!1), i = h(null), o = w(() => C.value), v = w(() => o.value?.keys || o.value?.data || []);
48
+ function V(t) {
49
+ g.value = t, A(t);
50
+ }
51
+ async function $(t) {
52
+ if (confirm(`Delete cache key "${t}"?`))
53
+ try {
54
+ await E(`cache/${encodeURIComponent(t)}`, "delete"), u.value === t && (u.value = null, d.value = null, i.value = null);
55
+ } catch {
56
+ }
57
+ }
58
+ const { tableRef: R } = j(() => v.value);
59
+ let r = null;
60
+ async function S(t) {
61
+ if (r?.abort(), r = null, u.value === t) {
62
+ u.value = null, d.value = null, i.value = null;
63
+ return;
64
+ }
65
+ u.value = t, d.value = null, i.value = null, f.value = !0;
66
+ const a = new AbortController();
67
+ r = a;
68
+ try {
69
+ if (!_) throw new Error("Cache API not initialized");
70
+ const s = await _.fetchCacheKey(t, { signal: a.signal });
71
+ if (r !== a) return;
72
+ d.value = s.value !== void 0 ? s.value : s.data !== void 0 ? s.data : s, i.value = null;
73
+ } catch (s) {
74
+ if (s instanceof Error && s.name === "AbortError" || r !== a) return;
75
+ d.value = null, i.value = "Failed to fetch key value";
76
+ } finally {
77
+ r === a && (f.value = !1);
78
+ }
79
+ }
80
+ return F(() => r?.abort()), (t, a) => (l(), n("div", null, [
81
+ o.value?.available && o.value?.stats ? (l(), n("div", W, [
82
+ e("div", X, [
83
+ a[0] || (a[0] = e("span", { class: "ss-dash-cache-stat-label" }, "Hit Rate:", -1)),
84
+ e("span", Y, c((o.value.stats.hitRate ?? 0).toFixed(1)) + "%", 1)
85
+ ]),
86
+ e("div", Z, [
87
+ a[1] || (a[1] = e("span", { class: "ss-dash-cache-stat-label" }, "Hits:", -1)),
88
+ e("span", ee, c(o.value.stats.hits ?? 0), 1)
89
+ ]),
90
+ e("div", se, [
91
+ a[2] || (a[2] = e("span", { class: "ss-dash-cache-stat-label" }, "Misses:", -1)),
92
+ e("span", ae, c(o.value.stats.misses ?? 0), 1)
93
+ ]),
94
+ e("div", te, [
95
+ a[3] || (a[3] = e("span", { class: "ss-dash-cache-stat-label" }, "Keys:", -1)),
96
+ e("span", le, c(o.value.stats.totalKeys || o.value.stats.keyCount || v.value.length || 0), 1)
97
+ ])
98
+ ])) : z("", !0),
99
+ N(Q, {
100
+ "model-value": g.value,
101
+ placeholder: "Filter cache keys...",
102
+ summary: `${v.value.length} keys`,
103
+ "onUpdate:modelValue": V
104
+ }, null, 8, ["model-value", "summary"]),
105
+ p(K) && !p(C) ? (l(), n("div", ne, "Loading cache...")) : !o.value || !o.value.available ? (l(), n("div", oe, " Cache inspector not available ")) : (l(), n("div", ce, [
106
+ v.value.length > 0 ? (l(), n("table", {
107
+ key: 0,
108
+ ref_key: "tableRef",
109
+ ref: R,
110
+ class: "ss-dash-table"
111
+ }, [
112
+ a[4] || (a[4] = e("thead", null, [
113
+ e("tr", null, [
114
+ e("th", null, "Key"),
115
+ e("th", null, "Type"),
116
+ e("th", null, "Size"),
117
+ e("th", null, "TTL"),
118
+ e("th")
119
+ ])
120
+ ], -1)),
121
+ e("tbody", null, [
122
+ (l(!0), n(U, null, B(v.value, (s) => (l(), n("tr", {
123
+ key: s.key,
124
+ class: "ss-dash-clickable",
125
+ onClick: (T) => S(s.key)
126
+ }, [
127
+ e("td", null, [
128
+ e("span", {
129
+ title: s.key,
130
+ style: { color: "var(--ss-sql-color)", overflow: "hidden", "text-overflow": "ellipsis", "white-space": "nowrap", display: "block" }
131
+ }, c(s.key), 9, ie)
132
+ ]),
133
+ e("td", null, [
134
+ e("span", re, c(s.type), 1)
135
+ ]),
136
+ e("td", null, c(s.size !== null && s.size !== void 0 && s.size > 0 ? p(P)(s.size) : "-"), 1),
137
+ e("td", null, c(s.ttl > 0 ? p(G)(s.ttl) : "-"), 1),
138
+ e("td", null, [
139
+ e("button", {
140
+ type: "button",
141
+ class: "ss-dash-retry-btn",
142
+ onClick: M((T) => $(s.key), ["stop"])
143
+ }, " Delete ", 8, de)
144
+ ])
145
+ ], 8, ue))), 128))
146
+ ])
147
+ ], 512)) : (l(), n("div", he, "No cache keys found"))
148
+ ])),
149
+ u.value ? (l(), n("div", ve, [
150
+ e("h4", null, "Key: " + c(u.value), 1),
151
+ f.value ? (l(), n("div", ye, "Loading value...")) : i.value ? (l(), n("div", pe, c(i.value), 1)) : (l(), H(O, {
152
+ key: 2,
153
+ value: d.value
154
+ }, null, 8, ["value"]))
155
+ ])) : z("", !0)
156
+ ]));
157
+ }
158
+ });
159
+ export {
160
+ ze as default
161
+ };
@@ -1,5 +1,5 @@
1
1
  import { defineComponent as nt, inject as L, ref as h, computed as k, openBlock as r, createElementBlock as c, createElementVNode as n, normalizeClass as a, createCommentVNode as g, Fragment as x, toDisplayString as u, unref as d, renderList as E, normalizeStyle as j } from "vue";
2
- import { u as lt } from "./index-14x39RI_.js";
2
+ import { u as lt } from "./index-DUsImuIN.js";
3
3
  import { isRedactedValue as p, flattenConfig as O, countLeaves as ot, collectTopLevelObjectKeys as st, copyWithFeedback as at, formatFlatValue as rt } from "adonisjs-server-stats/core";
4
4
  const ct = { style: { position: "relative", flex: 1 } }, ut = ["value"], it = ["onClick"], pt = { key: 0 }, dt = ["onClick"], vt = { key: 0 }, ft = { style: { padding: "4px 16px", fontSize: "10px", color: "var(--ss-muted)" } }, yt = ["onClick"], gt = ["title"], bt = ["title"], ht = ["onClick"], $t = {
5
5
  key: 1,