feat(update): server push-команда на массовое обновление UI и формат версий

This commit is contained in:
AidarKC 2026-04-23 16:19:00 +03:00
parent 630ba30c27
commit f213e9aa43
10 changed files with 252 additions and 14 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.1 client.version=1.2.2
server.version=1.2.1 server.version=1.2.2

View File

@ -1,5 +1,11 @@
self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim())); self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
self.addEventListener('message', (event) => {
const data = event?.data || {};
if (data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
async function broadcastToClients(payload) { async function broadcastToClients(payload) {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });

View File

@ -7,7 +7,7 @@
<title>Shine UI Demo</title> <title>Shine UI Demo</title>
<script> <script>
window.__SHINE_BUILD_HASH__ = '20260413151200'; window.__SHINE_BUILD_HASH__ = '20260413151200';
window.__SHINE_CLIENT_VERSION__ = '1.2.1'; window.__SHINE_CLIENT_VERSION__ = '1.2.2';
</script> </script>
<script> <script>
(function attachStylesWithBuildHash() { (function attachStylesWithBuildHash() {

View File

@ -118,6 +118,7 @@ let connectionNextRetryAtMs = 0;
let connectionCheckInFlight = false; let connectionCheckInFlight = false;
let wsSessionRestoreInFlight = null; let wsSessionRestoreInFlight = null;
let uiUpdateReloadScheduled = false; let uiUpdateReloadScheduled = false;
let pwaUpdateCheckAttempted = false;
setClientErrorTransport((payload) => authService.reportClientError(payload)); setClientErrorTransport((payload) => authService.reportClientError(payload));
initPwaInstallPromptHandling(); initPwaInstallPromptHandling();
@ -235,20 +236,62 @@ async function triggerImmediateConnectionRetry() {
} }
function checkAndReloadIfUiUpdated(remoteHashRaw) { function checkAndReloadIfUiUpdated(remoteHashRaw) {
if (uiUpdateReloadScheduled) return;
const remoteHash = String(remoteHashRaw || '').trim(); const remoteHash = String(remoteHashRaw || '').trim();
if (!remoteHash || !CURRENT_BUILD_HASH || remoteHash === CURRENT_BUILD_HASH) return; if (!remoteHash || !CURRENT_BUILD_HASH || remoteHash === CURRENT_BUILD_HASH) return;
scheduleUiReload({
source: 'version-check',
message: `Обнаружена новая версия UI (через Ping): ${CURRENT_BUILD_HASH} -> ${remoteHash}`,
delayMs: 600,
activateWaitingWorker: true,
});
}
async function refreshServiceWorkers({ activateWaitingWorker = false } = {}) {
if (!('serviceWorker' in navigator)) return;
try {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map(async (registration) => {
try {
await registration.update();
} catch {}
if (activateWaitingWorker && registration.waiting) {
try {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
} catch {}
}
}));
} catch {
// ignore service worker update failures
}
}
function scheduleUiReload({
source = 'ui-update',
message = 'Запрошено обновление интерфейса',
delayMs = 700,
activateWaitingWorker = true,
} = {}) {
if (uiUpdateReloadScheduled) return;
uiUpdateReloadScheduled = true; uiUpdateReloadScheduled = true;
addAppLogEntry({ addAppLogEntry({
level: 'info', level: 'info',
source: 'version-check', source,
message: `Обнаружена новая версия UI (через Ping): ${CURRENT_BUILD_HASH} -> ${remoteHash}`, message,
}); });
setConnectionStatus('updating'); setConnectionStatus('updating');
void refreshServiceWorkers({ activateWaitingWorker });
const ms = Math.min(15_000, Math.max(200, Number(delayMs || 700)));
window.setTimeout(() => { window.setTimeout(() => {
window.location.reload(); window.location.reload();
}, 600); }, ms);
}
async function tryUpdatePwaOnFirstConnectedPing() {
if (pwaUpdateCheckAttempted) return;
pwaUpdateCheckAttempted = true;
await refreshServiceWorkers({ activateWaitingWorker: false });
} }
async function checkConnectionHealth() { async function checkConnectionHealth() {
@ -271,6 +314,7 @@ async function checkConnectionHealth() {
const pingResp = await authService.ws.request('Ping', { ts: Date.now() }, 7000); const pingResp = await authService.ws.request('Ping', { ts: Date.now() }, 7000);
const remoteUiBuildHash = pingResp?.payload?.uiBuildHash || pingResp?.uiBuildHash || ''; const remoteUiBuildHash = pingResp?.payload?.uiBuildHash || pingResp?.uiBuildHash || '';
checkAndReloadIfUiUpdated(remoteUiBuildHash); checkAndReloadIfUiUpdated(remoteUiBuildHash);
await tryUpdatePwaOnFirstConnectedPing();
setConnectionStatus('connected'); setConnectionStatus('connected');
} catch { } catch {
connectionStatusText = ''; connectionStatusText = '';
@ -618,6 +662,18 @@ async function init() {
await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' }); await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' });
}); });
authService.onEvent('ForceUiReload', async (evt) => {
const payload = evt?.payload || {};
const reason = String(payload.reason || 'server_debug_api').trim() || 'server_debug_api';
const reloadAfterMs = Number(payload.reloadAfterMs || 700);
scheduleUiReload({
source: 'server-ui-reload',
message: `Сервер запросил обновление UI (${reason})`,
delayMs: reloadAfterMs,
activateWaitingWorker: true,
});
});
authService.onEvent('SignedMessageArrived', async (evt) => { authService.onEvent('SignedMessageArrived', async (evt) => {
const payload = evt?.payload || {}; const payload = evt?.payload || {};
const messageKey = String(payload.messageKey || '').trim(); const messageKey = String(payload.messageKey || '').trim();

View File

@ -10,6 +10,28 @@ import { initPwaPush } from '../services/pwa-push-service.js';
export const pageMeta = { id: 'settings-view', title: 'Настройки' }; export const pageMeta = { id: 'settings-view', title: 'Настройки' };
function formatBuildStamp(rawValue) {
const value = String(rawValue || '').trim();
if (!/^\d{14}$/.test(value)) return value;
const yyyy = value.slice(0, 4);
const mm = value.slice(4, 6);
const dd = value.slice(6, 8);
const hh = value.slice(8, 10);
const min = value.slice(10, 12);
const ss = value.slice(12, 14);
return `${yyyy}-${mm}-${dd}/${hh}:${min}__${ss}`;
}
function formatVersionForUi(rawValue) {
const value = String(rawValue || '').trim();
if (!value) return 'n/a';
const formatted = formatBuildStamp(value);
if (formatted && formatted !== value) {
return `${formatted} (${value})`;
}
return value;
}
export function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack';
@ -129,11 +151,11 @@ export function render({ navigate }) {
const clientVersion = document.createElement('p'); const clientVersion = document.createElement('p');
clientVersion.className = 'meta-muted'; clientVersion.className = 'meta-muted';
clientVersion.textContent = `Клиент: ${String(window.__SHINE_CLIENT_VERSION__ || 'n/a').trim() || 'n/a'}`; clientVersion.textContent = `Клиент: ${formatVersionForUi(window.__SHINE_CLIENT_VERSION__)}`;
const uiBuild = document.createElement('p'); const uiBuild = document.createElement('p');
uiBuild.className = 'meta-muted'; uiBuild.className = 'meta-muted';
uiBuild.textContent = `Сборка UI: ${String(window.__SHINE_BUILD_HASH__ || 'n/a').trim() || 'n/a'}`; uiBuild.textContent = `Сборка UI: ${formatVersionForUi(window.__SHINE_BUILD_HASH__)}`;
const serverVersion = document.createElement('p'); const serverVersion = document.createElement('p');
serverVersion.className = 'meta-muted'; serverVersion.className = 'meta-muted';
@ -143,11 +165,19 @@ export function render({ navigate }) {
void (async () => { void (async () => {
try { try {
const resp = await authService.ws.request('GetServerInfo', {}); let value = '';
const value = String(resp?.payload?.version || '').trim() || 'n/a'; try {
if (!isDisposed) { const pingResp = await authService.ws.request('Ping', { ts: Date.now() }, 7000);
serverVersion.textContent = `Сервер: ${value}`; value = String(pingResp?.payload?.serverVersion || pingResp?.serverVersion || '').trim();
} catch {
// fallback below
} }
if (!value) {
const infoResp = await authService.ws.request('GetServerInfo', {});
value = String(infoResp?.payload?.version || '').trim();
}
if (!isDisposed) serverVersion.textContent = `Сервер: ${formatVersionForUi(value)}`;
} catch { } catch {
if (!isDisposed) { if (!isDisposed) {
serverVersion.textContent = 'Сервер: недоступно'; serverVersion.textContent = 'Сервер: недоступно';

View File

@ -40,6 +40,7 @@ public class Net_Ping_Handler implements JsonMessageHandler {
// ничего не проверяем, просто отдаём серверное время // ничего не проверяем, просто отдаём серверное время
resp.setTs(System.currentTimeMillis()); resp.setTs(System.currentTimeMillis());
resp.setUiBuildHash(resolveUiBuildHash()); resp.setUiBuildHash(resolveUiBuildHash());
resp.setServerVersion(CONFIG.getStringOrEmpty("server.version"));
return resp; return resp;
} }

View File

@ -10,7 +10,8 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
* "status": 200, * "status": 200,
* "payload": { * "payload": {
* "ts": 1700000000123, * "ts": 1700000000123,
* "uiBuildHash": "20260422164933" * "uiBuildHash": "20260422164933",
* "serverVersion": "1.2.2"
* } * }
* } * }
*/ */
@ -18,10 +19,14 @@ public class Net_Ping_Response extends Net_Response {
private long ts; private long ts;
private String uiBuildHash; private String uiBuildHash;
private String serverVersion;
public long getTs() { return ts; } public long getTs() { return ts; }
public void setTs(long ts) { this.ts = ts; } public void setTs(long ts) { this.ts = ts; }
public String getUiBuildHash() { return uiBuildHash; } public String getUiBuildHash() { return uiBuildHash; }
public void setUiBuildHash(String uiBuildHash) { this.uiBuildHash = uiBuildHash; } public void setUiBuildHash(String uiBuildHash) { this.uiBuildHash = uiBuildHash; }
public String getServerVersion() { return serverVersion; }
public void setServerVersion(String serverVersion) { this.serverVersion = serverVersion; }
} }

View File

@ -30,6 +30,7 @@ public final class DebugApiConfigurator {
addServlet(context, new DebugClientsServlet(tokenProvider), "/debug/ws/clients"); addServlet(context, new DebugClientsServlet(tokenProvider), "/debug/ws/clients");
addServlet(context, new DebugConnectServlet(tokenProvider), "/debug/ws/connect"); addServlet(context, new DebugConnectServlet(tokenProvider), "/debug/ws/connect");
addServlet(context, new DebugLogsServlet(tokenProvider), "/debug/ws/logs"); addServlet(context, new DebugLogsServlet(tokenProvider), "/debug/ws/logs");
addServlet(context, new DebugUiReloadAllServlet(tokenProvider), "/debug/ws/ui-reload-all");
log.info("✅ Debug API включены настройкой {}=true", CFG_DEBUG_API_ENABLED); log.info("✅ Debug API включены настройкой {}=true", CFG_DEBUG_API_ENABLED);
} }

View File

@ -51,4 +51,10 @@ public final class DebugTokenProvider {
String actual = authorizationHeader.substring("Bearer ".length()).trim(); String actual = authorizationHeader.substring("Bearer ".length()).trim();
return token.equals(actual); return token.equals(actual);
} }
public boolean matchesRawToken(String rawToken) {
if (!isEnabled()) return false;
if (rawToken == null || rawToken.isBlank()) return false;
return token.equals(rawToken.trim());
}
} }

View File

@ -0,0 +1,133 @@
package server.debug;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.push.WsEventSender;
import java.io.IOException;
import java.util.Set;
/**
* POST /debug/ws/ui-reload-all
*
* Заголовки:
* - X-Debug-Token: <token> (рекомендуется)
* - или Authorization: Bearer <token>
*
* Тело (опционально):
* {
* "reason": "deploy_ui",
* "reloadAfterMs": 800
* }
*/
public class DebugUiReloadAllServlet extends HttpServlet {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final DebugTokenProvider tokenProvider;
public DebugUiReloadAllServlet(DebugTokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
if (!tokenProvider.isEnabled()) {
writeError(resp, 503, "DEBUG_DISABLED", "Debug API отключен: .debug-token не найден или пуст");
return;
}
String headerToken = req.getHeader("X-Debug-Token");
String bearerHeader = req.getHeader("Authorization");
boolean authorized = tokenProvider.matchesRawToken(headerToken) || tokenProvider.matchesBearerHeader(bearerHeader);
if (!authorized) {
writeError(resp, 401, "UNAUTHORIZED", "Неверный debug token");
return;
}
JsonNode body = null;
try {
if (req.getContentLengthLong() != 0) {
body = MAPPER.readTree(req.getInputStream());
}
} catch (Exception e) {
writeError(resp, 400, "BAD_JSON", "Тело запроса должно быть JSON");
return;
}
String reason = safe(text(body, "reason"));
if (reason.isBlank()) reason = "manual_debug_api";
int reloadAfterMs = clampInt(body == null ? 700 : body.path("reloadAfterMs").asInt(700), 100, 15_000);
long issuedAtMs = System.currentTimeMillis();
Set<ConnectionContext> all = ActiveConnectionsRegistry.getInstance().getAllConnectionsSnapshot();
int totalConnections = 0;
int sent = 0;
for (ConnectionContext ctx : all) {
if (ctx == null || ctx.getWsSession() == null || !ctx.getWsSession().isOpen()) continue;
totalConnections += 1;
ObjectNode payload = MAPPER.createObjectNode();
payload.put("reason", reason);
payload.put("issuedAtMs", issuedAtMs);
payload.put("reloadAfterMs", reloadAfterMs);
String sessionId = safe(ctx.getSessionId());
String eventId = "evt-ui-reload-" + issuedAtMs + "-" + (sessionId.isBlank() ? totalConnections : sessionId);
if (WsEventSender.sendEvent(ctx, "ForceUiReload", eventId, payload)) {
sent += 1;
}
}
ObjectNode payload = MAPPER.createObjectNode();
payload.put("accepted", sent > 0);
payload.put("reason", reason);
payload.put("reloadAfterMs", reloadAfterMs);
payload.put("issuedAtMs", issuedAtMs);
payload.put("totalConnections", totalConnections);
payload.put("sentCount", sent);
payload.put("skippedCount", Math.max(0, totalConnections - sent));
writeOk(resp, payload);
}
private static int clampInt(int value, int min, int max) {
return Math.min(max, Math.max(min, value));
}
private static String text(JsonNode node, String field) {
if (node == null) return "";
return String.valueOf(node.path(field).asText(""));
}
private static String safe(String value) {
return value == null ? "" : value.trim();
}
private static void writeOk(HttpServletResponse resp, ObjectNode payload) throws IOException {
ObjectNode root = MAPPER.createObjectNode();
root.put("ok", true);
root.set("payload", payload == null ? MAPPER.createObjectNode() : payload);
writeJson(resp, 200, root);
}
private static void writeError(HttpServletResponse resp, int status, String code, String message) throws IOException {
ObjectNode root = MAPPER.createObjectNode();
root.put("ok", false);
root.put("code", code);
root.put("message", message);
writeJson(resp, status, root);
}
private static void writeJson(HttpServletResponse resp, int status, ObjectNode root) throws IOException {
resp.setStatus(status);
resp.setCharacterEncoding("UTF-8");
resp.setContentType("application/json; charset=UTF-8");
resp.getWriter().write(MAPPER.writeValueAsString(root));
}
}