From 40d236c228a896f7baea701f495f026e3e80ba53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Thu, 14 May 2026 17:08:07 +0300 Subject: [PATCH] fix(ui): separate server vs browser TZ; highlight sync from device Co-authored-by: Cursor --- README.md | 2 +- public/app.js | 35 +++++++++++++++++++---- public/index.html | 15 +++++----- public/styles.css | 40 ++++++++++++++++++++++++++- scripts/install.sh | 8 +++--- server.js | 69 +++++++++++++++++++++++++++++++++++++++++++--- 6 files changed, 147 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 841f029..abd7d5e 100644 --- a/README.md +++ b/README.md @@ -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_PROFILES` | _(нет)_ | JSON-массив профилей: несколько контейнеров/путей (см. ниже). Если задан — переключатель «Инстанс» в вебе | | `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_DISABLED` | `0` | `1` — скрыть/отключить синхронизацию времени по SSH | | `ADMIN_PASSWORD` | _(генерируется)_ | Первый пароль вместо файла | diff --git a/public/app.js b/public/app.js index e0b8c64..3ed635e 100644 --- a/public/app.js +++ b/public/app.js @@ -8,6 +8,7 @@ const logoutBtn = document.querySelector("#logout"); const refreshBtn = document.querySelector("#refresh"); const clockServerEl = document.querySelector("#clock-server"); const clockLocalEl = document.querySelector("#clock-local"); +const clockZoneDiffEl = document.querySelector("#clock-zone-diff"); const clockSyncBtn = document.querySelector("#clock-sync"); const rowsEl = document.querySelector("#rows"); const statusEl = document.querySelector("#status"); @@ -320,7 +321,9 @@ function tickServerClockDisplay() { async function refreshServerClock() { 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 parsed = new Date(iso).getTime(); if (!iso || Number.isNaN(parsed)) { @@ -332,11 +335,28 @@ async function refreshServerClock() { typeof t.timeZone === "string" && t.timeZone.trim() ? t.timeZone.trim() : "UTC"; serverTzFmtCached = buildServerTzFmt(serverDisplayTz); 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 { serverAnchorUtcMs = null; serverTzFmtCached = null; clockServerEl.dateTime = ""; 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 = ""; clockServerEl.textContent = "—"; clockLocalEl.textContent = "—"; + if (clockZoneDiffEl) { + clockZoneDiffEl.textContent = ""; + clockZoneDiffEl.classList.add("hidden"); + clockZoneDiffEl.classList.remove("clock-zone-diff--accent"); + } } function startClocks() { @@ -386,8 +411,8 @@ async function loadTimeSyncCaps() { const c = await api("/api/time-sync-capabilities"); if (hint) { hint.textContent = c.hostTimeSync - ? `Через SSH на root@${c.sshHost}. Часовой пояс строки «Сервер»: ${c.serverClockTimeZone}. Пароль не сохраняется.` - : `Авто-синхронизация по SSH недоступна (или TIME_SYNC_DISABLED). Пояс «Сервер»: ${c.serverClockTimeZone}. Задайте DISPLAY_TZ или TZ для контейнера панели — см. README.`; + ? `Записывается UTC-момент с этого устройства на хост по SSH (root@${c.sshHost}). Пояс строки «Сервер»: ${c.serverClockTimeZone}. Пароль не сохраняется.` + : `Авто-синхронизация по SSH недоступна (или TIME_SYNC_DISABLED). Пояс «Сервер»: ${c.serverClockTimeZone}. Задайте TZ контейнера панели при необходимости — см. README.`; } if (btn) btn.disabled = !c.hostTimeSync; } catch { @@ -404,13 +429,13 @@ document.querySelector("#sync-host-time")?.addEventListener("click", async () => return; } try { - setStatus("Синхронизация времени хоста…", false); + setStatus("Беру время с этого устройства и отправляю на хост…", false); await api("/api/sync-host-time", { method: "POST", body: JSON.stringify({ rootPassword: pw, unixMs: Date.now() }), }); inp.value = ""; - setStatus("Запрос выполнен. Проверьте строку «Сервер».", false); + setStatus("Готово: часы хоста выставлены по вашему устройству (UTC). Проверьте строки времени.", false); void refreshServerClock(); } catch (e) { setStatus(String(e.message || e), true); diff --git a/public/index.html b/public/index.html index 8f58902..eeef47f 100644 --- a/public/index.html +++ b/public/index.html @@ -58,27 +58,28 @@
-
- Сервер +
+ Сервер (часы VPS / контейнера)
-
- Браузер +
+ Ваше место (браузер)
+

- «Сервер» — время в часовом поясе VPS (подпись справа). «Браузер» — ваш компьютер. «Сверить» — заново запросить момент с сервера. + Цифры разнятся, если пояса разные — это нормально для одного момента UTC. «Сверить» подтягивает время с VPS.

- Синхронизировать часы VPS с этим компьютером + Синхронизация: выставить часы хоста VPS под ваше устройство

diff --git a/public/styles.css b/public/styles.css index 76c66f9..031a9ef 100644 --- a/public/styles.css +++ b/public/styles.css @@ -227,6 +227,42 @@ h1 { 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 { display: flex; flex-direction: column; @@ -247,7 +283,9 @@ h1 { } .clock-kind { - flex: 0 0 4.25rem; + flex: 0 0 auto; + min-width: 7rem; + max-width: 11rem; } .clock-time { diff --git a/scripts/install.sh b/scripts/install.sh index a54f8c9..4aeb31f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -101,10 +101,6 @@ if [[ -n "${AWG_PROFILES:-}" ]]; then RUN_ENV+=( -e "AWG_PROFILES=${AWG_PROFILES}" ) fi -if [[ -n "${DISPLAY_TZ:-}" ]]; then - RUN_ENV+=( -e "DISPLAY_TZ=${DISPLAY_TZ}" ) -fi - if [[ -n "${TIME_SYNC_SSH_HOST:-}" ]]; then RUN_ENV+=( -e "TIME_SYNC_SSH_HOST=${TIME_SYNC_SSH_HOST}" ) fi @@ -113,6 +109,10 @@ if [[ -n "${TIME_SYNC_DISABLED:-}" ]]; then RUN_ENV+=( -e "TIME_SYNC_DISABLED=${TIME_SYNC_DISABLED}" ) fi +if [[ -n "${TZ:-}" ]]; then + RUN_ENV+=( -e "TZ=${TZ}" ) +fi + if [[ -n "${BOOT_PW}" ]]; then RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" ) elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then diff --git a/server.js b/server.js index 8e18858..5c68053 100644 --- a/server.js +++ b/server.js @@ -461,10 +461,10 @@ function requireDisconnectedAt(raw) { return d.toISOString(); } -/** Часовой пояс для строки «Сервер» в UI (IANA). Приоритет: DISPLAY_TZ → TZ процесса Node */ +/** Пояс для строки «Сервер»: переменная TZ контейнера или значение из Intl (часто UTC в Docker). Без подмены под пояс браузера. */ function resolveServerClockTimeZone() { - const override = process.env.DISPLAY_TZ?.trim(); - if (override) return override; + const tzEnv = process.env.TZ?.trim(); + if (tzEnv) return tzEnv; try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } 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() { for (const p of ["/usr/bin/sshpass", "/usr/local/bin/sshpass"]) { try { @@ -542,7 +596,7 @@ app.get("/api/session", (req, res) => { 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 timeZone = resolveServerClockTimeZone(); let formatted; @@ -558,10 +612,17 @@ app.get("/api/server-time", requireAuth, (_req, res) => { timeStyle: "medium", }); } + const browserTz = + typeof req.query.browserTz === "string" ? req.query.browserTz.trim() : ""; + const zoneCompare = buildZoneCompare(timeZone, browserTz, now); res.json({ iso: now.toISOString(), formatted, timeZone, + browserTimeZone: browserTz || null, + zoneSame: zoneCompare.sameZone, + zoneCompareHint: zoneCompare.hint, + zoneDiffMinutes: zoneCompare.diffMinutes ?? null, }); });