device-center 0.2.0-beta.2 → 0.2.0-beta.4
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 +2 -0
- package/Dockerfile +2 -1
- package/README.md +23 -4
- package/bin/device-center.mjs +3 -2
- package/docs/agent-install.md +41 -7
- package/docs/driver-binding.md +11 -1
- package/docs/friend-trial.md +2 -2
- package/docs/hosted-access.md +1 -1
- package/docs/i18n.md +32 -0
- package/docs/npm.md +9 -7
- package/docs/onboarding.md +3 -1
- package/docs/remote-access.md +2 -2
- package/docs/roadmap.md +6 -0
- package/docs/security-and-sync.md +2 -0
- package/favicon.svg +4 -0
- package/index.html +2 -0
- package/login.html +2 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/scripts/driver.mjs +27 -10
- package/server/cloudAccess.mjs +7 -1
- package/server/drivers/browserPairing.mjs +41 -0
- package/server/drivers/cloudRegistry.mjs +23 -6
- package/server/index.mjs +5 -2
- package/src/api.js +4 -1
- package/src/device-state.js +13 -12
- package/src/i18n.css +61 -0
- package/src/i18n.js +174 -0
- package/src/install-prompt.js +19 -0
- package/src/install-walkthrough.js +49 -0
- package/src/login.js +20 -11
- package/src/main.js +137 -124
- package/src/messages.js +7365 -0
- package/src/onboarding.js +41 -13
- package/src/pair.css +23 -0
- package/src/pair.js +60 -0
- package/src/pairing-handoff.js +30 -0
- package/src/start.css +265 -45
- package/src/start.js +153 -16
- package/src/styles.css +1 -1
- package/start.html +138 -29
package/src/main.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t, h, getLocale, initLanguage, languagePickerMarkup, reportMessage } from './i18n.js';
|
|
1
2
|
import { api } from './api.js';
|
|
2
3
|
import { expireSshSnapshots, deviceStatus, collectionAvailability, DEVICE_TYPE_LABELS } from './device-state.js';
|
|
3
4
|
import { OFFICIAL_CONTROL_ORIGIN, connectCommand, logAccessCommands, agentInstallPrompt } from './onboarding.js';
|
|
@@ -13,9 +14,9 @@ let cloudRefreshTimer, cloudRefreshBusy = false, cloudRefreshFailures = 0;
|
|
|
13
14
|
Object.assign(state, { profileDevice: null, profileDraft: null, profileError: null, profileSaving: false, collectingDevice: null, pendingCollections: {} });
|
|
14
15
|
Object.assign(state, { installMode: 'local', installOrigin: '', installBackground: true });
|
|
15
16
|
const RELAY_PROVIDERS = [
|
|
16
|
-
{ id: 'self-hosted', name
|
|
17
|
-
{ id: 'managed', name
|
|
18
|
-
{ 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"); } },
|
|
19
20
|
];
|
|
20
21
|
const ICONS = {
|
|
21
22
|
device: '<rect x="4" y="3" width="16" height="13" rx="2"/><path d="M2 20h20M8 16l-1 4m9-4 1 4"/>',
|
|
@@ -33,23 +34,23 @@ const ICONS = {
|
|
|
33
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>`;
|
|
34
35
|
const escapeHtml = (value = '') => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
35
36
|
function formatAgo(time) {
|
|
36
|
-
if (time == null) return
|
|
37
|
+
if (time == null) return t("panel.6");
|
|
37
38
|
const elapsed = Math.max(0, Date.now() - time);
|
|
38
|
-
if (elapsed < 60_000) return
|
|
39
|
-
|
|
40
|
-
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');
|
|
41
41
|
}
|
|
42
42
|
function metricMarkup(device, kind) {
|
|
43
43
|
const memory = kind === 'memory';
|
|
44
44
|
const metric = device[kind];
|
|
45
|
-
const label = memory ?
|
|
45
|
+
const label = memory ? t("panel.8") : t("panel.9");
|
|
46
46
|
const used = memory ? metric.usedMb : metric.usedGb;
|
|
47
47
|
const total = memory ? metric.totalMb : metric.totalGb;
|
|
48
|
-
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`;
|
|
49
50
|
const valid = metric.status === 'ok' && used != null && total != null && total > 0;
|
|
50
51
|
const percent = valid ? Math.min(100, Math.max(0, Math.round(used / total * 100))) : 0;
|
|
51
|
-
const description = device.state === 'expired' ?
|
|
52
|
-
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>`;
|
|
53
54
|
}
|
|
54
55
|
function connectivityState(device) {
|
|
55
56
|
if (state.checkingSshAlias && state.checkingSshAlias === device.ssh?.alias) return 'checking';
|
|
@@ -58,23 +59,23 @@ function connectivityState(device) {
|
|
|
58
59
|
}
|
|
59
60
|
function connectivityMarkup(device) {
|
|
60
61
|
const value = connectivityState(device);
|
|
61
|
-
const label = ({ queued:
|
|
62
|
-
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");
|
|
63
64
|
return `<span class="ssh-connectivity ${escapeHtml(value)}" title="${escapeHtml(detail)}">${escapeHtml(label)}</span>`;
|
|
64
65
|
}
|
|
65
66
|
function deviceCard(device) {
|
|
66
67
|
const local = device.source === 'local-runtime';
|
|
67
|
-
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;
|
|
68
69
|
const route = device.ssh?.route;
|
|
69
|
-
const path = local ?
|
|
70
|
-
const detail = device.transport === 'ssh' ? !device.ssh.configured ?
|
|
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");
|
|
71
72
|
const status = deviceStatus(device), availability = collectionAvailability(device);
|
|
72
73
|
const collecting = state.collectingDevice === device.id || device.transport === 'ssh' && (state.checkingSshAlias === device.ssh?.alias || device.ssh?.checking || device.ssh?.queued);
|
|
73
|
-
const collectButton = `<button class="button button-light ssh-check-button" data-action="collect-device" data-device="${escapeHtml(device.id)}" aria-label="
|
|
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>`;
|
|
74
75
|
const profile = device.profile;
|
|
75
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>` : '';
|
|
76
77
|
const identityHint = device.managementDriver?.fingerprint ? ` · ${device.managementDriver.fingerprint.slice(0, 8)}` : '';
|
|
77
|
-
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="${
|
|
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>`;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
function openDeviceProfile(deviceId) {
|
|
@@ -87,9 +88,9 @@ function openDeviceProfile(deviceId) {
|
|
|
87
88
|
}
|
|
88
89
|
function profileBody() {
|
|
89
90
|
const device = state.overview?.devices.find(d => d.id === state.profileDevice), draft = state.profileDraft;
|
|
90
|
-
if (!device || !draft) return
|
|
91
|
+
if (!device || !draft) return `<p class="modal-intro">${h("panel.50")}</p>`;
|
|
91
92
|
const disabled = state.profileSaving ? 'disabled' : '';
|
|
92
|
-
return `<p class="modal-intro"
|
|
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>`;
|
|
93
94
|
}
|
|
94
95
|
async function saveDeviceProfile() {
|
|
95
96
|
if (state.profileSaving || !state.profileDraft) return;
|
|
@@ -100,9 +101,9 @@ async function saveDeviceProfile() {
|
|
|
100
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) } });
|
|
101
102
|
state.overview = await api.overview();
|
|
102
103
|
if (state.dialog === 'profile' && state.profileDevice === deviceId) state.dialog = null;
|
|
103
|
-
toast(
|
|
104
|
+
toast(t("panel.70"));
|
|
104
105
|
} catch (cause) {
|
|
105
|
-
if (state.dialog === 'profile' && state.profileDevice === deviceId) state.profileError = ({ invalid_device_profile:
|
|
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";
|
|
106
107
|
} finally { state.profileSaving = false; render(); }
|
|
107
108
|
}
|
|
108
109
|
function updateOverview(overview) {
|
|
@@ -111,7 +112,7 @@ function updateOverview(overview) {
|
|
|
111
112
|
const device = overview.devices.find(d => d.id === id), result = device?.collection;
|
|
112
113
|
if (result?.requestedAt === requestedAt && result.status === 'pending') continue;
|
|
113
114
|
delete state.pendingCollections[id];
|
|
114
|
-
toast(result?.requestedAt === requestedAt && result.status === 'completed' ? `${device.name}
|
|
115
|
+
toast(result?.requestedAt === requestedAt && result.status === 'completed' ? `${device.name} ${t("panel.75")}` : t("panel.76"));
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
118
|
async function collectDevice(deviceId) {
|
|
@@ -122,9 +123,9 @@ async function collectDevice(deviceId) {
|
|
|
122
123
|
const result = await api.collectDevice(deviceId);
|
|
123
124
|
if (result.status === 'pending') state.pendingCollections[deviceId] = result.collection.requestedAt;
|
|
124
125
|
updateOverview(await api.overview());
|
|
125
|
-
if (result.status !== 'pending') toast(result.status === 'failed' ? sshErrorText(result.error) : `${device.name}
|
|
126
|
+
if (result.status !== 'pending') toast(result.status === 'failed' ? sshErrorText(result.error) : `${device.name} ${t("panel.77")}`);
|
|
126
127
|
} catch (cause) {
|
|
127
|
-
toast(({ driver_not_connected:
|
|
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"));
|
|
128
129
|
} finally { state.collectingDevice = null; render(); }
|
|
129
130
|
}
|
|
130
131
|
function endpointText(endpoint) {
|
|
@@ -132,16 +133,16 @@ function endpointText(endpoint) {
|
|
|
132
133
|
}
|
|
133
134
|
function currentRouteBody() {
|
|
134
135
|
const device = state.overview?.devices.find((item) => item.id === state.routeDevice);
|
|
135
|
-
if (!device) return
|
|
136
|
+
if (!device) return `<p class="modal-intro">${h("panel.84")}</p>`;
|
|
136
137
|
const local = device.source === 'local-runtime';
|
|
137
|
-
if (local) return `<p class="modal-intro">${
|
|
138
|
-
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>`;
|
|
139
140
|
const route = device.ssh?.route;
|
|
140
|
-
if (!route) return
|
|
141
|
-
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];
|
|
142
143
|
const forwarded = route.hops.length && route.target.addressScope === 'loopback';
|
|
143
|
-
const node = (endpoint, target = false) => `<li><span class="route-node-icon">${icon(target ? 'device' : 'cloud', 16)}</span><div><strong>${target && forwarded ?
|
|
144
|
-
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>`;
|
|
145
146
|
}
|
|
146
147
|
function routeBody() {
|
|
147
148
|
const device = state.overview?.devices.find((item) => item.id === state.routeDevice);
|
|
@@ -150,20 +151,20 @@ function routeBody() {
|
|
|
150
151
|
const plan = device.connections;
|
|
151
152
|
const provider = RELAY_PROVIDERS.find((item) => item.id === draft.relayProvider);
|
|
152
153
|
const disabled = state.connectionSaving ? 'disabled' : '';
|
|
153
|
-
return `<p class="modal-intro"><strong>${escapeHtml(device.name)}</strong>
|
|
154
|
-
<section class="connection-mode"><header><h3>${icon('device', 16)}
|
|
155
|
-
<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>
|
|
156
157
|
</section>
|
|
157
|
-
<section class="connection-mode"><header><h3>${icon('cloud', 16)}
|
|
158
|
-
<div class="relay-options" role="radiogroup" aria-label="
|
|
159
|
-
<p class="provider-description">${provider ? escapeHtml(provider.description) :
|
|
160
|
-
${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>` : ''}
|
|
161
162
|
</section>
|
|
162
|
-
<label class="connection-preference"
|
|
163
|
-
<p class="connection-note"
|
|
164
|
-
${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 ?
|
|
165
|
-
${plan.saved ? `<p class="saved-plan-note"
|
|
166
|
-
<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>`;
|
|
167
168
|
}
|
|
168
169
|
function openConnections(deviceId) {
|
|
169
170
|
const device = state.overview?.devices.find((item) => item.id === deviceId);
|
|
@@ -177,21 +178,21 @@ async function saveConnections() {
|
|
|
177
178
|
try {
|
|
178
179
|
await api.saveConnections({ ...state.connectionDraft });
|
|
179
180
|
state.overview = await api.overview();
|
|
180
|
-
toast(
|
|
181
|
+
toast(t("panel.158"));
|
|
181
182
|
} catch (cause) {
|
|
182
183
|
focusField = cause.message === 'invalid_lan_host' ? 'lan-host' : cause.message === 'invalid_lan_port' ? 'lan-port' : null;
|
|
183
|
-
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";
|
|
184
185
|
} finally { state.connectionSaving = false; render(); (focusField ? root.querySelector(`#${focusField}`) : root.querySelector('[data-action="save-connections"]'))?.focus({ preventScroll: true }); }
|
|
185
186
|
}
|
|
186
187
|
function sshErrorText(code) {
|
|
187
|
-
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");
|
|
188
189
|
}
|
|
189
190
|
function scheduleCheckRefresh() {
|
|
190
191
|
clearTimeout(checkRefreshTimer);
|
|
191
192
|
if (checkRefreshFailures >= 3 || !(state.overview?.devices ?? []).some((device) => device.ssh?.checking || device.ssh?.queued)) return;
|
|
192
193
|
checkRefreshTimer = setTimeout(async () => {
|
|
193
194
|
try { state.overview = await api.overview(); checkRefreshFailures = 0; }
|
|
194
|
-
catch { if (++checkRefreshFailures >= 3) toast(
|
|
195
|
+
catch { if (++checkRefreshFailures >= 3) toast(t("panel.177")); }
|
|
195
196
|
render();
|
|
196
197
|
}, 1000);
|
|
197
198
|
}
|
|
@@ -201,14 +202,14 @@ async function checkSshDevice(alias) {
|
|
|
201
202
|
try {
|
|
202
203
|
const result = await api.checkSsh(alias);
|
|
203
204
|
state.overview = await api.overview();
|
|
204
|
-
toast(result.ok ?
|
|
205
|
+
toast(result.ok ? t("panel.178") : sshErrorText(result.error));
|
|
205
206
|
} catch (cause) { toast(sshErrorText(cause.message)); }
|
|
206
207
|
finally { state.checkingSshAlias = null; render(); }
|
|
207
208
|
}
|
|
208
209
|
function clientLabel() {
|
|
209
|
-
if (!state.status) return
|
|
210
|
-
if (state.status.client.state === 'observed' && !recentMcpInitialize()) return
|
|
211
|
-
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];
|
|
212
213
|
}
|
|
213
214
|
function recentMcpInitialize() {
|
|
214
215
|
const time = state.status?.client.lastInitializeAt;
|
|
@@ -216,14 +217,14 @@ function recentMcpInitialize() {
|
|
|
216
217
|
}
|
|
217
218
|
function renderSidebar() {
|
|
218
219
|
const projects = state.overview?.projects ?? [];
|
|
219
|
-
if (cloudMode()) return `<aside class="sidebar"><a class="brand" href="
|
|
220
|
-
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>`;
|
|
221
222
|
}
|
|
222
223
|
function renderConnection() {
|
|
223
224
|
if (state.loading && !state.overview) return '';
|
|
224
|
-
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>`; }
|
|
225
226
|
const container = state.status?.service.runtime === 'container';
|
|
226
|
-
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>`;
|
|
227
228
|
}
|
|
228
229
|
function managementComputer() { return state.overview?.devices.find((item) => item.source === 'local-runtime'); }
|
|
229
230
|
function openAgentInstall() {
|
|
@@ -233,12 +234,17 @@ function openAgentInstall() {
|
|
|
233
234
|
state.toast = null; state.dialog = 'agent-install';
|
|
234
235
|
}
|
|
235
236
|
function installDraft() {
|
|
236
|
-
try { return { text: agentInstallPrompt({ mode: state.installMode, background: state.installBackground, ...(state.installMode === 'self-hosted' ? { origin: state.installOrigin.trim() } : {}) }), error: null }; }
|
|
237
|
-
catch (error) { return { text: '', error: error.
|
|
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 manualInstallHref(draft) {
|
|
241
|
+
const manualParams = new URLSearchParams({ lang: getLocale(), method: 'manual', mode: state.installMode });
|
|
242
|
+
if (state.installMode === 'self-hosted' && !draft.error) manualParams.set('origin', state.installOrigin.trim());
|
|
243
|
+
return `/start?${manualParams}#install-title`;
|
|
238
244
|
}
|
|
239
245
|
function agentInstallBody() {
|
|
240
246
|
const draft = installDraft();
|
|
241
|
-
return `<p class="modal-intro"
|
|
247
|
+
return `<p class="modal-intro">${h("panel.219")}</p><p class="small-note"><a data-manual-install href="${escapeHtml(manualInstallHref(draft))}">${h('guide.manual.openGuide')}</a></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>`;
|
|
242
248
|
}
|
|
243
249
|
async function copyAgentInstall() {
|
|
244
250
|
const field = root.querySelector('#agent-install-text');
|
|
@@ -249,23 +255,23 @@ async function copyAgentInstall() {
|
|
|
249
255
|
root.querySelector('.install-details').open = true; field.focus(); field.select();
|
|
250
256
|
try { copied = document.execCommand('copy'); } catch { /* manual selection remains available */ }
|
|
251
257
|
}
|
|
252
|
-
toast(copied ?
|
|
258
|
+
toast(copied ? t("panel.228") : t("panel.229"));
|
|
253
259
|
if (!copied) { root.querySelector('.install-details').open = true; root.querySelector('#agent-install-text')?.focus(); root.querySelector('#agent-install-text')?.select(); }
|
|
254
260
|
}
|
|
255
261
|
function cloudMode() { return state.overview?.mode === 'cloud' || state.status?.service.runtime === 'cloud'; }
|
|
256
262
|
function managementReady() { return state.status?.service.runtime === 'native' && managementComputer()?.scope === 'host'; }
|
|
257
263
|
function managementBody() {
|
|
258
264
|
const device = managementComputer();
|
|
259
|
-
if (!state.status || !device) return
|
|
265
|
+
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>`;
|
|
260
266
|
const ready = managementReady();
|
|
261
|
-
return `<p class="modal-intro"
|
|
267
|
+
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>`;
|
|
262
268
|
}
|
|
263
269
|
function onboardingBody() {
|
|
264
270
|
const ready = managementReady();
|
|
265
271
|
const seen = recentMcpInitialize();
|
|
266
|
-
const steps = [
|
|
272
|
+
const steps = [t("panel.249"), t("panel.250"), t("panel.251")];
|
|
267
273
|
const body = state.setupStep === 0 ? managementBody() : state.setupStep === 1 ? connectionBody() : initializationBody();
|
|
268
|
-
return `<nav class="onboarding-steps" aria-label="
|
|
274
|
+
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>`;
|
|
269
275
|
}
|
|
270
276
|
function openSetup(entry = 'start', step) {
|
|
271
277
|
state.setupReturn = entry; state.toast = null; state.checkMessage = null;
|
|
@@ -280,12 +286,12 @@ function selectSetupStep(step) {
|
|
|
280
286
|
}
|
|
281
287
|
function connectionBody() {
|
|
282
288
|
const terminal = state.method === 'terminal';
|
|
283
|
-
return `<p class="modal-intro"
|
|
289
|
+
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>`;
|
|
284
290
|
}
|
|
285
291
|
function addBody() {
|
|
286
292
|
if (cloudMode()) return cloudBindingBody();
|
|
287
293
|
const candidates = state.ssh?.candidates ?? [];
|
|
288
|
-
return `<p class="modal-intro"
|
|
294
|
+
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>`;
|
|
289
295
|
}
|
|
290
296
|
function driverCommand() {
|
|
291
297
|
return connectCommand(state.overview?.controlOrigin ?? window.location.origin, state.requestSsh);
|
|
@@ -294,46 +300,46 @@ function cloudConnectDraft() {
|
|
|
294
300
|
const official = state.cloudChoice === 'official';
|
|
295
301
|
let command = '', error = null;
|
|
296
302
|
try { command = connectCommand(official ? OFFICIAL_CONTROL_ORIGIN : state.customOrigin.trim()); }
|
|
297
|
-
catch (cause) { if (state.customOrigin.trim()) error = cause.
|
|
303
|
+
catch (cause) { if (state.customOrigin.trim()) error = t(cause.translationKey ?? 'error.origin'); }
|
|
298
304
|
return { command, error };
|
|
299
305
|
}
|
|
300
306
|
function cloudConnectBody() {
|
|
301
307
|
const official = state.cloudChoice === 'official';
|
|
302
308
|
const { command, error } = cloudConnectDraft();
|
|
303
|
-
return `<p class="modal-intro"
|
|
309
|
+
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>`;
|
|
304
310
|
}
|
|
305
311
|
function logCommandDraft() {
|
|
306
312
|
const peers = cloudMode() ? state.overview?.drivers?.filter(p=>p.revokedAt == null) ?? [] : state.remotePeers;
|
|
307
313
|
const manager = peers.find(p=>p.id === state.logManager), target = peers.find(p=>p.id === state.logTarget);
|
|
308
314
|
try { return { ...logAccessCommands({manager:manager?.fingerprint,target:target?.fingerprint,path:state.logPath,label:state.logLabel}), error:null }; }
|
|
309
|
-
catch(error) { return {trust:'',grant:'',error:error.
|
|
315
|
+
catch(error) { return {trust:'',grant:'',error:t(error.translationKey ?? 'error.devices')}; }
|
|
310
316
|
}
|
|
311
317
|
function logCommandFields() {
|
|
312
318
|
const peers = cloudMode() ? state.overview?.drivers?.filter(p=>p.revokedAt == null) ?? [] : state.remotePeers;
|
|
313
|
-
if (state.remoteLoading) return
|
|
314
|
-
if (state.remoteError) return
|
|
315
|
-
if (peers.length < 2) return
|
|
316
|
-
const options = selected =>
|
|
319
|
+
if (state.remoteLoading) return `<p class="small-note">${h("panel.314")}</p>`;
|
|
320
|
+
if (state.remoteError) return `<p class="form-error">${h("panel.315")}</p>`;
|
|
321
|
+
if (peers.length < 2) return `<p class="small-note">${h("panel.316")}</p>`;
|
|
322
|
+
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('');
|
|
317
323
|
const draft=logCommandDraft();
|
|
318
|
-
return `<div class="plain-panel"><h3
|
|
324
|
+
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>`;
|
|
319
325
|
}
|
|
320
326
|
function secureHelpBody() {
|
|
321
|
-
return `<p class="modal-intro"
|
|
327
|
+
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.4</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>`;
|
|
322
328
|
}
|
|
323
329
|
function cloudBindingBody() {
|
|
324
330
|
const pairing = state.pairing, disabled = state.pairBusy ? 'disabled' : '';
|
|
325
331
|
const sourceCommand = driverCommand().replace(/^npx --yes device-center@\S+/, 'npm run driver --');
|
|
326
332
|
const bindings = state.overview?.drivers ?? [];
|
|
327
|
-
return `<p class="modal-intro"
|
|
328
|
-
<section class="binding-step"><h3><span>1</span>
|
|
329
|
-
<section class="binding-step"><h3><span>2</span>
|
|
330
|
-
<
|
|
331
|
-
${pairing ? `<div class="pair-review"><strong>${escapeHtml(pairing.name)}</strong><p>${escapeHtml(pairing.platform)}
|
|
332
|
-
${bindings.length ? `<details class="binding-management" open><summary
|
|
333
|
-
<p class="small-note">${state.overview?.accountMode === 'multi-user' ?
|
|
333
|
+
return `<p class="modal-intro">${h("panel.365")}</p>
|
|
334
|
+
<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>
|
|
335
|
+
<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("pair.browserHint")}</p></section>
|
|
336
|
+
<details class="binding-step" ${pairing || state.pairError ? 'open' : ''}><summary>${h("pair.manual")}</summary><p>${h("pair.manualHint")}</p><textarea id="manual-driver-command" readonly rows="3" spellcheck="false" aria-label="${h("panel.375")}">${escapeHtml(driverCommand().replace(' --browser', ''))}</textarea><button class="button button-light" data-action="driver-manual-copy">${h("panel.309")}</button><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>` : ''}
|
|
337
|
+
${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>`}</details>
|
|
338
|
+
${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>` : ''}
|
|
339
|
+
<p class="small-note">${state.overview?.accountMode === 'multi-user' ? t("panel.363") : t("panel.364")}${h("panel.379")}</p>`;
|
|
334
340
|
}
|
|
335
341
|
function pairErrorText(code) {
|
|
336
|
-
return ({ invalid_pairing_code:
|
|
342
|
+
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";
|
|
337
343
|
}
|
|
338
344
|
async function pairAction(action, id) {
|
|
339
345
|
if (state.pairBusy) return;
|
|
@@ -343,54 +349,56 @@ async function pairAction(action, id) {
|
|
|
343
349
|
else if (action === 'pair-approve' && state.pairing) {
|
|
344
350
|
await api.approvePairing(state.pairing.id, Boolean(state.pairing.requestedSsh && state.pairShare), state.pairing.code, state.pairing.fingerprint);
|
|
345
351
|
state.pairing = null; state.pairCode = ''; state.pairShare = false;
|
|
346
|
-
state.toast =
|
|
352
|
+
state.toast = t("panel.388");
|
|
347
353
|
state.overview = await api.overview();
|
|
348
|
-
} else if (action === 'revoke-confirm' && id === state.revokeId) { await api.revokeDriver(id); state.revokeId = null; state.overview = await api.overview(); state.toast =
|
|
354
|
+
} 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"); }
|
|
349
355
|
} catch (error) { state.pairError = pairErrorText(error.message); }
|
|
350
356
|
finally { state.pairBusy = false; render(); }
|
|
351
357
|
}
|
|
352
|
-
async function copyDriverCommand() {
|
|
353
|
-
const
|
|
358
|
+
async function copyDriverCommand(manual = false) {
|
|
359
|
+
const selector = manual ? '#manual-driver-command' : state.dialog === 'cloud-connect' ? '#cloud-connect-command' : '#driver-command';
|
|
360
|
+
const field = root.querySelector(selector); if (!field || !field.value) return;
|
|
354
361
|
field.focus(); field.select(); let copied = false;
|
|
355
362
|
try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(field.value); copied = true; } } catch { /* manual selection remains available */ }
|
|
356
363
|
if (!copied) try { copied = document.execCommand('copy'); } catch { /* manual selection */ }
|
|
357
|
-
toast(copied ?
|
|
358
|
-
if (!copied) { const current = root.querySelector(
|
|
364
|
+
toast(copied ? t("panel.390") : t("panel.391"));
|
|
365
|
+
if (!copied) { const current = root.querySelector(selector); current?.focus(); current?.select(); }
|
|
359
366
|
}
|
|
360
367
|
function initializationBody() {
|
|
361
|
-
if (state.sshLoading) return
|
|
368
|
+
if (state.sshLoading) return `<p class="modal-intro">${h("panel.392")}</p>`;
|
|
362
369
|
const candidates = state.ssh?.candidates ?? [];
|
|
363
370
|
const ready = candidates.filter((item) => item.importable);
|
|
364
371
|
const pending = ready.filter((item) => !item.associated);
|
|
365
372
|
const skipped = candidates.filter((item) => !item.importable);
|
|
366
|
-
const reason = (item) => ({ host_driver_required:
|
|
367
|
-
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' ?
|
|
368
|
-
return `<p class="modal-intro"
|
|
373
|
+
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");
|
|
374
|
+
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('');
|
|
375
|
+
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>` : ''}`;
|
|
369
376
|
}
|
|
370
377
|
function residentBody() {
|
|
371
378
|
const port = new URL(state.setup?.url ?? location.href).port;
|
|
372
379
|
const portOption = port ? ` --port ${port}` : "";
|
|
373
|
-
return `<p class="modal-intro"
|
|
380
|
+
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>`;
|
|
374
381
|
}
|
|
375
382
|
function renderDialog() {
|
|
376
383
|
if (!state.dialog) return '';
|
|
377
|
-
const title = { 'agent-install':
|
|
384
|
+
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];
|
|
378
385
|
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();
|
|
379
|
-
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
|
|
386
|
+
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>`;
|
|
380
387
|
}
|
|
381
388
|
function filteredDevices() {
|
|
382
389
|
const needle = state.query.trim().toLocaleLowerCase();
|
|
383
390
|
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)));
|
|
384
391
|
}
|
|
385
392
|
function renderDevices() {
|
|
386
|
-
if (state.loading) return
|
|
387
|
-
if (state.error) return `<div class="empty-state"><h3
|
|
393
|
+
if (state.loading) return `<div class="empty-state"><p>${h("panel.440")}</p></div>`;
|
|
394
|
+
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>`;
|
|
388
395
|
const devices = filteredDevices();
|
|
389
396
|
if (devices.length) return devices.map(deviceCard).join('');
|
|
390
397
|
const any = state.overview?.devices.length;
|
|
391
|
-
return `<div class="empty-state"><span class="empty-icon">${icon('device', 26)}</span><h3>${any ?
|
|
398
|
+
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>`;
|
|
392
399
|
}
|
|
393
400
|
function render() {
|
|
401
|
+
document.title = "Device Center · " + t(cloudMode() ? "panel.184" : "panel.194");
|
|
394
402
|
clearTimeout(snapshotExpiryTimer);
|
|
395
403
|
scheduleCheckRefresh();
|
|
396
404
|
scheduleCloudRefresh();
|
|
@@ -406,31 +414,32 @@ function render() {
|
|
|
406
414
|
const dialogScroll = oldDialog?.scrollTop ?? 0;
|
|
407
415
|
const listScroll = root.querySelector('.device-grid')?.scrollTop ?? 0;
|
|
408
416
|
const focused = document.activeElement;
|
|
417
|
+
const languageFocused = Boolean(focused?.closest('[data-language-picker]'));
|
|
409
418
|
const focusAction = focused?.dataset.action;
|
|
410
419
|
const focusId = oldDialog?.contains(focused) ? focused.id : null;
|
|
411
420
|
const focusField = focused?.dataset.field;
|
|
412
421
|
const focusQualifier = ['method', 'copy', 'filter', 'project', 'alias', 'device', 'provider', 'step', 'mode']
|
|
413
422
|
.filter((key) => focused?.dataset[key])
|
|
414
423
|
.map((key) => `[data-${key}="${CSS.escape(focused.dataset[key])}"]`).join('');
|
|
415
|
-
const findFocus = (container) => focusId
|
|
424
|
+
const findFocus = (container) => languageFocused ? container.querySelector('[data-language-trigger]') : focusId
|
|
416
425
|
? container.querySelector(`#${CSS.escape(focusId)}`)
|
|
417
426
|
: focusAction ? container.querySelector(`[data-action="${CSS.escape(focusAction)}"]${focusQualifier}:not(:disabled)`)
|
|
418
427
|
: focusField ? container.querySelector(`[data-field="${CSS.escape(focusField)}"]${focusQualifier}:not(:disabled)`) : null;
|
|
419
428
|
const project = (state.overview?.projects ?? []).find((p) => p.id === state.project);
|
|
420
429
|
const counts = state.overview?.stats ?? { total: 0, online: 0, unknown: 0, expired: 0 };
|
|
421
430
|
root.innerHTML = `<div class="app-shell">${renderSidebar()}<div class="main-area">
|
|
422
|
-
<header class="topbar"><span>${cloudMode() ?
|
|
431
|
+
<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>
|
|
423
432
|
<main class="workspace">
|
|
424
|
-
<section class="page-heading"><div><h1>${escapeHtml(project?.name ??
|
|
433
|
+
<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>
|
|
425
434
|
${state.error ? '' : renderConnection()}
|
|
426
|
-
<section class="list-section" aria-label="
|
|
427
|
-
<div class="list-toolbar"><div class="filter-tabs" role="group" aria-label="
|
|
428
|
-
<div class="list-controls">${counts.total ? `<input class="search-input" type="search" data-field="search" placeholder="
|
|
435
|
+
<section class="list-section" aria-label="${h("panel.468")}">
|
|
436
|
+
<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>
|
|
437
|
+
<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>
|
|
429
438
|
</div>
|
|
430
|
-
<div class="device-grid" role="region" aria-label="
|
|
431
|
-
<p class="list-footnote">${cloudMode() ?
|
|
439
|
+
<div class="device-grid" role="region" aria-label="${h("panel.472")}" tabindex="0">${renderDevices()}</div>
|
|
440
|
+
<p class="list-footnote">${cloudMode() ? t("panel.460") : t("panel.461")}</p>
|
|
432
441
|
</section>
|
|
433
|
-
<footer class="page-footer"><span>${cloudMode() && state.overview?.relayUsage ?
|
|
442
|
+
<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>
|
|
434
443
|
</main>
|
|
435
444
|
</div>${renderDialog()}${state.toast && !state.dialog ? `<div class="toast page-toast" role="status">${escapeHtml(state.toast)}</div>` : ''}</div>`;
|
|
436
445
|
const dialog = root.querySelector('[role="dialog"]');
|
|
@@ -440,7 +449,8 @@ function render() {
|
|
|
440
449
|
root.querySelector('.main-area').inert = Boolean(dialog);
|
|
441
450
|
if (dialog) {
|
|
442
451
|
dialog.scrollTop = dialogScroll;
|
|
443
|
-
|
|
452
|
+
const initialFocus = dialog.querySelector('button[data-action="close"]') ?? dialog.querySelector('button');
|
|
453
|
+
(oldDialog ? findFocus(dialog) ?? initialFocus : initialFocus)?.focus({ preventScroll: true });
|
|
444
454
|
} 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 });
|
|
445
455
|
else findFocus(root)?.focus({ preventScroll: true });
|
|
446
456
|
if (dialog) dialog.dataset.kind = state.dialog;
|
|
@@ -453,7 +463,7 @@ function scheduleCloudRefresh() {
|
|
|
453
463
|
if (state.pairBusy || cloudRefreshBusy) { scheduleCloudRefresh(); return; }
|
|
454
464
|
cloudRefreshBusy = true;
|
|
455
465
|
try { updateOverview(await api.overview()); state.cloudRefreshError = null; cloudRefreshFailures = 0; }
|
|
456
|
-
catch { cloudRefreshFailures++; state.cloudRefreshError = cloudRefreshFailures >= 3 ?
|
|
466
|
+
catch { cloudRefreshFailures++; state.cloudRefreshError = cloudRefreshFailures >= 3 ? "panel.475" : "panel.476"; }
|
|
457
467
|
finally { cloudRefreshBusy = false; render(); }
|
|
458
468
|
}, Object.keys(state.pendingCollections).length ? 3000 : 15_000);
|
|
459
469
|
}
|
|
@@ -474,7 +484,7 @@ async function loadSsh() {
|
|
|
474
484
|
try {
|
|
475
485
|
state.ssh = await (state.dialog === 'initialize' || state.dialog === 'setup' ? api.sshInitialization() : api.sshCandidates());
|
|
476
486
|
state.selectedAliases = state.selectedAliases.filter((alias) => state.ssh.candidates.some((item) => item.alias === alias && !item.associated));
|
|
477
|
-
} catch { state.sshError =
|
|
487
|
+
} catch { state.sshError = "panel.477"; }
|
|
478
488
|
finally { state.sshLoading = false; render(); }
|
|
479
489
|
}
|
|
480
490
|
async function associateSsh() {
|
|
@@ -485,8 +495,8 @@ async function associateSsh() {
|
|
|
485
495
|
state.overview = await api.overview();
|
|
486
496
|
state.filter = 'all'; state.query = ''; state.project = 'all'; state.selectedAliases = []; state.dialog = null;
|
|
487
497
|
checkRefreshFailures = 0;
|
|
488
|
-
toast(result.checks.aliases.length ?
|
|
489
|
-
} catch (cause) { state.sshError = cause.message === 'ssh_alias_unavailable' ?
|
|
498
|
+
toast(result.checks.aliases.length ? t("count.associated", { count: result.added }) : t("panel.479"));
|
|
499
|
+
} catch (cause) { state.sshError = cause.message === 'ssh_alias_unavailable' ? "panel.480" : "panel.481"; }
|
|
490
500
|
finally { state.saving = false; render(); }
|
|
491
501
|
}
|
|
492
502
|
async function initializeSsh() {
|
|
@@ -499,8 +509,8 @@ async function initializeSsh() {
|
|
|
499
509
|
state.ssh = await api.sshInitialization();
|
|
500
510
|
state.filter = 'all'; state.query = ''; state.project = 'all';
|
|
501
511
|
checkRefreshFailures = 0;
|
|
502
|
-
toast(result.checks.aliases.length ?
|
|
503
|
-
} catch (cause) { state.sshError = cause.message === 'ssh_import_needs_refresh' ?
|
|
512
|
+
toast(result.checks.aliases.length ? t("count.imported", { count: result.added }) : t("panel.483"));
|
|
513
|
+
} catch (cause) { state.sshError = cause.message === 'ssh_import_needs_refresh' ? "panel.484" : "panel.485"; }
|
|
504
514
|
finally { state.saving = false; render(); root.querySelector('[data-action="ssh-refresh"]')?.focus({ preventScroll: true }); }
|
|
505
515
|
}
|
|
506
516
|
let toastTimer;
|
|
@@ -511,7 +521,7 @@ function toast(message) {
|
|
|
511
521
|
async function copyField(kind) {
|
|
512
522
|
const text = kind === 'command' ? state.setup?.setupCommand : state.setup?.url;
|
|
513
523
|
const field = root.querySelector('#copy-field');
|
|
514
|
-
if (!text || !field) return toast(
|
|
524
|
+
if (!text || !field) return toast(t("panel.486"));
|
|
515
525
|
let copied = false;
|
|
516
526
|
let timer;
|
|
517
527
|
if (navigator.clipboard?.writeText) {
|
|
@@ -519,7 +529,7 @@ async function copyField(kind) {
|
|
|
519
529
|
catch { /* retain manual selection */ } finally { clearTimeout(timer); }
|
|
520
530
|
}
|
|
521
531
|
if (!copied) try { copied = document.execCommand('copy'); } catch { /* retain manual selection */ }
|
|
522
|
-
toast(copied ?
|
|
532
|
+
toast(copied ? t("copy.codex") : t("panel.490"));
|
|
523
533
|
if (!copied) { root.querySelector('#copy-field')?.focus(); root.querySelector('#copy-field')?.select(); }
|
|
524
534
|
}
|
|
525
535
|
root.addEventListener('click', async (event) => {
|
|
@@ -529,11 +539,11 @@ root.addEventListener('click', async (event) => {
|
|
|
529
539
|
if (action === 'agent-install') { openAgentInstall(); render(); return; }
|
|
530
540
|
if (action === 'install-mode') { state.installMode = button.dataset.mode; state.toast = null; render(); return; }
|
|
531
541
|
if (action === 'agent-install-copy') return copyAgentInstall();
|
|
532
|
-
if (action === 'logout') { try { await api.logout(); location.replace('/login'); } catch { state.toast =
|
|
542
|
+
if (action === 'logout') { try { await api.logout(); location.replace('/login?lang=' + getLocale()); } catch { state.toast = t("panel.491"); render(); } return; }
|
|
533
543
|
if (cloudMode() && action === 'add') { state.dialog = 'add'; state.toast = null; state.pairError = null; state.revokeId = null; render(); return; }
|
|
534
|
-
if (['driver-copy', 'cloud-command-copy'].includes(action)) return copyDriverCommand();
|
|
544
|
+
if (['driver-copy', 'cloud-command-copy', 'driver-manual-copy'].includes(action)) return copyDriverCommand(action === 'driver-manual-copy');
|
|
535
545
|
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; }
|
|
536
|
-
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(
|
|
546
|
+
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; }
|
|
537
547
|
if (action === 'cloud-connect') { state.dialog = 'cloud-connect'; state.toast = null; render(); return; }
|
|
538
548
|
if (action === 'cloud-choice') { state.cloudChoice = button.dataset.provider; state.toast = null; render(); return; }
|
|
539
549
|
if (['pair-lookup', 'pair-approve', 'revoke-confirm'].includes(action)) return pairAction(action, button.dataset.device);
|
|
@@ -564,8 +574,8 @@ root.addEventListener('click', async (event) => {
|
|
|
564
574
|
state.checking = true; state.checkMessage = null; render();
|
|
565
575
|
try {
|
|
566
576
|
state.status = await api.connectorStatus();
|
|
567
|
-
state.checkMessage = state.status.client.state === 'observed' ?
|
|
568
|
-
} catch { state.checkMessage =
|
|
577
|
+
state.checkMessage = state.status.client.state === 'observed' ? "panel.494" : state.status.client.state === 'stale' ? "panel.495" : "panel.496";
|
|
578
|
+
} catch { state.checkMessage = "panel.497"; }
|
|
569
579
|
finally { state.checking = false; }
|
|
570
580
|
render();
|
|
571
581
|
root.querySelector('[data-action="check"]')?.focus({ preventScroll: true });
|
|
@@ -590,6 +600,7 @@ root.addEventListener('input', (event) => {
|
|
|
590
600
|
root.querySelector('#agent-install-text').value = draft.text;
|
|
591
601
|
const message = root.querySelector('#install-error'); message.textContent = draft.error ?? ''; message.hidden = !draft.error;
|
|
592
602
|
root.querySelector('[data-action="agent-install-copy"]').disabled = Boolean(draft.error);
|
|
603
|
+
root.querySelector('[data-manual-install]').href = manualInstallHref(draft);
|
|
593
604
|
return;
|
|
594
605
|
}
|
|
595
606
|
if (event.target.dataset.profileField && state.profileDraft) { state.profileDraft[event.target.dataset.profileField] = event.target.value; return; }
|
|
@@ -624,7 +635,6 @@ root.addEventListener('keydown', (event) => {
|
|
|
624
635
|
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
|
625
636
|
});
|
|
626
637
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) { cloudRefreshFailures = 0; scheduleCloudRefresh(); } else clearTimeout(cloudRefreshTimer); });
|
|
627
|
-
load();
|
|
628
638
|
|
|
629
639
|
root.addEventListener('input', event => {
|
|
630
640
|
const field=event.target.dataset.logField;
|
|
@@ -633,6 +643,9 @@ root.addEventListener('input', event => {
|
|
|
633
643
|
const draft=logCommandDraft();
|
|
634
644
|
const trust=root.querySelector('#log-trust-command'),grant=root.querySelector('#log-grant-command');
|
|
635
645
|
if(trust)trust.value=draft.trust;if(grant)grant.value=draft.grant;
|
|
636
|
-
const error=root.querySelector('#log-command-error');if(error)error.textContent=draft.error??
|
|
646
|
+
const error=root.querySelector('#log-command-error');if(error)error.textContent=draft.error??t("panel.318");
|
|
637
647
|
for(const button of root.querySelectorAll('[data-action="log-command-copy"]'))button.disabled=Boolean(draft.error);
|
|
638
648
|
});
|
|
649
|
+
|
|
650
|
+
initLanguage({ onChange: () => { state.toast = null; render(); } });
|
|
651
|
+
load();
|