mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-09-20 14:42:00 +00:00
fix(ui): separate server vs browser TZ; highlight sync from device
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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` | _(генерируется)_ | Первый пароль вместо файла |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -58,27 +58,28 @@
|
||||
<select id="proto-select" class="proto-select" aria-label="Инстанс AmneziaWG"></select>
|
||||
</div>
|
||||
<div class="clock-strip" role="status" aria-live="polite">
|
||||
<div class="clock-row">
|
||||
<span class="muted clock-kind">Сервер</span>
|
||||
<div class="clock-row clock-row--server">
|
||||
<span class="muted clock-kind">Сервер <span class="clock-kind-sub">(часы VPS / контейнера)</span></span>
|
||||
<time id="clock-server" class="clock-time">—</time>
|
||||
</div>
|
||||
<div class="clock-row">
|
||||
<span class="muted clock-kind">Браузер</span>
|
||||
<div class="clock-row clock-row--your-place">
|
||||
<span class="muted clock-kind">Ваше место <span class="clock-kind-sub">(браузер)</span></span>
|
||||
<time id="clock-local" class="clock-time">—</time>
|
||||
</div>
|
||||
<p id="clock-zone-diff" class="clock-zone-diff muted hidden" role="note"></p>
|
||||
<p class="clock-hint muted">
|
||||
«Сервер» — время в часовом поясе VPS (подпись справа). «Браузер» — ваш компьютер. «Сверить» — заново запросить момент с сервера.
|
||||
Цифры разнятся, если пояса разные — это нормально для одного момента UTC. «Сверить» подтягивает время с VPS.
|
||||
</p>
|
||||
<button type="button" id="clock-sync" class="btn small ghost clock-sync-btn">
|
||||
Сверить время
|
||||
</button>
|
||||
<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>
|
||||
<label for="sync-root-pw">Пароль root на хосте (не сохраняется)</label>
|
||||
<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">
|
||||
Выставить время хоста как у браузера
|
||||
Взять время с этого устройства и записать на хост VPS
|
||||
</button>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
69
server.js
69
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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user