@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
@@ -0,0 +1,118 @@
1
+ +section('overview', 'Product overview', 'info-circle')
2
+ p.
3
+ #[strong TestHub] is a small, self-hosted job network. It lets CI/CD pipelines and individual
4
+ developers run firmware and software tests on #[strong real hardware] or #[strong emulators]
5
+ that sit in a private lab behind a firewall. Nothing in the lab has to be reachable from outside.
6
+
7
+ .row.g-3.mb-3
8
+ .col-md-4
9
+ .card.h-100
10
+ .card-body
11
+ h3.h6.card-title
12
+ i.bi.bi-hdd-network.me-1
13
+ | Coordinator
14
+ p.card-text.small.mb-0.
15
+ This service. It holds the job queue and the resource registry, and runs the scheduler and
16
+ the heartbeat monitor. It stores logs and artifacts in SQLite plus a local directory,
17
+ and serves this dashboard. It's the only thing that needs a public HTTPS port.
18
+ .col-md-4
19
+ .card.h-100
20
+ .card-body
21
+ h3.h6.card-title
22
+ i.bi.bi-terminal.me-1
23
+ | Agent (#[code thub])
24
+ p.card-text.small.mb-0.
25
+ A CLI, and the only way to create jobs. The same #[code thub run] command works in a
26
+ GitHub Actions step and on a developer's laptop, so a CI failure can be reproduced exactly.
27
+ .col-md-4
28
+ .card.h-100
29
+ .card-body
30
+ h3.h6.card-title
31
+ i.bi.bi-motherboard.me-1
32
+ | Client (#[code thub-client])
33
+ p.card-text.small.mb-0.
34
+ A daemon (a systemd service) on a lab machine. Each instance owns one DUT slot. It connects
35
+ #[em outbound] to the Coordinator, picks up jobs, runs them and streams logs back.
36
+ Type #[strong HW] drives a physical board; type #[strong SW] runs a DUT emulator in Docker.
37
+
38
+ h3.h6 How a job flows
39
+ ol.small
40
+ li #[code thub run …] submits a job spec (type, labels, command, inputs) with an agent token.
41
+ li The Coordinator queues it (#[code QUEUED]) and the scheduler assigns it to an idle Client whose type, labels and group match.
42
+ li The Client accepts it, clones #[code --git-repo], downloads #[code --download-file] files and prepares the DUT (#[code PREPARING]).
43
+ li It runs #[code --command] with #[code sh -c], and output streams live to the Agent and the dashboard (#[code RUNNING]).
44
+ li The exit code is the verdict: #[code 0] = #[code PASSED], anything else = #[code FAILED]. JUnit files and artifacts are uploaded.
45
+ li The Agent exits with the verdict code, so a CI step passes or fails with it.
46
+
47
+ h3.h6 Key design rules
48
+ ul.small
49
+ li #[strong Clients only connect outbound] over HTTPS: registration, heartbeat, long-poll, logs, results. The lab firewall allows no inbound traffic.
50
+ li #[strong The Agent is the only way to create jobs.] CI and manual runs are identical.
51
+ li #[strong The Coordinator is the single source of truth.] It assigns a job inside a single SQLite transaction, so a job can never be assigned twice.
52
+ li #[strong A job is a command, not a script upload.] The Client runs your #[code --command] as its service user, never as root. Issue agent tokens only to trusted pipelines and people.
53
+
54
+ h3.h6 Resource and job states
55
+ .row.g-3.small
56
+ .col-md-6
57
+ table.table.table-sm.mb-0
58
+ thead
59
+ tr
60
+ th Resource
61
+ th Meaning
62
+ tbody
63
+ tr
64
+ td: code REGISTERED
65
+ td Just registered, no heartbeat yet
66
+ tr
67
+ td: code IDLE
68
+ td Online and free: schedulable
69
+ tr
70
+ td: code BUSY
71
+ td Running a job (#[code ci] / #[code cli]) or locked by hand (#[code local])
72
+ tr
73
+ td: code OUT_OF_SERVICE
74
+ td 3 heartbeats missed
75
+ tr
76
+ td: code MAINTENANCE
77
+ td Disabled by an admin
78
+ .col-md-6
79
+ table.table.table-sm.mb-0
80
+ thead
81
+ tr
82
+ th Job
83
+ th Meaning
84
+ tbody
85
+ tr
86
+ td: code QUEUED → ASSIGNED
87
+ td Waiting for, then bound to, a Client
88
+ tr
89
+ td: code PREPARING → RUNNING
90
+ td Inputs and DUT being prepared, then the command runs
91
+ tr
92
+ td: code PASSED / FAILED
93
+ td The command's exit code: 0, or non-zero
94
+ tr
95
+ td: code ERROR / TIMEOUT
96
+ td Infrastructure problem, or #[code --timeout] exceeded
97
+ tr
98
+ td: code CANCELED / LOST
99
+ td Canceled, or the Client went offline mid-job
100
+
101
+ +section('use-cases', 'Use cases', 'lightbulb')
102
+ -
103
+ const useCases = [
104
+ { icon: 'cpu', title: 'Hardware-in-the-loop CI', text: 'Every push builds firmware in the cloud, then a GitHub Actions test job runs `thub run --type hw --wait`: a lab Client flashes a real board through ST-Link, captures its UART, runs the tests and fails the pipeline on a non-zero exit.' },
105
+ { icon: 'pc-display', title: 'Emulator (SW) test farms', text: 'SW Clients on real or virtual Linux machines run each job’s DUT image (QEMU, Renode, a simulator) in a sandboxed Docker container. The tests talk to it at $THUB_DUT_HOST. Scale out by adding VMs.' },
106
+ { icon: 'person-workspace', title: 'Remote access for developers', text: 'Developers reproduce a CI failure on the exact bench it happened on (`--client lab-hw-01`) from home, with the same command CI ran, and stream the board’s console live.' },
107
+ { icon: 'lock', title: 'Shared benches without collisions', text: 'The scheduler hands out one job per bench. An engineer who needs a board by hand runs `thub-client lock` so nothing is scheduled there until `unlock`.' },
108
+ { icon: 'collection', title: 'Dedicated pools', text: 'Groups and labels (`board:nucleo-f401re`, `uart`) route jobs to the right hardware, e.g. a nightly pool separate from the PR pool.' },
109
+ { icon: 'box-seam', title: 'Any command-line test suite', text: 'Anything that runs from a shell and reports through its exit code: pytest, ctest, Robot Framework, a Dockerized test runner, a flashing and smoke-test script.' }
110
+ ]
111
+ .row.g-3
112
+ each uc in useCases
113
+ .col-md-6
114
+ .d-flex.gap-3
115
+ i.bi.fs-4.text-primary(class=`bi-${uc.icon}`)
116
+ div
117
+ h3.h6.mb-1= uc.title
118
+ p.small.text-body-secondary.mb-0!= uc.text.replace(/`([^`]+)`/g, '<code>$1</code>')
@@ -0,0 +1,37 @@
1
+ +section('quick-start', 'Quick start', 'rocket-takeoff')
2
+ p Five steps from nothing to a first job. Every step links to its full section.
3
+ ol.thub-help-steps
4
+ li
5
+ strong Install the Coordinator
6
+ | on a cloud VM behind an HTTPS reverse proxy (#[a(href="#coordinator") Coordinator setup]):
7
+ +code('Coordinator host').
8
+ sudo npm i -g @andrian.yablonskyy/thub-coordinator
9
+ thub-admin join-key generate # → put it in clientJoinKey
10
+ nano ~/.config/thub/coordinator.json # publicUrl, clientJoinKey
11
+ sudo systemctl restart thub-coordinator
12
+ thub-admin create-admin admin 'a-strong-password'
13
+ li
14
+ strong Install a Client
15
+ | on each lab machine, with the same join key (#[a(href="#client-setup") Client setup]):
16
+ +code('Lab machine (Ubuntu 26.04)').
17
+ sudo apt install -y nodejs npm docker.io # + stlink-tools openocd for HW
18
+ sudo npm i -g @andrian.yablonskyy/thub-client
19
+ nano ~/.config/thub/client.json # coordinatorUrl, type, joinKey
20
+ sudo systemctl enable --now thub-client@client
21
+ | It shows up on #[a(href="/resources") Resources] by itself, usually within 10 s.
22
+ li
23
+ strong Issue an agent token
24
+ | on #[a(href="/admin/agents") Agents] (admins), or on the Coordinator host with
25
+ | #[code thub-admin agent add &lt;name&gt; --kind ci|cli]. The token is shown once.
26
+ li
27
+ strong Install and configure the Agent
28
+ | (#[a(href="#agent-setup") Agent setup]):
29
+ +code('Developer machine').
30
+ npm i -g @andrian.yablonskyy/thub-agent
31
+ thub config set url #{coordinatorUrl}
32
+ thub config set token agt_…
33
+ li
34
+ strong Run a job
35
+ | (#[a(href="#agent-cli") Agent CLI reference]):
36
+ +code('Developer machine').
37
+ thub run --type sw --command 'uname -a && echo hello from the lab' --wait
@@ -0,0 +1,42 @@
1
+ +section('troubleshooting', 'Troubleshooting', 'life-preserver')
2
+ //- `…` in a question or answer marks a command or parameter: rendered as
3
+ //- <code>, with everything else HTML-escaped first (so `<instance>` shows as text).
4
+ -
5
+ const faq = [
6
+ ['A Client doesn\'t appear on Resources', 'Check `journalctl -u thub-client@<instance> -f`. A `503` at registration means the Coordinator has no `clientJoinKey`; a `401` means the `joinKey` differs. Also check that `coordinatorUrl` is reachable from the lab (`curl -sI <url>/login`).'],
7
+ ['A Client flaps or two machines show as one resource', 'Both use the same `.client-id` (a cloned VM or a shared config file). Delete `<varDir>/<instance>/.client-id` on one of them and restart it, or set a distinct `clientId`.'],
8
+ ['Name already in use (`409`)', 'Another live Client holds that name. Rename one of them in its config or from the resource card. A name held by an `OUT_OF_SERVICE` resource is reclaimed automatically.'],
9
+ ['The job is rejected with `422`', 'No registered Client can ever match its type, labels, group or client. Compare `thub resources` with your `--type`, `--board`/`--label` and `--group`.'],
10
+ ['The job stays `QUEUED`', 'Matching Clients are all `BUSY`, locked (`local`), in `MAINTENANCE` or offline, or the job is pinned with `--client`. It times out after `--timeout`.'],
11
+ ['`ERROR` during prepare', 'The clone, download or DUT image pull failed; the job log names the reason. For git, check the Client user\'s keys and `known_hosts` (`ssh -T git@<host>` as that user). For images, check the Client user\'s `docker login`.'],
12
+ ['Permission denied on `/dev/ttyUSB*` or `docker.sock`', 'Docker or the device groups were added after the Client was installed. Re-run `sudo npm i -g @andrian.yablonskyy/thub-client` so the unit gets the `dialout`/`plugdev`/`docker` groups, then restart.'],
13
+ ['A configured device is shown as missing', 'Check the `devpath` with `udevadm info -a -n <dev>`, then `thub-client udev --print`; restart the Client to regenerate the udev rules.'],
14
+ ['Job `LOST`', 'The Client missed 3 heartbeats (network, reboot, crash). With `requeueOnLost` the job is retried once on another Client.'],
15
+ ['A job can\'t write to `~` (read-only file system)', 'Under systemd the Client can only write to its state directory. Write to `$THUB_WORK_DIR`, and set `DOCKER_CONFIG` there for `docker login`.'],
16
+ ['Live logs don\'t stream through the proxy', 'Turn off response buffering for the Coordinator (nginx: `proxy_buffering off;`, Caddy: `flush_interval -1`).'],
17
+ ['Finding a job, resource, group or agent', 'Use the search box on its list page (press `/` to jump to it). Every word must match, e.g. `lab-hw-01 FAILED` or a git ref. Job search covers ids, users, resources, agents, commands, repos and `--meta`, but not `--env` values (secrets). On a job page, search the log above the log viewer: `Enter`/`Shift+Enter` step through matches.'],
18
+ ['Lists don\'t update by themselves (navbar shows Offline or Paused)', '`Paused`: you\'re editing something on the page (a field, the inline rename, a dialog); it resumes when you\'re done. `Offline`: the browser can\'t hold the `/live` stream open, often a proxy buffering it (nginx: `proxy_buffering off;`); reloading the page always shows current data. `Session ended`: sign in again. Background updates never extend the session\'s idle timeout.'],
19
+ ['Locked out of the dashboard', 'Restart the Coordinator once with `THUB_BOOTSTRAP_ADMIN_PASSWORD=<new>` (resets the `admin` user), then unset it.']
20
+ ]
21
+ const withCode = (text) => text
22
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
23
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
24
+ .accordion#help-faq
25
+ each item, i in faq
26
+ .accordion-item
27
+ h3.accordion-header
28
+ button.accordion-button.collapsed.small(
29
+ type="button"
30
+ data-bs-toggle="collapse"
31
+ data-bs-target=`#faq-${i}`
32
+ aria-expanded="false"
33
+ aria-controls=`faq-${i}`
34
+ )
35
+ //- One inline box: .accordion-button is flex, which would drop
36
+ //- the spaces around <code>.
37
+ span!= withCode(item[0])
38
+ .accordion-collapse.collapse(id=`faq-${i}` data-bs-parent="#help-faq")
39
+ .accordion-body.small!= withCode(item[1])
40
+ p.small.text-body-secondary.mt-3.mb-0.
41
+ The full design reference (API, state machines, database schema) is in the project README. Everything
42
+ that happens is also recorded per job: open it from #[a(href="/jobs") Jobs] for its spec, timeline and log.
@@ -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
- .row.g-3.mb-4
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'}`)
@@ -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
- +listToolbar(list, 'jobs')
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
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
- body
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,34 @@
1
1
  mixin logViewer(jobId, jobActive)
2
- .mb-2
3
- select#stream-filter.form-select.form-select-sm.w-auto.d-inline-block
2
+ //- Stream filter + search (public/js/log-viewer.js): matches are
3
+ //- highlighted; Enter / Shift+Enter or the arrows step through them.
4
+ .d-flex.flex-wrap.align-items-center.gap-2.mb-2
5
+ select#stream-filter.form-select.form-select-sm.w-auto(aria-label="Log stream")
4
6
  option(value="") all streams
5
7
  option(value="uart") uart
6
8
  option(value="runner") runner
7
9
  option(value="flash") flash
8
10
  option(value="emulator") emulator
9
- pre#log-viewer.bg-body-tertiary.p-3.rounded(
11
+ .input-group.input-group-sm.thub-search(role="search")
12
+ span.input-group-text
13
+ i.bi.bi-search
14
+ input#log-search.form-control(
15
+ type="search"
16
+ placeholder="Search log…"
17
+ aria-label="Search log"
18
+ autocomplete="off"
19
+ maxlength="200"
20
+ data-bs-toggle="tooltip"
21
+ data-bs-title="Enter: next match · Shift+Enter: previous · Esc: clear"
22
+ )
23
+ span#log-search-count.input-group-text.small(aria-live="polite" hidden)
24
+ button#log-search-prev.btn.btn-outline-secondary(type="button" aria-label="Previous match" disabled)
25
+ i.bi.bi-chevron-up
26
+ button#log-search-next.btn.btn-outline-secondary(type="button" aria-label="Next match" disabled)
27
+ i.bi.bi-chevron-down
28
+ .form-check.form-check-inline.small.mb-0
29
+ input#log-search-only.form-check-input(type="checkbox")
30
+ label.form-check-label(for="log-search-only") Display only matching lines
31
+ pre#log-viewer.bg-body-tertiary.p-3.rounded.position-relative(
10
32
  style="height: 420px; overflow-y: auto;"
11
33
  data-job-id=jobId
12
34
  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")
@@ -6,15 +6,18 @@ include ../mixins/list-controls
6
6
 
7
7
  block content
8
8
  h1.h4.mb-3 Resources
9
- p.text-body-secondary.small.
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
- +updatesToolbar('client', 'clients', '/resources')
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
- +listToolbar(list, 'resources')
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