@andrian.yablonskyy/thub-coordinator 1.0.37 → 1.0.39

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 (66) hide show
  1. package/bin/thub-admin.js +3 -5
  2. package/config.json +1 -6
  3. package/package.json +2 -2
  4. package/public/css/thub.css +129 -0
  5. package/public/js/config-import.js +37 -33
  6. package/public/js/copy-to-clipboard.js +21 -18
  7. package/public/js/help.js +93 -0
  8. package/public/js/list-search.js +51 -0
  9. package/public/js/live.js +287 -0
  10. package/public/js/log-viewer.js +289 -45
  11. package/public/js/remove-resource.js +5 -3
  12. package/public/js/resource-card.js +14 -13
  13. package/public/js/tooltips.js +3 -1
  14. package/scripts/install-default-config.js +1 -1
  15. package/scripts/install-target.js +2 -2
  16. package/src/api/admin.js +1 -1
  17. package/src/api/agent.js +4 -10
  18. package/src/api/resource.js +6 -11
  19. package/src/api/sse.js +13 -10
  20. package/src/config.js +16 -9
  21. package/src/db/migrations/022_add_sessions.sql +10 -0
  22. package/src/db/migrations/023_drop_artifacts.sql +4 -0
  23. package/src/dev/virtual.js +1 -1
  24. package/src/server.js +55 -21
  25. package/src/services/cleanup.js +2 -2
  26. package/src/services/jobs.js +33 -19
  27. package/src/services/list-prefs.js +4 -0
  28. package/src/services/live.js +124 -0
  29. package/src/services/logs.js +12 -14
  30. package/src/services/retention.js +5 -14
  31. package/src/services/search.js +59 -0
  32. package/src/services/session-store.js +127 -0
  33. package/src/web/routes.js +205 -34
  34. package/test/cleanup.test.js +1 -5
  35. package/test/list-prefs.test.js +6 -0
  36. package/test/live.test.js +153 -0
  37. package/test/logs.test.js +150 -0
  38. package/test/scheduler.test.js +10 -12
  39. package/test/search.test.js +97 -0
  40. package/test/session.test.js +132 -0
  41. package/test/views.test.js +30 -0
  42. package/views/admin/agents.pug +28 -10
  43. package/views/groups/list.pug +14 -1
  44. package/views/help/_agent-cli.pug +103 -0
  45. package/views/help/_agent-setup.pug +72 -0
  46. package/views/help/_ci.pug +44 -0
  47. package/views/help/_client-machines.pug +87 -0
  48. package/views/help/_client-setup.pug +122 -0
  49. package/views/help/_coordinator.pug +137 -0
  50. package/views/help/_docker.pug +124 -0
  51. package/views/help/_env.pug +127 -0
  52. package/views/help/_git.pug +130 -0
  53. package/views/help/_mixins.pug +24 -0
  54. package/views/help/_overview.pug +118 -0
  55. package/views/help/_quick-start.pug +37 -0
  56. package/views/help/_troubleshooting.pug +44 -0
  57. package/views/help/index.pug +67 -0
  58. package/views/index.pug +3 -2
  59. package/views/jobs/list.pug +8 -5
  60. package/views/jobs/show.pug +0 -10
  61. package/views/layout.pug +24 -1
  62. package/views/mixins/list-controls.pug +36 -0
  63. package/views/mixins/log-viewer.pug +39 -3
  64. package/views/mixins/resource.pug +1 -1
  65. package/views/resources/list.pug +7 -4
  66. package/src/services/artifacts.js +0 -149
package/bin/thub-admin.js CHANGED
@@ -30,7 +30,6 @@ const { loadConfig } = require('../src/config'),
30
30
  { createAgentsService } = require('../src/services/agents'),
31
31
  { createGroupsService } = require('../src/services/groups'),
32
32
  { createAdminUsersService } = require('../src/services/admin-users'),
33
- { createArtifactsService } = require('../src/services/artifacts'),
34
33
  { createJobsService } = require('../src/services/jobs'),
35
34
  { createCleanupService } = require('../src/services/cleanup'),
36
35
  { generateToken } = require('../src/services/tokens'),
@@ -46,7 +45,7 @@ function usage(){
46
45
  thub-admin resource maintenance <resourceId> --on|--off
47
46
  thub-admin jobs reset --yes Cancel every queued/assigned/preparing/running job
48
47
  thub-admin jobs clean --yes [--before "dd/mm/yyyy HH:MM:SS"]
49
- Permanently delete finished jobs (with logs and artifacts) and
48
+ Permanently delete finished jobs (with their logs) and
50
49
  history from before then (local time; default: now), then
51
50
  compact the database file
52
51
  thub-admin group add <name> [--comment <text>]
@@ -134,8 +133,7 @@ function main(){
134
133
  registry = createRegistryService(db, { bus, events }),
135
134
  agents = createAgentsService(db, { events }),
136
135
  adminUsers = createAdminUsersService(db),
137
- artifacts = createArtifactsService(db, { config }),
138
- jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
136
+ jobs = createJobsService(db, { bus, events, registry, config }),
139
137
  groups = createGroupsService(db, { events, registry });
140
138
 
141
139
  if (cmd === 'create-admin'){
@@ -193,7 +191,7 @@ function main(){
193
191
  if (cmd === 'jobs' && sub === 'clean'){
194
192
  const { flags } = parseFlags(rest);
195
193
  if (!flags.yes){
196
- console.error('This permanently deletes finished jobs and their logs/artifacts. Re-run with --yes to confirm.');
194
+ console.error('This permanently deletes finished jobs and their logs. Re-run with --yes to confirm.');
197
195
  process.exit(4);
198
196
  }
199
197
  const before = flags.before ? parseDateTime(flags.before) : new Date();
package/config.json CHANGED
@@ -20,11 +20,6 @@
20
20
  "maxTimeoutSec": 14400
21
21
  },
22
22
  "retention": {
23
- "logRetentionDays": 14,
24
- "artifactRetentionDays": 30
25
- },
26
- "artifacts": {
27
- "maxUploadMb": 512,
28
- "linkTtlHours": 168
23
+ "jobRetention": "forever"
29
24
  }
30
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.0.37",
3
+ "version": "1.0.39",
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.31",
22
+ "@andrian.yablonskyy/thub-common": "^1.0.32",
23
23
  "better-sqlite3": "^12.4.1",
24
24
  "express": "^5.1.0",
25
25
  "express-session": "^1.18.2",
@@ -98,3 +98,132 @@
98
98
  .tab-content .tab-pane[data-config-pane].active {
99
99
  display: block;
100
100
  }
101
+
102
+ /* Help page (views/help/): sticky table of contents, code blocks with a
103
+ copy button (public/js/help.js). */
104
+ .thub-help-toc {
105
+ top: 1rem;
106
+ max-height: calc(100vh - 2rem);
107
+ overflow-y: auto;
108
+ }
109
+
110
+ .thub-help-toc .nav-link {
111
+ font-size: 0.875rem;
112
+ }
113
+
114
+ .thub-help-section {
115
+ scroll-margin-top: 1rem;
116
+ }
117
+
118
+ .thub-code {
119
+ position: relative;
120
+ }
121
+
122
+ .thub-code-caption {
123
+ font-size: 0.75rem;
124
+ color: var(--bs-secondary-color);
125
+ margin-bottom: 0.25rem;
126
+ }
127
+
128
+ .thub-code pre {
129
+ margin: 0;
130
+ padding: 0.75rem 3rem 0.75rem 0.75rem;
131
+ background: var(--bs-tertiary-bg);
132
+ border: 1px solid var(--bs-border-color);
133
+ border-radius: var(--bs-border-radius);
134
+ font-size: 0.8125rem;
135
+ white-space: pre;
136
+ overflow-x: auto;
137
+ }
138
+
139
+ .thub-code pre code {
140
+ color: var(--bs-body-color);
141
+ }
142
+
143
+ .thub-code-copy {
144
+ position: absolute;
145
+ right: 0.4rem;
146
+ bottom: 0.4rem;
147
+ padding: 0.1rem 0.4rem;
148
+ opacity: 0.7;
149
+ }
150
+
151
+ .thub-code-copy:hover,
152
+ .thub-code-copy:focus {
153
+ opacity: 1;
154
+ }
155
+
156
+ .thub-help-steps > li {
157
+ margin-bottom: 1rem;
158
+ }
159
+
160
+ /* List search box (views/mixins/list-controls.pug +searchBox). */
161
+ .thub-search {
162
+ width: 22rem;
163
+ max-width: 100%;
164
+ }
165
+
166
+ .col-12 > .thub-search {
167
+ width: 100%;
168
+ }
169
+
170
+ /* Job log search (public/js/log-viewer.js): every match, and the current one. */
171
+ #log-viewer mark.thub-log-hit {
172
+ padding: 0;
173
+ background: rgba(255, 193, 7, 0.4);
174
+ color: inherit;
175
+ }
176
+
177
+ #log-viewer mark.thub-log-hit.current {
178
+ background: var(--bs-warning);
179
+ color: #000;
180
+ outline: 1px solid var(--bs-warning-border-subtle);
181
+ }
182
+
183
+ /* Live-update indicator in the navbar (public/js/live.js sets data-state). */
184
+ .thub-live {
185
+ display: inline-flex;
186
+ align-items: center;
187
+ gap: 0.35rem;
188
+ cursor: default;
189
+ user-select: none;
190
+ }
191
+
192
+ .thub-live-dot {
193
+ width: 0.5rem;
194
+ height: 0.5rem;
195
+ border-radius: 50%;
196
+ background: var(--bs-secondary-color);
197
+ }
198
+
199
+ .thub-live[data-state="live"] .thub-live-dot {
200
+ background: var(--bs-success);
201
+ box-shadow: 0 0 0 0 rgba(var(--bs-success-rgb), 0.5);
202
+ animation: thub-live-pulse 2.5s ease-out infinite;
203
+ }
204
+
205
+ .thub-live[data-state="paused"] .thub-live-dot {
206
+ background: var(--bs-warning);
207
+ }
208
+
209
+ .thub-live[data-state="offline"] .thub-live-dot,
210
+ .thub-live[data-state="ended"] .thub-live-dot {
211
+ background: var(--bs-danger);
212
+ }
213
+
214
+ .thub-live[data-state="ended"] {
215
+ cursor: pointer;
216
+ color: var(--bs-danger-text-emphasis) !important;
217
+ }
218
+
219
+ @keyframes thub-live-pulse {
220
+ 0% { box-shadow: 0 0 0 0 rgba(var(--bs-success-rgb), 0.5); }
221
+ 70% { box-shadow: 0 0 0 0.4rem rgba(var(--bs-success-rgb), 0); }
222
+ 100% { box-shadow: 0 0 0 0 rgba(var(--bs-success-rgb), 0); }
223
+ }
224
+
225
+ @media (prefers-reduced-motion: reduce) {
226
+ .thub-live[data-state="live"] .thub-live-dot {
227
+ animation: none;
228
+ }
229
+ }
@@ -18,42 +18,46 @@
18
18
  const IGNORED = ['joinKey', 'coordinatorUrl', 'name'],
19
19
  MAX_BYTES = 64 * 1024;
20
20
 
21
- document.querySelectorAll('form[data-config-import]').forEach((form) => {
22
- const picker = form.querySelector('[data-config-import-file]');
23
- form.querySelector('[data-config-import-pick]').addEventListener('click', () => {
21
+ // Delegated, so forms swapped in by live updates (live.js) work.
22
+ document.addEventListener('click', (e) => {
23
+ const pick = e.target.closest('[data-config-import-pick]'),
24
+ picker = pick?.closest('form[data-config-import]')?.querySelector('[data-config-import-file]');
25
+ if (picker){
24
26
  picker.value = ''; // the same file again still fires `change`
25
27
  picker.click();
26
- });
28
+ }
29
+ });
27
30
 
28
- picker.addEventListener('change', async () => {
29
- const file = picker.files[0];
30
- if (!file){
31
- return;
32
- }
33
- const text = file.size > MAX_BYTES ? '' : await file.text();
34
- form.querySelector('input[name="file"]').value = text;
35
- form.querySelector('input[name="fileName"]').value = file.name;
31
+ document.addEventListener('change', async (e) => {
32
+ const picker = e.target.closest?.('[data-config-import-file]'),
33
+ form = picker?.closest('form[data-config-import]'),
34
+ file = picker?.files[0];
35
+ if (!form || !file){
36
+ return;
37
+ }
38
+ const text = file.size > MAX_BYTES ? '' : await file.text();
39
+ form.querySelector('input[name="file"]').value = text;
40
+ form.querySelector('input[name="fileName"]').value = file.name;
36
41
 
37
- let parsed = null;
38
- try {
39
- parsed = JSON.parse(text);
40
- }
41
- catch {
42
- parsed = null;
43
- }
44
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)){
45
- // Nothing to confirm: the Coordinator says what's wrong with it.
46
- delete form.dataset.confirm;
47
- }
48
- else {
49
- const present = IGNORED.filter((k) => k in parsed);
50
- form.dataset.confirm = `Import ${file.name} into ${form.dataset.resourceName}? ` +
51
- 'It replaces the Client\'s config (capabilities, labels, groups, heartbeat settings, …); the Client writes it on its next heartbeat ' +
52
- 'and restarts once it has no job running.\n\n' +
53
- `Ignored: joinKey, coordinatorUrl and name${present.length ? ` (in this file: ${present.join(', ')})` : ''}, ` +
54
- 'and the Client\'s id, file paths and secrets — each Client keeps its own.';
55
- }
56
- form.requestSubmit();
57
- });
42
+ let parsed = null;
43
+ try {
44
+ parsed = JSON.parse(text);
45
+ }
46
+ catch {
47
+ parsed = null;
48
+ }
49
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)){
50
+ // Nothing to confirm: the Coordinator says what's wrong with it.
51
+ delete form.dataset.confirm;
52
+ }
53
+ else {
54
+ const present = IGNORED.filter((k) => k in parsed);
55
+ form.dataset.confirm = `Import ${file.name} into ${form.dataset.resourceName}? ` +
56
+ 'It replaces the Client\'s config (capabilities, labels, groups, heartbeat settings, …); the Client writes it on its next heartbeat ' +
57
+ 'and restarts once it has no job running.\n\n' +
58
+ `Ignored: joinKey, coordinatorUrl and name${present.length ? ` (in this file: ${present.join(', ')})` : ''}, ` +
59
+ 'and the Client\'s id, file paths and secrets — each Client keeps its own.';
60
+ }
61
+ form.requestSubmit();
58
62
  });
59
63
  })();
@@ -13,24 +13,27 @@
13
13
 
14
14
  'use strict';
15
15
 
16
+ // Delegated, so buttons swapped in by live updates (live.js) work.
16
17
  (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
- });
18
+ document.addEventListener('click', async (e) => {
19
+ const btn = e.target.closest('[data-copy]');
20
+ if (!btn){
21
+ return;
22
+ }
23
+ try {
24
+ await navigator.clipboard.writeText(btn.dataset.copy);
25
+ }
26
+ catch {
27
+ return; // clipboard unavailable (e.g. non-secure context) — fail quietly
28
+ }
29
+ const icon = btn.querySelector('i');
30
+ if (!icon){
31
+ return;
32
+ }
33
+ const original = icon.className;
34
+ icon.className = 'bi bi-check2';
35
+ setTimeout(() => {
36
+ icon.className = original;
37
+ }, 1200);
35
38
  });
36
39
  })();
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @file packages/coordinator/public/js/help.js
3
+ * @description Dashboard Help page: copy buttons on code blocks, table-of-contents scrollspy, section filter
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
+ // Copy button on every code block (views/help/_mixins.pug +code). Copies
18
+ // the decoded text, so `&lt;` placeholders come out as `<`.
19
+ document.querySelectorAll('.thub-code').forEach((figure) => {
20
+ const code = figure.querySelector('code');
21
+ if (!code){
22
+ return;
23
+ }
24
+ const btn = document.createElement('button');
25
+ btn.type = 'button';
26
+ btn.className = 'btn btn-sm btn-outline-secondary thub-code-copy';
27
+ btn.setAttribute('aria-label', 'Copy to clipboard');
28
+ btn.title = 'Copy to clipboard';
29
+ btn.innerHTML = '<i class="bi bi-clipboard"></i>';
30
+ btn.addEventListener('click', async () => {
31
+ try {
32
+ await navigator.clipboard.writeText(code.textContent);
33
+ }
34
+ catch {
35
+ return; // clipboard unavailable (e.g. non-secure context) — fail quietly
36
+ }
37
+ const icon = btn.querySelector('i');
38
+ icon.className = 'bi bi-check2';
39
+ setTimeout(() => {
40
+ icon.className = 'bi bi-clipboard';
41
+ }, 1200);
42
+ });
43
+ figure.appendChild(btn);
44
+ });
45
+
46
+ // Highlight the table-of-contents entry of the section in view.
47
+ if (window.bootstrap && document.getElementById('help-toc')){
48
+ new window.bootstrap.ScrollSpy(document.body, { target: '#help-toc', rootMargin: '0px 0px -60%' });
49
+ }
50
+
51
+ // Small screens: the jump menu replaces the sidebar.
52
+ const jump = document.getElementById('help-jump');
53
+ if (jump){
54
+ jump.addEventListener('change', () => {
55
+ document.getElementById(jump.value)?.scrollIntoView({ behavior: 'smooth' });
56
+ });
57
+ }
58
+
59
+ // Filter: hide sections (and their TOC entries) that don't contain every
60
+ // word typed. Both filter boxes (sidebar, small screens) stay in sync.
61
+ const sections = [...document.querySelectorAll('[data-help-section]')],
62
+ inputs = ['help-filter', 'help-filter-sm'].map((id) => document.getElementById(id)).filter(Boolean),
63
+ noMatch = document.getElementById('help-no-match'),
64
+ texts = new Map(sections.map((s) => [s, s.textContent.toLowerCase()]));
65
+
66
+ function applyFilter(value){
67
+ const words = value.toLowerCase().split(/\s+/).filter(Boolean);
68
+ let shown = 0;
69
+ for (const section of sections){
70
+ const match = words.every((w) => texts.get(section).includes(w));
71
+ section.hidden = !match;
72
+ shown += match ? 1 : 0;
73
+ const tocLink = document.querySelector(`[data-help-toc="${section.id}"]`);
74
+ if (tocLink){
75
+ tocLink.hidden = !match;
76
+ }
77
+ }
78
+ if (noMatch){
79
+ noMatch.hidden = shown > 0;
80
+ }
81
+ }
82
+
83
+ inputs.forEach((input) => {
84
+ input.addEventListener('input', () => {
85
+ inputs.forEach((other) => {
86
+ if (other !== input){
87
+ other.value = input.value;
88
+ }
89
+ });
90
+ applyFilter(input.value);
91
+ });
92
+ });
93
+ })();
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @file packages/coordinator/public/js/list-search.js
3
+ * @description Dashboard list search (views/mixins/list-controls.pug +searchBox): "/" focuses it, Esc clears it
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 input = document.querySelector('[data-list-search]');
18
+ if (!input){
19
+ return;
20
+ }
21
+
22
+ // "/" anywhere outside a text field jumps to the search box (as on GitHub).
23
+ document.addEventListener('keydown', (e) => {
24
+ if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey){
25
+ return;
26
+ }
27
+ const t = e.target;
28
+ if (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)){
29
+ return;
30
+ }
31
+ e.preventDefault();
32
+ input.focus();
33
+ input.select();
34
+ });
35
+
36
+ // Esc: empty the box; if the page is showing search results, go back to
37
+ // the full list (the Clear link keeps the page's other filters).
38
+ input.addEventListener('keydown', (e) => {
39
+ if (e.key !== 'Escape'){
40
+ return;
41
+ }
42
+ const clear = input.closest('.thub-search')?.querySelector('[data-list-search-clear]');
43
+ if (clear){
44
+ window.location.href = clear.href;
45
+ }
46
+ else {
47
+ input.value = '';
48
+ input.blur();
49
+ }
50
+ });
51
+ })();