bullpane 0.5.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/LICENSE +35 -0
- package/LICENSE-ee +46 -0
- package/README.md +60 -0
- package/bin/bullpane.mjs +99 -0
- package/dist/lua/getGroups.lua +65 -0
- package/dist/lua/getJob.lua +61 -0
- package/dist/lua/getJobs.lua +156 -0
- package/dist/lua/getSchedulers.lua +76 -0
- package/dist/lua/getTreeNode.lua +105 -0
- package/dist/lua/queueSetup.lua +47 -0
- package/dist/lua/queueStats.lua +140 -0
- package/dist/lua/sampleParents.lua +65 -0
- package/dist/lua/searchJobs.lua +146 -0
- package/dist/lua/windowMetrics.lua +167 -0
- package/dist/server.mjs +8703 -0
- package/migrations/mysql/0001_init.sql +102 -0
- package/migrations/mysql/0002_alert_scopes.sql +9 -0
- package/migrations/mysql/0003_hidden_queues.sql +21 -0
- package/migrations/mysql/0004_audit_log.sql +57 -0
- package/migrations/mysql/0005_sso.sql +48 -0
- package/migrations/mysql/0006_connection_position.sql +29 -0
- package/migrations/mysql/0007_user_disabled.sql +12 -0
- package/migrations/mysql/0008_alert_wide_scopes.sql +18 -0
- package/migrations/mysql/0009_mcp.sql +62 -0
- package/migrations/sqlite/0001_init.sql +158 -0
- package/migrations/sqlite/0002_mcp.sql +38 -0
- package/package.json +56 -0
- package/web/assets/FlowsPage-CxMz_9Et.js +6 -0
- package/web/assets/JobTreePage-BT79W1zX.js +6 -0
- package/web/assets/McpConsentPage-B2YnL6fN.js +1 -0
- package/web/assets/flowLayout-BnuhLJ6X.css +1 -0
- package/web/assets/flowLayout-DoC2dCG0.js +23 -0
- package/web/assets/index-C2gL_lbF.css +1 -0
- package/web/assets/index-DuAopWKm.js +543 -0
- package/web/index.html +18 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
-- Bullpane — initial schema. Applied by src/db/migrate.ts (statements split on ";\n").
|
|
2
|
+
|
|
3
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
4
|
+
id VARCHAR(36) NOT NULL,
|
|
5
|
+
email VARCHAR(255) NOT NULL,
|
|
6
|
+
name VARCHAR(80) NOT NULL,
|
|
7
|
+
password_hash VARCHAR(255) NOT NULL,
|
|
8
|
+
role ENUM('admin', 'operator', 'viewer') NOT NULL DEFAULT 'viewer',
|
|
9
|
+
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
10
|
+
last_login_at DATETIME(3) NULL,
|
|
11
|
+
PRIMARY KEY (id),
|
|
12
|
+
UNIQUE KEY users_email_unique (email)
|
|
13
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
16
|
+
id VARCHAR(64) NOT NULL,
|
|
17
|
+
user_id VARCHAR(36) NOT NULL,
|
|
18
|
+
expires_at DATETIME(3) NOT NULL,
|
|
19
|
+
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
20
|
+
PRIMARY KEY (id),
|
|
21
|
+
KEY sessions_user_id_idx (user_id),
|
|
22
|
+
KEY sessions_expires_at_idx (expires_at),
|
|
23
|
+
CONSTRAINT sessions_user_id_fk FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
24
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS connections (
|
|
27
|
+
id VARCHAR(36) NOT NULL,
|
|
28
|
+
name VARCHAR(80) NOT NULL,
|
|
29
|
+
url TEXT NOT NULL,
|
|
30
|
+
prefix VARCHAR(64) NOT NULL DEFAULT 'bull',
|
|
31
|
+
cluster TINYINT(1) NOT NULL DEFAULT 0,
|
|
32
|
+
queue_filter VARCHAR(200) NULL,
|
|
33
|
+
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
34
|
+
PRIMARY KEY (id)
|
|
35
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS folders (
|
|
38
|
+
id VARCHAR(36) NOT NULL,
|
|
39
|
+
name VARCHAR(80) NOT NULL,
|
|
40
|
+
color VARCHAR(20) NULL,
|
|
41
|
+
parent_id VARCHAR(36) NULL,
|
|
42
|
+
position INT NOT NULL DEFAULT 0,
|
|
43
|
+
PRIMARY KEY (id),
|
|
44
|
+
KEY folders_parent_id_idx (parent_id)
|
|
45
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS folder_queues (
|
|
48
|
+
folder_id VARCHAR(36) NOT NULL,
|
|
49
|
+
connection_id VARCHAR(36) NOT NULL,
|
|
50
|
+
queue_name VARCHAR(255) NOT NULL,
|
|
51
|
+
PRIMARY KEY (folder_id, connection_id, queue_name),
|
|
52
|
+
CONSTRAINT folder_queues_folder_id_fk FOREIGN KEY (folder_id) REFERENCES folders (id) ON DELETE CASCADE
|
|
53
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
54
|
+
|
|
55
|
+
CREATE TABLE IF NOT EXISTS alerts (
|
|
56
|
+
id VARCHAR(36) NOT NULL,
|
|
57
|
+
name VARCHAR(120) NOT NULL,
|
|
58
|
+
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
|
59
|
+
connection_id VARCHAR(36) NOT NULL,
|
|
60
|
+
queue_name VARCHAR(255) NULL,
|
|
61
|
+
`condition` JSON NOT NULL,
|
|
62
|
+
channels JSON NOT NULL,
|
|
63
|
+
cooldown_minutes INT NOT NULL DEFAULT 30,
|
|
64
|
+
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
65
|
+
last_fired_at DATETIME(3) NULL,
|
|
66
|
+
firing TINYINT(1) NOT NULL DEFAULT 0,
|
|
67
|
+
PRIMARY KEY (id),
|
|
68
|
+
KEY alerts_connection_id_idx (connection_id)
|
|
69
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
70
|
+
|
|
71
|
+
CREATE TABLE IF NOT EXISTS alert_events (
|
|
72
|
+
id VARCHAR(36) NOT NULL,
|
|
73
|
+
alert_id VARCHAR(36) NOT NULL,
|
|
74
|
+
alert_name VARCHAR(120) NOT NULL,
|
|
75
|
+
connection_id VARCHAR(36) NOT NULL,
|
|
76
|
+
queue_name VARCHAR(255) NULL,
|
|
77
|
+
kind VARCHAR(40) NOT NULL,
|
|
78
|
+
status VARCHAR(20) NOT NULL,
|
|
79
|
+
message TEXT NOT NULL,
|
|
80
|
+
value DOUBLE NULL,
|
|
81
|
+
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
82
|
+
PRIMARY KEY (id),
|
|
83
|
+
KEY alert_events_created_at_idx (created_at),
|
|
84
|
+
KEY alert_events_alert_id_idx (alert_id)
|
|
85
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
86
|
+
|
|
87
|
+
CREATE TABLE IF NOT EXISTS flow_edges (
|
|
88
|
+
id VARCHAR(36) NOT NULL,
|
|
89
|
+
connection_id VARCHAR(36) NOT NULL,
|
|
90
|
+
from_queue VARCHAR(255) NOT NULL,
|
|
91
|
+
to_queue VARCHAR(255) NOT NULL,
|
|
92
|
+
label VARCHAR(120) NULL,
|
|
93
|
+
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
94
|
+
PRIMARY KEY (id),
|
|
95
|
+
UNIQUE KEY flow_edges_unique (connection_id, from_queue, to_queue)
|
|
96
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
97
|
+
|
|
98
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
99
|
+
`key` VARCHAR(64) NOT NULL,
|
|
100
|
+
value TEXT NOT NULL,
|
|
101
|
+
PRIMARY KEY (`key`)
|
|
102
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Alerts are scoped to a queue or a folder; connection-level alerts are gone.
|
|
2
|
+
ALTER TABLE alerts MODIFY connection_id VARCHAR(36) NULL;
|
|
3
|
+
ALTER TABLE alerts ADD COLUMN scope_type ENUM('queue','folder') NOT NULL DEFAULT 'queue' AFTER enabled;
|
|
4
|
+
ALTER TABLE alerts ADD COLUMN folder_id VARCHAR(36) NULL AFTER queue_name;
|
|
5
|
+
ALTER TABLE alerts ADD KEY alerts_folder_id_idx (folder_id);
|
|
6
|
+
ALTER TABLE alert_events MODIFY connection_id VARCHAR(36) NULL;
|
|
7
|
+
-- Rows that cannot be expressed in the new model (connection_down, "all queues of a connection").
|
|
8
|
+
DELETE FROM alerts WHERE JSON_UNQUOTE(JSON_EXTRACT(`condition`, '$.kind')) = 'connection_down';
|
|
9
|
+
DELETE FROM alerts WHERE scope_type = 'queue' AND queue_name IS NULL;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- Hidden queues: a queue the team no longer wants to SEE, per instance.
|
|
2
|
+
--
|
|
3
|
+
-- Deliberately NOT a per-user preference. If a lead hides a dead queue it is
|
|
4
|
+
-- dead for everyone; a dashboard that differs per person is a liability in the
|
|
5
|
+
-- middle of an incident. `hidden_by` records who did it so the choice can be
|
|
6
|
+
-- traced back, and the row is the whole state (present = hidden).
|
|
7
|
+
--
|
|
8
|
+
-- Nothing here touches Redis. The queue keeps running, keeps being measured by
|
|
9
|
+
-- alerts, and stays reachable by direct URL. This is the opposite of
|
|
10
|
+
-- `obliterate`, which deletes the queue and its jobs and cannot be undone.
|
|
11
|
+
--
|
|
12
|
+
-- No FK to `connections`: the queue name is a discovered string, not a row, and
|
|
13
|
+
-- ConnectionsService.remove() cleans these up alongside folder_queues.
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS hidden_queues (
|
|
16
|
+
connection_id VARCHAR(36) NOT NULL,
|
|
17
|
+
queue_name VARCHAR(255) NOT NULL,
|
|
18
|
+
hidden_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
19
|
+
hidden_by VARCHAR(36) NULL,
|
|
20
|
+
PRIMARY KEY (connection_id, queue_name)
|
|
21
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
-- Audit log: the durable answer to "who did this to my production queue?".
|
|
2
|
+
--
|
|
3
|
+
-- The trail already existed as pino lines ({ queue, jobId, by }) in every
|
|
4
|
+
-- mutating handler. A log file that dies with the container and that nobody can
|
|
5
|
+
-- query is not an audit trail, it is a debugging aid. This table is the same
|
|
6
|
+
-- information, persisted, filterable and exportable.
|
|
7
|
+
--
|
|
8
|
+
-- WHY THE ACTOR IS DENORMALISED (actor_email / actor_name / actor_role):
|
|
9
|
+
-- an audit row whose only pointer to the person is a foreign key stops meaning
|
|
10
|
+
-- anything the moment that user is deleted — and "the person who did it left the
|
|
11
|
+
-- company" is precisely the case an auditor cares about. So there is deliberately
|
|
12
|
+
-- NO FK to `users`, and no FK to `connections` either: the connection name is
|
|
13
|
+
-- copied in, because a deleted connection must not erase what was done to it.
|
|
14
|
+
-- Rows are append-only by design; no route updates or deletes them (retention
|
|
15
|
+
-- pruning by age is the single exception).
|
|
16
|
+
--
|
|
17
|
+
-- WHAT IS NOT HERE: the job's `data`. `detail` carries the PARAMETERS of the
|
|
18
|
+
-- action (clean state/grace/limit, how many jobs were removed, the job name,
|
|
19
|
+
-- payload size in bytes) and never the payload itself, which routinely holds
|
|
20
|
+
-- customer PII. That rule is in CLAUDE.md ("never log job data") and is covered
|
|
21
|
+
-- by a test.
|
|
22
|
+
--
|
|
23
|
+
-- SIZE: a typical row is ~350-600 bytes on disk including the two indexes
|
|
24
|
+
-- (fixed columns ~120 B, user agent ~120 B, detail JSON usually < 200 B). At
|
|
25
|
+
-- 10k mutating actions/day that is ~2 GB/year; a dashboard where humans click
|
|
26
|
+
-- buttons is more like 200/day, i.e. ~40 MB/year. Retention is
|
|
27
|
+
-- BULLPANE_AUDIT_RETENTION_DAYS (default 365) and the prune runs with the alerts
|
|
28
|
+
-- engine tick.
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
31
|
+
id VARCHAR(36) NOT NULL,
|
|
32
|
+
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
33
|
+
-- Denormalised actor. NULL actor_id = anonymous call (failed login on an
|
|
34
|
+
-- unknown email). The other three survive the user row being deleted.
|
|
35
|
+
actor_id VARCHAR(36) NULL,
|
|
36
|
+
actor_email VARCHAR(255) NULL,
|
|
37
|
+
actor_name VARCHAR(80) NULL,
|
|
38
|
+
actor_role VARCHAR(20) NULL,
|
|
39
|
+
action VARCHAR(40) NOT NULL,
|
|
40
|
+
-- Denormalised target. Queues are discovered strings, not rows, so there is
|
|
41
|
+
-- nothing to reference even in principle.
|
|
42
|
+
connection_id VARCHAR(36) NULL,
|
|
43
|
+
connection_name VARCHAR(80) NULL,
|
|
44
|
+
queue_name VARCHAR(255) NULL,
|
|
45
|
+
job_id VARCHAR(255) NULL,
|
|
46
|
+
result VARCHAR(10) NOT NULL DEFAULT 'ok',
|
|
47
|
+
error_message VARCHAR(500) NULL,
|
|
48
|
+
detail JSON NULL,
|
|
49
|
+
ip VARCHAR(45) NULL,
|
|
50
|
+
user_agent VARCHAR(255) NULL,
|
|
51
|
+
PRIMARY KEY (id),
|
|
52
|
+
-- The main query is "newest first, optionally filtered". Paging is keyset on
|
|
53
|
+
-- (created_at, id), so this index serves both the ordering and the cursor.
|
|
54
|
+
KEY audit_log_created_at_idx (created_at, id),
|
|
55
|
+
KEY audit_log_actor_id_idx (actor_id),
|
|
56
|
+
KEY audit_log_queue_idx (connection_id, queue_name)
|
|
57
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
-- SSO (Pro): the customer's admin registers their own identity provider from
|
|
2
|
+
-- the Settings UI, so a self-hosted install can fix a bad issuer in the browser
|
|
3
|
+
-- instead of editing a compose file and redeploying.
|
|
4
|
+
--
|
|
5
|
+
-- WHY THE CONFIG IS A JSON COLUMN: the two protocols share almost no fields
|
|
6
|
+
-- (OIDC has issuer/client_id/scopes, SAML has entry_point/idp_cert), the shape
|
|
7
|
+
-- is validated by zod in @bullpane/shared before it ever reaches here, and no
|
|
8
|
+
-- query ever filters on a field inside it — providers are read whole, by id or
|
|
9
|
+
-- as a short list. Columns per protocol would mean half the table NULL and a
|
|
10
|
+
-- migration for every IdP quirk.
|
|
11
|
+
--
|
|
12
|
+
-- WHY THE SECRET IS ENCRYPTED AND NOT HASHED: we have to SEND the OIDC client
|
|
13
|
+
-- secret to the token endpoint, so it must be recoverable — unlike a password,
|
|
14
|
+
-- which only ever needs comparing. It is stored AES-256-GCM with a key derived
|
|
15
|
+
-- (HKDF) from SESSION_SECRET, in `secret_enc`, and is never returned by the API
|
|
16
|
+
-- (SsoProvider.hasSecret is the only thing the UI learns). Consequence the
|
|
17
|
+
-- operator must know: rotating SESSION_SECRET makes existing secrets
|
|
18
|
+
-- undecryptable and every provider has to have its secret re-entered.
|
|
19
|
+
-- SAML needs no secret: it verifies the IdP's signature with a public cert,
|
|
20
|
+
-- which lives in `config` in the clear on purpose.
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS sso_providers (
|
|
23
|
+
id VARCHAR(36) NOT NULL,
|
|
24
|
+
kind VARCHAR(10) NOT NULL,
|
|
25
|
+
name VARCHAR(60) NOT NULL,
|
|
26
|
+
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
|
27
|
+
config JSON NOT NULL,
|
|
28
|
+
-- AES-256-GCM ciphertext (iv.tag.data, base64url). NULL for SAML, and for an
|
|
29
|
+
-- OIDC row whose secret has not been set yet.
|
|
30
|
+
secret_enc TEXT NULL,
|
|
31
|
+
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
32
|
+
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
33
|
+
PRIMARY KEY (id),
|
|
34
|
+
-- The login page lists enabled providers on every render; keep it off a scan.
|
|
35
|
+
KEY sso_providers_enabled_idx (enabled)
|
|
36
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
37
|
+
|
|
38
|
+
-- A user who only ever signs in through the IdP has no password, and inventing
|
|
39
|
+
-- one for them (a) is a credential nobody rotates and (b) is exactly the
|
|
40
|
+
-- friction SSO exists to remove. So the column becomes nullable; verifyPassword
|
|
41
|
+
-- refuses a NULL hash outright, which means such an account CANNOT fall back to
|
|
42
|
+
-- the password form even when the escape hatch is open.
|
|
43
|
+
ALTER TABLE users MODIFY password_hash VARCHAR(255) NULL;
|
|
44
|
+
|
|
45
|
+
-- How each live session was authenticated, so the audit log can say "signed in
|
|
46
|
+
-- with SSO" and a future forced-logout-on-SSO-change can target the right rows.
|
|
47
|
+
-- Existing sessions predate SSO, so 'password' is the correct backfill.
|
|
48
|
+
ALTER TABLE sessions ADD COLUMN auth_method VARCHAR(10) NOT NULL DEFAULT 'password';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
-- Manual ordering for connections in the sidebar.
|
|
2
|
+
--
|
|
3
|
+
-- Deliberately NOT a per-user preference, and for the same reason as
|
|
4
|
+
-- `hidden_queues`: if a lead puts the production Redis at the top, it is at the
|
|
5
|
+
-- top for everyone. A sidebar whose order differs per person is a liability in
|
|
6
|
+
-- the middle of an incident, when one person is reading another's screen share.
|
|
7
|
+
--
|
|
8
|
+
-- `folders` already had `position` with these exact semantics; connections were
|
|
9
|
+
-- the odd one out, ordered by `created_at`, which meant the order was decided by
|
|
10
|
+
-- the accident of which Redis was added first and could never be changed.
|
|
11
|
+
--
|
|
12
|
+
-- Backfill: existing rows keep their current visible order. They were sorted by
|
|
13
|
+
-- `created_at`, so numbering them in that order makes this migration invisible.
|
|
14
|
+
--
|
|
15
|
+
-- WHY A SELF-JOIN AND NOT `SET @row := -1`: the migrator runs each statement
|
|
16
|
+
-- through `pool.query()`, and a pool hands out an arbitrary connection per call.
|
|
17
|
+
-- A user variable set in one statement would be NULL in the next one, silently
|
|
18
|
+
-- backfilling every row to 0. This counts predecessors per row instead, so it is
|
|
19
|
+
-- correct as a SINGLE statement regardless of which connection runs it.
|
|
20
|
+
|
|
21
|
+
ALTER TABLE connections ADD COLUMN position INT NOT NULL DEFAULT 0;
|
|
22
|
+
|
|
23
|
+
UPDATE connections AS c
|
|
24
|
+
SET position = (
|
|
25
|
+
SELECT COUNT(*)
|
|
26
|
+
FROM (SELECT id, created_at FROM connections) AS earlier
|
|
27
|
+
WHERE earlier.created_at < c.created_at
|
|
28
|
+
OR (earlier.created_at = c.created_at AND earlier.id < c.id)
|
|
29
|
+
);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Users are disabled, never deleted.
|
|
2
|
+
--
|
|
3
|
+
-- A deleted row takes the id with it, and the id is what everything else
|
|
4
|
+
-- remembers: audit rows name their actor by id, alert history and folder
|
|
5
|
+
-- ownership point at whoever created them, and an admin re-inviting the same
|
|
6
|
+
-- email got a NEW id that matched none of it. Disabling keeps the row, revokes
|
|
7
|
+
-- every session and refuses the next login (password or SSO), and can be undone.
|
|
8
|
+
--
|
|
9
|
+
-- Nullable timestamp rather than a boolean: "since when" is the first question
|
|
10
|
+
-- on the users page, and NULL stays the common case so no backfill is needed.
|
|
11
|
+
|
|
12
|
+
ALTER TABLE users ADD COLUMN disabled_at DATETIME(3) NULL;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- Alert rules can now cover every queue on one connection, or every queue on
|
|
2
|
+
-- every connection. They are also what drives "Needs attention" on the
|
|
3
|
+
-- Overview in Pro, so a rule with no channels (dashboard only) is valid.
|
|
4
|
+
--
|
|
5
|
+
-- `connection` reuses connection_id with queue_name NULL; `global` uses no
|
|
6
|
+
-- column at all. Hidden queues are excluded from both at evaluation time.
|
|
7
|
+
ALTER TABLE alerts MODIFY scope_type ENUM('queue','folder','connection','global') NOT NULL DEFAULT 'queue';
|
|
8
|
+
|
|
9
|
+
-- One starting rule, so the Overview has something to say on day one:
|
|
10
|
+
-- failure rate above 10% over 15 minutes on any queue with at least 20
|
|
11
|
+
-- finished jobs, measured from BullMQ metrics, notifying nobody. Only
|
|
12
|
+
-- inserted when the install has no alerts yet; edit or delete it freely.
|
|
13
|
+
INSERT INTO alerts (id, name, enabled, scope_type, connection_id, queue_name, folder_id, `condition`, channels, cooldown_minutes, created_at, last_fired_at, firing)
|
|
14
|
+
SELECT 'default-failure-rate', 'Failure rate above 10%', 1, 'global', NULL, NULL, NULL,
|
|
15
|
+
JSON_OBJECT('kind', 'failed_rate_above', 'percent', 10, 'windowMinutes', 15, 'minSample', 20),
|
|
16
|
+
JSON_ARRAY(), 30, UTC_TIMESTAMP(3), NULL, 0
|
|
17
|
+
FROM DUAL
|
|
18
|
+
WHERE NOT EXISTS (SELECT 1 FROM alerts);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
-- MCP (Pro): Claude and other MCP clients connect to /mcp with OAuth 2.1. The
|
|
2
|
+
-- user signs in to Bullpane the way they always do (password or SSO), approves
|
|
3
|
+
-- the client on a consent screen, and the client gets tokens tied to that user.
|
|
4
|
+
--
|
|
5
|
+
-- THREE TABLES, AND WHY NOT AN ACCESS-TOKEN TABLE. Access tokens are short-lived
|
|
6
|
+
-- (1 h) and signed (HMAC, key derived from SESSION_SECRET) with the grant id
|
|
7
|
+
-- inside, so a call needs no lookup of the token itself. It still loads the grant
|
|
8
|
+
-- and its user on every call — that is what makes revoking a client, disabling a
|
|
9
|
+
-- user or lowering the admin's ceiling take effect at once instead of at expiry.
|
|
10
|
+
--
|
|
11
|
+
-- * mcp_clients : dynamically registered clients (RFC 7591). Public clients,
|
|
12
|
+
-- no secret: PKCE is what binds the code to the client.
|
|
13
|
+
-- * mcp_auth_codes : single-use authorization codes, 60 s. In MySQL rather than
|
|
14
|
+
-- in memory so two replicas behind a load balancer work.
|
|
15
|
+
-- * mcp_grants : one row per approved consent = one refresh-token family.
|
|
16
|
+
-- Refresh tokens rotate on use; presenting the previous one
|
|
17
|
+
-- again means it leaked, and the whole grant is deleted.
|
|
18
|
+
--
|
|
19
|
+
-- Codes and refresh tokens are stored as SHA-256 hashes: a database dump does
|
|
20
|
+
-- not yield a usable token.
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS mcp_clients (
|
|
23
|
+
id VARCHAR(64) NOT NULL,
|
|
24
|
+
name VARCHAR(120) NOT NULL,
|
|
25
|
+
redirect_uris JSON NOT NULL,
|
|
26
|
+
created_at DATETIME(3) NOT NULL,
|
|
27
|
+
PRIMARY KEY (id),
|
|
28
|
+
KEY mcp_clients_created_at_idx (created_at)
|
|
29
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS mcp_auth_codes (
|
|
32
|
+
code_hash VARCHAR(64) NOT NULL,
|
|
33
|
+
client_id VARCHAR(64) NOT NULL,
|
|
34
|
+
user_id VARCHAR(36) NOT NULL,
|
|
35
|
+
access VARCHAR(10) NOT NULL,
|
|
36
|
+
redirect_uri VARCHAR(2048) NOT NULL,
|
|
37
|
+
code_challenge VARCHAR(128) NOT NULL,
|
|
38
|
+
expires_at DATETIME(3) NOT NULL,
|
|
39
|
+
PRIMARY KEY (code_hash),
|
|
40
|
+
KEY mcp_auth_codes_expires_at_idx (expires_at),
|
|
41
|
+
CONSTRAINT mcp_auth_codes_user_fk FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
42
|
+
CONSTRAINT mcp_auth_codes_client_fk FOREIGN KEY (client_id) REFERENCES mcp_clients (id) ON DELETE CASCADE
|
|
43
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
44
|
+
|
|
45
|
+
CREATE TABLE IF NOT EXISTS mcp_grants (
|
|
46
|
+
id VARCHAR(36) NOT NULL,
|
|
47
|
+
client_id VARCHAR(64) NOT NULL,
|
|
48
|
+
user_id VARCHAR(36) NOT NULL,
|
|
49
|
+
access VARCHAR(10) NOT NULL,
|
|
50
|
+
refresh_hash VARCHAR(64) NOT NULL,
|
|
51
|
+
-- the refresh token this one replaced; seeing it again = reuse = revoke
|
|
52
|
+
prev_refresh_hash VARCHAR(64) NULL,
|
|
53
|
+
refresh_expires_at DATETIME(3) NOT NULL,
|
|
54
|
+
created_at DATETIME(3) NOT NULL,
|
|
55
|
+
last_used_at DATETIME(3) NULL,
|
|
56
|
+
PRIMARY KEY (id),
|
|
57
|
+
UNIQUE KEY mcp_grants_refresh_hash_unique (refresh_hash),
|
|
58
|
+
KEY mcp_grants_prev_refresh_hash_idx (prev_refresh_hash),
|
|
59
|
+
KEY mcp_grants_user_id_idx (user_id),
|
|
60
|
+
CONSTRAINT mcp_grants_user_fk FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
61
|
+
CONSTRAINT mcp_grants_client_fk FOREIGN KEY (client_id) REFERENCES mcp_clients (id) ON DELETE CASCADE
|
|
62
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
-- Bullpane — SQLite schema, the zero-setup default when DATABASE_URL is unset.
|
|
2
|
+
--
|
|
3
|
+
-- This is the MySQL schema as of migrations/mysql/0008, created in one go: a
|
|
4
|
+
-- SQLite install has no history to replay. The reasoning behind each table and
|
|
5
|
+
-- column lives in the MySQL migration that introduced it; this file only notes
|
|
6
|
+
-- where SQLite differs.
|
|
7
|
+
--
|
|
8
|
+
-- Type mapping (see src/db/schema.sqlite.ts): DATETIME(3) → INTEGER epoch ms,
|
|
9
|
+
-- TINYINT(1) → INTEGER 0/1, JSON → TEXT, ENUM/VARCHAR → TEXT with a CHECK where
|
|
10
|
+
-- MySQL had an ENUM. Foreign keys are declared for documentation; the services
|
|
11
|
+
-- delete child rows explicitly, so nothing depends on them being enforced.
|
|
12
|
+
|
|
13
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
14
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
15
|
+
email TEXT NOT NULL,
|
|
16
|
+
name TEXT NOT NULL,
|
|
17
|
+
password_hash TEXT NULL,
|
|
18
|
+
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'operator', 'viewer')),
|
|
19
|
+
created_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)),
|
|
20
|
+
last_login_at INTEGER NULL,
|
|
21
|
+
disabled_at INTEGER NULL
|
|
22
|
+
);
|
|
23
|
+
CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique ON users (email);
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
26
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
27
|
+
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
|
28
|
+
expires_at INTEGER NOT NULL,
|
|
29
|
+
created_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)),
|
|
30
|
+
auth_method TEXT NOT NULL DEFAULT 'password'
|
|
31
|
+
);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS sessions_user_id_idx ON sessions (user_id);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions (expires_at);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS sso_providers (
|
|
36
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
37
|
+
kind TEXT NOT NULL,
|
|
38
|
+
name TEXT NOT NULL,
|
|
39
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
40
|
+
config TEXT NOT NULL,
|
|
41
|
+
secret_enc TEXT NULL,
|
|
42
|
+
created_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)),
|
|
43
|
+
updated_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER))
|
|
44
|
+
);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS sso_providers_enabled_idx ON sso_providers (enabled);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS connections (
|
|
48
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
49
|
+
name TEXT NOT NULL,
|
|
50
|
+
url TEXT NOT NULL,
|
|
51
|
+
prefix TEXT NOT NULL DEFAULT 'bull',
|
|
52
|
+
cluster INTEGER NOT NULL DEFAULT 0,
|
|
53
|
+
queue_filter TEXT NULL,
|
|
54
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
55
|
+
created_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER))
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
CREATE TABLE IF NOT EXISTS folders (
|
|
59
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
60
|
+
name TEXT NOT NULL,
|
|
61
|
+
color TEXT NULL,
|
|
62
|
+
parent_id TEXT NULL,
|
|
63
|
+
position INTEGER NOT NULL DEFAULT 0
|
|
64
|
+
);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS folders_parent_id_idx ON folders (parent_id);
|
|
66
|
+
|
|
67
|
+
CREATE TABLE IF NOT EXISTS folder_queues (
|
|
68
|
+
folder_id TEXT NOT NULL REFERENCES folders (id) ON DELETE CASCADE,
|
|
69
|
+
connection_id TEXT NOT NULL,
|
|
70
|
+
queue_name TEXT NOT NULL,
|
|
71
|
+
PRIMARY KEY (folder_id, connection_id, queue_name)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE TABLE IF NOT EXISTS hidden_queues (
|
|
75
|
+
connection_id TEXT NOT NULL,
|
|
76
|
+
queue_name TEXT NOT NULL,
|
|
77
|
+
hidden_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)),
|
|
78
|
+
hidden_by TEXT NULL,
|
|
79
|
+
PRIMARY KEY (connection_id, queue_name)
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
CREATE TABLE IF NOT EXISTS alerts (
|
|
83
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
84
|
+
name TEXT NOT NULL,
|
|
85
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
86
|
+
scope_type TEXT NOT NULL DEFAULT 'queue' CHECK (scope_type IN ('queue', 'folder', 'connection', 'global')),
|
|
87
|
+
connection_id TEXT NULL,
|
|
88
|
+
queue_name TEXT NULL,
|
|
89
|
+
folder_id TEXT NULL,
|
|
90
|
+
"condition" TEXT NOT NULL,
|
|
91
|
+
channels TEXT NOT NULL,
|
|
92
|
+
cooldown_minutes INTEGER NOT NULL DEFAULT 30,
|
|
93
|
+
created_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)),
|
|
94
|
+
last_fired_at INTEGER NULL,
|
|
95
|
+
firing INTEGER NOT NULL DEFAULT 0
|
|
96
|
+
);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS alerts_connection_id_idx ON alerts (connection_id);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS alerts_folder_id_idx ON alerts (folder_id);
|
|
99
|
+
|
|
100
|
+
CREATE TABLE IF NOT EXISTS alert_events (
|
|
101
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
102
|
+
alert_id TEXT NOT NULL,
|
|
103
|
+
alert_name TEXT NOT NULL,
|
|
104
|
+
connection_id TEXT NULL,
|
|
105
|
+
queue_name TEXT NULL,
|
|
106
|
+
kind TEXT NOT NULL,
|
|
107
|
+
status TEXT NOT NULL,
|
|
108
|
+
message TEXT NOT NULL,
|
|
109
|
+
value REAL NULL,
|
|
110
|
+
created_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER))
|
|
111
|
+
);
|
|
112
|
+
CREATE INDEX IF NOT EXISTS alert_events_created_at_idx ON alert_events (created_at);
|
|
113
|
+
CREATE INDEX IF NOT EXISTS alert_events_alert_id_idx ON alert_events (alert_id);
|
|
114
|
+
|
|
115
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
116
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
117
|
+
created_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)),
|
|
118
|
+
actor_id TEXT NULL,
|
|
119
|
+
actor_email TEXT NULL,
|
|
120
|
+
actor_name TEXT NULL,
|
|
121
|
+
actor_role TEXT NULL,
|
|
122
|
+
action TEXT NOT NULL,
|
|
123
|
+
connection_id TEXT NULL,
|
|
124
|
+
connection_name TEXT NULL,
|
|
125
|
+
queue_name TEXT NULL,
|
|
126
|
+
job_id TEXT NULL,
|
|
127
|
+
result TEXT NOT NULL DEFAULT 'ok',
|
|
128
|
+
error_message TEXT NULL,
|
|
129
|
+
detail TEXT NULL,
|
|
130
|
+
ip TEXT NULL,
|
|
131
|
+
user_agent TEXT NULL
|
|
132
|
+
);
|
|
133
|
+
-- Keyset paging on (created_at, id), newest first — same index as MySQL.
|
|
134
|
+
CREATE INDEX IF NOT EXISTS audit_log_created_at_idx ON audit_log (created_at, id);
|
|
135
|
+
CREATE INDEX IF NOT EXISTS audit_log_actor_id_idx ON audit_log (actor_id);
|
|
136
|
+
CREATE INDEX IF NOT EXISTS audit_log_queue_idx ON audit_log (connection_id, queue_name);
|
|
137
|
+
|
|
138
|
+
CREATE TABLE IF NOT EXISTS flow_edges (
|
|
139
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
140
|
+
connection_id TEXT NOT NULL,
|
|
141
|
+
from_queue TEXT NOT NULL,
|
|
142
|
+
to_queue TEXT NOT NULL,
|
|
143
|
+
label TEXT NULL,
|
|
144
|
+
created_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER))
|
|
145
|
+
);
|
|
146
|
+
CREATE UNIQUE INDEX IF NOT EXISTS flow_edges_unique ON flow_edges (connection_id, from_queue, to_queue);
|
|
147
|
+
|
|
148
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
149
|
+
"key" TEXT NOT NULL PRIMARY KEY,
|
|
150
|
+
value TEXT NOT NULL
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
-- The starting alert rule from migrations/mysql/0008_alert_wide_scopes.sql.
|
|
154
|
+
INSERT INTO alerts (id, name, enabled, scope_type, connection_id, queue_name, folder_id, "condition", channels, cooldown_minutes, created_at, last_fired_at, firing)
|
|
155
|
+
SELECT 'default-failure-rate', 'Failure rate above 10%', 1, 'global', NULL, NULL, NULL,
|
|
156
|
+
json_object('kind', 'failed_rate_above', 'percent', 10, 'windowMinutes', 15, 'minSample', 20),
|
|
157
|
+
json_array(), 30, CAST(unixepoch('subsec') * 1000 AS INTEGER), NULL, 0
|
|
158
|
+
WHERE NOT EXISTS (SELECT 1 FROM alerts);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- MCP (Pro): OAuth clients, single-use authorization codes and grants.
|
|
2
|
+
-- Same tables as migrations/mysql/0009_mcp.sql, which explains the design
|
|
3
|
+
-- (why there is no access-token table, why codes and refresh tokens are hashes).
|
|
4
|
+
|
|
5
|
+
CREATE TABLE IF NOT EXISTS mcp_clients (
|
|
6
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
7
|
+
name TEXT NOT NULL,
|
|
8
|
+
redirect_uris TEXT NOT NULL,
|
|
9
|
+
created_at INTEGER NOT NULL
|
|
10
|
+
);
|
|
11
|
+
CREATE INDEX IF NOT EXISTS mcp_clients_created_at_idx ON mcp_clients (created_at);
|
|
12
|
+
|
|
13
|
+
CREATE TABLE IF NOT EXISTS mcp_auth_codes (
|
|
14
|
+
code_hash TEXT NOT NULL PRIMARY KEY,
|
|
15
|
+
client_id TEXT NOT NULL REFERENCES mcp_clients (id) ON DELETE CASCADE,
|
|
16
|
+
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
|
17
|
+
access TEXT NOT NULL,
|
|
18
|
+
redirect_uri TEXT NOT NULL,
|
|
19
|
+
code_challenge TEXT NOT NULL,
|
|
20
|
+
expires_at INTEGER NOT NULL
|
|
21
|
+
);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS mcp_auth_codes_expires_at_idx ON mcp_auth_codes (expires_at);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS mcp_grants (
|
|
25
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
26
|
+
client_id TEXT NOT NULL REFERENCES mcp_clients (id) ON DELETE CASCADE,
|
|
27
|
+
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
|
28
|
+
access TEXT NOT NULL,
|
|
29
|
+
refresh_hash TEXT NOT NULL,
|
|
30
|
+
-- the refresh token this one replaced; seeing it again = reuse = revoke
|
|
31
|
+
prev_refresh_hash TEXT NULL,
|
|
32
|
+
refresh_expires_at INTEGER NOT NULL,
|
|
33
|
+
created_at INTEGER NOT NULL,
|
|
34
|
+
last_used_at INTEGER NULL
|
|
35
|
+
);
|
|
36
|
+
CREATE UNIQUE INDEX IF NOT EXISTS mcp_grants_refresh_hash_unique ON mcp_grants (refresh_hash);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS mcp_grants_prev_refresh_hash_idx ON mcp_grants (prev_refresh_hash);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS mcp_grants_user_id_idx ON mcp_grants (user_id);
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bullpane",
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Self-hosted dashboard for BullMQ and BullMQ Pro: queues, jobs, failures, metrics, Redis health, flows and Pro groups. A bull-board alternative you can run with npx.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bullmq",
|
|
7
|
+
"bullmq-pro",
|
|
8
|
+
"bull",
|
|
9
|
+
"dashboard",
|
|
10
|
+
"ui",
|
|
11
|
+
"queue",
|
|
12
|
+
"redis",
|
|
13
|
+
"jobs",
|
|
14
|
+
"monitoring",
|
|
15
|
+
"bull-board",
|
|
16
|
+
"admin",
|
|
17
|
+
"self-hosted"
|
|
18
|
+
],
|
|
19
|
+
"homepage": "https://bullpane.com",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/madmorett/bullpane.git"
|
|
23
|
+
},
|
|
24
|
+
"bugs": "https://github.com/madmorett/bullpane/issues",
|
|
25
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
26
|
+
"type": "module",
|
|
27
|
+
"bin": {
|
|
28
|
+
"bullpane": "bin/bullpane.mjs"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"bin",
|
|
32
|
+
"dist",
|
|
33
|
+
"migrations",
|
|
34
|
+
"web",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE",
|
|
37
|
+
"LICENSE-ee"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@fastify/cookie": "^11.0.1",
|
|
44
|
+
"@fastify/static": "^8.0.3",
|
|
45
|
+
"@libsql/client": "^0.18.0",
|
|
46
|
+
"@node-saml/node-saml": "^5.1.0",
|
|
47
|
+
"bcryptjs": "^2.4.3",
|
|
48
|
+
"bullmq": "^6.3.4",
|
|
49
|
+
"drizzle-orm": "^0.38.3",
|
|
50
|
+
"fastify": "^5.2.0",
|
|
51
|
+
"ioredis": "^5.4.2",
|
|
52
|
+
"mysql2": "^3.11.5",
|
|
53
|
+
"nanoid": "^5.0.9",
|
|
54
|
+
"zod": "^3.24.1"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
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-DuAopWKm.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-DoC2dCG0.js";/**
|
|
2
|
+
* @license lucide-react v0.469.0 - ISC
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the ISC license.
|
|
5
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/const Ee=ae("Link2",[["path",{d:"M9 17H7A5 5 0 0 1 7 7h2",key:"8i5ue5"}],["path",{d:"M15 7h2a5 5 0 1 1 0 10h-2",key:"1b9ql8"}],["line",{x1:"8",x2:"16",y1:"12",y2:"12",key:"1jonct"}]]),B=220,I=84;function Me(){var i;const{has:t}=$(),o=Z();if(!t("flows"))return e.jsx(G,{feature:"flows"});if(o.isLoading)return e.jsx(_,{children:e.jsx(J,{label:"Loading…"})});const u=(i=o.data)==null?void 0:i[0];return u?e.jsx(ne,{to:N.flows(u.id),replace:!0}):e.jsx(_,{children:e.jsx(A,{icon:e.jsx(O,{}),title:"No connections",description:"Add a Redis connection first; flows are detected per connection."})})}function Ae(){const{has:t}=$();return t("flows")?e.jsx(Le,{}):e.jsx(G,{feature:"flows"})}const Se=c.memo(function({data:o,selected:u}){const{node:i,connectionId:p}=o,m=i.counts;return e.jsxs("div",{className:X("rounded-lg border bg-surface px-3 py-2 text-fg shadow-sm transition-colors",u?"border-accent ring-2 ring-accent/30":"border-border-strong",i.isPaused&&"border-dashed"),style:{width:B,height:I},children:[e.jsx(V,{type:"target",position:Q.Left,className:"!size-2 !border-0 !bg-fg-subtle"}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("span",{className:"truncate text-[13px] font-semibold",title:i.queueName,children:i.queueName}),i.isPaused&&e.jsx(R,{variant:"warning",size:"xs",className:"ml-auto",children:"paused"})]}),e.jsxs("div",{className:"mt-1.5 grid grid-cols-3 gap-1 text-[11px]",children:[e.jsx(M,{label:"waiting",value:m.waiting+m.prioritized,tone:P.waiting.textClass}),e.jsx(M,{label:"active",value:m.active,tone:P.active.textClass}),e.jsx(M,{label:"failed",value:m.failed,tone:m.failed>0?P.failed.textClass:"text-fg-subtle"})]}),e.jsx(K,{to:N.queue(p,i.queueName),className:"absolute top-1.5 right-1.5 rounded p-0.5 text-fg-subtle hover:text-fg",title:"Open queue","aria-label":"Open queue",children:e.jsx(U,{className:"size-3"})}),e.jsx(V,{type:"source",position:Q.Right,className:"!size-2 !border-0 !bg-fg-subtle"})]})});function M({label:t,value:o,tone:u}){return e.jsxs("span",{className:"flex flex-col leading-tight",children:[e.jsx("span",{className:X("num font-semibold",u),children:fe(o)}),e.jsx("span",{className:"text-[10px] text-fg-subtle",children:t})]})}const qe={queue:Se};function ze(t){const o=t.source==="manual";return{id:t.id,source:t.from,target:t.to,type:"smoothstep",data:{edge:t},label:o?t.label??void 0:`×${v(t.evidence)}${t.label?` · ${t.label}`:""}`,labelStyle:{fontSize:10,fill:"var(--fg-muted)"},labelBgStyle:{fill:"var(--surface)",fillOpacity:.9},labelBgPadding:[4,2],labelBgBorderRadius:3,style:o?{stroke:"var(--accent)",strokeDasharray:"6 4",strokeWidth:1.5}:{stroke:"var(--fg-subtle)",strokeWidth:Math.min(4,1+Math.log10(Math.max(1,t.evidence)))},markerEnd:{type:Ce.ArrowClosed,color:o?"var(--accent)":"var(--fg-subtle)",width:16,height:16},animated:!1}}function Le(){var W,T;const{connectionId:t=""}=oe(),o=re(),{isOperator:u}=le(),i=Z(),[p,m]=c.useState(200),n=ie(t,p),w=de(),g=ce(),[q,h,z]=be([]),[y,r,L]=je([]),k=c.useRef(new Map),[d,j]=c.useState(null),[f,C]=c.useState(null),[Y,D]=c.useState(!1);c.useEffect(()=>{const s=n.data;if(!s)return;const l=H(s.nodes.map(a=>({id:a.id})),s.edges.map(a=>({from:a.from,to:a.to})),{nodeWidth:B,nodeHeight:I});h(s.nodes.map(a=>{const x=k.current.get(a.id)??l.get(a.id)??{x:0,y:0};return{id:a.id,type:"queue",position:{x:x.x,y:x.y},data:{node:a,connectionId:t},draggable:!0}})),r(s.edges.map(ze)),j(a=>a?s.edges.find(x=>x.id===a.id)??null:null),C(a=>a?s.nodes.find(x=>x.id===a.id)??null:null)},[n.data,t,h,r]),c.useEffect(()=>{k.current.clear()},[t]);const ee=c.useCallback(()=>{k.current.clear();const s=n.data;if(!s)return;const l=H(s.nodes.map(a=>({id:a.id})),s.edges.map(a=>({from:a.from,to:a.to})),{nodeWidth:B,nodeHeight:I});h(a=>a.map(x=>({...x,position:l.get(x.id)??x.position})))},[n.data,h]),se=((W=n.data)==null?void 0:W.edges.filter(s=>s.source==="detected").length)??0,te=((T=n.data)==null?void 0:T.edges.filter(s=>s.source==="manual").length)??0;return e.jsxs("div",{className:"flex h-full flex-col",children:[e.jsxs("div",{className:"flex flex-wrap items-center gap-2 border-b border-border bg-surface px-4 py-2",children:[e.jsx(O,{className:"size-4 text-pro","aria-hidden":!0}),e.jsx("h1",{className:"text-sm font-semibold",children:"Flows"}),e.jsx(S,{"aria-label":"Connection",className:"!h-7 !w-auto text-xs",value:t,onChange:s=>o(N.flows(s.target.value)),options:(i.data??[]).map(s=>({value:s.id,label:s.name}))}),e.jsx(S,{"aria-label":"Sample size",className:"!h-7 !w-auto text-xs",value:String(p),onChange:s=>m(Number(s.target.value)),options:[50,200,500,1e3].map(s=>({value:String(s),label:`sample ${s} jobs/queue`}))}),e.jsx("span",{className:"text-xs text-fg-muted",children:n.data?e.jsxs(e.Fragment,{children:[v(n.data.nodes.length)," queues · ",se," detected · ",te," manual · sampled ",v(n.data.sampledJobs)," jobs"]}):n.isLoading?e.jsx(J,{label:"Detecting flows…"}):null}),e.jsxs("div",{className:"ml-auto flex items-center gap-2",children:[e.jsx(b,{size:"sm",variant:"ghost",leftIcon:e.jsx(ue,{}),onClick:()=>n.refetch(),loading:n.isFetching,children:"Refresh"}),e.jsx(b,{size:"sm",variant:"ghost",onClick:ee,children:"Re-layout"}),u&&e.jsx(b,{size:"sm",variant:"primary",leftIcon:e.jsx(me,{}),onClick:()=>D(!0),disabled:!n.data||n.data.nodes.length<2,children:"Add edge"})]})]}),e.jsxs("div",{className:"relative min-h-0 flex-1",children:[n.isError&&!n.data&&e.jsx("div",{className:"absolute inset-0 z-10 flex items-center justify-center",children:e.jsx(A,{title:"Could not load flows",description:F(n.error)})}),n.data&&n.data.nodes.length===0&&e.jsx("div",{className:"absolute inset-0 z-10 flex items-center justify-center",children:e.jsx(A,{icon:e.jsx(O,{}),title:"No queues on this connection",description:"Flows are built from the queues discovered on the connection."})}),e.jsxs(ve,{nodes:q,edges:y,nodeTypes:qe,onNodesChange:z,onEdgesChange:L,onNodeDragStop:(s,l)=>k.current.set(l.id,l.position),onEdgeClick:(s,l)=>{var a;j(((a=l.data)==null?void 0:a.edge)??null),C(null)},onNodeClick:(s,l)=>{C(l.data.node),j(null)},onPaneClick:()=>{j(null),C(null)},onNodeDoubleClick:(s,l)=>o(N.queue(t,l.data.node.queueName)),fitView:!0,fitViewOptions:{padding:.2,maxZoom:1},minZoom:.2,maxZoom:1.75,nodesConnectable:!1,proOptions:{hideAttribution:!0},colorMode:"system",className:"bg-bg",children:[e.jsx(Ne,{variant:we.Dots,gap:20,size:1}),e.jsx(ye,{showInteractive:!1,position:"bottom-right"}),e.jsx(ke,{pannable:!0,zoomable:!0,position:"bottom-left",nodeColor:()=>"var(--surface-3)",maskColor:"color-mix(in srgb, var(--bg) 70%, transparent)",style:{width:160,height:100}})]}),e.jsxs("div",{className:"pointer-events-none absolute top-3 left-3 flex flex-col gap-2",children:[e.jsxs("div",{className:"pointer-events-auto rounded-md border border-border bg-surface/95 px-3 py-2 text-[11px] text-fg-muted shadow-sm",children:[e.jsx("div",{className:"mb-1 font-semibold tracking-wider text-fg-subtle uppercase",children:"Legend"}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("svg",{width:"36",height:"8",children:e.jsx("line",{x1:"0",y1:"4",x2:"36",y2:"4",stroke:"var(--fg-subtle)",strokeWidth:"2"})}),"detected · ",e.jsx("span",{className:"font-mono",children:"×n"})," = jobs evidencing it"]}),e.jsxs("div",{className:"mt-1 flex items-center gap-2",children:[e.jsx("svg",{width:"36",height:"8",children:e.jsx("line",{x1:"0",y1:"4",x2:"36",y2:"4",stroke:"var(--accent)",strokeWidth:"1.5",strokeDasharray:"6 4"})}),"manual edge"]}),e.jsxs("div",{className:"mt-1 flex items-center gap-2",children:[e.jsx("span",{className:"inline-block h-3 w-9 rounded border border-dashed border-border-strong"})," paused queue"]})]}),e.jsxs("div",{className:"pointer-events-auto flex max-w-sm items-start gap-2 rounded-md border border-border bg-surface/95 px-3 py-2 text-[11px] text-fg-muted shadow-sm",children:[e.jsx(xe,{className:"mt-px size-3.5 shrink-0 text-info","aria-hidden":!0}),e.jsxs("span",{children:["Detected edges come from BullMQ flows: children carry a ",e.jsx("span",{className:"font-mono",children:"parent"})," reference, so the arrow points from the child queue to the parent that waits for it. A worker that simply calls ",e.jsx("span",{className:"font-mono",children:"otherQueue.add()"})," leaves no trace in Redis; draw those with a manual edge. Double-click a queue to open it."]})]})]}),(d||f)&&e.jsxs("div",{className:"absolute top-3 right-3 w-72 rounded-lg border border-border bg-surface p-3 text-xs shadow-[var(--shadow)]",children:[f&&e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"mb-2 flex items-center justify-between",children:[e.jsx("span",{className:"text-sm font-semibold",children:f.queueName}),f.isPaused&&e.jsx(R,{variant:"warning",size:"xs",children:"paused"})]}),e.jsx("dl",{className:"grid grid-cols-2 gap-x-3 gap-y-1 text-fg-muted",children:Object.entries(f.counts).map(([s,l])=>e.jsxs("div",{className:"flex justify-between border-b border-border/60 py-0.5",children:[e.jsx("dt",{children:s}),e.jsx("dd",{className:"num text-fg",children:v(l)})]},s))}),e.jsxs(K,{to:N.queue(t,f.queueName),className:"mt-3 inline-flex items-center gap-1 text-accent hover:underline",children:["Open queue ",e.jsx(U,{className:"size-3"})]})]}),d&&e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"mb-2 flex items-center gap-2",children:[e.jsx(Ee,{className:"size-4 text-fg-subtle","aria-hidden":!0}),e.jsxs("span",{className:"font-mono text-[13px] font-semibold",children:[d.from," → ",d.to]})]}),e.jsxs("div",{className:"space-y-1 text-fg-muted",children:[e.jsxs("p",{children:["Source: ",e.jsx(R,{variant:d.source==="manual"?"accent":"neutral",size:"xs",children:d.source})]}),d.source==="detected"&&e.jsxs("p",{children:["Evidence: ",v(d.evidence)," sampled jobs referenced this parent."]}),d.label&&e.jsxs("p",{children:["Label: ",d.label]})]}),d.source==="manual"&&u&&e.jsx(b,{size:"sm",variant:"ghost",className:"mt-3 hover:text-danger",leftIcon:e.jsx(ge,{}),loading:g.isPending,onClick:()=>g.mutate(d.id,{onSuccess:()=>(j(null),E.success("Edge removed")),onError:s=>E.error(F(s))}),children:"Delete manual edge"}),d.source==="detected"&&e.jsx("p",{className:"mt-2 text-fg-subtle",children:"Detected edges disappear on their own once no sampled job references the parent."})]})]})]}),n.data&&e.jsx(De,{open:Y,onClose:()=>D(!1),queues:n.data.nodes.map(s=>s.queueName),saving:w.isPending,onSave:(s,l,a)=>w.mutate({connectionId:t,from:s,to:l,label:a||null},{onSuccess:()=>(D(!1),E.success("Edge added")),onError:x=>E.error(F(x))})})]})}function De({open:t,onClose:o,queues:u,onSave:i,saving:p}){const m=c.useMemo(()=>[...u].sort((r,L)=>r.localeCompare(L)),[u]),[n,w]=c.useState(m[0]??""),[g,q]=c.useState(m[1]??""),[h,z]=c.useState(""),y=n===g;return e.jsx(he,{open:t,onClose:o,size:"sm",title:"Add manual edge",description:"Document a producer → consumer relationship that Redis cannot observe.",footer:e.jsxs(e.Fragment,{children:[e.jsx(b,{variant:"ghost",size:"sm",onClick:o,children:"Cancel"}),e.jsx(b,{variant:"primary",size:"sm",disabled:y||!n||!g,loading:p,onClick:()=>i(n,g,h.trim()),children:"Add edge"})]}),children:e.jsxs("div",{className:"space-y-3",children:[e.jsx(S,{label:"From (producer)",value:n,onChange:r=>w(r.target.value),options:m.map(r=>({value:r,label:r}))}),e.jsx(S,{label:"To (consumer)",value:g,onChange:r=>q(r.target.value),options:m.map(r=>({value:r,label:r})),error:y?"Pick two different queues":void 0}),e.jsx(pe,{label:"Label (optional)",value:h,onChange:r=>z(r.target.value),maxLength:120,placeholder:"e.g. enqueues on success"})]})})}export{Me as FlowsIndexPage,Ae as FlowsPage};
|