@andrian.yablonskyy/thub-coordinator 1.0.29 → 1.0.30
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 +1 -1
- package/public/css/thub.css +40 -0
- package/public/js/client-config.js +117 -0
- package/public/js/usb-import.js +169 -0
- package/public/js/usb-parse.js +114 -0
- package/public/js/usb-scan.js +99 -0
- package/src/api/admin.js +22 -0
- package/src/api/resource.js +17 -3
- package/src/db/migrations/018_add_client_config.sql +12 -0
- package/src/db/migrations/019_add_name_override.sql +7 -0
- package/src/db/migrations/020_add_usb_scan.sql +8 -0
- package/src/dev/virtual.js +44 -2
- package/src/services/registry.js +131 -3
- package/src/web/routes.js +68 -0
- package/test/scheduler.test.js +64 -0
- package/test/usb-parse.test.js +73 -0
- package/test/views.test.js +35 -0
- package/views/index.pug +4 -0
- package/views/mixins/client-config.pug +205 -0
- package/views/mixins/resource.pug +88 -11
- package/views/resources/list.pug +4 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
//- A Client's capabilities — the hw or sw section of its config — edited on
|
|
2
|
+
//- the resource card's Config tab (README §10). public/js/client-config.js
|
|
3
|
+
//- turns the form into JSON on Save; the Client applies it on its next
|
|
4
|
+
//- heartbeat, then restarts once idle. Starts from what was last saved, else
|
|
5
|
+
//- what the Client reported at registration.
|
|
6
|
+
|
|
7
|
+
//- One device row: "Device" is a udev index (1-8) or a /dev path; fields the
|
|
8
|
+
//- form doesn't show (vendorId/productId/subsystem) ride along in data-extra.
|
|
9
|
+
mixin deviceRow(kind, d)
|
|
10
|
+
- const known = ['index', 'path', 'serial', 'baudRate', 'devpath']
|
|
11
|
+
- const extra = Object.fromEntries(Object.entries(d || {}).filter(([k]) => !known.includes(k)))
|
|
12
|
+
tr(data-row=kind data-extra=JSON.stringify(extra))
|
|
13
|
+
td: input.form-control.form-control-sm.font-monospace(data-f="device" value=d ? (d.path || d.index || '') : '' placeholder="1 or /dev/…" required)
|
|
14
|
+
if kind === 'stlinks'
|
|
15
|
+
td: input.form-control.form-control-sm.font-monospace(data-f="serial" value=d && d.serial || '' placeholder="(from udev)")
|
|
16
|
+
if kind === 'uarts'
|
|
17
|
+
td: input.form-control.form-control-sm(data-f="baudRate" type="number" min="50" value=d && d.baudRate || '' placeholder="115200")
|
|
18
|
+
td: input.form-control.form-control-sm.font-monospace(data-f="devpath" value=d && d.devpath || '' placeholder="3.3.4.1")
|
|
19
|
+
td.text-end
|
|
20
|
+
button.btn.btn-sm.btn-outline-secondary(type="button" data-remove-row aria-label="Remove row")
|
|
21
|
+
i.bi.bi-x-lg
|
|
22
|
+
|
|
23
|
+
mixin relayRow(rl)
|
|
24
|
+
tr(data-row="relays")
|
|
25
|
+
td: input.form-control.form-control-sm(data-f="channel" type="number" min="0" max="7" value=rl ? rl.channel : '' required)
|
|
26
|
+
td: input.form-control.form-control-sm(data-f="baseUrl" type="url" value=rl && rl.baseUrl || '' placeholder="http://localhost:3000")
|
|
27
|
+
td.text-end
|
|
28
|
+
button.btn.btn-sm.btn-outline-secondary(type="button" data-remove-row aria-label="Remove row")
|
|
29
|
+
i.bi.bi-x-lg
|
|
30
|
+
|
|
31
|
+
mixin deviceTable(kind, title, rows, columns)
|
|
32
|
+
.d-flex.justify-content-between.align-items-center.mb-1
|
|
33
|
+
strong.small= title
|
|
34
|
+
button.btn.btn-sm.btn-outline-primary(type="button" data-add-row=kind)
|
|
35
|
+
i.bi.bi-plus-lg.me-1
|
|
36
|
+
| Add
|
|
37
|
+
table.table.table-sm.align-middle.mb-0(data-table=kind)
|
|
38
|
+
thead
|
|
39
|
+
tr
|
|
40
|
+
each c in columns
|
|
41
|
+
th.small.text-body-secondary.fw-normal= c
|
|
42
|
+
th
|
|
43
|
+
tbody
|
|
44
|
+
each d in rows
|
|
45
|
+
if kind === 'relays'
|
|
46
|
+
+relayRow(d)
|
|
47
|
+
else
|
|
48
|
+
+deviceRow(kind, d)
|
|
49
|
+
template(data-template=kind)
|
|
50
|
+
if kind === 'relays'
|
|
51
|
+
+relayRow(null)
|
|
52
|
+
else
|
|
53
|
+
+deviceRow(kind, null)
|
|
54
|
+
|
|
55
|
+
//- The ⚠ shown on a config (sub-)tab while its data differs from what was
|
|
56
|
+
//- loaded — public/js/client-config.js toggles it.
|
|
57
|
+
mixin dirtyIcon()
|
|
58
|
+
i.bi.bi-exclamation-triangle-fill.text-warning.ms-1.d-none(
|
|
59
|
+
data-dirty-icon
|
|
60
|
+
role="img"
|
|
61
|
+
aria-label="changed, not saved"
|
|
62
|
+
title="Changed — not saved yet"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
mixin configTab(p, key, label)
|
|
66
|
+
li.nav-item(role="presentation")
|
|
67
|
+
button.nav-link(
|
|
68
|
+
id=`${p}-${key}-tab`
|
|
69
|
+
type="button"
|
|
70
|
+
role="tab"
|
|
71
|
+
data-bs-toggle="tab"
|
|
72
|
+
data-bs-target=`#${p}-${key}`
|
|
73
|
+
aria-controls=`${p}-${key}`
|
|
74
|
+
aria-selected="false"
|
|
75
|
+
data-config-tab=key
|
|
76
|
+
)
|
|
77
|
+
= label
|
|
78
|
+
+dirtyIcon()
|
|
79
|
+
|
|
80
|
+
mixin configPane(p, key)
|
|
81
|
+
.tab-pane.fade(id=`${p}-${key}` role="tabpanel" aria-labelledby=`${p}-${key}-tab` tabindex="0" data-config-pane=key)
|
|
82
|
+
block
|
|
83
|
+
|
|
84
|
+
mixin saveCapabilities(canEdit)
|
|
85
|
+
if canEdit
|
|
86
|
+
.d-flex.justify-content-end.mt-3
|
|
87
|
+
button.btn.btn-sm.btn-primary(type="submit") Save capabilities
|
|
88
|
+
|
|
89
|
+
//- Where the saved capabilities stand, at the top of each capability tab.
|
|
90
|
+
mixin capabilitiesStatus(r)
|
|
91
|
+
- const pending = r.config_desired && r.config_applied_revision < r.config_revision
|
|
92
|
+
if pending || (r.config_revision && !r.config_error) || r.config_error
|
|
93
|
+
.d-flex.justify-content-end.gap-1.mb-2
|
|
94
|
+
if pending
|
|
95
|
+
span.badge.text-bg-warning(
|
|
96
|
+
data-bs-toggle="tooltip"
|
|
97
|
+
data-bs-title=`Capabilities revision ${r.config_revision} is saved; the Client applies it on its next heartbeat, then restarts once idle`
|
|
98
|
+
) not applied yet
|
|
99
|
+
else if r.config_revision && !r.config_error
|
|
100
|
+
span.badge.text-bg-success(data-bs-toggle="tooltip" data-bs-title=`The Client runs capabilities revision ${r.config_applied_revision}`) applied
|
|
101
|
+
if r.config_error
|
|
102
|
+
span.badge.text-bg-danger(data-bs-toggle="tooltip" data-bs-title=r.config_error) refused by the Client
|
|
103
|
+
|
|
104
|
+
//- The Client's config, as tabs of the resource card next to Details:
|
|
105
|
+
//- HW — ST-Link, UART, DUT USB, Power (relays + power control); SW —
|
|
106
|
+
//- Emulator; both — Reboot (the schedule, its own Save). The capability tabs
|
|
107
|
+
//- share one form and one "Save capabilities"; a tab with unsaved changes
|
|
108
|
+
//- shows ⚠ (public/js/client-config.js).
|
|
109
|
+
mixin configTabItems(r)
|
|
110
|
+
- const p = `cfg-${r.id}`
|
|
111
|
+
- const tabs = r.type === 'hw' ? [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'DUT USB'], ['power', 'Power']] : [['emulator', 'Emulator']]
|
|
112
|
+
each t in tabs
|
|
113
|
+
+configTab(p, t[0], t[1])
|
|
114
|
+
+configTab(p, 'reboot', 'Reboot')
|
|
115
|
+
|
|
116
|
+
mixin configPanes(r, returnTo)
|
|
117
|
+
- const p = `cfg-${r.id}`
|
|
118
|
+
- const current = r.config_desired || r.client_config
|
|
119
|
+
- const canEdit = user.role === 'admin'
|
|
120
|
+
if !current
|
|
121
|
+
each t in (r.type === 'hw' ? [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'DUT USB'], ['power', 'Power']] : [['emulator', 'Emulator']])
|
|
122
|
+
+configPane(p, t[0])
|
|
123
|
+
p.small.text-body-secondary.mb-0 This Client is too old to report its config — update it to edit its capabilities here.
|
|
124
|
+
else
|
|
125
|
+
form(
|
|
126
|
+
method="post"
|
|
127
|
+
action=`/resources/${r.id}/config`
|
|
128
|
+
data-client-config=r.type
|
|
129
|
+
data-confirm=`Apply these capabilities to ${r.name}? The Client writes them to its config file on its next heartbeat and restarts once it has no job running.`
|
|
130
|
+
data-confirm-title="Save Client capabilities?"
|
|
131
|
+
data-confirm-ok="Save"
|
|
132
|
+
data-confirm-tone="primary"
|
|
133
|
+
)
|
|
134
|
+
input(type="hidden" name="returnTo" value=returnTo)
|
|
135
|
+
input(type="hidden" name="config")
|
|
136
|
+
fieldset(disabled=!canEdit)
|
|
137
|
+
if r.type === 'sw'
|
|
138
|
+
+configPane(p, 'emulator')
|
|
139
|
+
+capabilitiesStatus(r)
|
|
140
|
+
.row.g-2
|
|
141
|
+
.col-sm-6
|
|
142
|
+
label.form-label.small.mb-0 Image
|
|
143
|
+
input.form-control.form-control-sm.font-monospace(data-f="image" value=current.image || '' placeholder="dut-emulator:2026.08")
|
|
144
|
+
.col-sm-6
|
|
145
|
+
label.form-label.small.mb-0 Registry
|
|
146
|
+
input.form-control.form-control-sm.font-monospace(data-f="registry" value=current.registry || '' placeholder="registry.lab:5000")
|
|
147
|
+
.col-sm-3
|
|
148
|
+
label.form-label.small.mb-0 CPUs
|
|
149
|
+
input.form-control.form-control-sm(data-f="cpus" type="number" step="0.1" min="0.1" value=current.cpus != null ? current.cpus : '' placeholder="2")
|
|
150
|
+
.col-sm-3
|
|
151
|
+
label.form-label.small.mb-0 Memory
|
|
152
|
+
input.form-control.form-control-sm(data-f="memory" value=current.memory || '' placeholder="2g")
|
|
153
|
+
.col-sm-6
|
|
154
|
+
label.form-label.small.mb-0 Container command
|
|
155
|
+
input.form-control.form-control-sm.font-monospace(
|
|
156
|
+
data-f="cmd"
|
|
157
|
+
value=(current.cmd || []).join(' ')
|
|
158
|
+
placeholder="--firmware /downloads/app.bin"
|
|
159
|
+
title="For the image above (not a job's own); space-separated"
|
|
160
|
+
)
|
|
161
|
+
.col-12.d-flex.gap-4.mt-2
|
|
162
|
+
.form-check
|
|
163
|
+
input.form-check-input(type="checkbox" data-f="allowDockerHub" id=`cc-${r.id}-hub` checked=current.allowDockerHub === true)
|
|
164
|
+
label.form-check-label.small(for=`cc-${r.id}-hub`) Pull from Docker Hub
|
|
165
|
+
.form-check
|
|
166
|
+
input.form-check-input(type="checkbox" data-f="allowJobImages" id=`cc-${r.id}-jobimg` checked=current.allowJobImages === true)
|
|
167
|
+
label.form-check-label.small(for=`cc-${r.id}-jobimg`) Run jobs' own images (--docker-image)
|
|
168
|
+
+saveCapabilities(canEdit)
|
|
169
|
+
else
|
|
170
|
+
- const power = current.power || {}
|
|
171
|
+
+configPane(p, 'stlinks')
|
|
172
|
+
+capabilitiesStatus(r)
|
|
173
|
+
+deviceTable('stlinks', 'ST-Link probes', current.stlinks || [], ['Device', 'Serial', 'udev devpath'])
|
|
174
|
+
.form-text Device: a udev index 1–8 (→ /dev/thub/dut<N>-stlink) or a /dev path. The serial is looked up from the udev symlink when empty.
|
|
175
|
+
+saveCapabilities(canEdit)
|
|
176
|
+
+configPane(p, 'uarts')
|
|
177
|
+
+capabilitiesStatus(r)
|
|
178
|
+
+deviceTable('uarts', 'UARTs', current.uarts || [], ['Device', 'Baud rate', 'udev devpath'])
|
|
179
|
+
.form-text Device: a udev index 1–8 (→ /dev/thub/dut<N>-uart) or a /dev path. Baud rate: 115200 when empty.
|
|
180
|
+
+saveCapabilities(canEdit)
|
|
181
|
+
+configPane(p, 'usbs')
|
|
182
|
+
+capabilitiesStatus(r)
|
|
183
|
+
+deviceTable('usbs', 'DUT USB devices', current.usbs || [], ['Device', 'udev devpath'])
|
|
184
|
+
.form-text Device: a udev index 1–8 (→ /dev/thub/dut<N>-usb) or a /dev path. udev devpath: the USB port it's plugged into.
|
|
185
|
+
+saveCapabilities(canEdit)
|
|
186
|
+
+configPane(p, 'power')
|
|
187
|
+
+capabilitiesStatus(r)
|
|
188
|
+
+deviceTable('relays', 'Relay channels', current.relays || [], ['Channel (0-7)', 'Board URL'])
|
|
189
|
+
strong.small.d-block.mt-3.mb-1 Power control
|
|
190
|
+
.row.g-2(data-power)
|
|
191
|
+
.col-sm-3
|
|
192
|
+
select.form-select.form-select-sm(data-f="method" aria-label="Power method")
|
|
193
|
+
option(value="" selected=!power.method) none
|
|
194
|
+
option(value="uhubctl" selected=power.method === 'uhubctl') uhubctl
|
|
195
|
+
option(value="relay" selected=power.method === 'relay') relay
|
|
196
|
+
.col-sm-3
|
|
197
|
+
input.form-control.form-control-sm(data-f="hub" value=power.hub || '' placeholder="hub (uhubctl)")
|
|
198
|
+
.col-sm-2
|
|
199
|
+
input.form-control.form-control-sm(data-f="port" type="number" min="1" value=power.port != null ? power.port : '' placeholder="port")
|
|
200
|
+
.col-sm-4
|
|
201
|
+
input.form-control.form-control-sm(data-f="baseUrl" type="url" value=power.baseUrl || '' placeholder="relay URL")
|
|
202
|
+
+saveCapabilities(canEdit)
|
|
203
|
+
+configPane(p, 'reboot')
|
|
204
|
+
dl.row.mb-0
|
|
205
|
+
+rebootScheduleRow(r, returnTo)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
include client-config
|
|
2
|
+
|
|
1
3
|
//- Resource actions and the resource info card (modal), shared by the
|
|
2
4
|
//- Overview and Resources pages. `returnTo` is the page the action forms
|
|
3
5
|
//- redirect back to.
|
|
@@ -307,9 +309,11 @@ mixin resourceCard(r, groupsById, returnTo)
|
|
|
307
309
|
.modal-header
|
|
308
310
|
h5.modal-title(id=`resource-card-${r.id}-title`)= r.name
|
|
309
311
|
button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
312
|
+
//- data-config-root: where public/js/client-config.js tracks unsaved
|
|
313
|
+
//- changes (⚠ on the config tabs).
|
|
314
|
+
.modal-body.pt-2(data-config-root)
|
|
315
|
+
//- Details: what the Client is and reports. Then its config tabs
|
|
316
|
+
//- (client-config.pug; read-only for viewers), then its USB devices.
|
|
313
317
|
ul.nav.nav-tabs.mb-3(role="tablist")
|
|
314
318
|
li.nav-item(role="presentation")
|
|
315
319
|
button.nav-link.active(
|
|
@@ -321,22 +325,66 @@ mixin resourceCard(r, groupsById, returnTo)
|
|
|
321
325
|
aria-controls=`rc-${r.id}-details`
|
|
322
326
|
aria-selected="true"
|
|
323
327
|
) Details
|
|
328
|
+
+configTabItems(r)
|
|
324
329
|
li.nav-item(role="presentation")
|
|
325
330
|
button.nav-link(
|
|
326
|
-
id=`rc-${r.id}-
|
|
331
|
+
id=`rc-${r.id}-usb-tab`
|
|
327
332
|
type="button"
|
|
328
333
|
role="tab"
|
|
329
334
|
data-bs-toggle="tab"
|
|
330
|
-
data-bs-target=`#rc-${r.id}-
|
|
331
|
-
aria-controls=`rc-${r.id}-
|
|
335
|
+
data-bs-target=`#rc-${r.id}-usb`
|
|
336
|
+
aria-controls=`rc-${r.id}-usb`
|
|
332
337
|
aria-selected="false"
|
|
333
|
-
)
|
|
338
|
+
) Connected USB devices
|
|
339
|
+
|
|
334
340
|
.tab-content
|
|
335
341
|
.tab-pane.fade.show.active(id=`rc-${r.id}-details` role="tabpanel" aria-labelledby=`rc-${r.id}-details-tab` tabindex="0")
|
|
336
342
|
+resourceDetails(r, groupsById, returnTo, local, external)
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
343
|
+
+configPanes(r, returnTo)
|
|
344
|
+
.tab-pane.fade(id=`rc-${r.id}-usb` role="tabpanel" aria-labelledby=`rc-${r.id}-usb-tab` tabindex="0")
|
|
345
|
+
+usbScanPane(r)
|
|
346
|
+
|
|
347
|
+
//- "Connected USB devices" (resource card tab): the Client's `lsusb`, run
|
|
348
|
+
//- only on Refresh (admins; public/js/usb-scan.js fetches and fills it in).
|
|
349
|
+
mixin usbScanPane(r)
|
|
350
|
+
- const offline = r.status === 'OUT_OF_SERVICE' || !r.last_heartbeat_at
|
|
351
|
+
- const scan = r.usb_scan
|
|
352
|
+
//- Import to config (public/js/usb-import.js): HW Clients that report
|
|
353
|
+
//- their config only; enabled once there's a scan to import from.
|
|
354
|
+
- const importWhy = r.type !== 'hw' ? 'Only HW Clients have USB device lists to import into' : !(r.config_desired || r.client_config) ? 'This Client is too old to report its config — update it first' : null
|
|
355
|
+
div(data-usb-scan=`/resources/${r.id}/usb-scan` data-pending=r.usb_scan_request_id ? '1' : null data-requested-at=r.usb_scan_requested_at)
|
|
356
|
+
.d-flex.align-items-center.gap-2.mb-2
|
|
357
|
+
if user.role === 'admin'
|
|
358
|
+
if offline
|
|
359
|
+
span.d-inline-block(tabindex="0" data-bs-toggle="tooltip" data-bs-title="Offline — a Client can only be scanned while it's connected")
|
|
360
|
+
button.btn.btn-sm.btn-outline-primary(type="button" disabled style="pointer-events: none;")
|
|
361
|
+
i.bi.bi-arrow-clockwise.me-1
|
|
362
|
+
| Refresh
|
|
363
|
+
else
|
|
364
|
+
button.btn.btn-sm.btn-outline-primary(type="button" data-usb-refresh)
|
|
365
|
+
i.bi.bi-arrow-clockwise.me-1
|
|
366
|
+
| Refresh
|
|
367
|
+
if importWhy
|
|
368
|
+
span.d-inline-block(tabindex="0" data-bs-toggle="tooltip" data-bs-title=importWhy)
|
|
369
|
+
button.btn.btn-sm.btn-outline-secondary(type="button" disabled style="pointer-events: none;")
|
|
370
|
+
i.bi.bi-box-arrow-in-down.me-1
|
|
371
|
+
| Import to config
|
|
372
|
+
else
|
|
373
|
+
button.btn.btn-sm.btn-outline-secondary(
|
|
374
|
+
type="button"
|
|
375
|
+
data-usb-import
|
|
376
|
+
disabled=!(scan && scan.output)
|
|
377
|
+
title="Fill the ST-Link, UART and DUT USB tabs from this scan, for you to review and save"
|
|
378
|
+
)
|
|
379
|
+
i.bi.bi-box-arrow-in-down.me-1
|
|
380
|
+
| Import to config
|
|
381
|
+
span.small.text-body-secondary(data-usb-status)
|
|
382
|
+
if scan
|
|
383
|
+
= `lsusb -tvv at ${fmtDate(scan.at)}`
|
|
384
|
+
else
|
|
385
|
+
| Not scanned yet#{user.role === 'admin' ? ' — press Refresh to run lsusb -tvv on the Client' : ''}.
|
|
386
|
+
.alert.alert-danger.py-2.small(data-usb-error class=scan && scan.error ? null : 'd-none')= scan && scan.error || ''
|
|
387
|
+
pre.bg-body-tertiary.p-2.rounded.small.mb-0(data-usb-output style="max-height: 360px; overflow: auto;" class=scan && scan.output ? null : 'd-none')= scan && scan.output || ''
|
|
340
388
|
|
|
341
389
|
//- Reboot the Client's host now (card's Actions row, icon only): confirmed first — naming the
|
|
342
390
|
//- job that gets canceled, if one is running — then sent with the Client's
|
|
@@ -373,7 +421,36 @@ mixin rebootButton(r, returnTo)
|
|
|
373
421
|
mixin resourceDetails(r, groupsById, returnTo, local, external)
|
|
374
422
|
dl.row.mb-0
|
|
375
423
|
dt.col-sm-4 Name
|
|
376
|
-
dd.col-sm-8
|
|
424
|
+
dd.col-sm-8
|
|
425
|
+
span= r.name
|
|
426
|
+
if r.name_override && r.reported_name
|
|
427
|
+
span.text-body-secondary.small.ms-2(
|
|
428
|
+
data-bs-toggle="tooltip"
|
|
429
|
+
data-bs-title="Renamed on the dashboard; this is the name the Client itself reports"
|
|
430
|
+
)= `(Client: ${r.reported_name})`
|
|
431
|
+
if user.role === 'admin'
|
|
432
|
+
button.btn.btn-sm.btn-link.p-0.ms-2.align-baseline(
|
|
433
|
+
type="button"
|
|
434
|
+
data-bs-toggle="collapse"
|
|
435
|
+
data-bs-target=`#rename-${r.id}`
|
|
436
|
+
aria-expanded="false"
|
|
437
|
+
aria-controls=`rename-${r.id}`
|
|
438
|
+
aria-label=`Rename ${r.name}`
|
|
439
|
+
)
|
|
440
|
+
i.bi.bi-pencil(data-bs-toggle="tooltip" data-bs-title="Rename")
|
|
441
|
+
.collapse(id=`rename-${r.id}`)
|
|
442
|
+
form.d-flex.gap-1.mt-1(method="post" action=`/resources/${r.id}/rename`)
|
|
443
|
+
input(type="hidden" name="returnTo" value=returnTo)
|
|
444
|
+
input.form-control.form-control-sm(
|
|
445
|
+
name="name"
|
|
446
|
+
value=r.name
|
|
447
|
+
maxlength="64"
|
|
448
|
+
pattern="[A-Za-z0-9._@+\-]*"
|
|
449
|
+
placeholder=r.reported_name || ''
|
|
450
|
+
aria-label=`New name for ${r.name}`
|
|
451
|
+
)
|
|
452
|
+
button.btn.btn-sm.btn-primary(type="submit") Rename
|
|
453
|
+
.form-text Kept across the Client's restarts. Empty = back to the Client's own name#{r.reported_name ? ` (${r.reported_name})` : ''}.
|
|
377
454
|
dt.col-sm-4 Type
|
|
378
455
|
dd.col-sm-8.text-uppercase= r.type
|
|
379
456
|
dt.col-sm-4 Status
|
package/views/resources/list.pug
CHANGED