sync status widget

This commit is contained in:
zadam 2021-03-21 22:43:41 +01:00
parent 1a9919a866
commit 392a00ac17
9 changed files with 156 additions and 112 deletions

View File

@ -12,9 +12,10 @@ const messageHandlers = [];
let ws; let ws;
let lastAcceptedEntityChangeId = window.glob.maxEntityChangeIdAtLoad; let lastAcceptedEntityChangeId = window.glob.maxEntityChangeIdAtLoad;
let lastAcceptedEntityChangeSyncId = window.glob.maxEntityChangeSyncIdAtLoad;
let lastProcessedEntityChangeId = window.glob.maxEntityChangeIdAtLoad; let lastProcessedEntityChangeId = window.glob.maxEntityChangeIdAtLoad;
let lastPingTs; let lastPingTs;
let syncDataQueue = []; let frontendUpdateDataQueue = [];
function logError(message) { function logError(message) {
console.error(utils.now(), message); // needs to be separate from .trace() console.error(utils.now(), message); // needs to be separate from .trace()
@ -34,7 +35,7 @@ function subscribeToMessages(messageHandler) {
messageHandlers.push(messageHandler); messageHandlers.push(messageHandler);
} }
// used to serialize sync operations // used to serialize frontend update operations
let consumeQueuePromise = null; let consumeQueuePromise = null;
// to make sure each change event is processed only once. Not clear if this is still necessary // to make sure each change event is processed only once. Not clear if this is still necessary
@ -46,7 +47,7 @@ function logRows(entityChanges) {
&& (row.entityName !== 'options' || row.entityId !== 'openTabs')); && (row.entityName !== 'options' || row.entityId !== 'openTabs'));
if (filteredRows.length > 0) { if (filteredRows.length > 0) {
console.debug(utils.now(), "Sync data: ", filteredRows); console.debug(utils.now(), "Frontend update data: ", filteredRows);
} }
} }
@ -57,17 +58,24 @@ async function handleMessage(event) {
messageHandler(message); messageHandler(message);
} }
if (message.type === 'sync') { if (message.type === 'frontend-update') {
let entityChanges = message.data; let {entityChanges, lastSyncedPush} = message.data;
lastPingTs = Date.now(); lastPingTs = Date.now();
if (entityChanges.length > 0) { if (entityChanges.length > 0) {
logRows(entityChanges); logRows(entityChanges);
syncDataQueue.push(...entityChanges); frontendUpdateDataQueue.push(...entityChanges);
// we set lastAcceptedEntityChangeId even before sync processing and send ping so that backend can start sending more updates // we set lastAcceptedEntityChangeId even before frontend update processing and send ping so that backend can start sending more updates
lastAcceptedEntityChangeId = Math.max(lastAcceptedEntityChangeId, entityChanges[entityChanges.length - 1].id); lastAcceptedEntityChangeId = Math.max(lastAcceptedEntityChangeId, entityChanges[entityChanges.length - 1].id);
const lastSyncEntityChange = entityChanges.slice().reverse().find(ec => ec.isSynced);
if (lastSyncEntityChange) {
lastAcceptedEntityChangeSyncId = Math.max(lastAcceptedEntityChangeSyncId, lastSyncEntityChange.id);
}
sendPing(); sendPing();
// first wait for all the preceding consumers to finish // first wait for all the preceding consumers to finish
@ -77,7 +85,7 @@ async function handleMessage(event) {
try { try {
// it's my turn so start it up // it's my turn so start it up
consumeQueuePromise = consumeSyncData(); consumeQueuePromise = consumeFrontendUpdateData();
await consumeQueuePromise; await consumeQueuePromise;
} }
@ -129,19 +137,10 @@ function checkEntityChangeIdListeners() {
.forEach(l => console.log(`Waiting for entityChangeId ${l.desiredEntityChangeId} while last processed is ${lastProcessedEntityChangeId} (last accepted ${lastAcceptedEntityChangeId}) for ${Math.floor((Date.now() - l.start) / 1000)}s`)); .forEach(l => console.log(`Waiting for entityChangeId ${l.desiredEntityChangeId} while last processed is ${lastProcessedEntityChangeId} (last accepted ${lastAcceptedEntityChangeId}) for ${Math.floor((Date.now() - l.start) / 1000)}s`));
} }
async function runSafely(syncHandler, syncData) { async function consumeFrontendUpdateData() {
try { if (frontendUpdateDataQueue.length > 0) {
return await syncHandler(syncData); const allEntityChanges = frontendUpdateDataQueue;
} frontendUpdateDataQueue = [];
catch (e) {
console.log(`Sync handler failed with ${e.message}: ${e.stack}`);
}
}
async function consumeSyncData() {
if (syncDataQueue.length > 0) {
const allEntityChanges = syncDataQueue;
syncDataQueue = [];
const nonProcessedEntityChanges = allEntityChanges.filter(ec => !processedEntityChangeIds.has(ec.id)); const nonProcessedEntityChanges = allEntityChanges.filter(ec => !processedEntityChangeIds.has(ec.id));
@ -213,30 +212,6 @@ setTimeout(() => {
setInterval(sendPing, 1000); setInterval(sendPing, 1000);
}, 0); }, 0);
subscribeToMessages(async message => {
const appContext = (await import("./app_context.js")).default;
if (message.type === 'sync-pull-in-progress') {
toastService.showPersistent({
id: 'sync',
title: "Sync status",
message: "Sync update in progress",
icon: "refresh"
});
appContext.triggerEvent('syncInProgress');
}
else if (message.type === 'sync-finished') {
// this gives user a chance to see the toast in case of fast sync finish
setTimeout(() => toastService.closePersistent('sync'), 1000);
appContext.triggerEvent('syncFinished');
}
else if (message.type === 'sync-failed') {
appContext.triggerEvent('syncFailed');
}
});
async function processEntityChanges(entityChanges) { async function processEntityChanges(entityChanges) {
const loadResults = new LoadResults(treeCache); const loadResults = new LoadResults(treeCache);
@ -413,5 +388,6 @@ export default {
logError, logError,
subscribeToMessages, subscribeToMessages,
waitForEntityChangeId, waitForEntityChangeId,
waitForMaxKnownEntityChangeId waitForMaxKnownEntityChangeId,
getMaxKnownEntityChangeSyncId: () => lastAcceptedEntityChangeSyncId
}; };

View File

@ -1,6 +1,5 @@
import BasicWidget from "./basic_widget.js"; import BasicWidget from "./basic_widget.js";
import utils from "../services/utils.js"; import utils from "../services/utils.js";
import syncService from "../services/sync.js";
const TPL = ` const TPL = `
<div class="global-menu-wrapper"> <div class="global-menu-wrapper">
@ -45,11 +44,6 @@ const TPL = `
Options Options
</a> </a>
<a class="dropdown-item sync-now-button" title="Trigger sync">
<span class="bx bx-refresh"></span>
Sync now
</a>
<a class="dropdown-item" data-trigger-command="openNewWindow"> <a class="dropdown-item" data-trigger-command="openNewWindow">
<span class="bx bx-window-open"></span> <span class="bx bx-window-open"></span>
Open new window Open new window
@ -121,8 +115,6 @@ export default class GlobalMenuWidget extends BasicWidget {
this.$widget.find(".show-about-dialog-button").on('click', this.$widget.find(".show-about-dialog-button").on('click',
() => import("../dialogs/about.js").then(d => d.showDialog())); () => import("../dialogs/about.js").then(d => d.showDialog()));
this.$widget.find(".sync-now-button").on('click', () => syncService.syncNow());
this.$widget.find(".logout-button").toggle(!utils.isElectron()); this.$widget.find(".logout-button").toggle(!utils.isElectron());
this.$widget.find(".open-dev-tools-button").toggle(utils.isElectron()); this.$widget.find(".open-dev-tools-button").toggle(utils.isElectron());

View File

@ -1,9 +1,13 @@
import BasicWidget from "./basic_widget.js"; import BasicWidget from "./basic_widget.js";
import toastService from "../services/toast.js";
import ws from "../services/ws.js";
import options from "../services/options.js";
import syncService from "../services/sync.js";
const TPL = ` const TPL = `
<div class="sync-status-wrapper"> <div class="sync-status-widget">
<style> <style>
.sync-status-wrapper { .sync-status-widget {
height: 35px; height: 35px;
box-sizing: border-box; box-sizing: border-box;
border-bottom: 1px solid var(--main-border-color); border-bottom: 1px solid var(--main-border-color);
@ -12,78 +16,127 @@ const TPL = `
.sync-status { .sync-status {
height: 34px; height: 34px;
box-sizing: border-box; box-sizing: border-box;
}
.sync-status button {
height: 34px;
border: none;
font-size: 180%;
padding-left: 10px; padding-left: 10px;
padding-right: 10px; padding-right: 10px;
} }
.sync-status button > span { .sync-status .sync-status-icon {
display: inline-block; height: 34px;
font-size: 180%;
display: inline-block;
position: relative; position: relative;
top: -5px;
} }
.sync-status button:hover { .sync-status .sync-status-icon span {
border: none !important;
}
.sync-status-icon:not(.sync-status-in-progress):hover {
background-color: var(--hover-item-background-color); background-color: var(--hover-item-background-color);
} cursor: pointer;
.sync-status .dropdown-menu {
width: 20em;
} }
</style> </style>
<div class="sync-status"> <div class="sync-status">
<button type="button" class="btn btn-sm" title="Sync status"> <span class="sync-status-icon sync-status-connected-with-changes" title="<p>Connected to the sync server. <br>There are some outstanding changes yet to be synced.</p><p>Click to trigger sync.</p>">
<span class="sync-status-icon sync-status-online-with-changes" title="Connected to the sync server. There are some outstanding changes yet to be synced."> <span class="bx bx-wifi"></span>
<span class="bx bx-wifi"></span> <span class="bx bxs-star" style="font-size: 40%; position: absolute; left: -3px; top: 20px;"></span>
<span class="bx bxs-star" style="font-size: 40%; position: absolute; left: -3px; top: 20px;"></span> </span>
</span> <span class="sync-status-icon sync-status-connected-no-changes"
<span class="sync-status-icon sync-status-online-no-changes" title="Connected to the sync server. All changes have been already synced."> data-toggle="tooltip"
<span class="bx bx-wifi"></span> title="<p>Connected to the sync server.<br>All changes have been already synced.</p><p>Click to trigger sync.</p>">
</span> <span class="bx bx-wifi"></span>
<span class="sync-status-icon sync-status-offline-with-changes" title="Establishing the connection to the sync server was unsuccessful. There are some outstanding changes yet to be synced."> </span>
<span class="bx bx-wifi-off"></span> <span class="sync-status-icon sync-status-disconnected-with-changes"
<span class="bx bxs-star" style="font-size: 40%; position: absolute; left: -3px; top: 20px;"></span> data-toggle="tooltip"
</span> title="<p>Establishing the connection to the sync server was unsuccessful.<br>There are some outstanding changes yet to be synced.</p><p>Click to trigger sync.</p>">
<span class="sync-status-icon sync-status-offline-no-changes" title="Establishing the connection to the sync server was unsuccessful. All known changes have been synced."> <span class="bx bx-wifi-off"></span>
<span class="bx bx-wifi-off"></span> <span class="bx bxs-star" style="font-size: 40%; position: absolute; left: -3px; top: 20px;"></span>
</span> </span>
<span class="sync-status-icon sync-status-in-progress" title="Sync with the server is in progress."> <span class="sync-status-icon sync-status-disconnected-no-changes"
<span class="bx bx-analyse bx-spin"></span> data-toggle="tooltip"
</span> title="<p>Establishing the connection to the sync server was unsuccessful.<br>All known changes have been synced.</p><p>Click to trigger sync.</p>">
</button> <span class="bx bx-wifi-off"></span>
</span>
<span class="sync-status-icon sync-status-in-progress"
data-toggle="tooltip"
title="Sync with the server is in progress.">
<span class="bx bx-analyse bx-spin"></span>
</span>
</div> </div>
</div> </div>
`; `;
export default class SyncStatusWidget extends BasicWidget { export default class SyncStatusWidget extends BasicWidget {
constructor() {
super();
ws.subscribeToMessages(message => this.processMessage(message));
this.syncState = 'disconnected';
this.allChangesPushed = false;
}
doRender() { doRender() {
this.$widget = $(TPL); this.$widget = $(TPL);
this.$widget.hide(); this.$widget.hide();
this.$widget.find('[data-toggle="tooltip"]').tooltip({
html: true
});
this.$widget.find('.sync-status-icon:not(.sync-status-in-progress)')
.on('click', () => syncService.syncNow())
this.overflowing(); this.overflowing();
} }
syncInProgressEvent() {
this.showIcon('in-progress');
}
syncFinishedEvent() {
this.showIcon('online-no-changes');
}
syncFailedEvent() {
this.showIcon('offline-no-changes');
}
showIcon(className) { showIcon(className) {
if (!options.get('syncServerHost')) {
this.$widget.hide();
return;
}
this.$widget.show(); this.$widget.show();
this.$widget.find('.sync-status-icon').hide(); this.$widget.find('.sync-status-icon').hide();
this.$widget.find('.sync-status-' + className).show(); this.$widget.find('.sync-status-' + className).show();
} }
processMessage(message) {
if (message.type === 'sync-pull-in-progress') {
toastService.showPersistent({
id: 'sync',
title: "Sync status",
message: "Sync update in progress",
icon: "refresh"
});
this.syncState = 'in-progress';
this.allChangesPushed = false;
}
else if (message.type === 'sync-push-in-progress') {
this.syncState = 'in-progress';
this.allChangesPushed = false;
}
else if (message.type === 'sync-finished') {
// this gives user a chance to see the toast in case of fast sync finish
setTimeout(() => toastService.closePersistent('sync'), 1000);
this.syncState = 'connected';
}
else if (message.type === 'sync-failed') {
this.syncState = 'disconnected';
}
else if (message.type === 'frontend-update') {
const {lastSyncedPush} = message.data;
this.allChangesPushed = lastSyncedPush === ws.getMaxKnownEntityChangeSyncId();
}
if (this.syncState === 'in-progress') {
this.showIcon('in-progress');
} else {
this.showIcon(this.syncState + '-' + (this.allChangesPushed ? 'no-changes' : 'with-changes'));
}
}
} }

View File

@ -25,6 +25,7 @@ function index(req, res) {
detailFontSize: parseInt(options.detailFontSize), detailFontSize: parseInt(options.detailFontSize),
sourceId: sourceIdService.generateSourceId(), sourceId: sourceIdService.generateSourceId(),
maxEntityChangeIdAtLoad: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes"), maxEntityChangeIdAtLoad: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes"),
maxEntityChangeSyncIdAtLoad: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes WHERE isSynced = 1"),
instanceName: config.General ? config.General.instanceName : null, instanceName: config.General ? config.General.instanceName : null,
appCssNoteIds: getAppCssNoteIds(), appCssNoteIds: getAppCssNoteIds(),
isDev: env.isDev(), isDev: env.isDev(),

View File

@ -234,7 +234,7 @@ function transactional(func) {
const ret = dbConnection.transaction(func).deferred(); const ret = dbConnection.transaction(func).deferred();
if (!dbConnection.inTransaction) { // i.e. transaction was really committed (and not just savepoint released) if (!dbConnection.inTransaction) { // i.e. transaction was really committed (and not just savepoint released)
require('./ws.js').sendTransactionSyncsToAllClients(); require('./ws.js').sendTransactionEntityChangesToAllClients();
} }
return ret; return ret;

View File

@ -363,10 +363,16 @@ function setLastSyncedPull(entityChangeId) {
} }
function getLastSyncedPush() { function getLastSyncedPush() {
return parseInt(optionService.getOption('lastSyncedPush')); const lastSyncedPush = parseInt(optionService.getOption('lastSyncedPush'));
ws.setLastSyncedPush(lastSyncedPush);
return lastSyncedPush;
} }
function setLastSyncedPush(entityChangeId) { function setLastSyncedPush(entityChangeId) {
ws.setLastSyncedPush(entityChangeId);
optionService.setOption('lastSyncedPush', entityChangeId); optionService.setOption('lastSyncedPush', entityChangeId);
} }
@ -382,9 +388,12 @@ sqlInit.dbReady.then(() => {
setInterval(cls.wrap(sync), 60000); setInterval(cls.wrap(sync), 60000);
// kickoff initial sync immediately // kickoff initial sync immediately
setTimeout(cls.wrap(sync), 3000); setTimeout(cls.wrap(sync), 5000);
}); });
// called just so ws.setLastSyncedPush() is called
getLastSyncedPush();
module.exports = { module.exports = {
sync, sync,
login, login,

View File

@ -8,6 +8,7 @@ const syncMutexService = require('./sync_mutex');
const protectedSessionService = require('./protected_session'); const protectedSessionService = require('./protected_session');
let webSocketServer; let webSocketServer;
let lastSyncedPush = null;
function init(httpServer, sessionParser) { function init(httpServer, sessionParser) {
webSocketServer = new WebSocket.Server({ webSocketServer = new WebSocket.Server({
@ -61,7 +62,9 @@ function sendMessageToAllClients(message) {
const jsonStr = JSON.stringify(message); const jsonStr = JSON.stringify(message);
if (webSocketServer) { if (webSocketServer) {
log.info("Sending message to all clients: " + jsonStr); if (message.type !== 'sync-failed') {
log.info("Sending message to all clients: " + jsonStr);
}
webSocketServer.clients.forEach(function each(client) { webSocketServer.clients.forEach(function each(client) {
if (client.readyState === WebSocket.OPEN) { if (client.readyState === WebSocket.OPEN) {
@ -96,23 +99,26 @@ function fillInAdditionalProperties(entityChange) {
} }
function sendPing(client, entityChanges = []) { function sendPing(client, entityChanges = []) {
for (const sync of entityChanges) { for (const entityChange of entityChanges) {
try { try {
fillInAdditionalProperties(sync); fillInAdditionalProperties(entityChange);
} }
catch (e) { catch (e) {
log.error("Could not fill additional properties for sync " + JSON.stringify(sync) log.error("Could not fill additional properties for entity change " + JSON.stringify(entityChange)
+ " because of error: " + e.message + ": " + e.stack); + " because of error: " + e.message + ": " + e.stack);
} }
} }
sendMessage(client, { sendMessage(client, {
type: 'sync', type: 'frontend-update',
data: entityChanges data: {
lastSyncedPush,
entityChanges
}
}); });
} }
function sendTransactionSyncsToAllClients() { function sendTransactionEntityChangesToAllClients() {
if (webSocketServer) { if (webSocketServer) {
const entityChanges = cls.getAndClearEntityChanges(); const entityChanges = cls.getAndClearEntityChanges();
@ -136,6 +142,10 @@ function syncFailed() {
sendMessageToAllClients({ type: 'sync-failed' }); sendMessageToAllClients({ type: 'sync-failed' });
} }
function setLastSyncedPush(entityChangeId) {
lastSyncedPush = entityChangeId;
}
module.exports = { module.exports = {
init, init,
sendMessageToAllClients, sendMessageToAllClients,
@ -143,5 +153,6 @@ module.exports = {
syncPullInProgress, syncPullInProgress,
syncFinished, syncFinished,
syncFailed, syncFailed,
sendTransactionSyncsToAllClients sendTransactionEntityChangesToAllClients,
setLastSyncedPush
}; };

View File

@ -49,6 +49,7 @@
activeDialog: null, activeDialog: null,
sourceId: '<%= sourceId %>', sourceId: '<%= sourceId %>',
maxEntityChangeIdAtLoad: <%= maxEntityChangeIdAtLoad %>, maxEntityChangeIdAtLoad: <%= maxEntityChangeIdAtLoad %>,
maxEntityChangeSyncIdAtLoad: <%= maxEntityChangeSyncIdAtLoad %>,
instanceName: '<%= instanceName %>', instanceName: '<%= instanceName %>',
csrfToken: '<%= csrfToken %>', csrfToken: '<%= csrfToken %>',
isDev: <%= isDev %>, isDev: <%= isDev %>,

View File

@ -111,6 +111,7 @@
activeDialog: null, activeDialog: null,
sourceId: '<%= sourceId %>', sourceId: '<%= sourceId %>',
maxEntityChangeIdAtLoad: <%= maxEntityChangeIdAtLoad %>, maxEntityChangeIdAtLoad: <%= maxEntityChangeIdAtLoad %>,
maxEntityChangeSyncIdAtLoad: <%= maxEntityChangeSyncIdAtLoad %>,
instanceName: '<%= instanceName %>', instanceName: '<%= instanceName %>',
csrfToken: '<%= csrfToken %>', csrfToken: '<%= csrfToken %>',
isDev: <%= isDev %>, isDev: <%= isDev %>,