@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
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
extends ../layout
|
|
2
|
+
include _mixins
|
|
3
|
+
|
|
4
|
+
block content
|
|
5
|
+
-
|
|
6
|
+
const toc = [
|
|
7
|
+
{ id: 'overview', label: 'Product overview' },
|
|
8
|
+
{ id: 'use-cases', label: 'Use cases' },
|
|
9
|
+
{ id: 'quick-start', label: 'Quick start' },
|
|
10
|
+
{ id: 'coordinator', label: 'Coordinator setup' },
|
|
11
|
+
{ id: 'agent-setup', label: 'Agent setup' },
|
|
12
|
+
{ id: 'client-setup', label: 'Client setup' },
|
|
13
|
+
{ id: 'client-machines', label: 'Client machines (HW / SW)' },
|
|
14
|
+
{ id: 'docker', label: 'Docker registry' },
|
|
15
|
+
{ id: 'git', label: 'Git repositories' },
|
|
16
|
+
{ id: 'agent-cli', label: 'Agent CLI reference' },
|
|
17
|
+
{ id: 'env', label: 'Environment variables' },
|
|
18
|
+
{ id: 'ci', label: 'CI/CD (GitHub Actions)' },
|
|
19
|
+
{ id: 'troubleshooting', label: 'Troubleshooting' }
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
.row.g-4
|
|
23
|
+
.col-lg-3.d-none.d-lg-block
|
|
24
|
+
.thub-help-toc.sticky-top
|
|
25
|
+
input.form-control.form-control-sm.mb-3#help-filter(
|
|
26
|
+
type="search"
|
|
27
|
+
placeholder="Filter help…"
|
|
28
|
+
aria-label="Filter help sections"
|
|
29
|
+
)
|
|
30
|
+
nav.nav.nav-pills.flex-column#help-toc
|
|
31
|
+
each item in toc
|
|
32
|
+
a.nav-link.py-1(href=`#${item.id}` data-help-toc=item.id)= item.label
|
|
33
|
+
p.small.text-body-secondary.mt-3.mb-0
|
|
34
|
+
| Coordinator v#{coordinatorVersion} · examples use
|
|
35
|
+
br
|
|
36
|
+
code.small= coordinatorUrl
|
|
37
|
+
|
|
38
|
+
.col-lg-9
|
|
39
|
+
.d-flex.flex-wrap.align-items-center.justify-content-between.gap-2.mb-3
|
|
40
|
+
h1.h3.mb-0 TestHub help
|
|
41
|
+
//- Small screens have no sidebar: jump menu + filter instead.
|
|
42
|
+
.d-lg-none.d-flex.gap-2.w-100
|
|
43
|
+
select.form-select.form-select-sm#help-jump(aria-label="Jump to section")
|
|
44
|
+
each item in toc
|
|
45
|
+
option(value=item.id)= item.label
|
|
46
|
+
input.form-control.form-control-sm#help-filter-sm(type="search" placeholder="Filter…" aria-label="Filter help sections")
|
|
47
|
+
p.lead.
|
|
48
|
+
Everything needed to run TestHub: what it is, how to install and configure the
|
|
49
|
+
Coordinator, Agent and Client, how to prepare lab machines, and how jobs use Docker,
|
|
50
|
+
git and environment variables.
|
|
51
|
+
p.text-body-secondary.small#help-no-match(hidden) No section matches the filter.
|
|
52
|
+
|
|
53
|
+
include _overview
|
|
54
|
+
include _quick-start
|
|
55
|
+
include _coordinator
|
|
56
|
+
include _agent-setup
|
|
57
|
+
include _client-setup
|
|
58
|
+
include _client-machines
|
|
59
|
+
include _docker
|
|
60
|
+
include _git
|
|
61
|
+
include _agent-cli
|
|
62
|
+
include _env
|
|
63
|
+
include _ci
|
|
64
|
+
include _troubleshooting
|
|
65
|
+
|
|
66
|
+
block scripts
|
|
67
|
+
script(src="/js/help.js")
|
package/views/index.pug
CHANGED
|
@@ -6,7 +6,8 @@ include mixins/updates
|
|
|
6
6
|
block content
|
|
7
7
|
h1.h4.mb-2 Overview
|
|
8
8
|
|
|
9
|
-
.
|
|
9
|
+
//- data-live: swapped in place when it changes (public/js/live.js).
|
|
10
|
+
.row.g-3.mb-4(data-live="overview-stats")
|
|
10
11
|
.col-md-4
|
|
11
12
|
.card
|
|
12
13
|
.card-body
|
|
@@ -23,7 +24,7 @@ block content
|
|
|
23
24
|
h6.card-subtitle.mb-2.text-body-secondary Resources online
|
|
24
25
|
p.display-6= `${onlineCount} / ${resources.length}`
|
|
25
26
|
|
|
26
|
-
.row.g-3
|
|
27
|
+
.row.g-3(data-live="overview-cards")
|
|
27
28
|
each r in resources
|
|
28
29
|
.col-sm-6.col-md-4.col-lg-3
|
|
29
30
|
.card.h-100(role="button" tabindex="0" data-resource-card=`resource-card-${r.id}` class=`border-${{IDLE:'success',BUSY:'primary',OUT_OF_SERVICE:'danger',MAINTENANCE:'secondary',REGISTERED:'secondary'}[r.status] || 'secondary'}`)
|
package/views/jobs/list.pug
CHANGED
|
@@ -11,7 +11,9 @@ block content
|
|
|
11
11
|
button.btn.btn-sm.btn-outline-warning(type="button" data-bs-toggle="modal" data-bs-target="#reset-queue-modal") Reset queue
|
|
12
12
|
button.btn.btn-sm.btn-outline-danger.ms-1(type="button" data-bs-toggle="modal" data-bs-target="#clean-history-modal") Clean up database
|
|
13
13
|
|
|
14
|
-
form.row.g-2.mb-3(method="get")
|
|
14
|
+
form.row.g-2.mb-3(method="get" role="search")
|
|
15
|
+
.col-12.col-md-5.col-lg-4
|
|
16
|
+
+searchBox(filters.q, 'Search jobs: id, user, resource, command, repo…', list.urlFor({ q: '' }))
|
|
15
17
|
.col-auto
|
|
16
18
|
select.form-select.form-select-sm(name="state" onchange="this.form.submit()")
|
|
17
19
|
option(value="") any state
|
|
@@ -25,7 +27,8 @@ block content
|
|
|
25
27
|
.col-auto.ms-auto
|
|
26
28
|
+pageSizePicker(list)
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
div(data-live="list-toolbar")
|
|
31
|
+
+listToolbar(list, 'jobs')
|
|
29
32
|
|
|
30
33
|
table.table.table-hover.align-middle
|
|
31
34
|
thead
|
|
@@ -39,7 +42,7 @@ block content
|
|
|
39
42
|
+sortHeader(list, 'duration', 'Duration', 'desc')
|
|
40
43
|
if canManage
|
|
41
44
|
th
|
|
42
|
-
tbody
|
|
45
|
+
tbody(data-live="rows")
|
|
43
46
|
each j in jobs
|
|
44
47
|
tr
|
|
45
48
|
td: a(href=`/jobs/${j.id}`)= j.id
|
|
@@ -84,7 +87,7 @@ block content
|
|
|
84
87
|
|
|
|
85
88
|
code CANCELED
|
|
86
89
|
| .
|
|
87
|
-
p.small.text-body-secondary.mb-0 Finished jobs
|
|
90
|
+
p.small.text-body-secondary.mb-0 Finished jobs and their logs are not touched.
|
|
88
91
|
else
|
|
89
92
|
p.mb-0 There are no active jobs — nothing to cancel.
|
|
90
93
|
|
|
@@ -129,7 +132,7 @@ block content
|
|
|
129
132
|
onclick="document.getElementById('cleanup-before').value = this.dataset.value"
|
|
130
133
|
)= preset.label
|
|
131
134
|
p
|
|
132
|
-
| Deletes finished jobs that ended before then, with their logs
|
|
135
|
+
| Deletes finished jobs that ended before then, with their logs, and the resource, agent and group history logged before then — then compacts the database file.
|
|
133
136
|
|
|
|
134
137
|
strong This cannot be undone.
|
|
135
138
|
| Active jobs are never touched.
|
package/views/jobs/show.pug
CHANGED
|
@@ -49,15 +49,5 @@ block content
|
|
|
49
49
|
dt.col-sm-2 Message
|
|
50
50
|
dd.col-sm-10= job.message
|
|
51
51
|
|
|
52
|
-
h2.h6.mt-4#artifacts Artifacts
|
|
53
|
-
if artifacts.length
|
|
54
|
-
ul.list-group
|
|
55
|
-
each a in artifacts
|
|
56
|
-
li.list-group-item.d-flex.justify-content-between
|
|
57
|
-
span= a.name
|
|
58
|
-
a(href=a.url) download
|
|
59
|
-
else
|
|
60
|
-
p.text-body-secondary No artifacts yet.
|
|
61
|
-
|
|
62
52
|
h2.h6.mt-4 Log
|
|
63
53
|
+logViewer(job.id, jobActive)
|
package/views/layout.pug
CHANGED
|
@@ -7,7 +7,9 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
7
7
|
link(rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css")
|
|
8
8
|
link(rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css")
|
|
9
9
|
link(rel="stylesheet" href="/css/thub.css")
|
|
10
|
-
|
|
10
|
+
//- liveTopics (routes.js): the page updates itself when these change
|
|
11
|
+
//- (public/js/live.js, services/live.js).
|
|
12
|
+
body(data-live-topics=user && liveTopics ? liveTopics : false)
|
|
11
13
|
nav.navbar.navbar-expand-lg.bg-body-tertiary.border-bottom
|
|
12
14
|
.container-fluid
|
|
13
15
|
a.navbar-brand.d-flex.flex-column.lh-1.py-1(href="/")
|
|
@@ -42,6 +44,24 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
42
44
|
li.nav-item
|
|
43
45
|
a.nav-link(href="/admin/agents" class=active === 'agents' ? 'active' : '') Agents
|
|
44
46
|
ul.navbar-nav
|
|
47
|
+
if user && liveTopics
|
|
48
|
+
//- Live-update status, driven by public/js/live.js.
|
|
49
|
+
li.nav-item.d-flex.align-items-center.me-3
|
|
50
|
+
span#thub-live.thub-live.small.text-body-secondary(data-state="connecting" title="Connecting to live updates…" role="status")
|
|
51
|
+
span.thub-live-dot(aria-hidden="true")
|
|
52
|
+
span.thub-live-label Live
|
|
53
|
+
if user
|
|
54
|
+
li.nav-item.d-flex.align-items-center.me-2
|
|
55
|
+
a.nav-link.d-flex.align-items-center(
|
|
56
|
+
href="/help"
|
|
57
|
+
class=active === 'help' ? 'active' : ''
|
|
58
|
+
aria-current=active === 'help' ? 'page' : false
|
|
59
|
+
data-bs-toggle="tooltip"
|
|
60
|
+
data-bs-placement="bottom"
|
|
61
|
+
data-bs-title="Product guide, setup and CLI reference"
|
|
62
|
+
)
|
|
63
|
+
i.bi.bi-question-circle.me-1
|
|
64
|
+
| Help
|
|
45
65
|
if user && user.role === 'admin'
|
|
46
66
|
li.nav-item.d-flex.align-items-center.me-2
|
|
47
67
|
form(method="post" action="/updates/check" onsubmit="this.querySelector('button').disabled = true; this.querySelector('i').className = 'spinner-border spinner-border-sm';")
|
|
@@ -171,6 +191,9 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
171
191
|
script(src="/js/toasts.js")
|
|
172
192
|
script(src="/js/copy-to-clipboard.js")
|
|
173
193
|
script(src="/js/confirm.js")
|
|
194
|
+
script(src="/js/list-search.js")
|
|
195
|
+
if user && liveTopics
|
|
196
|
+
script(src="/js/live.js")
|
|
174
197
|
if user && user.role === 'admin' && updates.coordinatorPending
|
|
175
198
|
script(src="/js/update-watch.js")
|
|
176
199
|
block scripts
|
|
@@ -16,6 +16,38 @@ mixin pageSizePicker(list)
|
|
|
16
16
|
each s in list.pageSizes
|
|
17
17
|
option(value=list.urlFor({ size: s }) selected=list.prefs.size === s)= s === 'all' ? 'All' : s
|
|
18
18
|
|
|
19
|
+
//- Search box (?q=) for a list page: every word must match (routes.js,
|
|
20
|
+
//- services/search.js). Goes inside a GET form — the Jobs filter form, or
|
|
21
|
+
//- +searchForm on its own. `/` focuses it, Esc clears (public/js/list-search.js).
|
|
22
|
+
mixin searchBox(q, placeholder, clearUrl)
|
|
23
|
+
.input-group.input-group-sm.thub-search
|
|
24
|
+
span.input-group-text
|
|
25
|
+
i.bi.bi-search
|
|
26
|
+
input.form-control(
|
|
27
|
+
type="search"
|
|
28
|
+
name="q"
|
|
29
|
+
value=q
|
|
30
|
+
placeholder=placeholder
|
|
31
|
+
aria-label=placeholder
|
|
32
|
+
maxlength="200"
|
|
33
|
+
autocomplete="off"
|
|
34
|
+
data-list-search
|
|
35
|
+
data-bs-toggle="tooltip"
|
|
36
|
+
data-bs-title="Press Enter to search · / to focus · Esc to clear"
|
|
37
|
+
)
|
|
38
|
+
if q
|
|
39
|
+
a.btn.btn-outline-secondary(href=clearUrl aria-label="Clear search" data-list-search-clear)
|
|
40
|
+
i.bi.bi-x-lg
|
|
41
|
+
|
|
42
|
+
mixin searchForm(q, placeholder, clearUrl)
|
|
43
|
+
form.m-0(method="get" role="search")
|
|
44
|
+
+searchBox(q, placeholder, clearUrl)
|
|
45
|
+
|
|
46
|
+
//- "matching “foo”" after a list's count, when a search is active.
|
|
47
|
+
mixin searchNote(q)
|
|
48
|
+
if q
|
|
49
|
+
| matching “#{q}”
|
|
50
|
+
|
|
19
51
|
//- Shown above the table: "Showing 26–50 of 312 jobs" and the page links.
|
|
20
52
|
mixin listToolbar(list, noun)
|
|
21
53
|
- const p = list.pagination
|
|
@@ -25,6 +57,10 @@ mixin listToolbar(list, noun)
|
|
|
25
57
|
| Showing #{p.from}–#{p.to} of #{p.total} #{noun}
|
|
26
58
|
else
|
|
27
59
|
| No #{noun}
|
|
60
|
+
+searchNote(list.q)
|
|
61
|
+
if list.q
|
|
62
|
+
| ·
|
|
63
|
+
a(href=list.urlFor({ q: '' })) show all
|
|
28
64
|
if p.pages > 1
|
|
29
65
|
+pager(list)
|
|
30
66
|
|
|
@@ -1,12 +1,48 @@
|
|
|
1
1
|
mixin logViewer(jobId, jobActive)
|
|
2
|
-
.
|
|
3
|
-
|
|
2
|
+
//- Stream filter + search (public/js/log-viewer.js): matches are
|
|
3
|
+
//- highlighted; Enter / Shift+Enter or the arrows step through them.
|
|
4
|
+
//- The log loads from its end; earlier lines load as you scroll up.
|
|
5
|
+
.d-flex.flex-wrap.align-items-center.gap-2.mb-2
|
|
6
|
+
select#stream-filter.form-select.form-select-sm.w-auto(aria-label="Log stream")
|
|
4
7
|
option(value="") all streams
|
|
5
8
|
option(value="uart") uart
|
|
6
9
|
option(value="runner") runner
|
|
7
10
|
option(value="flash") flash
|
|
8
11
|
option(value="emulator") emulator
|
|
9
|
-
|
|
12
|
+
.input-group.input-group-sm.thub-search(role="search")
|
|
13
|
+
span.input-group-text
|
|
14
|
+
i.bi.bi-search
|
|
15
|
+
input#log-search.form-control(
|
|
16
|
+
type="search"
|
|
17
|
+
placeholder="Search log…"
|
|
18
|
+
aria-label="Search log"
|
|
19
|
+
autocomplete="off"
|
|
20
|
+
maxlength="200"
|
|
21
|
+
data-bs-toggle="tooltip"
|
|
22
|
+
data-bs-title="Enter: next match · Shift+Enter: previous · Esc: clear"
|
|
23
|
+
)
|
|
24
|
+
span#log-search-count.input-group-text.small(aria-live="polite" hidden)
|
|
25
|
+
button#log-search-prev.btn.btn-outline-secondary(type="button" aria-label="Previous match" disabled)
|
|
26
|
+
i.bi.bi-chevron-up
|
|
27
|
+
button#log-search-next.btn.btn-outline-secondary(type="button" aria-label="Next match" disabled)
|
|
28
|
+
i.bi.bi-chevron-down
|
|
29
|
+
.form-check.form-check-inline.small.mb-0
|
|
30
|
+
input#log-search-only.form-check-input(type="checkbox")
|
|
31
|
+
label.form-check-label(for="log-search-only") Display only matching lines
|
|
32
|
+
a.btn.btn-sm.btn-outline-secondary.ms-auto(
|
|
33
|
+
href=`/jobs/${jobId}/log.txt`
|
|
34
|
+
target="_blank"
|
|
35
|
+
rel="noopener"
|
|
36
|
+
data-bs-toggle="tooltip"
|
|
37
|
+
data-bs-title="Open the full log as plain text in a new tab"
|
|
38
|
+
)
|
|
39
|
+
i.bi.bi-file-earmark-text.me-1
|
|
40
|
+
| Raw
|
|
41
|
+
//- "Showing the last 1,000 of 5,321 lines · Load all" (log-viewer.js).
|
|
42
|
+
.d-flex.flex-wrap.align-items-center.gap-2.small.text-body-secondary.mb-1(aria-live="polite")
|
|
43
|
+
span#log-info Loading log…
|
|
44
|
+
button#log-load-all.btn.btn-link.btn-sm.p-0.align-baseline(type="button" hidden) Load all
|
|
45
|
+
pre#log-viewer.bg-body-tertiary.p-3.rounded.position-relative(
|
|
10
46
|
style="height: 420px; overflow-y: auto;"
|
|
11
47
|
data-job-id=jobId
|
|
12
48
|
data-job-active=jobActive ? 'true' : 'false'
|
|
@@ -322,7 +322,7 @@ mixin resourceCard(r, groupsById, returnTo)
|
|
|
322
322
|
) Connected USB devices
|
|
323
323
|
|
|
324
324
|
.tab-content
|
|
325
|
-
.tab-pane.fade.show.active(id=`rc-${r.id}-details` role="tabpanel" aria-labelledby=`rc-${r.id}-details-tab` tabindex="0")
|
|
325
|
+
.tab-pane.fade.show.active(id=`rc-${r.id}-details` role="tabpanel" aria-labelledby=`rc-${r.id}-details-tab` tabindex="0" data-live=`rc-details-${r.id}`)
|
|
326
326
|
+resourceDetails(r, groupsById, returnTo, local, external)
|
|
327
327
|
+configPanes(r, returnTo)
|
|
328
328
|
.tab-pane.fade(id=`rc-${r.id}-usb` role="tabpanel" aria-labelledby=`rc-${r.id}-usb-tab` tabindex="0")
|
package/views/resources/list.pug
CHANGED
|
@@ -6,15 +6,18 @@ include ../mixins/list-controls
|
|
|
6
6
|
|
|
7
7
|
block content
|
|
8
8
|
h1.h4.mb-3 Resources
|
|
9
|
-
|
|
9
|
+
.d-flex.flex-wrap.align-items-center.gap-2.mb-3
|
|
10
|
+
+updatesToolbar('client', 'clients', '/resources')
|
|
11
|
+
p.text-body-secondary.small: i.
|
|
10
12
|
Resources register themselves — start a Client with the shared join key and it appears here. Click a resource for its details, including IP addresses.
|
|
11
13
|
|
|
12
14
|
.d-flex.flex-wrap.align-items-center.gap-2.mb-3
|
|
13
|
-
+
|
|
15
|
+
+searchForm(list.q, 'Search resources: name, label, group, status, IP…', list.urlFor({ q: '' }))
|
|
14
16
|
.ms-auto
|
|
15
17
|
+pageSizePicker(list)
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
div(data-live="list-toolbar")
|
|
20
|
+
+listToolbar(list, 'resources')
|
|
18
21
|
|
|
19
22
|
table.table.table-hover.align-middle
|
|
20
23
|
thead
|
|
@@ -29,7 +32,7 @@ block content
|
|
|
29
32
|
+sortHeader(list, 'heartbeat', 'Last heartbeat', 'desc')
|
|
30
33
|
if user.role === 'admin'
|
|
31
34
|
th Actions
|
|
32
|
-
tbody
|
|
35
|
+
tbody(data-live="rows")
|
|
33
36
|
each r in resources
|
|
34
37
|
tr(role="button" tabindex="0" data-resource-card=`resource-card-${r.id}`)
|
|
35
38
|
td= r.name
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file packages/coordinator/src/services/artifacts.js
|
|
3
|
-
* @description Job artifact storage: saves uploaded files and generates download links
|
|
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
|
-
const fs = require('node:fs'),
|
|
17
|
-
path = require('node:path'),
|
|
18
|
-
crypto = require('node:crypto');
|
|
19
|
-
|
|
20
|
-
// §3.1 / §12: stores uploaded result files under artifacts/<jobId>/ and
|
|
21
|
-
// generates HMAC-signed, time-limited download links (no server-side
|
|
22
|
-
// session needed for the Agent/CI to fetch them).
|
|
23
|
-
function createArtifactsService(db, { config }){
|
|
24
|
-
const secret = config.sessionSecret;
|
|
25
|
-
|
|
26
|
-
function jobDir(jobId){
|
|
27
|
-
const dir = path.join(config.artifactsDir, jobId);
|
|
28
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
-
return dir;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function sha256File(filePath){
|
|
33
|
-
const hash = crypto.createHash('sha256');
|
|
34
|
-
hash.update(fs.readFileSync(filePath));
|
|
35
|
-
return hash.digest('hex');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// `files` are multer file objects already written to a temp path.
|
|
39
|
-
function storeUploaded(jobId, files){
|
|
40
|
-
const dir = jobDir(jobId),
|
|
41
|
-
stored = [];
|
|
42
|
-
for (const file of files){
|
|
43
|
-
const dest = path.join(dir, path.basename(file.originalname));
|
|
44
|
-
fs.renameSync(file.path, dest);
|
|
45
|
-
const sha256 = sha256File(dest),
|
|
46
|
-
size = fs.statSync(dest).size,
|
|
47
|
-
info = db
|
|
48
|
-
.prepare(
|
|
49
|
-
`INSERT INTO artifacts (job_id, name, path, size, sha256, content_type, created_at)
|
|
50
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
51
|
-
)
|
|
52
|
-
.run(jobId, file.originalname, dest, size, sha256, file.mimetype, new Date().toISOString());
|
|
53
|
-
stored.push(get(info.lastInsertRowid));
|
|
54
|
-
}
|
|
55
|
-
return stored;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function storeGenerated(jobId, name, buffer, contentType){
|
|
59
|
-
const dir = jobDir(jobId),
|
|
60
|
-
dest = path.join(dir, name);
|
|
61
|
-
fs.writeFileSync(dest, buffer);
|
|
62
|
-
const info = db
|
|
63
|
-
.prepare(
|
|
64
|
-
`INSERT INTO artifacts (job_id, name, path, size, sha256, content_type, created_at)
|
|
65
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
66
|
-
)
|
|
67
|
-
.run(jobId, name, dest, buffer.length, sha256File(dest), contentType, new Date().toISOString());
|
|
68
|
-
return get(info.lastInsertRowid);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function get(id){
|
|
72
|
-
return db.prepare('SELECT * FROM artifacts WHERE id = ?').get(id);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function listForJob(jobId){
|
|
76
|
-
return db.prepare('SELECT * FROM artifacts WHERE job_id = ? ORDER BY id ASC').all(jobId);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function sign(artifactId, ttlHours = config.artifacts.linkTtlHours){
|
|
80
|
-
const expiresAt = Date.now() + ttlHours * 3600 * 1000,
|
|
81
|
-
payload = `${artifactId}.${expiresAt}`,
|
|
82
|
-
mac = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
|
|
83
|
-
return `${payload}.${mac}`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function verify(token){
|
|
87
|
-
const [artifactId, expiresAt, mac] = String(token).split('.');
|
|
88
|
-
if (!artifactId || !expiresAt || !mac){
|
|
89
|
-
throw invalidLink();
|
|
90
|
-
}
|
|
91
|
-
const payload = `${artifactId}.${expiresAt}`,
|
|
92
|
-
expectedMac = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
|
|
93
|
-
if (!crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expectedMac))){
|
|
94
|
-
throw invalidLink();
|
|
95
|
-
}
|
|
96
|
-
if (Date.now() > Number(expiresAt)){
|
|
97
|
-
throw invalidLink('Link expired');
|
|
98
|
-
}
|
|
99
|
-
const artifact = get(Number(artifactId));
|
|
100
|
-
if (!artifact){
|
|
101
|
-
throw invalidLink();
|
|
102
|
-
}
|
|
103
|
-
return artifact;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function invalidLink(message = 'Invalid or expired link'){
|
|
107
|
-
return Object.assign(new Error(message), { status: 403 });
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function signedUrl(artifact){
|
|
111
|
-
return `${config.publicUrl}/artifacts/download/${sign(artifact.id)}`;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Removes a job's artifact files from disk (not the DB rows — the
|
|
115
|
-
// caller, jobs.js's cleanHistory, deletes those as part of the same
|
|
116
|
-
// transaction that also deletes the job itself).
|
|
117
|
-
function deleteJobArtifacts(jobId){
|
|
118
|
-
fs.rmSync(path.join(config.artifactsDir, jobId), { recursive: true, force: true });
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function purgeOlderThan(days){
|
|
122
|
-
const cutoff = new Date(Date.now() - days * 86400 * 1000).toISOString(),
|
|
123
|
-
stale = db
|
|
124
|
-
.prepare(
|
|
125
|
-
`SELECT a.* FROM artifacts a JOIN jobs j ON j.id = a.job_id
|
|
126
|
-
WHERE j.finished_at IS NOT NULL AND j.finished_at <= ?`
|
|
127
|
-
)
|
|
128
|
-
.all(cutoff);
|
|
129
|
-
for (const artifact of stale){
|
|
130
|
-
fs.rmSync(artifact.path, { force: true });
|
|
131
|
-
db.prepare('DELETE FROM artifacts WHERE id = ?').run(artifact.id);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return {
|
|
136
|
-
storeUploaded,
|
|
137
|
-
storeGenerated,
|
|
138
|
-
get,
|
|
139
|
-
listForJob,
|
|
140
|
-
sign,
|
|
141
|
-
verify,
|
|
142
|
-
signedUrl,
|
|
143
|
-
purgeOlderThan,
|
|
144
|
-
deleteJobArtifacts,
|
|
145
|
-
jobDir
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
module.exports = { createArtifactsService };
|