dsh-computers 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROTOCOL.md +135 -0
- package/README.md +45 -16
- package/agent/README.md +112 -5
- package/agent/dsh-computers-agent.mjs +835 -20
- package/client.js +777 -10
- package/cordis.patch.yml +2 -0
- package/index.js +32 -2
- package/lib/access.js +397 -0
- package/lib/fs-ops.js +162 -0
- package/lib/hub.js +346 -140
- package/lib/routes.js +425 -38
- package/lib/state.js +11 -2
- package/lib/tools.js +684 -85
- package/lib/version.js +62 -0
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -55,6 +55,10 @@ window.__ModuleLoader__.load({
|
|
|
55
55
|
runtimeDisconnect: 'api/dsh-computers/runtime/disconnect',
|
|
56
56
|
/* Per-session target computer for the composer chip (task-9 host side). */
|
|
57
57
|
selection: 'api/dsh-computers/selection',
|
|
58
|
+
/* Access rules (task-11 host side): which computers a session may use,
|
|
59
|
+
* and which agent presets may use a computer. */
|
|
60
|
+
access: 'api/dsh-computers/access',
|
|
61
|
+
accessComputer: 'api/dsh-computers/access/computer',
|
|
58
62
|
};
|
|
59
63
|
|
|
60
64
|
// #region locales
|
|
@@ -65,7 +69,7 @@ window.__ModuleLoader__.load({
|
|
|
65
69
|
title: '电脑',
|
|
66
70
|
description: '把你自己电脑接进来,agent 就能在它上面直接执行命令。',
|
|
67
71
|
'connect.action': '连接电脑',
|
|
68
|
-
'badge.bots': '{used}/{total} Bot',
|
|
72
|
+
'badge.bots': '{used}/{total} 个 Bot',
|
|
69
73
|
online: '在线',
|
|
70
74
|
offline: '离线',
|
|
71
75
|
'rename.action': '重命名',
|
|
@@ -168,6 +172,35 @@ window.__ModuleLoader__.load({
|
|
|
168
172
|
'target.menuLabel': '选择这台会话使用的电脑',
|
|
169
173
|
'target.chipLabel': '这台对话使用的电脑:{name}',
|
|
170
174
|
'target.manage': '在 设置 → 电脑 中管理',
|
|
175
|
+
'access.manage': '管理访问权',
|
|
176
|
+
'access.session.title': '这台对话',
|
|
177
|
+
'access.session.subtitle': '它可以使用哪些电脑?',
|
|
178
|
+
'access.manageComputers': '管理电脑',
|
|
179
|
+
'access.done': '完成',
|
|
180
|
+
'access.cloudHint': '服务器本机',
|
|
181
|
+
'access.noneHint': '关闭全部后,这台对话不能使用任何远程电脑',
|
|
182
|
+
'access.computer.subtitle': '哪些 Bot 可以使用它?',
|
|
183
|
+
'access.defaultPreset': '默认',
|
|
184
|
+
'access.loading': '正在读取权限…',
|
|
185
|
+
'access.loadFailed': '无法读取权限设置',
|
|
186
|
+
'access.saveFailed': '保存失败:{message}',
|
|
187
|
+
'access.badgeAria': '管理「{name}」的使用权限',
|
|
188
|
+
'access.presetsEmpty': '还没有可用的 Bot 预设',
|
|
189
|
+
'access.computersEmpty': '还没有连接的电脑',
|
|
190
|
+
'access.computer.titleFallback': '这台电脑',
|
|
191
|
+
'access.presetDenied': '该电脑的 Bot 权限未包含当前预设',
|
|
192
|
+
'cap.exec': '执行',
|
|
193
|
+
'cap.fs': '文件',
|
|
194
|
+
'cap.pty': '终端',
|
|
195
|
+
'drift.chip': '可升级',
|
|
196
|
+
'drift.title': '升级这台电脑的 agent',
|
|
197
|
+
'drift.body': '这台电脑运行的是 agent {current},需要更新的版本才能使用全部能力。',
|
|
198
|
+
'drift.expected': '当前服务器使用 agent {version}。',
|
|
199
|
+
'drift.commandLabel': '在要升级的电脑上运行',
|
|
200
|
+
'drift.hint': '升级会重启这台电脑上的 agent 服务,正在执行的命令会中断。',
|
|
201
|
+
'drift.close': '关闭',
|
|
202
|
+
'drift.unknownVersion': '未知版本',
|
|
203
|
+
'drift.runtimeHint': '本机 agent 版本落后于服务器,升级 DSH 后重启生效',
|
|
171
204
|
'target.offline': '离线',
|
|
172
205
|
'target.failed': '切换失败:{message}',
|
|
173
206
|
};
|
|
@@ -280,6 +313,35 @@ window.__ModuleLoader__.load({
|
|
|
280
313
|
'target.menuLabel': 'Choose the computer this session uses',
|
|
281
314
|
'target.chipLabel': 'Computer for this chat: {name}',
|
|
282
315
|
'target.manage': 'Manage in Settings → Computers',
|
|
316
|
+
'access.manage': 'Manage access',
|
|
317
|
+
'access.session.title': 'This chat',
|
|
318
|
+
'access.session.subtitle': 'Which computers may it use?',
|
|
319
|
+
'access.manageComputers': 'Manage computers',
|
|
320
|
+
'access.done': 'Done',
|
|
321
|
+
'access.cloudHint': 'Server itself',
|
|
322
|
+
'access.noneHint': 'With everything off, this chat cannot use any remote computer',
|
|
323
|
+
'access.computer.subtitle': 'Which Bots may use it?',
|
|
324
|
+
'access.defaultPreset': 'Default',
|
|
325
|
+
'access.loading': 'Reading access rules…',
|
|
326
|
+
'access.loadFailed': 'Could not read the access rules',
|
|
327
|
+
'access.saveFailed': 'Could not save: {message}',
|
|
328
|
+
'access.badgeAria': 'Manage who may use “{name}”',
|
|
329
|
+
'access.presetsEmpty': 'No Bot presets available yet',
|
|
330
|
+
'access.computersEmpty': 'No computers connected yet',
|
|
331
|
+
'access.computer.titleFallback': 'This computer',
|
|
332
|
+
'access.presetDenied': 'This computer’s Bot access does not include the current preset',
|
|
333
|
+
'cap.exec': 'Exec',
|
|
334
|
+
'cap.fs': 'Files',
|
|
335
|
+
'cap.pty': 'Terminal',
|
|
336
|
+
'drift.chip': 'Upgradable',
|
|
337
|
+
'drift.title': 'Upgrade this computer’s agent',
|
|
338
|
+
'drift.body': 'This computer runs agent {current}; a newer version is needed for every capability.',
|
|
339
|
+
'drift.expected': 'This server uses agent {version}.',
|
|
340
|
+
'drift.commandLabel': 'Run this on the computer you want to upgrade',
|
|
341
|
+
'drift.hint': 'Upgrading restarts the agent service on that computer; running commands are interrupted.',
|
|
342
|
+
'drift.close': 'Close',
|
|
343
|
+
'drift.unknownVersion': 'unknown version',
|
|
344
|
+
'drift.runtimeHint': 'This computer’s agent is behind the server; update DSH and restart',
|
|
283
345
|
'target.offline': 'Offline',
|
|
284
346
|
'target.failed': 'Could not switch: {message}',
|
|
285
347
|
};
|
|
@@ -579,6 +641,21 @@ window.__ModuleLoader__.load({
|
|
|
579
641
|
font-weight: 500;
|
|
580
642
|
white-space: nowrap;
|
|
581
643
|
}
|
|
644
|
+
.dshc-badgeBtn {
|
|
645
|
+
border: none;
|
|
646
|
+
font-family: inherit;
|
|
647
|
+
cursor: pointer;
|
|
648
|
+
}
|
|
649
|
+
@media (hover: hover) {
|
|
650
|
+
.dshc-badgeBtn:hover {
|
|
651
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
|
|
652
|
+
color: var(--dsw-alias-label-primary);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
.dshc-badgeBtn:focus-visible {
|
|
656
|
+
outline: 2px solid var(--dsw-alias-brand-primary);
|
|
657
|
+
outline-offset: 1px;
|
|
658
|
+
}
|
|
582
659
|
.dshc-status {
|
|
583
660
|
flex: none;
|
|
584
661
|
display: inline-flex;
|
|
@@ -760,6 +837,9 @@ window.__ModuleLoader__.load({
|
|
|
760
837
|
color: var(--dsw-alias-label-secondary);
|
|
761
838
|
}
|
|
762
839
|
.dshc-textBtn {
|
|
840
|
+
display: inline-flex;
|
|
841
|
+
align-items: center;
|
|
842
|
+
gap: 5px;
|
|
763
843
|
flex: none;
|
|
764
844
|
padding: 4px 9px;
|
|
765
845
|
border: none;
|
|
@@ -1036,6 +1116,129 @@ window.__ModuleLoader__.load({
|
|
|
1036
1116
|
gap: 8px;
|
|
1037
1117
|
margin-top: 20px;
|
|
1038
1118
|
}
|
|
1119
|
+
.dshc-footerSplit { justify-content: space-between; }
|
|
1120
|
+
|
|
1121
|
+
/* Access dialogs (task-12): one row per computer / per Bot preset. */
|
|
1122
|
+
.dshc-accessList {
|
|
1123
|
+
max-height: min(320px, calc(100vh - 280px));
|
|
1124
|
+
overflow-y: auto;
|
|
1125
|
+
margin: 2px -4px 0;
|
|
1126
|
+
padding: 0 4px;
|
|
1127
|
+
}
|
|
1128
|
+
.dshc-accessRow {
|
|
1129
|
+
display: flex;
|
|
1130
|
+
align-items: center;
|
|
1131
|
+
gap: 10px;
|
|
1132
|
+
min-height: 44px;
|
|
1133
|
+
padding: 6px 0;
|
|
1134
|
+
}
|
|
1135
|
+
.dshc-accessRow + .dshc-accessRow { border-top: 1px solid var(--dsw-alias-border-l1); }
|
|
1136
|
+
.dshc-accessIcon {
|
|
1137
|
+
flex: none;
|
|
1138
|
+
display: flex;
|
|
1139
|
+
color: var(--dsw-alias-label-secondary);
|
|
1140
|
+
}
|
|
1141
|
+
.dshc-accessText {
|
|
1142
|
+
display: flex;
|
|
1143
|
+
align-items: center;
|
|
1144
|
+
gap: 7px;
|
|
1145
|
+
min-width: 0;
|
|
1146
|
+
flex: 1 1 auto;
|
|
1147
|
+
}
|
|
1148
|
+
.dshc-accessName {
|
|
1149
|
+
min-width: 0;
|
|
1150
|
+
overflow: hidden;
|
|
1151
|
+
text-overflow: ellipsis;
|
|
1152
|
+
white-space: nowrap;
|
|
1153
|
+
font-size: 13px;
|
|
1154
|
+
line-height: 20px;
|
|
1155
|
+
font-weight: 500;
|
|
1156
|
+
color: var(--dsw-alias-label-primary);
|
|
1157
|
+
}
|
|
1158
|
+
.dshc-accessHint {
|
|
1159
|
+
flex: none;
|
|
1160
|
+
color: var(--dsw-alias-label-secondary);
|
|
1161
|
+
font-size: 11.5px;
|
|
1162
|
+
line-height: 16px;
|
|
1163
|
+
}
|
|
1164
|
+
.dshc-accessStatus {
|
|
1165
|
+
flex: none;
|
|
1166
|
+
display: inline-flex;
|
|
1167
|
+
align-items: center;
|
|
1168
|
+
gap: 5px;
|
|
1169
|
+
color: var(--dsw-alias-label-secondary);
|
|
1170
|
+
font-size: 11.5px;
|
|
1171
|
+
line-height: 16px;
|
|
1172
|
+
white-space: nowrap;
|
|
1173
|
+
}
|
|
1174
|
+
.dshc-accessEmpty {
|
|
1175
|
+
margin: 6px 0 0;
|
|
1176
|
+
color: var(--dsw-alias-label-secondary);
|
|
1177
|
+
font-size: 12.5px;
|
|
1178
|
+
line-height: 18px;
|
|
1179
|
+
}
|
|
1180
|
+
.dshc-switch:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
1181
|
+
|
|
1182
|
+
/* Row capability chips + the amber upgrade chip (task-15). */
|
|
1183
|
+
.dshc-capChip {
|
|
1184
|
+
flex: none;
|
|
1185
|
+
padding: 0 5px;
|
|
1186
|
+
border-radius: 5px;
|
|
1187
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
|
|
1188
|
+
color: var(--dsw-alias-label-secondary);
|
|
1189
|
+
font-size: 10.5px;
|
|
1190
|
+
line-height: 15px;
|
|
1191
|
+
font-weight: 500;
|
|
1192
|
+
white-space: nowrap;
|
|
1193
|
+
}
|
|
1194
|
+
.dshc-driftChip {
|
|
1195
|
+
flex: none;
|
|
1196
|
+
display: inline-flex;
|
|
1197
|
+
align-items: center;
|
|
1198
|
+
padding: 0 5px;
|
|
1199
|
+
border: 1px solid currentColor;
|
|
1200
|
+
border-radius: 5px;
|
|
1201
|
+
background: transparent;
|
|
1202
|
+
color: var(--dsw-alias-state-warn-primary);
|
|
1203
|
+
font: inherit;
|
|
1204
|
+
font-size: 10.5px;
|
|
1205
|
+
line-height: 15px;
|
|
1206
|
+
font-weight: 500;
|
|
1207
|
+
white-space: nowrap;
|
|
1208
|
+
cursor: pointer;
|
|
1209
|
+
}
|
|
1210
|
+
@media (hover: hover) {
|
|
1211
|
+
.dshc-driftChip:hover { background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2)); }
|
|
1212
|
+
}
|
|
1213
|
+
.dshc-driftChip:focus-visible { outline: 2px solid var(--dsw-alias-state-warn-primary); outline-offset: 1px; }
|
|
1214
|
+
.dshc-driftHint {
|
|
1215
|
+
margin: 8px 0 0;
|
|
1216
|
+
color: var(--dsw-alias-state-warn-primary);
|
|
1217
|
+
font-size: 11.5px;
|
|
1218
|
+
line-height: 16px;
|
|
1219
|
+
}
|
|
1220
|
+
.dshc-runtimeAgent {
|
|
1221
|
+
margin-top: 2px;
|
|
1222
|
+
color: var(--dsw-alias-label-secondary);
|
|
1223
|
+
font-size: 11.5px;
|
|
1224
|
+
line-height: 16px;
|
|
1225
|
+
overflow-wrap: anywhere;
|
|
1226
|
+
}
|
|
1227
|
+
.dshc-accessReason {
|
|
1228
|
+
margin: 2px 0 4px 25px;
|
|
1229
|
+
color: var(--dsw-alias-label-secondary);
|
|
1230
|
+
font-size: 11.5px;
|
|
1231
|
+
line-height: 16px;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/* Composer menu: the manage-access row replaces the old muted footer. */
|
|
1235
|
+
.dshc-menuSep {
|
|
1236
|
+
height: 1px;
|
|
1237
|
+
margin: 4px 4px;
|
|
1238
|
+
background: var(--dsw-alias-border-l1);
|
|
1239
|
+
}
|
|
1240
|
+
.dshc-menuManage { color: var(--dsw-alias-label-primary); }
|
|
1241
|
+
.dshc-targetError[data-kind="info"] { color: var(--dsw-alias-label-secondary); }
|
|
1039
1242
|
|
|
1040
1243
|
.dshc-seg {
|
|
1041
1244
|
display: flex;
|
|
@@ -1337,10 +1540,22 @@ window.__ModuleLoader__.load({
|
|
|
1337
1540
|
.dshc-copyRowKey {
|
|
1338
1541
|
flex: 0 0 auto;
|
|
1339
1542
|
min-width: 56px;
|
|
1543
|
+
white-space: nowrap;
|
|
1340
1544
|
color: var(--dsw-alias-label-secondary);
|
|
1341
1545
|
font-size: 11.5px;
|
|
1342
1546
|
line-height: 16px;
|
|
1343
1547
|
}
|
|
1548
|
+
/* Long label + long command (upgrade dialog): label on its own line, copy
|
|
1549
|
+
* pinned to the card's top-right corner. */
|
|
1550
|
+
.dshc-copyRow--stacked { position: relative; padding-right: 36px; }
|
|
1551
|
+
.dshc-copyRow--stacked .dshc-copyRowTop { flex-wrap: wrap; }
|
|
1552
|
+
.dshc-copyRow--stacked .dshc-copyRowBtn { position: absolute; top: 6px; right: 6px; }
|
|
1553
|
+
.dshc-copyRow--stacked .dshc-copyRowKey {
|
|
1554
|
+
flex: 0 0 100%;
|
|
1555
|
+
min-width: 0;
|
|
1556
|
+
margin-bottom: 2px;
|
|
1557
|
+
}
|
|
1558
|
+
.dshc-copyRow--stacked .dshc-copyRowHint { padding-left: 0; }
|
|
1344
1559
|
.dshc-copyRowHint {
|
|
1345
1560
|
margin: 0;
|
|
1346
1561
|
padding-left: 64px;
|
|
@@ -1539,8 +1754,35 @@ window.__ModuleLoader__.load({
|
|
|
1539
1754
|
body: { sessionId, computerId },
|
|
1540
1755
|
signal,
|
|
1541
1756
|
}),
|
|
1757
|
+
readAccess: (sessionId, signal) => requestJson(
|
|
1758
|
+
`${API.access}?sessionId=${encodeURIComponent(sessionId)}`,
|
|
1759
|
+
{ signal },
|
|
1760
|
+
),
|
|
1761
|
+
setAccess: (sessionId, allowedComputers, signal) => requestJson(API.access, {
|
|
1762
|
+
method: 'POST',
|
|
1763
|
+
body: { sessionId, allowedComputers },
|
|
1764
|
+
signal,
|
|
1765
|
+
}),
|
|
1766
|
+
readComputerAccess: (computerId, signal) => requestJson(
|
|
1767
|
+
`${API.accessComputer}?computerId=${encodeURIComponent(computerId)}`,
|
|
1768
|
+
{ signal },
|
|
1769
|
+
),
|
|
1770
|
+
setComputerAccess: (computerId, allowedPresets, signal) => requestJson(API.accessComputer, {
|
|
1771
|
+
method: 'POST',
|
|
1772
|
+
body: { computerId, allowedPresets },
|
|
1773
|
+
signal,
|
|
1774
|
+
}),
|
|
1542
1775
|
};
|
|
1543
1776
|
|
|
1777
|
+
/* Capability ids → localized chip labels; unknown ids render raw. */
|
|
1778
|
+
const CAPABILITY_KEYS = { exec: 'cap.exec', fs: 'cap.fs', pty: 'cap.pty' };
|
|
1779
|
+
|
|
1780
|
+
/** @param {string} capability - capability id. @returns {string} chip text. */
|
|
1781
|
+
function capabilityLabel(capability) {
|
|
1782
|
+
const key = CAPABILITY_KEYS[capability];
|
|
1783
|
+
return key === undefined ? String(capability) : t(key);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1544
1786
|
/** @param {object|null} data - a /state payload. @returns {string} 'hub' | 'runtime' | 'both'. */
|
|
1545
1787
|
function roleOf(data) {
|
|
1546
1788
|
if (data && typeof data.role === 'string' && data.role !== '') return data.role;
|
|
@@ -1802,7 +2044,7 @@ window.__ModuleLoader__.load({
|
|
|
1802
2044
|
}
|
|
1803
2045
|
};
|
|
1804
2046
|
const action = t('connect.copyField', { field: props.label });
|
|
1805
|
-
return h('div', { className: 'dshc-copyRow' },
|
|
2047
|
+
return h('div', { className: props.stacked === true ? 'dshc-copyRow dshc-copyRow--stacked' : 'dshc-copyRow' },
|
|
1806
2048
|
h('div', { className: 'dshc-copyRowTop' },
|
|
1807
2049
|
h('span', { className: 'dshc-copyRowKey' }, props.label),
|
|
1808
2050
|
h('span', { className: 'dshc-copyRowValue' }, typeof props.value === 'string' && props.value !== '' ? props.value : '—'),
|
|
@@ -1835,16 +2077,41 @@ window.__ModuleLoader__.load({
|
|
|
1835
2077
|
function ComputerRow(props) {
|
|
1836
2078
|
const computer = props.computer;
|
|
1837
2079
|
const online = computer.online === true;
|
|
1838
|
-
const bots =
|
|
2080
|
+
const bots = props.counts
|
|
2081
|
+
|| (computer.bots && typeof computer.bots === 'object' ? computer.bots : null);
|
|
1839
2082
|
const used = bots && typeof bots.used === 'number' ? bots.used : 1;
|
|
1840
2083
|
const total = bots && typeof bots.total === 'number' ? bots.total : 1;
|
|
1841
2084
|
const name = typeof computer.name === 'string' && computer.name !== ''
|
|
1842
2085
|
? computer.name
|
|
1843
2086
|
: (typeof computer.hostname === 'string' ? computer.hostname : '');
|
|
2087
|
+
const capabilities = Array.isArray(computer.capabilities)
|
|
2088
|
+
? computer.capabilities.filter((capability) => typeof capability === 'string' && capability !== '')
|
|
2089
|
+
: [];
|
|
2090
|
+
const drift = computer.updateRequired === true || computer.drift === true;
|
|
1844
2091
|
return h('div', { className: 'dshc-row', 'data-online': online ? 'true' : 'false' },
|
|
1845
2092
|
h('div', { className: 'dshc-rowMain' },
|
|
1846
2093
|
h('span', { className: 'dshc-name', title: name }, name),
|
|
1847
|
-
h('
|
|
2094
|
+
h('button', {
|
|
2095
|
+
type: 'button',
|
|
2096
|
+
className: 'dshc-badge dshc-badgeBtn',
|
|
2097
|
+
'aria-label': t('access.badgeAria', { name }),
|
|
2098
|
+
title: t('access.badgeAria', { name }),
|
|
2099
|
+
onClick: () => props.onAccess(computer),
|
|
2100
|
+
}, t('badge.bots', { used, total })),
|
|
2101
|
+
capabilities.map((capability) => h('span', {
|
|
2102
|
+
key: `cap-${capability}`,
|
|
2103
|
+
className: 'dshc-capChip',
|
|
2104
|
+
}, capabilityLabel(capability))),
|
|
2105
|
+
drift
|
|
2106
|
+
? h('button', {
|
|
2107
|
+
key: 'drift',
|
|
2108
|
+
type: 'button',
|
|
2109
|
+
className: 'dshc-driftChip',
|
|
2110
|
+
'aria-label': t('drift.title'),
|
|
2111
|
+
title: t('drift.title'),
|
|
2112
|
+
onClick: () => props.onUpgrade(computer),
|
|
2113
|
+
}, t('drift.chip'))
|
|
2114
|
+
: null),
|
|
1848
2115
|
h('span', { className: 'dshc-status' },
|
|
1849
2116
|
h('span', { className: 'dshc-dot', 'aria-hidden': 'true' }),
|
|
1850
2117
|
online ? t('online') : t('offline')),
|
|
@@ -1913,7 +2180,9 @@ window.__ModuleLoader__.load({
|
|
|
1913
2180
|
}, h(IconClose, { size: 16 }))),
|
|
1914
2181
|
props.subtitle ? h('p', { className: 'dshc-cardSub' }, props.subtitle) : null,
|
|
1915
2182
|
h('div', null, children),
|
|
1916
|
-
footer.length > 0
|
|
2183
|
+
footer.length > 0
|
|
2184
|
+
? h('div', { className: props.footerClassName ? `dshc-footer ${props.footerClassName}` : 'dshc-footer' }, footer)
|
|
2185
|
+
: null)));
|
|
1917
2186
|
}
|
|
1918
2187
|
|
|
1919
2188
|
function RenameDialog(props) {
|
|
@@ -2414,8 +2683,21 @@ window.__ModuleLoader__.load({
|
|
|
2414
2683
|
h('p', { className: 'dshc-hint' }, t('runtime.hint')));
|
|
2415
2684
|
} else {
|
|
2416
2685
|
const name = typeof status.name === 'string' && status.name !== '' ? status.name : t('runtime.nameUnknown');
|
|
2686
|
+
const agentVersion = typeof status.agentVersion === 'string' && status.agentVersion !== ''
|
|
2687
|
+
? status.agentVersion
|
|
2688
|
+
: '';
|
|
2689
|
+
const capabilities = Array.isArray(status.capabilities)
|
|
2690
|
+
? status.capabilities.filter((capability) => typeof capability === 'string' && capability !== '')
|
|
2691
|
+
: [];
|
|
2692
|
+
const agentParts = [];
|
|
2693
|
+
if (agentVersion !== '') agentParts.push(`agent ${agentVersion}`);
|
|
2694
|
+
if (capabilities.length > 0) agentParts.push(capabilities.map(capabilityLabel).join(' / '));
|
|
2695
|
+
const drift = status.updateRequired === true || status.drift === true;
|
|
2417
2696
|
body = h('div', null,
|
|
2418
2697
|
h('div', { className: 'dshc-runtimeName', title: name }, name),
|
|
2698
|
+
agentParts.length > 0
|
|
2699
|
+
? h('div', { className: 'dshc-runtimeAgent' }, agentParts.join(' · '))
|
|
2700
|
+
: null,
|
|
2419
2701
|
h('div', { className: 'dshc-runtimeMeta' },
|
|
2420
2702
|
h('span', { className: 'dshc-runtimeMetaKey' }, t('runtime.target')),
|
|
2421
2703
|
h('span', { className: 'dshc-runtimeMetaValue' }, typeof status.serverUrl === 'string' ? status.serverUrl : '')),
|
|
@@ -2437,6 +2719,7 @@ window.__ModuleLoader__.load({
|
|
|
2437
2719
|
disabled: busy,
|
|
2438
2720
|
onClick: () => setConfirming(true),
|
|
2439
2721
|
}, t('runtime.disconnect'))),
|
|
2722
|
+
drift ? h('p', { className: 'dshc-driftHint' }, t('drift.runtimeHint')) : null,
|
|
2440
2723
|
status.lastError
|
|
2441
2724
|
? h('p', { className: 'dshc-formError', role: 'status' },
|
|
2442
2725
|
t('runtime.lastError', { message: String(status.lastError) }))
|
|
@@ -2532,6 +2815,9 @@ window.__ModuleLoader__.load({
|
|
|
2532
2815
|
const scope = React.useRef(null);
|
|
2533
2816
|
const noticeTimer = React.useRef(null);
|
|
2534
2817
|
const runtimeRequested = React.useRef(false);
|
|
2818
|
+
const accessCounts = React.useRef(new Map());
|
|
2819
|
+
const accessRequested = React.useRef(new Set());
|
|
2820
|
+
const [, setCountsVersion] = React.useState(0);
|
|
2535
2821
|
if (scope.current === null) scope.current = createScope();
|
|
2536
2822
|
|
|
2537
2823
|
const loadRuntime = React.useCallback(async () => {
|
|
@@ -2622,6 +2908,11 @@ window.__ModuleLoader__.load({
|
|
|
2622
2908
|
const data = view.data;
|
|
2623
2909
|
const role = data === null ? null : roleOf(data);
|
|
2624
2910
|
const computers = data && Array.isArray(data.computers) ? data.computers : null;
|
|
2911
|
+
|
|
2912
|
+
const countsFor = (computer) => {
|
|
2913
|
+
const id = computer && computer.id ? String(computer.id) : '';
|
|
2914
|
+
return id === '' ? null : (accessCounts.current.get(id) || null);
|
|
2915
|
+
};
|
|
2625
2916
|
const showRuntime = role !== null && role !== 'hub';
|
|
2626
2917
|
const showComputers = role !== 'runtime';
|
|
2627
2918
|
const canConnect = role !== 'runtime';
|
|
@@ -2642,8 +2933,11 @@ window.__ModuleLoader__.load({
|
|
|
2642
2933
|
body = h('div', { className: 'dshc-list' }, computers.map((computer) => h(ComputerRow, {
|
|
2643
2934
|
key: computer.id || computer.name,
|
|
2644
2935
|
computer,
|
|
2936
|
+
counts: countsFor(computer),
|
|
2645
2937
|
onRename: (target) => setDialog({ kind: 'rename', computer: target }),
|
|
2646
2938
|
onDelete: (target) => setDialog({ kind: 'delete', computer: target }),
|
|
2939
|
+
onAccess: (target) => setDialog({ kind: 'access', computer: target }),
|
|
2940
|
+
onUpgrade: (target) => setDialog({ kind: 'upgrade', computer: target }),
|
|
2647
2941
|
})));
|
|
2648
2942
|
}
|
|
2649
2943
|
|
|
@@ -2660,6 +2954,24 @@ window.__ModuleLoader__.load({
|
|
|
2660
2954
|
onClose: () => setDialog(null),
|
|
2661
2955
|
onRenamed: handleRenamed,
|
|
2662
2956
|
});
|
|
2957
|
+
} else if (dialog !== null && dialog.kind === 'access' && dialog.computer) {
|
|
2958
|
+
layer = h(ComputerAccessDialog, {
|
|
2959
|
+
computer: dialog.computer,
|
|
2960
|
+
onClose: () => setDialog(null),
|
|
2961
|
+
onChanged: (counts) => {
|
|
2962
|
+
const id = dialog.computer && dialog.computer.id ? String(dialog.computer.id) : '';
|
|
2963
|
+
if (id === '' || !counts) return;
|
|
2964
|
+
accessCounts.current.set(id, counts);
|
|
2965
|
+
setCountsVersion((value) => value + 1);
|
|
2966
|
+
},
|
|
2967
|
+
});
|
|
2968
|
+
} else if (dialog !== null && dialog.kind === 'upgrade' && dialog.computer) {
|
|
2969
|
+
layer = h(UpgradeDialog, {
|
|
2970
|
+
computer: dialog.computer,
|
|
2971
|
+
serverUrl: data && typeof data.serverUrl === 'string' ? data.serverUrl : '',
|
|
2972
|
+
expectedVersion: data && typeof data.agentVersion === 'string' ? data.agentVersion : '',
|
|
2973
|
+
onClose: () => setDialog(null),
|
|
2974
|
+
});
|
|
2663
2975
|
} else if (dialog !== null && dialog.kind === 'delete' && dialog.computer) {
|
|
2664
2976
|
layer = h(DeleteDialog, {
|
|
2665
2977
|
computer: dialog.computer,
|
|
@@ -2668,6 +2980,33 @@ window.__ModuleLoader__.load({
|
|
|
2668
2980
|
});
|
|
2669
2981
|
}
|
|
2670
2982
|
|
|
2983
|
+
/* Real badge counts: one light GET per computer, cached for the life of
|
|
2984
|
+
* the page and refreshed by the access dialog. /state's sugar stays as
|
|
2985
|
+
* the fallback until the first read lands. Keyed by the id list so the
|
|
2986
|
+
* 5 s /state poll does not re-request. */
|
|
2987
|
+
React.useEffect(() => {
|
|
2988
|
+
if (computers === null) return;
|
|
2989
|
+
for (const computer of computers) {
|
|
2990
|
+
const id = computer && computer.id ? String(computer.id) : '';
|
|
2991
|
+
if (id === '' || accessCounts.current.has(id) || accessRequested.current.has(id)) continue;
|
|
2992
|
+
accessRequested.current.add(id);
|
|
2993
|
+
void (async () => {
|
|
2994
|
+
try {
|
|
2995
|
+
const payload = await scope.current.run((signal) => api.readComputerAccess(id, signal));
|
|
2996
|
+
const presets = payload && Array.isArray(payload.presets) ? payload.presets : null;
|
|
2997
|
+
if (presets === null || !alive.current) return;
|
|
2998
|
+
accessCounts.current.set(id, {
|
|
2999
|
+
used: presets.filter((preset) => preset.allowed === true).length,
|
|
3000
|
+
total: presets.length,
|
|
3001
|
+
});
|
|
3002
|
+
setCountsVersion((value) => value + 1);
|
|
3003
|
+
} catch (error) {
|
|
3004
|
+
/* Keep the /state fallback count for this computer. */
|
|
3005
|
+
}
|
|
3006
|
+
})();
|
|
3007
|
+
}
|
|
3008
|
+
}, [computers === null ? '' : computers.map((computer) => (computer && computer.id ? computer.id : '')).join(',')]);
|
|
3009
|
+
|
|
2671
3010
|
return h('section', { className: 'dshc-root', 'aria-label': t('nav') },
|
|
2672
3011
|
h('div', { className: 'dshc-head' },
|
|
2673
3012
|
h('div', { className: 'dshc-headMain' },
|
|
@@ -2698,6 +3037,363 @@ window.__ModuleLoader__.load({
|
|
|
2698
3037
|
|
|
2699
3038
|
// #endregion
|
|
2700
3039
|
|
|
3040
|
+
// #region access dialogs
|
|
3041
|
+
|
|
3042
|
+
/**
|
|
3043
|
+
* The on/off switch used by the access dialogs.
|
|
3044
|
+
* @param {object} props - `checked`, `label`, `onChange`, optional `disabled`.
|
|
3045
|
+
* @returns {object} switch button.
|
|
3046
|
+
*/
|
|
3047
|
+
function SwitchControl(props) {
|
|
3048
|
+
return h('button', {
|
|
3049
|
+
type: 'button',
|
|
3050
|
+
className: 'dshc-switch',
|
|
3051
|
+
role: 'switch',
|
|
3052
|
+
'aria-checked': props.checked === true ? 'true' : 'false',
|
|
3053
|
+
'aria-label': props.label,
|
|
3054
|
+
disabled: props.disabled === true,
|
|
3055
|
+
onClick: props.disabled === true ? undefined : props.onChange,
|
|
3056
|
+
}, h('span', { className: 'dshc-switchThumb' }));
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
/**
|
|
3060
|
+
* One dialog row: icon, name (+ hint / online state), trailing switch.
|
|
3061
|
+
* @param {object} props - `icon`, `name`, `hint`, `online`, `switch`.
|
|
3062
|
+
* @returns {object} row element.
|
|
3063
|
+
*/
|
|
3064
|
+
function AccessRow(props) {
|
|
3065
|
+
return h('div', { className: 'dshc-accessRow' },
|
|
3066
|
+
props.icon ? h('span', { className: 'dshc-accessIcon', 'aria-hidden': 'true' }, props.icon) : null,
|
|
3067
|
+
h('div', { className: 'dshc-accessText' },
|
|
3068
|
+
h('span', { className: 'dshc-accessName', title: props.name }, props.name),
|
|
3069
|
+
props.hint ? h('span', { className: 'dshc-accessHint' }, props.hint) : null,
|
|
3070
|
+
props.online === undefined
|
|
3071
|
+
? null
|
|
3072
|
+
: h('span', { className: 'dshc-accessStatus' },
|
|
3073
|
+
h('span', {
|
|
3074
|
+
className: 'dshc-dot',
|
|
3075
|
+
'data-state': props.online === true ? 'running' : 'stopped',
|
|
3076
|
+
'aria-hidden': 'true',
|
|
3077
|
+
}),
|
|
3078
|
+
props.online === true ? t('online') : t('offline'))),
|
|
3079
|
+
props.switch,
|
|
3080
|
+
props.reason ? h('p', { className: 'dshc-accessReason' }, props.reason) : null);
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
/**
|
|
3084
|
+
* Agent upgrade dialog: the manual one-liner for a machine whose agent is
|
|
3085
|
+
* behind the server (task-14 drift flag).
|
|
3086
|
+
* @param {object} props - `computer`, `serverUrl`, `expectedVersion`, `onClose`.
|
|
3087
|
+
* @returns {object} dialog layer.
|
|
3088
|
+
*/
|
|
3089
|
+
function UpgradeDialog(props) {
|
|
3090
|
+
const computer = props.computer || {};
|
|
3091
|
+
const serverUrl = typeof props.serverUrl === 'string' && props.serverUrl !== '' ? props.serverUrl : '';
|
|
3092
|
+
const command = `node ~/.dsh-computers/agent.mjs service stop && curl -fsSL '${serverUrl}/api/dsh-computers/agent.mjs' -o ~/.dsh-computers/agent.mjs && node ~/.dsh-computers/agent.mjs service start`;
|
|
3093
|
+
const current = typeof computer.agentVersion === 'string' && computer.agentVersion !== ''
|
|
3094
|
+
? computer.agentVersion
|
|
3095
|
+
: t('drift.unknownVersion');
|
|
3096
|
+
const expected = typeof props.expectedVersion === 'string' && props.expectedVersion !== ''
|
|
3097
|
+
? props.expectedVersion
|
|
3098
|
+
: '';
|
|
3099
|
+
return h(Dialog, {
|
|
3100
|
+
title: t('drift.title'),
|
|
3101
|
+
size: 'sm',
|
|
3102
|
+
onClose: props.onClose,
|
|
3103
|
+
footer: [
|
|
3104
|
+
h('button', {
|
|
3105
|
+
key: 'close',
|
|
3106
|
+
type: 'button',
|
|
3107
|
+
className: 'dshc-primary',
|
|
3108
|
+
onClick: props.onClose,
|
|
3109
|
+
}, t('drift.close')),
|
|
3110
|
+
],
|
|
3111
|
+
},
|
|
3112
|
+
h('p', { className: 'dshc-dialogBody' }, t('drift.body', { current })),
|
|
3113
|
+
expected !== ''
|
|
3114
|
+
? h('p', { className: 'dshc-dialogBody', style: { marginTop: '4px' } }, t('drift.expected', { version: expected }))
|
|
3115
|
+
: null,
|
|
3116
|
+
h('div', { className: 'dshc-copyStack', style: { marginTop: '12px' } },
|
|
3117
|
+
h(CopyRow, { label: t('drift.commandLabel'), value: command, stacked: true })),
|
|
3118
|
+
h('p', { className: 'dshc-hint' }, t('drift.hint')));
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
/**
|
|
3122
|
+
* Session access dialog — which computers this conversation may use
|
|
3123
|
+
* (PROTOCOL access API, task-11). Toggles POST the whole allow-list.
|
|
3124
|
+
* @param {object} props - `sessionId`, `onClose`, `onSaved(payload)`, `onManageComputers()`.
|
|
3125
|
+
* @returns {object} dialog layer.
|
|
3126
|
+
*/
|
|
3127
|
+
function SessionAccessDialog(props) {
|
|
3128
|
+
const [view, setView] = React.useState({ phase: 'loading', data: null });
|
|
3129
|
+
const [busy, setBusy] = React.useState(false);
|
|
3130
|
+
const [failure, setFailure] = React.useState(null);
|
|
3131
|
+
const alive = React.useRef(true);
|
|
3132
|
+
const scope = React.useRef(null);
|
|
3133
|
+
if (scope.current === null) scope.current = createScope();
|
|
3134
|
+
|
|
3135
|
+
React.useEffect(() => {
|
|
3136
|
+
alive.current = true;
|
|
3137
|
+
scope.current.run((signal) => api.readAccess(props.sessionId, signal))
|
|
3138
|
+
.then((payload) => {
|
|
3139
|
+
if (!alive.current) return;
|
|
3140
|
+
if (payload && typeof payload === 'object') setView({ phase: 'ready', data: payload });
|
|
3141
|
+
else setView({ phase: 'error', data: null });
|
|
3142
|
+
})
|
|
3143
|
+
.catch((error) => {
|
|
3144
|
+
if (!alive.current || (error && error.name === 'AbortError')) return;
|
|
3145
|
+
setView({ phase: 'error', data: null, error });
|
|
3146
|
+
});
|
|
3147
|
+
return () => {
|
|
3148
|
+
alive.current = false;
|
|
3149
|
+
scope.current.abortAll();
|
|
3150
|
+
};
|
|
3151
|
+
}, [props.sessionId]);
|
|
3152
|
+
|
|
3153
|
+
const computers = view.data && Array.isArray(view.data.computers) ? view.data.computers : null;
|
|
3154
|
+
const allowedCount = computers === null ? 0 : computers.filter((entry) => entry.allowed === true).length;
|
|
3155
|
+
|
|
3156
|
+
const toggle = async (computer) => {
|
|
3157
|
+
if (computers === null || busy) return;
|
|
3158
|
+
const previous = view.data;
|
|
3159
|
+
/* The switch acts on the SESSION rule: `sessionAllowed` is the raw
|
|
3160
|
+
* rule (null = all), while each row's `allowed` is the effective
|
|
3161
|
+
* (session AND preset) state. */
|
|
3162
|
+
const rawRule = Array.isArray(previous.sessionAllowed)
|
|
3163
|
+
? previous.sessionAllowed
|
|
3164
|
+
: computers.map((entry) => entry.id);
|
|
3165
|
+
const ruleSet = new Set(rawRule);
|
|
3166
|
+
if (ruleSet.has(computer.id)) ruleSet.delete(computer.id);
|
|
3167
|
+
else ruleSet.add(computer.id);
|
|
3168
|
+
const nextIds = Array.from(ruleSet).sort();
|
|
3169
|
+
/* Allowing every known computer posts null ("all, including future
|
|
3170
|
+
* machines"); an explicit list would freeze the set. */
|
|
3171
|
+
const allOn = computers.length > 0 && nextIds.length === computers.length;
|
|
3172
|
+
const optimistic = {
|
|
3173
|
+
...previous,
|
|
3174
|
+
sessionAllowed: allOn ? null : nextIds,
|
|
3175
|
+
computers: computers.map((entry) => ({
|
|
3176
|
+
...entry,
|
|
3177
|
+
sessionAllowed: ruleSet.has(entry.id),
|
|
3178
|
+
allowed: ruleSet.has(entry.id) && entry.presetAllowed !== false,
|
|
3179
|
+
})),
|
|
3180
|
+
};
|
|
3181
|
+
setFailure(null);
|
|
3182
|
+
setView({ phase: 'ready', data: optimistic });
|
|
3183
|
+
setBusy(true);
|
|
3184
|
+
try {
|
|
3185
|
+
const result = await scope.current.run((signal) => api.setAccess(props.sessionId, allOn ? null : nextIds, signal));
|
|
3186
|
+
if (!alive.current) return;
|
|
3187
|
+
const next = result && typeof result === 'object' ? result : optimistic;
|
|
3188
|
+
setView({ phase: 'ready', data: next });
|
|
3189
|
+
if (typeof props.onSaved === 'function') props.onSaved(next);
|
|
3190
|
+
} catch (error) {
|
|
3191
|
+
if (!alive.current) return;
|
|
3192
|
+
if (error && error.name === 'AbortError') return;
|
|
3193
|
+
setView({ phase: 'ready', data: previous });
|
|
3194
|
+
setFailure(t('access.saveFailed', { message: messageOf(error) }));
|
|
3195
|
+
} finally {
|
|
3196
|
+
if (alive.current) setBusy(false);
|
|
3197
|
+
}
|
|
3198
|
+
};
|
|
3199
|
+
|
|
3200
|
+
let body;
|
|
3201
|
+
if (computers === null && view.phase === 'loading') {
|
|
3202
|
+
body = h('div', { className: 'dshc-accessRow' }, h(Spinner), h('span', { className: 'dshc-accessHint' }, t('access.loading')));
|
|
3203
|
+
} else if (computers === null) {
|
|
3204
|
+
body = h('div', { className: 'dshc-errorCard', role: 'alert' },
|
|
3205
|
+
h('span', { className: 'dshc-errorText' },
|
|
3206
|
+
t('error.loadDetail', { title: t('access.loadFailed'), message: view.error ? messageOf(view.error) : '' })));
|
|
3207
|
+
} else {
|
|
3208
|
+
const rows = [h(AccessRow, {
|
|
3209
|
+
key: 'cloud',
|
|
3210
|
+
icon: h(IconMonitor, { size: 15 }),
|
|
3211
|
+
name: t('target.cloud'),
|
|
3212
|
+
hint: t('access.cloudHint'),
|
|
3213
|
+
switch: h(SwitchControl, { checked: true, disabled: true, label: t('target.cloud') }),
|
|
3214
|
+
})].concat(computers.map((computer) => h(AccessRow, {
|
|
3215
|
+
key: computer.id || computer.name,
|
|
3216
|
+
icon: h(IconMonitor, { size: 15 }),
|
|
3217
|
+
name: typeof computer.name === 'string' && computer.name !== '' ? computer.name : String(computer.id || ''),
|
|
3218
|
+
online: computer.online === true,
|
|
3219
|
+
reason: computer.allowed !== true && computer.sessionAllowed === true && computer.presetAllowed === false
|
|
3220
|
+
? t('access.presetDenied')
|
|
3221
|
+
: null,
|
|
3222
|
+
switch: h(SwitchControl, {
|
|
3223
|
+
checked: computer.allowed === true,
|
|
3224
|
+
disabled: busy,
|
|
3225
|
+
label: t('access.badgeAria', { name: computer.name || '' }),
|
|
3226
|
+
onChange: () => { void toggle(computer); },
|
|
3227
|
+
}),
|
|
3228
|
+
})));
|
|
3229
|
+
body = h('div', null,
|
|
3230
|
+
h('div', { className: 'dshc-accessList' }, rows),
|
|
3231
|
+
computers.length === 0 ? h('p', { className: 'dshc-accessEmpty' }, t('access.computersEmpty')) : null,
|
|
3232
|
+
computers.length > 0 && allowedCount === 0
|
|
3233
|
+
? h('p', { className: 'dshc-hint' }, t('access.noneHint'))
|
|
3234
|
+
: null);
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
return h(Dialog, {
|
|
3238
|
+
title: t('access.session.title'),
|
|
3239
|
+
subtitle: t('access.session.subtitle'),
|
|
3240
|
+
size: 'sm',
|
|
3241
|
+
footerClassName: 'dshc-footerSplit',
|
|
3242
|
+
onClose: props.onClose,
|
|
3243
|
+
footer: [
|
|
3244
|
+
h('button', {
|
|
3245
|
+
key: 'manage',
|
|
3246
|
+
type: 'button',
|
|
3247
|
+
className: 'dshc-textBtn',
|
|
3248
|
+
onClick: () => {
|
|
3249
|
+
props.onClose();
|
|
3250
|
+
if (typeof props.onManageComputers === 'function') props.onManageComputers();
|
|
3251
|
+
},
|
|
3252
|
+
}, h(IconGear, { size: 13 }), t('access.manageComputers')),
|
|
3253
|
+
h('button', {
|
|
3254
|
+
key: 'done',
|
|
3255
|
+
type: 'button',
|
|
3256
|
+
className: 'dshc-primary',
|
|
3257
|
+
onClick: props.onClose,
|
|
3258
|
+
}, t('access.done')),
|
|
3259
|
+
],
|
|
3260
|
+
},
|
|
3261
|
+
body,
|
|
3262
|
+
failure !== null ? h('p', { className: 'dshc-formError', role: 'alert' }, failure) : null);
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
/**
|
|
3266
|
+
* Computer access dialog — which agent presets ("Bots") may use this
|
|
3267
|
+
* computer (PROTOCOL access/computer, task-11).
|
|
3268
|
+
* @param {object} props - `computer`, `onClose`, `onChanged(counts)`.
|
|
3269
|
+
* @returns {object} dialog layer.
|
|
3270
|
+
*/
|
|
3271
|
+
function ComputerAccessDialog(props) {
|
|
3272
|
+
const computerId = props.computer && props.computer.id ? String(props.computer.id) : '';
|
|
3273
|
+
const [view, setView] = React.useState({ phase: 'loading', data: null });
|
|
3274
|
+
const [busy, setBusy] = React.useState(false);
|
|
3275
|
+
const [failure, setFailure] = React.useState(null);
|
|
3276
|
+
const alive = React.useRef(true);
|
|
3277
|
+
const scope = React.useRef(null);
|
|
3278
|
+
if (scope.current === null) scope.current = createScope();
|
|
3279
|
+
|
|
3280
|
+
const report = (payload) => {
|
|
3281
|
+
if (typeof props.onChanged !== 'function') return;
|
|
3282
|
+
const list = payload && Array.isArray(payload.presets) ? payload.presets : null;
|
|
3283
|
+
if (list === null) return;
|
|
3284
|
+
props.onChanged({
|
|
3285
|
+
used: list.filter((entry) => entry.allowed === true).length,
|
|
3286
|
+
total: list.length,
|
|
3287
|
+
});
|
|
3288
|
+
};
|
|
3289
|
+
|
|
3290
|
+
React.useEffect(() => {
|
|
3291
|
+
alive.current = true;
|
|
3292
|
+
scope.current.run((signal) => api.readComputerAccess(computerId, signal))
|
|
3293
|
+
.then((payload) => {
|
|
3294
|
+
if (!alive.current) return;
|
|
3295
|
+
if (payload && typeof payload === 'object') {
|
|
3296
|
+
setView({ phase: 'ready', data: payload });
|
|
3297
|
+
report(payload);
|
|
3298
|
+
} else {
|
|
3299
|
+
setView({ phase: 'error', data: null });
|
|
3300
|
+
}
|
|
3301
|
+
})
|
|
3302
|
+
.catch((error) => {
|
|
3303
|
+
if (!alive.current || (error && error.name === 'AbortError')) return;
|
|
3304
|
+
setView({ phase: 'error', data: null, error });
|
|
3305
|
+
});
|
|
3306
|
+
return () => {
|
|
3307
|
+
alive.current = false;
|
|
3308
|
+
scope.current.abortAll();
|
|
3309
|
+
};
|
|
3310
|
+
}, [computerId]);
|
|
3311
|
+
|
|
3312
|
+
const presets = view.data && Array.isArray(view.data.presets) ? view.data.presets : null;
|
|
3313
|
+
|
|
3314
|
+
const toggle = async (preset) => {
|
|
3315
|
+
if (presets === null || busy) return;
|
|
3316
|
+
const previous = view.data;
|
|
3317
|
+
const rawRule = Array.isArray(previous.allowedPresets)
|
|
3318
|
+
? previous.allowedPresets
|
|
3319
|
+
: presets.map((entry) => entry.id);
|
|
3320
|
+
const ruleSet = new Set(rawRule);
|
|
3321
|
+
if (ruleSet.has(preset.id)) ruleSet.delete(preset.id);
|
|
3322
|
+
else ruleSet.add(preset.id);
|
|
3323
|
+
const nextIds = Array.from(ruleSet).sort();
|
|
3324
|
+
/* All presets on posts null ("all, including presets added later"). */
|
|
3325
|
+
const allOn = presets.length > 0 && nextIds.length === presets.length;
|
|
3326
|
+
const optimistic = {
|
|
3327
|
+
...previous,
|
|
3328
|
+
allowedPresets: allOn ? null : nextIds,
|
|
3329
|
+
presets: presets.map((entry) => ({ ...entry, allowed: ruleSet.has(entry.id) })),
|
|
3330
|
+
};
|
|
3331
|
+
setFailure(null);
|
|
3332
|
+
setView({ phase: 'ready', data: optimistic });
|
|
3333
|
+
setBusy(true);
|
|
3334
|
+
try {
|
|
3335
|
+
const result = await scope.current.run((signal) => api.setComputerAccess(computerId, allOn ? null : nextIds, signal));
|
|
3336
|
+
if (!alive.current) return;
|
|
3337
|
+
const next = result && typeof result === 'object' ? result : optimistic;
|
|
3338
|
+
setView({ phase: 'ready', data: next });
|
|
3339
|
+
report(next);
|
|
3340
|
+
} catch (error) {
|
|
3341
|
+
if (!alive.current) return;
|
|
3342
|
+
if (error && error.name === 'AbortError') return;
|
|
3343
|
+
setView({ phase: 'ready', data: previous });
|
|
3344
|
+
setFailure(t('access.saveFailed', { message: messageOf(error) }));
|
|
3345
|
+
} finally {
|
|
3346
|
+
if (alive.current) setBusy(false);
|
|
3347
|
+
}
|
|
3348
|
+
};
|
|
3349
|
+
|
|
3350
|
+
let body;
|
|
3351
|
+
if (presets === null && view.phase === 'loading') {
|
|
3352
|
+
body = h('div', { className: 'dshc-accessRow' }, h(Spinner), h('span', { className: 'dshc-accessHint' }, t('access.loading')));
|
|
3353
|
+
} else if (presets === null) {
|
|
3354
|
+
body = h('div', { className: 'dshc-errorCard', role: 'alert' },
|
|
3355
|
+
h('span', { className: 'dshc-errorText' },
|
|
3356
|
+
t('error.loadDetail', { title: t('access.loadFailed'), message: view.error ? messageOf(view.error) : '' })));
|
|
3357
|
+
} else if (presets.length === 0) {
|
|
3358
|
+
body = h('p', { className: 'dshc-accessEmpty' }, t('access.presetsEmpty'));
|
|
3359
|
+
} else {
|
|
3360
|
+
body = h('div', { className: 'dshc-accessList' }, presets.map((preset) => {
|
|
3361
|
+
const name = preset.id === 'default'
|
|
3362
|
+
? t('access.defaultPreset')
|
|
3363
|
+
: (typeof preset.name === 'string' && preset.name !== '' ? preset.name : String(preset.id || ''));
|
|
3364
|
+
return h(AccessRow, {
|
|
3365
|
+
key: preset.id || preset.name,
|
|
3366
|
+
name,
|
|
3367
|
+
switch: h(SwitchControl, {
|
|
3368
|
+
checked: preset.allowed === true,
|
|
3369
|
+
disabled: busy,
|
|
3370
|
+
label: t('access.badgeAria', { name }),
|
|
3371
|
+
onChange: () => { void toggle(preset); },
|
|
3372
|
+
}),
|
|
3373
|
+
});
|
|
3374
|
+
}));
|
|
3375
|
+
}
|
|
3376
|
+
|
|
3377
|
+
return h(Dialog, {
|
|
3378
|
+
title: (props.computer && (props.computer.name || props.computer.hostname)) || t('access.computer.titleFallback', { id: computerId }),
|
|
3379
|
+
subtitle: t('access.computer.subtitle'),
|
|
3380
|
+
size: 'sm',
|
|
3381
|
+
onClose: props.onClose,
|
|
3382
|
+
footer: [
|
|
3383
|
+
h('button', {
|
|
3384
|
+
key: 'done',
|
|
3385
|
+
type: 'button',
|
|
3386
|
+
className: 'dshc-primary',
|
|
3387
|
+
onClick: props.onClose,
|
|
3388
|
+
}, t('access.done')),
|
|
3389
|
+
],
|
|
3390
|
+
},
|
|
3391
|
+
body,
|
|
3392
|
+
failure !== null ? h('p', { className: 'dshc-formError', role: 'alert' }, failure) : null);
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
// #endregion
|
|
3396
|
+
|
|
2701
3397
|
// #region composer target chip
|
|
2702
3398
|
|
|
2703
3399
|
/**
|
|
@@ -2718,6 +3414,9 @@ window.__ModuleLoader__.load({
|
|
|
2718
3414
|
const [open, setOpen] = React.useState(false);
|
|
2719
3415
|
const [anchor, setAnchor] = React.useState(null);
|
|
2720
3416
|
const [failure, setFailure] = React.useState(null);
|
|
3417
|
+
const [allowedIds, setAllowedIds] = React.useState(null);
|
|
3418
|
+
const [accessOpen, setAccessOpen] = React.useState(false);
|
|
3419
|
+
const [notice, setNotice] = React.useState(null);
|
|
2721
3420
|
const alive = React.useRef(true);
|
|
2722
3421
|
const scope = React.useRef(null);
|
|
2723
3422
|
const chipRef = React.useRef(null);
|
|
@@ -2725,6 +3424,7 @@ window.__ModuleLoader__.load({
|
|
|
2725
3424
|
const pending = React.useRef(false);
|
|
2726
3425
|
const initialFocus = React.useRef(0);
|
|
2727
3426
|
const failureTimer = React.useRef(null);
|
|
3427
|
+
const noticeTimer = React.useRef(null);
|
|
2728
3428
|
if (scope.current === null) scope.current = createScope();
|
|
2729
3429
|
|
|
2730
3430
|
const applySelection = React.useCallback((result) => {
|
|
@@ -2734,6 +3434,23 @@ window.__ModuleLoader__.load({
|
|
|
2734
3434
|
});
|
|
2735
3435
|
}, []);
|
|
2736
3436
|
|
|
3437
|
+
/* Which computers this session may use. Unknown → show everything; the
|
|
3438
|
+
* host still enforces, and a denied computer is never offered. */
|
|
3439
|
+
const loadAccess = React.useCallback(async () => {
|
|
3440
|
+
if (sessionId === '') return;
|
|
3441
|
+
try {
|
|
3442
|
+
const payload = await scope.current.run((signal) => api.readAccess(sessionId, signal));
|
|
3443
|
+
if (!alive.current) return;
|
|
3444
|
+
const list = payload && Array.isArray(payload.computers) ? payload.computers : null;
|
|
3445
|
+
setAllowedIds(list === null ? null : list
|
|
3446
|
+
.filter((entry) => entry.allowed === true)
|
|
3447
|
+
.map((entry) => entry.id));
|
|
3448
|
+
} catch (error) {
|
|
3449
|
+
if (!alive.current) return;
|
|
3450
|
+
setAllowedIds(null);
|
|
3451
|
+
}
|
|
3452
|
+
}, [sessionId]);
|
|
3453
|
+
|
|
2737
3454
|
/* One /state + /selection round per tick; a state failure hides the chip
|
|
2738
3455
|
* (there is no honest target to display). */
|
|
2739
3456
|
const load = React.useCallback(async () => {
|
|
@@ -2772,7 +3489,9 @@ window.__ModuleLoader__.load({
|
|
|
2772
3489
|
setView({ phase: 'hidden', role: null, computers: [] });
|
|
2773
3490
|
return () => { alive.current = false; };
|
|
2774
3491
|
}
|
|
3492
|
+
setAllowedIds(null);
|
|
2775
3493
|
void load();
|
|
3494
|
+
void loadAccess();
|
|
2776
3495
|
const poll = setInterval(() => {
|
|
2777
3496
|
if (alive.current) void load();
|
|
2778
3497
|
}, STATE_POLL_MS);
|
|
@@ -2783,9 +3502,13 @@ window.__ModuleLoader__.load({
|
|
|
2783
3502
|
clearTimeout(failureTimer.current);
|
|
2784
3503
|
failureTimer.current = null;
|
|
2785
3504
|
}
|
|
3505
|
+
if (noticeTimer.current !== null) {
|
|
3506
|
+
clearTimeout(noticeTimer.current);
|
|
3507
|
+
noticeTimer.current = null;
|
|
3508
|
+
}
|
|
2786
3509
|
if (scope.current) scope.current.abortAll();
|
|
2787
3510
|
};
|
|
2788
|
-
}, [load, sessionId]);
|
|
3511
|
+
}, [load, loadAccess, sessionId]);
|
|
2789
3512
|
|
|
2790
3513
|
const computers = view.computers;
|
|
2791
3514
|
const selectedId = selection.computerId;
|
|
@@ -2805,7 +3528,9 @@ window.__ModuleLoader__.load({
|
|
|
2805
3528
|
computer: null,
|
|
2806
3529
|
offline: false,
|
|
2807
3530
|
},
|
|
2808
|
-
].concat(computers
|
|
3531
|
+
].concat(computers
|
|
3532
|
+
.filter((computer) => allowedIds === null || allowedIds.indexOf(computer.id) >= 0)
|
|
3533
|
+
.map((computer) => ({
|
|
2809
3534
|
key: String(computer.id || computer.name || ''),
|
|
2810
3535
|
id: computer.id,
|
|
2811
3536
|
label: typeof computer.name === 'string' && computer.name !== ''
|
|
@@ -2819,7 +3544,7 @@ window.__ModuleLoader__.load({
|
|
|
2819
3544
|
const menuItems = () => {
|
|
2820
3545
|
if (menuRef.current === null || typeof menuRef.current.querySelectorAll !== 'function') return [];
|
|
2821
3546
|
try {
|
|
2822
|
-
return Array.prototype.slice.call(menuRef.current.querySelectorAll('[role="menuitemradio"]'));
|
|
3547
|
+
return Array.prototype.slice.call(menuRef.current.querySelectorAll('[role="menuitemradio"], [role="menuitem"]'));
|
|
2823
3548
|
} catch (error) {
|
|
2824
3549
|
return [];
|
|
2825
3550
|
}
|
|
@@ -2837,12 +3562,22 @@ window.__ModuleLoader__.load({
|
|
|
2837
3562
|
};
|
|
2838
3563
|
|
|
2839
3564
|
const openMenu = (preferLast) => {
|
|
3565
|
+
void loadAccess();
|
|
2840
3566
|
const checked = menuRows.findIndex((row) => (row.id === null ? selectedId === null : row.id === selectedId));
|
|
2841
3567
|
initialFocus.current = preferLast ? menuRows.length - 1 : (checked >= 0 ? checked : 0);
|
|
2842
3568
|
setFailure(null);
|
|
2843
3569
|
setOpen(true);
|
|
2844
3570
|
};
|
|
2845
3571
|
|
|
3572
|
+
const showNotice = (text) => {
|
|
3573
|
+
setNotice(text);
|
|
3574
|
+
if (noticeTimer.current !== null) clearTimeout(noticeTimer.current);
|
|
3575
|
+
noticeTimer.current = setTimeout(() => {
|
|
3576
|
+
noticeTimer.current = null;
|
|
3577
|
+
if (alive.current) setNotice(null);
|
|
3578
|
+
}, 5000);
|
|
3579
|
+
};
|
|
3580
|
+
|
|
2846
3581
|
const closeMenu = (restoreFocus) => {
|
|
2847
3582
|
setOpen(false);
|
|
2848
3583
|
if (!restoreFocus) return;
|
|
@@ -3031,7 +3766,17 @@ window.__ModuleLoader__.load({
|
|
|
3031
3766
|
row.offline ? h('span', { className: 'dshc-menuHint' }, t('target.offline')) : null,
|
|
3032
3767
|
checked ? h(IconCheck, { size: 13, className: 'dshc-menuCheck' }) : null);
|
|
3033
3768
|
}),
|
|
3034
|
-
h('div', { className: 'dshc-
|
|
3769
|
+
h('div', { className: 'dshc-menuSep', role: 'separator' }),
|
|
3770
|
+
h('button', {
|
|
3771
|
+
key: 'manage-access',
|
|
3772
|
+
type: 'button',
|
|
3773
|
+
role: 'menuitem',
|
|
3774
|
+
className: 'dshc-menuItem dshc-menuManage',
|
|
3775
|
+
onClick: () => {
|
|
3776
|
+
setOpen(false);
|
|
3777
|
+
setAccessOpen(true);
|
|
3778
|
+
},
|
|
3779
|
+
}, h(IconGear, { size: 14 }), h('span', { className: 'dshc-menuName' }, t('access.manage')))))
|
|
3035
3780
|
: null;
|
|
3036
3781
|
|
|
3037
3782
|
return h('span', { className: 'dshc-targetWrap' },
|
|
@@ -3039,7 +3784,29 @@ window.__ModuleLoader__.load({
|
|
|
3039
3784
|
failure !== null
|
|
3040
3785
|
? h('span', { className: 'dshc-targetError', role: 'status', title: failure }, failure)
|
|
3041
3786
|
: null,
|
|
3042
|
-
|
|
3787
|
+
notice !== null
|
|
3788
|
+
? h('span', { className: 'dshc-targetError', 'data-kind': 'info', role: 'status', title: notice }, notice)
|
|
3789
|
+
: null,
|
|
3790
|
+
menu,
|
|
3791
|
+
accessOpen
|
|
3792
|
+
? h(SessionAccessDialog, {
|
|
3793
|
+
sessionId,
|
|
3794
|
+
onClose: () => {
|
|
3795
|
+
setAccessOpen(false);
|
|
3796
|
+
try {
|
|
3797
|
+
if (chipRef.current && typeof chipRef.current.focus === 'function') chipRef.current.focus();
|
|
3798
|
+
} catch (error) {
|
|
3799
|
+
/* ignore */
|
|
3800
|
+
}
|
|
3801
|
+
},
|
|
3802
|
+
onSaved: (payload) => {
|
|
3803
|
+
const list = payload && Array.isArray(payload.computers) ? payload.computers : null;
|
|
3804
|
+
if (list === null) return;
|
|
3805
|
+
setAllowedIds(list.filter((entry) => entry.allowed === true).map((entry) => entry.id));
|
|
3806
|
+
},
|
|
3807
|
+
onManageComputers: () => showNotice(t('target.manage')),
|
|
3808
|
+
})
|
|
3809
|
+
: null);
|
|
3043
3810
|
}
|
|
3044
3811
|
|
|
3045
3812
|
// #endregion
|