@andrian.yablonskyy/thub-coordinator 1.0.0

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.
Files changed (53) hide show
  1. package/README.md +157 -0
  2. package/bin/thub-admin.js +190 -0
  3. package/config.json +30 -0
  4. package/eslint.config.js +115 -0
  5. package/package.json +32 -0
  6. package/public/css/thub.css +6 -0
  7. package/public/js/copy-to-clipboard.js +36 -0
  8. package/public/js/log-viewer.js +76 -0
  9. package/public/js/profile-theme.js +28 -0
  10. package/public/js/theme.js +51 -0
  11. package/public/js/tooltips.js +18 -0
  12. package/src/api/admin.js +112 -0
  13. package/src/api/agent.js +110 -0
  14. package/src/api/resource.js +211 -0
  15. package/src/api/sse.js +96 -0
  16. package/src/auth.js +94 -0
  17. package/src/config.js +110 -0
  18. package/src/db/index.js +61 -0
  19. package/src/db/migrations/001_init.sql +104 -0
  20. package/src/db/migrations/003_split_job_id_counters.sql +5 -0
  21. package/src/db/migrations/004_add_client_id.sql +8 -0
  22. package/src/db/migrations/005_add_groups.sql +14 -0
  23. package/src/db/migrations/006_events_allow_group.sql +20 -0
  24. package/src/db/migrations/007_add_user_profile.sql +10 -0
  25. package/src/server.js +179 -0
  26. package/src/services/admin-users.js +174 -0
  27. package/src/services/agents.js +56 -0
  28. package/src/services/artifacts.js +149 -0
  29. package/src/services/bus.js +23 -0
  30. package/src/services/commands.js +43 -0
  31. package/src/services/events.js +37 -0
  32. package/src/services/groups.js +79 -0
  33. package/src/services/heartbeat.js +56 -0
  34. package/src/services/jobs.js +355 -0
  35. package/src/services/logs.js +70 -0
  36. package/src/services/registry.js +347 -0
  37. package/src/services/retention.js +44 -0
  38. package/src/services/scheduler.js +101 -0
  39. package/src/services/tokens.js +28 -0
  40. package/src/web/routes.js +343 -0
  41. package/test/admin-users.test.js +127 -0
  42. package/test/scheduler.test.js +375 -0
  43. package/views/admin/agents.pug +53 -0
  44. package/views/groups/list.pug +94 -0
  45. package/views/index.pug +37 -0
  46. package/views/jobs/list.pug +45 -0
  47. package/views/jobs/show.pug +42 -0
  48. package/views/layout.pug +73 -0
  49. package/views/login.pug +16 -0
  50. package/views/mixins/log-viewer.pug +14 -0
  51. package/views/mixins/status-badge.pug +7 -0
  52. package/views/profile.pug +77 -0
  53. package/views/resources/list.pug +65 -0
package/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # @andrian.yablonskyy/thub-coordinator
2
+
3
+ The Coordinator component of [TestHub](https://github.com/andrianyablonskyy/thub) — a self-hosted job network that lets CI/CD pipelines and individual developers run firmware tests on real hardware or emulators in a private lab. The Coordinator owns the job queue, the resource registry, the scheduler, log/artifact storage and the web dashboard. It's the one component you self-host on a reachable server; the [Agent](https://github.com/andrianyablonskyy/thub-agent) and [Client](https://github.com/andrianyablonskyy/thub-client) both talk to it over HTTPS and don't need to be anywhere near it.
4
+
5
+ See the [main TestHub repo](https://github.com/andrianyablonskyy/thub) for the full system architecture, deployment diagrams, and how this fits together with the Agent and Client.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @andrian.yablonskyy/thub-coordinator
11
+ ```
12
+
13
+ Or clone this repo directly and run it from source (see below).
14
+
15
+ ## Configuration
16
+
17
+ The Coordinator loads a **JSON** config file (no YAML support here, unlike the Client). Resolution order, first match wins: `THUB_COORDINATOR_CONFIG` env var path → the bundled `config.json` default. There is no other automatic fallback path — a real deployment always sets `THUB_COORDINATOR_CONFIG` explicitly.
18
+
19
+ ```json
20
+ {
21
+ "listen": "127.0.0.1:8080",
22
+ "publicUrl": "https://thub.example.com",
23
+ "dataDir": "/var/lib/thub",
24
+ "sessionSecret": "change-me-to-a-long-random-string",
25
+ "clientJoinKey": "change-me-to-a-long-random-string",
26
+ "heartbeat": { "intervalSec": 10, "missedLimit": 3, "sweepIntervalSec": 5 },
27
+ "scheduler": { "assignAckTimeoutSec": 15, "requeueOnLost": true, "maxQueuedPerAgent": 20, "tickIntervalSec": 10 },
28
+ "jobs": { "defaultTimeoutSec": 1800, "maxTimeoutSec": 14400 },
29
+ "retention": { "logRetentionDays": 14, "artifactRetentionDays": 30 },
30
+ "artifacts": { "maxUploadMb": 512, "linkTtlHours": 168 }
31
+ }
32
+ ```
33
+
34
+ `sessionSecret` signs the dashboard's session cookie and the HMAC on artifact download links; `clientJoinKey` is the shared secret Clients self-register with — omit or leave `null` to disable auto-registration entirely. Individual `THUB_LISTEN` / `THUB_PUBLIC_URL` / `THUB_DATA_DIR` / `THUB_SESSION_SECRET` / `THUB_CLIENT_JOIN_KEY` env vars override whatever the file set.
35
+
36
+ ## Running it
37
+
38
+ ```bash
39
+ # First run: no admin_users row exists yet, so either set a bootstrap
40
+ # password (creates user "admin") or use thub-admin afterwards.
41
+ THUB_COORDINATOR_CONFIG=/etc/thub/coordinator.json \
42
+ THUB_BOOTSTRAP_ADMIN_PASSWORD=correct-horse-battery-staple \
43
+ node src/server.js
44
+ # -> Reset password for admin user "admin" from THUB_BOOTSTRAP_ADMIN_PASSWORD
45
+ # -> TestHub Coordinator listening on http://127.0.0.1:8080
46
+ ```
47
+
48
+ `THUB_BOOTSTRAP_ADMIN_PASSWORD` is more than a first-run convenience — it's an **exceptional password reset**, checked on every startup, not only when `admin_users` is empty. Whenever it's set, the named account (`THUB_BOOTSTRAP_ADMIN_USER`, default `admin`) has its password forced to it — creating that user as `admin` if it doesn't exist yet, or just resetting the password (never the role) if it does. With it unset, login uses whatever's already in the DB, as normal — unset it again once you're back in, or every subsequent restart re-applies it.
49
+
50
+ Or just `npm start` / `npm run dev` (auto-restart on change) against the bundled `config.json` default for local development.
51
+
52
+ ## `thub-admin` — the local operator CLI
53
+
54
+ Talks to the Coordinator's SQLite database directly — no running server required, and no HTTP auth of its own, so it's meant to be run on the Coordinator host itself:
55
+
56
+ ```bash
57
+ # Create additional dashboard users (first one can also come from
58
+ # THUB_BOOTSTRAP_ADMIN_PASSWORD above).
59
+ node bin/thub-admin.js create-admin alice s3cret --role admin
60
+
61
+ # Register a CI or developer identity — the only credential still issued
62
+ # by an admin; the token is shown once.
63
+ node bin/thub-admin.js agent add ci-firmware --kind ci
64
+ # -> Agent agt_... created. Token (shown once): agt_...
65
+
66
+ # Mint the shared secret Clients use to self-register, and put it in both
67
+ # the Coordinator's clientJoinKey and every Client's joinKey.
68
+ node bin/thub-admin.js join-key generate
69
+
70
+ # Take a resource out of rotation without an active job (or bring it back).
71
+ node bin/thub-admin.js resource maintenance res_abc123 --on
72
+
73
+ # Cancel every queued/assigned/preparing/running job.
74
+ node bin/thub-admin.js jobs reset --yes
75
+
76
+ # Permanently delete every finished job, plus its logs and artifacts on disk.
77
+ node bin/thub-admin.js jobs clean --yes
78
+
79
+ # Resource groups: constrain which resources a job can schedule onto.
80
+ node bin/thub-admin.js group add ci-nightly --comment "shared CI pool"
81
+ node bin/thub-admin.js group list
82
+ node bin/thub-admin.js group remove <groupId>
83
+ ```
84
+
85
+ Both `jobs reset` and `jobs clean` refuse to run without `--yes` — there's no undo for either, especially `clean`.
86
+
87
+ ## API
88
+
89
+ All endpoints are under `/api/v1`, JSON, and require `Authorization: Bearer <token>` (the dashboard is a separate session-based auth path).
90
+
91
+ **Agent endpoints** (used by [thub-agent](https://github.com/andrianyablonskyy/thub-agent)):
92
+
93
+ | Method | Path | Description |
94
+ |---|---|---|
95
+ | `POST` | `/jobs` | Submit a job spec. Returns `201 {jobId, state, webUrl}`. |
96
+ | `GET` | `/jobs/:id` | Job details and current state. |
97
+ | `GET` | `/jobs?state=&source=&limit=` | List jobs. |
98
+ | `POST` | `/jobs/:id/cancel` | Cancel a job (owner or admin). |
99
+ | `GET` | `/jobs/:id/logs?after=<seq>&limit=` | Paged log lines (polling fallback). |
100
+ | `GET` | `/jobs/:id/logs/stream` | Server-Sent Events: `log`, `state`, `end`. Honors `Last-Event-ID` for resume. |
101
+ | `GET` | `/jobs/:id/artifacts` | Artifact list with signed download URLs. |
102
+ | `GET` | `/resources` | Resources with status (read-only). |
103
+
104
+ **Resource (Client) endpoints** (used by [thub-client](https://github.com/andrianyablonskyy/thub-client)):
105
+
106
+ | Method | Path | Description |
107
+ |---|---|---|
108
+ | `POST` | `/resources/register` | Self-register (every start/restart) using the shared `clientJoinKey`; upserts by `clientId`. |
109
+ | `POST` | `/resources/:id/heartbeat` | Heartbeat and self-reported state; response may carry `commands[]`. |
110
+ | `POST` | `/resources/:id/status` | Explicit status change, e.g. a local lock. |
111
+ | `GET` | `/resources/:id/jobs/next?wait=30` | Long-poll for an assigned job; `204` when nothing arrived within `wait` seconds. |
112
+ | `POST` | `/jobs/:id/accept` | Acknowledge assignment. |
113
+ | `POST` | `/jobs/:id/state` | Progress: `PREPARING`, `RUNNING`, plus optional `message`. |
114
+ | `POST` | `/jobs/:id/logs` | Batch of log lines. |
115
+ | `POST` | `/jobs/:id/artifacts` | Multipart upload of result files. |
116
+ | `POST` | `/jobs/:id/result` | Final verdict `{state, exitCode, summary}`. |
117
+
118
+ **Admin endpoints** (dashboard-session-authenticated, not bearer tokens): `/admin/agents`, `/admin/resources/:id/maintenance`, `/admin/resources/:id/rotate-token`, `/admin/jobs/reset-queue`, `/admin/jobs/clean-history`, `/admin/groups`.
119
+
120
+ ## Web dashboard
121
+
122
+ Server-rendered Pug templates styled with Bootstrap 5.3, with a little vanilla JavaScript for live updates via the same SSE endpoints the Agent uses.
123
+
124
+ | Page | Content |
125
+ |---|---|
126
+ | `/` | Resource cards by status, queue length, jobs in the last 24 h. |
127
+ | `/resources` | Type, labels, groups, status, busy source/reason, last heartbeat age; admin maintenance/rotate-token actions. |
128
+ | `/groups` | Resource groups — id/name/comment, member count; admin create/rename/delete. Membership itself is set per-resource in the Client's own config. |
129
+ | `/jobs` | Filterable job list (state, source, resource), with the job's `user` label if set; admin **Reset queue** / **Clean history**. |
130
+ | `/jobs/:id` | Spec, timeline, live log viewer with stream filter, artifact downloads, cancel button. |
131
+ | `/admin/agents` | Register/revoke CI and developer agents; token shown once. |
132
+ | `/profile` | Every logged-in user's own account settings: display name, avatar, timezone (renders every dashboard timestamp), theme, idle session timeout, password change. |
133
+
134
+ ## Data storage
135
+
136
+ SQLite via `better-sqlite3`, WAL mode. Schema migrations are plain numbered SQL files in `src/db/migrations/`, applied automatically at startup. `job_logs` rows are compacted into `artifacts/<jobId>/console.log` when a job finishes and deleted after `logRetentionDays`; artifacts are deleted after `artifactRetentionDays`. See the [main repo's ER diagram](https://github.com/andrianyablonskyy/thub#9-data-storage-sqlite) for the full schema.
137
+
138
+ ## Security notes
139
+
140
+ - No inbound exposure needed for Clients — they connect outbound only.
141
+ - Tokens (agent, resource) are 256-bit random, stored as SHA-256 hashes, shown once, revocable.
142
+ - A resource token can only act on its own resource and jobs assigned to it; an agent can cancel only its own jobs unless admin.
143
+ - Job specs contain no shell commands — only the fixed entry point of a downloaded test package is ever executed.
144
+ - Artifact downloads are HMAC-signed URLs with expiry.
145
+ - Every state change is written to the `events` table and visible on the dashboard.
146
+
147
+ ## Development
148
+
149
+ ```bash
150
+ npm install
151
+ npm test # node --test test/*.test.js
152
+ npm run lint
153
+ ```
154
+
155
+ ## License
156
+
157
+ Proprietary — see the header comment in each source file.
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file packages/coordinator/bin/thub-admin.js
5
+ * @description Local operator CLI: admin/agent/resource/group/job management, talks to SQLite directly (README §13.1)
6
+ *
7
+ * @author Andrian Yablonskyy
8
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
9
+ *
10
+ * This file is part of TestHub and is proprietary and confidential.
11
+ * Unauthorized copying, modification, distribution, or use of this file,
12
+ * via any medium, is strictly prohibited without prior written permission
13
+ * from AdSystem.PRO.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ // Local admin tool: operates directly on the Coordinator's SQLite database.
19
+ // Used to bootstrap the first dashboard admin and manage agent tokens.
20
+ // Resources no longer need an admin action to join — a Client registers
21
+ // itself with the shared clientJoinKey (see `join-key generate` below and
22
+ // api/resource.js) — this tool only manages a resource after the fact
23
+ // (maintenance mode).
24
+
25
+ const { loadConfig } = require('../src/config'),
26
+ { openDb } = require('../src/db'),
27
+ { bus } = require('../src/services/bus'),
28
+ { createEventsService } = require('../src/services/events'),
29
+ { createRegistryService } = require('../src/services/registry'),
30
+ { createAgentsService } = require('../src/services/agents'),
31
+ { createGroupsService } = require('../src/services/groups'),
32
+ { createAdminUsersService } = require('../src/services/admin-users'),
33
+ { createArtifactsService } = require('../src/services/artifacts'),
34
+ { createJobsService } = require('../src/services/jobs'),
35
+ { generateToken } = require('../src/services/tokens');
36
+
37
+ function usage(){
38
+ console.log(`Usage:
39
+ thub-admin create-admin <username> <password> [--role admin|viewer]
40
+ thub-admin agent add <name> --kind ci|cli
41
+ thub-admin join-key generate
42
+ thub-admin resource maintenance <resourceId> --on|--off
43
+ thub-admin jobs reset --yes Cancel every queued/assigned/preparing/running job
44
+ thub-admin jobs clean --yes Permanently delete finished jobs, logs and artifacts
45
+ thub-admin group add <name> [--comment <text>]
46
+ thub-admin group list
47
+ thub-admin group remove <groupId>
48
+ `);
49
+ }
50
+
51
+ function parseFlags(args){
52
+ const positional = [],
53
+ flags = {};
54
+ for (let i = 0; i < args.length; i++){
55
+ const a = args[i];
56
+ if (a.startsWith('--')){
57
+ const key = a.slice(2);
58
+ if (key === 'label'){
59
+ flags.label = flags.label || [];
60
+ flags.label.push(args[++i]);
61
+ }
62
+ else if (key === 'on'){
63
+ flags.on = true;
64
+ }
65
+ else if (key === 'off'){
66
+ flags.on = false;
67
+ }
68
+ else if (key === 'yes'){
69
+ flags.yes = true;
70
+ }
71
+ else {
72
+ flags[key] = args[++i];
73
+ }
74
+ }
75
+ else {
76
+ positional.push(a);
77
+ }
78
+ }
79
+ return { positional, flags };
80
+ }
81
+
82
+ function main(){
83
+ const [, , cmd, sub, ...rest] = process.argv,
84
+ config = loadConfig(),
85
+ db = openDb(config.dbPath),
86
+ events = createEventsService(db),
87
+ registry = createRegistryService(db, { bus, events }),
88
+ agents = createAgentsService(db, { events }),
89
+ adminUsers = createAdminUsersService(db),
90
+ artifacts = createArtifactsService(db, { config }),
91
+ jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
92
+ groups = createGroupsService(db, { events, registry });
93
+
94
+ if (cmd === 'create-admin'){
95
+ const { positional, flags } = parseFlags([sub, ...rest].filter(Boolean)),
96
+ [username, password] = positional;
97
+ if (!username || !password){
98
+ return usage(), process.exit(4);
99
+ }
100
+ adminUsers.create({ username, password, role: flags.role || 'admin' });
101
+ console.log(`Created ${flags.role || 'admin'} user "${username}"`);
102
+ return;
103
+ }
104
+
105
+ if (cmd === 'agent' && sub === 'add'){
106
+ const { positional, flags } = parseFlags(rest),
107
+ [name] = positional;
108
+ if (!name || !flags.kind){
109
+ return usage(), process.exit(4);
110
+ }
111
+ const { agent, token } = agents.create({ name, kind: flags.kind });
112
+ console.log(`Agent ${agent.id} created. Token (shown once): ${token}`);
113
+ return;
114
+ }
115
+
116
+ if (cmd === 'join-key' && sub === 'generate'){
117
+ const key = generateToken('jk');
118
+ console.log(`Join key: ${key}`);
119
+ console.log('Put it in the Coordinator config as "clientJoinKey" (or THUB_CLIENT_JOIN_KEY),');
120
+ console.log('and on every Client as "joinKey" (or THUB_CLIENT_JOIN_KEY) to let it self-register.');
121
+ return;
122
+ }
123
+
124
+ if (cmd === 'resource' && sub === 'maintenance'){
125
+ const { positional, flags } = parseFlags(rest),
126
+ [resourceId] = positional;
127
+ if (!resourceId || flags.on === undefined){
128
+ return usage(), process.exit(4);
129
+ }
130
+ registry.setMaintenance(resourceId, flags.on);
131
+ console.log(`Resource ${resourceId} maintenance ${flags.on ? 'enabled' : 'disabled'}`);
132
+ return;
133
+ }
134
+
135
+ if (cmd === 'jobs' && sub === 'reset'){
136
+ const { flags } = parseFlags(rest);
137
+ if (!flags.yes){
138
+ console.error('This cancels every queued/assigned/preparing/running job. Re-run with --yes to confirm.');
139
+ process.exit(4);
140
+ }
141
+ const canceled = jobs.resetQueue();
142
+ console.log(`Canceled ${canceled} active job(s).`);
143
+ return;
144
+ }
145
+
146
+ if (cmd === 'jobs' && sub === 'clean'){
147
+ const { flags } = parseFlags(rest);
148
+ if (!flags.yes){
149
+ console.error('This permanently deletes finished jobs and their logs/artifacts. Re-run with --yes to confirm.');
150
+ process.exit(4);
151
+ }
152
+ const deleted = jobs.cleanHistory();
153
+ console.log(`Deleted ${deleted} finished job(s) and their logs/artifacts.`);
154
+ return;
155
+ }
156
+
157
+ if (cmd === 'group' && sub === 'add'){
158
+ const { positional, flags } = parseFlags(rest),
159
+ [name] = positional;
160
+ if (!name){
161
+ return usage(), process.exit(4);
162
+ }
163
+ const group = groups.create({ name, comment: flags.comment });
164
+ console.log(`Group ${group.id} created ("${group.name}"). Use it with: thub run --group ${group.id}`);
165
+ return;
166
+ }
167
+
168
+ if (cmd === 'group' && sub === 'list'){
169
+ for (const g of groups.list()){
170
+ console.log(`${g.id} ${g.name}${g.comment ? ' — ' + g.comment : ''}`);
171
+ }
172
+ return;
173
+ }
174
+
175
+ if (cmd === 'group' && sub === 'remove'){
176
+ const { positional } = parseFlags(rest),
177
+ [groupId] = positional;
178
+ if (!groupId){
179
+ return usage(), process.exit(4);
180
+ }
181
+ groups.remove(groupId);
182
+ console.log(`Group ${groupId} removed.`);
183
+ return;
184
+ }
185
+
186
+ usage();
187
+ process.exit(4);
188
+ }
189
+
190
+ main();
package/config.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "listen": "127.0.0.1:8000",
3
+ "publicUrl": "http://localhost",
4
+ "dataDir": "./tmp/srv/thub",
5
+ "sessionSecret": "change-me-to-a-long-random-string",
6
+ "clientJoinKey": "change-me-to-a-long-random-string",
7
+ "heartbeat": {
8
+ "intervalSec": 10,
9
+ "missedLimit": 3,
10
+ "sweepIntervalSec": 5
11
+ },
12
+ "scheduler": {
13
+ "assignAckTimeoutSec": 15,
14
+ "requeueOnLost": true,
15
+ "maxQueuedPerAgent": 20,
16
+ "tickIntervalSec": 10
17
+ },
18
+ "jobs": {
19
+ "defaultTimeoutSec": 1800,
20
+ "maxTimeoutSec": 14400
21
+ },
22
+ "retention": {
23
+ "logRetentionDays": 14,
24
+ "artifactRetentionDays": 30
25
+ },
26
+ "artifacts": {
27
+ "maxUploadMb": 512,
28
+ "linkTtlHours": 168
29
+ }
30
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @file eslint.config.js
3
+ * @description ESLint flat config for @andrian.yablonskyy/thub-coordinator: stylistic/promise rules plus Node globals
4
+ * for server code and browser globals for the dashboard's public/js scripts.
5
+ *
6
+ * @author Andrian Yablonskyy
7
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
8
+ *
9
+ * This file is part of TestHub and is proprietary and confidential.
10
+ * Unauthorized copying, modification, distribution, or use of this file,
11
+ * via any medium, is strictly prohibited without prior written permission
12
+ * from AdSystem.PRO.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const stylistic = require('@stylistic/eslint-plugin'),
18
+ promise = require('eslint-plugin-promise'),
19
+ globals = require('globals');
20
+
21
+ const rules = {
22
+ // Core JS
23
+ 'prefer-const': 'error',
24
+ strict: ['error', 'global'],
25
+ camelcase: [
26
+ 'error',
27
+ {
28
+ properties: 'never',
29
+ ignoreGlobals: true,
30
+ ignoreDestructuring: true,
31
+ allow: ['[a-z]+([a-z0-9_])?']
32
+ }
33
+ ],
34
+ 'one-var': ['error', { var: 'never', let: 'consecutive', const: 'consecutive' }],
35
+ 'no-var': 'error',
36
+ // No logging abstraction exists in this codebase — console.log/warn/error
37
+ // *is* the daemon/server's actual output mechanism, not leftover
38
+ // debugging. Disallowing it would just mean eslint-disable comments on
39
+ // every real call site.
40
+ 'no-console': 'off',
41
+
42
+ 'no-restricted-syntax': [
43
+ 'error',
44
+ {
45
+ selector: 'CallExpression[callee.object.name="console"][callee.property.name!=/^(log|warn|error|info|trace)$/]',
46
+ message: 'Unexpected property on console object was called'
47
+ }
48
+ ],
49
+
50
+ curly: 'error',
51
+
52
+ // Stylistic
53
+ '@stylistic/indent': ['error', 2],
54
+ '@stylistic/key-spacing': ['error', { beforeColon: false, afterColon: true, mode: 'strict' }],
55
+ '@stylistic/keyword-spacing': ['error', { before: false, after: true }],
56
+ '@stylistic/linebreak-style': ['error', 'unix'],
57
+ '@stylistic/space-before-function-paren': ['error', { anonymous: 'always', named: 'never', asyncArrow: 'always' }],
58
+ '@stylistic/space-infix-ops': 'error',
59
+ '@stylistic/space-before-blocks': ['error', 'never'],
60
+ '@stylistic/comma-dangle': ['error', 'never'],
61
+ '@stylistic/max-len': ['error', 160],
62
+ '@stylistic/no-trailing-spaces': ['error', { skipBlankLines: false }],
63
+ '@stylistic/no-multiple-empty-lines': ['error', { max: 1 }],
64
+ '@stylistic/semi': ['error', 'always'],
65
+ '@stylistic/quotes': ['error', 'single'],
66
+ '@stylistic/one-var-declaration-per-line': ['error', 'always'],
67
+ '@stylistic/no-extra-semi': 'error',
68
+ '@stylistic/no-multi-spaces': 'error',
69
+ '@stylistic/no-mixed-spaces-and-tabs': 'error',
70
+ '@stylistic/arrow-parens': ['error', 'always'],
71
+ '@stylistic/arrow-spacing': ['error', { before: true, after: true }],
72
+ '@stylistic/block-spacing': 'error',
73
+ '@stylistic/brace-style': ['error', 'stroustrup'],
74
+ '@stylistic/comma-spacing': ['error', { before: false, after: true }],
75
+ '@stylistic/comma-style': ['error', 'last'],
76
+ '@stylistic/dot-location': ['error', 'property'],
77
+ '@stylistic/function-call-spacing': ['error', 'never'],
78
+
79
+ // Promises
80
+ 'promise/always-return': ['error', { ignoreLastCallback: true }],
81
+ 'promise/param-names': 'off',
82
+ 'promise/catch-or-return': ['error', { allowFinally: true }],
83
+ 'promise/no-native': 'off',
84
+ 'promise/no-nesting': 'off',
85
+ 'promise/no-callback-in-promise': 'off',
86
+ 'promise/avoid-new': 'off',
87
+ 'promise/no-new-statics': 'error',
88
+ 'promise/no-return-in-finally': 'warn',
89
+ 'promise/valid-params': 'warn'
90
+ };
91
+
92
+ module.exports = [
93
+ { ignores: ['**/node_modules/**', '.data/**', 'tmp/**'] },
94
+ {
95
+ files: ['**/*.{js,cjs,mjs}'],
96
+ ignores: ['public/js/**'],
97
+ plugins: { '@stylistic': stylistic, promise },
98
+ languageOptions: {
99
+ ecmaVersion: 'latest',
100
+ sourceType: 'commonjs',
101
+ globals: { ...globals.node }
102
+ },
103
+ rules
104
+ },
105
+ {
106
+ files: ['public/js/**/*.js'],
107
+ plugins: { '@stylistic': stylistic, promise },
108
+ languageOptions: {
109
+ ecmaVersion: 'latest',
110
+ sourceType: 'script',
111
+ globals: { ...globals.browser, bootstrap: 'readonly' }
112
+ },
113
+ rules
114
+ }
115
+ ];
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@andrian.yablonskyy/thub-coordinator",
3
+ "version": "1.0.0",
4
+ "description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
5
+ "main": "src/server.js",
6
+ "bin": {
7
+ "thub-admin": "./bin/thub-admin.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node src/server.js",
11
+ "dev": "node --watch src/server.js",
12
+ "test": "node --test test/*.test.js",
13
+ "lint": "eslint .",
14
+ "lint:fix": "eslint . --fix"
15
+ },
16
+ "dependencies": {
17
+ "@andrian.yablonskyy/thub-common": "*",
18
+ "better-sqlite3": "^12.4.1",
19
+ "express": "^5.1.0",
20
+ "express-session": "^1.18.2",
21
+ "js-yaml": "^4.1.0",
22
+ "multer": "^2.0.2",
23
+ "pug": "^3.0.3",
24
+ "uuid": "^11.1.0"
25
+ },
26
+ "devDependencies": {
27
+ "@stylistic/eslint-plugin": "^5.10.0",
28
+ "eslint": "^10.11.0",
29
+ "eslint-plugin-promise": "^7.3.0",
30
+ "globals": "^17.12.0"
31
+ }
32
+ }
@@ -0,0 +1,6 @@
1
+ #log-viewer {
2
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
3
+ font-size: 0.8125rem;
4
+ white-space: pre-wrap;
5
+ word-break: break-all;
6
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @file packages/coordinator/public/js/copy-to-clipboard.js
3
+ * @description Dashboard: copies an element's data-copy value to the clipboard on click
4
+ *
5
+ * @author Andrian Yablonskyy
6
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
7
+ *
8
+ * This file is part of TestHub and is proprietary and confidential.
9
+ * Unauthorized copying, modification, distribution, or use of this file,
10
+ * via any medium, is strictly prohibited without prior written permission
11
+ * from AdSystem.PRO.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ (function (){
17
+ document.querySelectorAll('[data-copy]').forEach((btn) => {
18
+ btn.addEventListener('click', async () => {
19
+ try {
20
+ await navigator.clipboard.writeText(btn.dataset.copy);
21
+ }
22
+ catch {
23
+ return; // clipboard unavailable (e.g. non-secure context) — fail quietly
24
+ }
25
+ const icon = btn.querySelector('i');
26
+ if (!icon){
27
+ return;
28
+ }
29
+ const original = icon.className;
30
+ icon.className = 'bi bi-check2';
31
+ setTimeout(() => {
32
+ icon.className = original;
33
+ }, 1200);
34
+ });
35
+ });
36
+ })();
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @file packages/coordinator/public/js/log-viewer.js
3
+ * @description Dashboard: streams or fetches a job's log output into the log viewer pane
4
+ *
5
+ * @author Andrian Yablonskyy
6
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
7
+ *
8
+ * This file is part of TestHub and is proprietary and confidential.
9
+ * Unauthorized copying, modification, distribution, or use of this file,
10
+ * via any medium, is strictly prohibited without prior written permission
11
+ * from AdSystem.PRO.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ (function (){
17
+ const pre = document.getElementById('log-viewer');
18
+ if (!pre){
19
+ return;
20
+ }
21
+ const jobId = pre.dataset.jobId,
22
+ jobActive = pre.dataset.jobActive === 'true',
23
+ filter = document.getElementById('stream-filter'),
24
+ lines = [];
25
+
26
+ function render(){
27
+ const wanted = filter.value;
28
+ pre.textContent = lines
29
+ .filter((l) => !wanted || l.stream === wanted)
30
+ .map((l) => `[${l.ts}] [${l.stream}] ${l.line}`)
31
+ .join('\n');
32
+ pre.scrollTop = pre.scrollHeight;
33
+ }
34
+
35
+ filter.addEventListener('change', render);
36
+
37
+ // Already finished when the page loaded: the output will never change
38
+ // again, so just fetch it once — no EventSource, no live connection, no
39
+ // reload. (Opening a stream here used to trigger an auto-reload once it
40
+ // immediately got the job's "end" event, which reloaded the page, which
41
+ // re-opened the stream, which reloaded again — an infinite loop for
42
+ // anyone viewing a finished job.)
43
+ if (!jobActive){
44
+ fetch(`/jobs/${jobId}/logs`)
45
+ .then((r) => r.json())
46
+ .then(({ lines: fetched }) => {
47
+ lines.push(...fetched);
48
+ render();
49
+ })
50
+ .catch(() => {});
51
+ return;
52
+ }
53
+
54
+ const source = new EventSource(`/jobs/${jobId}/stream`);
55
+ source.addEventListener('log', (e) => {
56
+ lines.push(JSON.parse(e.data));
57
+ render();
58
+ });
59
+ source.addEventListener('state', (e) => {
60
+ const { state, resource } = JSON.parse(e.data);
61
+ lines.push({ ts: new Date().toISOString(), stream: 'state', line: `-> ${state}${resource ? ' on ' + resource : ''}` });
62
+ render();
63
+ });
64
+ source.addEventListener('end', (e) => {
65
+ const { state } = JSON.parse(e.data);
66
+ lines.push({ ts: new Date().toISOString(), stream: 'state', line: `job finished: ${state}` });
67
+ render();
68
+ source.close();
69
+ // The job was active when we opened this page and just finished —
70
+ // reload once to pick up the now-final badge/artifacts list.
71
+ setTimeout(() => window.location.reload(), 1500);
72
+ });
73
+ source.onerror = () => {
74
+ // EventSource auto-reconnects; nothing to do here.
75
+ };
76
+ })();
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @file packages/coordinator/public/js/profile-theme.js
3
+ * @description Profile page: wires the auto/light/dark buttons to the shared theme setter (theme.js)
4
+ *
5
+ * @author Andrian Yablonskyy
6
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
7
+ *
8
+ * This file is part of TestHub and is proprietary and confidential.
9
+ * Unauthorized copying, modification, distribution, or use of this file,
10
+ * via any medium, is strictly prohibited without prior written permission
11
+ * from AdSystem.PRO.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ (function (){
17
+ const buttons = document.querySelectorAll('[data-set-theme]');
18
+ buttons.forEach((btn) => {
19
+ btn.addEventListener('click', () => {
20
+ const theme = btn.dataset.setTheme;
21
+ window.thubSetTheme(theme);
22
+ buttons.forEach((b) => {
23
+ b.classList.toggle('btn-primary', b === btn);
24
+ b.classList.toggle('btn-outline-secondary', b !== btn);
25
+ });
26
+ });
27
+ });
28
+ })();