@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.
Files changed (53) hide show
  1. package/README.md +157 -0
  2. package/bin/thub-admin.js +190 -0
  3. package/config.json +30 -0
  4. package/eslint.config.js +115 -0
  5. package/package.json +32 -0
  6. package/public/css/thub.css +6 -0
  7. package/public/js/copy-to-clipboard.js +36 -0
  8. package/public/js/log-viewer.js +76 -0
  9. package/public/js/profile-theme.js +28 -0
  10. package/public/js/theme.js +51 -0
  11. package/public/js/tooltips.js +18 -0
  12. package/src/api/admin.js +112 -0
  13. package/src/api/agent.js +110 -0
  14. package/src/api/resource.js +211 -0
  15. package/src/api/sse.js +96 -0
  16. package/src/auth.js +94 -0
  17. package/src/config.js +110 -0
  18. package/src/db/index.js +61 -0
  19. package/src/db/migrations/001_init.sql +104 -0
  20. package/src/db/migrations/003_split_job_id_counters.sql +5 -0
  21. package/src/db/migrations/004_add_client_id.sql +8 -0
  22. package/src/db/migrations/005_add_groups.sql +14 -0
  23. package/src/db/migrations/006_events_allow_group.sql +20 -0
  24. package/src/db/migrations/007_add_user_profile.sql +10 -0
  25. package/src/server.js +179 -0
  26. package/src/services/admin-users.js +174 -0
  27. package/src/services/agents.js +56 -0
  28. package/src/services/artifacts.js +149 -0
  29. package/src/services/bus.js +23 -0
  30. package/src/services/commands.js +43 -0
  31. package/src/services/events.js +37 -0
  32. package/src/services/groups.js +79 -0
  33. package/src/services/heartbeat.js +56 -0
  34. package/src/services/jobs.js +355 -0
  35. package/src/services/logs.js +70 -0
  36. package/src/services/registry.js +347 -0
  37. package/src/services/retention.js +44 -0
  38. package/src/services/scheduler.js +101 -0
  39. package/src/services/tokens.js +28 -0
  40. package/src/web/routes.js +343 -0
  41. package/test/admin-users.test.js +127 -0
  42. package/test/scheduler.test.js +375 -0
  43. package/views/admin/agents.pug +53 -0
  44. package/views/groups/list.pug +94 -0
  45. package/views/index.pug +37 -0
  46. package/views/jobs/list.pug +45 -0
  47. package/views/jobs/show.pug +42 -0
  48. package/views/layout.pug +73 -0
  49. package/views/login.pug +16 -0
  50. package/views/mixins/log-viewer.pug +14 -0
  51. package/views/mixins/status-badge.pug +7 -0
  52. package/views/profile.pug +77 -0
  53. package/views/resources/list.pug +65 -0
@@ -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
@@ -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