@andrian.yablonskyy/thub-coordinator 1.0.37 → 1.0.38

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 (44) hide show
  1. package/package.json +2 -2
  2. package/public/css/thub.css +129 -0
  3. package/public/js/config-import.js +37 -33
  4. package/public/js/copy-to-clipboard.js +21 -18
  5. package/public/js/help.js +93 -0
  6. package/public/js/list-search.js +51 -0
  7. package/public/js/live.js +287 -0
  8. package/public/js/log-viewer.js +123 -11
  9. package/public/js/remove-resource.js +5 -3
  10. package/public/js/resource-card.js +14 -13
  11. package/public/js/tooltips.js +3 -1
  12. package/src/server.js +20 -2
  13. package/src/services/jobs.js +29 -12
  14. package/src/services/list-prefs.js +4 -0
  15. package/src/services/live.js +124 -0
  16. package/src/services/search.js +59 -0
  17. package/src/web/routes.js +130 -21
  18. package/test/list-prefs.test.js +6 -0
  19. package/test/live.test.js +153 -0
  20. package/test/search.test.js +97 -0
  21. package/test/views.test.js +30 -0
  22. package/views/admin/agents.pug +28 -10
  23. package/views/groups/list.pug +14 -1
  24. package/views/help/_agent-cli.pug +103 -0
  25. package/views/help/_agent-setup.pug +72 -0
  26. package/views/help/_ci.pug +44 -0
  27. package/views/help/_client-machines.pug +87 -0
  28. package/views/help/_client-setup.pug +122 -0
  29. package/views/help/_coordinator.pug +134 -0
  30. package/views/help/_docker.pug +124 -0
  31. package/views/help/_env.pug +127 -0
  32. package/views/help/_git.pug +129 -0
  33. package/views/help/_mixins.pug +24 -0
  34. package/views/help/_overview.pug +118 -0
  35. package/views/help/_quick-start.pug +37 -0
  36. package/views/help/_troubleshooting.pug +42 -0
  37. package/views/help/index.pug +67 -0
  38. package/views/index.pug +3 -2
  39. package/views/jobs/list.pug +6 -3
  40. package/views/layout.pug +24 -1
  41. package/views/mixins/list-controls.pug +36 -0
  42. package/views/mixins/log-viewer.pug +25 -3
  43. package/views/mixins/resource.pug +1 -1
  44. package/views/resources/list.pug +7 -4
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.38",
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
+ })();