@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.
- package/bin/thub-admin.js +3 -5
- package/config.json +1 -6
- package/package.json +2 -2
- package/public/css/thub.css +129 -0
- package/public/js/config-import.js +37 -33
- package/public/js/copy-to-clipboard.js +21 -18
- package/public/js/help.js +93 -0
- package/public/js/list-search.js +51 -0
- package/public/js/live.js +287 -0
- package/public/js/log-viewer.js +289 -45
- package/public/js/remove-resource.js +5 -3
- package/public/js/resource-card.js +14 -13
- package/public/js/tooltips.js +3 -1
- package/scripts/install-default-config.js +1 -1
- package/scripts/install-target.js +2 -2
- package/src/api/admin.js +1 -1
- package/src/api/agent.js +4 -10
- package/src/api/resource.js +6 -11
- package/src/api/sse.js +13 -10
- package/src/config.js +16 -9
- package/src/db/migrations/022_add_sessions.sql +10 -0
- package/src/db/migrations/023_drop_artifacts.sql +4 -0
- package/src/dev/virtual.js +1 -1
- package/src/server.js +55 -21
- package/src/services/cleanup.js +2 -2
- package/src/services/jobs.js +33 -19
- package/src/services/list-prefs.js +4 -0
- package/src/services/live.js +124 -0
- package/src/services/logs.js +12 -14
- package/src/services/retention.js +5 -14
- package/src/services/search.js +59 -0
- package/src/services/session-store.js +127 -0
- package/src/web/routes.js +205 -34
- package/test/cleanup.test.js +1 -5
- package/test/list-prefs.test.js +6 -0
- package/test/live.test.js +153 -0
- package/test/logs.test.js +150 -0
- package/test/scheduler.test.js +10 -12
- package/test/search.test.js +97 -0
- package/test/session.test.js +132 -0
- package/test/views.test.js +30 -0
- package/views/admin/agents.pug +28 -10
- package/views/groups/list.pug +14 -1
- package/views/help/_agent-cli.pug +103 -0
- package/views/help/_agent-setup.pug +72 -0
- package/views/help/_ci.pug +44 -0
- package/views/help/_client-machines.pug +87 -0
- package/views/help/_client-setup.pug +122 -0
- package/views/help/_coordinator.pug +137 -0
- package/views/help/_docker.pug +124 -0
- package/views/help/_env.pug +127 -0
- package/views/help/_git.pug +130 -0
- package/views/help/_mixins.pug +24 -0
- package/views/help/_overview.pug +118 -0
- package/views/help/_quick-start.pug +37 -0
- package/views/help/_troubleshooting.pug +44 -0
- package/views/help/index.pug +67 -0
- package/views/index.pug +3 -2
- package/views/jobs/list.pug +8 -5
- package/views/jobs/show.pug +0 -10
- package/views/layout.pug +24 -1
- package/views/mixins/list-controls.pug +36 -0
- package/views/mixins/log-viewer.pug +39 -3
- package/views/mixins/resource.pug +1 -1
- package/views/resources/list.pug +7 -4
- 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
|
|
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
|
-
|
|
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
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.0.
|
|
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.
|
|
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",
|
package/public/css/thub.css
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
18
|
-
btn.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 `<` 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
|
+
})();
|