device-center 0.2.0-beta.1 → 0.2.0-beta.3
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/CONTRIBUTING.md +1 -1
- package/README.md +14 -4
- package/docs/agent-install.md +105 -0
- package/docs/device-profiles.md +56 -0
- package/docs/friend-trial.md +42 -0
- package/docs/hosted-access.md +1 -1
- package/docs/i18n.md +32 -0
- package/docs/npm.md +7 -7
- package/docs/onboarding.md +4 -4
- package/docs/remote-access.md +2 -2
- package/docs/roadmap.md +21 -31
- package/index.html +1 -0
- package/login.html +2 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +7 -2
- package/server/connector.mjs +1 -1
- package/server/drivers/cloudLink.mjs +42 -2
- package/server/drivers/cloudRegistry.mjs +45 -2
- package/server/drivers/deviceProfiles.mjs +47 -0
- package/server/drivers/driverProtocol.mjs +3 -2
- package/server/drivers/inventory.mjs +21 -2
- package/server/index.mjs +20 -3
- package/src/api.js +4 -1
- package/src/device-state.js +20 -0
- package/src/i18n.css +61 -0
- package/src/i18n.js +174 -0
- package/src/install-prompt.js +19 -0
- package/src/login.css +4 -0
- package/src/login.js +14 -11
- package/src/main.js +230 -126
- package/src/messages.js +5880 -0
- package/src/onboarding.js +56 -7
- package/src/start.css +47 -0
- package/src/start.js +39 -0
- package/src/styles.css +23 -1
- package/start.html +46 -0
package/src/main.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { t, h, getLocale, initLanguage, languagePickerMarkup, reportMessage } from './i18n.js';
|
|
1
2
|
import { api } from './api.js';
|
|
2
|
-
import { expireSshSnapshots } from './device-state.js';
|
|
3
|
-
import { OFFICIAL_CONTROL_ORIGIN, connectCommand, logAccessCommands } from './onboarding.js';
|
|
3
|
+
import { expireSshSnapshots, deviceStatus, collectionAvailability, DEVICE_TYPE_LABELS } from './device-state.js';
|
|
4
|
+
import { OFFICIAL_CONTROL_ORIGIN, connectCommand, logAccessCommands, agentInstallPrompt } from './onboarding.js';
|
|
4
5
|
|
|
5
6
|
const root = document.querySelector('#root');
|
|
6
7
|
const state = { loading: true, error: null, overview: null, setup: null, status: null, ssh: null, selectedAliases: [], saving: false, sshLoading: false, sshError: null, checkingSshAlias: null, dialog: null, routeDevice: null, method: 'settings', filter: 'all', query: '', project: 'all', toast: null, checking: false, checkMessage: null };
|
|
@@ -10,10 +11,12 @@ Object.assign(state, { connectionDraft: null, connectionSaving: false, connectio
|
|
|
10
11
|
Object.assign(state, { pairCode: '', pairing: null, pairBusy: false, pairError: null, pairShare: false, requestSsh: false, revokeId: null, cloudRefreshError: null });
|
|
11
12
|
Object.assign(state, { cloudChoice: 'official', customOrigin: '', logManager: '', logTarget: '', logPath: '', logLabel: 'app', remotePeers: [], remoteLoading: false, remoteError: null });
|
|
12
13
|
let cloudRefreshTimer, cloudRefreshBusy = false, cloudRefreshFailures = 0;
|
|
14
|
+
Object.assign(state, { profileDevice: null, profileDraft: null, profileError: null, profileSaving: false, collectingDevice: null, pendingCollections: {} });
|
|
15
|
+
Object.assign(state, { installMode: 'local', installOrigin: '', installBackground: true });
|
|
13
16
|
const RELAY_PROVIDERS = [
|
|
14
|
-
{ id: 'self-hosted', name
|
|
15
|
-
{ id: 'managed', name
|
|
16
|
-
{ id: 'cloudflare', name: 'Cloudflare', description
|
|
17
|
+
{ id: 'self-hosted', get name() { return t("panel.1"); }, get description() { return t("panel.2"); } },
|
|
18
|
+
{ id: 'managed', get name() { return t("panel.3"); }, get description() { return t("panel.4"); } },
|
|
19
|
+
{ id: 'cloudflare', name: 'Cloudflare', get description() { return t("panel.5"); } },
|
|
17
20
|
];
|
|
18
21
|
const ICONS = {
|
|
19
22
|
device: '<rect x="4" y="3" width="16" height="13" rx="2"/><path d="M2 20h20M8 16l-1 4m9-4 1 4"/>',
|
|
@@ -30,25 +33,24 @@ const ICONS = {
|
|
|
30
33
|
};
|
|
31
34
|
const icon = (name, size = 18) => `<svg aria-hidden="true" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] ?? ICONS.device}</svg>`;
|
|
32
35
|
const escapeHtml = (value = '') => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
33
|
-
const statusText = (value) => ({ online: '在线', unknown: '未知', expired: '过期' })[value] ?? '未知';
|
|
34
36
|
function formatAgo(time) {
|
|
35
|
-
if (time == null) return
|
|
37
|
+
if (time == null) return t("panel.6");
|
|
36
38
|
const elapsed = Math.max(0, Date.now() - time);
|
|
37
|
-
if (elapsed < 60_000) return
|
|
38
|
-
|
|
39
|
-
return `${Math.floor(elapsed / 3_600_000)} 小时前`;
|
|
39
|
+
if (elapsed < 60_000) return t("panel.7");
|
|
40
|
+
return new Intl.RelativeTimeFormat(getLocale(), { numeric: 'always' }).format(-Math.floor(elapsed / (elapsed < 3_600_000 ? 60_000 : 3_600_000)), elapsed < 3_600_000 ? 'minute' : 'hour');
|
|
40
41
|
}
|
|
41
42
|
function metricMarkup(device, kind) {
|
|
42
43
|
const memory = kind === 'memory';
|
|
43
44
|
const metric = device[kind];
|
|
44
|
-
const label = memory ?
|
|
45
|
+
const label = memory ? t("panel.8") : t("panel.9");
|
|
45
46
|
const used = memory ? metric.usedMb : metric.usedGb;
|
|
46
47
|
const total = memory ? metric.totalMb : metric.totalGb;
|
|
47
|
-
const
|
|
48
|
+
const number = new Intl.NumberFormat(getLocale(), { minimumFractionDigits: memory ? 1 : 0, maximumFractionDigits: memory ? 1 : 0 });
|
|
49
|
+
const format = n => `${number.format(memory ? n / 1024 : n)} GB`;
|
|
48
50
|
const valid = metric.status === 'ok' && used != null && total != null && total > 0;
|
|
49
51
|
const percent = valid ? Math.min(100, Math.max(0, Math.round(used / total * 100))) : 0;
|
|
50
|
-
const description = device.state === 'expired' ?
|
|
51
|
-
return `<section class="resource" title="${escapeHtml(valid ? description : metric.error ??
|
|
52
|
+
const description = device.state === 'expired' ? t("panel.10") : device.source === 'local-runtime' || device.transport === 'ssh' ? memory ? metric.basis === 'total-minus-available' ? t("panel.11") : t("panel.12") : t("panel.13") : t("panel.14");
|
|
53
|
+
return `<section class="resource" title="${escapeHtml(valid ? description : reportMessage(metric.error) ?? t("panel.15"))}"><div class="resource-top">${icon(kind, 13)}<span>${label}</span><small>${valid ? `${percent}${h("panel.16")}` : metric.status === 'failed' ? t("panel.17") : t("panel.18")}</small></div><div class="resource-value">${valid ? `<span>${format(used)}</span><small>/ ${format(total)}</small>` : '<span>—</span><small>/ —</small>'}</div><progress max="100" value="${percent}" aria-label="${h("a11y.usage", { label })}"></progress><p class="${valid ? 'resource-basis' : 'resource-error'}">${valid ? description : escapeHtml(reportMessage(metric.error) ?? t("panel.15"))}</p></section>`;
|
|
52
54
|
}
|
|
53
55
|
function connectivityState(device) {
|
|
54
56
|
if (state.checkingSshAlias && state.checkingSshAlias === device.ssh?.alias) return 'checking';
|
|
@@ -57,38 +59,90 @@ function connectivityState(device) {
|
|
|
57
59
|
}
|
|
58
60
|
function connectivityMarkup(device) {
|
|
59
61
|
const value = connectivityState(device);
|
|
60
|
-
const label = ({ queued:
|
|
61
|
-
const detail = ['passed', 'stale'].includes(value) ?
|
|
62
|
+
const label = ({ queued: t("panel.20"), checking: t("panel.21"), passed: t("panel.22"), failed: t("panel.23"), stale: t("panel.24"), 'configuration-changed': t("panel.25"), unobserved: t("panel.26") })[value];
|
|
63
|
+
const detail = ['passed', 'stale'].includes(value) ? t("panel.27") : device.ssh?.connectivity?.error ? sshErrorText(device.ssh.connectivity.error) : t("panel.28");
|
|
62
64
|
return `<span class="ssh-connectivity ${escapeHtml(value)}" title="${escapeHtml(detail)}">${escapeHtml(label)}</span>`;
|
|
63
65
|
}
|
|
64
66
|
function deviceCard(device) {
|
|
65
67
|
const local = device.source === 'local-runtime';
|
|
66
|
-
const subtitle = local ? `${device.platform} · ${device.scope === 'host' ?
|
|
68
|
+
const subtitle = local ? `${device.platform === 'Docker 容器' ? 'Docker' : device.platform} · ${device.scope === 'host' ? t("panel.29") : t("panel.30")}` : device.transport === 'ssh' ? device.ssh.checkState === 'verified' ? device.platform.split(' · ')[0] : t("panel.31") : device.platform;
|
|
67
69
|
const route = device.ssh?.route;
|
|
68
|
-
const path = local ?
|
|
69
|
-
const detail = device.transport === 'ssh' ? !device.ssh.configured ?
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
70
|
+
const path = local ? t("panel.32") : device.source === 'driver-report' ? `${device.managementDriver.name} → ${new URL(device.managementDriver.origin).host} ${t("panel.33")}` : route ? `${t("panel.35")} ${route.hops.length ? `${route.hops.map((hop) => hop.alias).join(' → ')} ${t("panel.34")}` : endpointText(route.target)}` : t("panel.36");
|
|
71
|
+
const detail = device.transport === 'ssh' ? !device.ssh.configured ? t("panel.37") : device.ssh.error ? sshErrorText(device.ssh.error) : device.ssh.checkState !== 'verified' ? t("panel.38") : null : local ? null : t("panel.39");
|
|
72
|
+
const status = deviceStatus(device), availability = collectionAvailability(device);
|
|
73
|
+
const collecting = state.collectingDevice === device.id || device.transport === 'ssh' && (state.checkingSshAlias === device.ssh?.alias || device.ssh?.checking || device.ssh?.queued);
|
|
74
|
+
const collectButton = `<button class="button button-light ssh-check-button" data-action="collect-device" data-device="${escapeHtml(device.id)}" aria-label="${h("a11y.collect", { name: device.name })}" title="${escapeHtml(availability.hint)}" ${!availability.available || collecting || state.collectingDevice ? 'disabled' : ''}>${collecting ? t("panel.40") : availability.pending ? t("panel.41") : t("panel.42")}</button>`;
|
|
75
|
+
const profile = device.profile;
|
|
76
|
+
const description = profile && (profile.type !== 'unspecified' || profile.tags.length || profile.purpose) ? `<div class="device-description">${profile.type !== 'unspecified' ? `<span class="device-type">${DEVICE_TYPE_LABELS[profile.type]}</span>` : ''}${profile.tags.slice(0, 3).map(tag => `<span class="device-tag">${escapeHtml(tag)}</span>`).join('')}${profile.purpose ? `<p title="${escapeHtml(profile.purpose)}">${escapeHtml(profile.purpose)}</p>` : ''}</div>` : '';
|
|
77
|
+
const identityHint = device.managementDriver?.fingerprint ? ` · ${device.managementDriver.fingerprint.slice(0, 8)}` : '';
|
|
78
|
+
return `<article class="device-card"><div class="device-identity"><div class="device-heading"><span class="device-icon">${icon('device', 17)}</span><div><h2 title="${escapeHtml(device.name)}">${escapeHtml(device.name)}</h2><p>${escapeHtml(subtitle + identityHint)}</p></div><span class="status-pill ${device.state}" title="${escapeHtml(status.title)}">${status.label}</span></div>${description}<div class="device-path-line"><button class="device-route" data-action="route" data-device="${escapeHtml(device.id)}" aria-label="${h("a11y.route", { name: device.name })}" title="${h("panel.45")}">${escapeHtml(path)}</button>${device.transport === 'ssh' ? connectivityMarkup(device) : ''}</div>${detail ? `<div class="device-detail">${escapeHtml(detail)}</div>` : ''}</div><div class="resource-grid">${metricMarkup(device, 'memory')}${metricMarkup(device, 'disk')}</div><div class="device-actions"><div class="device-footer">${h("panel.46")} <span>${formatAgo(device.lastHeartbeat)}</span></div>${collectButton}${!availability.available && !availability.pending && !collecting ? `<small class="collection-hint">${escapeHtml(availability.hint)}</small>` : ''}<button class="text-button profile-edit" data-action="profile" data-device="${escapeHtml(device.id)}" aria-label="${h("a11y.profile", { name: device.name })}">${h("panel.49")}</button></div></article>`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function openDeviceProfile(deviceId) {
|
|
82
|
+
const device = state.overview?.devices.find(d => d.id === deviceId);
|
|
83
|
+
if (!device) return;
|
|
84
|
+
const profile = device.profile ?? { displayName: '', type: 'unspecified', purpose: '', tags: [], services: [], revision: 0 };
|
|
85
|
+
state.profileDevice = deviceId;
|
|
86
|
+
state.profileDraft = { ...profile, tags: profile.tags.join(','), services: profile.services.join(',') };
|
|
87
|
+
state.profileError = null; state.toast = null; state.dialog = 'profile';
|
|
88
|
+
}
|
|
89
|
+
function profileBody() {
|
|
90
|
+
const device = state.overview?.devices.find(d => d.id === state.profileDevice), draft = state.profileDraft;
|
|
91
|
+
if (!device || !draft) return `<p class="modal-intro">${h("panel.50")}</p>`;
|
|
92
|
+
const disabled = state.profileSaving ? 'disabled' : '';
|
|
93
|
+
return `<p class="modal-intro">${h("panel.56")}</p><form id="device-profile-form"><div class="profile-fields"><label class="field-label" for="profile-name">${h("panel.57")}<input id="profile-name" data-profile-field="displayName" value="${escapeHtml(draft.displayName)}" placeholder="${escapeHtml(device.reportedName ?? device.name)}" maxlength="80" ${disabled}></label><div><label class="field-label" for="profile-type">${h("panel.58")}</label><select id="profile-type" data-profile-field="type" ${disabled}>${Object.entries(DEVICE_TYPE_LABELS).map(([value, label]) => `<option value="${value}" ${draft.type === value ? 'selected' : ''}>${label}</option>`).join('')}</select></div></div><label class="field-label" for="profile-purpose">${h("panel.59")}<textarea id="profile-purpose" data-profile-field="purpose" maxlength="240" rows="2" placeholder="${h("panel.60")}" ${disabled}>${escapeHtml(draft.purpose)}</textarea></label><label class="field-label" for="profile-tags">${h("panel.61")}<input id="profile-tags" data-profile-field="tags" value="${escapeHtml(draft.tags)}" maxlength="200" placeholder="${h("panel.62")}" ${disabled}></label><label class="field-label" for="profile-services">${h("panel.63")}<input id="profile-services" data-profile-field="services" value="${escapeHtml(draft.services)}" maxlength="500" placeholder="${h("panel.64")}" ${disabled}></label><p class="small-note profile-service-note">${h("panel.65")}</p><details class="profile-identity"><summary>${h("panel.66")}</summary><p>${h("panel.67")}${escapeHtml(device.reportedName ?? device.name)}</p><code class="fingerprint">${escapeHtml(device.id)}</code>${device.managementDriver?.fingerprint ? `<p>${h("panel.51")}</p><code class="fingerprint">${escapeHtml(device.managementDriver.fingerprint)}</code>` : ''}<p>${h("panel.68")}</p></details><p class="small-note">${h("panel.69")} ${cloudMode() ? t("panel.52") : t("panel.53")}</p>${state.profileError ? `<p class="form-error" role="alert">${escapeHtml(t(state.profileError))}</p>` : ''}<button type="submit" class="button button-primary save-profile" ${disabled}>${state.profileSaving ? t("panel.54") : t("panel.55")}</button></form>`;
|
|
94
|
+
}
|
|
95
|
+
async function saveDeviceProfile() {
|
|
96
|
+
if (state.profileSaving || !state.profileDraft) return;
|
|
97
|
+
const draft = { ...state.profileDraft }, deviceId = state.profileDevice;
|
|
98
|
+
const split = value => value.split(/[,,\n]/).map(v => v.trim()).filter(Boolean);
|
|
99
|
+
state.profileSaving = true; state.profileError = null; render();
|
|
100
|
+
try {
|
|
101
|
+
await api.saveDeviceProfile({ deviceId, revision: draft.revision, profile: { displayName: draft.displayName, type: draft.type, purpose: draft.purpose, tags: split(draft.tags), services: split(draft.services) } });
|
|
102
|
+
state.overview = await api.overview();
|
|
103
|
+
if (state.dialog === 'profile' && state.profileDevice === deviceId) state.dialog = null;
|
|
104
|
+
toast(t("panel.70"));
|
|
105
|
+
} catch (cause) {
|
|
106
|
+
if (state.dialog === 'profile' && state.profileDevice === deviceId) state.profileError = ({ invalid_device_profile: "panel.71", device_profile_conflict: "panel.72", device_not_found: "panel.73" })[cause.message] ?? "panel.74";
|
|
107
|
+
} finally { state.profileSaving = false; render(); }
|
|
108
|
+
}
|
|
109
|
+
function updateOverview(overview) {
|
|
110
|
+
state.overview = overview;
|
|
111
|
+
for (const [id, requestedAt] of Object.entries(state.pendingCollections)) {
|
|
112
|
+
const device = overview.devices.find(d => d.id === id), result = device?.collection;
|
|
113
|
+
if (result?.requestedAt === requestedAt && result.status === 'pending') continue;
|
|
114
|
+
delete state.pendingCollections[id];
|
|
115
|
+
toast(result?.requestedAt === requestedAt && result.status === 'completed' ? `${device.name} ${t("panel.75")}` : t("panel.76"));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function collectDevice(deviceId) {
|
|
119
|
+
const device = state.overview?.devices.find(d => d.id === deviceId);
|
|
120
|
+
if (!device || state.collectingDevice || !collectionAvailability(device).available) return;
|
|
121
|
+
state.collectingDevice = deviceId; state.toast = null; render();
|
|
122
|
+
try {
|
|
123
|
+
const result = await api.collectDevice(deviceId);
|
|
124
|
+
if (result.status === 'pending') state.pendingCollections[deviceId] = result.collection.requestedAt;
|
|
125
|
+
updateOverview(await api.overview());
|
|
126
|
+
if (result.status !== 'pending') toast(result.status === 'failed' ? sshErrorText(result.error) : `${device.name} ${t("panel.77")}`);
|
|
127
|
+
} catch (cause) {
|
|
128
|
+
toast(({ driver_not_connected: t("panel.78"), driver_upgrade_required: t("panel.79"), collection_rate_limited: t("panel.80"), collection_requires_local_ssh_check: t("panel.81"), collection_unavailable: t("panel.82") })[cause.message] ?? t("panel.83"));
|
|
129
|
+
} finally { state.collectingDevice = null; render(); }
|
|
76
130
|
}
|
|
77
131
|
function endpointText(endpoint) {
|
|
78
132
|
return `${endpoint.host.includes(':') ? `[${endpoint.host}]` : endpoint.host}:${endpoint.port}`;
|
|
79
133
|
}
|
|
80
134
|
function currentRouteBody() {
|
|
81
135
|
const device = state.overview?.devices.find((item) => item.id === state.routeDevice);
|
|
82
|
-
if (!device) return
|
|
136
|
+
if (!device) return `<p class="modal-intro">${h("panel.84")}</p>`;
|
|
83
137
|
const local = device.source === 'local-runtime';
|
|
84
|
-
if (local) return `<p class="modal-intro">${
|
|
85
|
-
if (device.source === 'driver-report') return `<p class="modal-intro"><strong>${escapeHtml(device.name)}</strong>
|
|
138
|
+
if (local) return `<p class="modal-intro">${h("route.local", { name: device.name, scope: t(device.scope === 'host' ? "panel.85" : "panel.86") })}</p>`;
|
|
139
|
+
if (device.source === 'driver-report') return `<p class="modal-intro"><strong>${escapeHtml(device.name)}</strong> ${h("panel.91")}</p><ol class="route-path"><li><span class="route-node-icon">${icon('device', 16)}</span><div><strong>${escapeHtml(device.managementDriver.name)}</strong><small>${device.kind === 'management' ? t("panel.89") : t("panel.90")}</small></div></li><li><span class="route-node-icon">${icon('cloud', 16)}</span><div><strong>${escapeHtml(new URL(device.managementDriver.origin).host)}</strong><small>${h("panel.92")}</small></div></li></ol><p class="small-note">${h("panel.93")}${formatAgo(device.managementDriver.lastSeen)}${h("panel.94")}</p><details class="route-auth"><summary>${h("panel.95")}</summary><p>${h("panel.96")}</p><code class="fingerprint">${escapeHtml(device.managementDriver.fingerprint)}</code><p>${h("panel.97")}</p></details>`;
|
|
86
140
|
const route = device.ssh?.route;
|
|
87
|
-
if (!route) return
|
|
88
|
-
const scope = (endpoint) => ({ loopback:
|
|
141
|
+
if (!route) return `<p class="modal-intro">${h("panel.98")}</p>`;
|
|
142
|
+
const scope = (endpoint) => ({ loopback: t("panel.99"), private: t("panel.100"), 'link-local': t("panel.101"), 'other-ip': t("panel.102"), hostname: t("panel.103") })[endpoint.addressScope];
|
|
89
143
|
const forwarded = route.hops.length && route.target.addressScope === 'loopback';
|
|
90
|
-
const node = (endpoint, target = false) => `<li><span class="route-node-icon">${icon(target ? 'device' : 'cloud', 16)}</span><div><strong>${target && forwarded ?
|
|
91
|
-
return `<p class="modal-intro"><strong>${escapeHtml(device.name)}</strong>
|
|
144
|
+
const node = (endpoint, target = false) => `<li><span class="route-node-icon">${icon(target ? 'device' : 'cloud', 16)}</span><div><strong>${target && forwarded ? t("panel.104") : escapeHtml(endpoint.alias)}</strong><code>${escapeHtml(endpointText(endpoint))}</code><small>${escapeHtml(scope(endpoint))}${target && forwarded ? t("panel.105") : ''}</small></div></li>`;
|
|
145
|
+
return `<p class="modal-intro"><strong>${escapeHtml(device.name)}</strong> ${h("panel.119")}${route.mode === 'jump' ? t("panel.106") : t("panel.107")}${h("panel.120")}</p><ol class="route-path" aria-label="${h("panel.121")}"><li><span class="route-node-icon">${icon('device', 16)}</span><div><strong>${h("panel.29")}</strong><small>${h("panel.122")}</small></div></li>${route.hops.map((hop) => node(hop)).join('')}${node(route.target, true)}${forwarded ? `<li class="route-unverified"><span class="route-node-icon">${icon('device', 16)}</span><div><strong>${escapeHtml(device.name)}</strong><small>${h("panel.108")}</small></div></li>` : ''}</ol><p class="small-note">${device.ssh.lastVerifiedRoute ? `${h("panel.111")}${formatAgo(device.ssh.lastVerifiedRoute.checkedAt)}${h("panel.112")}${device.state === 'expired' ? t("panel.109") : t("panel.110")}` : t("panel.113")}</p><details class="route-auth"><summary>${h("panel.123")}</summary><p>${h("panel.124")}${route.hops.length ? t("panel.114") : t("panel.115")}${h("panel.125")}</p>${route.hops.length ? `<p>${h("panel.116")}</p>` : ''}<p>${forwarded ? t("panel.117") : t("panel.118")}</p></details><details class="route-policy"><summary>${h("panel.126")}</summary><p>${h("panel.127")}</p><p>${h("panel.128")}</p><p>${h("panel.129")}</p></details><p class="small-note">${h("panel.130")}</p>`;
|
|
92
146
|
}
|
|
93
147
|
function routeBody() {
|
|
94
148
|
const device = state.overview?.devices.find((item) => item.id === state.routeDevice);
|
|
@@ -97,20 +151,20 @@ function routeBody() {
|
|
|
97
151
|
const plan = device.connections;
|
|
98
152
|
const provider = RELAY_PROVIDERS.find((item) => item.id === draft.relayProvider);
|
|
99
153
|
const disabled = state.connectionSaving ? 'disabled' : '';
|
|
100
|
-
return `<p class="modal-intro"><strong>${escapeHtml(device.name)}</strong>
|
|
101
|
-
<section class="connection-mode"><header><h3>${icon('device', 16)}
|
|
102
|
-
<div class="lan-fields"><label
|
|
154
|
+
return `<p class="modal-intro"><strong>${escapeHtml(device.name)}</strong> ${h("panel.142")}</p>
|
|
155
|
+
<section class="connection-mode"><header><h3>${icon('device', 16)} ${h("panel.143")}</h3><span class="mode-status">${draft.lanHost !== plan.lan.host || draft.lanPort !== plan.lan.port ? t("panel.131") : plan.lan.host ? t("panel.132") : t("panel.133")}</span></header><p>${h("panel.144")}</p>
|
|
156
|
+
<div class="lan-fields"><label>${h("panel.145")}<input id="lan-host" data-field="lanHost" aria-label="${h("panel.146")}" value="${escapeHtml(draft.lanHost)}" placeholder="${h("panel.147")}" maxlength="253" autocomplete="off" ${disabled}></label><label>${h("panel.148")}<input id="lan-port" data-field="lanPort" type="number" min="1" max="65535" aria-label="${h("panel.149")}" value="${draft.lanPort}" ${disabled}></label></div>
|
|
103
157
|
</section>
|
|
104
|
-
<section class="connection-mode"><header><h3>${icon('cloud', 16)}
|
|
105
|
-
<div class="relay-options" role="radiogroup" aria-label="
|
|
106
|
-
<p class="provider-description">${provider ? escapeHtml(provider.description) :
|
|
107
|
-
${draft.relayProvider ? `<button class="text-button clear-relay" data-action="clear-relay" ${disabled}
|
|
158
|
+
<section class="connection-mode"><header><h3>${icon('cloud', 16)} ${h("panel.150")}</h3><span class="mode-status">${draft.relayProvider !== plan.relay.provider ? t("panel.134") : plan.relay.provider ? t("panel.135") : t("panel.136")}</span></header><p>${h("panel.151")}</p>
|
|
159
|
+
<div class="relay-options" role="radiogroup" aria-label="${h("panel.152")}">${RELAY_PROVIDERS.map((item) => `<label class="relay-choice"><input type="radio" name="relay-provider" data-field="relayProvider" data-provider="${item.id}" value="${item.id}" ${draft.relayProvider === item.id ? 'checked' : ''} ${disabled}>${item.name}</label>`).join('')}</div>
|
|
160
|
+
<p class="provider-description">${provider ? escapeHtml(provider.description) : t("panel.137")}</p>${draft.relayProvider === 'cloudflare' ? `<a class="provider-link" href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/use-cases/ssh/ssh-cloudflared-authentication/" target="_blank" rel="noopener noreferrer">${h("panel.138")}</a>` : ''}
|
|
161
|
+
${draft.relayProvider ? `<button class="text-button clear-relay" data-action="clear-relay" ${disabled}>${h("panel.139")}</button>` : ''}
|
|
108
162
|
</section>
|
|
109
|
-
<label class="connection-preference"
|
|
110
|
-
<p class="connection-note"
|
|
111
|
-
${state.connectionError ? `<p class="form-error" role="alert">${escapeHtml(state.connectionError)}</p>` : ''}<button class="button button-primary save-connections" data-action="save-connections" ${disabled}>${state.connectionSaving ?
|
|
112
|
-
${plan.saved ? `<p class="saved-plan-note"
|
|
113
|
-
<details class="current-route"><summary
|
|
163
|
+
<label class="connection-preference">${h("panel.153")}<select id="connection-preference" data-field="preference" aria-label="${h("panel.153")}" ${disabled}><option value="lan-first" ${draft.preference === 'lan-first' ? 'selected' : ''}>${h("panel.154")}</option><option value="relay-only" ${draft.preference === 'relay-only' ? 'selected' : ''}>${h("panel.155")}</option></select></label>
|
|
164
|
+
<p class="connection-note">${h("panel.156")}</p>
|
|
165
|
+
${state.connectionError ? `<p class="form-error" role="alert">${escapeHtml(t(state.connectionError))}</p>` : ''}<button class="button button-primary save-connections" data-action="save-connections" ${disabled}>${state.connectionSaving ? t("panel.54") : t("panel.140")}</button>
|
|
166
|
+
${plan.saved ? `<p class="saved-plan-note">${h("panel.141")} ${formatAgo(plan.updatedAt)}</p>` : ''}
|
|
167
|
+
<details class="current-route"><summary>${h("panel.157")}</summary>${currentRouteBody()}</details>`;
|
|
114
168
|
}
|
|
115
169
|
function openConnections(deviceId) {
|
|
116
170
|
const device = state.overview?.devices.find((item) => item.id === deviceId);
|
|
@@ -124,21 +178,21 @@ async function saveConnections() {
|
|
|
124
178
|
try {
|
|
125
179
|
await api.saveConnections({ ...state.connectionDraft });
|
|
126
180
|
state.overview = await api.overview();
|
|
127
|
-
toast(
|
|
181
|
+
toast(t("panel.158"));
|
|
128
182
|
} catch (cause) {
|
|
129
183
|
focusField = cause.message === 'invalid_lan_host' ? 'lan-host' : cause.message === 'invalid_lan_port' ? 'lan-port' : null;
|
|
130
|
-
state.connectionError = ({ invalid_lan_host:
|
|
184
|
+
state.connectionError = ({ invalid_lan_host: "panel.159", invalid_lan_port: "panel.160", invalid_relay_provider: "panel.161", ssh_alias_unavailable: "panel.162" })[cause.message] ?? "panel.163";
|
|
131
185
|
} finally { state.connectionSaving = false; render(); (focusField ? root.querySelector(`#${focusField}`) : root.querySelector('[data-action="save-connections"]'))?.focus({ preventScroll: true }); }
|
|
132
186
|
}
|
|
133
187
|
function sshErrorText(code) {
|
|
134
|
-
return ({ ssh_timeout:
|
|
188
|
+
return ({ ssh_timeout: t("panel.164"), ssh_host_key_unverified: t("panel.165"), ssh_authentication_failed: t("panel.166"), ssh_connection_unavailable: t("panel.167"), ssh_name_unresolved: t("panel.168"), ssh_profile_needs_review: t("panel.169"), ssh_profile_incomplete: t("panel.170"), ssh_configuration_changed: t("panel.171"), ssh_check_in_progress: t("panel.172"), ssh_observation_unsupported: t("panel.173"), ssh_observation_invalid: t("panel.174"), ssh_observation_command_failed: t("panel.175") })[code] ?? t("panel.176");
|
|
135
189
|
}
|
|
136
190
|
function scheduleCheckRefresh() {
|
|
137
191
|
clearTimeout(checkRefreshTimer);
|
|
138
192
|
if (checkRefreshFailures >= 3 || !(state.overview?.devices ?? []).some((device) => device.ssh?.checking || device.ssh?.queued)) return;
|
|
139
193
|
checkRefreshTimer = setTimeout(async () => {
|
|
140
194
|
try { state.overview = await api.overview(); checkRefreshFailures = 0; }
|
|
141
|
-
catch { if (++checkRefreshFailures >= 3) toast(
|
|
195
|
+
catch { if (++checkRefreshFailures >= 3) toast(t("panel.177")); }
|
|
142
196
|
render();
|
|
143
197
|
}, 1000);
|
|
144
198
|
}
|
|
@@ -148,14 +202,14 @@ async function checkSshDevice(alias) {
|
|
|
148
202
|
try {
|
|
149
203
|
const result = await api.checkSsh(alias);
|
|
150
204
|
state.overview = await api.overview();
|
|
151
|
-
toast(result.ok ?
|
|
205
|
+
toast(result.ok ? t("panel.178") : sshErrorText(result.error));
|
|
152
206
|
} catch (cause) { toast(sshErrorText(cause.message)); }
|
|
153
207
|
finally { state.checkingSshAlias = null; render(); }
|
|
154
208
|
}
|
|
155
209
|
function clientLabel() {
|
|
156
|
-
if (!state.status) return
|
|
157
|
-
if (state.status.client.state === 'observed' && !recentMcpInitialize()) return
|
|
158
|
-
return ({ waiting:
|
|
210
|
+
if (!state.status) return t("panel.179");
|
|
211
|
+
if (state.status.client.state === 'observed' && !recentMcpInitialize()) return t("panel.180");
|
|
212
|
+
return ({ waiting: t("panel.181"), observed: t("panel.182"), stale: t("panel.180") })[state.status.client.state];
|
|
159
213
|
}
|
|
160
214
|
function recentMcpInitialize() {
|
|
161
215
|
const time = state.status?.client.lastInitializeAt;
|
|
@@ -163,30 +217,56 @@ function recentMcpInitialize() {
|
|
|
163
217
|
}
|
|
164
218
|
function renderSidebar() {
|
|
165
219
|
const projects = state.overview?.projects ?? [];
|
|
166
|
-
if (cloudMode()) return `<aside class="sidebar"><a class="brand" href="
|
|
167
|
-
return `<aside class="sidebar"><a class="brand" href="
|
|
220
|
+
if (cloudMode()) return `<aside class="sidebar"><a class="brand" href="/?lang=${getLocale()}" aria-label="${h("panel.183")}"><span class="brand-mark">${icon('cloud')}</span><span><strong>Device Center</strong><small>${h("panel.184")}</small></span></a><div class="nav-caption">${h("panel.185")}</div><nav aria-label="${h("panel.186")}"><button class="nav-item active" data-action="project" data-project="all">${icon('device')}<span>${h("panel.187")}</span><small>${state.overview?.devices.length ?? 0}</small></button><button class="nav-item" data-action="secure-help">${icon('link')}<span>${h("panel.188")}</span></button></nav><div class="nav-caption">${h("panel.189")}</div><nav class="project-navigation" aria-label="${h("panel.190")}"><p class="nav-empty">${h("panel.191")}</p></nav><div class="sidebar-footer"><span class="local-dot"></span> ${h("panel.192")}<button class="text-button" data-action="logout">${h("panel.193")}</button></div></aside>`;
|
|
221
|
+
return `<aside class="sidebar"><a class="brand" href="/?lang=${getLocale()}" aria-label="${h("panel.183")}"><span class="brand-mark">${icon('cloud')}</span><span><strong>Device Center</strong><small>${h("panel.194")}</small></span></a><div class="nav-caption">${h("panel.185")}</div><nav aria-label="${h("panel.186")}"><button class="nav-item ${state.project === 'all' ? 'active' : ''}" data-action="project" data-project="all">${icon('device')}<span>${h("panel.187")}</span><small>${state.overview?.devices.length ?? 0}</small></button><button class="nav-item" data-action="connect">${icon('link')}<span>${h("panel.195")}</span></button><button class="nav-item" data-action="secure-help">${icon('link')}<span>${h("panel.188")}</span></button><button class="nav-item" data-action="cloud-connect">${icon('cloud')}<span>${h("panel.196")}</span></button></nav><div class="nav-caption">${h("panel.189")}</div><nav class="project-navigation" aria-label="${h("panel.190")}">${projects.length ? projects.map((p) => `<button class="nav-item ${state.project === p.id ? 'active' : ''}" data-action="project" data-project="${escapeHtml(p.id)}">${icon('folder')}<span>${escapeHtml(p.name)}</span></button>`).join('') : `<p class="nav-empty">${h("panel.191")}</p>`}</nav><div class="sidebar-footer"><span class="local-dot"></span> ${h("panel.197")}<button class="text-button" data-action="resident">${h("panel.198")}</button></div></aside>`;
|
|
168
222
|
}
|
|
169
223
|
function renderConnection() {
|
|
170
224
|
if (state.loading && !state.overview) return '';
|
|
171
|
-
if (cloudMode()) { const count = (state.overview?.drivers ?? []).filter(item => item.revokedAt == null).length; return `<section class="driver-summary" aria-label="
|
|
225
|
+
if (cloudMode()) { const count = (state.overview?.drivers ?? []).filter(item => item.revokedAt == null).length; return `<section class="driver-summary" aria-label="${h("panel.204")}"><span class="local-dot"></span><strong>${count ? h("count.bound", { count }) : t("panel.201")}</strong><p>${state.cloudRefreshError ? t(state.cloudRefreshError) : (count ? t("panel.202") : t("panel.203"))}</p></section>`; }
|
|
172
226
|
const container = state.status?.service.runtime === 'container';
|
|
173
|
-
return `<section class="driver-summary" aria-label="
|
|
227
|
+
return `<section class="driver-summary" aria-label="${h("panel.29")}"><span class="local-dot"></span><strong>${container ? t("panel.205") : t("panel.206")}</strong><p>${container ? t("panel.207") : t("panel.208")}</p><button class="text-button initialize-entry" data-action="start">${h("panel.209")}</button></section>`;
|
|
174
228
|
}
|
|
175
229
|
function managementComputer() { return state.overview?.devices.find((item) => item.source === 'local-runtime'); }
|
|
230
|
+
function openAgentInstall() {
|
|
231
|
+
const origin = cloudMode() ? state.overview.controlOrigin : null;
|
|
232
|
+
state.installMode = origin ? origin === OFFICIAL_CONTROL_ORIGIN ? 'official' : 'self-hosted' : 'local';
|
|
233
|
+
state.installOrigin = origin && origin !== OFFICIAL_CONTROL_ORIGIN ? origin : '';
|
|
234
|
+
state.toast = null; state.dialog = 'agent-install';
|
|
235
|
+
}
|
|
236
|
+
function installDraft() {
|
|
237
|
+
try { return { text: agentInstallPrompt({ locale: getLocale(), mode: state.installMode, background: state.installBackground, ...(state.installMode === 'self-hosted' ? { origin: state.installOrigin.trim() } : {}) }), error: null }; }
|
|
238
|
+
catch (error) { return { text: '', error: t(error.translationKey ?? 'error.install') }; }
|
|
239
|
+
}
|
|
240
|
+
function agentInstallBody() {
|
|
241
|
+
const draft = installDraft();
|
|
242
|
+
return `<p class="modal-intro">${h("panel.219")}</p><div class="method-tabs install-mode-tabs" role="group" aria-label="${h("panel.220")}">${[['local', t("panel.210")], ['official', t("panel.211")], ['self-hosted', t("panel.212")]].map(([mode, label]) => `<button data-action="install-mode" data-mode="${mode}" aria-pressed="${state.installMode === mode}" class="${state.installMode === mode ? 'active' : ''}">${label}</button>`).join('')}</div><p class="small-note">${state.installMode === 'local' ? t("panel.213") : state.installMode === 'official' ? t("panel.214") : t("panel.215")}</p>${state.installMode === 'self-hosted' ? `<label class="field-label" for="install-origin">${h("panel.216")}</label><input id="install-origin" data-field="installOrigin" type="url" value="${escapeHtml(state.installOrigin)}" placeholder="https://devices.example.com" autocomplete="off" spellcheck="false">` : ''}<label class="binding-scope"><input type="checkbox" data-field="installBackground" ${state.installBackground ? 'checked' : ''}>${h("panel.221")}</label><ol class="install-flow"><li>${h("panel.222")}</li><li>${state.installMode === 'local' ? t("panel.217") : t("panel.218")}</li><li>${h("panel.223")}</li></ol><p id="install-error" class="form-error" role="alert" ${draft.error ? '' : 'hidden'}>${escapeHtml(draft.error ?? '')}</p><button class="button button-primary install-copy" data-action="agent-install-copy" ${draft.error ? 'disabled' : ''}>${icon('copy', 15)} ${h("panel.224")}</button><details class="install-details"><summary>${h("panel.225")}</summary><textarea id="agent-install-text" aria-label="${h("panel.226")}" readonly rows="7" spellcheck="false">${escapeHtml(draft.text)}</textarea></details><p class="small-note">${h("panel.227")}</p>`;
|
|
243
|
+
}
|
|
244
|
+
async function copyAgentInstall() {
|
|
245
|
+
const field = root.querySelector('#agent-install-text');
|
|
246
|
+
if (!field?.value) return;
|
|
247
|
+
let copied = false;
|
|
248
|
+
try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(field.value); copied = true; } } catch { /* manual selection remains available */ }
|
|
249
|
+
if (!copied) {
|
|
250
|
+
root.querySelector('.install-details').open = true; field.focus(); field.select();
|
|
251
|
+
try { copied = document.execCommand('copy'); } catch { /* manual selection remains available */ }
|
|
252
|
+
}
|
|
253
|
+
toast(copied ? t("panel.228") : t("panel.229"));
|
|
254
|
+
if (!copied) { root.querySelector('.install-details').open = true; root.querySelector('#agent-install-text')?.focus(); root.querySelector('#agent-install-text')?.select(); }
|
|
255
|
+
}
|
|
176
256
|
function cloudMode() { return state.overview?.mode === 'cloud' || state.status?.service.runtime === 'cloud'; }
|
|
177
257
|
function managementReady() { return state.status?.service.runtime === 'native' && managementComputer()?.scope === 'host'; }
|
|
178
258
|
function managementBody() {
|
|
179
259
|
const device = managementComputer();
|
|
180
|
-
if (!state.status || !device) return
|
|
260
|
+
if (!state.status || !device) return `<div class="plain-panel"><h3>${h("panel.230")}</h3><p>${h("panel.231")}</p><button class="button button-light" data-action="refresh">${h("panel.232")}</button></div>`;
|
|
181
261
|
const ready = managementReady();
|
|
182
|
-
return `<p class="modal-intro"
|
|
262
|
+
return `<p class="modal-intro">${h("panel.240")}</p><div class="management-card"><span>${icon('device', 22)}</span><div><strong>${ready ? escapeHtml(device.name) : t("panel.233")}</strong><p>${ready ? `${escapeHtml(device.platform)} ${h("panel.234")}` : t("panel.235")}</p></div><small>${ready ? t("panel.236") : t("panel.237")}</small></div><p class="small-note">${ready ? t("panel.238") : t("panel.239")}</p><details class="hosted-onboarding"><summary>${h("panel.241")}</summary><p>${h("panel.242")}</p><ol class="simple-steps"><li>${h("panel.243")}</li><li>${h("panel.244")}</li><li>${h("panel.245")}</li></ol><p>${h("panel.246")}</p><p>${h("panel.247")}</p><p class="small-note">${h("panel.248")}</p></details>`;
|
|
183
263
|
}
|
|
184
264
|
function onboardingBody() {
|
|
185
265
|
const ready = managementReady();
|
|
186
266
|
const seen = recentMcpInitialize();
|
|
187
|
-
const steps = [
|
|
267
|
+
const steps = [t("panel.249"), t("panel.250"), t("panel.251")];
|
|
188
268
|
const body = state.setupStep === 0 ? managementBody() : state.setupStep === 1 ? connectionBody() : initializationBody();
|
|
189
|
-
return `<nav class="onboarding-steps" aria-label="
|
|
269
|
+
return `<nav class="onboarding-steps" aria-label="${h("panel.256")}">${steps.map((label, index) => `<button data-action="setup-step" data-step="${index}" ${index > 0 && !ready ? 'disabled' : ''} ${index === state.setupStep ? 'aria-current="step"' : ''}><span>${index === 0 && ready || index === 1 && seen ? icon('check', 12) : index + 1}</span>${label}</button>`).join('')}</nav>${body}<footer class="onboarding-footer">${state.setupStep > 0 ? `<button class="button button-light" data-action="setup-step" data-step="0">${h("panel.249")}</button>` : ''}${state.setupStep === 0 ? `<button class="button button-primary" data-action="setup-step" data-step="1" ${!ready ? 'disabled' : ''}>${h("panel.252")}</button>` : state.setupStep === 1 ? `<button class="button ${seen ? 'button-primary' : 'button-light'}" data-action="setup-step" data-step="2">${seen ? t("panel.253") : t("panel.254")}</button>` : `<button class="button button-primary" data-action="setup-finish">${h("panel.255")}</button>`}</footer>`;
|
|
190
270
|
}
|
|
191
271
|
function openSetup(entry = 'start', step) {
|
|
192
272
|
state.setupReturn = entry; state.toast = null; state.checkMessage = null;
|
|
@@ -201,12 +281,12 @@ function selectSetupStep(step) {
|
|
|
201
281
|
}
|
|
202
282
|
function connectionBody() {
|
|
203
283
|
const terminal = state.method === 'terminal';
|
|
204
|
-
return `<p class="modal-intro"
|
|
284
|
+
return `<p class="modal-intro">${h("panel.273")}</p><div class="method-tabs" role="group" aria-label="${h("panel.274")}"><button data-action="method" data-method="settings" aria-pressed="${!terminal}" class="${!terminal ? 'active' : ''}">${h("panel.275")}</button><button data-action="method" data-method="terminal" aria-pressed="${terminal}" class="${terminal ? 'active' : ''}">${h("panel.276")}</button></div>${terminal ? `<ol class="simple-steps"><li>${h("panel.257")}</li><li>${h("panel.258")}</li></ol><label class="field-label" for="copy-field">${h("panel.259")}</label><textarea id="copy-field" readonly rows="3" spellcheck="false">${escapeHtml(state.setup?.setupCommand ?? '')}</textarea><button class="button button-primary" data-action="copy" data-copy="command">${icon('copy', 15)} ${h("panel.260")}</button>` : `<ol class="simple-steps"><li>${h("panel.261")} <strong>${h("panel.262")}</strong>.</li><li>${h("panel.263")} <code>device-center-connector</code>${h("panel.264")} <strong>Streamable HTTP</strong>${h("panel.265")}</li><li>${h("panel.266")}</li></ol><label class="field-label" for="copy-field">${h("panel.267")}</label><div class="copy-row"><input id="copy-field" value="${escapeHtml(state.setup?.url ?? '')}" readonly aria-label="${h("panel.267")}"><button class="button button-primary" data-action="copy" data-copy="url">${icon('copy', 15)} ${h("panel.268")}</button></div>`}<div class="connection-check"><div><strong>${clientLabel()}</strong><p>${state.checkMessage ? t(state.checkMessage) : (state.status?.client.lastInitializeAt ? `${formatAgo(state.status.client.lastInitializeAt)}${h("panel.269")}` : t("panel.270"))}</p></div><button class="button button-light" data-action="check" ${state.checking ? 'disabled' : ''}>${state.checking ? t("panel.271") : t("panel.272")}</button></div><p class="small-note">${h("panel.277")}</p><details class="connector-help"><summary>${h("panel.278")}</summary><p>${h("panel.279")} <code>device_center_list_resources</code>${h("panel.280")}</p><p>${h("panel.281")}</p></details>`;
|
|
205
285
|
}
|
|
206
286
|
function addBody() {
|
|
207
287
|
if (cloudMode()) return cloudBindingBody();
|
|
208
288
|
const candidates = state.ssh?.candidates ?? [];
|
|
209
|
-
return `<p class="modal-intro"
|
|
289
|
+
return `<p class="modal-intro">${h("panel.294")}</p><div class="ssh-toolbar"><strong>${h("count.candidates", { count: candidates.length })}</strong><button class="button button-light" data-action="ssh-refresh" ${state.sshLoading || state.saving ? 'disabled' : ''}>${state.sshLoading ? t("panel.282") : t("panel.283")}</button></div>${state.sshError ? `<p class="form-error" role="alert">${escapeHtml(t(state.sshError))}</p>` : ''}${state.sshLoading ? `<p class="small-note">${h("panel.284")}</p>` : candidates.length ? `<div class="ssh-candidates" role="group" aria-label="${h("panel.290")}">${candidates.map((candidate, index) => `<label class="ssh-candidate"><input id="ssh-choice-${index}" type="checkbox" data-field="ssh-alias" data-alias="${escapeHtml(candidate.alias)}" ${state.selectedAliases.includes(candidate.alias) ? 'checked' : ''} ${candidate.associated || state.saving ? 'disabled' : ''}><span><strong>${escapeHtml(candidate.alias)}</strong><small>${escapeHtml(candidate.source)}</small></span><small>${candidate.associated ? t("panel.285") : t("panel.286")}</small></label>`).join('')}</div><button class="button button-primary associate-button" data-action="associate" ${!state.selectedAliases.length || state.saving ? 'disabled' : ''}>${state.saving ? t("panel.287") : h("count.associate", { count: state.selectedAliases.length })}</button>` : `<div class="plain-panel"><h3>${h("panel.291")}</h3><p>${h("panel.292")}</p></div>`}<p class="small-note">${h("panel.297")}</p>${state.ssh?.notices?.length ? `<details><summary>${h("panel.293")}</summary>${state.ssh.notices.map((notice) => `<p>${escapeHtml(reportMessage(notice))}</p>`).join('')}</details>` : ''}<details><summary>${h("panel.298")}</summary><p>${h("panel.299")}</p><p>${h("panel.300")}</p></details>`;
|
|
210
290
|
}
|
|
211
291
|
function driverCommand() {
|
|
212
292
|
return connectCommand(state.overview?.controlOrigin ?? window.location.origin, state.requestSsh);
|
|
@@ -215,46 +295,46 @@ function cloudConnectDraft() {
|
|
|
215
295
|
const official = state.cloudChoice === 'official';
|
|
216
296
|
let command = '', error = null;
|
|
217
297
|
try { command = connectCommand(official ? OFFICIAL_CONTROL_ORIGIN : state.customOrigin.trim()); }
|
|
218
|
-
catch (cause) { if (state.customOrigin.trim()) error = cause.
|
|
298
|
+
catch (cause) { if (state.customOrigin.trim()) error = t(cause.translationKey ?? 'error.origin'); }
|
|
219
299
|
return { command, error };
|
|
220
300
|
}
|
|
221
301
|
function cloudConnectBody() {
|
|
222
302
|
const official = state.cloudChoice === 'official';
|
|
223
303
|
const { command, error } = cloudConnectDraft();
|
|
224
|
-
return `<p class="modal-intro"
|
|
304
|
+
return `<p class="modal-intro">${h("panel.302")}</p><div class="method-tabs" role="group" aria-label="${h("panel.303")}"><button data-action="cloud-choice" data-provider="official" aria-pressed="${official}" class="${official ? 'active' : ''}">${h("panel.304")}</button><button data-action="cloud-choice" data-provider="self-hosted" aria-pressed="${!official}" class="${!official ? 'active' : ''}">${h("panel.212")}</button></div>${official ? `<p class="small-note">${h("panel.301")}</p>` : `<label class="field-label" for="custom-origin">${h("panel.216")}</label><input id="custom-origin" data-field="customOrigin" type="url" placeholder="https://devices.example.com" value="` + escapeHtml(state.customOrigin) + '" autocomplete="off" spellcheck="false">'}<ol class="simple-steps"><li>${h("panel.305")}</li><li>${h("panel.306")}</li><li>${h("panel.307")}</li></ol><textarea id="cloud-connect-command" readonly rows="3" spellcheck="false" aria-label="${h("panel.308")}">${escapeHtml(command)}</textarea><p id="cloud-command-error" class="form-error" role="alert" ${!error ? 'hidden' : ''}>${escapeHtml(error ?? '')}</p><button class="button button-primary" data-action="cloud-command-copy" ${!command ? 'disabled' : ''}>${icon('copy', 15)} ${h("panel.309")}</button><p class="small-note">${h("panel.310")}</p><details class="connector-help"><summary>${h("panel.311")}</summary><p>${h("panel.312")}</p><p>${h("panel.313")}</p></details>`;
|
|
225
305
|
}
|
|
226
306
|
function logCommandDraft() {
|
|
227
307
|
const peers = cloudMode() ? state.overview?.drivers?.filter(p=>p.revokedAt == null) ?? [] : state.remotePeers;
|
|
228
308
|
const manager = peers.find(p=>p.id === state.logManager), target = peers.find(p=>p.id === state.logTarget);
|
|
229
309
|
try { return { ...logAccessCommands({manager:manager?.fingerprint,target:target?.fingerprint,path:state.logPath,label:state.logLabel}), error:null }; }
|
|
230
|
-
catch(error) { return {trust:'',grant:'',error:error.
|
|
310
|
+
catch(error) { return {trust:'',grant:'',error:t(error.translationKey ?? 'error.devices')}; }
|
|
231
311
|
}
|
|
232
312
|
function logCommandFields() {
|
|
233
313
|
const peers = cloudMode() ? state.overview?.drivers?.filter(p=>p.revokedAt == null) ?? [] : state.remotePeers;
|
|
234
|
-
if (state.remoteLoading) return
|
|
235
|
-
if (state.remoteError) return
|
|
236
|
-
if (peers.length < 2) return
|
|
237
|
-
const options = selected =>
|
|
314
|
+
if (state.remoteLoading) return `<p class="small-note">${h("panel.314")}</p>`;
|
|
315
|
+
if (state.remoteError) return `<p class="form-error">${h("panel.315")}</p>`;
|
|
316
|
+
if (peers.length < 2) return `<p class="small-note">${h("panel.316")}</p>`;
|
|
317
|
+
const options = selected => `<option value="">${h("panel.317")}</option>` + peers.map(p=>`<option value="${escapeHtml(p.id)}" ${p.id===selected?'selected':''}>${escapeHtml(p.name)} · ${escapeHtml(p.fingerprint.slice(0,12))}…</option>`).join('');
|
|
238
318
|
const draft=logCommandDraft();
|
|
239
|
-
return `<div class="plain-panel"><h3
|
|
319
|
+
return `<div class="plain-panel"><h3>${h("panel.319")}</h3><label class="field-label" for="log-manager">${h("panel.320")}</label><select id="log-manager" data-log-field="logManager">${options(state.logManager)}</select><label class="field-label" for="log-target">${h("panel.321")}</label><select id="log-target" data-log-field="logTarget">${options(state.logTarget)}</select><label class="field-label" for="log-path">${h("panel.322")}</label><input id="log-path" data-log-field="logPath" value="${escapeHtml(state.logPath)}" placeholder="/home/me/app.log" autocomplete="off" spellcheck="false"><label class="field-label" for="log-label">${h("panel.323")}</label><input id="log-label" data-log-field="logLabel" value="${escapeHtml(state.logLabel)}" maxlength="40"><p id="log-command-error" class="small-note">${escapeHtml(draft.error??t("panel.318"))}</p><label class="field-label" for="log-trust-command">${h("panel.324")}</label><textarea id="log-trust-command" readonly rows="2">${escapeHtml(draft.trust)}</textarea><button class="button button-light" data-action="log-command-copy" data-field-id="log-trust-command" ${draft.error?'disabled':''}>${h("panel.325")}</button><label class="field-label" for="log-grant-command">${h("panel.326")}</label><textarea id="log-grant-command" readonly rows="3">${escapeHtml(draft.grant)}</textarea><button class="button button-primary" data-action="log-command-copy" data-field-id="log-grant-command" ${draft.error?'disabled':''}>${h("panel.327")}</button><p class="small-note">${h("panel.328")}</p></div>`;
|
|
240
320
|
}
|
|
241
321
|
function secureHelpBody() {
|
|
242
|
-
return `<p class="modal-intro"
|
|
322
|
+
return `<p class="modal-intro">${h("panel.329")}</p>${logCommandFields()}<ol class="simple-steps"><li>${h("panel.330")} <code>npm install -g device-center@0.2.0-beta.3</code>${h("panel.331")} <code>device-center access enable --confirm</code>.</li><li>${h("panel.332")} <code>device-center access peers</code>${h("panel.333")}</li><li>${h("panel.334")}</li></ol><details open><summary>${h("panel.324")}</summary><code class="block-code">${h("panel.335")}</code></details><details open><summary>${h("panel.326")}</summary><code class="block-code">${h("panel.336")}</code></details><p>${h("panel.337")} <code>device_center_read_log</code>${h("panel.338")}</p><details><summary>${h("panel.339")}</summary><p>${h("panel.340")} <code>device-center access listen --bind 192.168.1.10 --port 5181 --confirm</code>${h("panel.341")} <code>--lan 192.168.1.10 --port 5181</code>${h("panel.342")}</p><p>${h("panel.343")}</p></details><p class="small-note">${h("panel.344")} <code>${h("panel.345")}</code> ${h("panel.346")}</p>`;
|
|
243
323
|
}
|
|
244
324
|
function cloudBindingBody() {
|
|
245
325
|
const pairing = state.pairing, disabled = state.pairBusy ? 'disabled' : '';
|
|
246
326
|
const sourceCommand = driverCommand().replace(/^npx --yes device-center@\S+/, 'npm run driver --');
|
|
247
327
|
const bindings = state.overview?.drivers ?? [];
|
|
248
|
-
return `<p class="modal-intro"
|
|
249
|
-
<section class="binding-step"><h3><span>1</span>
|
|
250
|
-
<section class="binding-step"><h3><span>2</span>
|
|
251
|
-
<section class="binding-step"><h3><span>3</span>
|
|
252
|
-
${pairing ? `<div class="pair-review"><strong>${escapeHtml(pairing.name)}</strong><p>${escapeHtml(pairing.platform)}
|
|
253
|
-
${bindings.length ? `<details class="binding-management" open><summary
|
|
254
|
-
<p class="small-note">${state.overview?.accountMode === 'multi-user' ?
|
|
328
|
+
return `<p class="modal-intro">${h("panel.365")}</p>
|
|
329
|
+
<section class="binding-step"><h3><span>1</span> ${h("panel.366")}</h3><p>${h("panel.367")}</p><details><summary>${h("panel.368")}</summary><a class="button button-light" href="/api/driver/download" download>${h("panel.369")}</a><p>${h("panel.370")} <code>npm ci --ignore-scripts</code>${h("panel.371")} <code>${escapeHtml(sourceCommand)}</code>${h("panel.372")}</p></details></section>
|
|
330
|
+
<section class="binding-step"><h3><span>2</span> ${h("panel.373")}</h3><label class="binding-scope"><input id="request-ssh" type="checkbox" data-field="requestSsh" ${state.requestSsh ? 'checked' : ''} ${disabled}>${h("panel.374")}</label><textarea id="driver-command" readonly rows="3" spellcheck="false" aria-label="${h("panel.375")}">${escapeHtml(driverCommand())}</textarea><button class="button button-primary" data-action="driver-copy">${icon('copy', 15)} ${h("panel.309")}</button><p>${h("panel.376")}</p></section>
|
|
331
|
+
<section class="binding-step"><h3><span>3</span> ${h("panel.377")}</h3><div class="pair-code-row"><label for="pair-code" class="sr-only">${h("panel.378")}</label><input id="pair-code" data-field="pairCode" autocomplete="off" spellcheck="false" maxlength="9" placeholder="XXXX-XXXX" value="${escapeHtml(state.pairCode)}" ${disabled}><button class="button button-light" data-action="pair-lookup" ${disabled}>${state.pairBusy ? t("panel.347") : t("panel.348")}</button></div>${state.pairError ? `<p class="form-error" role="alert">${escapeHtml(t(state.pairError))}</p>` : ''}
|
|
332
|
+
${pairing ? `<div class="pair-review"><strong>${escapeHtml(pairing.name)}</strong><p>${escapeHtml(pairing.platform)} ${h("panel.350")} ${escapeHtml(new Date(pairing.expiresAt).toLocaleTimeString(getLocale()))}</p><label class="field-label">${h("panel.351")}</label><code class="fingerprint">${escapeHtml(pairing.fingerprint)}</code><p>${h("panel.352")}</p>${pairing.requestedSsh ? `<label class="binding-scope"><input id="pair-share" type="checkbox" data-field="pairShare" ${state.pairShare ? 'checked' : ''} ${disabled}>${h("panel.349")}</label>` : ''}<button class="button button-primary" data-action="pair-approve" ${disabled}>${h("panel.353")}</button></div>` : `<p class="small-note">${h("panel.354")}</p>`}</section>
|
|
333
|
+
${bindings.length ? `<details class="binding-management" open><summary>${h("count.active", { count: bindings.filter(item => item.revokedAt == null).length })}</summary>${bindings.map(item => `<div class="binding-row"><div><strong>${escapeHtml(item.name)}</strong><small>${item.revokedAt != null ? t("panel.355") : item.lastSeen == null ? t("panel.356") : `${h("panel.14")} ${formatAgo(item.lastSeen)}`}</small><code class="fingerprint">${escapeHtml(item.fingerprint)}</code></div>${item.revokedAt == null ? state.revokeId === item.id ? `<div class="revoke-confirm"><p>${h("panel.357")}</p><button class="button button-light" data-action="revoke-confirm" data-device="${item.id}" ${disabled}>${h("panel.358")}</button><button class="text-button" data-action="revoke-cancel" ${disabled}>${h("panel.359")}</button></div>` : `<button class="text-button" data-action="revoke-arm" data-device="${item.id}" ${disabled}>${h("panel.360")}</button>` : ''}</div>`).join('')}</details>` : ''}
|
|
334
|
+
<p class="small-note">${state.overview?.accountMode === 'multi-user' ? t("panel.363") : t("panel.364")}${h("panel.379")}</p>`;
|
|
255
335
|
}
|
|
256
336
|
function pairErrorText(code) {
|
|
257
|
-
return ({ invalid_pairing_code:
|
|
337
|
+
return ({ invalid_pairing_code: "panel.380", driver_pairing_confirmation_required: "panel.381", driver_pairing_unavailable: "panel.382", driver_pairing_expired: "panel.383", driver_pairing_consumed: "panel.384", driver_rate_limited: "panel.385", driver_limit: "panel.386" })[code] ?? "panel.387";
|
|
258
338
|
}
|
|
259
339
|
async function pairAction(action, id) {
|
|
260
340
|
if (state.pairBusy) return;
|
|
@@ -264,9 +344,9 @@ async function pairAction(action, id) {
|
|
|
264
344
|
else if (action === 'pair-approve' && state.pairing) {
|
|
265
345
|
await api.approvePairing(state.pairing.id, Boolean(state.pairing.requestedSsh && state.pairShare), state.pairing.code, state.pairing.fingerprint);
|
|
266
346
|
state.pairing = null; state.pairCode = ''; state.pairShare = false;
|
|
267
|
-
state.toast =
|
|
347
|
+
state.toast = t("panel.388");
|
|
268
348
|
state.overview = await api.overview();
|
|
269
|
-
} else if (action === 'revoke-confirm' && id === state.revokeId) { await api.revokeDriver(id); state.revokeId = null; state.overview = await api.overview(); state.toast =
|
|
349
|
+
} else if (action === 'revoke-confirm' && id === state.revokeId) { await api.revokeDriver(id); state.revokeId = null; state.overview = await api.overview(); state.toast = t("panel.389"); }
|
|
270
350
|
} catch (error) { state.pairError = pairErrorText(error.message); }
|
|
271
351
|
finally { state.pairBusy = false; render(); }
|
|
272
352
|
}
|
|
@@ -275,43 +355,44 @@ async function copyDriverCommand() {
|
|
|
275
355
|
field.focus(); field.select(); let copied = false;
|
|
276
356
|
try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(field.value); copied = true; } } catch { /* manual selection remains available */ }
|
|
277
357
|
if (!copied) try { copied = document.execCommand('copy'); } catch { /* manual selection */ }
|
|
278
|
-
toast(copied ?
|
|
358
|
+
toast(copied ? t("panel.390") : t("panel.391"));
|
|
279
359
|
if (!copied) { const current = root.querySelector(state.dialog === 'cloud-connect' ? '#cloud-connect-command' : '#driver-command'); current?.focus(); current?.select(); }
|
|
280
360
|
}
|
|
281
361
|
function initializationBody() {
|
|
282
|
-
if (state.sshLoading) return
|
|
362
|
+
if (state.sshLoading) return `<p class="modal-intro">${h("panel.392")}</p>`;
|
|
283
363
|
const candidates = state.ssh?.candidates ?? [];
|
|
284
364
|
const ready = candidates.filter((item) => item.importable);
|
|
285
365
|
const pending = ready.filter((item) => !item.associated);
|
|
286
366
|
const skipped = candidates.filter((item) => !item.importable);
|
|
287
|
-
const reason = (item) => ({ host_driver_required:
|
|
288
|
-
const rows = ready.map((item) => { const device = state.overview?.devices.find((entry) => entry.ssh?.alias === item.alias); return `<div class="ssh-candidate"><span><strong>${escapeHtml(item.alias)}</strong><small>${escapeHtml(item.source)} · ${item.routeMode === 'jump' ?
|
|
289
|
-
return `<p class="modal-intro"
|
|
367
|
+
const reason = (item) => ({ host_driver_required: t("panel.393"), ssh_runtime_unsupported: t("panel.394"), ssh_identity_unavailable: t("panel.395"), ssh_profile_incomplete: t("panel.396"), ssh_profile_needs_review: t("panel.397") })[item.reason] ?? t("panel.398");
|
|
368
|
+
const rows = ready.map((item) => { const device = state.overview?.devices.find((entry) => entry.ssh?.alias === item.alias); return `<div class="ssh-candidate"><span><strong>${escapeHtml(item.alias)}</strong><small>${escapeHtml(item.source)} · ${item.routeMode === 'jump' ? t("panel.399") : t("panel.400")}</small></span>${item.associated && device ? connectivityMarkup(device) : `<small>${item.associated ? t("panel.401") : t("panel.402")}</small>`}</div>`; }).join('');
|
|
369
|
+
return `<p class="modal-intro">${h("panel.415")}</p><div class="ssh-toolbar"><strong>${h("count.importable", { pending: pending.length, imported: ready.length - pending.length })}</strong><button class="button button-light" data-action="ssh-refresh" ${state.saving ? 'disabled' : ''}>${h("panel.283")}</button></div>${state.sshError ? `<p class="form-error" role="alert">${escapeHtml(t(state.sshError))}</p>` : ''}${ready.length ? `<div class="ssh-candidates initialization-candidates" role="region" aria-label="${h("panel.403")}">${rows}</div>` : `<div class="plain-panel"><h3>${state.ssh?.state === 'unavailable' ? t("panel.404") : t("panel.405")}</h3><p>${state.ssh?.state === 'unavailable' ? t("panel.406") : t("panel.407")}</p></div>`}<button class="button button-primary associate-button" data-action="initialize-import" ${!pending.length || state.saving ? 'disabled' : ''}>${state.saving ? t("panel.408") : pending.length ? h("count.import", { count: pending.length }) : ready.length ? t("panel.411") : t("panel.412")}</button><p class="small-note">${h("panel.418")}</p>${skipped.length ? `<details class="ssh-import-skipped"><summary>${h("count.skipped", { count: skipped.length })}</summary>${skipped.map((item) => `<p><strong>${escapeHtml(item.alias)}</strong>${item.associated ? t("panel.413") : ''}<br>${reason(item)}</p>`).join('')}</details>` : ''}${state.ssh?.notices?.length ? `<details class="ssh-import-notices"><summary>${h("panel.293")}</summary>${state.ssh.notices.map((notice) => `<p>${escapeHtml(reportMessage(notice))}</p>`).join('')}</details>` : ''}`;
|
|
290
370
|
}
|
|
291
371
|
function residentBody() {
|
|
292
372
|
const port = new URL(state.setup?.url ?? location.href).port;
|
|
293
373
|
const portOption = port ? ` --port ${port}` : "";
|
|
294
|
-
return `<p class="modal-intro"
|
|
374
|
+
return `<p class="modal-intro">${h("panel.419")}</p><div class="plain-panel"><h3>${h("panel.420")}</h3><p>${h("panel.421")}</p><code class="block-code">npm run service -- plan${portOption}</code><code class="block-code">npm run service -- install --confirm${portOption}</code><p class="small-note">${h("panel.422")}</p></div><details><summary>${h("panel.423")}</summary><p>${h("panel.424")} <code>${h("panel.425")}</code> ${h("panel.426")}</p><p>${h("panel.427")}</p><p>${h("panel.428")}</p></details><p class="small-note">${h("panel.429")}</p>`;
|
|
295
375
|
}
|
|
296
376
|
function renderDialog() {
|
|
297
377
|
if (!state.dialog) return '';
|
|
298
|
-
const title = { 'secure-help':
|
|
299
|
-
const body = state.dialog === 'secure-help' ? secureHelpBody() : state.dialog === 'cloud-connect' ? cloudConnectBody() : state.dialog === 'setup' ? onboardingBody() : state.dialog === 'route' ? routeBody() : state.dialog === 'connect' ? connectionBody() : state.dialog === 'add' ? addBody() : state.dialog === 'initialize' ? initializationBody() : residentBody();
|
|
300
|
-
return `<div class="modal-scrim" data-action="close"><section class="add-modal ${state.dialog === 'setup' ? 'onboarding-modal' : ''}" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><header class="modal-heading"><h2 id="dialog-title">${title}</h2
|
|
378
|
+
const title = { 'agent-install': t("panel.430"), profile: t("panel.49"), 'secure-help': t("panel.431"), setup: t("panel.209"), connect: t("panel.432"), 'cloud-connect': t("panel.433"), add: cloudMode() ? t("panel.434") : t("panel.435"), initialize: t("panel.436"), resident: t("panel.437"), route: t("panel.438") }[state.dialog];
|
|
379
|
+
const body = state.dialog === 'agent-install' ? agentInstallBody() : state.dialog === 'profile' ? profileBody() : state.dialog === 'secure-help' ? secureHelpBody() : state.dialog === 'cloud-connect' ? cloudConnectBody() : state.dialog === 'setup' ? onboardingBody() : state.dialog === 'route' ? routeBody() : state.dialog === 'connect' ? connectionBody() : state.dialog === 'add' ? addBody() : state.dialog === 'initialize' ? initializationBody() : residentBody();
|
|
380
|
+
return `<div class="modal-scrim" data-action="close"><section class="add-modal ${state.dialog === 'setup' ? 'onboarding-modal' : ''}" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><header class="modal-heading"><h2 id="dialog-title">${title}</h2>${languagePickerMarkup()}<button class="icon-button" data-action="close" aria-label="${h("panel.439")}">${icon('close')}</button></header>${body}${state.toast ? `<div class="toast" role="status">${escapeHtml(state.toast)}</div>` : ''}</section></div>`;
|
|
301
381
|
}
|
|
302
382
|
function filteredDevices() {
|
|
303
383
|
const needle = state.query.trim().toLocaleLowerCase();
|
|
304
|
-
return (state.overview?.devices ?? []).filter((d) => (state.project === 'all' || d.projectId === state.project) && (state.filter === 'all' || d.state === state.filter) && (!needle ||
|
|
384
|
+
return (state.overview?.devices ?? []).filter((d) => (state.project === 'all' || d.projectId === state.project) && (state.filter === 'all' || d.state === state.filter) && (!needle || [d.name, d.reportedName, d.platform, DEVICE_TYPE_LABELS[d.profile?.type], d.profile?.purpose, ...(d.profile?.tags ?? []), ...(d.profile?.services ?? [])].join(' ').toLocaleLowerCase().includes(needle)));
|
|
305
385
|
}
|
|
306
386
|
function renderDevices() {
|
|
307
|
-
if (state.loading) return
|
|
308
|
-
if (state.error) return `<div class="empty-state"><h3
|
|
387
|
+
if (state.loading) return `<div class="empty-state"><p>${h("panel.440")}</p></div>`;
|
|
388
|
+
if (state.error) return `<div class="empty-state"><h3>${h("panel.441")}</h3><p>${h("panel.442")}</p><button class="button button-light" data-action="refresh">${h("panel.443")}</button></div>`;
|
|
309
389
|
const devices = filteredDevices();
|
|
310
390
|
if (devices.length) return devices.map(deviceCard).join('');
|
|
311
391
|
const any = state.overview?.devices.length;
|
|
312
|
-
return `<div class="empty-state"><span class="empty-icon">${icon('device', 26)}</span><h3>${any ?
|
|
392
|
+
return `<div class="empty-state"><span class="empty-icon">${icon('device', 26)}</span><h3>${any ? t("panel.444") : t("panel.445")}</h3><p>${any ? t("panel.446") : cloudMode() ? t("panel.447") : t("panel.448")}</p>${any ? '' : `<span class="empty-hint">${cloudMode() ? t("panel.449") : t("panel.450")}</span>`}</div>`;
|
|
313
393
|
}
|
|
314
394
|
function render() {
|
|
395
|
+
document.title = "Device Center · " + t(cloudMode() ? "panel.184" : "panel.194");
|
|
315
396
|
clearTimeout(snapshotExpiryTimer);
|
|
316
397
|
scheduleCheckRefresh();
|
|
317
398
|
scheduleCloudRefresh();
|
|
@@ -327,31 +408,32 @@ function render() {
|
|
|
327
408
|
const dialogScroll = oldDialog?.scrollTop ?? 0;
|
|
328
409
|
const listScroll = root.querySelector('.device-grid')?.scrollTop ?? 0;
|
|
329
410
|
const focused = document.activeElement;
|
|
411
|
+
const languageFocused = Boolean(focused?.closest('[data-language-picker]'));
|
|
330
412
|
const focusAction = focused?.dataset.action;
|
|
331
413
|
const focusId = oldDialog?.contains(focused) ? focused.id : null;
|
|
332
414
|
const focusField = focused?.dataset.field;
|
|
333
|
-
const focusQualifier = ['method', 'copy', 'filter', 'project', 'alias', 'device', 'provider', 'step']
|
|
415
|
+
const focusQualifier = ['method', 'copy', 'filter', 'project', 'alias', 'device', 'provider', 'step', 'mode']
|
|
334
416
|
.filter((key) => focused?.dataset[key])
|
|
335
417
|
.map((key) => `[data-${key}="${CSS.escape(focused.dataset[key])}"]`).join('');
|
|
336
|
-
const findFocus = (container) => focusId
|
|
418
|
+
const findFocus = (container) => languageFocused ? container.querySelector('[data-language-trigger]') : focusId
|
|
337
419
|
? container.querySelector(`#${CSS.escape(focusId)}`)
|
|
338
420
|
: focusAction ? container.querySelector(`[data-action="${CSS.escape(focusAction)}"]${focusQualifier}:not(:disabled)`)
|
|
339
421
|
: focusField ? container.querySelector(`[data-field="${CSS.escape(focusField)}"]${focusQualifier}:not(:disabled)`) : null;
|
|
340
422
|
const project = (state.overview?.projects ?? []).find((p) => p.id === state.project);
|
|
341
423
|
const counts = state.overview?.stats ?? { total: 0, online: 0, unknown: 0, expired: 0 };
|
|
342
424
|
root.innerHTML = `<div class="app-shell">${renderSidebar()}<div class="main-area">
|
|
343
|
-
<header class="topbar"><span>${cloudMode() ?
|
|
425
|
+
<header class="topbar"><span>${cloudMode() ? t("panel.451") : t("panel.452")}</span><div class="header-actions"><span class="environment-badge">${cloudMode() ? t("panel.453") : t("panel.29")}</span>${languagePickerMarkup()}</div></header>
|
|
344
426
|
<main class="workspace">
|
|
345
|
-
<section class="page-heading"><div><h1>${escapeHtml(project?.name ??
|
|
427
|
+
<section class="page-heading"><div><h1>${escapeHtml(project?.name ?? t("panel.187"))}</h1><p>${h("panel.467")}</p></div><div class="heading-actions"><button class="button button-light" data-action="agent-install">${h("panel.430")}</button><button class="button button-primary" data-action="add">${icon('plus', 16)} ${cloudMode() ? state.overview?.drivers?.some(item => item.revokedAt == null) ? t("panel.454") : t("panel.434") : t("panel.435")}</button></div></section>
|
|
346
428
|
${state.error ? '' : renderConnection()}
|
|
347
|
-
<section class="list-section" aria-label="
|
|
348
|
-
<div class="list-toolbar"><div class="filter-tabs" role="group" aria-label="
|
|
349
|
-
<div class="list-controls">${counts.total ? `<input class="search-input" type="search" data-field="search" placeholder="
|
|
429
|
+
<section class="list-section" aria-label="${h("panel.468")}">
|
|
430
|
+
<div class="list-toolbar"><div class="filter-tabs" role="group" aria-label="${h("panel.469")}">${[['all', t("panel.455"), counts.total], ['online', t("panel.456"), counts.online], ['unknown', t("panel.457"), counts.unknown], ['expired', t("panel.458"), counts.expired]].map(([key, label, count]) => `<button class="filter-tab ${state.filter === key ? 'active' : ''}" data-action="filter" data-filter="${key}" aria-pressed="${state.filter === key}">${label}<span>${count}</span></button>`).join('')}</div>
|
|
431
|
+
<div class="list-controls">${counts.total ? `<input class="search-input" type="search" data-field="search" placeholder="${h("panel.459")}" aria-label="${h("panel.459")}" value="${escapeHtml(state.query)}">` : ''}<button class="icon-button" data-action="refresh" aria-label="${h("panel.470")}" title="${h("panel.471")}">${icon('refresh', 17)}</button></div>
|
|
350
432
|
</div>
|
|
351
|
-
<div class="device-grid" role="region" aria-label="
|
|
352
|
-
<p class="list-footnote">${cloudMode() ?
|
|
433
|
+
<div class="device-grid" role="region" aria-label="${h("panel.472")}" tabindex="0">${renderDevices()}</div>
|
|
434
|
+
<p class="list-footnote">${cloudMode() ? t("panel.460") : t("panel.461")}</p>
|
|
353
435
|
</section>
|
|
354
|
-
<footer class="page-footer"><span>${cloudMode() && state.overview?.relayUsage ?
|
|
436
|
+
<footer class="page-footer"><span>${cloudMode() && state.overview?.relayUsage ? `${h("panel.462")} ${(state.overview.relayUsage.bytes / 1048576).toFixed(1)} / ${state.overview.relayUsage.limitBytes / 1048576} ${h("panel.463")}` : t("panel.464")}</span><details class="status-help"><summary>${h("panel.473")}</summary><p>${cloudMode() ? t("panel.465") : t("panel.466")} ${h("panel.474")}</p></details></footer>
|
|
355
437
|
</main>
|
|
356
438
|
</div>${renderDialog()}${state.toast && !state.dialog ? `<div class="toast page-toast" role="status">${escapeHtml(state.toast)}</div>` : ''}</div>`;
|
|
357
439
|
const dialog = root.querySelector('[role="dialog"]');
|
|
@@ -361,8 +443,9 @@ function render() {
|
|
|
361
443
|
root.querySelector('.main-area').inert = Boolean(dialog);
|
|
362
444
|
if (dialog) {
|
|
363
445
|
dialog.scrollTop = dialogScroll;
|
|
364
|
-
|
|
365
|
-
|
|
446
|
+
const initialFocus = dialog.querySelector('button[data-action="close"]') ?? dialog.querySelector('button');
|
|
447
|
+
(oldDialog ? findFocus(dialog) ?? initialFocus : initialFocus)?.focus({ preventScroll: true });
|
|
448
|
+
} else if (oldDialog) root.querySelector(oldDialog.dataset.kind === 'setup' ? `[data-action="${CSS.escape(state.setupReturn)}"]` : oldDialog.dataset.kind === 'profile' ? `[data-action="profile"][data-device="${CSS.escape(state.profileDevice)}"]` : oldDialog.dataset.kind === 'route' ? `[data-action="route"][data-device="${CSS.escape(state.routeDevice)}"]` : `[data-action="${{ 'agent-install': 'agent-install', connect: 'connect', 'cloud-connect': 'cloud-connect', add: 'add', initialize: 'initialize', resident: 'resident' }[oldDialog.dataset.kind] ?? 'connect'}"]`)?.focus({ preventScroll: true });
|
|
366
449
|
else findFocus(root)?.focus({ preventScroll: true });
|
|
367
450
|
if (dialog) dialog.dataset.kind = state.dialog;
|
|
368
451
|
}
|
|
@@ -373,10 +456,10 @@ function scheduleCloudRefresh() {
|
|
|
373
456
|
cloudRefreshTimer = setTimeout(async () => {
|
|
374
457
|
if (state.pairBusy || cloudRefreshBusy) { scheduleCloudRefresh(); return; }
|
|
375
458
|
cloudRefreshBusy = true;
|
|
376
|
-
try {
|
|
377
|
-
catch { cloudRefreshFailures++; state.cloudRefreshError = cloudRefreshFailures >= 3 ?
|
|
459
|
+
try { updateOverview(await api.overview()); state.cloudRefreshError = null; cloudRefreshFailures = 0; }
|
|
460
|
+
catch { cloudRefreshFailures++; state.cloudRefreshError = cloudRefreshFailures >= 3 ? "panel.475" : "panel.476"; }
|
|
378
461
|
finally { cloudRefreshBusy = false; render(); }
|
|
379
|
-
}, 15_000);
|
|
462
|
+
}, Object.keys(state.pendingCollections).length ? 3000 : 15_000);
|
|
380
463
|
}
|
|
381
464
|
async function load() {
|
|
382
465
|
checkRefreshFailures = 0;
|
|
@@ -386,7 +469,7 @@ async function load() {
|
|
|
386
469
|
try {
|
|
387
470
|
const [overview, setup, status] = await Promise.all([api.overview(), api.connectorSetup(), api.connectorStatus()]);
|
|
388
471
|
if (version !== requestVersion) return;
|
|
389
|
-
Object.assign(state, {
|
|
472
|
+
Object.assign(state, { setup, status }); updateOverview(overview);
|
|
390
473
|
} catch { if (version === requestVersion) state.error = 'service_unavailable'; }
|
|
391
474
|
finally { if (version === requestVersion) { state.loading = false; render(); } }
|
|
392
475
|
}
|
|
@@ -395,7 +478,7 @@ async function loadSsh() {
|
|
|
395
478
|
try {
|
|
396
479
|
state.ssh = await (state.dialog === 'initialize' || state.dialog === 'setup' ? api.sshInitialization() : api.sshCandidates());
|
|
397
480
|
state.selectedAliases = state.selectedAliases.filter((alias) => state.ssh.candidates.some((item) => item.alias === alias && !item.associated));
|
|
398
|
-
} catch { state.sshError =
|
|
481
|
+
} catch { state.sshError = "panel.477"; }
|
|
399
482
|
finally { state.sshLoading = false; render(); }
|
|
400
483
|
}
|
|
401
484
|
async function associateSsh() {
|
|
@@ -406,8 +489,8 @@ async function associateSsh() {
|
|
|
406
489
|
state.overview = await api.overview();
|
|
407
490
|
state.filter = 'all'; state.query = ''; state.project = 'all'; state.selectedAliases = []; state.dialog = null;
|
|
408
491
|
checkRefreshFailures = 0;
|
|
409
|
-
toast(result.checks.aliases.length ?
|
|
410
|
-
} catch (cause) { state.sshError = cause.message === 'ssh_alias_unavailable' ?
|
|
492
|
+
toast(result.checks.aliases.length ? t("count.associated", { count: result.added }) : t("panel.479"));
|
|
493
|
+
} catch (cause) { state.sshError = cause.message === 'ssh_alias_unavailable' ? "panel.480" : "panel.481"; }
|
|
411
494
|
finally { state.saving = false; render(); }
|
|
412
495
|
}
|
|
413
496
|
async function initializeSsh() {
|
|
@@ -420,8 +503,8 @@ async function initializeSsh() {
|
|
|
420
503
|
state.ssh = await api.sshInitialization();
|
|
421
504
|
state.filter = 'all'; state.query = ''; state.project = 'all';
|
|
422
505
|
checkRefreshFailures = 0;
|
|
423
|
-
toast(result.checks.aliases.length ?
|
|
424
|
-
} catch (cause) { state.sshError = cause.message === 'ssh_import_needs_refresh' ?
|
|
506
|
+
toast(result.checks.aliases.length ? t("count.imported", { count: result.added }) : t("panel.483"));
|
|
507
|
+
} catch (cause) { state.sshError = cause.message === 'ssh_import_needs_refresh' ? "panel.484" : "panel.485"; }
|
|
425
508
|
finally { state.saving = false; render(); root.querySelector('[data-action="ssh-refresh"]')?.focus({ preventScroll: true }); }
|
|
426
509
|
}
|
|
427
510
|
let toastTimer;
|
|
@@ -432,8 +515,7 @@ function toast(message) {
|
|
|
432
515
|
async function copyField(kind) {
|
|
433
516
|
const text = kind === 'command' ? state.setup?.setupCommand : state.setup?.url;
|
|
434
517
|
const field = root.querySelector('#copy-field');
|
|
435
|
-
if (!text || !field) return toast(
|
|
436
|
-
field.focus(); field.select();
|
|
518
|
+
if (!text || !field) return toast(t("panel.486"));
|
|
437
519
|
let copied = false;
|
|
438
520
|
let timer;
|
|
439
521
|
if (navigator.clipboard?.writeText) {
|
|
@@ -441,30 +523,35 @@ async function copyField(kind) {
|
|
|
441
523
|
catch { /* retain manual selection */ } finally { clearTimeout(timer); }
|
|
442
524
|
}
|
|
443
525
|
if (!copied) try { copied = document.execCommand('copy'); } catch { /* retain manual selection */ }
|
|
444
|
-
toast(copied ?
|
|
526
|
+
toast(copied ? t("copy.codex") : t("panel.490"));
|
|
445
527
|
if (!copied) { root.querySelector('#copy-field')?.focus(); root.querySelector('#copy-field')?.select(); }
|
|
446
528
|
}
|
|
447
529
|
root.addEventListener('click', async (event) => {
|
|
448
530
|
const button = event.target.closest('[data-action]');
|
|
449
531
|
if (!button) return;
|
|
450
532
|
const action = button.dataset.action;
|
|
451
|
-
if (action === '
|
|
533
|
+
if (action === 'agent-install') { openAgentInstall(); render(); return; }
|
|
534
|
+
if (action === 'install-mode') { state.installMode = button.dataset.mode; state.toast = null; render(); return; }
|
|
535
|
+
if (action === 'agent-install-copy') return copyAgentInstall();
|
|
536
|
+
if (action === 'logout') { try { await api.logout(); location.replace('/login?lang=' + getLocale()); } catch { state.toast = t("panel.491"); render(); } return; }
|
|
452
537
|
if (cloudMode() && action === 'add') { state.dialog = 'add'; state.toast = null; state.pairError = null; state.revokeId = null; render(); return; }
|
|
453
538
|
if (['driver-copy', 'cloud-command-copy'].includes(action)) return copyDriverCommand();
|
|
454
539
|
if (action === 'secure-help') { state.dialog='secure-help'; state.toast=null; state.remoteError=null; render(); if (!cloudMode()) { state.remoteLoading=true; render(); try { const data=await api.remotePeers(); state.remotePeers=data.peers; state.logManager=data.driverId??''; } catch { state.remoteError=true; } finally { state.remoteLoading=false; render(); } } return; }
|
|
455
|
-
if (action === 'log-command-copy') { const field=root.querySelector('#'+button.dataset.fieldId); if(!field?.value)return;field.focus();field.select();try {await navigator.clipboard.writeText(field.value);toast(
|
|
540
|
+
if (action === 'log-command-copy') { const field=root.querySelector('#'+button.dataset.fieldId); if(!field?.value)return;field.focus();field.select();try {await navigator.clipboard.writeText(field.value);toast(t("panel.492"));}catch{toast(t("panel.493"));} return; }
|
|
456
541
|
if (action === 'cloud-connect') { state.dialog = 'cloud-connect'; state.toast = null; render(); return; }
|
|
457
542
|
if (action === 'cloud-choice') { state.cloudChoice = button.dataset.provider; state.toast = null; render(); return; }
|
|
458
543
|
if (['pair-lookup', 'pair-approve', 'revoke-confirm'].includes(action)) return pairAction(action, button.dataset.device);
|
|
459
544
|
if (action === 'revoke-arm') { state.revokeId = button.dataset.device; render(); return; }
|
|
460
545
|
if (action === 'revoke-cancel') { state.revokeId = null; render(); return; }
|
|
461
|
-
if (action === 'close') { if (button.classList.contains('modal-scrim') && button !== event.target) return; state.dialog = null; state.toast = null; }
|
|
546
|
+
if (action === 'close') { if (state.dialog === 'profile' && state.profileSaving || button.classList.contains('modal-scrim') && button !== event.target) return; state.dialog = null; state.toast = null; }
|
|
462
547
|
else if (action === 'start') return openSetup('start');
|
|
463
548
|
else if (action === 'connect') return openSetup('connect', 1);
|
|
464
549
|
else if (action === 'initialize') return openSetup('start', 2);
|
|
465
550
|
else if (action === 'setup-step') return selectSetupStep(Number(button.dataset.step));
|
|
466
551
|
else if (action === 'setup-finish') { state.dialog = null; state.toast = null; state.filter = 'all'; state.query = ''; state.project = 'all'; }
|
|
467
552
|
else if (action === 'route') openConnections(button.dataset.device);
|
|
553
|
+
else if (action === 'profile') openDeviceProfile(button.dataset.device);
|
|
554
|
+
else if (action === 'collect-device') return collectDevice(button.dataset.device);
|
|
468
555
|
else if (action === 'save-connections') return saveConnections();
|
|
469
556
|
else if (action === 'clear-relay') state.connectionDraft.relayProvider = null;
|
|
470
557
|
else if (['connect', 'resident', 'add', 'initialize'].includes(action)) { state.dialog = action; state.toast = null; state.checkMessage = null; if (['add', 'initialize'].includes(action)) { state.ssh = null; state.selectedAliases = []; state.sshError = null; render(); return loadSsh(); } }
|
|
@@ -481,8 +568,8 @@ root.addEventListener('click', async (event) => {
|
|
|
481
568
|
state.checking = true; state.checkMessage = null; render();
|
|
482
569
|
try {
|
|
483
570
|
state.status = await api.connectorStatus();
|
|
484
|
-
state.checkMessage = state.status.client.state === 'observed' ?
|
|
485
|
-
} catch { state.checkMessage =
|
|
571
|
+
state.checkMessage = state.status.client.state === 'observed' ? "panel.494" : state.status.client.state === 'stale' ? "panel.495" : "panel.496";
|
|
572
|
+
} catch { state.checkMessage = "panel.497"; }
|
|
486
573
|
finally { state.checking = false; }
|
|
487
574
|
render();
|
|
488
575
|
root.querySelector('[data-action="check"]')?.focus({ preventScroll: true });
|
|
@@ -491,6 +578,8 @@ root.addEventListener('click', async (event) => {
|
|
|
491
578
|
render();
|
|
492
579
|
});
|
|
493
580
|
root.addEventListener('change', (event) => {
|
|
581
|
+
if (event.target.dataset.field === 'installBackground') { state.installBackground = event.target.checked; render(); return; }
|
|
582
|
+
if (event.target.dataset.profileField && state.profileDraft) { state.profileDraft[event.target.dataset.profileField] = event.target.value; return; }
|
|
494
583
|
if (['requestSsh', 'pairShare'].includes(event.target.dataset.field)) { state[event.target.dataset.field] = event.target.checked; render(); return; }
|
|
495
584
|
if (['relayProvider', 'preference'].includes(event.target.dataset.field) && state.connectionDraft) { state.connectionDraft[event.target.dataset.field] = event.target.value; state.connectionError = null; render(); return; }
|
|
496
585
|
if (event.target.dataset.field !== 'ssh-alias') return;
|
|
@@ -499,6 +588,15 @@ root.addEventListener('change', (event) => {
|
|
|
499
588
|
render();
|
|
500
589
|
});
|
|
501
590
|
root.addEventListener('input', (event) => {
|
|
591
|
+
if (event.target.dataset.field === 'installOrigin') {
|
|
592
|
+
state.installOrigin = event.target.value;
|
|
593
|
+
const draft = installDraft();
|
|
594
|
+
root.querySelector('#agent-install-text').value = draft.text;
|
|
595
|
+
const message = root.querySelector('#install-error'); message.textContent = draft.error ?? ''; message.hidden = !draft.error;
|
|
596
|
+
root.querySelector('[data-action="agent-install-copy"]').disabled = Boolean(draft.error);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (event.target.dataset.profileField && state.profileDraft) { state.profileDraft[event.target.dataset.profileField] = event.target.value; return; }
|
|
502
600
|
if (event.target.dataset.field === 'customOrigin') {
|
|
503
601
|
state.customOrigin = event.target.value;
|
|
504
602
|
const { command, error } = cloudConnectDraft();
|
|
@@ -515,10 +613,14 @@ root.addEventListener('input', (event) => {
|
|
|
515
613
|
root.querySelector('.device-grid').innerHTML = renderDevices();
|
|
516
614
|
root.querySelector('.device-grid').scrollTop = 0;
|
|
517
615
|
});
|
|
616
|
+
root.addEventListener('submit', (event) => {
|
|
617
|
+
if (event.target.id !== 'device-profile-form') return;
|
|
618
|
+
event.preventDefault(); void saveDeviceProfile();
|
|
619
|
+
});
|
|
518
620
|
root.addEventListener('keydown', (event) => {
|
|
519
621
|
const dialog = root.querySelector('[role="dialog"]');
|
|
520
622
|
if (!dialog) return;
|
|
521
|
-
if (event.key === 'Escape') { state.dialog = null; state.toast = null; render(); return; }
|
|
623
|
+
if (event.key === 'Escape') { if (state.dialog === 'profile' && state.profileSaving) return; state.dialog = null; state.toast = null; render(); return; }
|
|
522
624
|
if (event.key !== 'Tab') return;
|
|
523
625
|
const controls = [...dialog.querySelectorAll('button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled), summary, a[href]')].filter((el) => el.getClientRects().length);
|
|
524
626
|
const first = controls[0], last = controls.at(-1);
|
|
@@ -526,7 +628,6 @@ root.addEventListener('keydown', (event) => {
|
|
|
526
628
|
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
|
527
629
|
});
|
|
528
630
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) { cloudRefreshFailures = 0; scheduleCloudRefresh(); } else clearTimeout(cloudRefreshTimer); });
|
|
529
|
-
load();
|
|
530
631
|
|
|
531
632
|
root.addEventListener('input', event => {
|
|
532
633
|
const field=event.target.dataset.logField;
|
|
@@ -535,6 +636,9 @@ root.addEventListener('input', event => {
|
|
|
535
636
|
const draft=logCommandDraft();
|
|
536
637
|
const trust=root.querySelector('#log-trust-command'),grant=root.querySelector('#log-grant-command');
|
|
537
638
|
if(trust)trust.value=draft.trust;if(grant)grant.value=draft.grant;
|
|
538
|
-
const error=root.querySelector('#log-command-error');if(error)error.textContent=draft.error??
|
|
639
|
+
const error=root.querySelector('#log-command-error');if(error)error.textContent=draft.error??t("panel.318");
|
|
539
640
|
for(const button of root.querySelectorAll('[data-action="log-command-copy"]'))button.disabled=Boolean(draft.error);
|
|
540
641
|
});
|
|
642
|
+
|
|
643
|
+
initLanguage({ onChange: () => { state.toast = null; render(); } });
|
|
644
|
+
load();
|