bunqueue-dashboard 0.0.32 → 0.0.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -15
- package/agent/db.ts +537 -64
- package/agent/dbQueryWorker.ts +34 -11
- package/agent/manager.ts +186 -45
- package/agent/server.ts +124 -35
- package/dist/.vite/manifest.json +212 -157
- package/dist/assets/AddJob-ytpBLTCJ.js +3 -0
- package/dist/assets/Alerts-C39RrGbQ.js +1 -0
- package/dist/assets/{AreaChart-2gM0YoZT.js → AreaChart-REK4ZsTC.js} +1 -1
- package/dist/assets/Benchmark-CM60tHbf.js +1 -0
- package/dist/assets/BulkAddJobs-BjL-xcQy.js +5 -0
- package/dist/assets/CopilotPanel-BGK1rw-V.js +44 -0
- package/dist/assets/Cron-s1e-jgOo.js +1 -0
- package/dist/assets/CronManager-BostU7BX.js +1 -0
- package/dist/assets/Database-ZluStWMz.js +3 -0
- package/dist/assets/Diagnostics-C0N5z5j-.js +1 -0
- package/dist/assets/Dlq-CtcLzs-b.js +1 -0
- package/dist/assets/DlqControl-B_Z8KKn5.js +1 -0
- package/dist/assets/DlqPro-B68en6AG.js +1 -0
- package/dist/assets/Flows-CD_bkCBM.js +1 -0
- package/dist/assets/JobInspector-DwRtyMz0.js +2 -0
- package/dist/assets/Jobs-BUtcBKKk.js +1 -0
- package/dist/assets/JobsPro-BK_xd4ji.js +1 -0
- package/dist/assets/Logs-CmVODYCI.js +1 -0
- package/dist/assets/LogsPro-CJ-Il3LP.js +1 -0
- package/dist/assets/McpServer-dMjyJKgT.js +22 -0
- package/dist/assets/Metrics-Dsbeplz-.js +1 -0
- package/dist/assets/MetricsPro-BqxurxrJ.js +1 -0
- package/dist/assets/Overview-DH9C2dsf.js +1 -0
- package/dist/assets/{Pagination-B9JDi5MI.js → Pagination-DpxiBnt8.js} +1 -1
- package/dist/assets/QueueActions-D_QGBaVm.js +1 -0
- package/dist/assets/QueueControl-V0DJ6qk6.js +1 -0
- package/dist/assets/QueueDetail-BgKAMJqf.js +1 -0
- package/dist/assets/QueueDetailPro-BkHYzGiq.js +1 -0
- package/dist/assets/Queues-2_v7DcNi.js +1 -0
- package/dist/assets/QueuesOverview-BySSkmB4.js +1 -0
- package/dist/assets/S3Backup-CPADzKQE.js +1 -0
- package/dist/assets/S3BackupPro-CSrQYIDL.js +2 -0
- package/dist/assets/ServerControl-C0tI1VDx.js +3 -0
- package/dist/assets/Settings-CSzoM5up.js +1 -0
- package/dist/assets/Usage-B4B7tmrc.js +1 -0
- package/dist/assets/UsagePro-CCBpgSDy.js +1 -0
- package/dist/assets/Webhooks-aJz-GisQ.js +1 -0
- package/dist/assets/Workers-Bg8xKjkg.js +1 -0
- package/dist/assets/WorkersPro-BOOCrXf8.js +1 -0
- package/dist/assets/connectionStore-Cd9-3Fw0.js +1 -0
- package/dist/assets/flowMutationSafety-DVD1IMr-.js +1 -0
- package/dist/assets/form-DsUq0w8s.js +1 -0
- package/dist/assets/index-BWoUj86M.css +1 -0
- package/dist/assets/index-Bn2R06qm.js +8 -0
- package/dist/assets/{install-BIVfix28.js → install-CUS8Kijb.js} +2 -2
- package/dist/assets/jobActions-LQD4KfCc.js +1 -0
- package/dist/assets/promisePool-BVfQ9n9Q.js +1 -0
- package/dist/assets/{react-vendor-BcouBxpZ.js → react-vendor-Bjui4wS4.js} +1 -1
- package/dist/assets/upstreamPaths-nG8N1XAz.js +1 -0
- package/dist/assets/useServerActionGuard-CItj3hvh.js +1 -0
- package/dist/assets/useThroughputSeries-tb26_lkb.js +1 -0
- package/dist/index.html +5 -4
- package/package.json +11 -3
- package/scripts/embedded.gen.ts +132 -126
- package/scripts/serve.ts +259 -67
- package/dist/assets/AddJob-Dvpp--yq.js +0 -3
- package/dist/assets/Alerts-CR9jgCCs.js +0 -1
- package/dist/assets/Benchmark-CkL-Ee-M.js +0 -1
- package/dist/assets/BulkAddJobs-wI6cRZnX.js +0 -5
- package/dist/assets/CopilotPanel-DPZV9opm.js +0 -44
- package/dist/assets/Cron-gWVkYWad.js +0 -1
- package/dist/assets/CronManager-BvfW219u.js +0 -1
- package/dist/assets/Database-D2T_ZIGA.js +0 -3
- package/dist/assets/Diagnostics-C2goqq5h.js +0 -1
- package/dist/assets/Dlq-Q4TmmZrK.js +0 -1
- package/dist/assets/DlqControl-C5BfE7Gq.js +0 -1
- package/dist/assets/DlqPro-ClPNmw-X.js +0 -1
- package/dist/assets/Flows-DbhMbvSG.js +0 -1
- package/dist/assets/JobInspector-CO_zKjSX.js +0 -2
- package/dist/assets/Jobs-D2rDHPM_.js +0 -1
- package/dist/assets/JobsPro-Drdgdj3s.js +0 -1
- package/dist/assets/Logs-DjwGuRLi.js +0 -1
- package/dist/assets/LogsPro-7uTBjPWX.js +0 -1
- package/dist/assets/McpServer-DAklQDB5.js +0 -22
- package/dist/assets/Metrics-Dup9Zinh.js +0 -1
- package/dist/assets/MetricsPro-PP3TymeP.js +0 -1
- package/dist/assets/Overview-C7RCDE6r.js +0 -1
- package/dist/assets/QueueActions-DJTw0h2g.js +0 -1
- package/dist/assets/QueueControl-CxKjUjF-.js +0 -1
- package/dist/assets/QueueDetail-bf2n-r2h.js +0 -1
- package/dist/assets/QueueDetailPro-C-XLWFBa.js +0 -1
- package/dist/assets/Queues-CMQW89J9.js +0 -1
- package/dist/assets/QueuesOverview-x7-akDzf.js +0 -1
- package/dist/assets/S3Backup-1jAySC9R.js +0 -1
- package/dist/assets/S3BackupPro-BG_0OHW7.js +0 -1
- package/dist/assets/ServerControl-DohTB--h.js +0 -3
- package/dist/assets/Settings-CnS5PjNe.js +0 -1
- package/dist/assets/Usage-C3H0hMSA.js +0 -1
- package/dist/assets/UsagePro-CdnQkP9o.js +0 -1
- package/dist/assets/Webhooks-CAwVmIEZ.js +0 -1
- package/dist/assets/Workers-lvujKZwF.js +0 -1
- package/dist/assets/WorkersPro-CIJKl7U1.js +0 -1
- package/dist/assets/connectionStore-DO6xQY7h.js +0 -1
- package/dist/assets/form-DGinTEEb.js +0 -1
- package/dist/assets/index-DcVOpnSj.css +0 -1
- package/dist/assets/index-r1ZMLIZ9.js +0 -8
- package/dist/assets/jobActions-CuOdMglt.js +0 -1
- package/dist/assets/promisePool-Daxkc1OB.js +0 -1
- 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
|
-
* -
|
|
18
|
-
*
|
|
19
|
-
* - Non-loopback bind (BIND_ADDR=0.0.0.0):
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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 ·
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
47
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
114
|
-
//
|
|
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
|
|
156
|
-
// this changes reachability,
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
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/'))
|
|
222
|
-
|
|
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).
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
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
|
|
263
|
-
|
|
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
|
|
286
|
-
|
|
455
|
+
const localAgentHandle = createFetchHandler(mgr, {
|
|
456
|
+
allowedOrigins,
|
|
457
|
+
allowedHosts,
|
|
458
|
+
token,
|
|
459
|
+
});
|
|
460
|
+
const remoteAgentHandle = createFetchHandler(mgr, {
|
|
287
461
|
allowedOrigins,
|
|
288
|
-
allowedHosts
|
|
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:
|
|
472
|
+
fetch: localAgentHandle,
|
|
298
473
|
error: onError,
|
|
299
474
|
});
|
|
300
475
|
|
|
301
|
-
const agentBridge = remoteControlEnabled(
|
|
476
|
+
const agentBridge = remoteControlEnabled(remoteBridgePolicy, process.env);
|
|
302
477
|
if (!agentBridge) {
|
|
303
478
|
logger.warn(
|
|
304
|
-
{ bind: HOST },
|
|
305
|
-
'
|
|
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};
|