@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/views/layout.pug
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
doctype html
|
|
2
|
+
html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
3
|
+
head
|
|
4
|
+
meta(charset="utf-8")
|
|
5
|
+
meta(name="viewport" content="width=device-width, initial-scale=1")
|
|
6
|
+
title= title ? `${title} · TestHub` : 'TestHub'
|
|
7
|
+
link(rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css")
|
|
8
|
+
link(rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css")
|
|
9
|
+
link(rel="stylesheet" href="/css/thub.css")
|
|
10
|
+
body
|
|
11
|
+
nav.navbar.navbar-expand-lg.bg-body-tertiary.border-bottom
|
|
12
|
+
.container-fluid
|
|
13
|
+
a.navbar-brand(href="/") TestHub
|
|
14
|
+
button.navbar-toggler(type="button" data-bs-toggle="collapse" data-bs-target="#nav")
|
|
15
|
+
span.navbar-toggler-icon
|
|
16
|
+
#nav.collapse.navbar-collapse
|
|
17
|
+
ul.navbar-nav.me-auto
|
|
18
|
+
if user
|
|
19
|
+
li.nav-item
|
|
20
|
+
a.nav-link(href="/" class=active === 'overview' ? 'active' : '') Overview
|
|
21
|
+
li.nav-item
|
|
22
|
+
a.nav-link(href="/resources" class=active === 'resources' ? 'active' : '') Resources
|
|
23
|
+
li.nav-item
|
|
24
|
+
a.nav-link(href="/groups" class=active === 'groups' ? 'active' : '') Groups
|
|
25
|
+
li.nav-item
|
|
26
|
+
a.nav-link(href="/jobs" class=active === 'jobs' ? 'active' : '') Jobs
|
|
27
|
+
if user.role === 'admin'
|
|
28
|
+
li.nav-item
|
|
29
|
+
a.nav-link(href="/admin/agents" class=active === 'agents' ? 'active' : '') Agents
|
|
30
|
+
ul.navbar-nav
|
|
31
|
+
li.nav-item.d-flex.align-items-center.me-3
|
|
32
|
+
button#theme-toggle.btn.btn-sm.btn-outline-secondary(
|
|
33
|
+
type="button"
|
|
34
|
+
aria-label="Toggle theme"
|
|
35
|
+
data-bs-toggle="tooltip"
|
|
36
|
+
data-bs-placement="bottom"
|
|
37
|
+
data-bs-title="Switch between light and dark theme"
|
|
38
|
+
)
|
|
39
|
+
i.bi.bi-circle-half
|
|
40
|
+
if user
|
|
41
|
+
li.nav-item.d-flex.align-items-center.me-3
|
|
42
|
+
a.nav-link.d-flex.align-items-center.p-0(
|
|
43
|
+
href="/profile"
|
|
44
|
+
data-bs-toggle="tooltip"
|
|
45
|
+
data-bs-placement="bottom"
|
|
46
|
+
data-bs-title=`Profile settings (${user.role})`
|
|
47
|
+
)
|
|
48
|
+
if user.avatarPath
|
|
49
|
+
img.rounded-circle.me-2(src=user.avatarPath width="24" height="24" style="object-fit: cover;")
|
|
50
|
+
else
|
|
51
|
+
span.rounded-circle.bg-secondary.text-white.d-inline-flex.align-items-center.justify-content-center.me-2(style="width: 24px; height: 24px; font-size: 12px;")
|
|
52
|
+
= ((user.firstName ? user.firstName[0] : user.username[0]) + (user.lastName ? user.lastName[0] : '')).toUpperCase()
|
|
53
|
+
span= user.firstName ? `${user.firstName} ${user.lastName || ''}`.trim() : user.username
|
|
54
|
+
li.nav-item.d-flex.align-items-center
|
|
55
|
+
form(action="/logout" method="post")
|
|
56
|
+
button.btn.btn-sm.btn-outline-danger(
|
|
57
|
+
type="submit"
|
|
58
|
+
aria-label="Log out"
|
|
59
|
+
data-bs-toggle="tooltip"
|
|
60
|
+
data-bs-placement="bottom"
|
|
61
|
+
data-bs-title="Sign out of the dashboard"
|
|
62
|
+
)
|
|
63
|
+
i.bi.bi-box-arrow-right
|
|
64
|
+
main.container.py-4
|
|
65
|
+
if messages && messages.length
|
|
66
|
+
each m in messages
|
|
67
|
+
.alert(class=`alert-${m.type || 'info'}`)= m.text
|
|
68
|
+
block content
|
|
69
|
+
script(src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js")
|
|
70
|
+
script(src="/js/theme.js")
|
|
71
|
+
script(src="/js/tooltips.js")
|
|
72
|
+
script(src="/js/copy-to-clipboard.js")
|
|
73
|
+
block scripts
|
package/views/login.pug
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
extends layout
|
|
2
|
+
|
|
3
|
+
block content
|
|
4
|
+
.row.justify-content-center
|
|
5
|
+
.col-md-4
|
|
6
|
+
h1.h4.mb-3 Sign in
|
|
7
|
+
if error
|
|
8
|
+
.alert.alert-danger= error
|
|
9
|
+
form(method="post" action="/login")
|
|
10
|
+
.mb-3
|
|
11
|
+
label.form-label(for="username") Username
|
|
12
|
+
input#username.form-control(type="text" name="username" required autofocus)
|
|
13
|
+
.mb-3
|
|
14
|
+
label.form-label(for="password") Password
|
|
15
|
+
input#password.form-control(type="password" name="password" required)
|
|
16
|
+
button.btn.btn-primary(type="submit") Sign in
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
mixin logViewer(jobId, jobActive)
|
|
2
|
+
.mb-2
|
|
3
|
+
select#stream-filter.form-select.form-select-sm.w-auto.d-inline-block
|
|
4
|
+
option(value="") all streams
|
|
5
|
+
option(value="uart") uart
|
|
6
|
+
option(value="runner") runner
|
|
7
|
+
option(value="flash") flash
|
|
8
|
+
option(value="emulator") emulator
|
|
9
|
+
pre#log-viewer.bg-body-tertiary.p-3.rounded(
|
|
10
|
+
style="height: 420px; overflow-y: auto;"
|
|
11
|
+
data-job-id=jobId
|
|
12
|
+
data-job-active=jobActive ? 'true' : 'false'
|
|
13
|
+
)
|
|
14
|
+
script(src="/js/log-viewer.js")
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
mixin resourceBadge(status)
|
|
2
|
+
- const map = { IDLE: 'success', BUSY: 'primary', OUT_OF_SERVICE: 'danger', MAINTENANCE: 'secondary', REGISTERED: 'secondary' }
|
|
3
|
+
span.badge(class=`text-bg-${map[status] || 'secondary'}`)= status
|
|
4
|
+
|
|
5
|
+
mixin jobBadge(state)
|
|
6
|
+
- const map = { QUEUED: 'secondary', ASSIGNED: 'info', PREPARING: 'info', RUNNING: 'primary', PASSED: 'success', FAILED: 'danger', ERROR: 'danger', TIMEOUT: 'warning', CANCELED: 'secondary', LOST: 'danger' }
|
|
7
|
+
span.badge(class=`text-bg-${map[state] || 'secondary'}`)= state
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
extends layout
|
|
2
|
+
|
|
3
|
+
block content
|
|
4
|
+
h1.h4.mb-3 Profile settings
|
|
5
|
+
|
|
6
|
+
.row
|
|
7
|
+
.col-md-6
|
|
8
|
+
form(method="post" action="/profile" enctype="multipart/form-data")
|
|
9
|
+
.d-flex.align-items-center.mb-3
|
|
10
|
+
if user.avatarPath
|
|
11
|
+
img.rounded-circle.me-3(src=user.avatarPath width="64" height="64" style="object-fit: cover;")
|
|
12
|
+
else
|
|
13
|
+
span.rounded-circle.bg-secondary.text-white.d-inline-flex.align-items-center.justify-content-center.me-3(style="width: 64px; height: 64px; font-size: 24px;")
|
|
14
|
+
= ((user.firstName ? user.firstName[0] : user.username[0]) + (user.lastName ? user.lastName[0] : '')).toUpperCase()
|
|
15
|
+
div
|
|
16
|
+
label.form-label.mb-1(for="avatar") Avatar
|
|
17
|
+
input#avatar.form-control.form-control-sm(type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp")
|
|
18
|
+
.form-text PNG, JPEG, GIF or WebP, up to 2MB.
|
|
19
|
+
|
|
20
|
+
.mb-3
|
|
21
|
+
label.form-label(for="username") Username
|
|
22
|
+
input#username.form-control(type="text" name="username" value=user.username required)
|
|
23
|
+
|
|
24
|
+
.row
|
|
25
|
+
.col-6.mb-3
|
|
26
|
+
label.form-label(for="firstName") First name
|
|
27
|
+
input#firstName.form-control(type="text" name="firstName" value=user.firstName || '')
|
|
28
|
+
.col-6.mb-3
|
|
29
|
+
label.form-label(for="lastName") Last name
|
|
30
|
+
input#lastName.form-control(type="text" name="lastName" value=user.lastName || '')
|
|
31
|
+
|
|
32
|
+
.mb-3
|
|
33
|
+
label.form-label(for="timezone") Timezone
|
|
34
|
+
select#timezone.form-select(name="timezone" required)
|
|
35
|
+
each tz in timezones
|
|
36
|
+
option(value=tz selected=tz === user.timezone)= tz
|
|
37
|
+
.form-text Used to render every timestamp in the dashboard.
|
|
38
|
+
|
|
39
|
+
.mb-3
|
|
40
|
+
label.form-label(for="sessionTimeoutMin") Session timeout (idle)
|
|
41
|
+
select#sessionTimeoutMin.form-select(name="sessionTimeoutMin" required)
|
|
42
|
+
each min in sessionTimeoutOptions
|
|
43
|
+
option(value=min selected=min === user.sessionTimeoutMin)= min < 60 ? `${min} minutes` : `${min / 60} hour${min === 60 ? '' : 's'}`
|
|
44
|
+
.form-text You'll be signed out after this long without activity.
|
|
45
|
+
|
|
46
|
+
button.btn.btn-primary(type="submit") Save
|
|
47
|
+
|
|
48
|
+
.col-md-6
|
|
49
|
+
.card.mb-3
|
|
50
|
+
.card-body
|
|
51
|
+
h6.card-subtitle.mb-2.text-body-secondary Theme
|
|
52
|
+
p.small.text-body-secondary.mb-2 Synced to your account — the same on every device you sign in from.
|
|
53
|
+
.btn-group(role="group" aria-label="Theme")
|
|
54
|
+
each t in ['auto', 'light', 'dark']
|
|
55
|
+
button.btn.btn-sm(
|
|
56
|
+
type="button"
|
|
57
|
+
class=user.theme === t ? 'btn-primary' : 'btn-outline-secondary'
|
|
58
|
+
data-set-theme=t
|
|
59
|
+
)= t.charAt(0).toUpperCase() + t.slice(1)
|
|
60
|
+
|
|
61
|
+
.card
|
|
62
|
+
.card-body
|
|
63
|
+
h6.card-subtitle.mb-3.text-body-secondary Change password
|
|
64
|
+
form(method="post" action="/profile/password")
|
|
65
|
+
.mb-3
|
|
66
|
+
label.form-label(for="currentPassword") Current password
|
|
67
|
+
input#currentPassword.form-control(type="password" name="currentPassword" autocomplete="current-password" required)
|
|
68
|
+
.mb-3
|
|
69
|
+
label.form-label(for="newPassword") New password
|
|
70
|
+
input#newPassword.form-control(type="password" name="newPassword" autocomplete="new-password" required)
|
|
71
|
+
.mb-3
|
|
72
|
+
label.form-label(for="confirmPassword") Confirm new password
|
|
73
|
+
input#confirmPassword.form-control(type="password" name="confirmPassword" autocomplete="new-password" required)
|
|
74
|
+
button.btn.btn-primary(type="submit") Change password
|
|
75
|
+
|
|
76
|
+
block scripts
|
|
77
|
+
script(src="/js/profile-theme.js")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
extends ../layout
|
|
2
|
+
include ../mixins/status-badge
|
|
3
|
+
|
|
4
|
+
block content
|
|
5
|
+
h1.h4.mb-3 Resources
|
|
6
|
+
p.text-body-secondary.small.
|
|
7
|
+
Resources register themselves — start a Client with the shared join key and it appears here.
|
|
8
|
+
|
|
9
|
+
table.table.table-hover.align-middle
|
|
10
|
+
thead
|
|
11
|
+
tr
|
|
12
|
+
th Name
|
|
13
|
+
th Type
|
|
14
|
+
th Status
|
|
15
|
+
th Labels
|
|
16
|
+
th Groups
|
|
17
|
+
th Busy source / reason
|
|
18
|
+
th Last heartbeat
|
|
19
|
+
if user.role === 'admin'
|
|
20
|
+
th Actions
|
|
21
|
+
tbody
|
|
22
|
+
each r in resources
|
|
23
|
+
tr
|
|
24
|
+
td= r.name
|
|
25
|
+
td.text-uppercase= r.type
|
|
26
|
+
td: +resourceBadge(r.status)
|
|
27
|
+
td
|
|
28
|
+
each l in r.labels
|
|
29
|
+
span.badge.text-bg-light.border.me-1= l
|
|
30
|
+
td
|
|
31
|
+
if r.group_ids.length
|
|
32
|
+
each gid in r.group_ids
|
|
33
|
+
span.badge.text-bg-info.me-1(title=gid)= groupsById[gid] ? groupsById[gid].name : `(unknown: ${gid})`
|
|
34
|
+
else
|
|
35
|
+
span.text-body-secondary —
|
|
36
|
+
td= r.busy_source ? `${r.busy_source}${r.busy_reason ? ' — ' + r.busy_reason : ''}` : '—'
|
|
37
|
+
td= fmtDate(r.last_heartbeat_at, 'never')
|
|
38
|
+
if user.role === 'admin'
|
|
39
|
+
td
|
|
40
|
+
form.d-inline(method="post" action=`/resources/${r.id}/maintenance`)
|
|
41
|
+
input(type="hidden" name="enabled" value=r.status === 'MAINTENANCE' ? '0' : '1')
|
|
42
|
+
if r.status === 'MAINTENANCE'
|
|
43
|
+
button.btn.btn-sm.btn-outline-secondary(
|
|
44
|
+
type="submit"
|
|
45
|
+
aria-label="Enable"
|
|
46
|
+
data-bs-toggle="tooltip"
|
|
47
|
+
data-bs-title="Bring this resource back into rotation so the scheduler can assign it jobs again"
|
|
48
|
+
)
|
|
49
|
+
i.bi.bi-play-circle
|
|
50
|
+
else
|
|
51
|
+
button.btn.btn-sm.btn-outline-secondary(
|
|
52
|
+
type="submit"
|
|
53
|
+
aria-label="Disable"
|
|
54
|
+
data-bs-toggle="tooltip"
|
|
55
|
+
data-bs-title="Take this resource out of rotation for maintenance — the scheduler will skip it"
|
|
56
|
+
)
|
|
57
|
+
i.bi.bi-tools
|
|
58
|
+
form.d-inline.ms-1(method="post" action=`/resources/${r.id}/rotate-token` onsubmit="return confirm('Rotate token? The Client will need the new one.')")
|
|
59
|
+
button.btn.btn-sm.btn-outline-warning(
|
|
60
|
+
type="submit"
|
|
61
|
+
aria-label="Rotate token"
|
|
62
|
+
data-bs-toggle="tooltip"
|
|
63
|
+
data-bs-title="Invalidate the current resource token and issue a new one — the Client won't reconnect until it has it"
|
|
64
|
+
)
|
|
65
|
+
i.bi.bi-arrow-repeat
|