@andrian.yablonskyy/thub-coordinator 1.0.12 → 1.0.14
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 +4 -4
- package/package.json +1 -1
- package/src/api/resource.js +4 -2
- package/src/config.js +6 -3
- package/src/db/migrations/012_add_uptime_activity.sql +7 -0
- package/src/services/jobs.js +11 -0
- package/src/services/registry.js +36 -6
- package/src/services/updates.js +4 -4
- package/src/web/routes.js +54 -2
- package/views/mixins/resource.pug +52 -0
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ The Coordinator loads a plain **JSON** config file (no YAML support). Resolution
|
|
|
44
44
|
"jobs": { "defaultTimeoutSec": 1800, "maxTimeoutSec": 14400 },
|
|
45
45
|
"retention": { "logRetentionDays": 14, "artifactRetentionDays": 30 },
|
|
46
46
|
"artifacts": { "maxUploadMb": 512, "linkTtlHours": 168 },
|
|
47
|
-
"updates": { "
|
|
47
|
+
"updates": { "checkIntervalMin": 15, "registry": null }
|
|
48
48
|
}
|
|
49
49
|
```
|
|
50
50
|
|
|
@@ -132,7 +132,7 @@ All endpoints are under `/api/v1`, JSON, and require `Authorization: Bearer <tok
|
|
|
132
132
|
| Method | Path | Description |
|
|
133
133
|
|---|---|---|
|
|
134
134
|
| `POST` | `/resources/register` | Self-register (every start/restart) using the shared `clientJoinKey`; upserts by `clientId`. `hostInfo.addresses` (`[{iface, address, family}]`) lists the host's non-loopback interface addresses; the request's source address is stored as the external one. |
|
|
135
|
-
| `POST` | `/resources/:id/heartbeat` | Heartbeat and self-reported state, plus the current `addresses
|
|
135
|
+
| `POST` | `/resources/:id/heartbeat` | Heartbeat and self-reported state, plus the current `addresses`, `hostUptimeSec` and `activity` (`{state, jobId, durationSec}`); refreshes the external address; response may carry `commands[]` (`cancel-job`, `self-update`, `unlock`, `cancel-update`). |
|
|
136
136
|
| `POST` | `/resources/:id/status` | Explicit status change, e.g. a local lock. |
|
|
137
137
|
| `GET` | `/resources/:id/jobs/next?wait=30` | Long-poll for an assigned job; `204` when nothing arrived within `wait` seconds. |
|
|
138
138
|
| `POST` | `/jobs/:id/accept` | Acknowledge assignment. |
|
|
@@ -157,11 +157,11 @@ Server-rendered Pug templates styled with Bootstrap 5.3, with a little vanilla J
|
|
|
157
157
|
| `/admin/agents` | Register/revoke CI and developer agents; token shown once. Shows each agent's last reported version. |
|
|
158
158
|
| `/profile` | Every logged-in user's own account settings: display name, avatar, timezone (renders every dashboard timestamp), theme, idle session timeout, password change. |
|
|
159
159
|
|
|
160
|
-
**Resource card.** Clicking a resource on `/` or `/resources` opens a card with its name, type, status, Client version, IP addresses, capabilities (reported at registration: HW udev devices with a **missing** flag for any whose device node doesn't exist, relay channels and power control; SW image, source and limits), labels, groups, busy source/reason, last heartbeat and (admins only) the maintenance/rotate-token actions. IP addresses are the Client host's interface addresses except loopback (reported at registration and on every heartbeat) plus the external address — the one the Coordinator last saw the Client connect from, taken from `X-Forwarded-For` when the request came through a proxy `trustProxy` trusts.
|
|
160
|
+
**Resource card.** Clicking a resource on `/` or `/resources` opens a card with its name, type, status, host uptime and current task/status with its duration (both from the Client's heartbeats, anchored to the Coordinator's clock; as of the last heartbeat for an OUT_OF_SERVICE resource), Client version, IP addresses, capabilities (reported at registration: HW udev devices with a **missing** flag for any whose device node doesn't exist, relay channels and power control; SW image, source and limits), labels, groups, busy source/reason, last heartbeat and (admins only) the maintenance/rotate-token actions. IP addresses are the Client host's interface addresses except loopback (reported at registration and on every heartbeat) plus the external address — the one the Coordinator last saw the Client connect from, taken from `X-Forwarded-For` when the request came through a proxy `trustProxy` trusts. Admins get a **Cancel** button on it, enabled only while the Client is doing something: it cancels its current job, releases a manual local lock, or aborts a self-update hold on its host (the last two delivered as `unlock` / `cancel-update` heartbeat commands).
|
|
161
161
|
|
|
162
162
|
The Coordinator's own version (`ver. X.Y.Z`) is shown under the TestHub logo, top left of every page.
|
|
163
163
|
|
|
164
|
-
**Versions and self-update.** The Coordinator checks the npm registry for the latest Coordinator, Agent and Client every `updates.
|
|
164
|
+
**Versions and self-update.** The Coordinator checks the npm registry for the latest Coordinator, Agent and Client every `updates.checkIntervalMin` (default 15 minutes, `0` disables; the older `checkIntervalHours` is ignored; `updates.registry` for a private mirror) and on **Check for updates**. A newer Coordinator shows as an **Update app to X.Y.Z** button in the navbar for admins (a badge under the logo for viewers); Agents and Resources show each one's version with an **update available** or pending **→ vX.Y.Z** badge. Admins request self-updates per agent/resource (**Update**, which becomes **Cancel update**) or with **Update all agents** / **Update all clients**, always to the latest version:
|
|
165
165
|
- Clients get a `self-update` command on their next heartbeat and install it through their root `thub-client-update` helper, once none of the host's instances is busy.
|
|
166
166
|
- Agents install it at the start of their next run (`GET /api/v1/agents/me/update`), then re-run the command on the new version.
|
|
167
167
|
- The Coordinator itself: the **Update app** button writes `<dataDir>/update-request.json`; the root `thub-coordinator-update.path` unit (installed by `sudo npm i -g`) runs `npm i -g` for it, and the postinstall restarts the service (the dashboard and API are briefly down). Logs: `journalctl -u thub-coordinator-update`. Without that unit, use `thub-admin self-update` on the host.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.14",
|
|
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": {
|
package/src/api/resource.js
CHANGED
|
@@ -84,7 +84,7 @@ function createResourceRouter({ services, config }){
|
|
|
84
84
|
|
|
85
85
|
router.post('/resources/:id/heartbeat', auth, requireOwnResource, (req, res, next) => {
|
|
86
86
|
try {
|
|
87
|
-
const { state, activeJobId, localLock, metrics, addresses } = req.body;
|
|
87
|
+
const { state, activeJobId, localLock, metrics, addresses, hostUptimeSec, activity } = req.body;
|
|
88
88
|
services.registry.heartbeat(req.params.id, {
|
|
89
89
|
state,
|
|
90
90
|
activeJobId,
|
|
@@ -92,7 +92,9 @@ function createResourceRouter({ services, config }){
|
|
|
92
92
|
metrics,
|
|
93
93
|
addresses,
|
|
94
94
|
remoteAddr: req.ip,
|
|
95
|
-
clientVersion: appVersion(req, 'client')
|
|
95
|
+
clientVersion: appVersion(req, 'client'),
|
|
96
|
+
hostUptimeSec,
|
|
97
|
+
activity
|
|
96
98
|
});
|
|
97
99
|
const commands = services.commands.drain(req.params.id),
|
|
98
100
|
// Repeated on every heartbeat until the Client reports the new
|
package/src/config.js
CHANGED
|
@@ -54,10 +54,13 @@ const DEFAULTS = {
|
|
|
54
54
|
linkTtlHours: 168
|
|
55
55
|
},
|
|
56
56
|
// New-version check of the Coordinator/Agent/Client packages (README
|
|
57
|
-
// §10.2)
|
|
58
|
-
// registry
|
|
57
|
+
// §10.2), every 15 minutes so a new Coordinator's "Update app" button
|
|
58
|
+
// shows up promptly. 0 disables the periodic check; registry null = the
|
|
59
|
+
// public npm registry (or npm_config_registry). The old
|
|
60
|
+
// `checkIntervalHours` (6) is no longer read — installed configs still
|
|
61
|
+
// carry it from their defaults.
|
|
59
62
|
updates: {
|
|
60
|
-
|
|
63
|
+
checkIntervalMin: 15,
|
|
61
64
|
registry: null
|
|
62
65
|
}
|
|
63
66
|
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- Reported on every heartbeat (README §10), converted to the
|
|
2
|
+
-- Coordinator's clock: when the Client's host booted (host uptime), and
|
|
3
|
+
-- what the Client is doing since when (JSON {state, jobId, since}; state
|
|
4
|
+
-- idle | job | locked | update-hold) for the "Task / status" duration.
|
|
5
|
+
-- NULL for Clients too old to report them.
|
|
6
|
+
ALTER TABLE resources ADD COLUMN host_booted_at TEXT;
|
|
7
|
+
ALTER TABLE resources ADD COLUMN activity TEXT;
|
package/src/services/jobs.js
CHANGED
|
@@ -202,6 +202,16 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
202
202
|
return setState(id, JOB_STATES.CANCELED);
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// The job a resource is working on (ASSIGNED through RUNNING), if any —
|
|
206
|
+
// for the resource card's Cancel button.
|
|
207
|
+
function activeForResource(resourceId){
|
|
208
|
+
const placeholders = [...ACTIVE_JOB_STATES].map(() => '?').join(','),
|
|
209
|
+
row = db.prepare(
|
|
210
|
+
`SELECT id FROM jobs WHERE resource_id = ? AND state IN (${placeholders}) ORDER BY created_at DESC LIMIT 1`
|
|
211
|
+
).get(resourceId, ...ACTIVE_JOB_STATES);
|
|
212
|
+
return row ? get(row.id) : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
205
215
|
// Resource: POST /jobs/:id/result — final verdict from the test runner.
|
|
206
216
|
function applyResult(id, resourceId, { state, exitCode, summary }){
|
|
207
217
|
const job = get(id);
|
|
@@ -341,6 +351,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
341
351
|
create,
|
|
342
352
|
setState,
|
|
343
353
|
cancel,
|
|
354
|
+
activeForResource,
|
|
344
355
|
applyResult,
|
|
345
356
|
requeueUnacked,
|
|
346
357
|
markLost,
|
package/src/services/registry.js
CHANGED
|
@@ -26,7 +26,8 @@ function rowToResource(row){
|
|
|
26
26
|
labels: JSON.parse(row.labels || '[]'),
|
|
27
27
|
group_ids: JSON.parse(row.group_ids || '[]'),
|
|
28
28
|
host_info: row.host_info ? JSON.parse(row.host_info) : null,
|
|
29
|
-
capabilities: row.capabilities ? JSON.parse(row.capabilities) : null
|
|
29
|
+
capabilities: row.capabilities ? JSON.parse(row.capabilities) : null,
|
|
30
|
+
activity: row.activity ? JSON.parse(row.activity) : null
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -98,6 +99,26 @@ function sanitizeCapabilities(caps){
|
|
|
98
99
|
return null;
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
// Heartbeat durations (README §10) arrive as seconds relative to "now" on
|
|
103
|
+
// the Client and are anchored to the Coordinator's clock here. Anything
|
|
104
|
+
// negative, non-numeric or over ~10 years is ignored.
|
|
105
|
+
const ACTIVITY_STATES = new Set(['idle', 'job', 'locked', 'update-hold']),
|
|
106
|
+
MAX_DURATION_SEC = 10 * 365 * 86400;
|
|
107
|
+
|
|
108
|
+
function secondsAgo(now, sec){
|
|
109
|
+
return Number.isFinite(sec) && sec >= 0 && sec < MAX_DURATION_SEC ? new Date(now - sec * 1000).toISOString() : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sanitizeActivity(now, activity){
|
|
113
|
+
if (!activity || !ACTIVITY_STATES.has(activity.state)){
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const since = secondsAgo(now, activity.durationSec);
|
|
117
|
+
return since
|
|
118
|
+
? { state: activity.state, jobId: activity.state === 'job' ? str(activity.jobId, 64) : null, since }
|
|
119
|
+
: null;
|
|
120
|
+
}
|
|
121
|
+
|
|
101
122
|
function createRegistryService(db, { bus, events }){
|
|
102
123
|
function get(id){
|
|
103
124
|
return rowToResource(db.prepare('SELECT * FROM resources WHERE id = ?').get(id));
|
|
@@ -266,7 +287,10 @@ function createRegistryService(db, { bus, events }){
|
|
|
266
287
|
}
|
|
267
288
|
|
|
268
289
|
// Reconcile status from a heartbeat's self-reported state (§5.1).
|
|
269
|
-
function heartbeat(
|
|
290
|
+
function heartbeat(
|
|
291
|
+
resourceId,
|
|
292
|
+
{ state, activeJobId, localLock, metrics, addresses, remoteAddr, clientVersion = null, hostUptimeSec, activity } = {}
|
|
293
|
+
){
|
|
270
294
|
const resource = get(resourceId);
|
|
271
295
|
if (!resource){
|
|
272
296
|
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
@@ -298,23 +322,29 @@ function createRegistryService(db, { bus, events }){
|
|
|
298
322
|
busyReason = null;
|
|
299
323
|
}
|
|
300
324
|
|
|
301
|
-
|
|
302
|
-
|
|
325
|
+
const now = Date.now(),
|
|
326
|
+
cleanActivity = sanitizeActivity(now, activity),
|
|
327
|
+
|
|
328
|
+
// Older Clients don't send addresses — keep whatever registration stored.
|
|
329
|
+
cleanAddresses = sanitizeAddresses(addresses),
|
|
303
330
|
hostInfo = cleanAddresses ? { ...(resource.host_info || {}), addresses: cleanAddresses } : resource.host_info;
|
|
304
331
|
|
|
305
332
|
db.prepare(
|
|
306
333
|
`UPDATE resources
|
|
307
334
|
SET last_heartbeat_at = ?, status = ?, busy_source = ?, busy_reason = ?, host_info = ?,
|
|
308
|
-
remote_addr = COALESCE(?, remote_addr), client_version = COALESCE(?, client_version)
|
|
335
|
+
remote_addr = COALESCE(?, remote_addr), client_version = COALESCE(?, client_version),
|
|
336
|
+
host_booted_at = COALESCE(?, host_booted_at), activity = COALESCE(?, activity)
|
|
309
337
|
WHERE id = ?`
|
|
310
338
|
).run(
|
|
311
|
-
new Date().toISOString(),
|
|
339
|
+
new Date(now).toISOString(),
|
|
312
340
|
nextStatus,
|
|
313
341
|
busySource,
|
|
314
342
|
busyReason,
|
|
315
343
|
hostInfo ? JSON.stringify(hostInfo) : null,
|
|
316
344
|
normalizeRemoteAddr(remoteAddr),
|
|
317
345
|
clientVersion,
|
|
346
|
+
secondsAgo(now, hostUptimeSec),
|
|
347
|
+
cleanActivity ? JSON.stringify(cleanActivity) : null,
|
|
318
348
|
resourceId
|
|
319
349
|
);
|
|
320
350
|
completeUpdateIfDone(resourceId);
|
package/src/services/updates.js
CHANGED
|
@@ -22,7 +22,7 @@ const fs = require('node:fs'),
|
|
|
22
22
|
// as root when the dashboard writes an update request (README §10.2).
|
|
23
23
|
const UPDATE_PATH_UNIT = '/etc/systemd/system/thub-coordinator-update.path';
|
|
24
24
|
|
|
25
|
-
// Latest published versions, refreshed every `updates.
|
|
25
|
+
// Latest published versions, refreshed every `updates.checkIntervalMin`
|
|
26
26
|
// and on demand from the dashboard. Kept in memory only — a restart just
|
|
27
27
|
// checks again.
|
|
28
28
|
function createUpdatesService({ config }){
|
|
@@ -96,12 +96,12 @@ function createUpdatesService({ config }){
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
function start(){
|
|
99
|
-
const
|
|
100
|
-
if (!(
|
|
99
|
+
const minutes = Number(config.updates.checkIntervalMin);
|
|
100
|
+
if (!(minutes > 0)){
|
|
101
101
|
return;
|
|
102
102
|
}
|
|
103
103
|
checkNow().catch(() => {});
|
|
104
|
-
const timer = setInterval(() => checkNow().catch(() => {}),
|
|
104
|
+
const timer = setInterval(() => checkNow().catch(() => {}), minutes * 60 * 1000);
|
|
105
105
|
timer.unref?.();
|
|
106
106
|
}
|
|
107
107
|
|
package/src/web/routes.js
CHANGED
|
@@ -53,6 +53,11 @@ function createWebRouter({ services, config }){
|
|
|
53
53
|
return typeof target === 'string' && /^\/(?![\/\\])/.test(target) ? target : fallback;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// The job each resource is on (if any), for the resource card's Cancel.
|
|
57
|
+
function withActiveJobs(resources){
|
|
58
|
+
return resources.map((r) => ({ ...r, activeJob: services.jobs.activeForResource(r.id) }));
|
|
59
|
+
}
|
|
60
|
+
|
|
56
61
|
router.use((req, res, next) => {
|
|
57
62
|
const user = req.session?.user || null;
|
|
58
63
|
res.locals.user = user;
|
|
@@ -69,6 +74,14 @@ function createWebRouter({ services, config }){
|
|
|
69
74
|
// without this every user would see the *server's* local time — not
|
|
70
75
|
// their own (§10.1). Falls back to UTC for the (rare) pre-login page.
|
|
71
76
|
const tz = user?.timezone || 'UTC';
|
|
77
|
+
// "3d 4h", "5h 12m", "12m 5s", "8s" — resource card uptime/durations.
|
|
78
|
+
res.locals.fmtDuration = (ms) => {
|
|
79
|
+
const s = Math.max(0, Math.floor(ms / 1000)),
|
|
80
|
+
d = Math.floor(s / 86400),
|
|
81
|
+
h = Math.floor((s % 86400) / 3600),
|
|
82
|
+
m = Math.floor((s % 3600) / 60);
|
|
83
|
+
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m ${s % 60}s` : `${s}s`;
|
|
84
|
+
};
|
|
72
85
|
res.locals.fmtDate = (iso, fallback = '—') =>
|
|
73
86
|
iso ? new Date(iso).toLocaleString('en-US', { timeZone: tz }) : fallback;
|
|
74
87
|
|
|
@@ -204,7 +217,7 @@ function createWebRouter({ services, config }){
|
|
|
204
217
|
});
|
|
205
218
|
|
|
206
219
|
router.get('/', (req, res) => {
|
|
207
|
-
const resources = services.registry.list(),
|
|
220
|
+
const resources = withActiveJobs(services.registry.list()),
|
|
208
221
|
queueLength = services.jobs.list({ state: JOB_STATES.QUEUED, limit: 1000 }).length,
|
|
209
222
|
dayAgo = new Date(Date.now() - 86400 * 1000).toISOString(),
|
|
210
223
|
jobsLast24h = services.jobs
|
|
@@ -220,7 +233,7 @@ function createWebRouter({ services, config }){
|
|
|
220
233
|
res.render('resources/list', {
|
|
221
234
|
title: 'Resources',
|
|
222
235
|
active: 'resources',
|
|
223
|
-
resources: services.registry.list(),
|
|
236
|
+
resources: withActiveJobs(services.registry.list()),
|
|
224
237
|
groupsById
|
|
225
238
|
});
|
|
226
239
|
});
|
|
@@ -230,6 +243,45 @@ function createWebRouter({ services, config }){
|
|
|
230
243
|
res.redirect(returnTo(req, '/resources'));
|
|
231
244
|
});
|
|
232
245
|
|
|
246
|
+
// Cancel whatever the Client is doing (resource card): its job, a manual
|
|
247
|
+
// local lock, or a self-update hold (README §10). The Client applies the
|
|
248
|
+
// lock/update ones via heartbeat commands; a job is canceled right here.
|
|
249
|
+
router.post('/resources/:id/cancel-activity', requireAdminRole, (req, res) => {
|
|
250
|
+
const r = services.registry.get(req.params.id),
|
|
251
|
+
job = r && services.jobs.activeForResource(r.id),
|
|
252
|
+
activity = r?.activity?.state;
|
|
253
|
+
try {
|
|
254
|
+
if (!r){
|
|
255
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
256
|
+
}
|
|
257
|
+
if (job){
|
|
258
|
+
services.jobs.cancel(job.id, { isAdmin: true });
|
|
259
|
+
flash(req, 'warning', `Canceled job ${job.id} on ${r.name}.`);
|
|
260
|
+
}
|
|
261
|
+
else if (activity === 'locked'){
|
|
262
|
+
services.commands.push(r.id, { command: 'unlock' });
|
|
263
|
+
flash(req, 'warning', `Releasing the local lock on ${r.name} (on its next heartbeat).`);
|
|
264
|
+
}
|
|
265
|
+
else if (activity === 'update-hold'){
|
|
266
|
+
services.commands.push(r.id, { command: 'cancel-update' });
|
|
267
|
+
if (r.update_to){
|
|
268
|
+
services.registry.setUpdateTo(r.id, null);
|
|
269
|
+
}
|
|
270
|
+
flash(req, 'warning', `Canceling the self-update on ${r.name}'s host (on its next heartbeat).`);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
flash(req, 'info', `${r.name} isn't doing anything to cancel.`);
|
|
274
|
+
}
|
|
275
|
+
if (!job && ['locked', 'update-hold'].includes(activity)){
|
|
276
|
+
services.events.record('resource', r.id, 'resource.activity_canceled', { activity, by: req.session.user.username });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch (err){
|
|
280
|
+
flash(req, 'danger', err.message);
|
|
281
|
+
}
|
|
282
|
+
res.redirect(returnTo(req, '/resources'));
|
|
283
|
+
});
|
|
284
|
+
|
|
233
285
|
router.post('/resources/:id/rotate-token', requireAdminRole, (req, res) => {
|
|
234
286
|
const { generateToken, hashToken } = require('../services/tokens');
|
|
235
287
|
const token = generateToken('res');
|
|
@@ -89,6 +89,56 @@ mixin capabilitiesList(caps)
|
|
|
89
89
|
if caps.hw.power.method === 'uhubctl' && caps.hw.power.hub
|
|
90
90
|
span.text-body-secondary.small.ms-2= `hub ${caps.hw.power.hub} port ${caps.hw.power.port}`
|
|
91
91
|
|
|
92
|
+
//- Host uptime and the Client's current task/status with its duration,
|
|
93
|
+
//- both from the last heartbeat. Measured up to now — or, for a Client
|
|
94
|
+
//- that stopped heartbeating, up to its last heartbeat.
|
|
95
|
+
mixin uptimeRows(r)
|
|
96
|
+
- const stale = r.status === 'OUT_OF_SERVICE' && r.last_heartbeat_at
|
|
97
|
+
- const ref = stale ? new Date(r.last_heartbeat_at).getTime() : Date.now()
|
|
98
|
+
- const activityLabel = { idle: 'Idle', job: 'Running job', locked: 'Locked locally', 'update-hold': 'Holding for self-update' }
|
|
99
|
+
dt.col-sm-4 Host uptime
|
|
100
|
+
dd.col-sm-8
|
|
101
|
+
if r.host_booted_at
|
|
102
|
+
= fmtDuration(ref - new Date(r.host_booted_at).getTime())
|
|
103
|
+
span.text-body-secondary.small.ms-2= `booted ${fmtDate(r.host_booted_at)}`
|
|
104
|
+
if stale
|
|
105
|
+
span.text-body-secondary.small.ms-1 (at last heartbeat)
|
|
106
|
+
else
|
|
107
|
+
span.text-body-secondary —
|
|
108
|
+
dt.col-sm-4 Task / status
|
|
109
|
+
dd.col-sm-8
|
|
110
|
+
if r.activity
|
|
111
|
+
= activityLabel[r.activity.state] || r.activity.state
|
|
112
|
+
if r.activity.jobId
|
|
113
|
+
a.ms-1(href=`/jobs/${r.activity.jobId}`)= r.activity.jobId
|
|
114
|
+
= ` for ${fmtDuration(ref - new Date(r.activity.since).getTime())}`
|
|
115
|
+
span.text-body-secondary.small.ms-2= `since ${fmtDate(r.activity.since)}`
|
|
116
|
+
if stale
|
|
117
|
+
span.text-body-secondary.small.ms-1 (at last heartbeat)
|
|
118
|
+
else
|
|
119
|
+
span.text-body-secondary —
|
|
120
|
+
|
|
121
|
+
//- Cancel the Client's current activity (its job, a local lock or a
|
|
122
|
+
//- self-update hold); disabled while it's idle.
|
|
123
|
+
mixin cancelActivityButton(r, returnTo)
|
|
124
|
+
- const state = r.activity && r.activity.state
|
|
125
|
+
- const what = r.activeJob ? `job ${r.activeJob.id}` : state === 'locked' ? 'the local lock' : state === 'update-hold' ? 'the self-update' : null
|
|
126
|
+
if what
|
|
127
|
+
form.d-inline.ms-1(
|
|
128
|
+
method="post"
|
|
129
|
+
action=`/resources/${r.id}/cancel-activity`
|
|
130
|
+
onsubmit=`return confirm('Cancel ${what} on ${r.name}?')`
|
|
131
|
+
)
|
|
132
|
+
input(type="hidden" name="returnTo" value=returnTo)
|
|
133
|
+
button.btn.btn-sm.btn-outline-danger(type="submit" data-bs-toggle="tooltip" data-bs-title=`Cancel ${what}`)
|
|
134
|
+
i.bi.bi-x-octagon.me-1
|
|
135
|
+
| Cancel
|
|
136
|
+
else
|
|
137
|
+
span.d-inline-block.ms-1(tabindex="0" data-bs-toggle="tooltip" data-bs-title="Nothing to cancel — the Client is idle")
|
|
138
|
+
button.btn.btn-sm.btn-outline-danger(type="button" disabled style="pointer-events: none;")
|
|
139
|
+
i.bi.bi-x-octagon.me-1
|
|
140
|
+
| Cancel
|
|
141
|
+
|
|
92
142
|
mixin resourceCard(r, groupsById, returnTo)
|
|
93
143
|
- const isLoopback = (a) => /^127\./.test(a) || a === '::1' || a === 'localhost'
|
|
94
144
|
- const local = ((r.host_info && r.host_info.addresses) || []).filter((a) => !isLoopback(a.address))
|
|
@@ -107,6 +157,7 @@ mixin resourceCard(r, groupsById, returnTo)
|
|
|
107
157
|
dd.col-sm-8.text-uppercase= r.type
|
|
108
158
|
dt.col-sm-4 Status
|
|
109
159
|
dd.col-sm-8: +resourceBadge(r.status)
|
|
160
|
+
+uptimeRows(r)
|
|
110
161
|
dt.col-sm-4 Client version
|
|
111
162
|
dd.col-sm-8: +versionCell(r.client_version, updates.latest.client, r.update_to)
|
|
112
163
|
dt.col-sm-4 IP addresses
|
|
@@ -150,3 +201,4 @@ mixin resourceCard(r, groupsById, returnTo)
|
|
|
150
201
|
dt.col-sm-4 Actions
|
|
151
202
|
dd.col-sm-8.mb-0
|
|
152
203
|
+resourceActions(r, returnTo)
|
|
204
|
+
+cancelActivityButton(r, returnTo)
|