adonisjs-server-stats 1.16.1 → 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 (88) 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 +0 -1
  41. package/dist/src/dashboard/dashboard_store.js +14 -6
  42. package/dist/src/dashboard/filter_handlers.js +26 -4
  43. package/dist/src/dashboard/flush_manager.d.ts +1 -0
  44. package/dist/src/dashboard/flush_manager.js +6 -1
  45. package/dist/src/dashboard/format_helpers.d.ts +0 -4
  46. package/dist/src/dashboard/format_helpers.js +0 -19
  47. package/dist/src/dashboard/integrations/config_inspector.js +6 -3
  48. package/dist/src/dashboard/migrator_tables.js +4 -0
  49. package/dist/src/dashboard/paginate_helper.js +21 -11
  50. package/dist/src/dashboard/sensitive_patterns.d.ts +37 -0
  51. package/dist/src/dashboard/sensitive_patterns.js +146 -5
  52. package/dist/src/dashboard/write_queue.d.ts +20 -21
  53. package/dist/src/dashboard/write_queue.js +37 -60
  54. package/dist/src/debug/query_collector.js +6 -1
  55. package/dist/src/debug/trace_collector.js +26 -13
  56. package/dist/src/edge/client/dashboard.js +2 -2
  57. package/dist/src/edge/client-vue/dashboard.js +5 -5
  58. package/dist/src/edge/views/dashboard.edge +1 -1
  59. package/dist/src/edge/views/debug-panel.edge +1 -1
  60. package/dist/src/edge/views/stats-bar.edge +1 -1
  61. package/dist/src/middleware/request_tracking_middleware.js +7 -2
  62. package/dist/src/provider/dashboard_init.d.ts +9 -1
  63. package/dist/src/provider/dashboard_init.js +10 -0
  64. package/dist/src/provider/email_helpers.js +20 -5
  65. package/dist/src/provider/server_stats_provider.d.ts +9 -0
  66. package/dist/src/provider/server_stats_provider.js +26 -7
  67. package/dist/src/provider/shutdown_helpers.js +3 -1
  68. package/dist/src/provider/toolbar_setup.js +21 -15
  69. package/dist/src/types.d.ts +24 -1
  70. package/dist/vue/CacheSection-Bm0goqX6.js +161 -0
  71. package/dist/vue/{ConfigSection-BiRAiaHj.js → ConfigSection-BrYslIMN.js} +1 -1
  72. package/dist/vue/{EmailsSection-Dl44qyqY.js → EmailsSection-B8N5vaWA.js} +89 -85
  73. package/dist/vue/{EventsSection-CWjeitjU.js → EventsSection-D2Y_xt0m.js} +1 -1
  74. package/dist/vue/{JobsSection-DOBb4LjZ.js → JobsSection-cEKqELLC.js} +1 -1
  75. package/dist/vue/{LogsSection-CXx-HOWJ.js → LogsSection-C3H2c0S3.js} +1 -1
  76. package/dist/vue/{OverviewSection-CyfNQ8uV.js → OverviewSection-I2swuHDa.js} +1 -1
  77. package/dist/vue/{QueriesSection-CXBsFp-y.js → QueriesSection-CF1IE3SN.js} +1 -1
  78. package/dist/vue/{RequestsSection-YTIaZGZd.js → RequestsSection-CuwgXzmU.js} +1 -1
  79. package/dist/vue/{RoutesSection-CDKMey49.js → RoutesSection-BhJCF7Rn.js} +1 -1
  80. package/dist/vue/composables/useDashboardData.d.ts +1 -1
  81. package/dist/vue/{index-14x39RI_.js → index-DUsImuIN.js} +95 -95
  82. package/dist/vue/index.js +1 -1
  83. package/package.json +12 -3
  84. package/dist/src/dashboard/explain_query.d.ts +0 -8
  85. package/dist/src/dashboard/explain_query.js +0 -22
  86. package/dist/src/provider/boot_initializer.d.ts +0 -28
  87. package/dist/src/provider/boot_initializer.js +0 -35
  88. package/dist/vue/CacheSection-CxEBVVkF.js +0 -156
@@ -1,4 +1,4 @@
1
- import { B as s, D as t, c as r, J as u, M as g, d as o, d, S as D, T as S, e as h, a as B, u as T, f as b, g as l, h as m } from "./index-DOSlCpZ9.js";
1
+ import { B as s, D as t, c as r, J as u, M as g, d as o, d, S as D, T as S, e as h, a as B, u as T, f as b, g as l, h as m } from "./index-LlFvooUN.js";
2
2
  export {
3
3
  s as Badge,
4
4
  t as DashboardPage,
@@ -3,6 +3,17 @@ import type { MetricCollector } from './collector.js';
3
3
  * Queries application-specific tables for user sessions,
4
4
  * pending webhooks, and pending emails.
5
5
  *
6
+ * **This polls your primary database** — three `COUNT(*)` queries every
7
+ * collection interval (3s by default), forever. Without indexes those are
8
+ * full table scans on every tick. If you enable this collector, make sure
9
+ * these exist (a plain `sessions` count uses the primary key, the other two
10
+ * need their filter column indexed):
11
+ *
12
+ * ```sql
13
+ * CREATE INDEX webhook_events_status_index ON webhook_events (status);
14
+ * CREATE INDEX scheduled_emails_status_index ON scheduled_emails (status);
15
+ * ```
16
+ *
6
17
  * **Peer dependencies:** `@adonisjs/lucid`
7
18
  */
8
19
  export declare function appCollector(): MetricCollector;
@@ -46,6 +46,17 @@ function warnLucidMissing() {
46
46
  * Queries application-specific tables for user sessions,
47
47
  * pending webhooks, and pending emails.
48
48
  *
49
+ * **This polls your primary database** — three `COUNT(*)` queries every
50
+ * collection interval (3s by default), forever. Without indexes those are
51
+ * full table scans on every tick. If you enable this collector, make sure
52
+ * these exist (a plain `sessions` count uses the primary key, the other two
53
+ * need their filter column indexed):
54
+ *
55
+ * ```sql
56
+ * CREATE INDEX webhook_events_status_index ON webhook_events (status);
57
+ * CREATE INDEX scheduled_emails_status_index ON scheduled_emails (status);
58
+ * ```
59
+ *
49
60
  * **Peer dependencies:** `@adonisjs/lucid`
50
61
  */
51
62
  export function appCollector() {
@@ -1,8 +1,5 @@
1
1
  import { appImport } from '../utils/app_import.js';
2
2
  import { log, dim, bold } from '../utils/logger.js';
3
- let warnedNotInstalled = false;
4
- let warnedPingFailed = false;
5
- let warnedConnectionError = false;
6
3
  /** Default metrics returned when Redis is unavailable. */
7
4
  const REDIS_DEFAULTS = {
8
5
  redisOk: false,
@@ -39,14 +36,14 @@ export function computeRedisMetrics(memParsed, statsParsed, dbSize) {
39
36
  };
40
37
  }
41
38
  /** Import Redis service, returning null if unavailable. */
42
- async function importRedis() {
39
+ async function importRedis(warned) {
43
40
  try {
44
41
  const mod = await appImport('@adonisjs/redis/services/main');
45
42
  return mod.default;
46
43
  }
47
44
  catch {
48
- if (!warnedNotInstalled) {
49
- warnedNotInstalled = true;
45
+ if (!warned.notInstalled) {
46
+ warned.notInstalled = true;
50
47
  log.block(`Redis collector ${bold('skipped')} — @adonisjs/redis is not installed`, [
51
48
  dim('Redis metrics will return defaults until the package is added.'),
52
49
  `Run ${bold('node ace add @adonisjs/redis')} to install it.`,
@@ -56,10 +53,10 @@ async function importRedis() {
56
53
  }
57
54
  }
58
55
  /** Warn once when PING fails. */
59
- function warnPingFailed(pong) {
60
- if (warnedPingFailed)
56
+ function warnPingFailed(warned, pong) {
57
+ if (warned.pingFailed)
61
58
  return;
62
- warnedPingFailed = true;
59
+ warned.pingFailed = true;
63
60
  log.block(`Redis collector ${bold('unhealthy')} — PING returned ${bold(String(pong))} instead of PONG`, [
64
61
  dim('Redis may be down or misconfigured.'),
65
62
  `Check your connection settings in ${bold('config/redis.ts')}.`,
@@ -71,6 +68,9 @@ function warnPingFailed(pong) {
71
68
  * **Peer dependencies:** `@adonisjs/redis`
72
69
  */
73
70
  export function redisCollector() {
71
+ // Closure-scoped like db_pool_collector's flags — module-level state would
72
+ // let the first instance's warnings suppress every later instance's.
73
+ const warned = { notInstalled: false, pingFailed: false, connectionError: false };
74
74
  return {
75
75
  name: 'redis',
76
76
  label: 'redis — memory, clients, keys, hit rate',
@@ -78,13 +78,13 @@ export function redisCollector() {
78
78
  return {};
79
79
  },
80
80
  async collect() {
81
- const redis = await importRedis();
81
+ const redis = await importRedis(warned);
82
82
  if (!redis)
83
83
  return REDIS_DEFAULTS;
84
84
  try {
85
85
  const pong = await redis.ping();
86
86
  if (pong !== 'PONG') {
87
- warnPingFailed(pong);
87
+ warnPingFailed(warned, pong);
88
88
  return REDIS_DEFAULTS;
89
89
  }
90
90
  const [memoryInfo, statsInfo, dbSize] = await Promise.all([
@@ -95,8 +95,8 @@ export function redisCollector() {
95
95
  return computeRedisMetrics(parseRedisInfo(memoryInfo), parseRedisInfo(statsInfo), dbSize);
96
96
  }
97
97
  catch (error) {
98
- if (!warnedConnectionError) {
99
- warnedConnectionError = true;
98
+ if (!warned.connectionError) {
99
+ warned.connectionError = true;
100
100
  const message = error instanceof Error ? error.message : String(error);
101
101
  log.block(`Redis collector ${bold('error')} — failed to communicate with Redis`, [
102
102
  dim(message),
@@ -1,14 +1,28 @@
1
1
  import type { InspectorManager } from './inspector_manager.js';
2
2
  import type { HttpContext } from '@adonisjs/core/http';
3
+ /**
4
+ * Access policy for cache operations, resolved by the controller from
5
+ * `advanced.cacheKeyPrefix` (or the SERVER_STATS_CACHE_KEY_PREFIX env var).
6
+ *
7
+ * The prefix restricts which Redis keys the dashboard may list, read, or
8
+ * delete so a dashboard viewer can't target arbitrary keys (sessions,
9
+ * rate-limit counters). Empty/unset stays unrestricted in dev for
10
+ * back-compat, but fails closed in production: single-key reads and deletes
11
+ * on a shared production Redis need a deliberate scope, not a default.
12
+ */
13
+ export interface CacheAccessPolicy {
14
+ prefix: string;
15
+ inProduction: boolean;
16
+ }
3
17
  /**
4
18
  * Handle GET /cache-stats — list cache keys and overall stats.
5
19
  */
6
- export declare function handleCacheStats(inspectors: InspectorManager, { request, response }: HttpContext): Promise<void>;
20
+ export declare function handleCacheStats(inspectors: InspectorManager, { request, response }: HttpContext, policy: CacheAccessPolicy): Promise<void>;
7
21
  /**
8
22
  * Handle GET /cache-stats/:key — get a single cache key's value and metadata.
9
23
  */
10
- export declare function handleCacheKey(inspectors: InspectorManager, { params, response }: HttpContext): Promise<void>;
24
+ export declare function handleCacheKey(inspectors: InspectorManager, { params, response }: HttpContext, policy: CacheAccessPolicy): Promise<void>;
11
25
  /**
12
26
  * Handle DELETE /cache-stats/:key — delete a single cache key.
13
27
  */
14
- export declare function handleCacheKeyDelete(inspectors: InspectorManager, { params, response }: HttpContext): Promise<void>;
28
+ export declare function handleCacheKeyDelete(inspectors: InspectorManager, { params, response }: HttpContext, policy: CacheAccessPolicy): Promise<void>;
@@ -1,27 +1,22 @@
1
1
  import { clamp } from '../utils/math_helpers.js';
2
- /**
3
- * Optional allow-list prefix for single-key cache operations (get/delete).
4
- * Restricts which Redis keys the dashboard may read or delete so an attacker
5
- * can't target arbitrary keys (sessions, rate-limit counters). Empty/unset
6
- * means unrestricted (back-compat default for local dev).
7
- */
8
- function getCacheKeyPrefix() {
9
- return (process.env.SERVER_STATS_CACHE_KEY_PREFIX ?? '').trim();
10
- }
11
- /** Check whether a cache key is permitted under the configured prefix. */
12
- function isKeyAllowed(key) {
13
- const prefix = getCacheKeyPrefix();
14
- return prefix === '' || key.startsWith(prefix);
2
+ /** Check whether a cache key is permitted under the policy. */
3
+ function isKeyAllowed(key, policy) {
4
+ if (policy.prefix !== '')
5
+ return key.startsWith(policy.prefix);
6
+ return !policy.inProduction;
15
7
  }
16
8
  /**
17
9
  * Handle GET /cache-stats — list cache keys and overall stats.
18
10
  */
19
- export async function handleCacheStats(inspectors, { request, response }) {
11
+ export async function handleCacheStats(inspectors, { request, response }, policy) {
20
12
  const inspector = await inspectors.getCacheInspector();
21
13
  if (!inspector)
22
14
  return response.json({ available: false, stats: null, keys: [] });
23
15
  const qs = request.qs();
24
- const pattern = qs.search || qs.pattern ? `*${qs.search || qs.pattern}*` : '*';
16
+ // A configured prefix scopes the listing glob the same way it scopes
17
+ // single-key access, so keys outside the allow-list are never enumerated.
18
+ const search = qs.search || qs.pattern ? `*${qs.search || qs.pattern}*` : '*';
19
+ const pattern = policy.prefix !== '' ? `${policy.prefix}${search}` : search;
25
20
  try {
26
21
  const [stats, keyList] = await Promise.all([
27
22
  inspector.getStats(),
@@ -36,12 +31,12 @@ export async function handleCacheStats(inspectors, { request, response }) {
36
31
  /**
37
32
  * Handle GET /cache-stats/:key — get a single cache key's value and metadata.
38
33
  */
39
- export async function handleCacheKey(inspectors, { params, response }) {
34
+ export async function handleCacheKey(inspectors, { params, response }, policy) {
40
35
  const inspector = await inspectors.getCacheInspector();
41
36
  if (!inspector)
42
37
  return response.notFound({ error: 'Cache not available' });
43
38
  const key = decodeURIComponent(params.key);
44
- if (!isKeyAllowed(key))
39
+ if (!isKeyAllowed(key, policy))
45
40
  return response.forbidden({ error: 'Cache key not permitted' });
46
41
  try {
47
42
  const detail = await inspector.getKey(key);
@@ -54,12 +49,12 @@ export async function handleCacheKey(inspectors, { params, response }) {
54
49
  /**
55
50
  * Handle DELETE /cache-stats/:key — delete a single cache key.
56
51
  */
57
- export async function handleCacheKeyDelete(inspectors, { params, response }) {
52
+ export async function handleCacheKeyDelete(inspectors, { params, response }, policy) {
58
53
  const inspector = await inspectors.getCacheInspector();
59
54
  if (!inspector)
60
55
  return response.notFound({ error: 'Cache not available' });
61
56
  const key = decodeURIComponent(params.key);
62
- if (!isKeyAllowed(key))
57
+ if (!isKeyAllowed(key, policy))
63
58
  return response.forbidden({ error: 'Cache key not permitted' });
64
59
  try {
65
60
  return (await inspector.deleteKey(key))
@@ -1,11 +1,3 @@
1
- /**
2
- * In-flight request coalescing with short-lived result caching.
3
- *
4
- * - coalesce(): prevents concurrent identical calls from executing
5
- * in parallel. Only ONE executes; others get the same promise.
6
- * - cached(): adds a TTL layer on top of coalesce so repeat
7
- * requests within the window serve stale data instantly.
8
- */
9
1
  export declare class CoalesceCache {
10
2
  private inflight;
11
3
  private resultCache;
@@ -20,6 +12,8 @@ export declare class CoalesceCache {
20
12
  * fall through to coalesce().
21
13
  */
22
14
  cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T>;
15
+ /** Drop expired entries on write; if still at the cap, drop oldest first. */
16
+ private evictForSpace;
23
17
  /** Clear all cached results (does not affect in-flight requests). */
24
18
  clearCache(): void;
25
19
  }
@@ -6,6 +6,13 @@
6
6
  * - cached(): adds a TTL layer on top of coalesce so repeat
7
7
  * requests within the window serve stale data instantly.
8
8
  */
9
+ /**
10
+ * Hard cap on retained results. Keys embed user input (search terms, filters,
11
+ * pagination), so without a bound a dashboard viewer could grow the heap
12
+ * without limit by walking distinct queries. TTLs are 1-10s, so anything past
13
+ * a few hundred entries is expired garbage anyway.
14
+ */
15
+ const MAX_RESULT_ENTRIES = 500;
9
16
  export class CoalesceCache {
10
17
  inflight = new Map();
11
18
  resultCache = new Map();
@@ -33,6 +40,7 @@ export class CoalesceCache {
33
40
  }
34
41
  return this.coalesce(key, async () => {
35
42
  const result = await fn();
43
+ this.evictForSpace();
36
44
  this.resultCache.set(key, {
37
45
  data: result,
38
46
  expiresAt: Date.now() + ttlMs,
@@ -40,6 +48,23 @@ export class CoalesceCache {
40
48
  return result;
41
49
  });
42
50
  }
51
+ /** Drop expired entries on write; if still at the cap, drop oldest first. */
52
+ evictForSpace() {
53
+ if (this.resultCache.size < MAX_RESULT_ENTRIES)
54
+ return;
55
+ const now = Date.now();
56
+ for (const [key, entry] of this.resultCache) {
57
+ if (entry.expiresAt <= now)
58
+ this.resultCache.delete(key);
59
+ }
60
+ // Map iteration is insertion-ordered, so the front is the oldest.
61
+ while (this.resultCache.size >= MAX_RESULT_ENTRIES) {
62
+ const oldest = this.resultCache.keys().next().value;
63
+ if (oldest === undefined)
64
+ break;
65
+ this.resultCache.delete(oldest);
66
+ }
67
+ }
43
68
  /** Clear all cached results (does not affect in-flight requests). */
44
69
  clearCache() {
45
70
  this.resultCache.clear();
@@ -15,6 +15,7 @@ export default class DashboardController {
15
15
  requestDetail({ params, response }: HttpContext): Promise<void>;
16
16
  queriesGrouped({ request, response }: HttpContext): Promise<void>;
17
17
  queryExplain(ctx: HttpContext): Promise<unknown>;
18
+ private cachePolicy;
18
19
  cacheStats(ctx: HttpContext): Promise<void>;
19
20
  cacheKey(ctx: HttpContext): Promise<void>;
20
21
  cacheKeyDelete(ctx: HttpContext): Promise<void>;
@@ -116,14 +116,19 @@ export default class DashboardController {
116
116
  async queryExplain(ctx) {
117
117
  return handleQueryExplain(this.dashboardStore, this.app, ctx);
118
118
  }
119
+ cachePolicy() {
120
+ const config = this.app.config.get('server_stats');
121
+ const prefix = config?.advanced?.cacheKeyPrefix ?? process.env.SERVER_STATS_CACHE_KEY_PREFIX ?? '';
122
+ return { prefix: prefix.trim(), inProduction: this.app.inProduction };
123
+ }
119
124
  async cacheStats(ctx) {
120
- return handleCacheStats(this.inspectors, ctx);
125
+ return handleCacheStats(this.inspectors, ctx, this.cachePolicy());
121
126
  }
122
127
  async cacheKey(ctx) {
123
- return handleCacheKey(this.inspectors, ctx);
128
+ return handleCacheKey(this.inspectors, ctx, this.cachePolicy());
124
129
  }
125
130
  async cacheKeyDelete(ctx) {
126
- return handleCacheKeyDelete(this.inspectors, ctx);
131
+ return handleCacheKeyDelete(this.inspectors, ctx, this.cachePolicy());
127
132
  }
128
133
  async jobs(ctx) {
129
134
  return handleJobs(this.inspectors, ctx);
@@ -74,6 +74,5 @@ export declare class DashboardStore {
74
74
  getSavedFilters(section?: string): Promise<Record<string, unknown>[]>;
75
75
  createSavedFilter(name: string, section: string, filterConfig: Record<string, unknown>): Promise<Record<string, unknown> | null>;
76
76
  deleteSavedFilter(id: number): Promise<boolean>;
77
- runExplain(queryId: number, appDb: unknown): Promise<Record<string, unknown> | null>;
78
77
  private wireEventListeners;
79
78
  }
@@ -4,7 +4,6 @@ import { log } from '../utils/logger.js';
4
4
  import { ChartAggregator } from './chart_aggregator.js';
5
5
  import { CoalesceCache } from './coalesce_cache.js';
6
6
  import { buildEmailRecordFromEvent } from './email_event_builder.js';
7
- import { executeExplain } from './explain_query.js';
8
7
  import { FlushManager } from './flush_manager.js';
9
8
  import { createKnexConnection, applyPragmas } from './knex_factory.js';
10
9
  import { autoMigrate, runRetentionCleanup } from './migrator.js';
@@ -12,6 +11,7 @@ import { fetchOverviewMetrics, fetchChartData, fetchOverviewWidgets, fetchSparkl
12
11
  import { queryRequests, queryQueries, queryQueriesGrouped, queryEvents, queryEmails, queryEmailHtml, queryLogs, queryTraces, queryTraceDetail, queryRequestDetail, } from './read_queries.js';
13
12
  import { fetchSavedFilters, insertSavedFilter, removeSavedFilter } from './saved_filter_queries.js';
14
13
  import { fetchStorageStats } from './storage_stats.js';
14
+ import { resetWriteWarnings } from './write_queue.js';
15
15
  const EMPTY_PAGINATED = (p, pp) => ({
16
16
  data: [],
17
17
  total: 0,
@@ -77,6 +77,7 @@ export class DashboardStore {
77
77
  }
78
78
  async stop() {
79
79
  await this.flushMgr.stop();
80
+ resetWriteWarnings();
80
81
  if (this.retentionTimer) {
81
82
  clearInterval(this.retentionTimer);
82
83
  this.retentionTimer = null;
@@ -143,6 +144,10 @@ export class DashboardStore {
143
144
  this.flushMgr.recordLog(entry);
144
145
  }
145
146
  recordEmail(record) {
147
+ // Guarded here as well as in wireEventListeners: the Redis email bridge
148
+ // calls this directly for cross-process mail, bypassing the listeners.
149
+ if (this.config.capture?.emails === false)
150
+ return;
146
151
  this.flushMgr.recordEmail(record);
147
152
  }
148
153
  async flushWriteQueue() {
@@ -204,12 +209,15 @@ export class DashboardStore {
204
209
  async deleteSavedFilter(id) {
205
210
  return this.db ? removeSavedFilter(this.db, id) : false;
206
211
  }
207
- async runExplain(queryId, appDb) {
208
- if (!this.db)
209
- return { error: 'Dashboard store not initialized' };
210
- return executeExplain(this.db, this.cache, queryId, appDb);
211
- }
212
212
  wireEventListeners() {
213
+ // `capture.emails: false` must stop persistence too, not just the in-memory
214
+ // collector — otherwise production mode stores full mail bodies while the
215
+ // config promises it doesn't. Undefined means an externally-constructed
216
+ // store without resolved capture flags; default on, matching DebugStore.
217
+ if (this.config.capture?.emails === false) {
218
+ log.info('dashboard: email capture disabled — mail listeners not wired');
219
+ return;
220
+ }
213
221
  if (!this.emitter || typeof this.emitter.on !== 'function') {
214
222
  log.warn('dashboard: emitter not available — email collection disabled');
215
223
  return;
@@ -14,6 +14,29 @@ export async function handleSavedFilters(dashboardStore) {
14
14
  })),
15
15
  };
16
16
  }
17
+ /**
18
+ * Validate saved-filter input. Returns an error message, or null when valid.
19
+ *
20
+ * Size bounds matter because these land in SQLite verbatim and the list
21
+ * endpoint returns every row — one oversized filter would bloat every
22
+ * response.
23
+ */
24
+ function validateSavedFilterInput(name, section, filterConfig) {
25
+ if (!name || !section || !filterConfig) {
26
+ return 'Missing required fields: name, section, filterConfig';
27
+ }
28
+ if (typeof name !== 'string' || name.length > 100) {
29
+ return 'name must be a string of at most 100 chars';
30
+ }
31
+ if (typeof section !== 'string' || section.length > 50) {
32
+ return 'section must be a string of at most 50 chars';
33
+ }
34
+ const configJson = typeof filterConfig === 'string' ? filterConfig : JSON.stringify(filterConfig);
35
+ if (configJson.length > 4096) {
36
+ return 'filterConfig too large (max 4096 chars)';
37
+ }
38
+ return null;
39
+ }
17
40
  /**
18
41
  * Handle POST /saved-filters — create a new saved filter.
19
42
  * Returns the response payload or null if creation fails.
@@ -24,10 +47,9 @@ export async function handleCreateSavedFilter(dashboardStore, { request, respons
24
47
  }
25
48
  try {
26
49
  const { name, section, filterConfig } = request.body();
27
- if (!name || !section || !filterConfig) {
28
- return response.badRequest({
29
- error: 'Missing required fields: name, section, filterConfig',
30
- });
50
+ const validationError = validateSavedFilterInput(name, section, filterConfig);
51
+ if (validationError) {
52
+ return response.badRequest({ error: validationError });
31
53
  }
32
54
  const result = await dashboardStore.createSavedFilter(name, section, typeof filterConfig === 'string' ? safeParseJson(filterConfig) : filterConfig);
33
55
  return result
@@ -7,6 +7,7 @@ export declare class FlushManager {
7
7
  pendingEmails: EmailRecord[];
8
8
  private flushTimer;
9
9
  private flushing;
10
+ private stopped;
10
11
  private inFlight;
11
12
  private db;
12
13
  constructor(getDb: () => Knex | null);
@@ -8,6 +8,7 @@ export class FlushManager {
8
8
  pendingEmails = [];
9
9
  flushTimer = null;
10
10
  flushing = false;
11
+ stopped = false;
11
12
  inFlight = null;
12
13
  db;
13
14
  constructor(getDb) {
@@ -35,6 +36,10 @@ export class FlushManager {
35
36
  this.scheduleFlush();
36
37
  }
37
38
  async stop() {
39
+ // Block rescheduling first: an in-flight runFlush ends with a
40
+ // scheduleFlush() that would otherwise create a fresh, untracked timer
41
+ // after the clearTimeout below — outliving this "clean" shutdown.
42
+ this.stopped = true;
38
43
  if (this.flushTimer) {
39
44
  clearTimeout(this.flushTimer);
40
45
  this.flushTimer = null;
@@ -50,7 +55,7 @@ export class FlushManager {
50
55
  q.splice(0, Math.floor(MAX_Q / 4));
51
56
  }
52
57
  scheduleFlush() {
53
- if (this.flushTimer)
58
+ if (this.stopped || this.flushTimer)
54
59
  return;
55
60
  this.flushTimer = setTimeout(() => {
56
61
  this.flushTimer = null;
@@ -120,7 +120,3 @@ export declare function formatGroupedQuery(g: Record<string, unknown>, totalTime
120
120
  totalDuration: number;
121
121
  percentOfTotal: number;
122
122
  };
123
- /** Run EXPLAIN on a query, returning the plan. */
124
- export declare function runExplain(appDb: {
125
- raw: (sql: string, bindings: unknown[]) => Promise<Record<string, unknown>>;
126
- }, query: Record<string, unknown>): Promise<unknown[]>;
@@ -119,22 +119,3 @@ export function formatGroupedQuery(g, totalTime) {
119
119
  percentOfTotal: totalTime > 0 ? round((g.total_duration / totalTime) * 100) : 0,
120
120
  };
121
121
  }
122
- /** Run EXPLAIN on a query, returning the plan. */
123
- export async function runExplain(appDb, query) {
124
- let bindings = [];
125
- if (query.bindings) {
126
- try {
127
- bindings = JSON.parse(query.bindings);
128
- }
129
- catch {
130
- /* skip */
131
- }
132
- }
133
- const explainResult = await appDb.raw(`EXPLAIN (FORMAT JSON) ${query.sql_text}`, bindings);
134
- const rawRows = explainResult?.rows ??
135
- (Array.isArray(explainResult) ? explainResult : []);
136
- if (rawRows.length > 0 && rawRows[0]['QUERY PLAN']) {
137
- return rawRows[0]['QUERY PLAN'];
138
- }
139
- return rawRows;
140
- }
@@ -102,7 +102,7 @@ function sanitizeValue(key, value, seen) {
102
102
  return redact(value);
103
103
  }
104
104
  if (typeof value === 'object' && value !== null) {
105
- return sanitizeObject(value, seen);
105
+ return sanitizeObject(value, seen, key);
106
106
  }
107
107
  return value;
108
108
  }
@@ -110,7 +110,7 @@ function sanitizeValue(key, value, seen) {
110
110
  * Recursively sanitize an object, redacting string values whose keys
111
111
  * match sensitive patterns. Booleans and numbers are never redacted.
112
112
  */
113
- function sanitizeObject(obj, seen = new WeakSet()) {
113
+ function sanitizeObject(obj, seen = new WeakSet(), parentKey = '') {
114
114
  if (obj === null || obj === undefined)
115
115
  return obj;
116
116
  if (typeof obj !== 'object')
@@ -119,7 +119,10 @@ function sanitizeObject(obj, seen = new WeakSet()) {
119
119
  return '[Circular]';
120
120
  seen.add(obj);
121
121
  if (Array.isArray(obj)) {
122
- return obj.map((item) => sanitizeObject(item, seen));
122
+ // Route items through sanitizeValue (under the array's own key) so string
123
+ // elements get the value-shape checks — previously a credential inside an
124
+ // array survived because sanitizeObject passes primitives through.
125
+ return obj.map((item) => sanitizeValue(parentKey, item, seen));
123
126
  }
124
127
  const record = obj;
125
128
  const result = {};
@@ -28,6 +28,10 @@ export async function migrateRequests(db) {
28
28
  await db.raw(`CREATE INDEX IF NOT EXISTS idx_ss_requests_created ON server_stats_requests(created_at)`);
29
29
  await db.raw(`CREATE INDEX IF NOT EXISTS idx_ss_requests_url ON server_stats_requests(url)`);
30
30
  await db.raw(`CREATE INDEX IF NOT EXISTS idx_ss_requests_duration ON server_stats_requests(duration)`);
31
+ // Covers the overview p95 query (WHERE created_at >= ? ORDER BY duration):
32
+ // with only the single-column indexes SQLite sorts the whole filtered range
33
+ // on every 2s cache miss.
34
+ await db.raw(`CREATE INDEX IF NOT EXISTS idx_ss_requests_created_duration ON server_stats_requests(created_at, duration)`);
31
35
  await db.raw(`CREATE INDEX IF NOT EXISTS idx_ss_requests_status ON server_stats_requests(status_code)`);
32
36
  try {
33
37
  await db.raw('ALTER TABLE server_stats_requests ADD COLUMN http_request_id TEXT');
@@ -15,31 +15,41 @@ export function clampPerPage(value) {
15
15
  return 25;
16
16
  return clamp(Math.trunc(n), 1, 200);
17
17
  }
18
+ /**
19
+ * Rows the COUNT is allowed to scan. Filters include leading-wildcard LIKEs
20
+ * that no index can serve, so an uncapped COUNT walks the entire table twice
21
+ * per request (count + data). Past this many matches the exact total stops
22
+ * being interesting — the UI just needs "lots of pages".
23
+ */
24
+ const COUNT_SCAN_CAP = 10_000;
18
25
  /**
19
26
  * Execute a paginated query within a transaction.
20
27
  */
21
28
  export async function executePaginate(db, opts) {
22
29
  const perPage = clampPerPage(opts.perPage);
23
30
  return db.transaction(async (trx) => {
24
- const countQuery = trx(opts.table);
31
+ const countSub = trx(opts.table).select(trx.raw('1')).limit(COUNT_SCAN_CAP);
25
32
  if (opts.applyFilters)
26
- opts.applyFilters(countQuery);
27
- const [{ count: totalRaw }] = await countQuery.count('* as count');
28
- const total = Number(totalRaw);
29
- const offset = (opts.page - 1) * perPage;
30
- const dataQuery = trx(opts.table)
31
- .orderBy('created_at', 'desc')
32
- .limit(perPage)
33
- .offset(offset);
33
+ opts.applyFilters(countSub);
34
+ const countRows = (await trx.count('* as count').from(countSub.as('t')));
35
+ const total = Number(countRows[0]?.count ?? 0);
36
+ // Clamp the page into the real range: an arbitrary ?page= forces a
37
+ // full-scan-sized OFFSET on the single sqlite connection, blocking every
38
+ // other dashboard read for nothing.
39
+ const lastPage = Math.ceil(total / perPage);
40
+ const requested = Number.isFinite(Number(opts.page)) ? Math.trunc(Number(opts.page)) : 1;
41
+ const page = clamp(requested, 1, Math.max(1, lastPage));
42
+ const offset = (page - 1) * perPage;
43
+ const dataQuery = trx(opts.table).orderBy('created_at', 'desc').limit(perPage).offset(offset);
34
44
  if (opts.applyFilters)
35
45
  opts.applyFilters(dataQuery);
36
46
  const data = await dataQuery;
37
47
  return {
38
48
  data,
39
49
  total,
40
- page: opts.page,
50
+ page,
41
51
  perPage,
42
- lastPage: Math.ceil(total / perPage),
52
+ lastPage,
43
53
  };
44
54
  });
45
55
  }
@@ -37,3 +37,40 @@ export declare function isSensitiveConfigName(name: string): boolean;
37
37
  */
38
38
  export declare function sqlMentionsSecret(sql: string): boolean;
39
39
  export declare function looksLikeCredentialValue(value: string): boolean;
40
+ /**
41
+ * Redact and truncate SQL bindings before persistence.
42
+ *
43
+ * Two independent rules, because neither catches everything on its own:
44
+ *
45
+ * 1. **By statement.** If the SQL mentions a credential-shaped identifier
46
+ * (`password`, `remember_token`, `otp`, ...), every binding for that
47
+ * statement is redacted. Positional bindings cannot be mapped back to
48
+ * columns reliably, so this is all-or-nothing per statement — coarse, but
49
+ * it is the only thing that catches a short secret like a 6-digit OTP.
50
+ * 2. **By value shape.** Hashes, JWTs, provider key prefixes, long hex
51
+ * digests, and URLs with embedded credentials are redacted wherever they
52
+ * appear, regardless of the statement.
53
+ *
54
+ * Anything that survives both is truncated at {@link MAX_BINDING_LEN} so an
55
+ * oversized payload cannot bloat the row.
56
+ *
57
+ * Ordinary parameters — ids, flags, emails, timestamps — still pass through, so
58
+ * the query pane stays useful for debugging.
59
+ */
60
+ export declare function sanitizeBindings(bindings: unknown, sqlText?: string): unknown;
61
+ /**
62
+ * Redact a structured record (a pino log entry, an event payload) with the
63
+ * same key/shape rules as bindings but WITHOUT length truncation — log data
64
+ * carries stack traces and payload context that must stay whole.
65
+ */
66
+ export declare function sanitizeRecordValues(record: unknown): unknown;
67
+ /**
68
+ * Redact credential-bearing query parameters from a URL before it is stored.
69
+ *
70
+ * Request URLs are persisted with their query strings (useful for debugging
71
+ * pagination and filters), but a `?token=…` password-reset link or a
72
+ * `?signature=…` signed URL must not land in SQLite in the clear. Parameters
73
+ * are redacted by name (see {@link isSecretName}) or when the value itself
74
+ * looks like a credential; everything else passes through untouched.
75
+ */
76
+ export declare function sanitizeUrlQuery(url: string): string;