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.
- package/dist/lua/searchJobs.lua +37 -7
- package/dist/server.mjs +34 -11
- package/package.json +1 -1
- package/web/assets/{FlowsPage-CRkXK3KR.js → FlowsPage-BAChK8uT.js} +1 -1
- package/web/assets/{JobTreePage-CcNNhc6M.js → JobTreePage-Vvq6aoQU.js} +1 -1
- package/web/assets/{McpConsentPage-BXb_LIdx.js → McpConsentPage-BHmbIbrR.js} +1 -1
- package/web/assets/{flowLayout-CPGuWm9t.js → flowLayout-g8b3XZHS.js} +1 -1
- package/web/assets/index-BuZ0fAkt.js +548 -0
- package/web/index.html +1 -1
- package/web/assets/index-xn9W6Th4.js +0 -548
package/dist/lua/searchJobs.lua
CHANGED
|
@@ -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
|
|
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 =
|
|
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().
|
|
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"
|
|
9226
|
+
["connection_id", "queue"]
|
|
9213
9227
|
),
|
|
9214
9228
|
annotations: ro,
|
|
9215
|
-
schema: zQueue.extend({
|
|
9216
|
-
|
|
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.
|
|
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-
|
|
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-
|
|
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-
|
|
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};
|