From 67375bd28192c3c28319d7f3c94b03d94a732fb9 Mon Sep 17 00:00:00 2001 From: jedi Date: Sun, 23 Jun 2024 01:04:32 +0200 Subject: [PATCH] add shared-state-plugin --- web/src/App.vue | 3 +- web/src/components/AddItemModal.vue | 11 +- web/src/shared-state-plugin/index.js | 345 +++++++++++++++++++++++++++ web/src/store.js | 99 +++++++- web/src/views/Items.vue | 4 +- web/src/views/Ticket.vue | 10 +- web/src/views/Tickets.vue | 7 +- 7 files changed, 455 insertions(+), 24 deletions(-) create mode 100644 web/src/shared-state-plugin/index.js diff --git a/web/src/App.vue b/web/src/App.vue index ede1d16..83a3bb2 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -28,7 +28,7 @@ export default { }), methods: { ...mapMutations(['removeToast', 'createToast', 'closeAddBoxModal', 'openAddBoxModal']), - ...mapActions(['loadEvents']), + ...mapActions(['loadEvents', 'scheduleAfterInit']), openAddItemModal() { this.addItemModalOpen = true; }, @@ -44,6 +44,7 @@ export default { }, created: function () { document.title = document.location.hostname; + this.scheduleAfterInit(() => [this.loadEvents()]); } }; diff --git a/web/src/components/AddItemModal.vue b/web/src/components/AddItemModal.vue index c90829f..a3c23fd 100644 --- a/web/src/components/AddItemModal.vue +++ b/web/src/components/AddItemModal.vue @@ -27,16 +27,19 @@ export default { computed: { ...mapState(['lastUsed']) }, - created() { - this.item = {box: this.lastUsed.box || '', cid: this.lastUsed.cid || ''}; - }, methods: { - ...mapActions(['postItem']), + ...mapActions(['postItem', 'loadBoxes', 'scheduleAfterInit']), saveNewItem() { this.postItem(this.item).then(() => { this.$emit('close'); }); } + }, + created() { + this.item = {box: this.lastUsed.box || '', cid: this.lastUsed.cid || ''}; + }, + mounted() { + this.scheduleAfterInit(() => [this.loadBoxes()]); } }; diff --git a/web/src/shared-state-plugin/index.js b/web/src/shared-state-plugin/index.js new file mode 100644 index 0000000..67397d0 --- /dev/null +++ b/web/src/shared-state-plugin/index.js @@ -0,0 +1,345 @@ +import {isProxy, toRaw} from 'vue'; + +export default (config) => { + if (!('isLoadedKey' in config)) { + throw new Error("isLoadedKey not defined in config"); + } + if (('asyncFetch' in config) && !('lastfetched' in config)) { + throw new Error("asyncFetch defined but lastfetched not defined in config"); + } + + if (config.debug) console.log('plugin created'); + + const clone = (obj) => { + if (isProxy(obj)) { + obj = toRaw(obj); + } + if (obj === null || typeof obj !== 'object') { + return obj; + } + if (obj.__proto__ === ({}).__proto__) { + return Object.assign({}, obj); + } + if (obj.__proto__ === [].__proto__) { + return obj.slice(); + } + return obj; + } + + const deepEqual = (a, b) => { + if (a === b) { + return true; + } + if (a === null || b === null) { + return false; + } + if (a.__proto__ === ({}).__proto__ && b.__proto__ === ({}).__proto__) { + + if (Object.keys(a).length !== Object.keys(b).length) { + return false; + } + for (let key in b) { + if (!(key in a)) { + return false; + } + } + for (let key in a) { + if (!(key in b)) { + return false; + } + if (!deepEqual(a[key], b[key])) { + return false; + } + } + return true; + } + if (a.__proto__ === [].__proto__ && b.__proto__ === [].__proto__) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) { + return false; + } + } + return true; + } + return false; + } + + const toRawRecursive = (obj) => { + if (isProxy(obj)) { + obj = toRaw(obj); + } + if (obj === null || typeof obj !== 'object') { + return obj; + } + if (obj.__proto__ === ({}).__proto__) { + const new_obj = {}; + for (let key in obj) { + new_obj[key] = toRawRecursive(obj[key]); + } + return new_obj; + } + if (obj.__proto__ === [].__proto__) { + return obj.map((item) => toRawRecursive(item)); + } + return obj; + } + + /** may only be called from worker */ + const worker_fun = function (self, ctx) { + /* globals WebSocket, SharedWorker, onconnect, onmessage, postMessage, close, location */ + + let intialized = false; + let state = {}; + let ports = []; + let notify_socket; + + const tryConnect = () => { + if (self.WebSocket === undefined) { + if (ctx.debug) console.log("no websocket support"); + return; + } + if (!notify_socket || notify_socket.readyState !== WebSocket.OPEN) { + // global location is not useful in worker loaded from data url + const scheme = ctx.location.protocol === "https:" ? "wss" : "ws"; + if (ctx.debug) console.log("connecting to", scheme + '://' + ctx.location.host + '/ws/2/notify/'); + notify_socket = new WebSocket(scheme + '://' + ctx.location.host + '/ws/2/notify/'); + notify_socket.onopen = (e) => { + if (ctx.debug) console.log("open", JSON.stringify(e)); + }; + notify_socket.onclose = (e) => { + if (ctx.debug) console.log("close", JSON.stringify(e)); + setTimeout(() => { + tryConnect(); + }, 1000); + }; + notify_socket.onerror = (e) => { + if (ctx.debug) console.log("error", JSON.stringify(e)); + setTimeout(() => { + tryConnect(); + }, 1000); + }; + notify_socket.onmessage = (e) => { + let data = JSON.parse(e.data); + if (ctx.debug) console.log("message", data); + //this.loadEventItems() + //this.loadTickets() + } + } + } + + const deepEqual = (a, b) => { + if (a === b) { + return true; + } + if (a === null || b === null) { + return false; + } + if (a.__proto__ === ({}).__proto__ && b.__proto__ === ({}).__proto__) { + + if (Object.keys(a).length !== Object.keys(b).length) { + return false; + } + for (let key in b) { + if (!(key in a)) { + return false; + } + } + for (let key in a) { + if (!(key in b)) { + return false; + } + if (!deepEqual(a[key], b[key])) { + return false; + } + } + return true; + } + if (a.__proto__ === [].__proto__ && b.__proto__ === [].__proto__) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) { + return false; + } + } + return true; + } + return false; + } + + const handle_message = (message_data, reply, others, all) => { + switch (message_data.type) { + case 'state_init': + if (!intialized) { + intialized = true; + state = message_data.state; + reply({type: 'state_init', first: true}); + } else { + reply({type: 'state_init', first: false, state: state}); + } + break; + case 'state_diff': + if (message_data.key in state) { + if (!deepEqual(state[message_data.key], message_data.old_value)) { + if (ctx.debug) console.log("state diff old value mismatch | state:", state[message_data.key], " old:", message_data.old_value); + } + if (!deepEqual(state[message_data.key], message_data.new_value)) { + if (ctx.debug) console.log("state diff changed | state:", state[message_data.key], " new:", message_data.new_value); + state[message_data.key] = message_data.new_value; + others(message_data); + } else { + if (ctx.debug) console.log("state diff no change | state:", state[message_data.key], " new:", message_data.new_value); + } + } else { + if (ctx.debug) console.log("state diff key not found", message_data.key); + } + break; + default: + if (ctx.debug) console.log("unknown message", message_data); + } + } + + onconnect = (connect_event) => { + const port = connect_event.ports[0]; + ports.push(port); + port.onmessage = (message_event) => { + const reply = (message_data) => { + port.postMessage(message_data); + } + const others = (message_data) => { + for (let i = 0; i < ports.length; i++) { + if (ports[i] !== port) { + ports[i].postMessage(message_data); + } + } + } + const all = (message_data) => { + for (let i = 0; i < ports.length; i++) { + ports[i].postMessage(message_data); + } + } + handle_message(message_event.data, reply, others, all); + } + port.start(); + if (ctx.debug) console.log("worker connected", JSON.stringify(connect_event)); + tryConnect(); + } + + if (ctx.debug) console.log("worker loaded"); + } + + const worker_context = { + location: { + protocol: location.protocol, host: location.host + }, bug: config.debug + } + const worker_code = '(' + worker_fun.toString() + ')(self,' + JSON.stringify(worker_context) + ')'; + const worker_url = 'data:application/javascript;base64,' + btoa(worker_code); + + const worker = new SharedWorker(worker_url, 'vuex-shared-state-plugin'); + worker.port.start(); + if (config.debug) console.log('worker started'); + + const updateWorkerState = (key, new_value, old_value = null) => { + if (new_value === old_value) { + if (config.debug) console.log('updateWorkerState: no change', key, new_value); + return; + } + if (new_value === undefined) { + if (config.debug) console.log('updateWorkerState: undefined', key, new_value); + return; + } + + worker.port.postMessage({ + type: 'state_diff', + key: key, + new_value: isProxy(new_value) ? toRawRecursive(new_value) : new_value, + old_value: isProxy(old_value) ? toRawRecursive(old_value) : old_value + }); + } + + const registerInitialState = (keys, local_state) => { + const value = keys.reduce((obj, key) => { + obj[key] = isProxy(local_state[key]) ? toRawRecursive(local_state[key]) : local_state[key]; + return obj; + }, {}); + if (config.debug) console.log('registerInitilState', value); + worker.port.postMessage({ + type: 'state_init', state: value + }); + } + + return (store) => { + + worker.port.onmessage = function (e) { + switch (e.data.type) { + case 'state_init': + if (config.debug) console.log('state_init', e.data); + if (e.data.first) { + if (config.debug) console.log('worker state initialized'); + } else { + for (let key in e.data.state) { + if (key in store.state) { + if (config.debug) console.log('worker state init received', key, clone(e.data.state[key])); + if (!deepEqual(store.state[key], e.data.state[key])) { + store.state[key] = e.data.state[key]; + } + } else { + if (config.debug) console.log("state init key not found", key); + } + } + } + store.state[config.isLoadedKey] = true; + if ('afterInit' in config) { + setTimeout(() => { + store.dispatch(config.afterInit); + }, 0); + } + break; + case 'state_diff': + if (config.debug) console.log('state_diff', e.data); + if (e.data.key in store.state) { + if (config.debug) console.log('worker state update', e.data.key, clone(e.data.new_value)); + //TODO this triggers the watcher again, but we don't want that + store.state[e.data.key] = e.data.new_value; + } else { + if (config.debug) console.log("state diff key not found", e.data.key); + } + break; + default: + if (config.debug) console.log("unknown message", e.data); + } + }; + + registerInitialState(config.state, store.state); + + if ('mutations' in config) { + store.subscribe((mutation, state) => { + if (mutation.type in config.mutations) { + console.log(mutation.type, mutation.payload); + console.log(state); + } + }); + } + /*if ('actions' in config) { + store.subscribeAction((action, state) => { + if (action.type in config.actions) { + console.log(action.type, action.payload); + console.log(state); + } + }); + }*/ + if ('state' in config) { + config.watch.forEach((member) => { + store.watch((state, getters) => state[member], (newValue, oldValue) => { + if (config.debug) console.log('watch', member, clone(newValue), clone(oldValue)); + updateWorkerState(member, newValue, oldValue); + }); + }); + } + }; +} \ No newline at end of file diff --git a/web/src/store.js b/web/src/store.js index 94bfc70..7ee6614 100644 --- a/web/src/store.js +++ b/web/src/store.js @@ -4,8 +4,9 @@ import router from './router'; import * as base64 from 'base-64'; import * as utf8 from 'utf8'; import {ticketStateColorLookup, ticketStateIconLookup, http} from "@/utils"; - +import sharedStatePlugin from "@/shared-state-plugin"; import persistentStatePlugin from "@/persistent-state-plugin"; +import {triggerRef} from "vue"; const store = createStore({ state: { @@ -20,6 +21,7 @@ const store = createStore({ groups: [], state_options: [], lastEvent: '37C3', + lastUsed: {}, remember: false, user: { username: null, @@ -30,7 +32,19 @@ const store = createStore({ }, thumbnailCache: {}, + fetchedData: { + events: 0, + items: 0, + boxes: 0, + tickets: 0, + users: 0, + groups: 0, + states: 0, + }, persistent_loaded: false, + shared_loaded: false, + afterInitHandlers: [], + showAddBoxModal: false, }, getters: { @@ -80,26 +94,33 @@ const store = createStore({ }, }, mutations: { + updateLastUsed(state, diff) { + state.lastUsed = {...state.lastUsed, ...diff}; + }, updateLastEvent(state, slug) { state.lastEvent = slug; }, replaceEvents(state, events) { state.events = events; + state.fetchedData = {...state.fetchedData, events: Date.now()}; }, replaceTicketStates(state, states) { state.state_options = states; + state.fetchedData = {...state.fetchedData, states: Date.now()}; }, changeView(state, {view, slug}) { router.push({path: `/${slug}/${view}`}); }, replaceLoadedItems(state, newItems) { state.loadedItems = newItems; + state.fetchedData = {...state.fetchedData, items: Date.now()}; // TODO: manage caching items for different events and search results correctly }, setItemCache(state, {slug, items}) { state.itemCache[slug] = items; }, replaceBoxes(state, loadedBoxes) { state.loadedBoxes = loadedBoxes; + state.fetchedData = {...state.fetchedData, boxes: Date.now()}; }, updateItem(state, updatedItem) { const item = state.loadedItems.filter(({uid}) => uid === updatedItem.uid)[0]; @@ -113,16 +134,21 @@ const store = createStore({ }, replaceTickets(state, tickets) { state.tickets = tickets; - }, - replaceUsers(state, users) { - state.users = users; - }, - replaceGroups(state, groups) { - state.groups = groups; + state.fetchedData = {...state.fetchedData, tickets: Date.now()}; }, updateTicket(state, updatedTicket) { const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0]; Object.assign(ticket, updatedTicket); + //triggerRef(state.tickets); + state.tickets = [...state.tickets]; + }, + replaceUsers(state, users) { + state.users = users; + state.fetchedData = {...state.fetchedData, users: Date.now()}; + }, + replaceGroups(state, groups) { + state.groups = groups; + state.fetchedData = {...state.fetchedData, groups: Date.now()}; }, openAddBoxModal(state) { state.showAddBoxModal = true; @@ -227,6 +253,19 @@ const store = createStore({ } await Promise.all(promises); }, + async afterSharedInit({dispatch, state}) { + const handlers = state.afterInitHandlers; + state.afterInitHandlers = []; + await Promise.all(handlers.map(h => h()).flat()); + }, + scheduleAfterInit({dispatch, state}, handler) { + if (state.shared_loaded) { + Promise.all(handler()).then(() => { + }); + } else { + state.afterInitHandlers.push(handler); + } + }, async fetchImage({state}, url) { return await fetch(url, {headers: {'Authorization': `Token ${state.user.token}`}}); }, @@ -235,11 +274,15 @@ const store = createStore({ commit('setPermissions', data.permissions); }, async loadEvents({commit, state}) { + if (!state.user.token) return; + if (state.fetchedData.events > Date.now() - 1000 * 60 * 60 * 24) return; const {data, success} = await http.get('/2/events/', state.user.token); if (data && success) commit('replaceEvents', data); }, async fetchTicketStates({commit, state}) { + if (!state.user.token) return; + if (state.fetchedData.states > Date.now() - 1000 * 60 * 60 * 24) return; const {data, success} = await http.get('/2/tickets/states/', state.user.token); if (data && success) commit('replaceTicketStates', data); @@ -255,6 +298,8 @@ const store = createStore({ router.push({path: `/${getters.getEventSlug}/items/`, query: {box}}); }, async loadEventItems({commit, getters, state}) { + if (!state.user.token) return; + if (state.fetchedData.items > Date.now() - 1000 * 60 * 60 * 24) return; try { commit('replaceLoadedItems', []); const slug = getters.getEventSlug; @@ -279,6 +324,8 @@ const store = createStore({ commit('replaceLoadedItems', data); }, async loadBoxes({commit, state}) { + if (!state.user.token) return; + if (state.fetchedData.boxes > Date.now() - 1000 * 60 * 60 * 24) return; const {data, success} = await http.get('/2/boxes/', state.user.token); if (data && success) commit('replaceBoxes', data); @@ -315,6 +362,8 @@ const store = createStore({ commit('appendItem', data); }, async loadTickets({commit, state}) { + if (!state.user.token) return; + if (state.fetchedData.tickets > Date.now() - 1000 * 60 * 60 * 24) return; const {data, success} = await http.get('/2/tickets/', state.user.token); if (data && success) commit('replaceTickets', data); @@ -322,6 +371,7 @@ const store = createStore({ async sendMail({commit, dispatch, state}, {id, message}) { const {data, success} = await http.post(`/2/tickets/${id}/reply/`, {message}, state.user.token); if (data && success) { + state.fetchedData.tickets = 0; await dispatch('loadTickets'); } }, @@ -337,15 +387,20 @@ const store = createStore({ async postComment({commit, dispatch, state}, {id, message}) { const {data, success} = await http.post(`/2/tickets/${id}/comment/`, {comment: message}, state.user.token); if (data && success) { + state.fetchedData.tickets = 0; await dispatch('loadTickets'); } }, async loadUsers({commit, state}) { + if (!state.user.token) return; + if (state.fetchedData.users > Date.now() - 1000 * 60 * 60 * 24) return; const {data, success} = await http.get('/2/users/', state.user.token); if (data && success) commit('replaceUsers', data); }, async loadGroups({commit, state}) { + if (!state.user.token) return; + if (state.fetchedData.groups > Date.now() - 1000 * 60 * 60 * 24) return; const {data, success} = await http.get('/2/groups/', state.user.token); if (data && success) commit('replaceGroups', data); @@ -368,8 +423,38 @@ const store = createStore({ "remember", "user", "events", + "lastUsed", ] }), + sharedStatePlugin({ + debug: true, + isLoadedKey: "shared_loaded", + clearingMutation: "logout", + afterInit: "afterSharedInit", + state: [ + "test", + "state_options", + "fetchedData", + "tickets", + "users", + "groups", + "loadedBoxes", + "loadedItems", + ], + watch: [ + "test", + "state_options", + "fetchedData", + "tickets", + "users", + "groups", + "loadedBoxes", + "loadedItems", + ], + mutations: [ + //"replaceTickets", + ], + }), ], }); diff --git a/web/src/views/Items.vue b/web/src/views/Items.vue index 2eec96f..5824999 100644 --- a/web/src/views/Items.vue +++ b/web/src/views/Items.vue @@ -93,7 +93,7 @@ export default { ...mapGetters(['layout']), }, methods: { - ...mapActions(['deleteItem', 'markItemReturned', 'loadEventItems', 'updateItem']), + ...mapActions(['deleteItem', 'markItemReturned', 'loadEventItems', 'updateItem', 'scheduleAfterInit']), openLightboxModalWith(item) { this.lightboxHash = item.file; }, @@ -115,7 +115,7 @@ export default { } }, mounted() { - this.loadEventItems(); + this.scheduleAfterInit(() => [this.loadEventItems()]); } }; diff --git a/web/src/views/Ticket.vue b/web/src/views/Ticket.vue index b8c0cf9..b4d1e0e 100644 --- a/web/src/views/Ticket.vue +++ b/web/src/views/Ticket.vue @@ -62,7 +62,7 @@ export default { }, methods: { ...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']), - ...mapActions(['loadTickets', 'loadUsers', 'fetchTicketStates']), + ...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']), handleMail(mail) { this.sendMail({ id: this.ticket.id, @@ -86,12 +86,10 @@ export default { id: ticket.id, assigned_to: ticket.assigned_to }) - } + }, }, - created() { - this.fetchTicketStates() - this.loadTickets() - this.loadUsers() + mounted() { + this.scheduleAfterInit(() => [this.fetchTicketStates(), this.loadTickets(), this.loadUsers()]); } }; diff --git a/web/src/views/Tickets.vue b/web/src/views/Tickets.vue index 0b31e4d..40b2a9c 100644 --- a/web/src/views/Tickets.vue +++ b/web/src/views/Tickets.vue @@ -65,7 +65,7 @@ export default { ...mapGetters(['stateInfo', 'getEventSlug', 'layout']), }, methods: { - ...mapActions(['loadTickets', 'fetchTicketStates']), + ...mapActions(['loadTickets', 'fetchTicketStates', 'scheduleAfterInit']), gotoDetail(ticket) { this.$router.push({name: 'ticket', params: {id: ticket.id}}); }, @@ -80,9 +80,8 @@ export default { }; } }, - created() { - this.fetchTicketStates(); - this.loadTickets(); + mounted() { + this.scheduleAfterInit(() => [this.fetchTicketStates(), this.loadTickets()]); } };