feat(update): server push-команда на массовое обновление UI и формат версий
This commit is contained in:
parent
630ba30c27
commit
f213e9aa43
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.1
|
client.version=1.2.2
|
||||||
server.version=1.2.1
|
server.version=1.2.2
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 = 'Сервер: недоступно';
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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