@andrian.yablonskyy/thub-coordinator 1.0.43 → 1.1.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/bin/thub-admin.js CHANGED
@@ -34,14 +34,24 @@ const { loadConfig } = require('../src/config'),
34
34
  { createCleanupService } = require('../src/services/cleanup'),
35
35
  { generateToken } = require('../src/services/tokens'),
36
36
  { createSettingsService } = require('../src/services/settings'),
37
+ { roleLabel, normalizeRole } = require('../src/services/admin-users'),
37
38
  { PACKAGES, fetchLatestVersion, isNewer, isValidVersion, npmBin, formatDateTime, parseDateTime } = require('@andrian.yablonskyy/thub-common'),
38
39
  { spawnSync } = require('node:child_process'),
39
40
  { version: installedVersion } = require('../package.json');
40
41
 
41
42
  function usage(){
42
43
  console.log(`Usage:
43
- thub-admin create-admin <username> <password> [--role admin|viewer]
44
- thub-admin agent add <name> --kind ci|cli
44
+ thub-admin user add <username> --email <email> [--role user|maintainer|admin] [--password <p>] [--key]
45
+ New user (§10.3). Maintainers/admins without --password get a
46
+ temporary one (printed once); --key also creates their access key
47
+ thub-admin user list
48
+ thub-admin user block|unblock <username>
49
+ thub-admin user role <username> user|maintainer|admin
50
+ thub-admin user password <username> <password> Set a password (no change required)
51
+ thub-admin user key <username> New access key for them (printed once; the old one stops)
52
+ thub-admin user delete <username>
53
+ thub-admin create-admin <username> <password> [--role admin|maintainer] (= user add, no email)
54
+ thub-admin agent add <name> A CI token (pipelines; people get their key with: user key)
45
55
  thub-admin join-key generate
46
56
  thub-admin resource maintenance <resourceId> --on|--off
47
57
  thub-admin jobs reset --yes Cancel every queued/assigned/preparing/running job
@@ -138,7 +148,7 @@ function main(){
138
148
  events = createEventsService(db),
139
149
  registry = createRegistryService(db, { bus, events }),
140
150
  agents = createAgentsService(db, { events }),
141
- adminUsers = createAdminUsersService(db),
151
+ adminUsers = createAdminUsersService(db, { events }),
142
152
  jobs = createJobsService(db, { bus, events, registry, config }),
143
153
  groups = createGroupsService(db, { events, registry });
144
154
 
@@ -159,6 +169,83 @@ function main(){
159
169
  return;
160
170
  }
161
171
 
172
+ // Users (§10.3) — also the way back in when nobody can sign in: unblock or
173
+ // re-role an admin, set a password. Run as the Coordinator's user.
174
+ if (cmd === 'user'){
175
+ const { positional, flags } = parseFlags(rest),
176
+ [username, arg] = positional,
177
+ by = 'thub-admin',
178
+ need = (u) => {
179
+ const row = adminUsers.getByUsername(u || '');
180
+ if (!row){
181
+ console.error(`No user "${u}"`);
182
+ process.exit(4);
183
+ }
184
+ return row;
185
+ };
186
+ try {
187
+ if (sub === 'list'){
188
+ for (const u of adminUsers.list()){
189
+ const key = u.key ? `…${u.key.hint || '????'}` : 'none';
190
+ console.log(`${u.username.padEnd(24)} ${roleLabel(u.role).padEnd(11)} ${u.blocked ? 'blocked' : 'active '} ${u.email || '(no email)'} key: ${key}`);
191
+ }
192
+ return;
193
+ }
194
+ if (sub === 'add'){
195
+ if (!username || !flags.email){
196
+ return usage(), process.exit(4);
197
+ }
198
+ const created = adminUsers.create({ username, email: flags.email, role: flags.role || 'user', password: flags.password, requireEmail: true }, { by });
199
+ console.log(`Created ${roleLabel(created.role)} "${created.username}"`);
200
+ if (created.tempPassword){
201
+ console.log(`Temporary password (shown once; to change at the first sign-in): ${created.tempPassword}`);
202
+ }
203
+ if (rest.includes('--key')){
204
+ console.log(`Access key (shown once): ${adminUsers.issueKey(created.id, { by })}`);
205
+ }
206
+ return;
207
+ }
208
+ const target = need(username);
209
+ if (sub === 'block' || sub === 'unblock'){
210
+ adminUsers.setBlocked(target.id, sub === 'block', { by });
211
+ console.log(`${sub === 'block' ? 'Blocked' : 'Unblocked'} "${target.username}"`);
212
+ }
213
+ else if (sub === 'role'){
214
+ const { tempPassword } = adminUsers.update(target.id, { role: arg }, { by });
215
+ console.log(`"${target.username}" is now ${roleLabel(normalizeRole(arg))}`);
216
+ if (tempPassword){
217
+ console.log(`Temporary password (shown once): ${tempPassword}`);
218
+ }
219
+ }
220
+ else if (sub === 'password'){
221
+ if (!arg){
222
+ return usage(), process.exit(4);
223
+ }
224
+ adminUsers.resetPassword({ username: target.username, password: arg });
225
+ console.log(`Password of "${target.username}" set`);
226
+ }
227
+ else if (sub === 'key'){
228
+ console.log(`Access key for "${target.username}" (shown once; any previous one has stopped working): ${adminUsers.issueKey(target.id, { by })}`);
229
+ }
230
+ else if (sub === 'delete'){
231
+ if (!flags.yes){
232
+ console.error(`This deletes "${target.username}" for good (their jobs stay). Re-run with --yes to confirm.`);
233
+ process.exit(4);
234
+ }
235
+ adminUsers.remove(target.id, { by });
236
+ console.log(`Deleted "${target.username}"`);
237
+ }
238
+ else {
239
+ return usage(), process.exit(4);
240
+ }
241
+ }
242
+ catch (err){
243
+ console.error(`Error: ${err.message}`);
244
+ process.exit(1);
245
+ }
246
+ return;
247
+ }
248
+
162
249
  if (cmd === 'create-admin'){
163
250
  const { positional, flags } = parseFlags([sub, ...rest].filter(Boolean)),
164
251
  [username, password] = positional;
@@ -166,18 +253,22 @@ function main(){
166
253
  return usage(), process.exit(4);
167
254
  }
168
255
  adminUsers.create({ username, password, role: flags.role || 'admin' });
169
- console.log(`Created ${flags.role || 'admin'} user "${username}"`);
256
+ console.log(`Created ${roleLabel(normalizeRole(flags.role || 'admin'))} user "${username}"`);
170
257
  return;
171
258
  }
172
259
 
173
260
  if (cmd === 'agent' && sub === 'add'){
174
261
  const { positional, flags } = parseFlags(rest),
175
262
  [name] = positional;
176
- if (!name || !flags.kind){
263
+ if (!name){
177
264
  return usage(), process.exit(4);
178
265
  }
179
- const { agent, token } = agents.create({ name, kind: flags.kind });
180
- console.log(`Agent ${agent.id} created. Token (shown once): ${token}`);
266
+ if (flags.kind && flags.kind !== 'ci'){
267
+ console.error('Agent tokens are for CI only now; a person gets their access key with: thub-admin user key <username>');
268
+ process.exit(4);
269
+ }
270
+ const { agent, token } = agents.create({ name, kind: 'ci' });
271
+ console.log(`CI token ${agent.id} created. Token (shown once): ${token}`);
181
272
  return;
182
273
  }
183
274
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.0.43",
3
+ "version": "1.1.1",
4
4
  "description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
5
5
  "main": "src/server.js",
6
6
  "engines": {
@@ -19,7 +19,7 @@
19
19
  "lint:fix": "eslint . --fix"
20
20
  },
21
21
  "dependencies": {
22
- "@andrian.yablonskyy/thub-common": "^1.0.33",
22
+ "@andrian.yablonskyy/thub-common": "^1.1.1",
23
23
  "better-sqlite3": "^12.4.1",
24
24
  "express": "^5.1.0",
25
25
  "express-session": "^1.18.2",
@@ -86,17 +86,33 @@
86
86
  padding: 0.45rem 0.65rem;
87
87
  }
88
88
 
89
- /* Bootstrap hides inactive tab panes only as direct children of
90
- .tab-content (`.tab-content > .tab-pane`). The client details modal's
91
- capability tabs sit inside their shared <form>/<fieldset> (one Save for
92
- all of them), so without this the inactive ones merely fade out and still
93
- take up space, pushing the active tab's fields down. */
94
- .tab-content .tab-pane[data-config-pane] {
95
- display: none;
89
+ /* The client details modal keeps one size whichever tab is open — the
90
+ tallest tab's — instead of growing and shrinking (and, centered, jumping)
91
+ on every switch. Every pane sits in the same grid cell, so the tab area is
92
+ as tall as the tallest; inactive ones are hidden with `visibility`, which
93
+ keeps their height (and still takes them out of the tab order and of what
94
+ screen readers read). The device tabs sit inside their shared
95
+ <form>/<fieldset> (one Save for all of them), so that fieldset stacks its
96
+ panes the same way. The USB tab's output scrolls in its own box (360 px),
97
+ so a long lsusb doesn't make every tab that tall. */
98
+ .thub-resource-card .tab-content,
99
+ .thub-resource-card .tab-content > form > fieldset {
100
+ display: grid;
101
+ }
102
+
103
+ .thub-resource-card .tab-content > *,
104
+ .thub-resource-card .tab-content > form > fieldset > * {
105
+ grid-area: 1 / 1;
106
+ min-width: 0;
96
107
  }
97
108
 
98
- .tab-content .tab-pane[data-config-pane].active {
109
+ .thub-resource-card .tab-content .tab-pane {
99
110
  display: block;
111
+ visibility: hidden;
112
+ }
113
+
114
+ .thub-resource-card .tab-content .tab-pane.active {
115
+ visibility: visible;
100
116
  }
101
117
 
102
118
  /* Help page (views/help/): sticky table of contents, code blocks with a
@@ -233,9 +249,6 @@
233
249
  itself on short screens, and is laid out for thumbs: pages as a two-column
234
250
  grid, the tools in one row, the account in the next. public/js/actions.js
235
251
  closes it on a tap outside, Esc, or choosing a page. */
236
- .thub-nav-break {
237
- display: none;
238
- }
239
252
 
240
253
  @media (max-width: 991.98px) {
241
254
  .navbar .thub-nav {
@@ -295,15 +308,19 @@
295
308
  min-height: 2.5rem;
296
309
  }
297
310
 
298
- /* The account row: the profile link takes the line, sign-out at its end. */
299
- .thub-nav-break {
300
- display: block;
301
- flex-basis: 100%;
302
- height: 0;
311
+ /* The account row, first in the menu: you, your role, your profile, and
312
+ sign-out at its end. */
313
+ .thub-nav-me {
314
+ display: flex;
315
+ align-items: center;
316
+ gap: 0.75rem;
317
+ padding-bottom: 0.75rem;
318
+ margin-bottom: 0.75rem;
319
+ border-bottom: 1px solid var(--bs-border-color);
303
320
  }
304
321
 
305
- .thub-nav-account {
306
- flex: 1 1 auto;
322
+ .thub-nav-me .btn {
323
+ min-width: 2.5rem;
307
324
  min-height: 2.5rem;
308
325
  }
309
326
  }
@@ -30,6 +30,16 @@
30
30
  }
31
31
  });
32
32
 
33
+ // <select data-describe="#hint">: show the chosen option's data-help in
34
+ // #hint (e.g. what a role allows, under the role picker).
35
+ document.addEventListener('change', (e) => {
36
+ const select = e.target.closest?.('select[data-describe]'),
37
+ hint = select && document.querySelector(select.dataset.describe);
38
+ if (hint){
39
+ hint.textContent = select.selectedOptions[0]?.dataset.help || '';
40
+ }
41
+ });
42
+
33
43
  // <button data-fill="#field" data-value="…">: put the value into #field.
34
44
  document.addEventListener('click', (e) => {
35
45
  const btn = e.target.closest('[data-fill]'),
@@ -34,7 +34,6 @@
34
34
  return compact({
35
35
  ...extra,
36
36
  ...device(text(tr, 'device')),
37
- serial: text(tr, 'serial'),
38
37
  baudRate: num(tr, 'baudRate'),
39
38
  devpath: text(tr, 'devpath')
40
39
  });
package/src/api/agent.js CHANGED
@@ -26,14 +26,17 @@ function createAgentRouter({ services, config }){
26
26
  // matching even happens, and reject them for lacking an agent token.
27
27
  auth = requireRole('agent'),
28
28
 
29
- // Which jobs an agent may see (README §12): a `ci` token (pipelines) all
30
- // of them; a `cli` token (a person's own `thub`) only the jobs it
31
- // submitted. Anyone else's job is answered exactly like a job that
32
- // doesn't exist (404), so a cli token can't tell which ids are in use.
33
- canSee = (agent, job) => agent.kind === 'ci' || job.agent_id === agent.id,
29
+ // Which jobs a key may see (README §12, §10.3): a CI token all of them;
30
+ // a user's key all of them for maintainers and admins, only their own
31
+ // for the `user` role. Anyone else's job is answered exactly like a job
32
+ // that doesn't exist (404), so a user can't tell which ids are in use.
33
+ seesAll = (req) => req.agent.kind === 'ci' || ['maintainer', 'admin'].includes(req.user?.role),
34
+ canSee = (req, job) => seesAll(req) || job.agent_id === req.agent.id,
35
+ // Cancelling: maintainers and admins any job; anyone else their own.
36
+ cancelsAll = (req) => ['maintainer', 'admin'].includes(req.user?.role),
34
37
  visibleJob = (req, res, next) => {
35
38
  const job = services.jobs.get(req.params.id);
36
- if (!job || !canSee(req.agent, job)){
39
+ if (!job || !canSee(req, job)){
37
40
  return res.status(404).json({ error: 'Unknown job' });
38
41
  }
39
42
  req.job = job;
@@ -68,8 +71,8 @@ function createAgentRouter({ services, config }){
68
71
  state: req.query.state,
69
72
  source: req.query.source,
70
73
  agentId: req.agent.id,
71
- // A cli token's list is always its own jobs (canSee above).
72
- mine: req.agent.kind !== 'ci' || req.query.mine === 'true' || req.query.mine === '1',
74
+ // A `user`'s list is always their own jobs (canSee above).
75
+ mine: !seesAll(req) || req.query.mine === 'true' || req.query.mine === '1',
73
76
  limit: req.query.limit ? Number(req.query.limit) : undefined
74
77
  });
75
78
  res.json({ jobs: jobs.map(services.jobs.publicJob) });
@@ -77,7 +80,7 @@ function createAgentRouter({ services, config }){
77
80
 
78
81
  router.post('/jobs/:id/cancel', auth, visibleJob, (req, res, next) => {
79
82
  try {
80
- const job = services.jobs.cancel(req.params.id, { agentId: req.agent.id, isAdmin: false });
83
+ const job = services.jobs.cancel(req.params.id, { agentId: req.agent.id, isAdmin: cancelsAll(req) });
81
84
  res.json(services.jobs.publicJob(job));
82
85
  }
83
86
  catch (err){
@@ -101,6 +104,30 @@ function createAgentRouter({ services, config }){
101
104
  res.json({ artifacts: req.job.artifacts.map((a) => ({ ...a, url: a.link })) });
102
105
  });
103
106
 
107
+ // `thub whoami` / `thub key show` (§10.3): who this key belongs to.
108
+ router.get('/me', auth, (req, res) => {
109
+ if (!req.user){
110
+ return res.json({ kind: req.agent.kind, name: req.agent.name });
111
+ }
112
+ const { username, email, role } = req.user,
113
+ key = services.adminUsers.keyOf(req.user.id);
114
+ res.json({
115
+ kind: req.agent.kind,
116
+ user: { username, email, role },
117
+ key: { hint: key.token_hint, createdAt: key.token_created_at || key.created_at, lastUsedAt: key.last_used_at }
118
+ });
119
+ });
120
+
121
+ // `thub key rotate`: a user replaces their own key — the one sending this
122
+ // stops working at once, the new one is in the answer (only there).
123
+ // A CI token is replaced by an admin, on the dashboard.
124
+ router.post('/me/key/rotate', auth, (req, res) => {
125
+ if (!req.user){
126
+ return res.status(403).json({ error: 'A CI token is replaced by an admin on the dashboard (CI tokens)' });
127
+ }
128
+ res.json({ key: services.adminUsers.issueKey(req.user.id, { by: req.user.username }), username: req.user.username });
129
+ });
130
+
104
131
  router.get('/resources', auth, (req, res) => {
105
132
  res.json({ resources: services.registry.list().map(publicResource) });
106
133
  });
package/src/auth.js CHANGED
@@ -36,11 +36,20 @@ function requireRole(role){
36
36
  const tokenHash = hashToken(token);
37
37
 
38
38
  if (role === 'agent'){
39
- const agent = req.app.locals.services.agents.getByTokenHash(tokenHash);
39
+ const { agents, adminUsers } = req.app.locals.services,
40
+ agent = agents.getByTokenHash(tokenHash);
40
41
  if (!agent){
41
- return res.status(401).json({ error: 'Invalid or revoked agent token' });
42
+ return res.status(401).json({ error: 'Invalid or revoked access key' });
42
43
  }
43
- req.app.locals.services.agents.touchLastUsed(agent.id, appVersion(req, 'agent'));
44
+ // A user's access key (§10.3) works only while its user may use it.
45
+ if (agent.user_id){
46
+ const user = adminUsers.getById(agent.user_id);
47
+ if (!user || user.blocked){
48
+ return res.status(401).json({ error: user ? `User ${user.username} is blocked — ask an admin` : 'Invalid or revoked access key' });
49
+ }
50
+ req.user = user;
51
+ }
52
+ agents.touchLastUsed(agent.id, appVersion(req, 'agent'));
44
53
  req.agent = agent;
45
54
  return next();
46
55
  }
@@ -81,19 +90,43 @@ function requireJoinKey(config){
81
90
  };
82
91
  }
83
92
 
93
+ // Dashboard session (§10.3): the user is read again on every request, so
94
+ // blocking, deleting or demoting someone to `user` ends their session at
95
+ // their next click, and a role change applies at once. A temporary password
96
+ // must be changed (on the profile page) before anything else.
97
+ const MUST_CHANGE_ALLOWED = ['/profile', '/profile/password', '/logout', '/live'];
84
98
  function requireAdminSession(req, res, next){
85
- if (!req.session?.user){
99
+ const fresh = req.session?.user && req.app.locals.services.adminUsers.getById(req.session.user.id);
100
+ if (!fresh || fresh.blocked || !fresh.canUseDashboard){
101
+ const reason = !req.session?.user ? null
102
+ : !fresh ? 'Your account no longer exists.'
103
+ : fresh.blocked ? 'Your account is blocked — ask an admin.' : 'Your account has no dashboard access — use the Agent with your access key.';
104
+ if (req.session?.user){
105
+ req.session.user = null;
106
+ if (reason){
107
+ req.session.flash = [{ type: 'danger', text: reason }];
108
+ }
109
+ }
86
110
  if (req.accepts('html')){
87
111
  return res.redirect('/login');
88
112
  }
89
- return res.status(401).json({ error: 'Login required' });
113
+ return res.status(401).json({ error: reason || 'Login required' });
114
+ }
115
+ req.session.user = fresh;
116
+ res.locals.user = fresh;
117
+ if (fresh.mustChangePassword && !MUST_CHANGE_ALLOWED.includes(req.path) && req.method === 'GET'){
118
+ return res.redirect('/profile');
90
119
  }
91
120
  next();
92
121
  }
93
122
 
123
+ // The dashboard's security features (§10.3): Users, Settings, CI tokens…
94
124
  function requireAdminRole(req, res, next){
95
125
  if (req.session?.user?.role !== 'admin'){
96
- return res.status(403).json({ error: 'Admin role required' });
126
+ const text = 'Only admins can open this — it\'s one of the Coordinator\'s security features.';
127
+ return req.originalUrl.startsWith('/api/') || !req.accepts('html')
128
+ ? res.status(403).json({ error: text })
129
+ : res.status(403).type('text/plain').send(text);
97
130
  }
98
131
  next();
99
132
  }
@@ -0,0 +1,74 @@
1
+ -- User management (README §10.3): one account table for everyone. Roles:
2
+ -- user submits jobs with the Agent and reads its results; no dashboard
3
+ -- maintainer user + the dashboard, without its security features
4
+ -- admin everything
5
+ -- Each user may hold one access key: an `agents` row linked by user_id
6
+ -- (jobs already reference agents, so a user's job history stays attached
7
+ -- across key rotations). CI tokens stay agents rows without a user.
8
+ --
9
+ -- admin_users becomes users: SQLite can't change its role CHECK in place.
10
+ CREATE TABLE users (
11
+ id TEXT PRIMARY KEY,
12
+ username TEXT NOT NULL UNIQUE,
13
+ email TEXT NOT NULL DEFAULT '',
14
+ -- NULL: no dashboard sign-in (role user, or a converted agent token).
15
+ password_hash TEXT,
16
+ role TEXT NOT NULL CHECK (role IN ('user', 'maintainer', 'admin')),
17
+ first_name TEXT,
18
+ last_name TEXT,
19
+ avatar_path TEXT,
20
+ timezone TEXT NOT NULL DEFAULT 'UTC',
21
+ theme TEXT NOT NULL DEFAULT 'auto' CHECK (theme IN ('auto', 'light', 'dark')),
22
+ session_timeout_min INTEGER NOT NULL DEFAULT 60,
23
+ list_prefs TEXT,
24
+ must_change_password INTEGER NOT NULL DEFAULT 0,
25
+ blocked_at TEXT,
26
+ created_at TEXT NOT NULL
27
+ );
28
+
29
+ -- Dashboard accounts: admin stays admin; viewer becomes maintainer.
30
+ INSERT INTO users (id, username, email, password_hash, role, first_name, last_name, avatar_path, timezone, theme,
31
+ session_timeout_min, list_prefs, created_at)
32
+ SELECT id, username, '', password_hash, CASE role WHEN 'viewer' THEN 'maintainer' ELSE role END,
33
+ first_name, last_name, avatar_path, timezone, theme, session_timeout_min, list_prefs, created_at
34
+ FROM admin_users;
35
+
36
+ DROP TABLE admin_users;
37
+
38
+ -- Every developer (cli) agent token becomes a user that owns it, so each
39
+ -- existing key keeps working. Its name becomes the username — suffixed
40
+ -- where that's taken (a dashboard account, an earlier token of the same
41
+ -- name). A revoked token becomes a blocked user, kept for its job history.
42
+ INSERT INTO users (id, username, email, password_hash, role, blocked_at, created_at)
43
+ SELECT 'usr_' || substr(a.id, 5),
44
+ a.name || CASE
45
+ WHEN EXISTS (SELECT 1 FROM users u WHERE u.username = a.name)
46
+ OR EXISTS (SELECT 1 FROM agents b WHERE b.kind = 'cli' AND b.name = a.name AND (b.created_at < a.created_at OR (b.created_at = a.created_at AND b.id < a.id)))
47
+ THEN '-' || substr(a.id, 5, 6) ELSE '' END,
48
+ '', NULL, 'user', a.revoked_at, a.created_at
49
+ FROM agents a
50
+ WHERE a.kind = 'cli';
51
+
52
+ ALTER TABLE agents ADD COLUMN user_id TEXT REFERENCES users(id);
53
+ -- The last characters of the key, to recognize it by (keys are stored hashed).
54
+ ALTER TABLE agents ADD COLUMN token_hint TEXT;
55
+ ALTER TABLE agents ADD COLUMN token_created_at TEXT;
56
+ UPDATE agents SET user_id = 'usr_' || substr(id, 5) WHERE kind = 'cli';
57
+
58
+ CREATE UNIQUE INDEX idx_agents_user ON agents(user_id) WHERE user_id IS NOT NULL;
59
+ CREATE UNIQUE INDEX idx_users_email ON users(lower(email)) WHERE email <> '';
60
+
61
+ -- The audit log gets user events (created, blocked, role changed, key
62
+ -- rotated…). Same rebuild as 006: nothing has a foreign key into events.
63
+ CREATE TABLE events_new (
64
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
65
+ ts TEXT NOT NULL,
66
+ entity TEXT NOT NULL CHECK (entity IN ('job', 'resource', 'agent', 'group', 'user')),
67
+ entity_id TEXT NOT NULL,
68
+ type TEXT NOT NULL,
69
+ data TEXT
70
+ );
71
+ INSERT INTO events_new (id, ts, entity, entity_id, type, data) SELECT id, ts, entity, entity_id, type, data FROM events;
72
+ DROP TABLE events;
73
+ ALTER TABLE events_new RENAME TO events;
74
+ CREATE INDEX idx_events_entity ON events(entity, entity_id);
package/src/server.js CHANGED
@@ -68,7 +68,7 @@ function buildServices(config){
68
68
  registry = createRegistryService(db, { bus, events }),
69
69
  agents = createAgentsService(db, { events }),
70
70
  groups = createGroupsService(db, { events, registry }),
71
- adminUsers = createAdminUsersService(db),
71
+ adminUsers = createAdminUsersService(db, { events }),
72
72
  commands = createCommandsService({ bus }),
73
73
  logs = createLogsService(db, { bus }),
74
74
  jobs = createJobsService(db, { bus, events, registry, config }),