@andrian.yablonskyy/thub-coordinator 1.0.27 → 1.0.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.0.27",
3
+ "version": "1.0.28",
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": {
@@ -47,7 +47,16 @@
47
47
  field('ok').className = `btn btn-${tone}`;
48
48
  field('ok').textContent = form.dataset.confirmOk || 'Confirm';
49
49
  field('dismiss').textContent = form.dataset.confirmDismiss || 'Cancel';
50
- modal.show();
50
+ // Asked from inside another modal (e.g. the resource card): close that
51
+ // one first — Bootstrap doesn't stack modals.
52
+ const host = form.closest('.modal.show');
53
+ if (host && host !== modalEl){
54
+ host.addEventListener('hidden.bs.modal', () => modal.show(), { once: true });
55
+ bootstrap.Modal.getInstance(host)?.hide();
56
+ }
57
+ else {
58
+ modal.show();
59
+ }
51
60
  });
52
61
 
53
62
  field('ok').addEventListener('click', () => {
package/src/api/admin.js CHANGED
@@ -51,6 +51,17 @@ function createAdminRouter({ services }){
51
51
  res.status(204).end();
52
52
  });
53
53
 
54
+ // Reboot a Client's host now (its running job is canceled first).
55
+ router.post('/resources/:id/reboot', (req, res, next) => {
56
+ try {
57
+ const { resource, canceledJob } = services.jobs.requestReboot(req.params.id);
58
+ res.json({ id: resource.id, rebooting: true, canceledJob });
59
+ }
60
+ catch (err){
61
+ next(err);
62
+ }
63
+ });
64
+
54
65
  // Scheduled host reboot: { cron } (5-field, host local time; "" clears).
55
66
  router.post('/resources/:id/reboot-schedule', (req, res, next) => {
56
67
  try {
@@ -30,8 +30,7 @@ const DEV_AGENT_NAME = 'virtual-agent',
30
30
  type: 'sw',
31
31
  labels: ['virtual', 'emulator'],
32
32
  capabilities: {
33
- sw: { image: 'dut-emulator:dev', registry: null, allowDockerHub: true, allowJobImages: true, cpus: 2, memory: '2g' },
34
- allowJobCommands: true
33
+ sw: { image: 'dut-emulator:dev', registry: null, allowDockerHub: true, allowJobImages: true, cpus: 2, memory: '2g' }
35
34
  },
36
35
  address: '10.0.0.41'
37
36
  },
@@ -47,8 +46,7 @@ const DEV_AGENT_NAME = 'virtual-agent',
47
46
  usbs: [{ path: '/dev/thub/dut1-usb', index: 1, present: false }],
48
47
  relays: [{ channel: 0, baseUrl: 'http://localhost:3000' }],
49
48
  power: { method: 'relay' }
50
- },
51
- allowJobCommands: false
49
+ }
52
50
  },
53
51
  address: '10.0.0.42'
54
52
  }
@@ -69,12 +67,12 @@ const DEV_AGENT_NAME = 'virtual-agent',
69
67
  [0.1, 'cli', 'sw', 'PASSED', 73, 'alice']
70
68
  ],
71
69
  LOG_SCRIPT = [
72
- ['runner', 'downloading firmware (virtual)'],
73
- ['runner', 'downloading tests (virtual)'],
70
+ ['runner', 'downloading app.bin (virtual)'],
71
+ ['runner', 'cloning yourorg/firmware-tests at main (virtual)'],
74
72
  ['flash', 'st-flash write app.bin 0x08000000 ... done (virtual)'],
75
73
  ['uart', '[BOOT] app v1.4.0-dev'],
76
74
  ['uart', '[INIT] peripherals ok'],
77
- ['runner', 'running: run-tests.sh --suite default'],
75
+ ['runner', 'running: sh -c "./ci/run.sh"'],
78
76
  ['runner', 'test_boot ............ ok'],
79
77
  ['runner', 'test_uart_echo ....... ok'],
80
78
  ['runner', 'test_flash_crc ....... ok'],
@@ -83,8 +81,10 @@ const DEV_AGENT_NAME = 'virtual-agent',
83
81
 
84
82
  spec = (type, user, extra = {}) => ({
85
83
  target: { type, labels: ['virtual'] },
86
- firmware: type === 'sw' ? { image: 'alpine:3.20' } : { url: 'https://artifactory.example.com/fw-local/app/1.4.0-dev/app.bin' },
87
- tests: { url: 'https://artifactory.example.com/fw-local/app/1.4.0-dev/tests.tar.gz', suite: 'default' },
84
+ command: './ci/run.sh',
85
+ downloads: [{ url: 'https://artifactory.example.com/fw-local/app/1.4.0-dev/app.bin' }],
86
+ ...(type === 'sw' ? { image: 'alpine:3.20' } : {}),
87
+ git: { url: 'https://github.com/yourorg/firmware-tests.git', ref: 'main', depth: 1 },
88
88
  ...(user ? { user } : {}),
89
89
  meta: { repo: 'yourorg/firmware', branch: 'main', virtual: true },
90
90
  ...extra
@@ -278,7 +278,7 @@ function startDevMode(services, config, env = process.env){
278
278
  '*** DEV MODE (DEV_MODE=1): virtual Clients virtual-sw-01 / virtual-hw-01 are running in-process' +
279
279
  `${seeded ? `, and ${seeded} past jobs + 3 live ones were seeded` : ''}.\n` +
280
280
  `*** Virtual agent token: ${DEV_AGENT_TOKEN}\n` +
281
- `*** e.g. thub --url ${config.publicUrl} --token ${DEV_AGENT_TOKEN} run --type sw --image alpine --tests https://x/t.tgz`
281
+ `*** e.g. thub --url ${config.publicUrl} --token ${DEV_AGENT_TOKEN} run --type sw --docker-image alpine --command 'uname -a'`
282
282
  );
283
283
  return virtual;
284
284
  }
@@ -13,7 +13,7 @@
13
13
 
14
14
  'use strict';
15
15
 
16
- const { validateJobSpec, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES } = require('@andrian.yablonskyy/thub-common'),
16
+ const { validateJobSpec, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
17
17
  { paginate } = require('./list-prefs');
18
18
 
19
19
  function rowToJob(row){
@@ -145,22 +145,15 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
145
145
  spec.target.client = resource.id;
146
146
  }
147
147
 
148
- // A job-supplied Docker image or command needs a Client that opted in —
149
- // say which, rather than the generic "no resource can satisfy".
150
- const needs = { jobImage: Boolean(spec.firmware.image), jobCommand: Boolean(spec.tests.command) },
148
+ // A job-supplied Docker image needs an SW Client that opted in — say so,
149
+ // rather than the generic "no resource can satisfy".
150
+ const needs = { jobImage: Boolean(spec.image) },
151
151
  { type, labels, group, client } = spec.target;
152
- if ((needs.jobImage || needs.jobCommand) && registry.everSatisfiable(type, labels, group, client) &&
153
- !registry.everSatisfiable(type, labels, group, client, needs)){
154
- const missing = [
155
- needs.jobImage && !registry.everSatisfiable(type, labels, group, client, { jobImage: true }) &&
156
- `job-supplied Docker images (${spec.firmware.image}) — "sw": { "allowJobImages": true }`,
157
- needs.jobCommand && !registry.everSatisfiable(type, labels, group, client, { jobCommand: true }) &&
158
- 'job-supplied commands (--run) — "allowJobCommands": true'
159
- ].filter(Boolean);
152
+ if (needs.jobImage && registry.everSatisfiable(type, labels, group, client) && !registry.everSatisfiable(type, labels, group, client, needs)){
160
153
  throw Object.assign(
161
154
  new Error(
162
- `No matching Client runs ${missing.length ? missing.join(', nor ') : 'both a job-supplied Docker image and command together'}` +
163
- ' — enable it in a Client\'s config'
155
+ `No matching SW Client runs job-supplied Docker images (${spec.image}) — enable it on a Client ` +
156
+ 'with "sw": { "allowJobImages": true } in its config'
164
157
  ),
165
158
  { status: 422 }
166
159
  );
@@ -263,7 +256,8 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
263
256
  }
264
257
 
265
258
  // Agent: POST /jobs/:id/cancel
266
- function cancel(id, { agentId, isAdmin }){
259
+ // `reason`: shown as the job's message (e.g. "Canceled: user reboot request").
260
+ function cancel(id, { agentId, isAdmin, reason }){
267
261
  const job = get(id);
268
262
  if (!job){
269
263
  throw Object.assign(new Error('Unknown job'), { status: 404 });
@@ -278,7 +272,35 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
278
272
  if (job.resource_id){
279
273
  bus.emit('command', { resourceId: job.resource_id, command: 'cancel-job', jobId: id });
280
274
  }
281
- return setState(id, JOB_STATES.CANCELED);
275
+ return setState(id, JOB_STATES.CANCELED, reason ? { message: reason } : {});
276
+ }
277
+
278
+ // Admin "Reboot" on a Client (resource card): its running job is canceled
279
+ // first (the host is going down anyway), then a `reboot` command goes out
280
+ // with its next heartbeat — the Client reboots the host through its root
281
+ // reboot helper once nothing on it is busy. Refused for a Client that's
282
+ // offline (it would reboot whenever it next reconnects) or can't reboot.
283
+ function requestReboot(resourceId, { by } = {}){
284
+ const r = registry.get(resourceId);
285
+ if (!r){
286
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
287
+ }
288
+ if (r.status === RESOURCE_STATES.OUT_OF_SERVICE || !r.last_heartbeat_at){
289
+ throw Object.assign(new Error(`${r.name} is offline — it can only be rebooted while it's connected`), { status: 409 });
290
+ }
291
+ if (r.capabilities?.rebootSupported === false){
292
+ throw Object.assign(
293
+ new Error(`${r.name}'s host has no reboot helper — reinstall the Client as root (sudo npm i -g @andrian.yablonskyy/thub-client)`),
294
+ { status: 409 }
295
+ );
296
+ }
297
+ const job = activeForResource(resourceId);
298
+ if (job){
299
+ cancel(job.id, { isAdmin: true, reason: 'Canceled: user reboot request' });
300
+ }
301
+ bus.emit('command', { resourceId, command: 'reboot', reason: `user reboot request${by ? ` by ${by}` : ''}` });
302
+ events.record('resource', resourceId, 'resource.reboot_requested', { by, canceledJob: job?.id || null });
303
+ return { resource: r, canceledJob: job?.id || null };
282
304
  }
283
305
 
284
306
  // Queued jobs pinned to a resource (`target.client`) can never run once it's
@@ -493,6 +515,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
493
515
  setState,
494
516
  cancel,
495
517
  cancelPinnedTo,
518
+ requestReboot,
496
519
  removeResource,
497
520
  completeRemoval,
498
521
  activeForResource,
@@ -67,7 +67,6 @@ const MAX_DEVICES = 8,
67
67
  present: typeof d?.present === 'boolean' ? d.present : null
68
68
  });
69
69
 
70
- // Plus `allowJobCommands` (either type): runs a job's own `tests.command`.
71
70
  function sanitizeCapabilities(caps){
72
71
  if (!caps || typeof caps !== 'object'){
73
72
  return null;
@@ -76,7 +75,6 @@ function sanitizeCapabilities(caps){
76
75
  return typed
77
76
  ? {
78
77
  ...typed,
79
- allowJobCommands: caps.allowJobCommands === true,
80
78
  // Whether the host has the root reboot helper (thub-client-reboot.path)
81
79
  // a scheduled reboot needs; unknown (null) for older Clients.
82
80
  rebootSupported: typeof caps.rebootSupported === 'boolean' ? caps.rebootSupported : null
@@ -498,14 +496,12 @@ function createRegistryService(db, { bus, events }){
498
496
  }
499
497
 
500
498
  // `needs`: what a job brings that a Client must have opted in to run —
501
- // jobImage (firmware.image; sw.allowJobImages) and jobCommand
502
- // (tests.command; allowJobCommands).
499
+ // jobImage (the job's own Docker image; sw.allowJobImages).
503
500
  function matchesTarget(r, labels, groupId, resourceId, needs = {}){
504
501
  return labels.every((l) => r.labels.includes(l)) &&
505
502
  (!groupId || r.group_ids.includes(groupId)) &&
506
503
  (!resourceId || r.id === resourceId) &&
507
- (!needs.jobImage || r.capabilities?.sw?.allowJobImages === true) &&
508
- (!needs.jobCommand || r.capabilities?.allowJobCommands === true);
504
+ (!needs.jobImage || r.capabilities?.sw?.allowJobImages === true);
509
505
  }
510
506
 
511
507
  // Admin: remove a resource from the registry (dashboard "Remove"). Refused
@@ -17,7 +17,7 @@ const { JOB_STATES, BUSY_SOURCES } = require('@andrian.yablonskyy/thub-common');
17
17
 
18
18
  // What a job needs a Client to have opted in to (registry matchesTarget).
19
19
  function jobNeeds(spec){
20
- return { jobImage: Boolean(spec.firmware?.image), jobCommand: Boolean(spec.tests?.command) };
20
+ return { jobImage: Boolean(spec.image) };
21
21
  }
22
22
 
23
23
  function rowToJob(row){
package/src/web/routes.js CHANGED
@@ -412,6 +412,19 @@ function createWebRouter({ services, config }){
412
412
  res.redirect(returnTo(req, '/resources'));
413
413
  });
414
414
 
415
+ // Reboot now (resource card footer): cancels a running job, then the
416
+ // Client reboots its host on its next heartbeat (jobs.requestReboot).
417
+ router.post('/resources/:id/reboot', requireAdminRole, (req, res) => {
418
+ try {
419
+ const { resource: r, canceledJob } = services.jobs.requestReboot(req.params.id, { by: req.session.user.username });
420
+ flash(req, 'warning', `Rebooting ${r.name}'s host on its next heartbeat.` + (canceledJob ? `\nCanceled its running job ${canceledJob}.` : ''));
421
+ }
422
+ catch (err){
423
+ flash(req, 'danger', err.message);
424
+ }
425
+ res.redirect(returnTo(req, '/resources'));
426
+ });
427
+
415
428
  // Scheduled host reboot (resource card): save a cron expression, or clear
416
429
  // it; the Client applies it on its next heartbeat.
417
430
  router.post('/resources/:id/reboot-schedule', requireAdminRole, (req, res) => {
@@ -55,7 +55,7 @@ function setup(jobRetention = 'forever'){
55
55
 
56
56
  // A job that finished at `finishedAt` (or is still queued, if null), with a log line.
57
57
  function job(finishedAt){
58
- const spec = { target: { type: 'sw' }, firmware: { url: 'https://x/a' }, tests: { url: 'https://x/t' } },
58
+ const spec = { target: { type: 'sw' }, command: './run.sh' },
59
59
  j = jobs.create({ agentId: agent.id, source: 'cli', spec });
60
60
  db.prepare('INSERT INTO job_logs (job_id, seq, ts, stream, line) VALUES (?, 1, ?, \'runner\', ?)').run(j.id, NOW.toISOString(), 'x'.repeat(2000));
61
61
  if (finishedAt){
@@ -74,7 +74,7 @@ test('jobs.page sorts and pages in SQL, with empty values last', () => {
74
74
  jobs.create({
75
75
  agentId: agent.id,
76
76
  source: 'cli',
77
- spec: { target: { type: 'sw' }, firmware: { url: 'https://x/a' }, tests: { url: 'https://x/t' }, ...(user ? { user } : {}) }
77
+ spec: { target: { type: 'sw' }, command: './run.sh', ...(user ? { user } : {}) }
78
78
  }).id),
79
79
 
80
80
  byUser = (dir) => jobs.page({ sort: 'user', dir, size: 'all' }).rows.map((j) => j.spec.user ?? null);
@@ -57,7 +57,7 @@ function buildTestServices(overrides = {}){
57
57
  jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
58
58
  scheduler = createScheduler(db, { bus, events, registry, config }),
59
59
  heartbeatMonitor = createHeartbeatMonitor(db, { bus, events, registry, jobs, config });
60
- return { db, registry, agents, groups, artifacts, jobs, scheduler, heartbeatMonitor, artifactsDir };
60
+ return { db, bus, registry, agents, groups, artifacts, jobs, scheduler, heartbeatMonitor, artifactsDir };
61
61
  }
62
62
 
63
63
  function registerResource(registry, { name, type, labels = [], groups = [], clientId } = {}){
@@ -68,8 +68,7 @@ function registerResource(registry, { name, type, labels = [], groups = [], clie
68
68
  function makeSpec(overrides = {}){
69
69
  return {
70
70
  target: { type: 'sw', labels: [] },
71
- firmware: { url: 'https://x/app.bin' },
72
- tests: { url: 'https://x/tests.tar.gz' },
71
+ command: './run.sh',
73
72
  ...overrides
74
73
  };
75
74
  }
@@ -528,7 +527,7 @@ test('a removed Client re-registers as a fresh resource under the same name', ()
528
527
  test('a job-supplied Docker image only goes to SW Clients that allow it; otherwise 422', () => {
529
528
  const { registry, agents, jobs, scheduler } = buildTestServices(),
530
529
  { agent } = agents.create({ name: 'dev', kind: 'cli' }),
531
- imageJob = { target: { type: 'sw', labels: [] }, firmware: { image: 'alpine' }, tests: { url: 'https://x/t.tar.gz' } },
530
+ imageJob = { target: { type: 'sw', labels: [] }, image: 'alpine', command: './run.sh' },
532
531
  strict = registerResource(registry, { name: 'sw-strict', type: 'sw' });
533
532
  registry.heartbeat(strict.id, { state: 'idle' });
534
533
 
@@ -544,25 +543,25 @@ test('a job-supplied Docker image only goes to SW Clients that allow it; otherwi
544
543
 
545
544
  const fileJob = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
546
545
  scheduler.runPass();
547
- assert.equal(jobs.get(fileJob.id).resource_id, strict.id); // firmware URLs still go anywhere
546
+ assert.equal(jobs.get(fileJob.id).resource_id, strict.id); // jobs without an image still go anywhere
548
547
  });
549
548
 
550
- test('a job-supplied command only goes to Clients with allowJobCommands; otherwise 422 naming it', () => {
549
+ test('every job carries its command and runs on any matching Client; an old-Agent spec is refused', () => {
551
550
  const { registry, agents, jobs, scheduler } = buildTestServices(),
552
551
  { agent } = agents.create({ name: 'dev', kind: 'cli' }),
553
- commandJob = makeSpec({ tests: { url: 'https://x/t.tar.gz', command: 'make test' } }),
554
- plain = registerResource(registry, { name: 'sw-plain', type: 'sw' });
555
- registry.heartbeat(plain.id, { state: 'idle' });
556
-
557
- assert.throws(() => jobs.create({ agentId: agent.id, source: 'cli', spec: commandJob }), /allowJobCommands/);
552
+ r = registerResource(registry, { name: 'sw-any', type: 'sw' });
553
+ registry.heartbeat(r.id, { state: 'idle' });
558
554
 
559
- const { resourceId } = registry.registerAuto({
560
- clientId: 'c-cmd', name: 'sw-cmd', type: 'sw', labels: [], capabilities: { sw: { image: 'emu' }, allowJobCommands: true }
561
- });
562
- registry.heartbeat(resourceId, { state: 'idle' });
563
- const job = jobs.create({ agentId: agent.id, source: 'cli', spec: commandJob });
555
+ const job = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ command: 'make test', args: ['-j4'] }) });
564
556
  scheduler.runPass();
565
- assert.equal(jobs.get(job.id).resource_id, resourceId);
557
+ assert.equal(jobs.get(job.id).resource_id, r.id);
558
+ assert.equal(jobs.get(job.id).spec.command, 'make test');
559
+
560
+ assert.throws(
561
+ () => jobs.create({ agentId: agent.id, source: 'cli', spec: { target: { type: 'sw' }, firmware: { url: 'https://x/a' }, tests: { url: 'https://x/t' } } }),
562
+ /older Agent.*--command/
563
+ );
564
+ assert.throws(() => jobs.create({ agentId: agent.id, source: 'cli', spec: { target: { type: 'sw' } } }), /command/);
566
565
  });
567
566
 
568
567
  test('reboot schedule: validated, then resent on heartbeats until the Client reports applying it', () => {
@@ -585,6 +584,40 @@ test('reboot schedule: validated, then resent on heartbeats until the Client rep
585
584
  assert.equal(registry.pendingRebootSchedule(r.id), null);
586
585
  });
587
586
 
587
+ test('reboot now: refused offline or without a helper; cancels a running job first, then sends reboot', () => {
588
+ const { bus, registry, agents, jobs, scheduler } = buildTestServices(),
589
+ { agent } = agents.create({ name: 'dev', kind: 'cli' }),
590
+ reg = (name, rebootSupported) => registry.get(registry.registerAuto({
591
+ clientId: `c-${name}`, name, type: 'sw', labels: [], capabilities: { sw: { image: 'emu' }, rebootSupported }
592
+ }).resourceId),
593
+ offline = reg('rb-offline', true),
594
+ noHelper = reg('rb-nohelper', false),
595
+ idle = reg('rb-idle', true),
596
+ busy = reg('rb-busy', true),
597
+ commands = [];
598
+ for (const r of [noHelper, idle, busy]){
599
+ registry.heartbeat(r.id, { state: 'idle' });
600
+ }
601
+ bus.on('command', (c) => commands.push(c));
602
+
603
+ assert.throws(() => jobs.requestReboot(offline.id), /offline/);
604
+ assert.throws(() => jobs.requestReboot(noHelper.id), /no reboot helper/);
605
+
606
+ assert.equal(jobs.requestReboot(idle.id).canceledJob, null);
607
+ assert.deepEqual(commands.map((c) => [c.resourceId, c.command]), [[idle.id, 'reboot']]);
608
+ commands.length = 0;
609
+
610
+ const job = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', client: busy.id } }) });
611
+ scheduler.runPass();
612
+ jobs.setState(job.id, JOB_STATES.RUNNING);
613
+ assert.equal(jobs.requestReboot(busy.id, { by: 'admin' }).canceledJob, job.id);
614
+ assert.deepEqual(commands.map((c) => c.command), ['cancel-job', 'reboot']); // same heartbeat reply, in order
615
+ assert.equal(commands[1].reason, 'user reboot request by admin');
616
+ const after = jobs.get(job.id);
617
+ assert.equal(after.state, JOB_STATES.CANCELED);
618
+ assert.equal(after.message, 'Canceled: user reboot request');
619
+ });
620
+
588
621
  test('a resource can belong to several groups at once', () => {
589
622
  const { registry, groups } = buildTestServices(),
590
623
  g1 = groups.create({ name: 'g1' }),
@@ -337,6 +337,37 @@ mixin resourceCard(r, groupsById, returnTo)
337
337
  .tab-pane.fade(id=`rc-${r.id}-config` role="tabpanel" aria-labelledby=`rc-${r.id}-config-tab` tabindex="0")
338
338
  dl.row.mb-0
339
339
  +rebootScheduleRow(r, returnTo)
340
+ if user.role === 'admin'
341
+ .modal-footer
342
+ +rebootButton(r, returnTo)
343
+
344
+ //- Reboot the Client's host now (card footer): confirmed first — naming the
345
+ //- job that gets canceled, if one is running — then sent with the Client's
346
+ //- next heartbeat. Disabled while it's offline or has no reboot helper.
347
+ mixin rebootButton(r, returnTo)
348
+ - const offline = r.status === 'OUT_OF_SERVICE' || !r.last_heartbeat_at
349
+ - const unsupported = r.capabilities && r.capabilities.rebootSupported === false
350
+ - const why = offline ? 'Offline — a Client can only be rebooted while connected' : unsupported ? "This host has no reboot helper — reinstall the Client as root" : null
351
+ if why
352
+ span.d-inline-block(tabindex="0" data-bs-toggle="tooltip" data-bs-title=why)
353
+ button.btn.btn-outline-danger(type="button" disabled style="pointer-events: none;")
354
+ i.bi.bi-bootstrap-reboot.me-1
355
+ | Reboot
356
+ else
357
+ form(
358
+ method="post"
359
+ action=`/resources/${r.id}/reboot`
360
+ data-confirm=r.activeJob
361
+ ? `${r.name} is running job ${r.activeJob.id}. Rebooting cancels that job (reason: user reboot request), then reboots the host on the Client's next heartbeat. Every Client instance on that host goes down with it.`
362
+ : `Reboot the host of ${r.name}? It reboots on the Client's next heartbeat, once nothing on the host is busy. Every Client instance on that host goes down with it.`
363
+ data-confirm-title=r.activeJob ? `Cancel job ${r.activeJob.id} and reboot?` : `Reboot ${r.name}?`
364
+ data-confirm-ok=r.activeJob ? 'Cancel job and reboot' : 'Reboot'
365
+ data-confirm-dismiss="Don't reboot"
366
+ )
367
+ input(type="hidden" name="returnTo" value=returnTo)
368
+ button.btn.btn-outline-danger(type="submit")
369
+ i.bi.bi-bootstrap-reboot.me-1
370
+ | Reboot
340
371
 
341
372
  //- The resource card's Details tab.
342
373
  mixin resourceDetails(r, groupsById, returnTo, local, external)