feat(update): server push-команда на массовое обновление UI и формат версий
This commit is contained in:
parent
630ba30c27
commit
f213e9aa43
@ -1,2 +1,2 @@
|
||||
client.version=1.2.1
|
||||
server.version=1.2.1
|
||||
client.version=1.2.2
|
||||
server.version=1.2.2
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
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) {
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
<title>Shine UI Demo</title>
|
||||
<script>
|
||||
window.__SHINE_BUILD_HASH__ = '20260413151200';
|
||||
window.__SHINE_CLIENT_VERSION__ = '1.2.1';
|
||||
window.__SHINE_CLIENT_VERSION__ = '1.2.2';
|
||||
</script>
|
||||
<script>
|
||||
(function attachStylesWithBuildHash() {
|
||||
|
||||
@ -118,6 +118,7 @@ let connectionNextRetryAtMs = 0;
|
||||
let connectionCheckInFlight = false;
|
||||
let wsSessionRestoreInFlight = null;
|
||||
let uiUpdateReloadScheduled = false;
|
||||
let pwaUpdateCheckAttempted = false;
|
||||
|
||||
setClientErrorTransport((payload) => authService.reportClientError(payload));
|
||||
initPwaInstallPromptHandling();
|
||||
@ -235,20 +236,62 @@ async function triggerImmediateConnectionRetry() {
|
||||
}
|
||||
|
||||
function checkAndReloadIfUiUpdated(remoteHashRaw) {
|
||||
if (uiUpdateReloadScheduled) return;
|
||||
const remoteHash = String(remoteHashRaw || '').trim();
|
||||
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;
|
||||
addAppLogEntry({
|
||||
level: 'info',
|
||||
source: 'version-check',
|
||||
message: `Обнаружена новая версия UI (через Ping): ${CURRENT_BUILD_HASH} -> ${remoteHash}`,
|
||||
source,
|
||||
message,
|
||||
});
|
||||
setConnectionStatus('updating');
|
||||
void refreshServiceWorkers({ activateWaitingWorker });
|
||||
|
||||
const ms = Math.min(15_000, Math.max(200, Number(delayMs || 700)));
|
||||
window.setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 600);
|
||||
}, ms);
|
||||
}
|
||||
|
||||
async function tryUpdatePwaOnFirstConnectedPing() {
|
||||
if (pwaUpdateCheckAttempted) return;
|
||||
pwaUpdateCheckAttempted = true;
|
||||
await refreshServiceWorkers({ activateWaitingWorker: false });
|
||||
}
|
||||
|
||||
async function checkConnectionHealth() {
|
||||
@ -271,6 +314,7 @@ async function checkConnectionHealth() {
|
||||
const pingResp = await authService.ws.request('Ping', { ts: Date.now() }, 7000);
|
||||
const remoteUiBuildHash = pingResp?.payload?.uiBuildHash || pingResp?.uiBuildHash || '';
|
||||
checkAndReloadIfUiUpdated(remoteUiBuildHash);
|
||||
await tryUpdatePwaOnFirstConnectedPing();
|
||||
setConnectionStatus('connected');
|
||||
} catch {
|
||||
connectionStatusText = '';
|
||||
@ -618,6 +662,18 @@ async function init() {
|
||||
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) => {
|
||||
const payload = evt?.payload || {};
|
||||
const messageKey = String(payload.messageKey || '').trim();
|
||||
|
||||
@ -10,6 +10,28 @@ import { initPwaPush } from '../services/pwa-push-service.js';
|
||||
|
||||
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 }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
@ -129,11 +151,11 @@ export function render({ navigate }) {
|
||||
|
||||
const clientVersion = document.createElement('p');
|
||||
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');
|
||||
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');
|
||||
serverVersion.className = 'meta-muted';
|
||||
@ -143,11 +165,19 @@ export function render({ navigate }) {
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const resp = await authService.ws.request('GetServerInfo', {});
|
||||
const value = String(resp?.payload?.version || '').trim() || 'n/a';
|
||||
if (!isDisposed) {
|
||||
serverVersion.textContent = `Сервер: ${value}`;
|
||||
let value = '';
|
||||
try {
|
||||
const pingResp = await authService.ws.request('Ping', { ts: Date.now() }, 7000);
|
||||
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 {
|
||||
if (!isDisposed) {
|
||||
serverVersion.textContent = 'Сервер: недоступно';
|
||||
|
||||
@ -40,6 +40,7 @@ public class Net_Ping_Handler implements JsonMessageHandler {
|
||||
// ничего не проверяем, просто отдаём серверное время
|
||||
resp.setTs(System.currentTimeMillis());
|
||||
resp.setUiBuildHash(resolveUiBuildHash());
|
||||
resp.setServerVersion(CONFIG.getStringOrEmpty("server.version"));
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
@ -10,7 +10,8 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
* "status": 200,
|
||||
* "payload": {
|
||||
* "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 String uiBuildHash;
|
||||
private String serverVersion;
|
||||
|
||||
public long getTs() { return ts; }
|
||||
public void setTs(long ts) { this.ts = ts; }
|
||||
|
||||
public String getUiBuildHash() { return uiBuildHash; }
|
||||
public void setUiBuildHash(String uiBuildHash) { this.uiBuildHash = uiBuildHash; }
|
||||
|
||||
public String getServerVersion() { return serverVersion; }
|
||||
public void setServerVersion(String serverVersion) { this.serverVersion = serverVersion; }
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ public final class DebugApiConfigurator {
|
||||
addServlet(context, new DebugClientsServlet(tokenProvider), "/debug/ws/clients");
|
||||
addServlet(context, new DebugConnectServlet(tokenProvider), "/debug/ws/connect");
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -51,4 +51,10 @@ public final class DebugTokenProvider {
|
||||
String actual = authorizationHeader.substring("Bearer ".length()).trim();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
133
src/main/java/server/debug/DebugUiReloadAllServlet.java
Normal file
133
src/main/java/server/debug/DebugUiReloadAllServlet.java
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user