@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.
- package/README.md +157 -0
- package/bin/thub-admin.js +190 -0
- package/config.json +30 -0
- package/eslint.config.js +115 -0
- package/package.json +32 -0
- package/public/css/thub.css +6 -0
- package/public/js/copy-to-clipboard.js +36 -0
- package/public/js/log-viewer.js +76 -0
- package/public/js/profile-theme.js +28 -0
- package/public/js/theme.js +51 -0
- package/public/js/tooltips.js +18 -0
- package/src/api/admin.js +112 -0
- package/src/api/agent.js +110 -0
- package/src/api/resource.js +211 -0
- package/src/api/sse.js +96 -0
- package/src/auth.js +94 -0
- package/src/config.js +110 -0
- package/src/db/index.js +61 -0
- package/src/db/migrations/001_init.sql +104 -0
- package/src/db/migrations/003_split_job_id_counters.sql +5 -0
- package/src/db/migrations/004_add_client_id.sql +8 -0
- package/src/db/migrations/005_add_groups.sql +14 -0
- package/src/db/migrations/006_events_allow_group.sql +20 -0
- package/src/db/migrations/007_add_user_profile.sql +10 -0
- package/src/server.js +179 -0
- package/src/services/admin-users.js +174 -0
- package/src/services/agents.js +56 -0
- package/src/services/artifacts.js +149 -0
- package/src/services/bus.js +23 -0
- package/src/services/commands.js +43 -0
- package/src/services/events.js +37 -0
- package/src/services/groups.js +79 -0
- package/src/services/heartbeat.js +56 -0
- package/src/services/jobs.js +355 -0
- package/src/services/logs.js +70 -0
- package/src/services/registry.js +347 -0
- package/src/services/retention.js +44 -0
- package/src/services/scheduler.js +101 -0
- package/src/services/tokens.js +28 -0
- package/src/web/routes.js +343 -0
- package/test/admin-users.test.js +127 -0
- package/test/scheduler.test.js +375 -0
- package/views/admin/agents.pug +53 -0
- package/views/groups/list.pug +94 -0
- package/views/index.pug +37 -0
- package/views/jobs/list.pug +45 -0
- package/views/jobs/show.pug +42 -0
- package/views/layout.pug +73 -0
- package/views/login.pug +16 -0
- package/views/mixins/log-viewer.pug +14 -0
- package/views/mixins/status-badge.pug +7 -0
- package/views/profile.pug +77 -0
- 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
|
+
}
|
package/eslint.config.js
ADDED
|
@@ -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,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
|
+
})();
|