From 688811317c37a6f865bd15e50fd167bec3d08dab Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Mon, 15 Feb 2021 16:27:21 -0800 Subject: [PATCH] Display spinner animation during loading --- ui/app.ts | 23 +-- ui/components.ts | 2 + ui/components/all-parameters.ts | 38 ++-- ui/components/device-faults.ts | 10 +- ui/components/loading.ts | 80 ++++++++ ui/components/parameter-list.ts | 11 +- ui/components/parameter-table.ts | 9 +- ui/components/summon-button.ts | 2 +- ui/components/tags.ts | 4 +- ui/config-page.ts | 32 ++-- ui/css/app.css | 29 ++- ui/device-page.ts | 20 +- ui/devices-page.ts | 20 +- ui/drawer-component.ts | 2 +- ui/faults-page.ts | 10 +- ui/files-page.ts | 14 +- ui/permissions-page.ts | 18 +- ui/pie-chart-component.ts | 36 ++-- ui/presets-page.ts | 20 +- ui/provisions-page.ts | 20 +- ui/store.ts | 312 ++++++++++++++----------------- ui/users-page.ts | 22 ++- ui/virtual-parameters-page.ts | 20 +- 23 files changed, 447 insertions(+), 307 deletions(-) create mode 100644 ui/components/loading.ts diff --git a/ui/app.ts b/ui/app.ts index 3418054..d9baf1e 100644 --- a/ui/app.ts +++ b/ui/app.ts @@ -64,39 +64,27 @@ const adminPages = [ let state; -let lastRenderTimestamp = 0; -let pageVisitTimestamp = 0; -let fulfillTimeout = null; - -function fulfill(): void { - clearTimeout(fulfillTimeout); - fulfillTimeout = setTimeout(() => { - store.fulfill(lastRenderTimestamp, pageVisitTimestamp, (updated) => { - if (updated) m.redraw(); - }); - }, 100); -} - function pagify(pageName, page): RouteResolver { const component: RouteResolver = { render: () => { - lastRenderTimestamp = Date.now(); + const lastRenderTimestamp = Date.now(); let p; if (state && state.error) p = m(errorPage.component, state); else p = m(contextifyComponent(page.component), state); - fulfill(); const attrs = {}; attrs["page"] = pageName; + attrs["oncreate"] = attrs["onupdate"] = () => { + store.fulfill(lastRenderTimestamp); + }; return m(layout, attrs, p); }, onmatch: null, }; component.onmatch = (args, requestedPath) => { - pageVisitTimestamp = Date.now(); + store.setTimestamp(Date.now()); if (!page.init) { state = null; - fulfill(); return null; } @@ -107,7 +95,6 @@ function pagify(pageName, page): RouteResolver { if (!st) return void m.route.set("/"); state = st; resolve(); - fulfill(); }) .catch((err) => { if (!window.username && err.message.indexOf("authorized") >= 0) { diff --git a/ui/components.ts b/ui/components.ts index bf783e9..3856bae 100644 --- a/ui/components.ts +++ b/ui/components.ts @@ -39,6 +39,7 @@ import tags from "./components/tags"; import ping from "./components/ping"; import deviceLink from "./components/device-link"; import longTextComponent from "./long-text-component"; +import loading from "./components/loading"; const comps = { parameter, @@ -54,6 +55,7 @@ const comps = { ping, "device-link": deviceLink, "long-text": longTextComponent, + loading: loading, }; const contextifiedComponents = new WeakMap(); diff --git a/ui/components/all-parameters.ts b/ui/components/all-parameters.ts index 709f1dc..3e9cff9 100644 --- a/ui/components/all-parameters.ts +++ b/ui/components/all-parameters.ts @@ -171,25 +171,29 @@ const component: ClosureComponent = (): Component => { }); return m( - ".all-parameters", + "loading", + { queries: [vnode.attrs["deviceQuery"]] }, m( - "a.download-csv", - { - href: `api/devices/${encodeURIComponent( - device["DeviceID.ID"].value[0] - )}.csv`, - download: "", - style: "float: right;", - }, - "Download" - ), - search, - m( - ".parameter-list", - m("table", m("tbody", rows)), + ".all-parameters", m( - "m", - `Displaying ${filteredKeys.length} out of ${count} parameters.` + "a.download-csv", + { + href: `api/devices/${encodeURIComponent( + device["DeviceID.ID"].value[0] + )}.csv`, + download: "", + style: "float: right;", + }, + "Download" + ), + search, + m( + ".parameter-list", + m("table", m("tbody", rows)), + m( + "m", + `Displaying ${filteredKeys.length} out of ${count} parameters.` + ) ) ) ); diff --git a/ui/components/device-faults.ts b/ui/components/device-faults.ts index 6acb5e0..aad01b1 100644 --- a/ui/components/device-faults.ts +++ b/ui/components/device-faults.ts @@ -67,12 +67,12 @@ const component: ClosureComponent = (): Component => { .deleteResource("faults", f["_id"]) .then(() => { notifications.push("success", "Fault deleted"); - store.fulfill(Date.now(), Date.now()); + store.setTimestamp(Date.now()); m.redraw(); }) .catch((err) => { notifications.push("error", err.message); - store.fulfill(Date.now(), Date.now()); + store.setTimestamp(Date.now()); }); }, }, @@ -95,7 +95,11 @@ const component: ClosureComponent = (): Component => { ); } - return m("table.table", thead, tbody); + return m( + "loading", + { queries: [faults] }, + m("table.table", thead, tbody) + ); }, }; }; diff --git a/ui/components/loading.ts b/ui/components/loading.ts new file mode 100644 index 0000000..dcabcca --- /dev/null +++ b/ui/components/loading.ts @@ -0,0 +1,80 @@ +/** + * Copyright 2013-2021 GenieACS Inc. + * + * This file is part of GenieACS. + * + * GenieACS is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * GenieACS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with GenieACS. If not, see . + */ + +import { VnodeDOM, ClosureComponent, Component } from "mithril"; + +const component: ClosureComponent = (): Component => { + let overlay: HTMLElement; + let dom: Element; + let loading = false; + + function apply(vnode: VnodeDOM): void { + if (!loading) { + if (overlay) overlay.parentElement.remove(); + if (dom) dom.classList.remove("loading"); + overlay = null; + dom = null; + return; + } + + if (dom && dom !== vnode.dom) dom.classList.remove("loading"); + + dom = vnode.dom; + dom.classList.add("loading"); + + if (!overlay) { + const wrapper = document.createElement("div"); + wrapper.style.position = "relative"; + wrapper.style.pointerEvents = "none"; + overlay = document.createElement("div"); + overlay.classList.add("loading-overlay"); + overlay.style.position = "absolute"; + wrapper.appendChild(overlay); + } + + const wrapper = overlay.parentElement; + if (wrapper.parentElement !== dom.parentElement) + dom.parentNode.appendChild(wrapper); + + const wrapperRect = wrapper.getBoundingClientRect(); + const domRect = dom.getBoundingClientRect(); + overlay.style.width = `${dom.scrollWidth}px`; + overlay.style.height = `${dom.scrollHeight}px`; + overlay.style.left = `${domRect.left - wrapperRect.left}px`; + overlay.style.top = `${domRect.top - wrapperRect.top}px`; + } + + return { + view: (vnode) => { + const queries = vnode.attrs["queries"]; + loading = queries.some((q) => q.fulfilling); + return vnode.children; + }, + oncreate: apply, + onupdate: apply, + onremove: () => { + if (overlay) overlay.parentElement.remove(); + if (dom) dom.classList.remove("loading"); + overlay = null; + dom = null; + }, + }; +}; + +export default component; diff --git a/ui/components/parameter-list.ts b/ui/components/parameter-list.ts index b7e8b4b..8e2fa42 100644 --- a/ui/components/parameter-list.ts +++ b/ui/components/parameter-list.ts @@ -38,6 +38,11 @@ const component: ClosureComponent = (): Component => { return m( "tr", { + oncreate: (vn) => { + (vn.dom as HTMLElement).style.display = (p as VnodeDOM).dom + ? "" + : "none"; + }, onupdate: (vn) => { (vn.dom as HTMLElement).style.display = (p as VnodeDOM).dom ? "" @@ -49,7 +54,11 @@ const component: ClosureComponent = (): Component => { ); }); - return m("table.parameter-list", rows); + return m( + "loading", + { queries: [vnode.attrs["deviceQuery"]] }, + m("table.parameter-list", rows) + ); }, }; }; diff --git a/ui/components/parameter-table.ts b/ui/components/parameter-table.ts index 5823129..3d3baea 100644 --- a/ui/components/parameter-table.ts +++ b/ui/components/parameter-table.ts @@ -149,7 +149,14 @@ const component: ClosureComponent = (): Component => { let label; if (vnode.attrs["label"]) label = m("h2", vnode.attrs["label"]); - return [label, m("table.table", thead, m("tbody", rows))]; + return [ + label, + m( + "loading", + { queries: [vnode.attrs["deviceQuery"]] }, + m("table.table", thead, m("tbody", rows)) + ), + ]; }, }; }; diff --git a/ui/components/summon-button.ts b/ui/components/summon-button.ts index 0b66310..74cb5c6 100644 --- a/ui/components/summon-button.ts +++ b/ui/components/summon-button.ts @@ -78,7 +78,7 @@ const component: ClosureComponent = (): Component => { ) .then(() => { e.target.disabled = false; - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }) .catch((err) => { e.target.disabled = false; diff --git a/ui/components/tags.ts b/ui/components/tags.ts index 83d4659..e53ba26 100644 --- a/ui/components/tags.ts +++ b/ui/components/tags.ts @@ -64,7 +64,7 @@ const component: ClosureComponent = (): Component => { "success", `${deviceId}: Tags updated` ); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }) .catch((err) => { e.target.disabled = false; @@ -99,7 +99,7 @@ const component: ClosureComponent = (): Component => { .then(() => { e.target.disabled = false; notifications.push("success", `${deviceId}: Tags updated`); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }) .catch((err) => { e.target.disabled = false; diff --git a/ui/config-page.ts b/ui/config-page.ts index 725ea91..c106bcc 100644 --- a/ui/config-page.ts +++ b/ui/config-page.ts @@ -60,12 +60,12 @@ function putActionHandler(action, _object, isNew?): Promise { .resourceExists("config", id) .then((exists) => { if (exists && isNew) { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); return void resolve({ _id: "Config already exists" }); } if (!exists && !isNew) { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); return void resolve({ _id: "Config does not exist" }); } @@ -76,7 +76,7 @@ function putActionHandler(action, _object, isNew?): Promise { "success", `Config ${exists ? "updated" : "created"}` ); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); resolve(); }) .catch(reject); @@ -87,11 +87,11 @@ function putActionHandler(action, _object, isNew?): Promise { .deleteResource("config", object["_id"]) .then(() => { notifications.push("success", "Config deleted"); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); resolve(); }) .catch((err) => { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); reject(err); }); } else { @@ -337,11 +337,11 @@ export const component: ClosureComponent = (): Component => { ); overlay.close(cb); } - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }, onError: (err) => { notifications.push("error", err.message); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); overlay.close(cb); }, }, @@ -366,14 +366,18 @@ export const component: ClosureComponent = (): Component => { return [ m("h1", "Listing config"), m( - ".all-parameters", - search, + "loading", + { queries: [confs] }, m( - ".parameter-list", - { style: "height: 400px" }, - renderTable(confs, vnode.state["searchString"]) - ), - m(".actions-bar", [newConfig].concat(subs)) + ".all-parameters", + search, + m( + ".parameter-list", + { style: "height: 400px" }, + renderTable(confs, vnode.state["searchString"]) + ), + m(".actions-bar", [newConfig].concat(subs)) + ) ), ]; }, diff --git a/ui/css/app.css b/ui/css/app.css index 4d13828..b9b34e1 100644 --- a/ui/css/app.css +++ b/ui/css/app.css @@ -347,7 +347,6 @@ body { table.table { border-spacing: 0; text-align: left; - display: block; overflow-x: auto; & table.table { @@ -753,3 +752,31 @@ span.parameter-value { opacity: 0.3; } } + +.loading { + opacity: 0.6; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.loading-overlay { + display: flex; + align-items: center; + justify-content: center; + &::after { + background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCI+PHBhdGggZD0iTTEwIDUwYTQwIDQwIDAgMDA4MCAwIDQwIDQyIDAgMDEtODAgMCIgZmlsbD0iI2IxMmQ1YyIgLz48L3N2Zz4=); + content: ""; + width: 48px; + height: 48px; + animation: spin 1s linear infinite; + background-repeat: no-repeat; + background-size: 100% 100%; + } +} diff --git a/ui/device-page.ts b/ui/device-page.ts index 3831266..586b49c 100644 --- a/ui/device-page.ts +++ b/ui/device-page.ts @@ -42,13 +42,25 @@ export const component: ClosureComponent = (): Component => { view: (vnode) => { document.title = `${vnode.attrs["deviceId"]} - Devices - GenieACS`; - const dev = store.fetch("devices", vnode.attrs["deviceFilter"]).value; - if (!dev.length) return "Loading"; + const dev = store.fetch("devices", vnode.attrs["deviceFilter"]); + if (!dev.value.length) { + if (!dev.fulfilling) + return m("p.error", `No such device ${vnode.attrs["deviceId"]}`); + return m( + "loading", + { queries: [dev] }, + m("div", { style: "height: 100px;" }) + ); + } + const conf = config.ui.device; const cmps = []; - for (const c of Object.values(conf)) - cmps.push(m.context({ device: dev[0] }, c["type"], c)); + for (const c of Object.values(conf)) { + cmps.push( + m.context({ device: dev.value[0], deviceQuery: dev }, c["type"], c) + ); + } return [m("h1", vnode.attrs["deviceId"]), cmps]; }, diff --git a/ui/devices-page.ts b/ui/devices-page.ts index ac06308..bf3d61a 100644 --- a/ui/devices-page.ts +++ b/ui/devices-page.ts @@ -154,14 +154,14 @@ function renderActions(selected: Set): Children { .deleteResource("devices", id) .then(() => { notifications.push("success", `${id}: Deleted`); - if (--counter === 0) store.fulfill(0, Date.now()); + if (--counter === 0) store.setTimestamp(Date.now()); }) .catch((err) => { notifications.push("error", `${id}: ${err.message}`); - if (--counter === 0) store.fulfill(0, Date.now()); + if (--counter === 0) store.setTimestamp(Date.now()); }); } - if (--counter === 0) store.fulfill(0, Date.now()); + if (--counter === 0) store.setTimestamp(Date.now()); }, }, "Delete" @@ -186,14 +186,14 @@ function renderActions(selected: Set): Children { .updateTags(id, { [tag]: true }) .then(() => { notifications.push("success", `${id}: Tags updated`); - if (--counter === 0) store.fulfill(0, Date.now()); + if (--counter === 0) store.setTimestamp(Date.now()); }) .catch((err) => { notifications.push("error", `${id}: ${err.message}`); - if (--counter === 0) store.fulfill(0, Date.now()); + if (--counter === 0) store.setTimestamp(Date.now()); }); } - if (--counter === 0) store.fulfill(0, Date.now()); + if (--counter === 0) store.setTimestamp(Date.now()); }, }, "Tag" @@ -220,14 +220,14 @@ function renderActions(selected: Set): Children { .updateTags(id, { [tag]: false }) .then(() => { notifications.push("success", `${id}: Tags updated`); - if (--counter === 0) store.fulfill(0, Date.now()); + if (--counter === 0) store.setTimestamp(Date.now()); }) .catch((err) => { notifications.push("error", `${id}: ${err.message}`); - if (--counter === 0) store.fulfill(0, Date.now()); + if (--counter === 0) store.setTimestamp(Date.now()); }); } - if (--counter === 0) store.fulfill(0, Date.now()); + if (--counter === 0) store.setTimestamp(Date.now()); }, }, "Untag" @@ -336,7 +336,7 @@ export const component: ClosureComponent = (): Component => { return [ m("h1", "Listing devices"), m(filterComponent, filterAttrs), - m(indexTableComponent, attrs), + m("loading", { queries: [devs, count] }, m(indexTableComponent, attrs)), ]; }, }; diff --git a/ui/drawer-component.ts b/ui/drawer-component.ts index 6076d01..17e2037 100644 --- a/ui/drawer-component.ts +++ b/ui/drawer-component.ts @@ -465,7 +465,7 @@ const component: ClosureComponent = (): Component => { } ) .then(() => { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }) .catch((err) => { notifications.push("error", err.message); diff --git a/ui/faults-page.ts b/ui/faults-page.ts index c2bd803..9cae35f 100644 --- a/ui/faults-page.ts +++ b/ui/faults-page.ts @@ -187,11 +187,11 @@ export const component: ClosureComponent = (): Component => { "success", `${res.length} faults deleted` ); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }) .catch((err) => { notifications.push("error", err.message); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }); }, }, @@ -208,7 +208,11 @@ export const component: ClosureComponent = (): Component => { return [ m("h1", "Listing faults"), m(filterComponent, filterAttrs), - m(indexTableComponent, attrs), + m( + "loading", + { queries: [faults, count] }, + m(indexTableComponent, attrs) + ), ]; }, }; diff --git a/ui/files-page.ts b/ui/files-page.ts index 54e586d..ff041c3 100644 --- a/ui/files-page.ts +++ b/ui/files-page.ts @@ -93,7 +93,7 @@ function putActionHandler(action, _object): Promise { .resourceExists("files", id) .then((exists) => { if (exists) { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); return void resolve({ file: "File already exists" }); } const headers = Object.assign( @@ -117,7 +117,7 @@ function putActionHandler(action, _object): Promise { "success", `File ${exists ? "updated" : "created"}` ); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); resolve(); }) .catch(reject); @@ -288,11 +288,11 @@ export const component: ClosureComponent = (): Component => { "success", `${res.length} files deleted` ); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }) .catch((err) => { notifications.push("error", err.message); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }); }, }, @@ -310,7 +310,11 @@ export const component: ClosureComponent = (): Component => { return [ m("h1", "Listing files"), m(filterComponent, filterAttrs), - m(indexTableComponent, attrs), + m( + "loading", + { queries: [files, count] }, + m(indexTableComponent, attrs) + ), ]; }, }; diff --git a/ui/permissions-page.ts b/ui/permissions-page.ts index e4ac703..cf9cb74 100644 --- a/ui/permissions-page.ts +++ b/ui/permissions-page.ts @@ -117,7 +117,7 @@ function putActionHandler(action, _object): Promise { .resourceExists("permissions", id) .then((exists) => { if (exists) { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); return void resolve({ _id: "Permission already exists" }); } @@ -125,7 +125,7 @@ function putActionHandler(action, _object): Promise { .putResource("permissions", id, object) .then(() => { notifications.push("success", "Permission created"); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); resolve(); }) .catch(reject); @@ -136,11 +136,11 @@ function putActionHandler(action, _object): Promise { .deleteResource("permissions", object["_id"]) .then(() => { notifications.push("success", "Permission deleted"); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); resolve(); }) .catch((err) => { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); reject(err); }); } else { @@ -351,11 +351,11 @@ export const component: ClosureComponent = (): Component => { "success", `${res.length} permissions deleted` ); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }) .catch((err) => { notifications.push("error", err.message); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }); }, }, @@ -373,7 +373,11 @@ export const component: ClosureComponent = (): Component => { return [ m("h1", "Listing permissions"), m(filterComponent, filterAttrs), - m(indexTableComponent, attrs), + m( + "loading", + { queries: [permissions, count] }, + m(indexTableComponent, attrs) + ), ]; }, }; diff --git a/ui/pie-chart-component.ts b/ui/pie-chart-component.ts index e73ed21..55dbe95 100644 --- a/ui/pie-chart-component.ts +++ b/ui/pie-chart-component.ts @@ -116,21 +116,27 @@ function drawChart(chartData): Children { legend.push(m("span.legend-total", `Total: ${total}`)); - return m("div", { class: "pie-chart" }, [ - m( - "svg", - { - // Adding 2 as padding; strokes must not be more than 2 - viewBox: "-102 -102 204 204", - width: "204px", - height: "204px", - xmlns: "http://www.w3.org/2000/svg", - "xmlns:xlink": "http://www.w3.org/1999/xlink", - }, - paths.concat(links) - ), - m(".legend", legend), - ]); + return m( + "loading", + { + queries: Object.values(chartData.slices).map((s) => s["count"]), + }, + m("div", { class: "pie-chart" }, [ + m( + "svg", + { + // Adding 2 as padding; strokes must not be more than 2 + viewBox: "-102 -102 204 204", + width: "204px", + height: "204px", + xmlns: "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink", + }, + paths.concat(links) + ), + m(".legend", legend), + ]) + ); } const component: ClosureComponent = (): Component => { diff --git a/ui/presets-page.ts b/ui/presets-page.ts index 71d55ca..804848e 100644 --- a/ui/presets-page.ts +++ b/ui/presets-page.ts @@ -86,12 +86,12 @@ function putActionHandler(action, _object, isNew): Promise { .resourceExists("presets", id) .then((exists) => { if (exists && isNew) { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); return void resolve({ _id: "Preset already exists" }); } if (!exists && !isNew) { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); return void resolve({ _id: "Preset does not exist" }); } @@ -102,7 +102,7 @@ function putActionHandler(action, _object, isNew): Promise { "success", `Preset ${exists ? "updated" : "created"}` ); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); resolve(); }) .catch(reject); @@ -113,12 +113,12 @@ function putActionHandler(action, _object, isNew): Promise { .deleteResource("presets", object["_id"]) .then(() => { notifications.push("success", "Preset deleted"); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); resolve(); }) .catch((err) => { reject(err); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }); } else { reject(new Error("Undefined action")); @@ -413,11 +413,11 @@ export const component: ClosureComponent = (): Component => { "success", `${res.length} presets deleted` ); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }) .catch((err) => { notifications.push("error", err.message); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }); }, }, @@ -435,7 +435,11 @@ export const component: ClosureComponent = (): Component => { return [ m("h1", "Listing presets"), m(filterComponent, filterAttrs), - m(indexTableComponent, attrs), + m( + "loading", + { queries: [presets, count] }, + m(indexTableComponent, attrs) + ), ]; }, }; diff --git a/ui/provisions-page.ts b/ui/provisions-page.ts index 98a9952..2bc0a79 100644 --- a/ui/provisions-page.ts +++ b/ui/provisions-page.ts @@ -66,12 +66,12 @@ function putActionHandler(action, _object, isNew): Promise { .resourceExists("provisions", id) .then((exists) => { if (exists && isNew) { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); return void resolve({ _id: "Provision already exists" }); } if (!exists && !isNew) { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); return void resolve({ _id: "Provision does not exist" }); } @@ -82,7 +82,7 @@ function putActionHandler(action, _object, isNew): Promise { "success", `Provision ${exists ? "updated" : "created"}` ); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); resolve(); }) .catch((err) => { @@ -99,11 +99,11 @@ function putActionHandler(action, _object, isNew): Promise { .deleteResource("provisions", object["_id"]) .then(() => { notifications.push("success", "Provision deleted"); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); resolve(); }) .catch((err) => { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); reject(err); }); } else { @@ -332,11 +332,11 @@ export const component: ClosureComponent = (): Component => { "success", `${res.length} provisions deleted` ); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }) .catch((err) => { notifications.push("error", err.message); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }); }, }, @@ -354,7 +354,11 @@ export const component: ClosureComponent = (): Component => { return [ m("h1", "Listing provisions"), m(filterComponent, filterAttrs), - m(indexTableComponent, attrs), + m( + "loading", + { queries: [provisions, count] }, + m(indexTableComponent, attrs) + ), ]; }, }; diff --git a/ui/store.ts b/ui/store.ts index 82bb02f..a1c424a 100644 --- a/ui/store.ts +++ b/ui/store.ts @@ -76,14 +76,14 @@ for (const r of [ } class QueryResponse { - public get fulfilled(): boolean { + public get fulfilled(): number { queries.accessed.set(this, Date.now()); - return !!queries.fulfilled.get(this); + return queries.fulfilled.get(this) || 0; } public get fulfilling(): boolean { queries.accessed.set(this, Date.now()); - return queries.fulfilling.has(this); + return !(queries.fulfilled.get(this) >= fulfillTimestamp); } public get value(): any { @@ -348,18 +348,7 @@ export function fetch( return queryResponse; } -function _fulfill( - accessTimestamp: number, - _fulfillTimestamp: number -): Promise { - let updated = false; - - if (_fulfillTimestamp > fulfillTimestamp) { - for (const resource of Object.values(resources)) - resource.combinedFilter = null; - fulfillTimestamp = _fulfillTimestamp; - } - +export function fulfill(accessTimestamp: number): void { const allPromises = []; for (const [resourceType, resource] of Object.entries(resources)) { @@ -373,38 +362,32 @@ function _fulfill( if (!(fulfillTimestamp <= queries.fulfilled.get(queryResponse))) { queries.fulfilling.add(queryResponse); + let filter = queries.filter.get(queryResponse); + filter = unpackExpression(filter); allPromises.push( - new Promise((resolve, reject) => { - updated = true; - let filter = queries.filter.get(queryResponse); - filter = unpackExpression(filter); - xhrRequest({ - method: "HEAD", - url: - `api/${resourceType}/?` + - m.buildQueryString({ - filter: memoizedStringify(filter), - }), - extract: (xhr) => { - if (xhr.status === 403) throw new Error("Not authorized"); - if (!xhr.status) { - throw new Error("Server is unreachable"); - } else if (xhr.status !== 200) { - throw new Error( - `Unexpected response status code ${xhr.status}` - ); - } - return +xhr.getResponseHeader("x-total-count"); - }, - background: true, - }) - .then((c) => { - queries.value.set(queryResponse, c); - queries.fulfilled.set(queryResponse, fulfillTimestamp); - queries.fulfilling.delete(queryResponse); - resolve(); - }) - .catch((err) => reject(err)); + xhrRequest({ + method: "HEAD", + url: + `api/${resourceType}/?` + + m.buildQueryString({ + filter: memoizedStringify(filter), + }), + extract: (xhr) => { + if (xhr.status === 403) throw new Error("Not authorized"); + if (!xhr.status) { + throw new Error("Server is unreachable"); + } else if (xhr.status !== 200) { + throw new Error( + `Unexpected response status code ${xhr.status}` + ); + } + return +xhr.getResponseHeader("x-total-count"); + }, + background: false, + }).then((c) => { + queries.value.set(queryResponse, c); + queries.fulfilled.set(queryResponse, fulfillTimestamp); + queries.fulfilling.delete(queryResponse); }) ); } @@ -429,45 +412,39 @@ function _fulfill( const limit = queries.limit.get(queryResponse); const sort = queries.sort.get(queryResponse); if (limit) { + let filter = queries.filter.get(queryResponse); + filter = unpackExpression(filter); allPromises.push( - new Promise((resolve, reject) => { - updated = true; - let filter = queries.filter.get(queryResponse); - filter = unpackExpression(filter); - xhrRequest({ - method: "GET", - url: - `api/${resourceType}/?` + - m.buildQueryString({ - filter: memoizedStringify(filter), - limit: 1, - skip: limit - 1, - sort: JSON.stringify(sort), - projection: Object.keys(sort).join(","), - }), - }) - .then((res) => { - if ((res as any[]).length) { - // Generate bookmark object - const bm = Object.keys(sort).reduce((b, k) => { - if (res[0][k] != null) { - if (typeof res[0][k] === "object") { - if (res[0][k].value != null) - b[k] = res[0][k].value[0]; - } else { - b[k] = res[0][k]; - } - } - - return b; - }, {}); - queries.bookmark.set(queryResponse, bm); - } else { - queries.bookmark.delete(queryResponse); + xhrRequest({ + method: "GET", + url: + `api/${resourceType}/?` + + m.buildQueryString({ + filter: memoizedStringify(filter), + limit: 1, + skip: limit - 1, + sort: JSON.stringify(sort), + projection: Object.keys(sort).join(","), + }), + background: true, + }).then((res) => { + if ((res as any[]).length) { + // Generate bookmark object + const bm = Object.keys(sort).reduce((b, k) => { + if (res[0][k] != null) { + if (typeof res[0][k] === "object") { + if (res[0][k].value != null) b[k] = res[0][k].value[0]; + } else { + b[k] = res[0][k]; + } } - resolve(); - }) - .catch(reject); + + return b; + }, {}); + queries.bookmark.set(queryResponse, bm); + } else { + queries.bookmark.delete(queryResponse); + } }) ); } @@ -475,31 +452,82 @@ function _fulfill( } } - return new Promise((resolve, reject) => { - Promise.all(allPromises) - .then(() => { - const allPromises2 = []; - for (const [resourceType, toFetch] of Object.entries(toFetchAll)) { - let combinedFilter = null; + Promise.all(allPromises) + .then(() => { + let updated = false; + const allPromises2 = []; + for (const [resourceType, toFetch] of Object.entries(toFetchAll)) { + let combinedFilter = null; + for (const queryResponse of toFetch) { + let filter = queries.filter.get(queryResponse); + filter = memoizedEvaluate(filter, null, fulfillTimestamp); + const bookmark = queries.bookmark.get(queryResponse); + const sort = queries.sort.get(queryResponse); + if (bookmark) filter = limitFilter(filter, sort, bookmark); + combinedFilter = or(combinedFilter, filter); + } + + const [union, diff] = unionDiff( + resources[resourceType].combinedFilter, + combinedFilter + ); + + if (!diff) { for (const queryResponse of toFetch) { let filter = queries.filter.get(queryResponse); filter = memoizedEvaluate(filter, null, fulfillTimestamp); + const limit = queries.limit.get(queryResponse); const bookmark = queries.bookmark.get(queryResponse); const sort = queries.sort.get(queryResponse); if (bookmark) filter = limitFilter(filter, sort, bookmark); - combinedFilter = or(combinedFilter, filter); + + queries.value.set( + queryResponse, + findMatches(resourceType, filter, sort, limit) + ); + queries.fulfilled.set(queryResponse, fulfillTimestamp); + queries.fulfilling.delete(queryResponse); + updated = true; } + continue; + } - const [union, diff] = unionDiff( - resources[resourceType].combinedFilter, - combinedFilter - ); + let deleted = new Set(); + if (!resources[resourceType].combinedFilter) + deleted = new Set(resources[resourceType].objects.keys()); + + const combinedFilterDiff = diff; + resources[resourceType].combinedFilter = union; + + allPromises2.push( + xhrRequest({ + method: "GET", + url: + `api/${resourceType}/?` + + m.buildQueryString({ + filter: memoizedStringify(combinedFilterDiff), + }), + background: false, + }).then((res) => { + for (const r of res as any[]) { + const id = + resourceType === "devices" + ? r["DeviceID.ID"].value[0] + : r["_id"]; + resources[resourceType].objects.set(id, r); + deleted.delete(id); + } + + for (const d of deleted) { + const obj = resources[resourceType].objects.get(d); + if (evaluate(combinedFilterDiff, obj, fulfillTimestamp)) + resources[resourceType].objects.delete(d); + } - if (!diff) { for (const queryResponse of toFetch) { let filter = queries.filter.get(queryResponse); - filter = memoizedEvaluate(filter, null, fulfillTimestamp); + filter = unpackExpression(filter); const limit = queries.limit.get(queryResponse); const bookmark = queries.bookmark.get(queryResponse); const sort = queries.sort.get(queryResponse); @@ -512,87 +540,29 @@ function _fulfill( queries.fulfilled.set(queryResponse, fulfillTimestamp); queries.fulfilling.delete(queryResponse); } - continue; - } - - updated = true; - let deleted = new Set(); - if (!resources[resourceType].combinedFilter) - deleted = new Set(resources[resourceType].objects.keys()); - - const combinedFilterDiff = diff; - resources[resourceType].combinedFilter = union; - - allPromises2.push( - new Promise((resolve2, reject2) => { - xhrRequest({ - method: "GET", - url: - `api/${resourceType}/?` + - m.buildQueryString({ - filter: memoizedStringify(combinedFilterDiff), - }), - }) - .then((res) => { - for (const r of res as any[]) { - const id = - resourceType === "devices" - ? r["DeviceID.ID"].value[0] - : r["_id"]; - resources[resourceType].objects.set(id, r); - deleted.delete(id); - } - - for (const d of deleted) { - const obj = resources[resourceType].objects.get(d); - if (evaluate(combinedFilterDiff, obj, fulfillTimestamp)) - resources[resourceType].objects.delete(d); - } - - for (const queryResponse of toFetch) { - let filter = queries.filter.get(queryResponse); - filter = unpackExpression(filter); - const limit = queries.limit.get(queryResponse); - const bookmark = queries.bookmark.get(queryResponse); - const sort = queries.sort.get(queryResponse); - if (bookmark) filter = limitFilter(filter, sort, bookmark); - - queries.value.set( - queryResponse, - findMatches(resourceType, filter, sort, limit) - ); - queries.fulfilled.set(queryResponse, fulfillTimestamp); - queries.fulfilling.delete(queryResponse); - } - resolve2(); - }) - .catch(reject2); - }) - ); - } - Promise.all(allPromises2) - .then(() => resolve(updated)) - .catch(reject); - }) - .catch(reject); - }); -} - -export function fulfill( - accessTimestamp: number, - _fulfillTimestamp: number, - callback?: (updated: boolean) => void -): void { - _fulfill(accessTimestamp, _fulfillTimestamp).then(callback, (err) => { - notifications.push("error", err.message); - if (callback) callback(false); - }); + }) + ); + } + if (updated) m.redraw(); + return Promise.all(allPromises2); + }) + .catch((err) => { + notifications.push("error", err.message); + }); } export function getTimestamp(): number { return fulfillTimestamp; } +export function setTimestamp(t: number): void { + if (t > fulfillTimestamp) { + fulfillTimestamp = t; + for (const resource of Object.values(resources)) + resource.combinedFilter = null; + } +} + export function postTasks( deviceId: string, tasks: QueueTask[] diff --git a/ui/users-page.ts b/ui/users-page.ts index 15ade7f..3ba2c91 100644 --- a/ui/users-page.ts +++ b/ui/users-page.ts @@ -85,12 +85,12 @@ function putActionHandler(action, _object, isNew): Promise { .resourceExists("users", id) .then((exists) => { if (exists && isNew) { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); return void resolve({ _id: "User already exists" }); } if (!exists && !isNew) { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); return void resolve({ _id: "User does not exist" }); } @@ -102,13 +102,13 @@ function putActionHandler(action, _object, isNew): Promise { .changePassword(id, password) .then(() => { notifications.push("success", "User created"); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); resolve(); }) .catch(reject); } else { notifications.push("success", "User updated"); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); resolve(); } }) @@ -120,11 +120,11 @@ function putActionHandler(action, _object, isNew): Promise { .deleteResource("users", object["_id"]) .then(() => { notifications.push("success", "User deleted"); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); resolve(); }) .catch((err) => { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); reject(err); }); } else { @@ -387,11 +387,11 @@ export const component: ClosureComponent = (): Component => { "success", `${res.length} users deleted` ); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }) .catch((err) => { notifications.push("error", err.message); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }); }, }, @@ -409,7 +409,11 @@ export const component: ClosureComponent = (): Component => { return [ m("h1", "Listing users"), m(filterComponent, filterAttrs), - m(indexTableComponent, attrs), + m( + "loading", + { queries: [users, count] }, + m(indexTableComponent, attrs) + ), ]; }, }; diff --git a/ui/virtual-parameters-page.ts b/ui/virtual-parameters-page.ts index 742d5db..427c84a 100644 --- a/ui/virtual-parameters-page.ts +++ b/ui/virtual-parameters-page.ts @@ -66,12 +66,12 @@ function putActionHandler(action, _object, isNew): Promise { .resourceExists("virtualParameters", id) .then((exists) => { if (exists && isNew) { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); return void resolve({ _id: "Virtual parameter already exists" }); } if (!exists && !isNew) { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); return void resolve({ _id: "Virtual parameter does not exist" }); } @@ -82,7 +82,7 @@ function putActionHandler(action, _object, isNew): Promise { "success", `Virtual parameter ${exists ? "updated" : "created"}` ); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); resolve(); }) .catch((err) => { @@ -99,11 +99,11 @@ function putActionHandler(action, _object, isNew): Promise { .deleteResource("virtualParameters", object["_id"]) .then(() => { notifications.push("success", "Virtual parameter deleted"); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); resolve(); }) .catch((err) => { - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); reject(err); }); } else { @@ -332,11 +332,11 @@ export const component: ClosureComponent = (): Component => { "success", `${res.length} virtual parameters deleted` ); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }) .catch((err) => { notifications.push("error", err.message); - store.fulfill(0, Date.now()); + store.setTimestamp(Date.now()); }); }, }, @@ -354,7 +354,11 @@ export const component: ClosureComponent = (): Component => { return [ m("h1", "Listing virtual parameters"), m(filterComponent, filterAttrs), - m(indexTableComponent, attrs), + m( + "loading", + { queries: [virtualParameters, count] }, + m(indexTableComponent, attrs) + ), ]; }, };