device-center 0.2.0-beta.2 → 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/README.md +8 -4
- package/docs/agent-install.md +2 -2
- package/docs/friend-trial.md +2 -2
- package/docs/hosted-access.md +1 -1
- package/docs/i18n.md +32 -0
- package/docs/npm.md +7 -7
- package/docs/onboarding.md +1 -1
- package/docs/remote-access.md +2 -2
- package/docs/roadmap.md +6 -0
- package/index.html +1 -0
- package/login.html +2 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +3 -2
- package/server/index.mjs +2 -2
- package/src/api.js +2 -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/login.js +14 -11
- package/src/main.js +126 -120
- package/src/messages.js +5880 -0
- package/src/onboarding.js +14 -9
- package/src/start.js +22 -6
- package/src/styles.css +1 -1
- package/start.html +23 -22
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,12 @@ 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') }; }
|
|
238
239
|
}
|
|
239
240
|
function agentInstallBody() {
|
|
240
241
|
const draft = installDraft();
|
|
241
|
-
return `<p class="modal-intro"
|
|
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>`;
|
|
242
243
|
}
|
|
243
244
|
async function copyAgentInstall() {
|
|
244
245
|
const field = root.querySelector('#agent-install-text');
|
|
@@ -249,23 +250,23 @@ async function copyAgentInstall() {
|
|
|
249
250
|
root.querySelector('.install-details').open = true; field.focus(); field.select();
|
|
250
251
|
try { copied = document.execCommand('copy'); } catch { /* manual selection remains available */ }
|
|
251
252
|
}
|
|
252
|
-
toast(copied ?
|
|
253
|
+
toast(copied ? t("panel.228") : t("panel.229"));
|
|
253
254
|
if (!copied) { root.querySelector('.install-details').open = true; root.querySelector('#agent-install-text')?.focus(); root.querySelector('#agent-install-text')?.select(); }
|
|
254
255
|
}
|
|
255
256
|
function cloudMode() { return state.overview?.mode === 'cloud' || state.status?.service.runtime === 'cloud'; }
|
|
256
257
|
function managementReady() { return state.status?.service.runtime === 'native' && managementComputer()?.scope === 'host'; }
|
|
257
258
|
function managementBody() {
|
|
258
259
|
const device = managementComputer();
|
|
259
|
-
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>`;
|
|
260
261
|
const ready = managementReady();
|
|
261
|
-
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>`;
|
|
262
263
|
}
|
|
263
264
|
function onboardingBody() {
|
|
264
265
|
const ready = managementReady();
|
|
265
266
|
const seen = recentMcpInitialize();
|
|
266
|
-
const steps = [
|
|
267
|
+
const steps = [t("panel.249"), t("panel.250"), t("panel.251")];
|
|
267
268
|
const body = state.setupStep === 0 ? managementBody() : state.setupStep === 1 ? connectionBody() : initializationBody();
|
|
268
|
-
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>`;
|
|
269
270
|
}
|
|
270
271
|
function openSetup(entry = 'start', step) {
|
|
271
272
|
state.setupReturn = entry; state.toast = null; state.checkMessage = null;
|
|
@@ -280,12 +281,12 @@ function selectSetupStep(step) {
|
|
|
280
281
|
}
|
|
281
282
|
function connectionBody() {
|
|
282
283
|
const terminal = state.method === 'terminal';
|
|
283
|
-
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>`;
|
|
284
285
|
}
|
|
285
286
|
function addBody() {
|
|
286
287
|
if (cloudMode()) return cloudBindingBody();
|
|
287
288
|
const candidates = state.ssh?.candidates ?? [];
|
|
288
|
-
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>`;
|
|
289
290
|
}
|
|
290
291
|
function driverCommand() {
|
|
291
292
|
return connectCommand(state.overview?.controlOrigin ?? window.location.origin, state.requestSsh);
|
|
@@ -294,46 +295,46 @@ function cloudConnectDraft() {
|
|
|
294
295
|
const official = state.cloudChoice === 'official';
|
|
295
296
|
let command = '', error = null;
|
|
296
297
|
try { command = connectCommand(official ? OFFICIAL_CONTROL_ORIGIN : state.customOrigin.trim()); }
|
|
297
|
-
catch (cause) { if (state.customOrigin.trim()) error = cause.
|
|
298
|
+
catch (cause) { if (state.customOrigin.trim()) error = t(cause.translationKey ?? 'error.origin'); }
|
|
298
299
|
return { command, error };
|
|
299
300
|
}
|
|
300
301
|
function cloudConnectBody() {
|
|
301
302
|
const official = state.cloudChoice === 'official';
|
|
302
303
|
const { command, error } = cloudConnectDraft();
|
|
303
|
-
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>`;
|
|
304
305
|
}
|
|
305
306
|
function logCommandDraft() {
|
|
306
307
|
const peers = cloudMode() ? state.overview?.drivers?.filter(p=>p.revokedAt == null) ?? [] : state.remotePeers;
|
|
307
308
|
const manager = peers.find(p=>p.id === state.logManager), target = peers.find(p=>p.id === state.logTarget);
|
|
308
309
|
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.
|
|
310
|
+
catch(error) { return {trust:'',grant:'',error:t(error.translationKey ?? 'error.devices')}; }
|
|
310
311
|
}
|
|
311
312
|
function logCommandFields() {
|
|
312
313
|
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 =>
|
|
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('');
|
|
317
318
|
const draft=logCommandDraft();
|
|
318
|
-
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>`;
|
|
319
320
|
}
|
|
320
321
|
function secureHelpBody() {
|
|
321
|
-
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>`;
|
|
322
323
|
}
|
|
323
324
|
function cloudBindingBody() {
|
|
324
325
|
const pairing = state.pairing, disabled = state.pairBusy ? 'disabled' : '';
|
|
325
326
|
const sourceCommand = driverCommand().replace(/^npx --yes device-center@\S+/, 'npm run driver --');
|
|
326
327
|
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
|
-
<section class="binding-step"><h3><span>3</span>
|
|
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' ?
|
|
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>`;
|
|
334
335
|
}
|
|
335
336
|
function pairErrorText(code) {
|
|
336
|
-
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";
|
|
337
338
|
}
|
|
338
339
|
async function pairAction(action, id) {
|
|
339
340
|
if (state.pairBusy) return;
|
|
@@ -343,9 +344,9 @@ async function pairAction(action, id) {
|
|
|
343
344
|
else if (action === 'pair-approve' && state.pairing) {
|
|
344
345
|
await api.approvePairing(state.pairing.id, Boolean(state.pairing.requestedSsh && state.pairShare), state.pairing.code, state.pairing.fingerprint);
|
|
345
346
|
state.pairing = null; state.pairCode = ''; state.pairShare = false;
|
|
346
|
-
state.toast =
|
|
347
|
+
state.toast = t("panel.388");
|
|
347
348
|
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 =
|
|
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"); }
|
|
349
350
|
} catch (error) { state.pairError = pairErrorText(error.message); }
|
|
350
351
|
finally { state.pairBusy = false; render(); }
|
|
351
352
|
}
|
|
@@ -354,43 +355,44 @@ async function copyDriverCommand() {
|
|
|
354
355
|
field.focus(); field.select(); let copied = false;
|
|
355
356
|
try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(field.value); copied = true; } } catch { /* manual selection remains available */ }
|
|
356
357
|
if (!copied) try { copied = document.execCommand('copy'); } catch { /* manual selection */ }
|
|
357
|
-
toast(copied ?
|
|
358
|
+
toast(copied ? t("panel.390") : t("panel.391"));
|
|
358
359
|
if (!copied) { const current = root.querySelector(state.dialog === 'cloud-connect' ? '#cloud-connect-command' : '#driver-command'); current?.focus(); current?.select(); }
|
|
359
360
|
}
|
|
360
361
|
function initializationBody() {
|
|
361
|
-
if (state.sshLoading) return
|
|
362
|
+
if (state.sshLoading) return `<p class="modal-intro">${h("panel.392")}</p>`;
|
|
362
363
|
const candidates = state.ssh?.candidates ?? [];
|
|
363
364
|
const ready = candidates.filter((item) => item.importable);
|
|
364
365
|
const pending = ready.filter((item) => !item.associated);
|
|
365
366
|
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"
|
|
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>` : ''}`;
|
|
369
370
|
}
|
|
370
371
|
function residentBody() {
|
|
371
372
|
const port = new URL(state.setup?.url ?? location.href).port;
|
|
372
373
|
const portOption = port ? ` --port ${port}` : "";
|
|
373
|
-
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>`;
|
|
374
375
|
}
|
|
375
376
|
function renderDialog() {
|
|
376
377
|
if (!state.dialog) return '';
|
|
377
|
-
const title = { 'agent-install':
|
|
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];
|
|
378
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();
|
|
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
|
|
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>`;
|
|
380
381
|
}
|
|
381
382
|
function filteredDevices() {
|
|
382
383
|
const needle = state.query.trim().toLocaleLowerCase();
|
|
383
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)));
|
|
384
385
|
}
|
|
385
386
|
function renderDevices() {
|
|
386
|
-
if (state.loading) return
|
|
387
|
-
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>`;
|
|
388
389
|
const devices = filteredDevices();
|
|
389
390
|
if (devices.length) return devices.map(deviceCard).join('');
|
|
390
391
|
const any = state.overview?.devices.length;
|
|
391
|
-
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>`;
|
|
392
393
|
}
|
|
393
394
|
function render() {
|
|
395
|
+
document.title = "Device Center · " + t(cloudMode() ? "panel.184" : "panel.194");
|
|
394
396
|
clearTimeout(snapshotExpiryTimer);
|
|
395
397
|
scheduleCheckRefresh();
|
|
396
398
|
scheduleCloudRefresh();
|
|
@@ -406,31 +408,32 @@ function render() {
|
|
|
406
408
|
const dialogScroll = oldDialog?.scrollTop ?? 0;
|
|
407
409
|
const listScroll = root.querySelector('.device-grid')?.scrollTop ?? 0;
|
|
408
410
|
const focused = document.activeElement;
|
|
411
|
+
const languageFocused = Boolean(focused?.closest('[data-language-picker]'));
|
|
409
412
|
const focusAction = focused?.dataset.action;
|
|
410
413
|
const focusId = oldDialog?.contains(focused) ? focused.id : null;
|
|
411
414
|
const focusField = focused?.dataset.field;
|
|
412
415
|
const focusQualifier = ['method', 'copy', 'filter', 'project', 'alias', 'device', 'provider', 'step', 'mode']
|
|
413
416
|
.filter((key) => focused?.dataset[key])
|
|
414
417
|
.map((key) => `[data-${key}="${CSS.escape(focused.dataset[key])}"]`).join('');
|
|
415
|
-
const findFocus = (container) => focusId
|
|
418
|
+
const findFocus = (container) => languageFocused ? container.querySelector('[data-language-trigger]') : focusId
|
|
416
419
|
? container.querySelector(`#${CSS.escape(focusId)}`)
|
|
417
420
|
: focusAction ? container.querySelector(`[data-action="${CSS.escape(focusAction)}"]${focusQualifier}:not(:disabled)`)
|
|
418
421
|
: focusField ? container.querySelector(`[data-field="${CSS.escape(focusField)}"]${focusQualifier}:not(:disabled)`) : null;
|
|
419
422
|
const project = (state.overview?.projects ?? []).find((p) => p.id === state.project);
|
|
420
423
|
const counts = state.overview?.stats ?? { total: 0, online: 0, unknown: 0, expired: 0 };
|
|
421
424
|
root.innerHTML = `<div class="app-shell">${renderSidebar()}<div class="main-area">
|
|
422
|
-
<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>
|
|
423
426
|
<main class="workspace">
|
|
424
|
-
<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>
|
|
425
428
|
${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="
|
|
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>
|
|
429
432
|
</div>
|
|
430
|
-
<div class="device-grid" role="region" aria-label="
|
|
431
|
-
<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>
|
|
432
435
|
</section>
|
|
433
|
-
<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>
|
|
434
437
|
</main>
|
|
435
438
|
</div>${renderDialog()}${state.toast && !state.dialog ? `<div class="toast page-toast" role="status">${escapeHtml(state.toast)}</div>` : ''}</div>`;
|
|
436
439
|
const dialog = root.querySelector('[role="dialog"]');
|
|
@@ -440,7 +443,8 @@ function render() {
|
|
|
440
443
|
root.querySelector('.main-area').inert = Boolean(dialog);
|
|
441
444
|
if (dialog) {
|
|
442
445
|
dialog.scrollTop = dialogScroll;
|
|
443
|
-
|
|
446
|
+
const initialFocus = dialog.querySelector('button[data-action="close"]') ?? dialog.querySelector('button');
|
|
447
|
+
(oldDialog ? findFocus(dialog) ?? initialFocus : initialFocus)?.focus({ preventScroll: true });
|
|
444
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 });
|
|
445
449
|
else findFocus(root)?.focus({ preventScroll: true });
|
|
446
450
|
if (dialog) dialog.dataset.kind = state.dialog;
|
|
@@ -453,7 +457,7 @@ function scheduleCloudRefresh() {
|
|
|
453
457
|
if (state.pairBusy || cloudRefreshBusy) { scheduleCloudRefresh(); return; }
|
|
454
458
|
cloudRefreshBusy = true;
|
|
455
459
|
try { updateOverview(await api.overview()); state.cloudRefreshError = null; cloudRefreshFailures = 0; }
|
|
456
|
-
catch { cloudRefreshFailures++; state.cloudRefreshError = cloudRefreshFailures >= 3 ?
|
|
460
|
+
catch { cloudRefreshFailures++; state.cloudRefreshError = cloudRefreshFailures >= 3 ? "panel.475" : "panel.476"; }
|
|
457
461
|
finally { cloudRefreshBusy = false; render(); }
|
|
458
462
|
}, Object.keys(state.pendingCollections).length ? 3000 : 15_000);
|
|
459
463
|
}
|
|
@@ -474,7 +478,7 @@ async function loadSsh() {
|
|
|
474
478
|
try {
|
|
475
479
|
state.ssh = await (state.dialog === 'initialize' || state.dialog === 'setup' ? api.sshInitialization() : api.sshCandidates());
|
|
476
480
|
state.selectedAliases = state.selectedAliases.filter((alias) => state.ssh.candidates.some((item) => item.alias === alias && !item.associated));
|
|
477
|
-
} catch { state.sshError =
|
|
481
|
+
} catch { state.sshError = "panel.477"; }
|
|
478
482
|
finally { state.sshLoading = false; render(); }
|
|
479
483
|
}
|
|
480
484
|
async function associateSsh() {
|
|
@@ -485,8 +489,8 @@ async function associateSsh() {
|
|
|
485
489
|
state.overview = await api.overview();
|
|
486
490
|
state.filter = 'all'; state.query = ''; state.project = 'all'; state.selectedAliases = []; state.dialog = null;
|
|
487
491
|
checkRefreshFailures = 0;
|
|
488
|
-
toast(result.checks.aliases.length ?
|
|
489
|
-
} 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"; }
|
|
490
494
|
finally { state.saving = false; render(); }
|
|
491
495
|
}
|
|
492
496
|
async function initializeSsh() {
|
|
@@ -499,8 +503,8 @@ async function initializeSsh() {
|
|
|
499
503
|
state.ssh = await api.sshInitialization();
|
|
500
504
|
state.filter = 'all'; state.query = ''; state.project = 'all';
|
|
501
505
|
checkRefreshFailures = 0;
|
|
502
|
-
toast(result.checks.aliases.length ?
|
|
503
|
-
} 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"; }
|
|
504
508
|
finally { state.saving = false; render(); root.querySelector('[data-action="ssh-refresh"]')?.focus({ preventScroll: true }); }
|
|
505
509
|
}
|
|
506
510
|
let toastTimer;
|
|
@@ -511,7 +515,7 @@ function toast(message) {
|
|
|
511
515
|
async function copyField(kind) {
|
|
512
516
|
const text = kind === 'command' ? state.setup?.setupCommand : state.setup?.url;
|
|
513
517
|
const field = root.querySelector('#copy-field');
|
|
514
|
-
if (!text || !field) return toast(
|
|
518
|
+
if (!text || !field) return toast(t("panel.486"));
|
|
515
519
|
let copied = false;
|
|
516
520
|
let timer;
|
|
517
521
|
if (navigator.clipboard?.writeText) {
|
|
@@ -519,7 +523,7 @@ async function copyField(kind) {
|
|
|
519
523
|
catch { /* retain manual selection */ } finally { clearTimeout(timer); }
|
|
520
524
|
}
|
|
521
525
|
if (!copied) try { copied = document.execCommand('copy'); } catch { /* retain manual selection */ }
|
|
522
|
-
toast(copied ?
|
|
526
|
+
toast(copied ? t("copy.codex") : t("panel.490"));
|
|
523
527
|
if (!copied) { root.querySelector('#copy-field')?.focus(); root.querySelector('#copy-field')?.select(); }
|
|
524
528
|
}
|
|
525
529
|
root.addEventListener('click', async (event) => {
|
|
@@ -529,11 +533,11 @@ root.addEventListener('click', async (event) => {
|
|
|
529
533
|
if (action === 'agent-install') { openAgentInstall(); render(); return; }
|
|
530
534
|
if (action === 'install-mode') { state.installMode = button.dataset.mode; state.toast = null; render(); return; }
|
|
531
535
|
if (action === 'agent-install-copy') return copyAgentInstall();
|
|
532
|
-
if (action === 'logout') { try { await api.logout(); location.replace('/login'); } catch { state.toast =
|
|
536
|
+
if (action === 'logout') { try { await api.logout(); location.replace('/login?lang=' + getLocale()); } catch { state.toast = t("panel.491"); render(); } return; }
|
|
533
537
|
if (cloudMode() && action === 'add') { state.dialog = 'add'; state.toast = null; state.pairError = null; state.revokeId = null; render(); return; }
|
|
534
538
|
if (['driver-copy', 'cloud-command-copy'].includes(action)) return copyDriverCommand();
|
|
535
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; }
|
|
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(
|
|
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; }
|
|
537
541
|
if (action === 'cloud-connect') { state.dialog = 'cloud-connect'; state.toast = null; render(); return; }
|
|
538
542
|
if (action === 'cloud-choice') { state.cloudChoice = button.dataset.provider; state.toast = null; render(); return; }
|
|
539
543
|
if (['pair-lookup', 'pair-approve', 'revoke-confirm'].includes(action)) return pairAction(action, button.dataset.device);
|
|
@@ -564,8 +568,8 @@ root.addEventListener('click', async (event) => {
|
|
|
564
568
|
state.checking = true; state.checkMessage = null; render();
|
|
565
569
|
try {
|
|
566
570
|
state.status = await api.connectorStatus();
|
|
567
|
-
state.checkMessage = state.status.client.state === 'observed' ?
|
|
568
|
-
} 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"; }
|
|
569
573
|
finally { state.checking = false; }
|
|
570
574
|
render();
|
|
571
575
|
root.querySelector('[data-action="check"]')?.focus({ preventScroll: true });
|
|
@@ -624,7 +628,6 @@ root.addEventListener('keydown', (event) => {
|
|
|
624
628
|
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
|
625
629
|
});
|
|
626
630
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) { cloudRefreshFailures = 0; scheduleCloudRefresh(); } else clearTimeout(cloudRefreshTimer); });
|
|
627
|
-
load();
|
|
628
631
|
|
|
629
632
|
root.addEventListener('input', event => {
|
|
630
633
|
const field=event.target.dataset.logField;
|
|
@@ -633,6 +636,9 @@ root.addEventListener('input', event => {
|
|
|
633
636
|
const draft=logCommandDraft();
|
|
634
637
|
const trust=root.querySelector('#log-trust-command'),grant=root.querySelector('#log-grant-command');
|
|
635
638
|
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??
|
|
639
|
+
const error=root.querySelector('#log-command-error');if(error)error.textContent=draft.error??t("panel.318");
|
|
637
640
|
for(const button of root.querySelectorAll('[data-action="log-command-copy"]'))button.disabled=Boolean(draft.error);
|
|
638
641
|
});
|
|
642
|
+
|
|
643
|
+
initLanguage({ onChange: () => { state.toast = null; render(); } });
|
|
644
|
+
load();
|