bullpane 0.6.0 → 0.6.1

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.
@@ -16,7 +16,10 @@
16
16
  ARGV[9] byteBudget: stop the call once this many payload bytes were copied
17
17
  into Lua, even if `batch` hashes were not reached. The cursor comes
18
18
  back as usual and the UI keeps streaming.
19
- ARGV[10..] hash fields to HMGET (must include name, data, failedReason)
19
+ ARGV[10] group id (BullMQ Pro), or "" for no group filter
20
+ ARGV[11] the hash fields that may hold the group id, comma separated
21
+ (GROUP_ID_FIELDS in keys.ts); `opts.group.id` is the fallback
22
+ ARGV[12..] hash fields to HMGET (must include name, data, failedReason)
20
23
 
21
24
  Returns { matches, nextCursor, scanned, total, skipped }
22
25
  matches rows shaped exactly like getJobs.lua rows (…, dataTruncated, dataBytes)
@@ -30,6 +33,11 @@
30
33
  call independent of both queue size and payload size: at most `batch` hashes
31
34
  and at most `byteBudget` payload bytes. Before them a search over 1000 × 1 MB
32
35
  jobs kept Redis busy for 13 s; with them a call is a few milliseconds.
36
+
37
+ With a group id, a job outside the group costs one HMGET of its group fields
38
+ and opts (a few hundred bytes, counted in the byte budget) and its payload is
39
+ never read. Pro keeps a group's delayed, failed and completed jobs in the
40
+ queue-wide state keys, so this is the only way to list them per group.
33
41
  ]]
34
42
  local rcall = redis.call
35
43
  local key = KEYS[1]
@@ -42,9 +50,14 @@ local qprefix = ARGV[6]
42
50
  local previewBytes = tonumber(ARGV[7])
43
51
  local maxFieldBytes = tonumber(ARGV[8])
44
52
  local byteBudget = tonumber(ARGV[9])
53
+ local group = ARGV[10]
54
+
55
+ local groupFields = {}
56
+ for f in string.gmatch(ARGV[11], "[^,]+") do groupFields[#groupFields + 1] = f end
57
+ groupFields[#groupFields + 1] = "opts"
45
58
 
46
59
  local fields = {}
47
- for i = 10, #ARGV do fields[#fields + 1] = ARGV[i] end
60
+ for i = 12, #ARGV do fields[#fields + 1] = ARGV[i] end
48
61
  local SKIP = "\0skip"
49
62
 
50
63
  local dataIdx, retIdx, nameIdx, reasonIdx = nil, nil, nil, nil
@@ -83,9 +96,25 @@ local function has(hay, needle)
83
96
  return hay and string.find(string.lower(hay), needle, 1, true) ~= nil
84
97
  end
85
98
 
99
+ -- Exact group match: a group id field on the hash, else opts.group.id. opts is
100
+ -- only decoded when it contains the id as plain text, so most misses cost a find.
101
+ local function inGroup(hkey)
102
+ local vals = rcall("HMGET", hkey, unpack(groupFields))
103
+ local opts = vals[#vals]
104
+ if opts then bytes = bytes + #opts end
105
+ for i = 1, #vals - 1 do
106
+ if vals[i] then return vals[i] == group end
107
+ end
108
+ if not opts or not string.find(opts, group, 1, true) then return false end
109
+ local ok, decoded = pcall(cjson.decode, opts)
110
+ if not ok or type(decoded) ~= "table" or type(decoded.group) ~= "table" then return false end
111
+ local id = decoded.group.id
112
+ return id ~= nil and tostring(id) == group
113
+ end
114
+
86
115
  for _, id in ipairs(ids) do
87
116
  scanned = scanned + 1
88
- if string.sub(id, 1, 2) ~= "0:" then
117
+ if string.sub(id, 1, 2) ~= "0:" and (group == "" or inGroup(qprefix .. id)) then
89
118
  local hkey = qprefix .. id
90
119
  local dataBytes = dataIdx and rcall("HSTRLEN", hkey, "data") or 0
91
120
  local retBytes = retIdx and rcall("HSTRLEN", hkey, "returnvalue") or 0
@@ -129,12 +158,13 @@ for _, id in ipairs(ids) do
129
158
  break
130
159
  end
131
160
  end
132
- if bytes >= byteBudget then
133
- stoppedEarly = true
134
- break
135
- end
136
161
  end
137
162
  end
163
+ -- Also after a group miss: those read opts, which counts against the budget.
164
+ if bytes >= byteBudget then
165
+ stoppedEarly = true
166
+ break
167
+ end
138
168
  end
139
169
 
140
170
  local nextCursor = cursor + scanned
package/dist/server.mjs CHANGED
@@ -254,10 +254,16 @@ var listJobsQuerySchema = z.object({
254
254
  });
255
255
  var searchJobsQuerySchema = z.object({
256
256
  state: jobStateSchema.default("failed"),
257
- q: z.string().min(1).max(500),
257
+ q: z.string().max(500).default(""),
258
+ /**
259
+ * BullMQ Pro: only jobs of this group (exact id). A delayed, failed or completed
260
+ * job sits in the queue-wide state key, not under its group, so this is the only
261
+ * way to list a group's jobs outside `waiting`. With it, `q` may be empty.
262
+ */
263
+ groupId: z.string().min(1).max(200).optional(),
258
264
  cursor: z.string().optional(),
259
265
  limit: z.coerce.number().int().min(1).max(200).default(50)
260
- });
266
+ }).refine((v) => v.q.length > 0 || v.groupId !== void 0, { message: "q or groupId is required", path: ["q"] });
261
267
  var hideQueueSchema = z.object({
262
268
  queueName: z.string().min(1).max(255)
263
269
  });
@@ -1344,7 +1350,7 @@ var PgInspector = class {
1344
1350
  */
1345
1351
  async searchJobs(queueName, state, query, opts) {
1346
1352
  const s = STATE_SQL[state];
1347
- if (!s) return { jobs: [], nextCursor: null, scanned: 0, total: 0, skippedLargePayloads: 0 };
1353
+ if (!s || opts.groupId !== void 0) return { jobs: [], nextCursor: null, scanned: 0, total: 0, skippedLargePayloads: 0 };
1348
1354
  const cursor = Math.max(0, Math.trunc(Number(opts.cursor ?? 0)) || 0);
1349
1355
  const limit = Math.max(1, opts.limit);
1350
1356
  const ob = orderBy(state, "desc");
@@ -2300,6 +2306,7 @@ var DEFAULTS2 = {
2300
2306
  discoveryTtlMs: 3e4,
2301
2307
  previewBytes: 2048,
2302
2308
  maxScanPerCall: 1e3,
2309
+ groupScanPerCall: 1e4,
2303
2310
  connectTimeoutMs: 5e3,
2304
2311
  maxScanIterations: 2e3,
2305
2312
  discoveryScanBudgetMs: 1500,
@@ -2775,22 +2782,28 @@ var RedisInspector = class {
2775
2782
  * are not read at all), plain substring match done in Lua. The cursor is the
2776
2783
  * index of the next job (newest = 0). Measured: 1000 × 1 MB jobs used to hold
2777
2784
  * Redis for 13 s per call; both bounds keep a call in the milliseconds.
2785
+ *
2786
+ * `groupId` filters before any payload is read (one HMGET of the group fields
2787
+ * and opts per job), so a group scan covers groupScanPerCall jobs per call.
2778
2788
  */
2779
2789
  async searchJobs(queueName, state, query, opts) {
2780
2790
  const c = await this.ensureConnected();
2781
2791
  const cursor = Math.max(0, toInt(opts.cursor ?? "0", 0));
2792
+ const group = opts.groupId ?? "";
2782
2793
  const reply = asArray(
2783
2794
  await callScript(c, "getJobsSearch", [
2784
2795
  stateKey(this.config.prefix, queueName, state),
2785
2796
  STATE_KEY[state].type,
2786
2797
  cursor,
2787
- this.opts.maxScanPerCall,
2798
+ group ? this.opts.groupScanPerCall : this.opts.maxScanPerCall,
2788
2799
  query.toLowerCase(),
2789
2800
  Math.max(1, opts.limit),
2790
2801
  queueKeyPrefix(this.config.prefix, queueName),
2791
2802
  this.opts.previewBytes,
2792
2803
  this.opts.searchFieldCapBytes,
2793
2804
  this.opts.searchByteBudget,
2805
+ group,
2806
+ GROUP_ID_FIELDS.join(","),
2794
2807
  ...JOB_SUMMARY_FIELDS
2795
2808
  ])
2796
2809
  );
@@ -6470,7 +6483,7 @@ async function jobRoutes(app) {
6470
6483
  const query = searchJobsQuerySchema.parse(request.query);
6471
6484
  const inspector = await app.ctx.connections.getInspector(request.params.id);
6472
6485
  return withRedis(
6473
- () => inspector.searchJobs(request.params.queue, query.state, query.q, { cursor: query.cursor ?? null, limit: query.limit })
6486
+ () => inspector.searchJobs(request.params.queue, query.state, query.q, { cursor: query.cursor ?? null, limit: query.limit, groupId: query.groupId })
6474
6487
  );
6475
6488
  });
6476
6489
  app.post(base, { preHandler: [operator] }, async (request, reply) => {
@@ -9199,21 +9212,31 @@ var MCP_TOOLS = [
9199
9212
  tool({
9200
9213
  name: "search_jobs",
9201
9214
  title: "Search jobs",
9202
- description: "Searches the jobs of one state for a text in the job id, name, data or failure reason. Scans in pages: pass next_cursor back as cursor to continue.",
9215
+ description: "Searches the jobs of one state for a text in the job id, name, data or failure reason. With group_id (BullMQ Pro) only that group's jobs are returned and query may be omitted: this is how to list a group's delayed, failed or completed jobs, which list_groups does not count. Scans in pages: pass next_cursor back as cursor to continue.",
9203
9216
  access: "read",
9204
9217
  inputSchema: object(
9205
9218
  {
9206
9219
  ...QUEUE,
9207
- query: str4("Text to look for"),
9220
+ query: str4("Text to look for (optional with group_id)"),
9221
+ group_id: str4("BullMQ Pro group id (exact): only jobs of this group"),
9208
9222
  state: str4("Job state to search", { enum: [...JOB_STATES], default: "failed" }),
9209
9223
  cursor: str4("Cursor from a previous call's nextCursor"),
9210
9224
  limit: int3("Max matches", 1, 200)
9211
9225
  },
9212
- ["connection_id", "queue", "query"]
9226
+ ["connection_id", "queue"]
9213
9227
  ),
9214
9228
  annotations: ro,
9215
- schema: zQueue.extend({ query: z10.string().min(1).max(500), state: zState.optional(), cursor: z10.string().optional(), limit: z10.number().int().min(1).max(200).optional() }),
9216
- run: (a, ctx) => forward(ctx, { method: "GET", url: `${queuePath(a)}/jobs/search${qs({ q: a.query, state: a.state, cursor: a.cursor, limit: a.limit })}` })
9229
+ schema: zQueue.extend({
9230
+ query: z10.string().max(500).optional(),
9231
+ group_id: z10.string().min(1).max(200).optional(),
9232
+ state: zState.optional(),
9233
+ cursor: z10.string().optional(),
9234
+ limit: z10.number().int().min(1).max(200).optional()
9235
+ }).refine((a) => !!a.query || !!a.group_id, { message: "query or group_id is required", path: ["query"] }),
9236
+ run: (a, ctx) => forward(ctx, {
9237
+ method: "GET",
9238
+ url: `${queuePath(a)}/jobs/search${qs({ q: a.query || void 0, groupId: a.group_id, state: a.state, cursor: a.cursor, limit: a.limit })}`
9239
+ })
9217
9240
  }),
9218
9241
  tool({
9219
9242
  name: "get_job",
@@ -9248,7 +9271,7 @@ var MCP_TOOLS = [
9248
9271
  tool({
9249
9272
  name: "list_groups",
9250
9273
  title: "List groups (BullMQ Pro)",
9251
- description: "BullMQ Pro groups of a queue: status (rate limited, paused, maxed), waiting and active per group. Empty on plain BullMQ.",
9274
+ description: "BullMQ Pro groups of a queue: status (rate limited, paused, maxed), waiting and active per group. Empty on plain BullMQ. Delayed jobs are not under their group in Pro, so a group with only delayed jobs is not listed: use search_jobs with group_id and state delayed.",
9252
9275
  access: "read",
9253
9276
  inputSchema: object({ ...QUEUE, page: int3("Page, from 1", 1, 1e4), page_size: int3("Per page", 1, 200) }, ["connection_id", "queue"]),
9254
9277
  annotations: ro,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullpane",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Self-hosted dashboard for BullMQ and BullMQ Pro, on Redis or on BullMQ 6's Postgres backend: queues, jobs, failures, metrics, server health, flows and Pro groups. A bull-board alternative you can run with npx.",
5
5
  "keywords": [
6
6
  "bullmq",
@@ -1,4 +1,4 @@
1
- import{c as ae,u as $,a as Z,j as e,L as G,P as _,S as J,E as A,W as O,N as ne,r as N,b as oe,d as re,e as le,f as c,g as ie,h as de,i as ce,k as S,l as v,B as b,R as ue,m as me,n as F,I as xe,o as R,p as K,q as U,t as E,T as ge,s as P,v as X,D as he,w as pe,x as fe}from"./index-xn9W6Th4.js";import{u as be,a as je,l as H,i as ve,B as Ne,b as we,C as ye,M as ke,c as Ce,H as V,P as Q}from"./flowLayout-CPGuWm9t.js";/**
1
+ import{c as ae,u as $,a as Z,j as e,L as G,P as _,S as J,E as A,W as O,N as ne,r as N,b as oe,d as re,e as le,f as c,g as ie,h as de,i as ce,k as S,l as v,B as b,R as ue,m as me,n as F,I as xe,o as R,p as K,q as U,t as E,T as ge,s as P,v as X,D as he,w as pe,x as fe}from"./index-BuZ0fAkt.js";import{u as be,a as je,l as H,i as ve,B as Ne,b as we,C as ye,M as ke,c as Ce,H as V,P as Q}from"./flowLayout-g8b3XZHS.js";/**
2
2
  * @license lucide-react v0.469.0 - ISC
3
3
  *
4
4
  * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
1
- import{c as T,b as B,y as L,J as P,j as e,P as g,K as b,S as $,E as f,p as x,r as u,n as z,M as w,l as p,C as F,f as h,O as j,v as N,o as K,Q as O,U as S,V as y}from"./index-xn9W6Th4.js";import{u as A,a as H,i as q,B as I,b as J,C as V,M as G,l as R,c as D,H as k,P as v}from"./flowLayout-CPGuWm9t.js";/**
1
+ import{c as T,b as B,y as L,J as P,j as e,P as g,K as b,S as $,E as f,p as x,r as u,n as z,M as w,l as p,C as F,f as h,O as j,v as N,o as K,Q as O,U as S,V as y}from"./index-BuZ0fAkt.js";import{u as A,a as H,i as q,B as I,b as J,C as V,M as G,l as R,c as D,H as k,P as v}from"./flowLayout-g8b3XZHS.js";/**
2
2
  * @license lucide-react v0.469.0 - ISC
3
3
  *
4
4
  * This source code is licensed under the ISC license.
@@ -1 +1 @@
1
- import{y as S,z as P,e as N,j as e,A as o,C as M,S as C,N as A,F as B,G as q,f as p,n as j,B as m,H as E,v as z}from"./index-xn9W6Th4.js";function L(){const[n]=S(),t=P(),{user:s,loading:a}=N(),r=n.get("error"),i=n.get("request");return r||!i?e.jsx(o,{title:"Could not connect to Bullpane",children:e.jsxs("p",{className:"flex gap-2 text-sm text-fg",children:[e.jsx(M,{className:"mt-0.5 size-4 shrink-0 text-danger"}),r??"This page needs a sign-in request from an MCP client. Start the connection again from Claude."]})}):a?e.jsx("div",{className:"flex min-h-screen items-center justify-center",children:e.jsx(C,{})}):s?e.jsx(D,{request:i}):e.jsx(A,{to:"/login",replace:!0,state:{from:t}})}function D({request:n}){const{user:t}=N(),s=B(n),a=q(),[r,i]=p.useState(null),[f,g]=p.useState(null);if(s.isLoading)return e.jsx("div",{className:"flex min-h-screen items-center justify-center",children:e.jsx(C,{})});if(s.isError||!s.data)return e.jsx(o,{title:"Could not connect to Bullpane",children:e.jsx("p",{className:"text-sm text-fg",children:j(s.error,"This sign-in request is no longer valid. Start the connection again from Claude.")})});const{clientName:c,redirectHost:w,allowed:l,requested:y,maxAccess:k}=s.data,d=r??(l==="write"&&y==="write"?"write":"read"),h=l==="write"?null:k!=="write"?"an admin limited MCP to read only":`your role is ${t==null?void 0:t.role}`,x=v=>{g(null),a.mutate({request:n,approve:v,access:d},{onSuccess:({redirectTo:u})=>window.location.assign(u),onError:u=>g(j(u))})};return l==="off"?e.jsxs(o,{title:"MCP is turned off",children:[e.jsxs("p",{className:"text-sm text-fg-muted",children:["An admin has to allow MCP clients in ",e.jsx("span",{className:"text-fg",children:"Settings → MCP"})," before ",c," can connect."]}),e.jsxs(m,{className:"mt-5 w-full",variant:"secondary",loading:a.isPending,onClick:()=>x(!1),children:["Back to ",c]})]}):e.jsxs(o,{title:`Connect ${c} to Bullpane`,description:e.jsxs(e.Fragment,{children:["It will act as ",e.jsx("span",{className:"text-fg",children:t==null?void 0:t.email})," (",t==null?void 0:t.role,") and send you back to ",w,"."]}),children:[e.jsxs("div",{className:"flex flex-col gap-2",role:"radiogroup","aria-label":"Access",children:[e.jsx(b,{selected:d==="read",onSelect:()=>i("read"),title:"Read only",detail:"See connections, queues, jobs, logs and schedulers."}),e.jsx(b,{selected:d==="write",disabled:l!=="write",onSelect:()=>i("write"),title:"Read & write",detail:h?`Not available: ${h}.`:"Also retry, promote, remove and add jobs, pause and resume queues."})]}),e.jsxs("p",{className:"mt-3 flex gap-1.5 text-[11px] text-fg-subtle",children:[e.jsx(E,{className:"size-3.5 shrink-0"}),"Every change is in the audit log. Drain, clean and obliterate always need you in the dashboard. Disconnect any time in Settings → MCP."]}),f&&e.jsx("p",{className:"mt-3 text-xs text-danger",children:f}),e.jsxs("div",{className:"mt-5 flex gap-2",children:[e.jsx(m,{className:"flex-1",variant:"ghost",disabled:a.isPending,onClick:()=>x(!1),children:"Cancel"}),e.jsx(m,{className:"flex-1",variant:"primary",loading:a.isPending,onClick:()=>x(!0),children:"Allow"})]})]})}function b({selected:n,disabled:t,onSelect:s,title:a,detail:r}){return e.jsxs("button",{type:"button",role:"radio","aria-checked":n,disabled:t,onClick:s,className:z("rounded-md border px-3 py-2.5 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",n?"border-accent bg-accent/10":"border-border bg-surface-2 hover:border-border-strong"),children:[e.jsx("span",{className:"block text-sm font-medium text-fg",children:a}),e.jsx("span",{className:"mt-0.5 block text-xs text-fg-muted",children:r})]})}export{L as McpConsentPage};
1
+ import{y as S,z as P,e as N,j as e,A as o,C as M,S as C,N as A,F as B,G as q,f as p,n as j,B as m,H as E,v as z}from"./index-BuZ0fAkt.js";function L(){const[n]=S(),t=P(),{user:s,loading:a}=N(),r=n.get("error"),i=n.get("request");return r||!i?e.jsx(o,{title:"Could not connect to Bullpane",children:e.jsxs("p",{className:"flex gap-2 text-sm text-fg",children:[e.jsx(M,{className:"mt-0.5 size-4 shrink-0 text-danger"}),r??"This page needs a sign-in request from an MCP client. Start the connection again from Claude."]})}):a?e.jsx("div",{className:"flex min-h-screen items-center justify-center",children:e.jsx(C,{})}):s?e.jsx(D,{request:i}):e.jsx(A,{to:"/login",replace:!0,state:{from:t}})}function D({request:n}){const{user:t}=N(),s=B(n),a=q(),[r,i]=p.useState(null),[f,g]=p.useState(null);if(s.isLoading)return e.jsx("div",{className:"flex min-h-screen items-center justify-center",children:e.jsx(C,{})});if(s.isError||!s.data)return e.jsx(o,{title:"Could not connect to Bullpane",children:e.jsx("p",{className:"text-sm text-fg",children:j(s.error,"This sign-in request is no longer valid. Start the connection again from Claude.")})});const{clientName:c,redirectHost:w,allowed:l,requested:y,maxAccess:k}=s.data,d=r??(l==="write"&&y==="write"?"write":"read"),h=l==="write"?null:k!=="write"?"an admin limited MCP to read only":`your role is ${t==null?void 0:t.role}`,x=v=>{g(null),a.mutate({request:n,approve:v,access:d},{onSuccess:({redirectTo:u})=>window.location.assign(u),onError:u=>g(j(u))})};return l==="off"?e.jsxs(o,{title:"MCP is turned off",children:[e.jsxs("p",{className:"text-sm text-fg-muted",children:["An admin has to allow MCP clients in ",e.jsx("span",{className:"text-fg",children:"Settings → MCP"})," before ",c," can connect."]}),e.jsxs(m,{className:"mt-5 w-full",variant:"secondary",loading:a.isPending,onClick:()=>x(!1),children:["Back to ",c]})]}):e.jsxs(o,{title:`Connect ${c} to Bullpane`,description:e.jsxs(e.Fragment,{children:["It will act as ",e.jsx("span",{className:"text-fg",children:t==null?void 0:t.email})," (",t==null?void 0:t.role,") and send you back to ",w,"."]}),children:[e.jsxs("div",{className:"flex flex-col gap-2",role:"radiogroup","aria-label":"Access",children:[e.jsx(b,{selected:d==="read",onSelect:()=>i("read"),title:"Read only",detail:"See connections, queues, jobs, logs and schedulers."}),e.jsx(b,{selected:d==="write",disabled:l!=="write",onSelect:()=>i("write"),title:"Read & write",detail:h?`Not available: ${h}.`:"Also retry, promote, remove and add jobs, pause and resume queues."})]}),e.jsxs("p",{className:"mt-3 flex gap-1.5 text-[11px] text-fg-subtle",children:[e.jsx(E,{className:"size-3.5 shrink-0"}),"Every change is in the audit log. Drain, clean and obliterate always need you in the dashboard. Disconnect any time in Settings → MCP."]}),f&&e.jsx("p",{className:"mt-3 text-xs text-danger",children:f}),e.jsxs("div",{className:"mt-5 flex gap-2",children:[e.jsx(m,{className:"flex-1",variant:"ghost",disabled:a.isPending,onClick:()=>x(!1),children:"Cancel"}),e.jsx(m,{className:"flex-1",variant:"primary",loading:a.isPending,onClick:()=>x(!0),children:"Allow"})]})]})}function b({selected:n,disabled:t,onSelect:s,title:a,detail:r}){return e.jsxs("button",{type:"button",role:"radio","aria-checked":n,disabled:t,onClick:s,className:z("rounded-md border px-3 py-2.5 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",n?"border-accent bg-accent/10":"border-border bg-surface-2 hover:border-border-strong"),children:[e.jsx("span",{className:"block text-sm font-medium text-fg",children:a}),e.jsx("span",{className:"mt-0.5 block text-xs text-fg-muted",children:r})]})}export{L as McpConsentPage};