fix(ui): separate server vs browser TZ; highlight sync from device

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-14 17:08:07 +03:00
parent dfae0d8256
commit 40d236c228
6 changed files with 147 additions and 22 deletions

View File

@@ -38,7 +38,7 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas
| `AWG_CONTAINER` | `amnezia-awg2` | Имя контейнера Amnezia WG | | `AWG_CONTAINER` | `amnezia-awg2` | Имя контейнера Amnezia WG |
| `AWG_PROFILES` | _(нет)_ | JSON-массив профилей: несколько контейнеров/путей (см. ниже). Если задан — переключатель «Инстанс» в вебе | | `AWG_PROFILES` | _(нет)_ | JSON-массив профилей: несколько контейнеров/путей (см. ниже). Если задан — переключатель «Инстанс» в вебе |
| `SCHEDULE_DISCONNECT_MS` | `60000` | Как часто планировщик проверяет отложенное отключение из туннеля (мс) | | `SCHEDULE_DISCONNECT_MS` | `60000` | Как часто планировщик проверяет отложенное отключение из туннеля (мс) |
| `DISPLAY_TZ` | _(нет)_ | IANA пояс для строки «Сервер» в UI (например `Asia/Nicosia`), если не хотите полагаться только на `TZ` контейнера | | `TZ` | _(часто UTC в Docker)_ | Пояс строки «Сервер» в панели (IANA, например `Europe/Berlin`). Без `TZ` берётся из образа (часто UTC) — тогда от браузера будет видна разница часов |
| `TIME_SYNC_SSH_HOST` | `172.17.0.1` | Хост для SSH root при синхронизации времени из панели (часто шлюз Docker к хосту) | | `TIME_SYNC_SSH_HOST` | `172.17.0.1` | Хост для SSH root при синхронизации времени из панели (часто шлюз Docker к хосту) |
| `TIME_SYNC_DISABLED` | `0` | `1` — скрыть/отключить синхронизацию времени по SSH | | `TIME_SYNC_DISABLED` | `0` | `1` — скрыть/отключить синхронизацию времени по SSH |
| `ADMIN_PASSWORD` | _(генерируется)_ | Первый пароль вместо файла | | `ADMIN_PASSWORD` | _(генерируется)_ | Первый пароль вместо файла |

View File

@@ -8,6 +8,7 @@ const logoutBtn = document.querySelector("#logout");
const refreshBtn = document.querySelector("#refresh"); const refreshBtn = document.querySelector("#refresh");
const clockServerEl = document.querySelector("#clock-server"); const clockServerEl = document.querySelector("#clock-server");
const clockLocalEl = document.querySelector("#clock-local"); const clockLocalEl = document.querySelector("#clock-local");
const clockZoneDiffEl = document.querySelector("#clock-zone-diff");
const clockSyncBtn = document.querySelector("#clock-sync"); const clockSyncBtn = document.querySelector("#clock-sync");
const rowsEl = document.querySelector("#rows"); const rowsEl = document.querySelector("#rows");
const statusEl = document.querySelector("#status"); const statusEl = document.querySelector("#status");
@@ -320,7 +321,9 @@ function tickServerClockDisplay() {
async function refreshServerClock() { async function refreshServerClock() {
try { try {
const t = await api("/api/server-time"); const tz = browserTimeZoneLabel();
const q = tz ? `?browserTz=${encodeURIComponent(tz)}` : "";
const t = await api(`/api/server-time${q}`);
const iso = typeof t.iso === "string" ? t.iso : ""; const iso = typeof t.iso === "string" ? t.iso : "";
const parsed = new Date(iso).getTime(); const parsed = new Date(iso).getTime();
if (!iso || Number.isNaN(parsed)) { if (!iso || Number.isNaN(parsed)) {
@@ -332,11 +335,28 @@ async function refreshServerClock() {
typeof t.timeZone === "string" && t.timeZone.trim() ? t.timeZone.trim() : "UTC"; typeof t.timeZone === "string" && t.timeZone.trim() ? t.timeZone.trim() : "UTC";
serverTzFmtCached = buildServerTzFmt(serverDisplayTz); serverTzFmtCached = buildServerTzFmt(serverDisplayTz);
tickServerClockDisplay(); tickServerClockDisplay();
if (clockZoneDiffEl) {
const hint = typeof t.zoneCompareHint === "string" ? t.zoneCompareHint.trim() : "";
if (hint) {
clockZoneDiffEl.textContent = hint;
clockZoneDiffEl.classList.remove("hidden");
clockZoneDiffEl.classList.toggle("clock-zone-diff--accent", t.zoneSame === false);
} else {
clockZoneDiffEl.textContent = "";
clockZoneDiffEl.classList.add("hidden");
clockZoneDiffEl.classList.remove("clock-zone-diff--accent");
}
}
} catch { } catch {
serverAnchorUtcMs = null; serverAnchorUtcMs = null;
serverTzFmtCached = null; serverTzFmtCached = null;
clockServerEl.dateTime = ""; clockServerEl.dateTime = "";
clockServerEl.textContent = "—"; clockServerEl.textContent = "—";
if (clockZoneDiffEl) {
clockZoneDiffEl.textContent = "";
clockZoneDiffEl.classList.add("hidden");
clockZoneDiffEl.classList.remove("clock-zone-diff--accent");
}
} }
} }
@@ -369,6 +389,11 @@ function stopClocks() {
clockLocalEl.dateTime = ""; clockLocalEl.dateTime = "";
clockServerEl.textContent = "—"; clockServerEl.textContent = "—";
clockLocalEl.textContent = "—"; clockLocalEl.textContent = "—";
if (clockZoneDiffEl) {
clockZoneDiffEl.textContent = "";
clockZoneDiffEl.classList.add("hidden");
clockZoneDiffEl.classList.remove("clock-zone-diff--accent");
}
} }
function startClocks() { function startClocks() {
@@ -386,8 +411,8 @@ async function loadTimeSyncCaps() {
const c = await api("/api/time-sync-capabilities"); const c = await api("/api/time-sync-capabilities");
if (hint) { if (hint) {
hint.textContent = c.hostTimeSync hint.textContent = c.hostTimeSync
? `Через SSH на root@${c.sshHost}. Часовой пояс строки «Сервер»: ${c.serverClockTimeZone}. Пароль не сохраняется.` ? `Записывается UTC-момент с этого устройства на хост по SSH (root@${c.sshHost}). Пояс строки «Сервер»: ${c.serverClockTimeZone}. Пароль не сохраняется.`
: `Авто-синхронизация по SSH недоступна (или TIME_SYNC_DISABLED). Пояс «Сервер»: ${c.serverClockTimeZone}. Задайте DISPLAY_TZ или TZ для контейнера панели — см. README.`; : `Авто-синхронизация по SSH недоступна (или TIME_SYNC_DISABLED). Пояс «Сервер»: ${c.serverClockTimeZone}. Задайте TZ контейнера панели при необходимости — см. README.`;
} }
if (btn) btn.disabled = !c.hostTimeSync; if (btn) btn.disabled = !c.hostTimeSync;
} catch { } catch {
@@ -404,13 +429,13 @@ document.querySelector("#sync-host-time")?.addEventListener("click", async () =>
return; return;
} }
try { try {
setStatus("Синхронизация времени хоста…", false); setStatus("Беру время с этого устройства и отправляю на хост…", false);
await api("/api/sync-host-time", { await api("/api/sync-host-time", {
method: "POST", method: "POST",
body: JSON.stringify({ rootPassword: pw, unixMs: Date.now() }), body: JSON.stringify({ rootPassword: pw, unixMs: Date.now() }),
}); });
inp.value = ""; inp.value = "";
setStatus("Запрос выполнен. Проверьте строку «Сервер».", false); setStatus("Готово: часы хоста выставлены по вашему устройству (UTC). Проверьте строки времени.", false);
void refreshServerClock(); void refreshServerClock();
} catch (e) { } catch (e) {
setStatus(String(e.message || e), true); setStatus(String(e.message || e), true);

View File

@@ -58,27 +58,28 @@
<select id="proto-select" class="proto-select" aria-label="Инстанс AmneziaWG"></select> <select id="proto-select" class="proto-select" aria-label="Инстанс AmneziaWG"></select>
</div> </div>
<div class="clock-strip" role="status" aria-live="polite"> <div class="clock-strip" role="status" aria-live="polite">
<div class="clock-row"> <div class="clock-row clock-row--server">
<span class="muted clock-kind">Сервер</span> <span class="muted clock-kind">Сервер <span class="clock-kind-sub">(часы VPS / контейнера)</span></span>
<time id="clock-server" class="clock-time"></time> <time id="clock-server" class="clock-time"></time>
</div> </div>
<div class="clock-row"> <div class="clock-row clock-row--your-place">
<span class="muted clock-kind">Браузер</span> <span class="muted clock-kind">Ваше место <span class="clock-kind-sub">(браузер)</span></span>
<time id="clock-local" class="clock-time"></time> <time id="clock-local" class="clock-time"></time>
</div> </div>
<p id="clock-zone-diff" class="clock-zone-diff muted hidden" role="note"></p>
<p class="clock-hint muted"> <p class="clock-hint muted">
«Сервер» — время в часовом поясе VPS (подпись справа). «Браузер» — ваш компьютер. «Сверить» — заново запросить момент с сервера. Цифры разнятся, если пояса разные — это нормально для одного момента UTC. «Сверить» подтягивает время с VPS.
</p> </p>
<button type="button" id="clock-sync" class="btn small ghost clock-sync-btn"> <button type="button" id="clock-sync" class="btn small ghost clock-sync-btn">
Сверить время Сверить время
</button> </button>
<details class="clock-host-sync"> <details class="clock-host-sync">
<summary>Синхронизировать часы VPS с этим компьютером</summary> <summary class="clock-sync-summary">Синхронизация: выставить часы <strong>хоста VPS</strong> под <strong>ваше устройство</strong></summary>
<p id="sync-host-hint" class="muted clock-sync-hint"></p> <p id="sync-host-hint" class="muted clock-sync-hint"></p>
<label for="sync-root-pw">Пароль root на хосте (не сохраняется)</label> <label for="sync-root-pw">Пароль root на хосте (не сохраняется)</label>
<input id="sync-root-pw" type="password" autocomplete="off" class="clock-sync-input"> <input id="sync-root-pw" type="password" autocomplete="off" class="clock-sync-input">
<button type="button" id="sync-host-time" class="btn small primary clock-sync-submit"> <button type="button" id="sync-host-time" class="btn small primary clock-sync-submit">
Выставить время хоста как у браузера Взять время с этого устройства и записать на хост VPS
</button> </button>
</details> </details>
</div> </div>

View File

@@ -227,6 +227,42 @@ h1 {
max-width: 22rem; max-width: 22rem;
} }
.clock-row--server .clock-kind-sub,
.clock-row--your-place .clock-kind-sub {
font-weight: 400;
opacity: 0.82;
font-size: 0.72rem;
}
.clock-row--your-place {
padding: 0.35rem 0.55rem;
margin: 0.15rem -0.35rem 0;
border-radius: 10px;
border-left: 3px solid #22d3ee;
background: rgba(56, 189, 248, 0.07);
}
.clock-zone-diff {
margin: 0.5rem 0 0;
font-size: 0.78rem;
line-height: 1.45;
max-width: 26rem;
}
.clock-zone-diff--accent {
color: #a5f3fc;
padding: 0.45rem 0.55rem;
margin-top: 0.45rem;
border-radius: 8px;
border: 1px solid rgba(34, 211, 238, 0.35);
background: rgba(56, 189, 248, 0.08);
}
.clock-sync-summary strong {
color: var(--text);
font-weight: 650;
}
.clock-strip { .clock-strip {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -247,7 +283,9 @@ h1 {
} }
.clock-kind { .clock-kind {
flex: 0 0 4.25rem; flex: 0 0 auto;
min-width: 7rem;
max-width: 11rem;
} }
.clock-time { .clock-time {

View File

@@ -101,10 +101,6 @@ if [[ -n "${AWG_PROFILES:-}" ]]; then
RUN_ENV+=( -e "AWG_PROFILES=${AWG_PROFILES}" ) RUN_ENV+=( -e "AWG_PROFILES=${AWG_PROFILES}" )
fi fi
if [[ -n "${DISPLAY_TZ:-}" ]]; then
RUN_ENV+=( -e "DISPLAY_TZ=${DISPLAY_TZ}" )
fi
if [[ -n "${TIME_SYNC_SSH_HOST:-}" ]]; then if [[ -n "${TIME_SYNC_SSH_HOST:-}" ]]; then
RUN_ENV+=( -e "TIME_SYNC_SSH_HOST=${TIME_SYNC_SSH_HOST}" ) RUN_ENV+=( -e "TIME_SYNC_SSH_HOST=${TIME_SYNC_SSH_HOST}" )
fi fi
@@ -113,6 +109,10 @@ if [[ -n "${TIME_SYNC_DISABLED:-}" ]]; then
RUN_ENV+=( -e "TIME_SYNC_DISABLED=${TIME_SYNC_DISABLED}" ) RUN_ENV+=( -e "TIME_SYNC_DISABLED=${TIME_SYNC_DISABLED}" )
fi fi
if [[ -n "${TZ:-}" ]]; then
RUN_ENV+=( -e "TZ=${TZ}" )
fi
if [[ -n "${BOOT_PW}" ]]; then if [[ -n "${BOOT_PW}" ]]; then
RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" ) RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" )
elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then

View File

@@ -461,10 +461,10 @@ function requireDisconnectedAt(raw) {
return d.toISOString(); return d.toISOString();
} }
/** Часовой пояс для строки «Сервер» в UI (IANA). Приоритет: DISPLAY_TZ → TZ процесса Node */ /** Пояс для строки «Сервер»: переменная TZ контейнера или значение из Intl (часто UTC в Docker). Без подмены под пояс браузера. */
function resolveServerClockTimeZone() { function resolveServerClockTimeZone() {
const override = process.env.DISPLAY_TZ?.trim(); const tzEnv = process.env.TZ?.trim();
if (override) return override; if (tzEnv) return tzEnv;
try { try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
} catch { } catch {
@@ -472,6 +472,60 @@ function resolveServerClockTimeZone() {
} }
} }
/** Смещение от UTC в минутах для IANA-пояса в данный момент (через GMT± из Intl). */
function offsetMinutesFromUtc(timeZone, date) {
try {
const dtf = new Intl.DateTimeFormat("en-US", {
timeZone,
timeZoneName: "longOffset",
});
const parts = dtf.formatToParts(date);
let raw = parts.find((p) => p.type === "timeZoneName")?.value || "";
raw = raw.replace(/\u2212/g, "-").trim();
let m = raw.match(/^GMT([+-])(\d{1,2})(?::(\d{2}))?$/i);
if (!m) {
m = raw.match(/^([+-])(\d{2}):(\d{2})$/);
if (m) {
const sign = m[1] === "-" ? -1 : 1;
const h = parseInt(m[2], 10);
const min = parseInt(m[3], 10);
return sign * (h * 60 + min);
}
return 0;
}
const sign = m[1] === "-" ? -1 : 1;
const h = parseInt(m[2], 10);
const min = m[3] ? parseInt(m[3], 10) : 0;
return sign * (h * 60 + min);
} catch {
return 0;
}
}
function buildZoneCompare(serverTz, browserTz, now) {
if (!browserTz) {
return { sameZone: null, hint: "", diffMinutes: null };
}
if (browserTz === serverTz) {
return {
sameZone: true,
hint: "Пояс браузера совпадает с поясом строки «Сервер» — часы совпадут.",
diffMinutes: 0,
};
}
const so = offsetMinutesFromUtc(serverTz, now);
const bo = offsetMinutesFromUtc(browserTz, now);
const diffMin = bo - so;
const abs = Math.abs(diffMin);
const h = Math.floor(abs / 60);
const m = abs % 60;
const ahead = diffMin > 0;
const hint = ahead
? `Ваше место (${browserTz}): на ${h} ч ${m} мин «впереди» строки «Сервер» (${serverTz}) при одном UTC.`
: `Ваше место (${browserTz}): на ${h} ч ${m} мин «позже» пояса сервера (${serverTz}).`;
return { sameZone: false, hint, diffMinutes: diffMin };
}
function sshpassBinaryPath() { function sshpassBinaryPath() {
for (const p of ["/usr/bin/sshpass", "/usr/local/bin/sshpass"]) { for (const p of ["/usr/bin/sshpass", "/usr/local/bin/sshpass"]) {
try { try {
@@ -542,7 +596,7 @@ app.get("/api/session", (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
app.get("/api/server-time", requireAuth, (_req, res) => { app.get("/api/server-time", requireAuth, (req, res) => {
const now = new Date(); const now = new Date();
const timeZone = resolveServerClockTimeZone(); const timeZone = resolveServerClockTimeZone();
let formatted; let formatted;
@@ -558,10 +612,17 @@ app.get("/api/server-time", requireAuth, (_req, res) => {
timeStyle: "medium", timeStyle: "medium",
}); });
} }
const browserTz =
typeof req.query.browserTz === "string" ? req.query.browserTz.trim() : "";
const zoneCompare = buildZoneCompare(timeZone, browserTz, now);
res.json({ res.json({
iso: now.toISOString(), iso: now.toISOString(),
formatted, formatted,
timeZone, timeZone,
browserTimeZone: browserTz || null,
zoneSame: zoneCompare.sameZone,
zoneCompareHint: zoneCompare.hint,
zoneDiffMinutes: zoneCompare.diffMinutes ?? null,
}); });
}); });