bunqueue-dashboard 0.0.32 → 0.0.34

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 (106) hide show
  1. package/README.md +44 -15
  2. package/agent/db.ts +537 -64
  3. package/agent/dbQueryWorker.ts +34 -11
  4. package/agent/manager.ts +186 -45
  5. package/agent/server.ts +124 -35
  6. package/dist/.vite/manifest.json +212 -157
  7. package/dist/assets/AddJob-Cih9F84t.js +3 -0
  8. package/dist/assets/Alerts-Dp4Vclez.js +1 -0
  9. package/dist/assets/{AreaChart-2gM0YoZT.js → AreaChart-REK4ZsTC.js} +1 -1
  10. package/dist/assets/Benchmark-X_gdZun5.js +1 -0
  11. package/dist/assets/BulkAddJobs-CXTpYMZf.js +5 -0
  12. package/dist/assets/CopilotPanel-CIXpcwkH.js +44 -0
  13. package/dist/assets/Cron-DUHkse77.js +1 -0
  14. package/dist/assets/CronManager-BeYrLK2E.js +1 -0
  15. package/dist/assets/Database-CXPb3XGr.js +3 -0
  16. package/dist/assets/Diagnostics-DDtoiDYT.js +1 -0
  17. package/dist/assets/Dlq-CX_uRblW.js +1 -0
  18. package/dist/assets/DlqControl-CazFmSEd.js +1 -0
  19. package/dist/assets/DlqPro-HSffdtLJ.js +1 -0
  20. package/dist/assets/Flows-CGmyghKF.js +1 -0
  21. package/dist/assets/JobInspector-B9i1HnG5.js +2 -0
  22. package/dist/assets/Jobs-BZGtMnJI.js +1 -0
  23. package/dist/assets/JobsPro-tmEN8f-P.js +1 -0
  24. package/dist/assets/Logs-DxUD9V2A.js +1 -0
  25. package/dist/assets/LogsPro-BTQhvPKj.js +1 -0
  26. package/dist/assets/McpServer-D5rwBg74.js +22 -0
  27. package/dist/assets/Metrics-CSPpBf1m.js +1 -0
  28. package/dist/assets/MetricsPro-DSn-3qNT.js +1 -0
  29. package/dist/assets/Overview-StvHYRZ5.js +1 -0
  30. package/dist/assets/{Pagination-B9JDi5MI.js → Pagination-eotgGQf9.js} +1 -1
  31. package/dist/assets/QueueActions-CibRLzrz.js +1 -0
  32. package/dist/assets/QueueControl-DwYNcMfI.js +1 -0
  33. package/dist/assets/QueueDetail-NMfKwj6E.js +1 -0
  34. package/dist/assets/QueueDetailPro-C8SBjX09.js +1 -0
  35. package/dist/assets/Queues-BeTAi8V8.js +1 -0
  36. package/dist/assets/QueuesOverview-BfQO8NS4.js +1 -0
  37. package/dist/assets/S3Backup-BYkAtONl.js +1 -0
  38. package/dist/assets/S3BackupPro-BP1Ch54R.js +2 -0
  39. package/dist/assets/ServerControl-C85jYgyN.js +3 -0
  40. package/dist/assets/Settings-DjP28c_5.js +1 -0
  41. package/dist/assets/Usage-DECeEsO2.js +1 -0
  42. package/dist/assets/UsagePro-CD7dgt3d.js +1 -0
  43. package/dist/assets/Webhooks-Ct-x8DD1.js +1 -0
  44. package/dist/assets/Workers-lP1BXHFq.js +1 -0
  45. package/dist/assets/WorkersPro-CAUqy3Cp.js +1 -0
  46. package/dist/assets/connectionStore-Cd9-3Fw0.js +1 -0
  47. package/dist/assets/flowMutationSafety-DVD1IMr-.js +1 -0
  48. package/dist/assets/form-DsUq0w8s.js +1 -0
  49. package/dist/assets/index-BWoUj86M.css +1 -0
  50. package/dist/assets/index-Dr__4DsT.js +8 -0
  51. package/dist/assets/install-DPCcfOYo.js +3 -0
  52. package/dist/assets/jobActions-LQD4KfCc.js +1 -0
  53. package/dist/assets/promisePool-BVfQ9n9Q.js +1 -0
  54. package/dist/assets/{react-vendor-BcouBxpZ.js → react-vendor-Bjui4wS4.js} +1 -1
  55. package/dist/assets/upstreamPaths-nG8N1XAz.js +1 -0
  56. package/dist/assets/useServerActionGuard-CItj3hvh.js +1 -0
  57. package/dist/assets/useThroughputSeries-Bpyg6Jaq.js +1 -0
  58. package/dist/index.html +5 -4
  59. package/package.json +11 -3
  60. package/scripts/embedded.gen.ts +132 -126
  61. package/scripts/serve.ts +259 -67
  62. package/dist/assets/AddJob-Dvpp--yq.js +0 -3
  63. package/dist/assets/Alerts-CR9jgCCs.js +0 -1
  64. package/dist/assets/Benchmark-CkL-Ee-M.js +0 -1
  65. package/dist/assets/BulkAddJobs-wI6cRZnX.js +0 -5
  66. package/dist/assets/CopilotPanel-DPZV9opm.js +0 -44
  67. package/dist/assets/Cron-gWVkYWad.js +0 -1
  68. package/dist/assets/CronManager-BvfW219u.js +0 -1
  69. package/dist/assets/Database-D2T_ZIGA.js +0 -3
  70. package/dist/assets/Diagnostics-C2goqq5h.js +0 -1
  71. package/dist/assets/Dlq-Q4TmmZrK.js +0 -1
  72. package/dist/assets/DlqControl-C5BfE7Gq.js +0 -1
  73. package/dist/assets/DlqPro-ClPNmw-X.js +0 -1
  74. package/dist/assets/Flows-DbhMbvSG.js +0 -1
  75. package/dist/assets/JobInspector-CO_zKjSX.js +0 -2
  76. package/dist/assets/Jobs-D2rDHPM_.js +0 -1
  77. package/dist/assets/JobsPro-Drdgdj3s.js +0 -1
  78. package/dist/assets/Logs-DjwGuRLi.js +0 -1
  79. package/dist/assets/LogsPro-7uTBjPWX.js +0 -1
  80. package/dist/assets/McpServer-DAklQDB5.js +0 -22
  81. package/dist/assets/Metrics-Dup9Zinh.js +0 -1
  82. package/dist/assets/MetricsPro-PP3TymeP.js +0 -1
  83. package/dist/assets/Overview-C7RCDE6r.js +0 -1
  84. package/dist/assets/QueueActions-DJTw0h2g.js +0 -1
  85. package/dist/assets/QueueControl-CxKjUjF-.js +0 -1
  86. package/dist/assets/QueueDetail-bf2n-r2h.js +0 -1
  87. package/dist/assets/QueueDetailPro-C-XLWFBa.js +0 -1
  88. package/dist/assets/Queues-CMQW89J9.js +0 -1
  89. package/dist/assets/QueuesOverview-x7-akDzf.js +0 -1
  90. package/dist/assets/S3Backup-1jAySC9R.js +0 -1
  91. package/dist/assets/S3BackupPro-BG_0OHW7.js +0 -1
  92. package/dist/assets/ServerControl-DohTB--h.js +0 -3
  93. package/dist/assets/Settings-CnS5PjNe.js +0 -1
  94. package/dist/assets/Usage-C3H0hMSA.js +0 -1
  95. package/dist/assets/UsagePro-CdnQkP9o.js +0 -1
  96. package/dist/assets/Webhooks-CAwVmIEZ.js +0 -1
  97. package/dist/assets/Workers-lvujKZwF.js +0 -1
  98. package/dist/assets/WorkersPro-CIJKl7U1.js +0 -1
  99. package/dist/assets/connectionStore-DO6xQY7h.js +0 -1
  100. package/dist/assets/form-DGinTEEb.js +0 -1
  101. package/dist/assets/index-DcVOpnSj.css +0 -1
  102. package/dist/assets/index-r1ZMLIZ9.js +0 -8
  103. package/dist/assets/install-BIVfix28.js +0 -3
  104. package/dist/assets/jobActions-CuOdMglt.js +0 -1
  105. package/dist/assets/promisePool-Daxkc1OB.js +0 -1
  106. package/dist/assets/useThroughputSeries-CVKzWzdv.js +0 -1
package/scripts/serve.ts CHANGED
@@ -14,44 +14,159 @@
14
14
  * browser, where loopback :6800 would be unreachable.
15
15
  *
16
16
  * Control-plane exposure rule (the agent can spawn processes):
17
- * - Loopback bind (the default): /agent/* is bridged, and the loopback :6800
18
- * listener keeps its Host allowlist (DNS-rebinding defense).
19
- * - Non-loopback bind (BIND_ADDR=0.0.0.0): the bridge would hand the whole
20
- * LAN an unauthenticated process spawner (the agent's Origin gate lets an
21
- * Origin-less curl through by design), so it is REFUSED with 403 unless the
22
- * operator opts in with AGENT_TOKEN (recommended — it gates every mutation)
23
- * or AGENT_ALLOW_REMOTE_CONTROL=1 (explicit, unauthenticated).
17
+ * - Truly local loopback access (the default): /agent/* keeps zero-config
18
+ * reads, and the direct loopback :6800 listener remains local-only.
19
+ * - Non-loopback bind (BIND_ADDR=0.0.0.0): AGENT_TOKEN is mandatory and gates
20
+ * EVERY agent route, including status/log/database reads. The public
21
+ * listener also keeps a Host allowlist; put every public/LAN hostname or IP
22
+ * in AGENT_ALLOWED_HOSTS (or AGENT_ALLOWED_ORIGINS) to admit it. There is no
23
+ * unauthenticated remote-control opt-in.
24
+ * - A loopback bind reached through a reverse proxy follows the same remote
25
+ * rule. TRUST_PROXY, non-loopback allowlists/origins, forwarding headers or
26
+ * a non-loopback request Host all switch the bridge to all-route token auth.
27
+ * - The same signals gate the administrative /api proxy independently with
28
+ * BUNQUEUE_TOKEN; without it, remote/proxied /api access is disabled.
24
29
  *
25
30
  * Env: PORT (dashboard, default 8080) · BIND_ADDR (default 127.0.0.1) ·
26
31
  * BUNQUEUE_URL · AGENT_PORT · AGENT_ALLOWED_ORIGINS · AGENT_ALLOWED_HOSTS ·
27
- * AGENT_TOKEN · AGENT_ALLOW_REMOTE_CONTROL · TRUST_PROXY · BUNQUEUE_START_CMD ·
32
+ * AGENT_TOKEN · BUNQUEUE_TOKEN · TRUST_PROXY · BUNQUEUE_START_CMD ·
28
33
  * HTTP_PORT · TCP_PORT · BUNQUEUE_DATA_PATH · LOG_LEVEL (pino level, info)
29
34
  */
35
+ import { timingSafeEqual } from 'node:crypto';
30
36
  import { logger } from '../agent/logger';
31
37
  import { setQueryWorkerUrl } from '../agent/db';
32
38
  import {
33
39
  createFetchHandler,
40
+ hostnameOf,
34
41
  isHostAllowed,
35
42
  isOriginAllowed,
36
43
  resolveAllowedHosts,
37
44
  resolveAllowedOrigins,
38
45
  } from '../agent/server';
39
46
 
40
- /** Hostnames that mean "only this machine can reach the listener". */
47
+ export const RESPONSE_SECURITY_HEADERS = {
48
+ 'Content-Security-Policy': "frame-ancestors 'none'; object-src 'none'; base-uri 'self'",
49
+ 'X-Frame-Options': 'DENY',
50
+ 'X-Content-Type-Options': 'nosniff',
51
+ 'Referrer-Policy': 'no-referrer',
52
+ } as const;
53
+
54
+ export function withSecurityHeaders(response: Response): Response {
55
+ const headers = new Headers(response.headers);
56
+ for (const [name, value] of Object.entries(RESPONSE_SECURITY_HEADERS)) headers.set(name, value);
57
+ return new Response(response.body, {
58
+ status: response.status,
59
+ statusText: response.statusText,
60
+ headers,
61
+ });
62
+ }
63
+
64
+ /** True loopback names/addresses. 0.0.0.0 is a wildcard, never local-only. */
65
+ export function isLoopbackHost(host: string): boolean {
66
+ const value = hostnameOf(host);
67
+ const octets = value.split('.');
68
+ const ipv4Loopback =
69
+ octets.length === 4 &&
70
+ octets[0] === '127' &&
71
+ octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255);
72
+ return (
73
+ value === 'localhost' ||
74
+ value === '::1' ||
75
+ value === '0:0:0:0:0:0:0:1' ||
76
+ ipv4Loopback
77
+ );
78
+ }
79
+
80
+ /** Bind addresses that mean "only this machine can reach the listener". */
41
81
  export function isLoopbackBind(host: string): boolean {
42
- return host === '127.0.0.1' || host === '::1' || host === 'localhost';
82
+ return isLoopbackHost(host);
83
+ }
84
+
85
+ function configuredValues(value: string | undefined): string[] {
86
+ return (value ?? '')
87
+ .split(',')
88
+ .map((entry) => entry.trim())
89
+ .filter(Boolean);
43
90
  }
44
91
 
45
92
  /**
46
- * May the process-spawning control plane be reachable over the network?
47
- * Loopback always yes; a non-loopback bind requires an explicit opt-in.
93
+ * A loopback socket can still be public through a reverse proxy. These are
94
+ * operator-controlled signals that the same-origin /agent bridge is intended
95
+ * to be reachable beyond the machine, so its reads and writes must both use
96
+ * the bearer-token policy.
48
97
  */
49
- export function remoteControlEnabled(
98
+ export function remoteBridgeRequiresToken(
50
99
  loopbackBind: boolean,
51
100
  env: Record<string, string | undefined> = process.env
52
101
  ): boolean {
53
- if (loopbackBind) return true;
54
- return Boolean(env.AGENT_TOKEN) || env.AGENT_ALLOW_REMOTE_CONTROL === '1';
102
+ if (!loopbackBind || env.TRUST_PROXY === '1') return true;
103
+ const configured = [
104
+ ...configuredValues(env.AGENT_ALLOWED_HOSTS),
105
+ ...configuredValues(env.AGENT_ALLOWED_ORIGINS),
106
+ ];
107
+ return configured.some((value) => !isLoopbackHost(value));
108
+ }
109
+
110
+ const PROXY_HINT_HEADERS = [
111
+ 'forwarded',
112
+ 'x-forwarded-for',
113
+ 'x-forwarded-host',
114
+ 'x-forwarded-proto',
115
+ ] as const;
116
+
117
+ /**
118
+ * Per-request backstop for a public Host/Origin or a proxy that adds forwarding
119
+ * metadata. Forwarded-header presence is only used to demand stronger auth;
120
+ * values remain untrusted unless TRUST_PROXY enables the Origin comparison.
121
+ */
122
+ export function isRemoteBridgeRequest(req: Request, remotePolicy = false): boolean {
123
+ if (remotePolicy || PROXY_HINT_HEADERS.some((name) => req.headers.has(name))) return true;
124
+ const origin = req.headers.get('origin');
125
+ if (origin && !isLoopbackHost(origin)) return true;
126
+ const host = req.headers.get('host') ?? new URL(req.url).hostname;
127
+ return !isLoopbackHost(host);
128
+ }
129
+
130
+ /** Exact, timing-safe bearer comparison for the standalone admin-API proxy. */
131
+ export function apiTokenOk(req: Request, token: string | undefined): boolean {
132
+ if (!token) return false;
133
+ const authorization = req.headers.get('authorization');
134
+ if (!authorization?.startsWith('Bearer ')) return false;
135
+ const supplied = Buffer.from(authorization.slice('Bearer '.length));
136
+ const expected = Buffer.from(token);
137
+ return supplied.length === expected.length && timingSafeEqual(supplied, expected);
138
+ }
139
+
140
+ /** Host policy shared by main() and deployment regression tests. */
141
+ export function resolveServeAllowedHosts(
142
+ bindHost: string,
143
+ allowedOrigins: string[],
144
+ env: Record<string, string | undefined> = process.env
145
+ ): string[] {
146
+ const originHosts = allowedOrigins
147
+ .map((origin) => {
148
+ try {
149
+ return new URL(origin).hostname;
150
+ } catch {
151
+ return '';
152
+ }
153
+ })
154
+ .filter(Boolean);
155
+ const concreteBindHosts = bindHost === '0.0.0.0' || bindHost === '::' ? [] : [bindHost];
156
+ return resolveAllowedHosts(env, [...originHosts, ...concreteBindHosts]);
157
+ }
158
+
159
+ /**
160
+ * May the process-spawning bridge be enabled? Truly local access stays
161
+ * zero-config; any remote deployment policy requires a real token. This
162
+ * intentionally ignores the former unauthenticated escape hatch.
163
+ */
164
+ export function remoteControlEnabled(
165
+ remotePolicy: boolean,
166
+ env: Record<string, string | undefined> = process.env
167
+ ): boolean {
168
+ if (!remotePolicy) return true;
169
+ return Boolean(env.AGENT_TOKEN?.trim());
55
170
  }
56
171
 
57
172
  /**
@@ -74,12 +189,24 @@ export interface ServeHandlerOptions {
74
189
  indexHtml: string;
75
190
  /** embedded dist assets: request path → on-disk path. */
76
191
  assets: Record<string, string>;
77
- /** in-process control agent handler. */
192
+ /** Local policy: zero-config reads, configured token on mutations. */
78
193
  agentHandle: (req: Request) => Response | Promise<Response>;
194
+ /** Remote/proxied policy: configured token on every agent route. */
195
+ remoteAgentHandle: (req: Request) => Response | Promise<Response>;
79
196
  allowedOrigins: string[];
80
197
  allowedHosts?: string[];
81
- /** false → /agent/* is refused (non-loopback bind without opt-in). */
198
+ /** false → /agent/* is refused under a remote policy without AGENT_TOKEN. */
82
199
  agentBridge: boolean;
200
+ /** Whether the remote bridge has a non-empty AGENT_TOKEN to enforce. */
201
+ agentTokenConfigured: boolean;
202
+ /**
203
+ * Bearer required by /api on every remote/proxied request. It is deliberately
204
+ * independent of AGENT_TOKEN because the two credentials protect different
205
+ * principals and can be rotated independently.
206
+ */
207
+ apiToken?: string;
208
+ /** Deployment-level signal that even a loopback bind is externally proxied. */
209
+ remoteBridgePolicy?: boolean;
83
210
  /**
84
211
  * TRUST_PROXY=1 — a reverse proxy in front of us owns X-Forwarded-Host, so it
85
212
  * may be believed. Off by default: the header is otherwise client-settable
@@ -98,11 +225,16 @@ export function createServeHandler(opts: ServeHandlerOptions) {
98
225
  indexHtml,
99
226
  assets,
100
227
  agentHandle,
228
+ remoteAgentHandle,
101
229
  allowedOrigins,
102
230
  allowedHosts,
103
231
  agentBridge,
232
+ agentTokenConfigured,
233
+ apiToken,
234
+ remoteBridgePolicy = false,
104
235
  trustProxy = false,
105
236
  } = opts;
237
+ const secure = withSecurityHeaders;
106
238
  const indexResponse = () =>
107
239
  new Response(indexHtml, { headers: { 'content-type': 'text/html; charset=utf-8' } });
108
240
 
@@ -110,10 +242,10 @@ export function createServeHandler(opts: ServeHandlerOptions) {
110
242
  const url = new URL(req.url);
111
243
 
112
244
  // DNS-rebinding defense across every route (/api proxy, /agent, assets):
113
- // a page rebound to this loopback port sends its own domain as Host. No-op
114
- // on non-loopback binds (allowedHosts is undefined there).
245
+ // a rebound page sends its own domain as Host. Main configures this on both
246
+ // loopback and network binds; tests/custom embedders may omit the policy.
115
247
  if (!isHostAllowed(req.headers.get('host'), allowedHosts)) {
116
- return new Response('Host not allowed', { status: 403 });
248
+ return secure(new Response('Host not allowed', { status: 403 }));
117
249
  }
118
250
 
119
251
  // A request whose Origin is the origin this listener serves from is by
@@ -152,17 +284,20 @@ export function createServeHandler(opts: ServeHandlerOptions) {
152
284
 
153
285
  // Same-origin bridge to the control agent: strip the /agent prefix and
154
286
  // hand the request to the in-process agent handler (no loopback hop).
155
- // The agent's own Origin allowlist + optional AGENT_TOKEN still apply —
156
- // this changes reachability, not authorization.
287
+ // The selected agent handler still applies Host, Origin and token policy;
288
+ // this bridge changes reachability, never authorization.
157
289
  if (url.pathname === '/agent' || url.pathname.startsWith('/agent/')) {
158
- if (!agentBridge) {
159
- return Response.json(
160
- {
161
- ok: false,
162
- error:
163
- 'Control agent disabled on a non-loopback bind. Set AGENT_TOKEN (recommended) or AGENT_ALLOW_REMOTE_CONTROL=1 to expose it.',
164
- },
165
- { status: 403 }
290
+ const remoteRequest = isRemoteBridgeRequest(req, remoteBridgePolicy);
291
+ if (!agentBridge || (remoteRequest && !agentTokenConfigured)) {
292
+ return secure(
293
+ Response.json(
294
+ {
295
+ ok: false,
296
+ error:
297
+ 'Control agent disabled for remote or proxied access. Set AGENT_TOKEN to expose it.',
298
+ },
299
+ { status: 403 }
300
+ )
166
301
  );
167
302
  }
168
303
  const sub = agentSubUrl(url.pathname, url.search);
@@ -171,7 +306,17 @@ export function createServeHandler(opts: ServeHandlerOptions) {
171
306
  // (a LAN IP / hostname alias). Loopback origins are already allowlisted,
172
307
  // so they are forwarded untouched and CORS behaviour is unchanged.
173
308
  if (sameOrigin && origin && !isOriginAllowed(origin, allowedOrigins)) headers.delete('origin');
174
- return agentHandle(new Request(sub.href, { method: req.method, headers, body: req.body }));
309
+ const handleAgent = remoteRequest ? remoteAgentHandle : agentHandle;
310
+ return secure(
311
+ await handleAgent(
312
+ new Request(sub.href, {
313
+ method: req.method,
314
+ headers,
315
+ body: req.body,
316
+ signal: req.signal,
317
+ })
318
+ )
319
+ );
175
320
  }
176
321
 
177
322
  // Same-origin proxy to the bunqueue server (mirrors the Vite dev proxy).
@@ -179,7 +324,34 @@ export function createServeHandler(opts: ServeHandlerOptions) {
179
324
  // The proxy talks to bunqueue's admin API, so it needs the same drive-by
180
325
  // CSRF gate as the agent: a cross-site page must not reach it.
181
326
  if (!sameOrigin && !isOriginAllowed(origin, allowedOrigins)) {
182
- return Response.json({ ok: false, error: 'Origin not allowed' }, { status: 403 });
327
+ return secure(
328
+ Response.json({ ok: false, error: 'Origin not allowed' }, { status: 403 })
329
+ );
330
+ }
331
+ // Host/Origin checks stop browser CSRF and DNS rebinding; they do not
332
+ // authenticate curl, LAN peers or callers whose upstream has AUTH_TOKENS
333
+ // disabled. Fail closed for every public/proxied request at this boundary.
334
+ if (isRemoteBridgeRequest(req, remoteBridgePolicy)) {
335
+ if (!apiToken) {
336
+ return secure(
337
+ Response.json(
338
+ {
339
+ ok: false,
340
+ error:
341
+ 'Admin API proxy disabled for remote or proxied access. Set BUNQUEUE_TOKEN to expose it.',
342
+ },
343
+ { status: 403 }
344
+ )
345
+ );
346
+ }
347
+ if (!apiTokenOk(req, apiToken)) {
348
+ return secure(
349
+ Response.json(
350
+ { ok: false, error: 'A valid BUNQUEUE_TOKEN bearer token is required.' },
351
+ { status: 401, headers: { 'WWW-Authenticate': 'Bearer' } }
352
+ )
353
+ );
354
+ }
183
355
  }
184
356
  const target = api + (url.pathname.slice(4) || '/') + url.search;
185
357
  let res: Response;
@@ -189,13 +361,16 @@ export function createServeHandler(opts: ServeHandlerOptions) {
189
361
  headers: req.headers,
190
362
  body: req.body,
191
363
  redirect: 'manual',
364
+ signal: req.signal,
192
365
  });
193
366
  } catch (err) {
194
367
  // bunqueue not running / wrong BUNQUEUE_URL: answer in the shape the
195
368
  // dashboard parses instead of letting Bun render an HTML error page.
196
- return Response.json(
197
- { ok: false, error: `bunqueue unreachable at ${api}: ${(err as Error).message}` },
198
- { status: 502 }
369
+ return secure(
370
+ Response.json(
371
+ { ok: false, error: `bunqueue unreachable at ${api}: ${(err as Error).message}` },
372
+ { status: 502 }
373
+ )
199
374
  );
200
375
  }
201
376
  // Bun's fetch advertises accept-encoding upstream and transparently
@@ -207,7 +382,9 @@ export function createServeHandler(opts: ServeHandlerOptions) {
207
382
  headers.delete('content-encoding');
208
383
  headers.delete('content-length');
209
384
  headers.delete('transfer-encoding');
210
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
385
+ return secure(
386
+ new Response(res.body, { status: res.status, statusText: res.statusText, headers })
387
+ );
211
388
  }
212
389
 
213
390
  // Embedded static assets with SPA history fallback. A missing fingerprinted
@@ -215,11 +392,13 @@ export function createServeHandler(opts: ServeHandlerOptions) {
215
392
  // index.html would feed HTML to a stale chunk import() and mask the miss.
216
393
  // index.html (direct or via fallback) is served from the injected copy.
217
394
  const key = url.pathname === '/' ? '/index.html' : url.pathname;
218
- if (key === '/index.html') return indexResponse();
395
+ if (key === '/index.html') return secure(indexResponse());
219
396
  const asset = assets[key];
220
- if (asset) return new Response(Bun.file(asset));
221
- if (url.pathname.startsWith('/assets/')) return new Response('Not found', { status: 404 });
222
- return indexResponse();
397
+ if (asset) return secure(new Response(Bun.file(asset)));
398
+ if (url.pathname.startsWith('/assets/')) {
399
+ return secure(new Response('Not found', { status: 404 }));
400
+ }
401
+ return secure(indexResponse());
223
402
  };
224
403
  }
225
404
 
@@ -252,24 +431,14 @@ async function main(): Promise<void> {
252
431
  ])
253
432
  );
254
433
 
255
- // DNS-rebinding defense (see agent/server.ts). Always enforced on the loopback
256
- // :6800 listener. On the public dashboard listener it is only enforced for a
257
- // loopback bind — when the user binds a non-loopback interface
258
- // (BIND_ADDR=0.0.0.0) a fixed host allowlist would break legitimate LAN-IP
259
- // access, and the control plane there is gated by remoteControlEnabled()
260
- // instead (AGENT_TOKEN / AGENT_ALLOW_REMOTE_CONTROL).
434
+ // DNS-rebinding defense (see agent/server.ts). Enforced on the loopback :6800
435
+ // listener AND on the dashboard listener for every bind. For a concrete bind
436
+ // address, admit that address automatically. A wildcard bind cannot reveal
437
+ // which LAN/public address clients use, so the operator must list those names
438
+ // or IPs in AGENT_ALLOWED_HOSTS (origins in AGENT_ALLOWED_ORIGINS also count).
261
439
  const loopbackBind = isLoopbackBind(HOST);
262
- const originHosts = allowedOrigins
263
- .map((o) => {
264
- try {
265
- return new URL(o).hostname;
266
- } catch {
267
- return '';
268
- }
269
- })
270
- .filter(Boolean);
271
- const agentHosts = resolveAllowedHosts(process.env, originHosts);
272
- const allowedHosts = loopbackBind ? agentHosts : undefined;
440
+ const remoteBridgePolicy = remoteBridgeRequiresToken(loopbackBind, process.env);
441
+ const allowedHosts = resolveServeAllowedHosts(HOST, allowedOrigins, process.env);
273
442
 
274
443
  // Imported here rather than at module scope: this file's exported policy
275
444
  // helpers are unit-tested, and a top-level import would pull the whole
@@ -278,31 +447,48 @@ async function main(): Promise<void> {
278
447
  // the coverage report and hides that module's real coverage.
279
448
  const { ProcessManager } = await import('../agent/manager');
280
449
  const mgr = new ProcessManager();
281
- const token = process.env.AGENT_TOKEN || undefined;
450
+ const token = process.env.AGENT_TOKEN?.trim() || undefined;
451
+ const apiToken = process.env.BUNQUEUE_TOKEN?.trim() || undefined;
282
452
  // Two handlers, same manager: the loopback :6800 listener keeps the Host gate
283
453
  // unconditionally (it is always reached as localhost/127.0.0.1), while the
284
454
  // bridged one sees the dashboard client's Host and follows `allowedHosts`.
285
- const agentHandle = createFetchHandler(mgr, { allowedOrigins, allowedHosts, token });
286
- const loopbackAgentHandle = createFetchHandler(mgr, {
455
+ const localAgentHandle = createFetchHandler(mgr, {
456
+ allowedOrigins,
457
+ allowedHosts,
458
+ token,
459
+ });
460
+ const remoteAgentHandle = createFetchHandler(mgr, {
287
461
  allowedOrigins,
288
- allowedHosts: agentHosts,
462
+ allowedHosts,
289
463
  token,
464
+ requireTokenForAll: true,
290
465
  });
291
466
  // `error` is a backstop: without it an unexpected throw renders Bun's HTML
292
467
  // error page (with a stack trace) to the client instead of a plain 500.
293
- const onError = () => new Response('Internal error', { status: 500 });
468
+ const onError = () => withSecurityHeaders(new Response('Internal error', { status: 500 }));
294
469
  Bun.serve({
295
470
  port: AGENT_PORT,
296
471
  hostname: '127.0.0.1',
297
- fetch: loopbackAgentHandle,
472
+ fetch: localAgentHandle,
298
473
  error: onError,
299
474
  });
300
475
 
301
- const agentBridge = remoteControlEnabled(loopbackBind, process.env);
476
+ const agentBridge = remoteControlEnabled(remoteBridgePolicy, process.env);
302
477
  if (!agentBridge) {
303
478
  logger.warn(
304
- { bind: HOST },
305
- 'non-loopback bind without AGENT_TOKEN / AGENT_ALLOW_REMOTE_CONTROL=1 — /agent is disabled (403)'
479
+ { bind: HOST, remoteBridgePolicy },
480
+ 'remote or proxied bridge policy without AGENT_TOKEN — /agent is disabled (403)'
481
+ );
482
+ }
483
+ if (remoteBridgePolicy && !apiToken) {
484
+ logger.warn(
485
+ { apiProxy: API },
486
+ 'remote or proxied bridge policy without BUNQUEUE_TOKEN — /api is disabled (403)'
487
+ );
488
+ } else if (remoteBridgePolicy) {
489
+ logger.info(
490
+ { apiProxy: API },
491
+ 'remote or proxied /api requires the configured BUNQUEUE_TOKEN bearer'
306
492
  );
307
493
  }
308
494
 
@@ -324,10 +510,14 @@ async function main(): Promise<void> {
324
510
  api: API,
325
511
  indexHtml,
326
512
  assets: ASSETS,
327
- agentHandle,
513
+ agentHandle: localAgentHandle,
514
+ remoteAgentHandle,
328
515
  allowedOrigins,
329
516
  allowedHosts,
330
517
  agentBridge,
518
+ agentTokenConfigured: Boolean(token),
519
+ apiToken,
520
+ remoteBridgePolicy,
331
521
  trustProxy: process.env.TRUST_PROXY === '1',
332
522
  }),
333
523
  error: onError,
@@ -355,6 +545,8 @@ async function main(): Promise<void> {
355
545
  apiProxy: API,
356
546
  agent: `http://127.0.0.1:${AGENT_PORT}/control`,
357
547
  agentBridge,
548
+ remoteBridgePolicy,
549
+ allowedHosts,
358
550
  },
359
551
  'bunqueue dashboard (standalone) ready'
360
552
  );
@@ -1,3 +0,0 @@
1
- import{r as e}from"./cn-CMFYpumL.js";import{h as t,l as n,r,t as i}from"./react-vendor-BcouBxpZ.js";import{A as a,C as o,S as s,h as c,nt as l,rt as u,w as ee}from"./index-r1ZMLIZ9.js";import{a as te,n as d,o as f,t as p}from"./form-DGinTEEb.js";var m=e(t(),1),h=i(),g=e=>e==null?``:String(e);function ne(e,t){return e==null?t?{ok:!1,msg:`Backoff strategy needs a base delay in Backoff (ms)`}:{ok:!0,backoff:void 0}:{ok:!0,backoff:t?{type:t,delay:e}:e}}function _(e){return[e.tags?.length?`Tags`:``,e.groupId?`Group ID`:``,e.dependsOn?.length?`Depends on`:``,e.uniqueKey?`Unique key`:``].filter(Boolean)}function re(e,t){return e===t?{ok:!0,msg:`Created ${e} job${e===1?``:`s`}`}:{ok:!1,msg:`Created ${e} of ${t} jobs — ${t-e} were not created (duplicate jobId/uniqueKey?)`}}function v(){let{data:e}=a(()=>l.queues(),[],{intervalMs:3e4}),t=n().state?.clone,i=t?.options??{},[f,v]=(0,m.useState)(t?.queue??``),[b,x]=(0,m.useState)(t?.dataText??`{
2
- "hello": "world"
3
- }`),[S,ie]=(0,m.useState)(`1`),[C,ae]=(0,m.useState)(g(i.priority)),[w,T]=(0,m.useState)(``),[E,D]=(0,m.useState)(``),[O,oe]=(0,m.useState)(g(i.maxAttempts)),[k,se]=(0,m.useState)(g(typeof i.backoff==`number`?i.backoff:void 0)),[A,ce]=(0,m.useState)(g(i.timeout)),[j,M]=(0,m.useState)(``),[N,le]=(0,m.useState)(i.removeOnComplete??!1),[P,ue]=(0,m.useState)(i.removeOnFail??!1),[F,I]=(0,m.useState)(!1),[L,R]=(0,m.useState)(!1),[z,B]=(0,m.useState)(``),[V,H]=(0,m.useState)(``),[U,de]=(0,m.useState)(``),[W,fe]=(0,m.useState)(``),[G,pe]=(0,m.useState)(``),[K,q]=(0,m.useState)(!1),[J,Y]=(0,m.useState)(null),[X,Z]=(0,m.useState)(null),Q=e=>{let t=Number(e);return e.trim()!==``&&Number.isFinite(t)?t:void 0},$=(Q(S)??1)>1?_({data:null,tags:V.trim()?[V.trim()]:void 0,groupId:U.trim()||void 0,dependsOn:W.trim()?[W.trim()]:void 0,uniqueKey:G.trim()||void 0}):[],me=async()=>{Y(null),Z(null);let e=f.trim();if(!e){Y({ok:!1,msg:`Choose a queue`});return}let t;try{t=JSON.parse(b)}catch(e){Z(`Invalid JSON: ${e.message}`);return}let n=Q(w);if(E.trim()){let e=new Date(E).getTime();if(!Number.isFinite(e)){Y({ok:!1,msg:`Run at is not a valid date/time`});return}n=Math.max(0,e-Date.now())}let r=ne(Q(k),z);if(!r.ok){Y({ok:!1,msg:r.msg});return}let i=r.backoff,a=V.split(`,`).map(e=>e.trim()).filter(Boolean),o=W.split(`,`).map(e=>e.trim()).filter(Boolean),s={data:t,priority:Q(C),delay:n,maxAttempts:Q(O),backoff:i,timeout:Q(A),jobId:j.trim()||void 0,removeOnComplete:N||void 0,removeOnFail:P||void 0,durable:F||void 0,lifo:L||void 0,tags:a.length?a:void 0,groupId:U.trim()||void 0,dependsOn:o.length?o:void 0,uniqueKey:G.trim()||void 0},c=Q(S)??1;if(!Number.isInteger(c)||c<1){Y({ok:!1,msg:`Count must be an integer of at least 1`});return}if(c>1e4){Y({ok:!1,msg:`Count must be 10000 or fewer`});return}q(!0);try{if(c===1){let t=await l.addJob(e,s);Y({ok:!0,msg:`Created job ${t.id}`}),u.success(`Job created`,`${e} · ${t.id}`)}else{let t=await l.addJobsBulk(e,Array.from({length:c},()=>s)),n=re(new Set(t.ids).size,c);Y(n),n.ok?u.success(n.msg,`in ${e}`):u.error(n.msg,`in ${e}`)}}catch(e){Y({ok:!1,msg:e.message}),u.error(`Add job failed`,e.message)}finally{q(!1)}};return(0,h.jsxs)(`div`,{children:[(0,h.jsx)(c,{title:`Add Job`,description:`Enqueue a job with full options.`,actions:(0,h.jsx)(r,{to:`/jobs/bulk-add`,className:`rounded-lg border border-line px-3 py-1.5 text-sm text-muted hover:bg-surface-2 hover:text-fg`,children:`Bulk import`})}),t&&(0,h.jsx)(`div`,{className:`mb-4 flex items-center gap-2 rounded-lg border border-accent/30 bg-accent/[0.06] px-4 py-2 text-sm text-accent`,children:`Pre-filled from an existing job. Review the data and options, then enqueue a fresh job.`}),(0,h.jsxs)(`form`,{className:`grid grid-cols-1 gap-6 lg:grid-cols-2`,onSubmit:e=>{e.preventDefault(),me()},children:[(0,h.jsxs)(s,{children:[(0,h.jsx)(o,{title:`Job`}),(0,h.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,h.jsxs)(p,{label:`Queue`,children:[(0,h.jsx)(d,{list:`queue-options`,value:f,onChange:e=>v(e.target.value),placeholder:`queue name (existing or new)`}),(0,h.jsx)(`datalist`,{id:`queue-options`,children:(e?.queues??[]).map(e=>(0,h.jsx)(`option`,{value:e.name},e.name))})]}),(0,h.jsxs)(`div`,{children:[(0,h.jsx)(p,{label:`Data (JSON)`,children:(0,h.jsx)(`textarea`,{value:b,onChange:e=>x(e.target.value),onBlur:()=>{try{JSON.parse(b),Z(null)}catch(e){Z(`Invalid JSON: ${e.message}`)}},spellCheck:!1,rows:7,className:`w-full rounded-lg border border-line bg-surface-2 p-3 font-mono text-sm text-fg focus:border-accent/60 focus:outline-none focus:ring-2 focus:ring-accent/30`})}),X&&(0,h.jsx)(`p`,{className:`mt-2 text-xs text-danger`,children:X})]})]})]}),(0,h.jsxs)(s,{children:[(0,h.jsx)(o,{title:`Options`}),(0,h.jsxs)(`div`,{className:`grid grid-cols-2 gap-4 md:grid-cols-3`,children:[(0,h.jsx)(p,{label:`Priority`,children:(0,h.jsx)(d,{type:`number`,value:C,onChange:e=>ae(e.target.value),placeholder:`0`})}),(0,h.jsx)(p,{label:`Delay (ms)`,children:(0,h.jsx)(d,{type:`number`,min:0,value:w,onChange:e=>T(e.target.value),placeholder:`0`,disabled:E.trim()!==``})}),(0,h.jsx)(p,{label:`Run at`,hint:E.trim()?`Overrides Delay — derived from this time (local time).`:`(local time)`,children:(0,h.jsx)(d,{type:`datetime-local`,value:E,onChange:e=>D(e.target.value)})}),(0,h.jsx)(p,{label:`Max attempts`,hint:`blank = server default`,children:(0,h.jsx)(d,{type:`number`,min:1,value:O,onChange:e=>oe(e.target.value),placeholder:`3`})}),(0,h.jsx)(p,{label:`Backoff (ms)`,hint:`blank = server default`,children:(0,h.jsx)(d,{type:`number`,min:0,value:k,onChange:e=>se(e.target.value),placeholder:`1000`})}),(0,h.jsx)(p,{label:`Backoff strategy`,hint:`flat delay unless set`,children:(0,h.jsxs)(te,{value:z,onChange:e=>B(e.target.value),children:[(0,h.jsx)(`option`,{value:``,children:`flat`}),(0,h.jsx)(`option`,{value:`fixed`,children:`fixed`}),(0,h.jsx)(`option`,{value:`exponential`,children:`exponential`})]})}),(0,h.jsx)(p,{label:`Timeout (ms)`,children:(0,h.jsx)(d,{type:`number`,min:0,value:A,onChange:e=>ce(e.target.value),placeholder:`—`})}),(0,h.jsx)(p,{label:`Custom job ID`,children:(0,h.jsx)(d,{value:j,onChange:e=>M(e.target.value),placeholder:`idempotency key`})})]}),(0,h.jsxs)(`div`,{className:`mt-4 flex flex-wrap gap-6`,children:[(0,h.jsx)(y,{label:`removeOnComplete`,checked:N,onChange:le}),(0,h.jsx)(y,{label:`removeOnFail`,checked:P,onChange:ue}),(0,h.jsx)(y,{label:`durable`,checked:F,onChange:I}),(0,h.jsx)(y,{label:`lifo`,checked:L,onChange:R})]}),(0,h.jsxs)(`div`,{className:`mt-4 border-t border-line pt-4`,children:[(0,h.jsx)(`div`,{className:`mb-3 text-[11px] uppercase tracking-wider text-faint`,children:`Advanced`}),(0,h.jsxs)(`div`,{className:`grid grid-cols-2 gap-4 md:grid-cols-3`,children:[(0,h.jsx)(p,{label:`Tags`,hint:`comma-separated`,children:(0,h.jsx)(d,{value:V,onChange:e=>H(e.target.value),placeholder:`email, urgent`})}),(0,h.jsx)(p,{label:`Group ID`,children:(0,h.jsx)(d,{value:U,onChange:e=>de(e.target.value),placeholder:`—`})}),(0,h.jsx)(p,{label:`Unique key`,hint:`dedup key`,children:(0,h.jsx)(d,{value:G,onChange:e=>pe(e.target.value),placeholder:`—`})}),(0,h.jsx)(p,{label:`Depends on`,hint:`parent job ids, comma-separated`,children:(0,h.jsx)(d,{value:W,onChange:e=>fe(e.target.value),placeholder:`job-id-1, job-id-2`})})]})]})]}),(0,h.jsxs)(`div`,{className:`flex flex-wrap items-end gap-3 lg:col-span-2`,children:[(0,h.jsx)(`div`,{className:`w-28`,children:(0,h.jsx)(p,{label:`Count`,hint:`1–10000`,children:(0,h.jsx)(d,{type:`number`,min:1,value:S,onChange:e=>ie(e.target.value)})})}),(0,h.jsx)(ee,{type:`submit`,variant:`accent`,disabled:K,children:K?`Adding…`:`Add job`}),$.length>0&&(0,h.jsxs)(`span`,{className:`pb-2 text-xs text-warning`,children:[$.join(`, `),` `,$.length===1?`is`:`are`,` applied only when Count is 1 — the bulk route ignores `,$.length===1?`it`:`them`,`.`]}),J&&(0,h.jsx)(`span`,{role:`status`,className:J.ok?`text-sm text-success`:`text-sm text-danger`,children:J.msg})]})]})]})}function y({label:e,checked:t,onChange:n}){return(0,h.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,h.jsx)(f,{checked:t,onChange:n,label:e}),(0,h.jsx)(`span`,{className:`font-mono text-xs text-muted`,children:e})]})}export{v as AddJob};
@@ -1 +0,0 @@
1
- import{r as e}from"./cn-CMFYpumL.js";import{h as t,t as n}from"./react-vendor-BcouBxpZ.js";import{$ as r,N as i,Q as a,S as o,T as s,Y as c,at as l,h as u,it as d,u as f,v as p,w as m}from"./index-r1ZMLIZ9.js";import{a as h,n as g,o as _,t as v}from"./form-DGinTEEb.js";var y=e(t(),1),b=n(),x=[`email`,`webhook`,`slack`],S=Object.keys(d),C=[`>=`,`>`,`<=`,`<`],w={error_rate:{unit:`% (0–100)`,placeholder:`e.g. 5`},p99_latency:{unit:`ms`,placeholder:`e.g. 250`},waiting:{unit:`jobs`,placeholder:`e.g. 100`},failed:{unit:`jobs`,placeholder:`e.g. 10`},dlq:{unit:`jobs`,placeholder:`e.g. 1`}},T=`bunqueue OSS has no delivery backend — alerts fire as in-app toast + desktop notification only`;function E(){return typeof Notification>`u`?`unsupported`:Notification.permission}function D(){let{channels:e,rules:t,addChannel:n,removeChannel:h,addRule:g,removeRule:v,toggleRule:x}=l(),S=r(e=>e.breaching),[C,w]=(0,y.useState)(!1),[D,A]=(0,y.useState)(E);return(0,b.jsxs)(`div`,{children:[(0,b.jsx)(u,{title:`Alerts`,description:`Threshold rules evaluated live in your browser.`,live:!0,actions:(0,b.jsxs)(b.Fragment,{children:[D===`granted`?(0,b.jsxs)(`span`,{className:`flex items-center gap-1.5 text-sm text-success`,children:[(0,b.jsx)(i,{className:`size-4`}),` Desktop alerts on`]}):D===`unsupported`?(0,b.jsx)(`span`,{className:`text-sm text-faint`,children:`Notifications unsupported`}):D===`denied`?(0,b.jsx)(`span`,{className:`text-sm text-faint`,children:`Desktop alerts blocked — allow notifications in browser settings`}):(0,b.jsx)(m,{size:`sm`,onClick:async()=>{A(await a())},children:`Enable desktop notifications`}),(0,b.jsx)(m,{variant:`accent`,size:`sm`,onClick:()=>w(e=>!e),children:`+ Create Alert Rule`})]})}),(0,b.jsx)(`div`,{className:`mb-6 rounded-lg border border-amber-500/20 bg-amber-500/5 px-4 py-3 text-sm text-amber-400/90`,children:`Rules are evaluated in this browser while a tab is open (even backgrounded), raising an in-app toast and — if enabled — a desktop notification. bunqueue OSS has no alerting backend, so email/webhook/slack delivery still needs your own monitoring or hosted bunqueue Cloud.`}),(0,b.jsxs)(`section`,{className:`mb-8`,children:[(0,b.jsx)(`h2`,{className:`mb-3 text-lg font-semibold text-fg`,children:`Triggered Alerts`}),S.length===0?(0,b.jsx)(p,{icon:(0,b.jsx)(i,{}),title:`No triggered alerts`,hint:t.some(e=>e.enabled)?`All enabled rules are within their thresholds.`:`No enabled rules yet. Create one below.`}):(0,b.jsx)(`div`,{className:`overflow-x-auto rounded-xl border border-danger/30 bg-red-500/5`,children:(0,b.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,b.jsx)(`thead`,{children:(0,b.jsxs)(`tr`,{className:`border-b border-line text-left text-[11px] uppercase tracking-wider text-faint`,children:[(0,b.jsx)(`th`,{className:`px-5 py-3 font-medium`,children:`Rule`}),(0,b.jsx)(`th`,{className:`px-5 py-3 font-medium`,children:`Condition`}),(0,b.jsx)(`th`,{className:`px-5 py-3 font-medium`,children:`Queue`}),(0,b.jsx)(`th`,{className:`px-5 py-3 text-right font-medium`,children:`Current`}),(0,b.jsx)(`th`,{className:`px-5 py-3 text-right font-medium`,children:`Since`})]})}),(0,b.jsx)(`tbody`,{children:S.map(e=>(0,b.jsxs)(`tr`,{className:`border-b border-line last:border-0`,children:[(0,b.jsx)(`td`,{className:`px-5 py-3 font-medium text-danger`,children:e.ruleName}),(0,b.jsxs)(`td`,{className:`px-5 py-3 text-muted`,children:[e.metricLabel,` `,e.operator,` `,(0,b.jsx)(`span`,{className:`font-semibold text-fg`,children:e.threshold})]}),(0,b.jsx)(`td`,{className:`px-5 py-3 text-muted`,children:e.queue||`All queues`}),(0,b.jsx)(`td`,{className:`px-5 py-3 text-right tnum font-semibold text-danger`,children:Math.round(e.value*100)/100}),(0,b.jsx)(`td`,{className:`px-5 py-3 text-right text-faint`,children:f(e.at)})]},e.ruleId))})]})})]}),(0,b.jsxs)(`section`,{className:`mb-8`,children:[(0,b.jsx)(`h2`,{className:`mb-3 text-lg font-semibold text-fg`,children:`Notification Channels`}),(0,b.jsxs)(o,{children:[e.length===0?(0,b.jsx)(`p`,{className:`mb-4 text-sm text-faint`,children:`No channels yet. Add one below.`}):(0,b.jsx)(`ul`,{className:`mb-4 divide-y divide-line`,children:e.map(e=>(0,b.jsxs)(`li`,{className:`flex items-center justify-between py-2.5`,children:[(0,b.jsxs)(`div`,{children:[(0,b.jsx)(`div`,{className:`text-sm font-medium capitalize text-fg`,children:e.type}),e.target?(0,b.jsx)(`div`,{className:`font-mono text-xs text-faint`,children:e.target}):(0,b.jsx)(`div`,{className:`text-xs italic text-warning`,children:`target not persisted (secret) — remove and re-add to restore it`})]}),(0,b.jsx)(s,{"aria-label":`Remove channel`,onClick:()=>h(e.id),children:(0,b.jsx)(c,{className:`size-3.5`})})]},e.id))}),(0,b.jsx)(O,{onAdd:n})]})]}),(0,b.jsxs)(`section`,{children:[(0,b.jsx)(`h2`,{className:`mb-3 text-lg font-semibold text-fg`,children:`Alert Rules`}),C&&(0,b.jsx)(o,{className:`mb-4`,children:(0,b.jsx)(k,{onAdd:e=>{g(e),w(!1)}})}),t.length===0?(0,b.jsx)(p,{title:`No alert rules`,hint:`Create a rule to be notified when a threshold is crossed.`}):(0,b.jsx)(`div`,{className:`overflow-x-auto rounded-xl border border-line bg-surface`,children:(0,b.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,b.jsx)(`thead`,{children:(0,b.jsxs)(`tr`,{className:`border-b border-line text-left text-[11px] uppercase tracking-wider text-faint`,children:[(0,b.jsx)(`th`,{className:`px-5 py-3 font-medium`,children:`Rule`}),(0,b.jsx)(`th`,{className:`px-5 py-3 font-medium`,children:`Condition`}),(0,b.jsx)(`th`,{className:`px-5 py-3 font-medium`,children:`Queue`}),(0,b.jsx)(`th`,{className:`px-5 py-3 font-medium`,children:`Channel`}),(0,b.jsx)(`th`,{className:`px-5 py-3 font-medium`,children:`Status`}),(0,b.jsx)(`th`,{className:`w-12 px-5 py-3`})]})}),(0,b.jsx)(`tbody`,{children:t.map(e=>(0,b.jsxs)(`tr`,{className:`border-b border-line last:border-0`,children:[(0,b.jsx)(`td`,{className:`px-5 py-3 font-medium text-fg`,children:e.name}),(0,b.jsxs)(`td`,{className:`px-5 py-3 text-muted`,children:[d[e.metric],` `,e.operator,` `,(0,b.jsx)(`span`,{className:`font-semibold text-accent`,children:e.threshold})]}),(0,b.jsx)(`td`,{className:`px-5 py-3 text-muted`,children:e.queue||`All queues`}),(0,b.jsxs)(`td`,{className:`px-5 py-3`,children:[(0,b.jsx)(`span`,{className:`rounded-md bg-surface-2 px-2 py-0.5 text-xs capitalize text-muted`,children:e.channel}),(0,b.jsx)(`span`,{className:`ml-1.5 text-[10px] uppercase tracking-wider text-faint`,title:T,children:`not wired`})]}),(0,b.jsx)(`td`,{className:`px-5 py-3`,children:(0,b.jsx)(_,{checked:e.enabled,onChange:()=>x(e.id),label:`Enabled`})}),(0,b.jsx)(`td`,{className:`px-5 py-3 text-right`,children:(0,b.jsx)(s,{"aria-label":`Delete rule`,onClick:()=>v(e.id),children:(0,b.jsx)(c,{className:`size-3.5`})})})]},e.id))})]})})]})]})}function O({onAdd:e}){let[t,n]=(0,y.useState)(`email`),[r,i]=(0,y.useState)(``);return(0,b.jsxs)(`div`,{className:`flex flex-wrap items-end gap-3`,children:[(0,b.jsx)(`div`,{className:`w-36`,children:(0,b.jsx)(v,{label:`Type`,children:(0,b.jsx)(h,{value:t,onChange:e=>n(e.target.value),children:x.map(e=>(0,b.jsx)(`option`,{value:e,children:e},e))})})}),(0,b.jsx)(`div`,{className:`min-w-56 flex-1`,children:(0,b.jsx)(v,{label:t===`email`?`Email address`:`URL`,children:(0,b.jsx)(g,{value:r,onChange:e=>i(e.target.value),placeholder:t===`email`?`ops@example.com`:`https://hooks…`})})}),(0,b.jsx)(m,{variant:`accent`,disabled:!r,onClick:()=>{e(t,r),i(``)},children:`Add channel`})]})}function k({onAdd:e}){let[t,n]=(0,y.useState)(``),[r,i]=(0,y.useState)(`error_rate`),[a,o]=(0,y.useState)(`>=`),[s,c]=(0,y.useState)(``),[l,u]=(0,y.useState)(``),[f,p]=(0,y.useState)(`email`);return(0,b.jsxs)(`div`,{className:`grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6`,children:[(0,b.jsx)(v,{label:`Name`,children:(0,b.jsx)(g,{value:t,onChange:e=>n(e.target.value),placeholder:`High error rate`})}),(0,b.jsx)(v,{label:`Metric`,children:(0,b.jsx)(h,{value:r,onChange:e=>i(e.target.value),children:S.map(e=>(0,b.jsx)(`option`,{value:e,children:d[e]},e))})}),(0,b.jsx)(v,{label:`Operator`,children:(0,b.jsx)(h,{value:a,onChange:e=>o(e.target.value),children:C.map(e=>(0,b.jsx)(`option`,{value:e,children:e},e))})}),(0,b.jsx)(v,{label:`Threshold`,hint:w[r].unit,children:(0,b.jsx)(g,{type:`number`,value:s,onChange:e=>c(e.target.value),placeholder:w[r].placeholder})}),(0,b.jsx)(v,{label:`Queue (optional)`,hint:r===`p99_latency`?`global only — latency is not per queue`:void 0,children:(0,b.jsx)(g,{value:l,onChange:e=>u(e.target.value),disabled:r===`p99_latency`,placeholder:r===`p99_latency`?`Global`:`All queues`,className:`disabled:cursor-not-allowed disabled:opacity-40`})}),(0,b.jsx)(v,{label:`Channel`,hint:`delivery not wired — in-app only`,children:(0,b.jsx)(h,{value:f,onChange:e=>p(e.target.value),children:x.map(e=>(0,b.jsx)(`option`,{value:e,children:e},e))})}),(0,b.jsx)(`div`,{className:`col-span-full`,children:(0,b.jsx)(m,{variant:`accent`,disabled:!t||!s,onClick:()=>e({name:t,metric:r,operator:a,threshold:Number(s),queue:r===`p99_latency`?``:l,channel:f,enabled:!0}),children:`Save rule`})})]})}export{D as Alerts};
@@ -1 +0,0 @@
1
- import{r as e,t}from"./cn-CMFYpumL.js";import{h as n,t as r}from"./react-vendor-BcouBxpZ.js";import{r as i}from"./connectionStore-DO6xQY7h.js";import{A as a,C as o,O as s,S as c,U as l,W as u,c as d,h as f,m as p,nt as m,r as h,s as g,w as _}from"./index-r1ZMLIZ9.js";import{i as v,n as y,o as b,t as x}from"./form-DGinTEEb.js";import{t as S}from"./AreaChart-2gM0YoZT.js";var C=e(n(),1),w={total:1e6,batch:5e3,producers:64,payload:65536,workers:64,workerBatch:1e3,processMs:6e4,durationS:600},T={queue:`benchmark`,mode:`count`,total:5e3,durationS:30,batch:100,producers:4,payload:128,workers:4,workerBatch:100,processMs:5,durable:!1,removeOnComplete:!0},E={Smoke:{mode:`count`,total:200,batch:50,producers:2,workers:1,workerBatch:50,processMs:0},Standard:{mode:`count`,total:5e3,batch:100,producers:4,workers:4,workerBatch:100,processMs:5},Stress:{mode:`count`,total:5e4,batch:500,producers:16,workers:16,workerBatch:200,processMs:0},Soak:{mode:`duration`,durationS:30,batch:100,producers:4,workers:8,workerBatch:100,processMs:10}},D=(e,t,n)=>Number.isFinite(e)?Math.max(t,Math.min(n,Math.floor(e))):t,O=(e,t)=>e.length?e[Math.min(e.length-1,Math.max(0,Math.ceil(t/100*e.length)-1))]:0,k=(e,t,n,r,i)=>Array.from({length:t},(t,a)=>({data:{i:e+a,blob:n},durable:r,removeOnComplete:i})),A=e=>new Promise(t=>setTimeout(t,e)),j=async(e,t)=>{let n=performance.now()+e;for(;t()&&performance.now()<n;)await A(Math.min(100,Math.max(0,n-performance.now())))},M=e=>e instanceof Error?e.message:String(e),N=e=>e>=1e6?`${(e/1e6).toFixed(2)}M`:e>=1e3?`${(e/1e3).toFixed(1)}K`:`${Math.round(e)}`,P=r();function F({history:e,onClear:t}){if(!e.length)return null;let n=JSON.stringify(e,null,2);return(0,P.jsxs)(c,{padded:!1,className:`mt-6 overflow-hidden`,children:[(0,P.jsxs)(`div`,{className:`flex items-center justify-between border-b border-line px-5 py-3`,children:[(0,P.jsx)(`h3`,{className:`text-base font-semibold text-fg`,children:`Run history`}),(0,P.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,P.jsx)(s,{value:n}),(0,P.jsx)(_,{size:`sm`,variant:`ghost`,onClick:()=>{let e=new Blob([n],{type:`application/json`}),t=URL.createObjectURL(e),r=document.createElement(`a`);r.href=t,r.download=`benchmark-history-${Date.now()}.json`,r.click(),URL.revokeObjectURL(t)},children:`Export JSON`}),(0,P.jsx)(_,{size:`sm`,variant:`ghost`,onClick:t,children:`Clear`})]})]}),(0,P.jsx)(`div`,{className:`overflow-x-auto`,children:(0,P.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,P.jsx)(`thead`,{children:(0,P.jsxs)(`tr`,{className:`border-b border-line text-left text-[11px] uppercase tracking-wider text-faint`,children:[(0,P.jsx)(`th`,{className:`px-5 py-2 font-medium`,children:`Mode`}),(0,P.jsx)(`th`,{className:`px-5 py-2 text-right font-medium`,children:`Prod`}),(0,P.jsx)(`th`,{className:`px-5 py-2 text-right font-medium`,children:`Wkr`}),(0,P.jsx)(`th`,{className:`px-5 py-2 text-right font-medium`,children:`Pushed`}),(0,P.jsx)(`th`,{className:`px-5 py-2 text-right font-medium`,children:`Done`}),(0,P.jsx)(`th`,{className:`px-5 py-2 text-right font-medium`,children:`Push/s`}),(0,P.jsx)(`th`,{className:`px-5 py-2 text-right font-medium`,children:`Done/s`}),(0,P.jsx)(`th`,{className:`px-5 py-2 text-right font-medium`,children:`p95`}),(0,P.jsx)(`th`,{className:`px-5 py-2 text-right font-medium`,children:`Dur`})]})}),(0,P.jsx)(`tbody`,{children:e.map(e=>(0,P.jsxs)(`tr`,{className:`border-b border-line last:border-0 hover:bg-surface-2/40`,children:[(0,P.jsx)(`td`,{className:`px-5 py-2 capitalize text-muted`,children:e.mode}),(0,P.jsx)(`td`,{className:`px-5 py-2 text-right tnum text-muted`,children:e.producers}),(0,P.jsx)(`td`,{className:`px-5 py-2 text-right tnum text-muted`,children:e.workers}),(0,P.jsx)(`td`,{className:`px-5 py-2 text-right tnum text-fg`,children:d(e.pushed)}),(0,P.jsx)(`td`,{className:`px-5 py-2 text-right tnum text-success`,children:d(e.completed)}),(0,P.jsxs)(`td`,{className:`px-5 py-2 text-right tnum text-accent`,children:[N(e.pushPerSec),`/s`]}),(0,P.jsxs)(`td`,{className:`px-5 py-2 text-right tnum text-success`,children:[N(e.donePerSec),`/s`]}),(0,P.jsx)(`td`,{className:`px-5 py-2 text-right tnum text-muted`,children:g(e.p95)}),(0,P.jsx)(`td`,{className:`px-5 py-2 text-right tnum text-muted`,children:g(e.durationMs)})]},e.id))})]})})]})}var I={pushed:0,completed:0,pushFailed:0,ackFailed:0,bytes:0,elapsedMs:0,pushPerSec:0,donePerSec:0,activeWorkers:0,pushSeries:[],doneSeries:[],etaMs:null,error:null},L=()=>({pushed:0,completed:0,pushFailed:0,ackFailed:0,bytes:0,assigned:0,startedAt:0,pushLat:[],activeWorkers:0,error:null,lastAt:0,lastPushed:0,lastDone:0,pushSeries:[],doneSeries:[]}),R=0;function z(){let[e,t]=(0,C.useState)(`idle`),[n,r]=(0,C.useState)(I),[i,a]=(0,C.useState)(null),[o,s]=(0,C.useState)([]),[c,l]=(0,C.useState)(null),u=(0,C.useRef)(!1),d=(0,C.useRef)(!1),f=(0,C.useRef)(!1),p=(0,C.useRef)(null),h=(0,C.useRef)(`idle`);h.current=e;let g=(0,C.useRef)(L());return(0,C.useEffect)(()=>()=>{u.current=!0},[]),(0,C.useEffect)(()=>{if(e!==`running`&&e!==`draining`)return;let t=setInterval(()=>{let e=g.current,t=performance.now(),n=t-e.startedAt,i=(t-e.lastAt)/1e3,a=i>0?Math.max(0,(e.pushed-e.lastPushed)/i):0,o=i>0?Math.max(0,(e.completed-e.lastDone)/i):0;e.lastAt=t,e.lastPushed=e.pushed,e.lastDone=e.completed,e.pushSeries=[...e.pushSeries,a].slice(-60),e.doneSeries=[...e.doneSeries,o].slice(-60);let s=null,c=p.current;if(c&&c.mode===`count`){let t=D(c.total,1,w.total);if(h.current===`draining`){let r=e.doneSeries.slice(-10),i=r.length?r.reduce((e,t)=>e+t,0)/r.length:0;i<=0&&n>0&&(i=e.completed/(n/1e3)),i>0&&(s=Math.max(0,t-e.completed)/i*1e3)}else a>0&&(s=Math.max(0,t-e.pushed)/a*1e3)}else c&&c.mode===`duration`&&(s=Math.max(0,D(c.durationS,1,w.durationS)*1e3-n));r({pushed:e.pushed,completed:e.completed,pushFailed:e.pushFailed,ackFailed:e.ackFailed,bytes:e.bytes,elapsedMs:n,pushPerSec:a,donePerSec:o,activeWorkers:e.activeWorkers,pushSeries:e.pushSeries,doneSeries:e.doneSeries,etaMs:s,error:e.error})},200);return()=>clearInterval(t)},[e]),{phase:e,live:n,summary:i,history:o,runCfg:c,run:async e=>{if(!d.current){d.current=!0;try{a(null);let n=e.queue.trim();if(!n){r({...I,error:`Queue name is required.`}),t(`error`);return}try{await m.overview()}catch(e){r({...I,error:`Server unreachable — start it on the Server page first. (${M(e)})`}),t(`error`);return}let i=D(e.total,1,w.total),o=D(e.durationS,1,w.durationS)*1e3,c=D(e.batch,1,w.batch),d=D(e.producers,e.mode===`duration`?0:1,w.producers),_=D(e.payload,0,w.payload),v=D(e.workers,0,w.workers),y=D(e.workerBatch,1,w.workerBatch),b=D(e.processMs,0,w.processMs);u.current=!1,f.current=!1,p.current=e,l(e),g.current=L(),g.current.startedAt=performance.now(),g.current.lastAt=performance.now(),r({...I}),t(`running`);let x=`x`.repeat(_),S=performance.now()+o,C=async()=>{for(;!u.current;){let t;if(e.mode===`count`){if(g.current.assigned>=i)break;t=Math.min(c,i-g.current.assigned)}else{if(performance.now()>=S)break;t=c}let r=g.current.assigned;g.current.assigned+=t;let a=k(r,t,x,e.durable,e.removeOnComplete),o=performance.now();try{await m.addJobsBulk(n,a),g.current.pushed+=t,g.current.bytes+=t*_}catch(e){g.current.pushFailed+=t,g.current.error??=M(e)}g.current.pushLat.push(performance.now()-o)}},T=async()=>{for(;!u.current&&!(e.mode===`duration`&&performance.now()>=S||e.mode===`count`&&g.current.completed>=i);){g.current.activeWorkers++;let t=[];try{try{t=(await m.pullBatch(n,y)).jobs??[]}catch(e){g.current.error??=M(e)}if(t.length>0){b>0&&await j(b,()=>!u.current);try{await m.ackBatch(t.map(e=>e.id)),g.current.completed+=t.length}catch(e){g.current.ackFailed+=t.length,g.current.error??=M(e)}}}finally{g.current.activeWorkers--}if(t.length===0){if(e.mode===`count`&&f.current)break;await A(50)}}},E=Array.from({length:d},C),N=Array.from({length:v},T),P=Promise.all(E).then(()=>{f.current=!0,!u.current&&e.mode===`count`&&v>0&&h.current===`running`&&t(`draining`)});await Promise.all([P,...N]);let F=g.current,z=performance.now()-F.startedAt,B=z/1e3||1,V=[...F.pushLat].sort((e,t)=>e-t),H={pushed:F.pushed,completed:F.completed,pushFailed:F.pushFailed,ackFailed:F.ackFailed,bytes:F.bytes,durationMs:z,pushPerSec:F.pushed/B,donePerSec:F.completed/B,mbPerSec:F.bytes/B,avg:V.length?V.reduce((e,t)=>e+t,0)/V.length:0,p50:O(V,50),p95:O(V,95),p99:O(V,99),max:V.length?V[V.length-1]:0,error:F.error};a(H),r(e=>({...e,pushed:F.pushed,completed:F.completed,elapsedMs:z,pushPerSec:0,donePerSec:0,activeWorkers:0,etaMs:0})),s(t=>[{...H,id:++R,at:Date.now(),mode:e.mode,producers:d,workers:v},...t].slice(0,12)),t(u.current?`stopped`:`done`)}finally{d.current=!1}}},stop:()=>{u.current=!0,(h.current===`running`||h.current===`draining`)&&t(`stopping`)},reset:()=>{u.current=!0,a(null),r(I),t(`idle`)},clearHistory:()=>s([])}}var B=[`count`,`duration`],V=[`waiting`,`completed`,`failed`,`delayed`],H=[`total`,`durationS`,`batch`,`producers`,`payload`,`workers`,`workerBatch`,`processMs`],ee=e=>({...e,total:String(e.total),durationS:String(e.durationS),batch:String(e.batch),producers:String(e.producers),payload:String(e.payload),workers:String(e.workers),workerBatch:String(e.workerBatch),processMs:String(e.processMs)}),U=e=>({...e,total:Number(e.total),durationS:Number(e.durationS),batch:Number(e.batch),producers:Number(e.producers),payload:Number(e.payload),workers:Number(e.workers),workerBatch:Number(e.workerBatch),processMs:Number(e.processMs)});function W(){let[e,n]=(0,C.useState)(()=>ee(T)),r=z(),{phase:s,live:O,summary:k,history:A}=r,j=s===`running`||s===`draining`||s===`stopping`,M=(e,t)=>n(n=>({...n,[e]:t})),I=e=>t=>M(e,t.target.value),L=e=>n(t=>{let n={...t};for(let[t,r]of Object.entries(e))n[t]=H.includes(t)?String(r):r;return n}),[R,W]=(0,C.useState)(T.queue);(0,C.useEffect)(()=>{let t=setTimeout(()=>W(e.queue.trim()||T.queue),400);return()=>clearTimeout(t)},[e.queue]);let{data:te,error:ne}=a(()=>m.counts(R),[R],{intervalMs:1e3}),q=te?.counts??null,[J,Y]=(0,C.useState)(!1),[X,Z]=(0,C.useState)(null),re=async()=>{let t=e.queue.trim();if(J||!t)return;let n=q&&R===t?` Currently ${d(q.waiting??0)} waiting / ${d(q.completed??0)} completed.`:``;if(window.confirm(`Remove benchmark jobs from "${t}"?${n}`)){Y(!0),Z(null);try{let e=null;for(let n of V)try{await m.clean(t,{state:n,limit:w.total})}catch(t){e=t.message}let n=await m.counts(t).catch(t=>(e=t.message,null));if(n==null)Z({error:e??`server unreachable — could not verify`});else{let t=n.counts??{},r=V.reduce((e,n)=>e+(t[n]??0),0);Z(e||r>0?{error:e??`${d(r)} job(s) still present`}:{remaining:t.active??0})}}finally{Y(!1)}}},Q=r.runCfg??U(e),$=D(Q.total,1,w.total),ie=D(Q.workers,0,w.workers),ae=Q.mode===`count`&&(j||k)?Math.min(100,O.pushed/$*100):0,oe=Q.mode===`count`&&(j||k)?Math.min(100,O.completed/$*100):0,se=Q.mode===`duration`?Math.min(100,O.elapsedMs/(D(Q.durationS,1,w.durationS)*1e3)*100):0,ce=s===`running`?Q.mode===`duration`?`Running…`:`Producing…`:s===`draining`?`Draining…`:s===`stopping`?`Stopping…`:s===`error`?`Error`:k?`Result`:`Ready`,le=(s===`running`||s===`draining`)&&O.etaMs!=null?O.etaMs>0?`ETA ${g(O.etaMs)}`:`finishing…`:s===`stopped`?`stopped early`:s===`done`?`complete`:``;return(0,P.jsxs)(`div`,{children:[(0,P.jsx)(f,{title:`Benchmark`,description:`Drive real load against the server — producers bulk-enqueue jobs while simulated workers pull, process and ack them. Throughput is measured client-side; the queue genuinely fills and drains.`,actions:s===`stopping`?(0,P.jsxs)(_,{variant:`warning`,size:`sm`,disabled:!0,children:[(0,P.jsx)(l,{className:`size-3.5`}),` Stopping…`]}):j?(0,P.jsxs)(_,{variant:`warning`,size:`sm`,onClick:r.stop,children:[(0,P.jsx)(l,{className:`size-3.5`}),` Stop`]}):(0,P.jsxs)(_,{variant:`success`,size:`sm`,disabled:J,onClick:()=>{if(J)return;let t=U(e),n=t.queue.trim()||T.queue,a=q&&R===n?(q.waiting??0)+(q.delayed??0):0,o=t.workers>0&&a>0?` Warning: it already holds ${d(a)} job(s) — the simulated workers will pull and ack those too, and they will be counted as completed.`:``;window.confirm(`Enqueue real jobs into "${n}" on ${i()}?${o}`)&&r.run(t)},children:[(0,P.jsx)(u,{className:`size-3.5`}),` Run benchmark`]})}),O.error&&(0,P.jsx)(`div`,{className:`mb-4 rounded-lg border border-red-500/20 bg-red-500/5 px-4 py-2 text-sm text-danger`,children:O.error}),(0,P.jsxs)(`div`,{className:`mb-4 flex flex-wrap items-center gap-2`,children:[(0,P.jsx)(`span`,{className:`text-xs font-medium uppercase tracking-wider text-faint`,children:`Presets`}),Object.keys(E).map(e=>(0,P.jsx)(`button`,{type:`button`,disabled:j,onClick:()=>L(E[e]),className:`rounded-md border border-line bg-surface-2 px-2.5 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg disabled:opacity-40`,children:e},e))]}),(0,P.jsxs)(`div`,{className:`grid grid-cols-1 gap-6 lg:grid-cols-3`,children:[(0,P.jsxs)(c,{className:`lg:col-span-1`,children:[(0,P.jsx)(o,{title:`Configuration`}),(0,P.jsxs)(`div`,{className:`flex flex-col gap-3`,children:[(0,P.jsx)(x,{label:`Queue`,hint:`Jobs are enqueued here (created on first push).`,children:(0,P.jsx)(y,{value:e.queue,disabled:j,onChange:e=>M(`queue`,e.target.value)})}),(0,P.jsx)(x,{label:`Mode`,hint:e.mode===`count`?`Push a fixed number of jobs.`:`Run producers + workers for a fixed time.`,children:(0,P.jsx)(v,{options:B,value:e.mode,onChange:e=>M(`mode`,e),disabled:j})}),e.mode===`count`?(0,P.jsx)(x,{label:`Total jobs`,hint:`max ${d(w.total)}`,children:(0,P.jsx)(y,{type:`number`,min:1,max:w.total,value:e.total,disabled:j,onChange:I(`total`)})}):(0,P.jsx)(x,{label:`Duration (s)`,hint:`max ${w.durationS}s`,children:(0,P.jsx)(y,{type:`number`,min:1,max:w.durationS,value:e.durationS,disabled:j,onChange:I(`durationS`)})}),(0,P.jsx)(`div`,{className:`mt-1 text-[11px] font-semibold uppercase tracking-wider text-faint`,children:`Producers`}),(0,P.jsxs)(`div`,{className:`grid grid-cols-2 gap-3`,children:[(0,P.jsx)(x,{label:`Producers`,hint:`parallel, max ${w.producers}`,children:(0,P.jsx)(y,{type:`number`,min:e.mode===`duration`?0:1,max:w.producers,value:e.producers,disabled:j,onChange:I(`producers`)})}),(0,P.jsx)(x,{label:`Push batch`,hint:`jobs/req, max ${w.batch}`,children:(0,P.jsx)(y,{type:`number`,min:1,max:w.batch,value:e.batch,disabled:j,onChange:I(`batch`)})}),(0,P.jsx)(x,{label:`Payload`,hint:`bytes/job, max ${d(w.payload)}`,children:(0,P.jsx)(y,{type:`number`,min:0,max:w.payload,value:e.payload,disabled:j,onChange:I(`payload`)})})]}),(0,P.jsx)(`div`,{className:`mt-1 text-[11px] font-semibold uppercase tracking-wider text-faint`,children:`Workers (simulated)`}),(0,P.jsxs)(`div`,{className:`grid grid-cols-2 gap-3`,children:[(0,P.jsx)(x,{label:`Workers`,hint:`0 = produce only, max ${w.workers}`,children:(0,P.jsx)(y,{type:`number`,min:0,max:w.workers,value:e.workers,disabled:j,onChange:I(`workers`)})}),(0,P.jsx)(x,{label:`Pull batch`,hint:`jobs/pull, max ${w.workerBatch}`,children:(0,P.jsx)(y,{type:`number`,min:1,max:w.workerBatch,value:e.workerBatch,disabled:j,onChange:I(`workerBatch`)})}),(0,P.jsx)(x,{label:`Process (ms)`,hint:`simulated work per pull`,children:(0,P.jsx)(y,{type:`number`,min:0,max:w.processMs,value:e.processMs,disabled:j,onChange:I(`processMs`)})})]}),(0,P.jsxs)(`label`,{className:`flex cursor-pointer items-center gap-2`,children:[(0,P.jsx)(b,{checked:e.durable,onChange:e=>M(`durable`,e),disabled:j}),(0,P.jsx)(`span`,{className:`text-sm text-muted`,children:`Durable (fsync each job)`})]}),(0,P.jsxs)(`label`,{className:`flex cursor-pointer items-center gap-2`,children:[(0,P.jsx)(b,{checked:e.removeOnComplete,onChange:e=>M(`removeOnComplete`,e),disabled:j}),(0,P.jsx)(`span`,{className:`text-sm text-muted`,children:`Remove on complete`})]}),(0,P.jsxs)(`div`,{className:`flex items-center gap-3 pt-1`,children:[(0,P.jsx)(_,{variant:`ghost`,size:`sm`,disabled:j||J,onClick:re,children:J?`Cleaning…`:`Clean queue`}),X&&(`error`in X?(0,P.jsxs)(`span`,{className:`text-xs text-danger`,children:[`Clean unverified — `,X.error]}):(0,P.jsx)(`span`,{className:t(`text-xs`,X.remaining>0?`text-warning`:`text-success`),children:X.remaining>0?`Cleaned — ${d(X.remaining)} active job(s) remain (requeued after the stall timeout)`:`Cleaned — 0 jobs remain`}))]})]})]}),(0,P.jsxs)(`div`,{className:`flex flex-col gap-6 lg:col-span-2`,children:[(0,P.jsxs)(c,{children:[(0,P.jsxs)(`div`,{className:`mb-3 flex items-center justify-between`,children:[(0,P.jsx)(`h3`,{className:`text-base font-semibold text-fg`,children:ce}),(0,P.jsx)(`span`,{className:`text-xs text-faint`,children:le})]}),(0,P.jsx)(G,{label:Q.mode===`duration`?`Elapsed`:`Produced`,pct:Q.mode===`duration`?se:ae,tone:`accent`}),ie>0&&Q.mode===`count`&&(0,P.jsx)(G,{label:`Completed`,pct:oe,tone:`emerald`}),(0,P.jsxs)(`div`,{className:`mt-4 grid grid-cols-2 gap-4 md:grid-cols-4`,children:[(0,P.jsx)(p,{label:`Pushed`,value:d(O.pushed),tone:`accent`,compact:!0}),(0,P.jsx)(p,{label:`Completed`,value:d(O.completed),tone:`green`,compact:!0}),(0,P.jsx)(p,{label:`Push/sec`,value:N(O.pushPerSec),compact:!0}),(0,P.jsx)(p,{label:`Done/sec`,value:N(O.donePerSec),tone:`green`,compact:!0}),(0,P.jsx)(p,{label:`Elapsed`,value:g(O.elapsedMs),compact:!0}),(0,P.jsx)(p,{label:`Active workers`,value:`${O.activeWorkers}/${ie}`,tone:`blue`,compact:!0}),(0,P.jsx)(p,{label:`Data`,value:h(k?.bytes??O.bytes),compact:!0}),(0,P.jsx)(p,{label:`Errors`,value:d((k?.pushFailed??O.pushFailed)+(k?.ackFailed??O.ackFailed)),tone:O.pushFailed+O.ackFailed?`red`:`default`,compact:!0})]})]}),(0,P.jsxs)(c,{children:[(0,P.jsx)(o,{title:`Throughput`}),(0,P.jsx)(`p`,{className:`-mt-3 mb-4 text-xs text-faint`,children:`Enqueue (client → server) vs completion (workers) per second`}),(0,P.jsx)(S,{series:[{label:`Push/sec`,color:`#38bdf8`,points:O.pushSeries,area:!0},{label:`Done/sec`,color:`#34d399`,points:O.doneSeries,area:!0}]}),(0,P.jsxs)(`div`,{className:`mt-2 flex items-center gap-4 font-mono text-[11px] text-faint`,children:[(0,P.jsx)(K,{color:`#38bdf8`,label:`Push/sec`,value:O.pushPerSec}),(0,P.jsx)(K,{color:`#34d399`,label:`Done/sec`,value:O.donePerSec})]})]})]})]}),k&&(0,P.jsxs)(c,{className:`mt-6`,children:[(0,P.jsx)(o,{title:`Summary`}),(0,P.jsxs)(`div`,{className:`grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-8`,children:[(0,P.jsx)(p,{label:`Pushed`,value:d(k.pushed),tone:`accent`,compact:!0}),(0,P.jsx)(p,{label:`Completed`,value:d(k.completed),tone:`green`,compact:!0}),(0,P.jsx)(p,{label:`Duration`,value:g(k.durationMs),compact:!0}),(0,P.jsx)(p,{label:`Avg push/s`,value:N(k.pushPerSec),tone:`accent`,compact:!0}),(0,P.jsx)(p,{label:`Avg done/s`,value:N(k.donePerSec),tone:`green`,compact:!0}),(0,P.jsx)(p,{label:`Data rate`,value:`${h(k.mbPerSec)}/s`,compact:!0}),(0,P.jsx)(p,{label:`Push p95`,value:g(k.p95),compact:!0}),(0,P.jsx)(p,{label:`Push p99`,value:g(k.p99),compact:!0})]}),(0,P.jsxs)(`p`,{className:`mt-3 text-xs text-faint`,children:[`Push-batch latency avg `,g(k.avg),` · p50 `,g(k.p50),` · p95`,` `,g(k.p95),` · p99 `,g(k.p99),` · max `,g(k.max),`.`,k.error?` First error: ${k.error}`:``]})]}),(0,P.jsxs)(c,{className:`mt-6`,children:[(0,P.jsx)(o,{title:`Server queue: ${R}`,action:ne?(0,P.jsx)(`span`,{className:`text-xs font-medium text-warning`,children:`stale — server unreachable`}):void 0}),(0,P.jsx)(`p`,{className:`-mt-3 mb-4 text-xs text-faint`,children:`Live counts from the server (poll 1s).`}),q?(0,P.jsxs)(`div`,{className:`grid grid-cols-2 gap-4 md:grid-cols-5`,children:[(0,P.jsx)(p,{label:`Waiting`,value:d(q.waiting??0),tone:`amber`,compact:!0}),(0,P.jsx)(p,{label:`Active`,value:d(q.active??0),tone:`blue`,compact:!0}),(0,P.jsx)(p,{label:`Completed`,value:d(q.completed??0),tone:`green`,compact:!0}),(0,P.jsx)(p,{label:`Failed`,value:d(q.failed??0),tone:q.failed?`red`:`default`,compact:!0}),(0,P.jsx)(p,{label:`Delayed`,value:d(q.delayed??0),compact:!0})]}):(0,P.jsx)(`p`,{className:`text-sm text-faint`,children:`No counts yet — waiting for the server to answer for this queue.`})]}),(0,P.jsx)(F,{history:A,onClear:r.clearHistory})]})}function G({label:e,pct:n,tone:r}){return(0,P.jsxs)(`div`,{className:`mb-2`,children:[(0,P.jsxs)(`div`,{className:`mb-1 flex items-center justify-between text-[11px] text-faint`,children:[(0,P.jsx)(`span`,{children:e}),(0,P.jsxs)(`span`,{className:`tnum`,children:[n.toFixed(0),`%`]})]}),(0,P.jsx)(`div`,{className:`h-1.5 overflow-hidden rounded-full bg-surface-2`,children:(0,P.jsx)(`div`,{className:t(`h-full rounded-full transition-all`,r===`accent`?`bg-accent`:`bg-emerald-500`),style:{width:`${n}%`}})})]})}function K({color:e,label:t,value:n}){return(0,P.jsxs)(`span`,{className:`flex items-center gap-1.5`,children:[(0,P.jsx)(`span`,{className:`size-2 rounded-full`,style:{background:e}}),t,` `,(0,P.jsxs)(`span`,{className:`text-fg`,children:[N(n),`/s`]})]})}export{W as Benchmark};
@@ -1,5 +0,0 @@
1
- import{r as e}from"./cn-CMFYpumL.js";import{h as t,t as n}from"./react-vendor-BcouBxpZ.js";import{A as r,C as i,S as a,h as o,nt as s,rt as c,w as l}from"./index-r1ZMLIZ9.js";import{i as u,n as d,t as f}from"./form-DGinTEEb.js";var p=e(t(),1),m=n(),h=1e4,g=`[
2
- { "data": { "to": "a@example.com", "template": "welcome" }, "priority": 1 },
3
- { "data": { "to": "b@example.com", "template": "welcome" } }
4
- ]`,_=e=>{let t=Number(e);return e.trim()!==``&&Number.isFinite(t)?t:void 0},v=e=>{let t=typeof e==`string`&&e.trim()!==``?Number(e.trim()):e;return typeof t==`number`&&Number.isFinite(t)?t:void 0},y=e=>typeof e==`boolean`?e:void 0,b=e=>typeof e==`string`?e===``?void 0:e:typeof e==`number`&&Number.isFinite(e)?String(e):void 0;function x(e,t,n){if(n===`spec`&&e&&typeof e==`object`&&!Array.isArray(e)&&`data`in e){let n=e;return{data:n.data,priority:v(n.priority)??t.priority,delay:v(n.delay),maxAttempts:v(n.maxAttempts)??t.maxAttempts,backoff:v(n.backoff)??t.backoff,timeout:v(n.timeout)??t.timeout,jobId:b(n.jobId),removeOnComplete:y(n.removeOnComplete),removeOnFail:y(n.removeOnFail),durable:y(n.durable),lifo:y(n.lifo),ttl:v(n.ttl),uniqueKey:b(n.uniqueKey)}}return{data:e,...t}}var S=new Set([`data`,`priority`,`delay`,`maxAttempts`,`backoff`,`timeout`,`jobId`,`removeOnComplete`,`removeOnFail`,`durable`,`lifo`,`ttl`,`uniqueKey`]);function C(e){return e.some(e=>typeof e==`object`&&!!e&&!Array.isArray(e)&&`data`in e&&Object.keys(e).some(e=>!S.has(e)))}var w={priority:v,delay:v,maxAttempts:v,backoff:v,timeout:v,ttl:v,jobId:b,uniqueKey:b,removeOnComplete:y,removeOnFail:y,durable:y,lifo:y};function T(e){return e.some(e=>{if(typeof e!=`object`||!e||Array.isArray(e)||!(`data`in e))return!1;let t=e;return Object.keys(t).some(e=>{let n=w[e];return n!=null&&t[e]!==void 0&&n(t[e])===void 0})})}function E(e,t,n){return e===t?{ok:!0,msg:`Created ${e} job${e===1?``:`s`} in ${n}`}:{ok:!1,msg:`Created ${e} of ${t} jobs in ${n} — ${t-e} were not created (duplicate jobId/uniqueKey?)`}}function D(e){let t=e.trim();if(!t)return{items:[],error:null};try{let e=JSON.parse(t);return Array.isArray(e)?{items:e,error:null}:{items:[e],error:null}}catch{}let n=[],r=e.split(`
5
- `);for(let e=0;e<r.length;e++){let t=r[e].trim();if(t)try{n.push(JSON.parse(t))}catch(t){return{items:[],error:`Line ${e+1}: ${t.message}`}}}return{items:n,error:null}}function O(){let{data:e}=r(()=>s.queues(),[],{intervalMs:3e4}),[t,n]=(0,p.useState)(``),[v,y]=(0,p.useState)(``),[b,S]=(0,p.useState)(`spec`),[w,O]=(0,p.useState)(``),[k,A]=(0,p.useState)(``),[j,M]=(0,p.useState)(``),[N,P]=(0,p.useState)(``),[F,I]=(0,p.useState)(!1),[L,R]=(0,p.useState)(null),{items:z,error:B}=(0,p.useMemo)(()=>D(v),[v]),V=b===`spec`&&!B&&C(z),H=b===`spec`&&!B&&T(z);return(0,m.jsxs)(`div`,{children:[(0,m.jsx)(o,{title:`Bulk Add Jobs`,description:`Import many distinct jobs at once from a JSON array, NDJSON, or a file.`}),(0,m.jsxs)(`div`,{className:`grid grid-cols-1 gap-6 lg:grid-cols-3`,children:[(0,m.jsxs)(a,{className:`lg:col-span-2`,children:[(0,m.jsx)(i,{title:`Jobs`,action:(0,m.jsxs)(`label`,{className:`cursor-pointer rounded-lg border border-line px-3 py-1.5 text-xs text-muted hover:bg-surface-2 hover:text-fg`,children:[`Upload file`,(0,m.jsx)(`input`,{type:`file`,accept:`.json,.ndjson,.txt,application/json`,onChange:e=>{let t=e.target.files?.[0];if(!t)return;if(v.trim()!==``&&!window.confirm(`Replace the current input with "${t.name}"?`)){e.target.value=``;return}let n=new FileReader;n.onload=()=>y(String(n.result??``)),n.onerror=()=>R({ok:!1,msg:`Could not read file`}),n.readAsText(t),e.target.value=``},className:`hidden`})]})}),(0,m.jsx)(`textarea`,{value:v,onChange:e=>y(e.target.value),spellCheck:!1,rows:16,placeholder:g,"aria-label":`Jobs JSON`,className:`w-full rounded-lg border border-line bg-surface-2 p-3 font-mono text-xs text-fg placeholder:text-faint focus:border-accent/60 focus:outline-none focus:ring-2 focus:ring-accent/30`}),(0,m.jsxs)(`div`,{className:`mt-3 flex flex-wrap items-center gap-3`,children:[(0,m.jsx)(`span`,{className:`text-xs font-medium text-faint`,children:`Interpret each item as`}),(0,m.jsx)(u,{options:[`spec`,`raw`],value:b,onChange:S}),(0,m.jsx)(`span`,{className:`text-xs text-faint`,children:b===`spec`?`job spec: an object with a "data" key sets options from its sibling fields.`:`raw data: the whole item becomes the job payload, untouched.`})]}),(0,m.jsx)(`p`,{className:`mt-2 text-xs text-faint`,children:`Accepts a JSON array, a single object, or newline-delimited JSON.`}),(0,m.jsx)(`div`,{className:`mt-2 text-sm`,children:B?(0,m.jsx)(`span`,{className:`text-danger`,children:B}):z.length>0?(0,m.jsxs)(`span`,{className:`text-success`,children:[z.length,` job(s) parsed ✓`]}):(0,m.jsx)(`span`,{className:`text-faint`,children:`Nothing parsed yet.`})}),V&&(0,m.jsx)(`p`,{className:`mt-2 text-xs text-warning`,children:`Some items carry keys beyond the known job options — in “spec” mode those keys are dropped. Switch to “raw” to keep every field as the job's data.`}),H&&(0,m.jsx)(`p`,{className:`mt-2 text-xs text-warning`,children:`Some items carry a job option whose value type can't be sent (e.g. a boolean written as text) — those options are dropped. Fix the value types or switch to “raw”.`})]}),(0,m.jsxs)(a,{children:[(0,m.jsx)(i,{title:`Target & defaults`}),(0,m.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,m.jsxs)(f,{label:`Queue`,children:[(0,m.jsx)(d,{list:`bulk-queue-options`,value:t,onChange:e=>n(e.target.value),placeholder:`queue name (existing or new)`}),(0,m.jsx)(`datalist`,{id:`bulk-queue-options`,children:(e?.queues??[]).map(e=>(0,m.jsx)(`option`,{value:e.name},e.name))})]}),(0,m.jsx)(`p`,{className:`-mt-2 text-xs text-faint`,children:`Defaults below fill any field an item omits.`}),(0,m.jsxs)(`div`,{className:`grid grid-cols-2 gap-3`,children:[(0,m.jsx)(f,{label:`Priority`,children:(0,m.jsx)(d,{type:`number`,value:w,onChange:e=>O(e.target.value),placeholder:`0`})}),(0,m.jsx)(f,{label:`Max attempts`,children:(0,m.jsx)(d,{type:`number`,min:0,value:k,onChange:e=>A(e.target.value),placeholder:`3`})}),(0,m.jsx)(f,{label:`Backoff (ms)`,children:(0,m.jsx)(d,{type:`number`,min:0,value:j,onChange:e=>M(e.target.value),placeholder:`1000`})}),(0,m.jsx)(f,{label:`Timeout (ms)`,children:(0,m.jsx)(d,{type:`number`,min:0,value:N,onChange:e=>P(e.target.value),placeholder:`—`})})]}),(0,m.jsx)(`div`,{className:`flex items-center gap-3`,children:(0,m.jsx)(l,{variant:`accent`,disabled:F||z.length===0||B!=null,onClick:async()=>{R(null);let e=t.trim();if(!e){R({ok:!1,msg:`Choose a queue`});return}if(B){R({ok:!1,msg:B});return}if(z.length===0){R({ok:!1,msg:`Nothing to enqueue — paste a JSON array or NDJSON.`});return}if(z.length>h){R({ok:!1,msg:`Too many jobs (${z.length}). Limit is ${h}.`});return}let n=e=>e!=null&&e<0?0:e,r={priority:_(w),maxAttempts:n(_(k)),backoff:n(_(j)),timeout:n(_(N))},i=z.map(e=>x(e,r,b));I(!0);try{let t=await s.addJobsBulk(e,i),n=E(new Set(t.ids).size,i.length,e);R(n),n.ok?c.success(n.msg):c.error(`Bulk import incomplete`,n.msg)}catch(e){let t=e.message;R({ok:!1,msg:t}),c.error(`Bulk import failed`,t)}finally{I(!1)}},children:F?`Importing…`:`Import ${z.length||``}`})}),L&&(0,m.jsx)(`span`,{role:`status`,className:L.ok?`text-sm text-success`:`text-sm text-danger`,children:L.msg})]})]})]})]})}export{O as BulkAddJobs};