From 83dba52e96d51047927780f2565fcf66aa0983b8 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Thu, 25 Apr 2019 19:00:18 +0300 Subject: [PATCH] Show reload notification on config update --- lib/ui.ts | 2 + ui/app.ts | 1 + ui/config.ts | 1 + ui/css/app.css | 5 ++ ui/drawer-component.ts | 10 +++- ui/notifications.ts | 26 +++++++--- ui/store.ts | 114 ++++++++++++++++++++++++++++++----------- 7 files changed, 122 insertions(+), 37 deletions(-) diff --git a/lib/ui.ts b/lib/ui.ts index aee29af..3d7df6a 100644 --- a/lib/ui.ts +++ b/lib/ui.ts @@ -41,6 +41,7 @@ koa.on("error", async err => { koa.use(async (ctx, next) => { const configSnapshot = await localCache.getCurrentSnapshot(); ctx.state.configSnapshot = configSnapshot; + ctx.set("X-Config-Snapshot", configSnapshot); return next(); }); @@ -137,6 +138,7 @@ router.get("/", async ctx => { window.clientConfig = ${JSON.stringify({ ui: localCache.getUiConfig(ctx.state.configSnapshot) })}; + window.configSnapshot = ${JSON.stringify(ctx.state.configSnapshot)}; window.username = ${JSON.stringify( ctx.state.user ? ctx.state.user.username : "" )}; diff --git a/ui/app.ts b/ui/app.ts index 4a6b07e..0f0334a 100644 --- a/ui/app.ts +++ b/ui/app.ts @@ -24,6 +24,7 @@ declare global { permissionSets: any; username: string; clientConfig: any; + configSnapshot: string; } } diff --git a/ui/config.ts b/ui/config.ts index 7d4591f..b6452cb 100644 --- a/ui/config.ts +++ b/ui/config.ts @@ -1 +1,2 @@ export default window.clientConfig; +export const configSnapshot = window.configSnapshot; diff --git a/ui/css/app.css b/ui/css/app.css index f5976c6..9955bb1 100644 --- a/ui/css/app.css +++ b/ui/css/app.css @@ -18,6 +18,7 @@ --status-red: hsl(0, 80%, 80%); --status-green: hsl(125, 80%, 80%); --status-gray: hsl(58, 3%, 80%); + --status-yellow: hsl(46, 79%, 80%); } body, @@ -201,6 +202,10 @@ i { background-color: color(var(--status-green) a(90%)); border: 1px solid var(--status-green); } + &.warning { + background-color: color(var(--status-yellow) a(90%)); + border: 1px solid var(--status-yellow); + } } } } diff --git a/ui/drawer-component.ts b/ui/drawer-component.ts index 6796ff8..480a144 100644 --- a/ui/drawer-component.ts +++ b/ui/drawer-component.ts @@ -290,6 +290,14 @@ function renderNotifications(notifs): Child[] { const notificationElements: Child[] = []; for (const n of notifs) { + let buttons; + if (n.actions) { + const btns = Object.entries(n.actions).map(([label, onclick]) => + m("button.primary", { onclick: onclick }, label) + ); + if (btns.length) buttons = m("div", { style: "float: right" }, btns); + } + notificationElements.push( m( "div.notification", @@ -309,7 +317,7 @@ function renderNotifications(notifs): Child[] { }, key: n.timestamp }, - n.message + m("div", buttons, n.message) ) ); } diff --git a/ui/notifications.ts b/ui/notifications.ts index 06dc2d5..29b314e 100644 --- a/ui/notifications.ts +++ b/ui/notifications.ts @@ -4,18 +4,32 @@ interface Notification { type: string; message: string; timestamp: number; + actions?: { [label: string]: () => void }; } const notifications = new Set(); -export function push(type, message): void { - const n = { type: type, message: message, timestamp: Date.now() }; +export function push(type, message, actions?): Notification { + const n: Notification = { + type: type, + message: message, + timestamp: Date.now(), + actions: actions + }; notifications.add(n); m.redraw(); - setTimeout(() => { - notifications.delete(n); - m.redraw(); - }, 4000); + if (!actions) { + setTimeout(() => { + dismiss(n); + }, 4000); + } + + return n; +} + +export function dismiss(n: Notification): void { + notifications.delete(n); + m.redraw(); } export function getNotifications(): Set { diff --git a/ui/store.ts b/ui/store.ts index a3ad1ae..508e371 100644 --- a/ui/store.ts +++ b/ui/store.ts @@ -3,11 +3,14 @@ import { map, stringify } from "../lib/common/expression-parser"; import { or, and, not, evaluate, subset } from "../lib/common/expression"; import memoize from "../lib/common/memoize"; import { QueryOptions, Expression } from "../lib/types"; +import * as notifications from "./notifications"; +import { configSnapshot } from "./config"; const memoizedStringify = memoize(stringify); const memoizedEvaluate = memoize(evaluate); let fulfillTimestamp = 0; +let refreshNotification; const queries = { filter: new WeakMap(), @@ -67,6 +70,59 @@ class QueryResponse { } } +export async function xhrRequest( + options: { url: string } & m.RequestOptions<{}> +): Promise { + const extract = options.extract; + const deserialize = options.deserialize; + + options.extract = ( + xhr: XMLHttpRequest, + _options?: { url: string } & m.RequestOptions<{}> + ): any => { + if ( + !refreshNotification && + configSnapshot !== xhr.getResponseHeader("x-config-snapshot") + ) { + refreshNotification = notifications.push( + "warning", + "Configuration has been modified, please reload the page", + { + Reload: () => { + window.location.reload(); + } + } + ); + } + + if (typeof extract === "function") return extract(xhr, _options); + + let response: any; + if (typeof deserialize === "function") { + response = deserialize(xhr.responseText); + } else { + try { + response = xhr.responseText ? JSON.parse(xhr.responseText) : null; + } catch (err) { + throw new Error("Invalid JSON: " + response); + } + } + + // https://mithril.js.org/request.html#error-handling + if (xhr.status !== 304 && Math.floor(xhr.status / 100) !== 2) { + const err = new Error(); + err["message"] = xhr.responseText; + err["code"] = xhr.status; + err["response"] = response; + throw err; + } + + return response; + }; + + return m.request(options); +} + export function unpackExpression(exp): Expression { if (!Array.isArray(exp)) return exp; const e = memoizedEvaluate(exp, null, fulfillTimestamp); @@ -271,7 +327,7 @@ export function fulfill(accessTimestamp, _fulfillTimestamp): Promise { updated = true; let filter = queries.filter.get(queryResponse); filter = unpackExpression(filter); - m.request({ + xhrRequest({ method: "HEAD", url: `/api/${resourceType}/?` + @@ -317,7 +373,7 @@ export function fulfill(accessTimestamp, _fulfillTimestamp): Promise { updated = true; let filter = queries.filter.get(queryResponse); filter = unpackExpression(filter); - m.request({ + xhrRequest({ method: "GET", url: `/api/${resourceType}/?` + @@ -410,7 +466,7 @@ export function fulfill(accessTimestamp, _fulfillTimestamp): Promise { allPromises2.push( new Promise((resolve2, reject2) => { - m.request({ + xhrRequest({ method: "GET", url: `/api/${resourceType}/?` + @@ -473,30 +529,28 @@ export function postTasks(deviceId, tasks): Promise { t.device = deviceId; } - return new Promise((resolve, reject) => { - m.request({ - method: "POST", - url: `/api/devices/${encodeURIComponent(deviceId)}/tasks`, - data: tasks, - extract: xhr => { - if (xhr.status !== 200) throw new Error(xhr.response); - const connectionRequestStatus = xhr.getResponseHeader( - "Connection-Request" - ); - const st = JSON.parse(xhr.response); - for (const [i, t] of st.entries()) { - tasks[i]._id = t._id; - tasks[i].status = t.status; - tasks[i].fault = t.fault; - } - resolve(connectionRequestStatus); + return xhrRequest({ + method: "POST", + url: `/api/devices/${encodeURIComponent(deviceId)}/tasks`, + data: tasks, + extract: xhr => { + if (xhr.status !== 200) throw new Error(xhr.response); + const connectionRequestStatus = xhr.getResponseHeader( + "Connection-Request" + ); + const st = JSON.parse(xhr.response); + for (const [i, t] of st.entries()) { + tasks[i]._id = t._id; + tasks[i].status = t.status; + tasks[i].fault = t.fault; } - }).catch(reject); + return connectionRequestStatus; + } }); } export function updateTags(deviceId, tags): Promise { - return m.request({ + return xhrRequest({ method: "POST", url: `/api/devices/${encodeURIComponent(deviceId)}/tags`, data: tags @@ -504,7 +558,7 @@ export function updateTags(deviceId, tags): Promise { } export function deleteResource(resourceType, id): Promise { - return m.request({ + return xhrRequest({ method: "DELETE", url: `/api/${resourceType}/${encodeURIComponent(id)}` }); @@ -513,7 +567,7 @@ export function deleteResource(resourceType, id): Promise { export function putResource(resourceType, id, object): Promise { for (const k in object) if (object[k] === undefined) object[k] = null; - return m.request({ + return xhrRequest({ method: "PUT", url: `/api/${resourceType}/${encodeURIComponent(id)}`, data: object @@ -522,7 +576,7 @@ export function putResource(resourceType, id, object): Promise { export function queryConfig(pattern = "%"): Promise { const filter = stringify(["LIKE", ["PARAM", "_id"], pattern]); - return m.request({ + return xhrRequest({ method: "GET", url: `api/config/?${m.buildQueryString({ filter: filter })}`, background: true @@ -532,7 +586,7 @@ export function queryConfig(pattern = "%"): Promise { export function resourceExists(resource, id): Promise { const param = resource === "devices" ? "DeviceID.ID" : "_id"; const filter = ["=", ["PARAM", param], id]; - return m.request({ + return xhrRequest({ method: "HEAD", url: `/api/${resource}/?` + @@ -556,7 +610,7 @@ export function changePassword( ): Promise { const data = { newPassword }; if (authPassword) data["authPassword"] = authPassword; - return m.request({ + return xhrRequest({ method: "PUT", url: `/api/users/${username}/password`, background: true, @@ -565,7 +619,7 @@ export function changePassword( } export function logIn(username, password): Promise { - return m.request({ + return xhrRequest({ method: "POST", url: "/login", background: true, @@ -574,14 +628,14 @@ export function logIn(username, password): Promise { } export function logOut(): Promise { - return m.request({ + return xhrRequest({ method: "POST", url: "/logout" }); } export function ping(host): Promise<{}> { - return m.request({ + return xhrRequest({ url: `/api/ping/${encodeURIComponent(host)}` }); }