UI: исправить автообновление и хронологию диалогов; обновить деплой-цели
This commit is contained in:
parent
f213e9aa43
commit
93aef6e18b
50
build.gradle
50
build.gradle
@ -174,10 +174,10 @@ tasks.named('build') {
|
|||||||
|
|
||||||
tasks.register('deployServer', JavaExec) {
|
tasks.register('deployServer', JavaExec) {
|
||||||
group = "!!deployment"
|
group = "!!deployment"
|
||||||
description = "Build → upload to server → clean remote data → restart service → run IT against server"
|
description = "Build → upload to server → restart service (без удаления БД, без IT тестов)"
|
||||||
|
|
||||||
classpath = sourceSets.test.runtimeClasspath
|
classpath = sourceSets.test.runtimeClasspath
|
||||||
mainClass = "test.it.IT_DeployRestartAndRunRemoteMain"
|
mainClass = "test.it.IT_DeployRestartNoCleanNoTestsMain"
|
||||||
|
|
||||||
// можно переопределить при запуске:
|
// можно переопределить при запуске:
|
||||||
// ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=...
|
// ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=...
|
||||||
@ -185,13 +185,30 @@ tasks.register('deployServer', JavaExec) {
|
|||||||
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "194.87.0.247")
|
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "194.87.0.247")
|
||||||
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "user")
|
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "user")
|
||||||
systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/user/docker/shine-server")
|
systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/user/docker/shine-server")
|
||||||
systemProperty "it.remoteDataDir", System.getProperty("it.remoteDataDir", "/home/user/docker/shine-server/data")
|
|
||||||
systemProperty "it.service", System.getProperty("it.service", "shine-server")
|
systemProperty "it.service", System.getProperty("it.service", "shine-server")
|
||||||
systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar")
|
systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar")
|
||||||
|
|
||||||
systemProperty "it.wsUri", System.getProperty("it.wsUri", "wss://shineup.me/ws")
|
dependsOn testClasses
|
||||||
systemProperty "it.login", System.getProperty("it.login", "anya24")
|
}
|
||||||
|
|
||||||
|
tasks.register('deployServerWithBackupCleanAndTests', JavaExec) {
|
||||||
|
group = "!!deployment"
|
||||||
|
description = "DANGER: deploy + backup data + clean data + restart + run IT tests (с обязательным подтверждением)"
|
||||||
|
|
||||||
|
classpath = sourceSets.test.runtimeClasspath
|
||||||
|
mainClass = "test.it.IT_DeployBackupCleanAndRunRemoteMain"
|
||||||
|
|
||||||
|
dependsOn shadowJar
|
||||||
|
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "194.87.0.247")
|
||||||
|
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "user")
|
||||||
|
systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/user/docker/shine-server")
|
||||||
|
systemProperty "it.remoteDataDir", System.getProperty("it.remoteDataDir", "/home/user/docker/shine-server/data")
|
||||||
|
systemProperty "it.remoteBackupDir", System.getProperty("it.remoteBackupDir", "/home/user/docker/shine-server/backup")
|
||||||
|
systemProperty "it.service", System.getProperty("it.service", "shine-server")
|
||||||
|
systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar")
|
||||||
|
systemProperty "it.wsUri", System.getProperty("it.wsUri", "wss://shineup.me/ws")
|
||||||
|
|
||||||
|
standardInput = System.in
|
||||||
dependsOn testClasses
|
dependsOn testClasses
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -214,21 +231,22 @@ tasks.register('deployServerNoCleanNoTests', JavaExec) {
|
|||||||
dependsOn testClasses
|
dependsOn testClasses
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register('deployWEB', Exec) {
|
def registerWebDeployTask = { String taskName, String target, String descriptionText ->
|
||||||
|
tasks.register(taskName, Exec) {
|
||||||
group = "!!deployment"
|
group = "!!deployment"
|
||||||
description = "Deploy WEB via deploy_shine-PWA.sh"
|
description = descriptionText
|
||||||
|
|
||||||
workingDir = rootDir
|
workingDir = rootDir
|
||||||
commandLine 'bash', file('deploy_shine-PWA.sh').absolutePath
|
commandLine 'bash', file('deploy_shine-PWA.sh').absolutePath, target
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register('deployAll') {
|
registerWebDeployTask('deployWEB_Production', 'prod', 'Deploy WEB (production: shineup.me)')
|
||||||
group = "!!deployment"
|
registerWebDeployTask('deployWEB_ui_1', 'ui_1', 'Deploy WEB (ui-1.shineup.me)')
|
||||||
description = "Deploy server and WEB"
|
registerWebDeployTask('deployWEB_ui_2', 'ui_2', 'Deploy WEB (ui-2.shineup.me)')
|
||||||
|
registerWebDeployTask('deployWEB_ui_3', 'ui_3', 'Deploy WEB (ui-3.shineup.me)')
|
||||||
dependsOn tasks.named('deployServer')
|
registerWebDeployTask('deployWEB_DrygMira', 'ui_drygmira', 'Deploy WEB (ui-drygmira.shineup.me)')
|
||||||
dependsOn tasks.named('deployWEB')
|
registerWebDeployTask('deployWEB_Milana', 'ui_milana', 'Deploy WEB (ui-milana.shineup.me)')
|
||||||
}
|
registerWebDeployTask('deployWEB_Aidar', 'ui_aidar', 'Deploy WEB (ui-aidar.shineup.me)')
|
||||||
|
|
||||||
tasks.register('startLocal', Exec) {
|
tasks.register('startLocal', Exec) {
|
||||||
group = "!!run"
|
group = "!!run"
|
||||||
|
|||||||
@ -2,12 +2,13 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SRC_DIR="shine-UI"
|
SRC_DIR="shine-UI"
|
||||||
REMOTE_HOST="root@194.87.0.247"
|
REMOTE_HOST="${REMOTE_HOST:-user@10.147.20.7}"
|
||||||
REMOTE_DIR="/home/user/docker/caddyFile/sites/shine-UI"
|
REMOTE_BASE_DIR="${REMOTE_BASE_DIR:-/home/user/docker/caddyFile/sites}"
|
||||||
BUILD_VERSION="$(date -u +%Y%m%d%H%M%S)"
|
BUILD_VERSION="$(date -u +%Y%m%d%H%M%S)"
|
||||||
VERSION_FILE="VERSION.properties"
|
VERSION_FILE="VERSION.properties"
|
||||||
export BUILD_VERSION
|
export BUILD_VERSION
|
||||||
TMP_DIR="$(mktemp -d)"
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
TARGET="${1:-prod}"
|
||||||
|
|
||||||
CLIENT_VERSION="dev"
|
CLIENT_VERSION="dev"
|
||||||
if [[ -f "$VERSION_FILE" ]]; then
|
if [[ -f "$VERSION_FILE" ]]; then
|
||||||
@ -18,6 +19,45 @@ if [[ -f "$VERSION_FILE" ]]; then
|
|||||||
fi
|
fi
|
||||||
export CLIENT_VERSION
|
export CLIENT_VERSION
|
||||||
|
|
||||||
|
TARGET_DIR="shine-UI"
|
||||||
|
TARGET_URL="https://shineup.me"
|
||||||
|
case "$TARGET" in
|
||||||
|
prod|production|main|shineup|shineup.me|shine-UI)
|
||||||
|
TARGET_DIR="shine-UI"
|
||||||
|
TARGET_URL="https://shineup.me"
|
||||||
|
;;
|
||||||
|
ui_1|ui-1|1|shine-UI_1)
|
||||||
|
TARGET_DIR="shine-UI_1"
|
||||||
|
TARGET_URL="https://ui-1.shineup.me"
|
||||||
|
;;
|
||||||
|
ui_2|ui-2|2|shine-UI_2)
|
||||||
|
TARGET_DIR="shine-UI_2"
|
||||||
|
TARGET_URL="https://ui-2.shineup.me"
|
||||||
|
;;
|
||||||
|
ui_3|ui-3|3|shine-UI_3)
|
||||||
|
TARGET_DIR="shine-UI_3"
|
||||||
|
TARGET_URL="https://ui-3.shineup.me"
|
||||||
|
;;
|
||||||
|
ui_drygmira|ui-drygmira|drygmira|shine-UI_drygmira)
|
||||||
|
TARGET_DIR="shine-UI_drygmira"
|
||||||
|
TARGET_URL="https://ui-drygmira.shineup.me"
|
||||||
|
;;
|
||||||
|
ui_milana|ui-milana|milana|shine-UI_milana)
|
||||||
|
TARGET_DIR="shine-UI_milana"
|
||||||
|
TARGET_URL="https://ui-milana.shineup.me"
|
||||||
|
;;
|
||||||
|
ui_aidar|ui-aidar|aidar|shine-UI_aidar)
|
||||||
|
TARGET_DIR="shine-UI_aidar"
|
||||||
|
TARGET_URL="https://ui-aidar.shineup.me"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: unknown target '$TARGET'" >&2
|
||||||
|
echo "Available targets: prod, ui_1, ui_2, ui_3, ui_drygmira, ui_milana, ui_aidar" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
REMOTE_DIR="${REMOTE_BASE_DIR}/${TARGET_DIR}"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -rf "$TMP_DIR"
|
rm -rf "$TMP_DIR"
|
||||||
}
|
}
|
||||||
@ -29,6 +69,7 @@ if [[ ! -d "$SRC_DIR" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Preparing staged UI copy with build version: $BUILD_VERSION"
|
echo "==> Preparing staged UI copy with build version: $BUILD_VERSION"
|
||||||
|
echo "==> Deploy target: $TARGET_URL ($TARGET_DIR)"
|
||||||
rsync -a "$SRC_DIR"/ "$TMP_DIR"/
|
rsync -a "$SRC_DIR"/ "$TMP_DIR"/
|
||||||
|
|
||||||
INDEX_FILE="$TMP_DIR/index.html"
|
INDEX_FILE="$TMP_DIR/index.html"
|
||||||
@ -49,4 +90,4 @@ ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'"
|
|||||||
echo "==> Syncing staged files to $REMOTE_DIR"
|
echo "==> Syncing staged files to $REMOTE_DIR"
|
||||||
rsync -avz --delete "$TMP_DIR"/ "$REMOTE_HOST":"$REMOTE_DIR"/
|
rsync -avz --delete "$TMP_DIR"/ "$REMOTE_HOST":"$REMOTE_DIR"/
|
||||||
|
|
||||||
echo "Всё хорошо"
|
echo "Всё хорошо: $TARGET_URL"
|
||||||
|
|||||||
38
doc/instructions/ui-deploy-targets.md
Normal file
38
doc/instructions/ui-deploy-targets.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# Деплой UI по окружениям (Caddy sites)
|
||||||
|
|
||||||
|
## Куда деплоит скрипт
|
||||||
|
- Базовая директория на сервере: `/home/user/docker/caddyFile/sites`
|
||||||
|
- По умолчанию деплой идёт на production (`shineup.me`) в папку `shine-UI`.
|
||||||
|
|
||||||
|
## Gradle-команды
|
||||||
|
- Продакшен (`shineup.me`): `./gradlew deployWEB_Production`
|
||||||
|
- `ui-1.shineup.me`: `./gradlew deployWEB_ui_1`
|
||||||
|
- `ui-2.shineup.me`: `./gradlew deployWEB_ui_2`
|
||||||
|
- `ui-3.shineup.me`: `./gradlew deployWEB_ui_3`
|
||||||
|
- `ui-drygmira.shineup.me`: `./gradlew deployWEB_DrygMira`
|
||||||
|
- `ui-milana.shineup.me`: `./gradlew deployWEB_Milana`
|
||||||
|
- `ui-aidar.shineup.me`: `./gradlew deployWEB_Aidar`
|
||||||
|
|
||||||
|
## Прямой запуск скрипта
|
||||||
|
- `bash deploy_shine-PWA.sh prod`
|
||||||
|
- `bash deploy_shine-PWA.sh ui_1`
|
||||||
|
- `bash deploy_shine-PWA.sh ui_2`
|
||||||
|
- `bash deploy_shine-PWA.sh ui_3`
|
||||||
|
- `bash deploy_shine-PWA.sh ui_drygmira`
|
||||||
|
- `bash deploy_shine-PWA.sh ui_milana`
|
||||||
|
- `bash deploy_shine-PWA.sh ui_aidar`
|
||||||
|
|
||||||
|
Также поддерживаются алиасы с дефисом:
|
||||||
|
- `bash deploy_shine-PWA.sh ui-1`
|
||||||
|
- `bash deploy_shine-PWA.sh ui-2`
|
||||||
|
- `bash deploy_shine-PWA.sh ui-3`
|
||||||
|
- `bash deploy_shine-PWA.sh ui-drygmira`
|
||||||
|
- `bash deploy_shine-PWA.sh ui-milana`
|
||||||
|
- `bash deploy_shine-PWA.sh ui-aidar`
|
||||||
|
|
||||||
|
## Поддержка переопределения
|
||||||
|
- `REMOTE_HOST` (по умолчанию `user@10.147.20.7`)
|
||||||
|
- `REMOTE_BASE_DIR` (по умолчанию `/home/user/docker/caddyFile/sites`)
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
`REMOTE_HOST=user@10.147.20.7 REMOTE_BASE_DIR=/home/user/docker/caddyFile/sites bash deploy_shine-PWA.sh ui_2`
|
||||||
@ -105,6 +105,7 @@ const appShellEl = document.querySelector('.app-shell');
|
|||||||
|
|
||||||
const CONNECTION_CHECK_INTERVAL_MS = 20 * 1000;
|
const CONNECTION_CHECK_INTERVAL_MS = 20 * 1000;
|
||||||
const CURRENT_BUILD_HASH = String(window.__SHINE_BUILD_HASH__ || '').trim();
|
const CURRENT_BUILD_HASH = String(window.__SHINE_BUILD_HASH__ || '').trim();
|
||||||
|
const UI_BUILD_HASH_PATTERN = /window\.__SHINE_BUILD_HASH__\s*=\s*'([^']+)'/;
|
||||||
|
|
||||||
let currentCleanup = null;
|
let currentCleanup = null;
|
||||||
let pingIntervalId = null;
|
let pingIntervalId = null;
|
||||||
@ -119,6 +120,7 @@ let connectionCheckInFlight = false;
|
|||||||
let wsSessionRestoreInFlight = null;
|
let wsSessionRestoreInFlight = null;
|
||||||
let uiUpdateReloadScheduled = false;
|
let uiUpdateReloadScheduled = false;
|
||||||
let pwaUpdateCheckAttempted = false;
|
let pwaUpdateCheckAttempted = false;
|
||||||
|
let uiVersionCheckInFlight = false;
|
||||||
|
|
||||||
setClientErrorTransport((payload) => authService.reportClientError(payload));
|
setClientErrorTransport((payload) => authService.reportClientError(payload));
|
||||||
initPwaInstallPromptHandling();
|
initPwaInstallPromptHandling();
|
||||||
@ -235,16 +237,51 @@ async function triggerImmediateConnectionRetry() {
|
|||||||
await checkConnectionHealth();
|
await checkConnectionHealth();
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkAndReloadIfUiUpdated(remoteHashRaw) {
|
function extractBuildHashFromHtml(htmlText) {
|
||||||
|
const html = String(htmlText || '');
|
||||||
|
if (!html) return '';
|
||||||
|
const match = html.match(UI_BUILD_HASH_PATTERN);
|
||||||
|
return String(match?.[1] || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCurrentHostUiBuildHash() {
|
||||||
|
try {
|
||||||
|
const url = `./index.html?build_probe=${Date.now()}`;
|
||||||
|
const response = await fetch(url, { cache: 'no-store' });
|
||||||
|
if (!response?.ok) return '';
|
||||||
|
const html = await response.text();
|
||||||
|
return extractBuildHashFromHtml(html);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAndReloadIfUiUpdated(remoteHashRaw) {
|
||||||
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;
|
||||||
|
if (uiVersionCheckInFlight) return;
|
||||||
|
|
||||||
|
uiVersionCheckInFlight = true;
|
||||||
|
try {
|
||||||
|
const latestHostHash = await fetchCurrentHostUiBuildHash();
|
||||||
|
if (!latestHostHash || latestHostHash === CURRENT_BUILD_HASH) {
|
||||||
|
addAppLogEntry({
|
||||||
|
level: 'info',
|
||||||
|
source: 'version-check',
|
||||||
|
message: `Ping сообщил другую версию UI (${remoteHash}), но текущий хост всё ещё отдаёт ${CURRENT_BUILD_HASH}. Reload пропущен, чтобы избежать цикла.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
scheduleUiReload({
|
scheduleUiReload({
|
||||||
source: 'version-check',
|
source: 'version-check',
|
||||||
message: `Обнаружена новая версия UI (через Ping): ${CURRENT_BUILD_HASH} -> ${remoteHash}`,
|
message: `Обнаружена новая версия UI: ${CURRENT_BUILD_HASH} -> ${latestHostHash} (ping: ${remoteHash})`,
|
||||||
delayMs: 600,
|
delayMs: 600,
|
||||||
activateWaitingWorker: true,
|
activateWaitingWorker: true,
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
uiVersionCheckInFlight = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshServiceWorkers({ activateWaitingWorker = false } = {}) {
|
async function refreshServiceWorkers({ activateWaitingWorker = false } = {}) {
|
||||||
@ -313,7 +350,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);
|
void checkAndReloadIfUiUpdated(remoteUiBuildHash);
|
||||||
await tryUpdatePwaOnFirstConnectedPing();
|
await tryUpdatePwaOnFirstConnectedPing();
|
||||||
setConnectionStatus('connected');
|
setConnectionStatus('connected');
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -68,6 +68,17 @@ function resolveDeliveryStatus(msg) {
|
|||||||
return '…';
|
return '…';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrollToLatestMessage(list) {
|
||||||
|
if (!list) return;
|
||||||
|
const apply = () => {
|
||||||
|
list.scrollTop = list.scrollHeight;
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
window.requestAnimationFrame(apply);
|
||||||
|
window.setTimeout(apply, 0);
|
||||||
|
window.setTimeout(apply, 120);
|
||||||
|
}
|
||||||
|
|
||||||
function renderLog(list, chatId) {
|
function renderLog(list, chatId) {
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
const messages = getChatMessages(chatId);
|
const messages = getChatMessages(chatId);
|
||||||
@ -110,7 +121,7 @@ function renderLog(list, chatId) {
|
|||||||
bubble.append(textNode, metaNode);
|
bubble.append(textNode, metaNode);
|
||||||
list.append(bubble);
|
list.append(bubble);
|
||||||
});
|
});
|
||||||
list.scrollTop = list.scrollHeight;
|
scrollToLatestMessage(list);
|
||||||
markChatRead(chatId);
|
markChatRead(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -240,10 +251,11 @@ export function render({ navigate, route }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
renderLog(log, chatId);
|
|
||||||
void sendReadReceiptsForVisible(chatId);
|
|
||||||
wrap.append(log, form);
|
wrap.append(log, form);
|
||||||
screen.append(wrap);
|
screen.append(wrap);
|
||||||
|
renderLog(log, chatId);
|
||||||
|
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||||
|
void sendReadReceiptsForVisible(chatId);
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
async function sendReadReceiptsForVisible(chatId) {
|
async function sendReadReceiptsForVisible(chatId) {
|
||||||
|
|||||||
@ -263,8 +263,69 @@ export const authService = new AuthService(state.entrySettings.shineServer);
|
|||||||
let onSessionReset = null;
|
let onSessionReset = null;
|
||||||
let onSessionAuthorized = null;
|
let onSessionAuthorized = null;
|
||||||
|
|
||||||
|
function parseMessageTimeFromKey(rawKey) {
|
||||||
|
const value = String(rawKey || '').trim();
|
||||||
|
if (!value) return 0;
|
||||||
|
const parts = value.split('|');
|
||||||
|
if (parts.length < 4) return 0;
|
||||||
|
const timeMs = Number(parts[2] || 0);
|
||||||
|
if (!Number.isFinite(timeMs) || timeMs <= 0) return 0;
|
||||||
|
return Math.trunc(timeMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveChatMessageTimeMs(row) {
|
||||||
|
const fromBaseKey = parseMessageTimeFromKey(row?.baseKey);
|
||||||
|
if (fromBaseKey > 0) return fromBaseKey;
|
||||||
|
|
||||||
|
const fromMessageKey = parseMessageTimeFromKey(row?.messageKey);
|
||||||
|
if (fromMessageKey > 0) return fromMessageKey;
|
||||||
|
|
||||||
|
const fromCreatedAt = Number(row?.createdAtMs || row?.ts || 0);
|
||||||
|
if (Number.isFinite(fromCreatedAt) && fromCreatedAt > 0) return Math.trunc(fromCreatedAt);
|
||||||
|
|
||||||
|
const tempId = String(row?.tempId || '').trim();
|
||||||
|
if (tempId.startsWith('tmp-')) {
|
||||||
|
const parts = tempId.split('-');
|
||||||
|
const ts = Number(parts[1] || 0);
|
||||||
|
if (Number.isFinite(ts) && ts > 0) return Math.trunc(ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableMessageOrderKey(row) {
|
||||||
|
const key = String(row?.messageKey || '').trim();
|
||||||
|
if (key) return `mk:${key}`;
|
||||||
|
const tmp = String(row?.tempId || '').trim();
|
||||||
|
if (tmp) return `tmp:${tmp}`;
|
||||||
|
const base = String(row?.baseKey || '').trim();
|
||||||
|
if (base) return `bk:${base}`;
|
||||||
|
const text = String(row?.text || '').trim();
|
||||||
|
return `txt:${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortChatMessagesInPlace(chatId) {
|
||||||
|
const list = getChatMessages(chatId);
|
||||||
|
list.sort((a, b) => {
|
||||||
|
const ta = resolveChatMessageTimeMs(a);
|
||||||
|
const tb = resolveChatMessageTimeMs(b);
|
||||||
|
if (ta !== tb) return ta - tb;
|
||||||
|
|
||||||
|
const ka = stableMessageOrderKey(a);
|
||||||
|
const kb = stableMessageOrderKey(b);
|
||||||
|
const byKey = ka.localeCompare(kb, 'ru');
|
||||||
|
if (byKey !== 0) return byKey;
|
||||||
|
|
||||||
|
if ((a?.from || '') !== (b?.from || '')) {
|
||||||
|
return (a?.from === 'in') ? -1 : 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function persistMessageRecord(chatId, row) {
|
function persistMessageRecord(chatId, row) {
|
||||||
if (!chatId || !row?.messageKey) return;
|
if (!chatId || !row?.messageKey) return;
|
||||||
|
const resolvedTs = resolveChatMessageTimeMs(row);
|
||||||
void putStoredMessage({
|
void putStoredMessage({
|
||||||
messageKey: row.messageKey,
|
messageKey: row.messageKey,
|
||||||
chatId,
|
chatId,
|
||||||
@ -278,13 +339,14 @@ function persistMessageRecord(chatId, row) {
|
|||||||
secondTick: Boolean(row.secondTick),
|
secondTick: Boolean(row.secondTick),
|
||||||
readReceiptSent: Boolean(row.readReceiptSent),
|
readReceiptSent: Boolean(row.readReceiptSent),
|
||||||
refBaseKey: String(row.refBaseKey || ''),
|
refBaseKey: String(row.refBaseKey || ''),
|
||||||
ts: Date.now(),
|
ts: resolvedTs > 0 ? resolvedTs : Date.now(),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hydrateMessagesFromStore() {
|
export async function hydrateMessagesFromStore() {
|
||||||
try {
|
try {
|
||||||
const rows = await listStoredMessages();
|
const rows = await listStoredMessages();
|
||||||
|
const touchedChats = new Set();
|
||||||
rows
|
rows
|
||||||
.sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0))
|
.sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0))
|
||||||
.forEach((row) => {
|
.forEach((row) => {
|
||||||
@ -293,6 +355,7 @@ export async function hydrateMessagesFromStore() {
|
|||||||
if (!chatId || !messageKey) return;
|
if (!chatId || !messageKey) return;
|
||||||
if (state.knownMessageKeys[messageKey]) return;
|
if (state.knownMessageKeys[messageKey]) return;
|
||||||
state.knownMessageKeys[messageKey] = true;
|
state.knownMessageKeys[messageKey] = true;
|
||||||
|
touchedChats.add(chatId);
|
||||||
getChatMessages(chatId).push({
|
getChatMessages(chatId).push({
|
||||||
from: row.from === 'out' ? 'out' : 'in',
|
from: row.from === 'out' ? 'out' : 'in',
|
||||||
text: String(row.text || ''),
|
text: String(row.text || ''),
|
||||||
@ -305,8 +368,10 @@ export async function hydrateMessagesFromStore() {
|
|||||||
secondTick: Boolean(row.secondTick),
|
secondTick: Boolean(row.secondTick),
|
||||||
readReceiptSent: Boolean(row.readReceiptSent),
|
readReceiptSent: Boolean(row.readReceiptSent),
|
||||||
refBaseKey: String(row.refBaseKey || ''),
|
refBaseKey: String(row.refBaseKey || ''),
|
||||||
|
createdAtMs: Number(row.ts || 0),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
touchedChats.forEach((chatId) => sortChatMessagesInPlace(chatId));
|
||||||
} catch {
|
} catch {
|
||||||
// ignore broken storage
|
// ignore broken storage
|
||||||
}
|
}
|
||||||
@ -322,7 +387,15 @@ export function getChatMessages(chatId) {
|
|||||||
export function addChatMessage(chatId, text) {
|
export function addChatMessage(chatId, text) {
|
||||||
const message = text.trim();
|
const message = text.trim();
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
getChatMessages(chatId).push({ from: 'out', text: message, firstTick: false, secondTick: false, unread: false });
|
getChatMessages(chatId).push({
|
||||||
|
from: 'out',
|
||||||
|
text: message,
|
||||||
|
firstTick: false,
|
||||||
|
secondTick: false,
|
||||||
|
unread: false,
|
||||||
|
createdAtMs: Date.now(),
|
||||||
|
});
|
||||||
|
sortChatMessagesInPlace(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addSystemChatMessage(chatId, text, { from = 'out', kind = 'system' } = {}) {
|
export function addSystemChatMessage(chatId, text, { from = 'out', kind = 'system' } = {}) {
|
||||||
@ -335,7 +408,9 @@ export function addSystemChatMessage(chatId, text, { from = 'out', kind = 'syste
|
|||||||
unread: from === 'in',
|
unread: from === 'in',
|
||||||
firstTick: from !== 'in',
|
firstTick: from !== 'in',
|
||||||
secondTick: false,
|
secondTick: false,
|
||||||
|
createdAtMs: Date.now(),
|
||||||
});
|
});
|
||||||
|
sortChatMessagesInPlace(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -344,7 +419,14 @@ export function addIncomingMessage(chatId, text, messageId = '') {
|
|||||||
if (!msg) return false;
|
if (!msg) return false;
|
||||||
if (messageId && state.incomingDedup[messageId]) return false;
|
if (messageId && state.incomingDedup[messageId]) return false;
|
||||||
if (messageId) state.incomingDedup[messageId] = true;
|
if (messageId) state.incomingDedup[messageId] = true;
|
||||||
getChatMessages(chatId).push({ from: 'in', text: msg, messageId, unread: true });
|
getChatMessages(chatId).push({
|
||||||
|
from: 'in',
|
||||||
|
text: msg,
|
||||||
|
messageId,
|
||||||
|
unread: true,
|
||||||
|
createdAtMs: Date.now(),
|
||||||
|
});
|
||||||
|
sortChatMessagesInPlace(chatId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -359,7 +441,9 @@ export function addOutgoingPendingMessage(chatId, text) {
|
|||||||
firstTick: false,
|
firstTick: false,
|
||||||
secondTick: false,
|
secondTick: false,
|
||||||
unread: false,
|
unread: false,
|
||||||
|
createdAtMs: Date.now(),
|
||||||
});
|
});
|
||||||
|
sortChatMessagesInPlace(chatId);
|
||||||
return tempId;
|
return tempId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -377,6 +461,7 @@ export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {})
|
|||||||
state.knownMessageKeys[messageKey] = true;
|
state.knownMessageKeys[messageKey] = true;
|
||||||
persistMessageRecord(chatId, row);
|
persistMessageRecord(chatId, row);
|
||||||
}
|
}
|
||||||
|
sortChatMessagesInPlace(chatId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -455,6 +540,7 @@ export function addSignedMessageToChat({
|
|||||||
secondTick: false,
|
secondTick: false,
|
||||||
};
|
};
|
||||||
getChatMessages(chatId).push(row);
|
getChatMessages(chatId).push(row);
|
||||||
|
sortChatMessagesInPlace(chatId);
|
||||||
persistMessageRecord(chatId, row);
|
persistMessageRecord(chatId, row);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
190
src/test/java/test/it/IT_DeployBackupCleanAndRunRemoteMain.java
Normal file
190
src/test/java/test/it/IT_DeployBackupCleanAndRunRemoteMain.java
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
package test.it;
|
||||||
|
|
||||||
|
import test.it.runner.IT_RunAllMain;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.util.Enumeration;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.jar.JarEntry;
|
||||||
|
import java.util.jar.JarFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Деплой сервера с ПОЛНОЙ очисткой data, но только после:
|
||||||
|
* 1) явного интерактивного подтверждения;
|
||||||
|
* 2) бэкапа data в backup/data_YYYYMMDD-HHMMSS-SSS.
|
||||||
|
*
|
||||||
|
* После запуска сервиса выполняет полный IT-прогон.
|
||||||
|
*/
|
||||||
|
public class IT_DeployBackupCleanAndRunRemoteMain {
|
||||||
|
|
||||||
|
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "194.87.0.247");
|
||||||
|
private static final String REMOTE_USER = System.getProperty("it.remoteUser", "user");
|
||||||
|
|
||||||
|
private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/user/docker/shine-server");
|
||||||
|
private static final String REMOTE_JAR = REMOTE_DIR + "/shine-server.jar";
|
||||||
|
private static final String REMOTE_DATA = System.getProperty("it.remoteDataDir", REMOTE_DIR + "/data");
|
||||||
|
private static final String REMOTE_BACKUP_DIR = System.getProperty("it.remoteBackupDir", REMOTE_DIR + "/backup");
|
||||||
|
|
||||||
|
private static final String SERVICE_NAME = System.getProperty("it.service", "shine-server");
|
||||||
|
private static final String LOCAL_JAR = System.getProperty("it.localJar", "build/libs/shine-server.jar");
|
||||||
|
private static final String WS_URI_SERVER = System.getProperty("it.wsUri", "wss://shineup.me/ws");
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
requireDangerousConfirmation();
|
||||||
|
|
||||||
|
boolean serviceStopped = false;
|
||||||
|
boolean serviceStarted = false;
|
||||||
|
try {
|
||||||
|
sshStrict("sudo systemctl stop " + SERVICE_NAME + " || true");
|
||||||
|
serviceStopped = true;
|
||||||
|
|
||||||
|
validateLocalFatJarOrThrow(LOCAL_JAR);
|
||||||
|
sshStrict("mkdir -p " + q(REMOTE_DIR));
|
||||||
|
scpStrict(LOCAL_JAR, REMOTE_JAR + ".new");
|
||||||
|
verifyRemoteNewJarOrThrow(REMOTE_JAR + ".new");
|
||||||
|
sshStrict("mv -f " + q(REMOTE_JAR + ".new") + " " + q(REMOTE_JAR));
|
||||||
|
|
||||||
|
backupAndCleanRemoteDataOrThrow();
|
||||||
|
|
||||||
|
sshStrict("sudo systemctl start " + SERVICE_NAME);
|
||||||
|
serviceStarted = true;
|
||||||
|
waitRemotePort7070();
|
||||||
|
|
||||||
|
System.setProperty("it.wsUri", WS_URI_SERVER);
|
||||||
|
int failed = IT_RunAllMain.runAll();
|
||||||
|
System.exit(failed);
|
||||||
|
} catch (Throwable error) {
|
||||||
|
// На случай сбоя стараемся поднять сервис обратно.
|
||||||
|
if (serviceStopped && !serviceStarted) {
|
||||||
|
try {
|
||||||
|
sshStrict("sudo systemctl start " + SERVICE_NAME + " || true");
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireDangerousConfirmation() {
|
||||||
|
String warning = ""
|
||||||
|
+ "\n============================================================\n"
|
||||||
|
+ "ВНИМАНИЕ: будет очищена серверная БД после бэкапа.\n"
|
||||||
|
+ "HOST: " + REMOTE_USER + "@" + REMOTE_HOST + "\n"
|
||||||
|
+ "DATA: " + REMOTE_DATA + "\n"
|
||||||
|
+ "BACKUP DIR: " + REMOTE_BACKUP_DIR + "\n"
|
||||||
|
+ "Для продолжения введите: DELETE\n"
|
||||||
|
+ "============================================================\n";
|
||||||
|
System.out.print(warning);
|
||||||
|
try {
|
||||||
|
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
|
||||||
|
String answer = reader.readLine();
|
||||||
|
if (!"DELETE".equals(String.valueOf(answer).trim())) {
|
||||||
|
throw new RuntimeException("Операция отменена: подтверждение не получено.");
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Не удалось прочитать подтверждение из stdin", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void backupAndCleanRemoteDataOrThrow() {
|
||||||
|
String cmd = ""
|
||||||
|
+ "mkdir -p " + q(REMOTE_DATA) + " " + q(REMOTE_BACKUP_DIR) + " && "
|
||||||
|
+ "stamp=$(date +%Y%m%d-%H%M%S)-$(date +%3N) && "
|
||||||
|
+ "backup=" + q(REMOTE_BACKUP_DIR) + "/data_$stamp && "
|
||||||
|
+ "cp -a " + q(REMOTE_DATA) + " \"$backup\" && "
|
||||||
|
+ "echo backup_dir=$backup && "
|
||||||
|
+ "find " + q(REMOTE_DATA) + " -mindepth 1 -maxdepth 1 -exec rm -rf {} +";
|
||||||
|
sshStrict(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void waitRemotePort7070() {
|
||||||
|
for (int i = 0; i < 50; i++) {
|
||||||
|
int code = ssh("ss -ltnp | grep -q ':7070'");
|
||||||
|
if (code == 0) return;
|
||||||
|
sleepMs(200);
|
||||||
|
}
|
||||||
|
throw new RuntimeException("Remote port 7070 did not start in time on " + REMOTE_HOST);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sshStrict(String remoteCmd) {
|
||||||
|
int code = ssh(remoteCmd);
|
||||||
|
if (code != 0) throw new RuntimeException("SSH command failed (" + code + "): " + remoteCmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ssh(String remoteCmd) {
|
||||||
|
String cmd = "ssh " + REMOTE_USER + "@" + REMOTE_HOST + " " + q("bash -lc " + q(remoteCmd));
|
||||||
|
return sh(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void scpStrict(String local, String remote) {
|
||||||
|
Objects.requireNonNull(local);
|
||||||
|
Objects.requireNonNull(remote);
|
||||||
|
int code = sh("scp -p " + q(local) + " " + REMOTE_USER + "@" + REMOTE_HOST + ":" + q(remote));
|
||||||
|
if (code != 0) throw new RuntimeException("SCP failed (" + code + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int sh(String cmd) {
|
||||||
|
try {
|
||||||
|
Process p = new ProcessBuilder("bash", "-lc", cmd).inheritIO().start();
|
||||||
|
return p.waitFor();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Command error: " + cmd, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String q(String s) {
|
||||||
|
return "'" + s.replace("'", "'\"'\"'") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sleepMs(long ms) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(ms);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateLocalFatJarOrThrow(String localJarPath) {
|
||||||
|
File jar = new File(localJarPath);
|
||||||
|
if (!jar.isFile()) {
|
||||||
|
throw new RuntimeException("Local jar not found: " + localJarPath);
|
||||||
|
}
|
||||||
|
long size = jar.length();
|
||||||
|
if (size < 10L * 1024L * 1024L) {
|
||||||
|
throw new RuntimeException("Local jar is too small for fat-jar: " + size + " bytes (" + localJarPath + ")");
|
||||||
|
}
|
||||||
|
try (JarFile jf = new JarFile(jar)) {
|
||||||
|
boolean hasJetty = false;
|
||||||
|
boolean hasBc = false;
|
||||||
|
Enumeration<JarEntry> entries = jf.entries();
|
||||||
|
while (entries.hasMoreElements()) {
|
||||||
|
String name = entries.nextElement().getName();
|
||||||
|
if (!hasJetty && "org/eclipse/jetty/server/Handler.class".equals(name)) hasJetty = true;
|
||||||
|
if (!hasBc && "org/bouncycastle/jce/provider/BouncyCastleProvider.class".equals(name)) hasBc = true;
|
||||||
|
if (hasJetty && hasBc) break;
|
||||||
|
}
|
||||||
|
if (!hasJetty || !hasBc) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Local jar doesn't look like fat-jar (missing deps). hasJetty=" + hasJetty + ", hasBC=" + hasBc
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to inspect local jar: " + localJarPath, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void verifyRemoteNewJarOrThrow(String remoteJarNewPath) {
|
||||||
|
String cmd = "test -f " + q(remoteJarNewPath) + " && " +
|
||||||
|
"sz=$(stat -c %s " + q(remoteJarNewPath) + ") && " +
|
||||||
|
"echo remote_new_size=$sz && test \"$sz\" -ge 10485760";
|
||||||
|
int code = ssh(cmd);
|
||||||
|
if (code != 0) {
|
||||||
|
throw new RuntimeException("Remote uploaded jar is missing or too small: " + remoteJarNewPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user