From 3453dffb530c68ece5d7953c1045819a8513db7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Tue, 21 Jan 2025 18:20:50 +0100 Subject: [PATCH] Sync progress bar somewhat working --- main.go | 3 +- node.go | 20 +++--- static/css/notes2.css | 54 +++++++++++++++-- static/js/node.mjs | 3 + static/js/node_store.mjs | 19 ++++-- static/js/notes2.mjs | 5 +- static/js/sync.mjs | 127 ++++++++++++++++++++++++++++++++++----- static/less/notes2.less | 61 ++++++++++++++++++- 8 files changed, 250 insertions(+), 42 deletions(-) diff --git a/main.go b/main.go index 54b9ab7..3c17136 100644 --- a/main.go +++ b/main.go @@ -124,7 +124,7 @@ func main() { // {{{ http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler) - http.HandleFunc("/sync/from_server/count", authenticated(actionSyncFromServerCount)) + http.HandleFunc("/sync/from_server/count/{sequence}", authenticated(actionSyncFromServerCount)) http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer)) http.HandleFunc("/sync/to_server", authenticated(actionSyncToServer)) @@ -277,6 +277,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{ user := getUser(r) changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) + Log.Debug("FOO", "UUID", user.ClientUUID, "changedFrom", changedFrom) count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID) if err != nil { Log.Error("/sync/from_server/count", "error", err) diff --git a/node.go b/node.go index f92d524..c33a513 100644 --- a/node.go +++ b/node.go @@ -192,22 +192,22 @@ func NodesCount(userID int, synced uint64, clientUUID string) (count int, err er public.node WHERE user_id = $1 AND - client != $5 AND + client != $3 AND NOT history AND ( - created_seq > $4 OR - updated_seq > $4 OR - deleted_seq > $4 + created_seq > $2 OR + updated_seq > $2 OR + deleted_seq > $2 ) `, userID, synced, clientUUID, ) - row.Scan(&row) + err = row.Scan(&count) if err != nil { err = werr.Wrap(err).WithData( struct { - UserID uint64 + UserID int Synced uint64 ClientUUID string }{ @@ -286,13 +286,13 @@ func NodeCrumbs(nodeUUID string) (nodes []Node, err error) { // {{{ } // }}} func TestData() (err error) { - for range 10 { + for range 8 { hash1, name1, _ := generateOneTestNode("", "G") - for range 10 { + for range 8 { hash2, name2, _ := generateOneTestNode(hash1, name1) - for range 10 { + for range 8 { hash3, name3, _ := generateOneTestNode(hash2, name2) - for range 10 { + for range 8 { generateOneTestNode(hash3, name3) } } diff --git a/static/css/notes2.css b/static/css/notes2.css index 30f207a..4be2968 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -4,12 +4,12 @@ html { #notes2 { min-height: 100vh; display: grid; - grid-template-areas: "tree crumbs" "tree name" "tree content" "tree blank"; + grid-template-areas: "tree crumbs" "tree sync" "tree name" "tree content" "tree blank"; grid-template-columns: min-content 1fr; } @media only screen and (max-width: 600px) { #notes2 { - grid-template-areas: "crumbs" "name" "content" "blank"; + grid-template-areas: "crumbs" "sync" "name" "content" "blank"; grid-template-columns: 1fr; } #notes2 #tree { @@ -75,6 +75,52 @@ html { justify-items: center; margin: 16px; } +#sync-progress { + grid-area: sync; + display: grid; + justify-items: center; + justify-self: center; + width: 100%; + max-width: 900px; + height: 56px; + position: relative; +} +#sync-progress.hidden { + visibility: hidden; + opacity: 0; + transition: visibility 0s 500ms, opacity 500ms linear; +} +#sync-progress progress { + width: calc(100% - 16px); + height: 16px; + border-radius: 4px; +} +#sync-progress progress[value]::-webkit-progress-bar { + background-color: #eee; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25) inset; + border-radius: 4px; +} +#sync-progress progress[value]::-moz-progress-bar { + background-color: #eee; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25) inset; + border-radius: 4px; +} +#sync-progress progress[value]::-webkit-progress-value { + background: #ba5f59; + background: linear-gradient(180deg, #ba5f59 0%, #fe5f55 50%, #ba5f59 100%); + border-radius: 4px; +} +#sync-progress progress[value]::-moz-progress-value { + background: #ba5f59; + background: linear-gradient(180deg, #ba5f59 0%, #fe5f55 50%, #ba5f59 100%); + border-radius: 4px; +} +#sync-progress .count { + margin-top: 0px; + color: #888; + position: absolute; + top: 22px; +} .crumbs { display: flex; flex-wrap: wrap; @@ -109,11 +155,11 @@ html { margin-left: 0px; } #name { - color: #666; + color: #333; font-weight: bold; text-align: center; font-size: 1.15em; - margin-top: 32px; + margin-top: 0px; margin-bottom: 16px; } /* ============================================================= * diff --git a/static/js/node.mjs b/static/js/node.mjs index 0374be9..e0b3201 100644 --- a/static/js/node.mjs +++ b/static/js/node.mjs @@ -2,6 +2,7 @@ import { h, Component, createRef } from 'preact' import htm from 'htm' import { signal } from 'preact/signals' import { ROOT_NODE } from 'node_store' +import { SyncProgress } from 'sync' const html = htm.bind(h) export class NodeUI extends Component { @@ -15,6 +16,7 @@ export class NodeUI extends Component { this.keys = signal([]) this.page = signal('node') this.crumbs = [] + this.syncProgress = createRef() window.addEventListener('popstate', evt => { if (evt.state?.hasOwnProperty('nodeUUID')) _notes2.current.goToNode(evt.state.nodeUUID, true) @@ -52,6 +54,7 @@ export class NodeUI extends Component { ${crumbDivs} + <${SyncProgress} ref=${this.syncProgress} />
${node.get('Name')}
<${NodeContent} key=${node.UUID} node=${node} ref=${this.nodeContent} />
diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index e1253a4..8b6dea5 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -246,9 +246,6 @@ export class NodeStore { console.log('transaction error', event.target.error) reject(event.target.error) } - t.oncomplete = () => { - console.log('OK') - } // records is an object, not an array. const promises = [] @@ -395,16 +392,26 @@ class SimpleNodeStore { const promises = [] for (const key of keys) { - const p = new Promise((resolve, reject)=>{ + const p = new Promise((resolve, reject) => { // TODO - implement a way to add an error to a page-global error log. const request = store.delete(key) - request.onsuccess = (event)=>resolve(event) - request.onerror = (event)=>reject(event) + request.onsuccess = (event) => resolve(event) + request.onerror = (event) => reject(event) }) promises.push(p) } return Promise.all(promises) }//}}} + async count() {//{{{ + const store = this.db + .transaction(['nodes', this.storeName], 'readonly') + .objectStore(this.storeName) + return new Promise((resolve, reject) => { + const request = store.count() + request.onsuccess = (event) => resolve(event.target.result) + request.onerror = (event) => reject(event.target.error) + }) + }//}}} } // vim: foldmethod=marker diff --git a/static/js/notes2.mjs b/static/js/notes2.mjs index 3a91a29..4da700a 100644 --- a/static/js/notes2.mjs +++ b/static/js/notes2.mjs @@ -13,9 +13,8 @@ export class Notes2 extends Component { startNode: null, } - Sync.nodesFromServer().then(durationNodes => - console.log(`Total time: ${Math.round(100 * durationNodes) / 100}s`) - ) + window._sync = new Sync() + window._sync.run() this.getStartNode() }//}}} diff --git a/static/js/sync.mjs b/static/js/sync.mjs index 6321db6..6bfe1c8 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -1,21 +1,68 @@ import { API } from 'api' import { Node } from 'node' +import { h, Component, createRef } from 'preact' +import htm from 'htm' +const html = htm.bind(h) + +const SYNC_COUNT = 1 +const SYNC_HANDLED = 2 +const SYNC_DONE = 3 export class Sync { - constructor() { - this.foo = '' - } - static async nodesFromServer() {//{{{ - let duration = 0 - const syncStart = Date.now() + constructor() {//{{{ + this.listeners = [] + this.messagesReceived = [] + }//}}} + addListener(fn, runMessageQueue) {//{{{ + // Some handlers won't be added until a time after sync messages have been added to the queue. + // This is an opportunity for the handler to receive the old messages in order. + if (runMessageQueue) + for (const msg of this.messagesReceived) + fn(msg) + this.listeners.push(fn) + }//}}} + pushMessage(msg) {//{{{ + this.messagesReceived.push(msg) + for (const fn of this.listeners) + fn(msg) + }//}}} + + async run() {//{{{ try { + let duration = 0 // in ms + // The latest sync node value is used to retrieve the changes // from the backend. const state = await nodeStore.getAppState('latest_sync_node') const oldMax = (state?.value ? state.value : 0) - let currMax = oldMax + let nodeCount = await this.getNodeCount(oldMax) + nodeCount += await nodeStore.sendQueue.count() + const msg = { op: SYNC_COUNT, count: nodeCount } + this.pushMessage(msg) + + await this.nodesFromServer(oldMax) + .then(durationNodes => { + duration = durationNodes // in ms + console.log(`Total time: ${Math.round(1000 * durationNodes) / 1000}s`) + }) + + await this.nodesToServer() + } finally { + this.pushMessage({ op: SYNC_DONE, }) + } + }//}}} + async getNodeCount(oldMax) {//{{{ + // Retrieve the amount of values the server will send us. + const res = await API.query('POST', `/sync/from_server/count/${oldMax}`) + return res?.Count + }//}}} + async nodesFromServer(oldMax) {//{{{ + const syncStart = Date.now() + let syncEnd + try { + let currMax = oldMax let offset = 0 let res = { Continue: false } let batch = 0 @@ -39,7 +86,7 @@ export class Sync { let backendNode = null for (const i in res.Nodes) { backendNode = new Node(res.Nodes[i], -1) - await Sync.handleNode(backendNode) + await window._sync.handleNode(backendNode) } } while (res.Continue) @@ -48,14 +95,14 @@ export class Sync { } catch (e) { console.log('sync node tree', e) } finally { - const syncEnd = Date.now() - duration = (syncEnd - syncStart) / 1000 + syncEnd = Date.now() + const duration = (syncEnd - syncStart) / 1000 const count = await nodeStore.nodeCount() console.log(`Node sync took ${duration}s`, count) } - return duration + return (syncEnd - syncStart) }//}}} - static async handleNode(backendNode) {//{{{ + async handleNode(backendNode) {//{{{ try { /* Retrieving the local copy of this node from IndexedDB. * The backend node can be discarded if it is older than @@ -83,14 +130,16 @@ export class Sync { }) } catch (e) { console.error(e) + } finally { + this.pushMessage({ op: SYNC_HANDLED, count: 1 }) } }//}}} - - static async nodesToServer() {//{{{ - while(true) { + async nodesToServer() {//{{{ + const BATCH_SIZE = 32 + while (true) { try { // Send nodes in batches until everything is sent, or an error has occured. - const nodesToSend = await nodeStore.sendQueue.retrieve(2) + const nodesToSend = await nodeStore.sendQueue.retrieve(BATCH_SIZE) if (nodesToSend.length === 0) break console.debug(`Sending ${nodesToSend.length} node(s) to server`) @@ -109,6 +158,7 @@ export class Sync { // Nodes are archived on server and can now be deleted from the send queue. const keys = nodesToSend.map(node => node.ClientSequence) console.log(await nodeStore.sendQueue.delete(keys)) + this.pushMessage({ op: SYNC_HANDLED, count: nodesToSend.length }) } catch (e) { console.trace(e) @@ -118,3 +168,48 @@ export class Sync { } }//}}} } + +export class SyncProgress extends Component { + constructor() {//{{{ + super() + this.state = { + nodesToSync: 0, + nodesSynced: 0, + syncedDone: false, + } + }//}}} + componentDidMount() {//{{{ + window._sync.addListener(msg => this.progressHandler(msg), true) + }//}}} + getSnapshotBeforeUpdate(_, prevState) {//{{{ + if (!prevState.syncedDone && this.state.syncedDone) + setTimeout(() => document.getElementById('sync-progress')?.classList.add('hidden'), 750) + }//}}} + progressHandler(msg) {//{{{ + switch (msg.op) { + case SYNC_COUNT: + this.setState({ nodesToSync: msg.count }) + break + + case SYNC_HANDLED: + this.setState({ nodesSynced: this.state.nodesSynced + msg.count }) + break + + + case SYNC_DONE: + this.setState({ syncedDone: true }) + break + } + }//}}} + render(_, { nodesToSync, nodesSynced }) {//{{{ + if (nodesToSync === 0) + return html`
` + + return html` +
+ +
${nodesSynced} / ${nodesToSync}
+
+ ` + }//}}} +} diff --git a/static/less/notes2.less b/static/less/notes2.less index f3abdc5..c39d7af 100644 --- a/static/less/notes2.less +++ b/static/less/notes2.less @@ -10,6 +10,7 @@ html { display: grid; grid-template-areas: "tree crumbs" + "tree sync" "tree name" "tree content" //"tree checklist" @@ -22,6 +23,7 @@ html { @media only screen and (max-width: 600px) { grid-template-areas: "crumbs" + "sync" "name" "content" //"checklist" @@ -110,6 +112,61 @@ html { margin: 16px; } +#sync-progress { + grid-area: sync; + display: grid; + justify-items: center; + justify-self: center; + width: 100%; + max-width: 900px; + height: 56px; + position: relative; + + &.hidden { + visibility: hidden; + opacity: 0; + transition: visibility 0s 500ms, opacity 500ms linear; + } + + progress { + width: calc(100% - 16px); + height: 16px; + border-radius: 4px; + } + + progress[value]::-webkit-progress-bar { + background-color: #eee; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25) inset; + border-radius: 4px; + } + + progress[value]::-moz-progress-bar { + background-color: #eee; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25) inset; + border-radius: 4px; + } + + progress[value]::-webkit-progress-value { + background: rgb(186,95,89); + background: linear-gradient(180deg, rgba(186,95,89,1) 0%, rgba(254,95,85,1) 50%, rgba(186,95,89,1) 100%); + border-radius: 4px; + } + + // TODO: style the progress value for Firefox + progress[value]::-moz-progress-value { + background: rgb(186,95,89); + background: linear-gradient(180deg, rgba(186,95,89,1) 0%, rgba(254,95,85,1) 50%, rgba(186,95,89,1) 100%); + border-radius: 4px; + } + + .count { + margin-top: 0px; + color: #888; + position: absolute; + top: 22px; + } +} + .crumbs { background: #e4e4e4; display: flex; @@ -151,11 +208,11 @@ html { } #name { - color: @color3; + color: #333; font-weight: bold; text-align: center; font-size: 1.15em; - margin-top: 32px; + margin-top: 0px; margin-bottom: 16px; }