From 75d7e8545463a383d4056de6e96f9a1171585973 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sat, 2 Dec 2017 01:44:10 -0800 Subject: [PATCH 001/106] New skeleton web app --- .eslintrc.json | 17 +++++++++++++++++ client/.eslintrc.json | 6 ++++++ client/app.js | 5 +++++ package.json | 25 +++++++++++++++++++++++++ public/index.html | 7 +++++++ server/.eslintrc.json | 5 +++++ server/api.js | 7 +++++++ server/config.js | 3 +++ server/server.js | 21 +++++++++++++++++++++ webpack.config.js | 6 ++++++ 10 files changed, 102 insertions(+) create mode 100644 .eslintrc.json create mode 100644 client/.eslintrc.json create mode 100644 client/app.js create mode 100644 package.json create mode 100644 public/index.html create mode 100644 server/.eslintrc.json create mode 100644 server/api.js create mode 100644 server/config.js create mode 100644 server/server.js create mode 100644 webpack.config.js diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..7a3bed9 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,17 @@ +{ + "env": { + "es6": true + }, + "extends": "eslint:recommended", + "parserOptions": { + "sourceType": "script" + }, + "plugins": ["prettier"], + "rules": { + "no-shadow": ["error", { "allow": ["err"] }], + "handle-callback-err": "error", + "prefer-arrow-callback": "error", + "curly": ["error", "multi", "consistent"], + "prettier/prettier": "error" + } +} diff --git a/client/.eslintrc.json b/client/.eslintrc.json new file mode 100644 index 0000000..dcd577e --- /dev/null +++ b/client/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "env": { + "browser": true, + "commonjs": true + } +} diff --git a/client/app.js b/client/app.js new file mode 100644 index 0000000..5f20515 --- /dev/null +++ b/client/app.js @@ -0,0 +1,5 @@ +"use strict"; + +const m = require("mithril"); + +m.render(document.body, "hello world"); diff --git a/package.json b/package.json new file mode 100644 index 0000000..75fa1d6 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "genieacs-ui", + "version": "1.0.0", + "description": "", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "lint": "eslint --fix server/*.js client/*.js", + "build": "webpack", + "start": "npm run lint;npm run build;node server/server" + }, + "author": "Zaid Abdulla ", + "dependencies": { + "koa": "^2.4.1", + "koa-bodyparser": "^4.2.0", + "koa-router": "^7.3.0", + "koa-static": "^4.0.2", + "mithril": "^1.1.5" + }, + "devDependencies": { + "eslint": "^4.12.1", + "eslint-plugin-prettier": "^2.3.1", + "prettier": "^1.8.2", + "webpack": "^3.9.1" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..772daa5 --- /dev/null +++ b/public/index.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/server/.eslintrc.json b/server/.eslintrc.json new file mode 100644 index 0000000..10d2238 --- /dev/null +++ b/server/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "env": { + "node": true + } +} diff --git a/server/api.js b/server/api.js new file mode 100644 index 0000000..c0c9598 --- /dev/null +++ b/server/api.js @@ -0,0 +1,7 @@ +"use strict"; + +const Router = require("koa-router"); + +const router = new Router(); + +module.exports = router; diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..41d7767 --- /dev/null +++ b/server/config.js @@ -0,0 +1,3 @@ +"use strict"; + +exports.PORT = 3000; diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..ea0d898 --- /dev/null +++ b/server/server.js @@ -0,0 +1,21 @@ +"use strict"; + +const Koa = require("koa"); +const Router = require("koa-router"); +const koaStatic = require("koa-static"); + +const config = require("./config"); +const api = require("./api"); + +const koa = new Koa(); +const router = new Router(); + +router.use("/api", api.routes(), api.allowedMethods()); + +koa.use(router.routes()); +koa.use(koaStatic("./public")); + +koa.listen(config.PORT, () => { + // eslint-disable-next-line no-console + console.log(`Server listening on port ${config.PORT}`); +}); diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..06a9de9 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,6 @@ +module.exports = { + entry: "./client/app.js", + output: { + filename: "public/app.js" + } +}; From 75957ea85c6fd221403ed751acdd7411ef6b2f72 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sat, 2 Dec 2017 16:45:05 -0800 Subject: [PATCH 002/106] Implement query API function --- server/api-functions.js | 106 ++++++++++++++++++++++++++++++++++++++++ server/config.js | 1 + 2 files changed, 107 insertions(+) create mode 100644 server/api-functions.js diff --git a/server/api-functions.js b/server/api-functions.js new file mode 100644 index 0000000..706a63b --- /dev/null +++ b/server/api-functions.js @@ -0,0 +1,106 @@ +"use strict"; + +const http = require("http"); +const https = require("https"); +const url = require("url"); +const querystring = require("querystring"); + +const config = require("./config"); + +const commaAscii = ",".charCodeAt(0); +const newLineAscii = "\n".charCodeAt(0); + +function filtersToQuery(filters) { + if (!Array.isArray(filters)) filters = [filters]; + + let queries = []; + for (let filter of filters) { + let q = {}; + for (let [k, v] of Object.entries(filter)) { + let [param, op] = k.split(/([^a-zA-Z0-9\-_.].*)/, 2); + if (!op || op === "=") q[param] = v; + else throw new Error(`Operator "${op}" not recognized`); + } + queries.push(q); + } + + if (queries.length === 1) return queries[0]; + else return { $or: queries }; +} + +function query(resource, filters, limit, callback) { + return new Promise((resolve, reject) => { + let ret; + if (!callback) ret = []; + let q = {}; + if (filters) q.query = JSON.stringify(filtersToQuery(filters)); + + if (limit) q.limit = limit; + + let options = url.parse( + `${config.GENIEACS_NBI}${resource}?${querystring.stringify(q)}` + ); + + let _http = options.protocol === "https:" ? https : http; + + _http.get(options, res => { + let chunks = []; + let bytes = 0; + res.on("data", chunk => { + let i; + while ((i = chunk.indexOf(newLineAscii)) !== -1) { + let buf; + if (chunk[chunk.length - 2] === commaAscii) { + i -= 1; + buf = new Buffer(bytes + i); + chunk.copy(buf, buf.length - i, 0, i); + chunk = chunk.slice(i + 2); + } else { + buf = new Buffer(bytes + i); + chunk.copy(buf, buf.length - i, 0, i); + chunk = chunk.slice(i + 1); + } + + let o = 0; + for (let c of chunks) { + c.copy(buf, o, 0, c.length); + o += c.length; + } + + if (buf.length > 1) { + let obj = JSON.parse(buf); + if (ret) ret.push(obj); + else callback(obj); + } + + bytes = 0; + chunks = []; + } + bytes += chunk.length; + chunks.push(chunk); + }); + + res.on("end", () => { + let buf = new Buffer(bytes); + let o = 0; + for (let c of chunks) { + c.copy(buf, o, 0, c.length); + o += c.length; + } + if (buf.length > 1) { + let obj = JSON.parse(buf); + if (ret) ret.push(obj); + else callback(obj); + } + resolve(ret); + }); + + res.on("aborted", () => { + res.removeAllListeners("end"); + reject(new Error("Timeout")); + }); + }); + }); +} + +exports.query = query; diff --git a/server/config.js b/server/config.js index 41d7767..3a7809a 100644 --- a/server/config.js +++ b/server/config.js @@ -1,3 +1,4 @@ "use strict"; exports.PORT = 3000; +exports.GENIEACS_NBI = "http://127.0.0.1:7557/"; From f33717acb049f240880fbdb9f616295f8d810e25 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sat, 2 Dec 2017 19:27:24 -0800 Subject: [PATCH 003/106] Implement GET and HEAD APIs --- server/.eslintrc.json | 3 +++ server/api.js | 62 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/server/.eslintrc.json b/server/.eslintrc.json index 10d2238..1b750a1 100644 --- a/server/.eslintrc.json +++ b/server/.eslintrc.json @@ -1,5 +1,8 @@ { "env": { "node": true + }, + "parserOptions": { + "ecmaVersion": 8 } } diff --git a/server/api.js b/server/api.js index c0c9598..cd09895 100644 --- a/server/api.js +++ b/server/api.js @@ -2,6 +2,68 @@ const Router = require("koa-router"); +const apiFunctions = require("./api-functions"); + const router = new Router(); +const resources = { + devices: false, + presets: true, + provisions: true, + files: true, + virtual_parameters: true +}; + +for (let [resource, update] of Object.entries(resources)) { + router.head(`/${resource}`, async ctx => { + let filters, limit; + if (ctx.request.query.query) + if (Array.isArray(ctx.request.query.query)) + filters = ctx.request.query.query.map(q => JSON.parse(q)); + else { + filters = JSON.parse(ctx.request.query.query); + if (!Array.isArray(filters)) filters = [filters]; + } + + if (ctx.request.query.limit) limit = +ctx.request.query.limit; + + let res = await apiFunctions.query(resource, filters, limit); + ctx.set("X-Total-Count", res.length); + ctx.body = ""; + }); + + router.get(`/${resource}`, async ctx => { + let filters, limit; + if (ctx.request.query.query) + if (Array.isArray(ctx.request.query.query)) + filters = ctx.request.query.query.map(q => JSON.parse(q)); + else { + filters = JSON.parse(ctx.request.query.query); + if (!Array.isArray(filters)) filters = [filters]; + } + + if (ctx.request.query.limit) limit = +ctx.request.query.limit; + + ctx.body = await apiFunctions.query(resource, filters, limit); + }); + + router.head(`/${resource}/:id`, async (ctx, next) => { + let filter = { _id: ctx.params.id }; + let res = await apiFunctions.query(resource, filter); + if (!res.length) return next(); + ctx.body = ""; + }); + + router.get(`/${resource}/:id`, async (ctx, next) => { + let filter = { _id: ctx.params.id }; + let res = await apiFunctions.query(resource, filter); + if (!res.length) return next(); + ctx.body = res[0]; + }); + + if (update) { + // TODO add PUT, PATCH, DELETE routes + } +} + module.exports = router; From 1c9061ab5a00497b26e03ba4de7f582b198cabec Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Fri, 15 Dec 2017 22:46:27 -0800 Subject: [PATCH 004/106] Implement layout and device list page --- .eslintrc.json | 4 +- client/.eslintrc.json | 7 +- client/app.js | 42 +++- client/config.js | 24 +++ client/css/app.css | 202 ++++++++++++++++++ client/css/normalize.css | 450 +++++++++++++++++++++++++++++++++++++++ client/devices-page.js | 115 ++++++++++ client/error-page.js | 10 + client/layout.js | 19 ++ client/menu.js | 27 +++ client/overview-page.js | 22 ++ package.json | 11 +- postcss.config.js | 7 + public/favicon.png | Bin 0 -> 6130 bytes public/index.html | 3 + public/logo.svg | 1 + server/api-functions.js | 55 ++++- server/api.js | 30 +-- server/device.js | 152 +++++++++++++ 19 files changed, 1151 insertions(+), 30 deletions(-) create mode 100644 client/config.js create mode 100644 client/css/app.css create mode 100644 client/css/normalize.css create mode 100644 client/devices-page.js create mode 100644 client/error-page.js create mode 100644 client/layout.js create mode 100644 client/menu.js create mode 100644 client/overview-page.js create mode 100644 postcss.config.js create mode 100644 public/favicon.png create mode 100644 public/logo.svg create mode 100644 server/device.js diff --git a/.eslintrc.json b/.eslintrc.json index 7a3bed9..9d8ed29 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -6,12 +6,10 @@ "parserOptions": { "sourceType": "script" }, - "plugins": ["prettier"], "rules": { "no-shadow": ["error", { "allow": ["err"] }], "handle-callback-err": "error", "prefer-arrow-callback": "error", - "curly": ["error", "multi", "consistent"], - "prettier/prettier": "error" + "curly": ["error", "multi", "consistent"] } } diff --git a/client/.eslintrc.json b/client/.eslintrc.json index dcd577e..6881637 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -1,6 +1,9 @@ { "env": { - "browser": true, - "commonjs": true + "browser": true + }, + "parserOptions": { + "sourceType": "module", + "ecmaVersion": 8 } } diff --git a/client/app.js b/client/app.js index 5f20515..2781f38 100644 --- a/client/app.js +++ b/client/app.js @@ -1,5 +1,43 @@ "use strict"; -const m = require("mithril"); +import m from "mithril"; -m.render(document.body, "hello world"); +import layout from "./layout"; +import * as overviewPage from "./overview-page"; +import * as devicesPage from "./devices-page"; +import * as errorPage from "./error-page"; + +let state; + +function pagify(pageName, page) { + const component = { + render: () => { + if (state && state.error) return m(errorPage.component, state); + else return m(layout, { page: pageName }, m(page.component, state)); + } + }; + + if (page.init) + component.onmatch = args => { + return new Promise(resolve => { + page + .init(args) + .then(st => { + if (!st) return m.reoute.set("/"); + state = st; + resolve(); + }) + .catch(err => { + state = { error: err.message }; + resolve(); + }); + }); + }; + + return component; +} + +m.route(document.body, "/overview", { + "/overview": pagify("overview", overviewPage), + "/devices": pagify("devices", devicesPage) +}); diff --git a/client/config.js b/client/config.js new file mode 100644 index 0000000..20f5748 --- /dev/null +++ b/client/config.js @@ -0,0 +1,24 @@ +"use strict"; + +export default { + pageSize: 10, + index: [ + { + label: "Serial number", + parameter: "DeviceID.SerialNumber" + }, + { + label: "Product class", + parameter: "DeviceID.ProductClass" + }, + { + label: "Software version", + parameter: "InternetGatewayDevice.DeviceInfo.SoftwareVersion" + }, + { + label: "MAC", + parameter: + "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress" + } + ] +}; diff --git a/client/css/app.css b/client/css/app.css new file mode 100644 index 0000000..5454683 --- /dev/null +++ b/client/css/app.css @@ -0,0 +1,202 @@ +@import "normalize.css"; + +:root { + --base-font-size: 13px; + --base-line-height: 16px; + + --color1: #ebe0bf; + --color2: #b9b098; + --color3: #f2ebd5; + --color4: #046380; + --color5: #002f2f; + + --fade: 0.2s; +} + +body, +input, +button, +select, +option, +textarea { + font-family: sans-serif; + color: var(--color5); +} + +a { + color: var(--color4); + cursor: pointer; + transition: background-color var(--fade), color var(--fade); + &:visited { + color: var(--color4); + } + &:hover { + color: white; + background-color: var(--color4); + } +} + +strong, +b { + font-weight: bold; +} + +i { + font-style: italic; +} + +#header { + background-color: #f2eddf; + border-bottom: 1px solid var(--color1); + overflow: visible; + position: relative; + + & > nav { + display: inline-block; + overflow: visible; + position: absolute; + bottom: -1px; + + & > ul { + font-weight: bold; + display: inline-block; + margin: 0 10px; + padding: 0; + + & > li { + margin: 0 2px; + background-color: var(--color1); + display: inline-block; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + transition: background-color var(--fade); + &.active, + &:hover { + background-color: white; + } + + & > a, + & > a:visited, + & > a:hover { + display: inline-block; + min-width: 140px; + color: var(--color4); + text-decoration: none; + background: none; + padding: 8px; + } + } + } + } + & > .logo { + margin: 10px; + display: inline; + height: 56px; + } +} + +#content { + margin: 10px; + min-height: 250px; +} + +table.table { + border-spacing: 0; + text-align: left; + + & th { + color: var(--color4); + font-weight: bold; + border-bottom: 2px solid var(--color4); + margin: 0; + padding: 0.2em 0.5em; + } + + & td { + margin: 0; + padding: 0.2em 0.5em; + border-bottom: 1px dotted var(--color1); + & .action a { + visibility: hidden; + } + } + + & tbody tr:hover td { + & .action { + visibility: visible; + } + + transition: background-color var(--fade); + background-color: var(--color3); + } + + & tfoot td > a { + margin-left: 1em; + } +} + +h1 { + color: var(--color4); + font-size: 130%; + font-weight: bold; + margin-top: 1em; + margin-bottom: 0.5em; +} + +h2 { + color: var(--color5); + font-weight: bold; + margin-top: 1em; + margin-bottom: 0.5em; +} + +h3 { + color: var(--color5); + margin-top: 1em; + margin-bottom: 0.5em; +} + +/* Remove button padding in FF */ +input::-moz-focus-inner { + border: 0; + padding: 0; +} + +input[type="button"], +button { + display: inline-block; + min-width: 6em; + position: relative; + top: 0px; + vertical-align: baseline; + margin: 0 0.1em; + outline: none; + cursor: pointer; + text-align: center; + text-decoration: none; + padding: 0.1em 0.6em; + border-top-left-radius: 1em; + border-bottom-right-radius: 1em; + border-top-right-radius: 0.2em; + border-bottom-left-radius: 0.2em; + + color: var(--color4); + transition: background-color var(--fade), color var(--fade); + + &:link, + &:visited { + color: var(--color4); + background-color: var(--color1); + } + + &:hover, + &:active { + color: var(--color1); + background-color: var(--color4); + } + + &:disabled { + background-color: var(--color1); + color: var(--color2); + } +} diff --git a/client/css/normalize.css b/client/css/normalize.css new file mode 100644 index 0000000..04beed5 --- /dev/null +++ b/client/css/normalize.css @@ -0,0 +1,450 @@ +/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in + * IE on Windows Phone and in iOS. + */ + +html { + line-height: 1.15; /* 1 */ + -ms-text-size-adjust: 100%; /* 2 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers (opinionated). + */ + +body { + margin: 0; +} + +/** + * Add the correct display in IE 9-. + */ + +article, +aside, +footer, +header, +nav, +section { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * Add the correct display in IE 9-. + * 1. Add the correct display in IE. + */ + +figcaption, +figure, +main { + /* 1 */ + display: block; +} + +/** + * Add the correct margin in IE 8. + */ + +figure { + margin: 1em 40px; +} + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * 1. Remove the gray background on active links in IE 10. + * 2. Remove gaps in links underline in iOS 8+ and Safari 8+. + */ + +a { + background-color: transparent; /* 1 */ + -webkit-text-decoration-skip: objects; /* 2 */ +} + +/** + * 1. Remove the bottom border in Chrome 57- and Firefox 39-. + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} + +/** + * Prevent the duplicate application of `bolder` by the next rule in Safari 6. + */ + +b, +strong { + font-weight: inherit; +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** + * Add the correct font style in Android 4.3-. + */ + +dfn { + font-style: italic; +} + +/** + * Add the correct background and color in IE 9-. + */ + +mark { + background-color: #ff0; + color: #000; +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Add the correct display in IE 9-. + */ + +audio, +video { + display: inline-block; +} + +/** + * Add the correct display in iOS 4-7. + */ + +audio:not([controls]) { + display: none; + height: 0; +} + +/** + * Remove the border on images inside links in IE 10-. + */ + +img { + border-style: none; +} + +/** + * Hide the overflow in IE. + */ + +svg:not(:root) { + overflow: hidden; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers (opinionated). + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: sans-serif; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { + /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { + /* 1 */ + text-transform: none; +} + +/** + * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video` + * controls in Android 4. + * 2. Correct the inability to style clickable types in iOS and Safari. + */ + +button, +html [type="button"], +/* 1 */ [type="reset"], +[type="submit"] { + -webkit-appearance: button; /* 2 */ +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * 1. Add the correct display in IE 9-. + * 2. Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + display: inline-block; /* 1 */ + vertical-align: baseline; /* 2 */ +} + +/** + * Remove the default vertical scrollbar in IE. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10-. + * 2. Remove the padding in IE 10-. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding and cancel buttons in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-cancel-button, +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in IE 9-. + * 1. Add the correct display in Edge, IE, and Firefox. + */ + +details, +/* 1 */ menu { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Scripting + ========================================================================== */ + +/** + * Add the correct display in IE 9-. + */ + +canvas { + display: inline-block; +} + +/** + * Add the correct display in IE. + */ + +template { + display: none; +} + +/* Hidden + ========================================================================== */ + +/** + * Add the correct display in IE 10-. + */ + +[hidden] { + display: none; +} diff --git a/client/devices-page.js b/client/devices-page.js new file mode 100644 index 0000000..ad6223d --- /dev/null +++ b/client/devices-page.js @@ -0,0 +1,115 @@ +"use strict"; + +import m from "mithril"; + +import config from "./config"; + +function load(filter, limit) { + return new Promise((resolve, reject) => { + m + .request({ + url: "/api/devices/", + data: { filter: JSON.stringify(filter), limit: limit } + }) + .then(res => { + resolve(res); + }) + .catch(err => { + err.message = err.message || "Unknown error"; + reject(err); + }); + }); +} + +function count(filter, limit) { + return new Promise((resolve, reject) => { + function extract(xhr) { + return +xhr.getResponseHeader("x-total-count"); + } + m + .request({ + method: "HEAD", + url: "/api/devices/", + data: { filter: JSON.stringify(filter), limit: limit }, + extract: extract + }) + .then(res => { + resolve(res); + }) + .catch(err => { + err.message = err.message || "Unknown error"; + reject(err); + }); + }); +} + +function init(args) { + return new Promise((resolve, reject) => { + Promise.all([count(args.filter), load(args.filter, config.pageSize)]) + .then(res => { + resolve({ filter: args.filter, total: res[0], devices: res[1] }); + }) + .catch(reject); + }); +} + +function renderTable(devices, parameters, total, showMoreCallback) { + let labels = []; + for (let param of parameters) labels.push(m("th", param.label)); + + let rows = []; + for (let device of devices) + rows.push( + m( + "tr", + parameters.map(p => { + if (!device[p.parameter] || !device[p.parameter].value) + return m("td.na"); + if (!device[p.parameter].value[0] === "") return m("td.blank"); + return m("td", device[p.parameter].value[0]); + }) + ) + ); + + let footerElements = [`${devices.length}/${total}`]; + + if (devices.length < total) + footerElements.push(m("a", { onclick: showMoreCallback }, "Show more")); + + let tfoot = m( + "tfoot", + m("tr", m("td", { colspan: labels.length }, footerElements)) + ); + + return m("table.table", [ + m("thead", m("tr", labels)), + m("tbody", rows), + tfoot + ]); +} + +const component = { + view: vnode => { + document.title = "Devices - GenieACS"; + function showMore() { + const lastDevice = vnode.attrs.devices[vnode.attrs.devices.length - 1]; + let f = Object.assign({}, vnode.attrs.filter, { + "DeviceID.ID>": lastDevice["DeviceID.ID"].value[0] + }); + load(f, config.pageSize).then(res => { + vnode.attrs.devices = vnode.attrs.devices.concat(res); + }); + } + return [ + m("h1", "Listing devices"), + renderTable( + vnode.attrs.devices, + config.index, + vnode.attrs.total, + showMore + ) + ]; + } +}; + +export { init, component }; diff --git a/client/error-page.js b/client/error-page.js new file mode 100644 index 0000000..4398d87 --- /dev/null +++ b/client/error-page.js @@ -0,0 +1,10 @@ +"use strict"; + +const component = { + view: function(vnode) { + document.title = "Error! - GenieACS"; + return vnode.attrs.error; + } +}; + +export { component }; diff --git a/client/layout.js b/client/layout.js new file mode 100644 index 0000000..bacab47 --- /dev/null +++ b/client/layout.js @@ -0,0 +1,19 @@ +"use strict"; + +import m from "mithril"; + +import menu from "./menu"; + +const layout = { + view: vnode => { + return [ + m("#header", [ + m("img.logo", { src: "logo.svg" }), + m(menu, { page: vnode.attrs.page }) + ]), + m("#content", [vnode.children]) + ]; + } +}; + +export default layout; diff --git a/client/menu.js b/client/menu.js new file mode 100644 index 0000000..d7c32bb --- /dev/null +++ b/client/menu.js @@ -0,0 +1,27 @@ +"use strict"; + +import m from "mithril"; + +const menu = { + view: vnode => { + const active = { [vnode.attrs.page]: "active" }; + + return m( + "nav", + m("ul", [ + m( + "li", + { class: active["overview"] }, + m("a", { href: "#!/overview" }, "Overview") + ), + m( + "li", + { class: active["devices"] }, + m("a", { href: "#!/devices" }, "Devices") + ) + ]) + ); + } +}; + +export default menu; diff --git a/client/overview-page.js b/client/overview-page.js new file mode 100644 index 0000000..35c091f --- /dev/null +++ b/client/overview-page.js @@ -0,0 +1,22 @@ +"use strict"; + +import m from "mithril"; + +const init = function() { + return new Promise(resolve => { + resolve({ message: "Hello world!" }); + }); +}; + +const component = { + view: vnode => { + document.title = "Overview - GenieACS"; + return m( + "a", + { href: "#!/devices/202BC1-BM632w-000000" }, + vnode.attrs.message + ); + } +}; + +export { init, component }; diff --git a/package.json b/package.json index 75fa1d6..e7cc313 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "description": "", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "lint": "eslint --fix server/*.js client/*.js", - "build": "webpack", - "start": "npm run lint;npm run build;node server/server" + "lint": "eslint --fix server/*.js client/*.js;prettier --write server/*.js client/*.js client/css/*.css", + "build": "npm run lint;webpack;postcss -c postcss.config.js client/css/app.css -o public/app.css", + "start": "node server/server" }, "author": "Zaid Abdulla ", "dependencies": { @@ -17,8 +17,11 @@ "mithril": "^1.1.5" }, "devDependencies": { + "cssnano": "^3.10.0", "eslint": "^4.12.1", - "eslint-plugin-prettier": "^2.3.1", + "postcss-cli": "^4.1.1", + "postcss-cssnext": "^3.0.2", + "postcss-import": "^11.0.0", "prettier": "^1.8.2", "webpack": "^3.9.1" } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..91a9f0d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,7 @@ +module.exports = { + plugins: [ + require("postcss-import"), + require("postcss-cssnext")({warnForDuplicates: false}), + require("cssnano") + ] +} diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..1f1657038feeb3328f2d83d0d15b7775b959a4c4 GIT binary patch literal 6130 zcmVKK~#90?Ol0TmB+dNeaktVePQ1h0atJ(8nJ3N_r#jTG}or- znxtuZvq$5CA%ezO6O$%QO>f%d-lnmA+SqENh$aRFcSQk(2*}Q1KX4Y#{(ay513}>I z3y7w_^UU+WH#6_dyzeaU%)B#0V2tAIy6p=X*77~hWSz$KGAS0lIfm^uOIZs;vsQ-U z%nZpI83F)EA;l3v6if0bhU8N=S{$UMWc+4I#IGU*bSZd5;hkGI72WJIIRbIBGXMbD z$R_W54ofM9`H`v1IM>{)iM46WR3Ou!WCB(cCvWCOh|dHCi~l}z;@K~VNB_ds6{XD@Fw)25X|Ml>$?a!|D7tvOpa?HSFq2V>L)aDKzCD3hjZ zx1n0S*xaHMjCT5tAc&P2#p8nW#G$fhre(j{KALHdngGsZZyRq>8UCOvQ!QY6tz)9= z8xBf9bCUGPlcFf)k27-DUK@=xN1PGIa&zNQ)4Nw&)|$bYZ6lprKtKs;PBJy>j8GBt z;OyT#+d7hIji>-ly_%iVeYSbMt=kyT0d=%M%LJ_Aj+l=#cfIlSNTo3%0ywp9>$2Xf zZGY*lQ7cD+{%Av-64E7}8v5AW?5wW>O*`NMIPv1^AE}SmJ;hpXx`r1}kUR>(^Ai3% z@BJ-L1d?vR1W@?qn@U@S`lRM!%fvv@9E*@mji^yf3AuCTh7BEopcfDUoLTqA3iXAS zFU)Papg_kvdrP0BmsR?XSAG2dTWehc9A+MJ(u7S{aHrnihq)pDdjiFsVe!sKT% z-^%*PA3A<9?fCOq8+wZBa~TWsonn4JNIr$IyOTF(?tDGRR~kMu?)bACcd1X*zrZn^ zuOw~@5G09^c?loQ|1kHLKE?MXQsJ*Re$;Wi?kRBJZRQVvAP}}N>38$r&;7NxQAuwj z6up$at+TNHKg=Htjzec*-LplnypiQ?RIggViPzqExh=nHCud{5P3M*YK@#9t-zpas zL~~*Hr2c%~j;v2SPS4YTCpNvgxZ}vThsNqcS|AX&W;%rN3RLYp4qq!7E#Y%v_oOb% z^iW0hh?%F?X2*7%Z@Ok~@8$cHt6Ko^Yi1zkk#qn+_t{2V-FeJsAqb;nridj`aT%{< zce)Gfb`0RMvq^13&)M6V`EftI!F&J^#>qiTe4kSqTeO0nOS%)Dgmo)`W9^o|X)d=# zaR6>d7x(xK#6En3`NkG4E@vHvrN`(O-R?`O@deLi{f|5Tx)eas=IzV6PB%Q_j>xTw z_{V1;_MxH7zmjzr7PTQD^t(>f{i1MF&V8=J(9S%*$)PetvEn1X0V7@#(3KL#lh>$xIAWrgaYeQGv8FKukthb%{JJf=Twr`Jb-~Vl` zRcrEkqBho%xMl`o9~s8{EPLjQ6O@>my9@wOzU^yhD%yNbla>qEsJl|)Gq-MPw)1lo z!BnZho0^hs+m5%aE467>wD9D_f4Bx4wJ8j~^_8wa4hyWX;xN zBn@razXQk|?q|p3lUuf=t1mXsy4l)Epk2ZbXAWb&7NuGFFslti!tvw+OxbuJ7^4*> z8}p#A?;h}@Wdfwpx*{vw=6^@tKGl-0i5eD9t)# zSMzBR4^uWThRtZjrF92jYSj(#OM87WgwZlsG$!=1)pIaLD`9Qa|8e;DA!ERa9XZKO zfBUwYH8~dm$)_NSkwX|QgD_SOQH&fUp9W`RU{q<*b*>5eYaQUm$Z}EA6SELEtlKGZ z(Cv_X3hA2{gR$69v7-Qt$vWUi@F+~px)1WSNYwr1A{xFZ^(ikrNe~ycEIo4GAmgtg zV}Pl_xRV`}up~%PRD3MP1)T%c-of`lEYh%$}qJqA*3t^dwAV_;fiYFBy{Ci0Nk3>&t zi`$Hi1d<+~h4_c34*5n2c+l6XhYYG92&Dch6IP80mAg;cF*5b(xd_WlLdDJkv=`P4 zeCI2Kn3}T`A+us(Y|%hJn5GMGRxa_c*Dh=w$W#9xi#Bb0y6b%XDxhzdM;xbsBrzB~ zX%K9BGluVc8C6=C+H?q+oe08_GD{FpOpXL&us~ZG5CZ~_;fVQtSt1ZRCmzOD-GC61 z*35*p$B4Smi|xWE{$K_o7mY_*?h$lfQVo2kMLbM>eF?pf>PV$sTz4?E^{xZ(Z(z_MTyf~LX4$xqXjc(A zhC}1O%250Hg@G0!2$Nv;?;ZxpcW!;CI(!wCA9}Pn=oiL4k%9P!>}$Rwa14j01Le5( zr!#hbVVQ|YS~Cl!Tk>IQ*AILrC<2pTz8fJKaVUTL8+4v&7!r@5NyM(0f~GI83>im; zLl7phJ#=?M5RskDQUK6Yd0@IgDSp*mrU{aOd~yUt@k(@@sRze$uo*0nCWSy8r*Ili z6stgUUIkd|$XUxJd%=95uPm~ZFc{l(L;7z;dK3~?Pe;iceRG7t82?lzLg&PzH1{yn z7ga;)(InDdy&JLjPlhB>iT2a=P6uY2!9r?N!q0zFR8UC*00F|~irsL8&rQVS=NF*A z^H-OtT!yEDXeg)0c$0)zNdEC1UNOHP1Vv!da|;l$Fc|<&#j7oe!(VG$d@& zV>1wXXFQ5G97NBRq018e%nzND000P`lYqq4(_N-*(wWx)17iSTH$`@b@cHAQE>%Hw zq!QXHw;MlDPKiYOiCS-yaf_6n%trjGG@s-53n3^(48Br`D{mLr?S#cFn~3lQ$++;^ z0lTq`ph-*_#C!ngE^9&k-cpxSHFJkP6X+KJhq-PyCpiwfS~WBkZRn|Jg{Gnn=B~ab zYSWwCLhtx!FbTO^A*B?vOo1F zvk||_xwhjACjNQ>Se8TUvA)AG%Je8i-II#q&3Qv+#y~&w@3CWki3iLF0F&BCo!pq6 zNs}y@#1TVT!`WCg9xO-0zOsQ@?g~7q&}|rRH#1y8mJ%9p<^$KYoaYt-0D7A=h?t*@ z((PZ{bx8zGBHa(>_rq*v=hGk%H~6Ezp&OUqE`YvX?J=RCP?6g(jL{ab77)E;QiQQpi*G+Vi7C%52nan_ASeP;o?i%p zCJ{a-A)w3$0Bf&BMuR|nTTu-()$Vn9M-WEJz?ZwPmD<{_fQk*nBU1bt(V zUkvaC-B(&soSg@o$>Mhz7>k7_EgF-gyRyw^G9lAr-DX->+Xa^4{K_s*E=1Z3cYzY} zzyYwCZ76x`h+kcRw@_bh!G$;SU^CkSQjKh88%b)a+r_?~av?|{be4Mo^nB~PNk>0o z7fnF)-Km)S&V!I9g#ZBbjXfyeJvz70s4usmc*~)H3Y;<92vXnJL-{H}X-XLQa`%(4 z&Pz>x)fA9G%;G-XUlc9Jojbmd`1{kq0cbv2h32olUz7I+-4(4U&dCp0%^&Q@-ewKw zt4z32eg1j`R*d<8a&$)^cLze z70z$YgUx91s|*h3G<=F9t$GvdtMo!M;vD692)Zt}fH5lt zT8>L^eFetsn;Pv5>M|A1Z^?(vYz=tL=Kv5vG)r3b7M~Vrf`Y}6C%d13wHEnz>x(c< zg0Pv+Tbejpj?CYzLg@4uTz>R`}8JR8Ya8LKbS3!jq!$Gh=2 zz}Z-|p04w&mV{=+gP`owF?HV9D(vf$G7&aA9<2e`b5NJ6aPIXySWP2S^9Nc0pR|z_ zpECFoJ$!b8+k$iyH^Qp#w<7b+H6AWUb3wHoZN|c&YR`osd#}+8QlOX+?w3)Ry0jVR za`LX%{E=io2cDR(B?+3+cpH@x@DQ5ra{nFx(0shw?`*ORhm!Y>+6h4%Er%#thEwbJ z!`N|SnL`jNc4#o>3A##EIGb~5M6?}8jwcl~leCCe=WRwqr^mXqf&lssC$ycZ_m;Fn za885}voICXgkTiCuovy;{7b8=RCT@PkKV+NC+1g?e1+(Ow~`A^i*if6{X%2FvTd0oW zMz3f(bCOs&=DoENB##1TV{z%-uhCp^!;p#!qA+XSBG5q;YW7~j$@TkT?9jW&*$r?u z7EMPhadyi=pVssJb;2OJkoxrKR}E#K?a#8AJUf6*1yM+Q>U$1tLI3O8HgFu!dbSRtNI7JQeM=I2sQ|(V8Cp)(z}Trr)6q)E;*}7` zIJQ+XwCix~A0@cB>nNHED#7~DYW#&DSj4RQ{DUV+f*?3aoYLdFG8HM|ASlK@za@}-8h5<57_{()VX?ZR6%~IzGp3ji0JK!F2e>YPMLTl$3d1Be zPeyK0l-w#|xJ1=HUKE(C#n6HFoJdpfvOcn!xP!OAtQ8 z^$IJ=rx9~ks$G1RVXx0S#{N$hE!#0rp8h}Xe|Ou*;z+p#ZrTswZUK&A!I*4bwMmLk zVb1!ch@741R=DPF1M2e1Z+<`Gqm#xejQ4Kc^6&og3qr3l}9bCpNT2Y^c-9*0G5zS(^A*~yc`^9Gk^h(b@uMK9AqJkAvZjGb;~nFQgH znF;_X$46k{+YduI-o0uI0M+|Tp*mILKHwMyUn*b)(b8uIe;Xozl{<55LubTXaMgbq zJecN7;*~BaZ~)06_2C&<_?uM_g?n}%NJnWiE`NN&CEc<93!5JO&64cwAv(I$m2wb(kF+1 zw-dmkoSd?->2c?s^W@2Y0`{+T$(<0Mu3OD9qXii{7n% zFs5DT%(zoavT|#Om(Ss%jx3!25ML^ESlxPRJ795hB1SPT6v~tv_M*}l1s47Ohe%vJ z$>H{*Gqj$qL(#^4uo%Fm?ALa(1FyH5LFk7O4E97#H6E8q96*4nh1~DZ7?!+x$;Q zeoi~#r&lcg(x@@NXznrET_31#)*yL#y2~YLNsI#81~s(T4|?=m>qJLsGeXm2z;|*Z zA;)lNK6MRecjTj?pt7&+-r@+E6xF%<|2|mc%-^X1{A?}AP>`sU;}3lSL!S0DM)d&}H~b8Ems09?q7xVe&Og`FzG+E`rN zeGEsQ+k@Kta+sX#FxgC2#LZ82xWs%rLKdqu1y6|Z=I{cfC6z zX%Z<*(>_}H?pr=R;^Xt}xGUdz>&4`yX|M8x{M%r@>Kmva9#WU2W%|lOInil%auE}{=pwQ0eKJR;D*i18ctUJQ`gkx}>Q&rOy0`?%!mv6qN)RpUxH=e5Y{|2QojHv1HHHx(8IS*vJe;>vZ1pGy$z0ba~ zySCu!(<6Dd>Sllz@G$PaNguCzcZc1smOmJgC#m+W-LR;uqIpk8MXPfQt6K=6lcGAp zr^P<{KyGe9plOfjn_yo&|8kD%blrMGr~a0F(p(rUVq#_|{_)}8?Ygy|G#`w^+1b%u zwVhw6F4WDj8gGq9;P@gw7d12fL~v5r!^5o@0*z5)$iFvkj4`SWzgL~DTVd)kj7gO> zNGf0>rpFgbV&y+wnQOm+J`x!98o6)l)(BI5`|hsFwk4WIwfl=EMgfY1U_)?fG8QY3wKAm5WTC9azNgfALW-jWe1_yv zEXC(pgQNnjK*n$8iG`Is0ly?jOdnsqb?fPyT_#8Pf9<7s_+OfMrT_o{07*qoM6N<$ Ef_vf5EdT%j literal 0 HcmV?d00001 diff --git a/public/index.html b/public/index.html index 772daa5..cc4fe7c 100644 --- a/public/index.html +++ b/public/index.html @@ -1,5 +1,8 @@ + GenieACS + + diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..2352be8 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/api-functions.js b/server/api-functions.js index 706a63b..131dfe1 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -6,6 +6,7 @@ const url = require("url"); const querystring = require("querystring"); const config = require("./config"); +const device = require("./device"); const commaAscii = ",".charCodeAt(0); const newLineAscii = "\n".charCodeAt(0); @@ -19,6 +20,7 @@ function filtersToQuery(filters) { for (let [k, v] of Object.entries(filter)) { let [param, op] = k.split(/([^a-zA-Z0-9\-_.].*)/, 2); if (!op || op === "=") q[param] = v; + else if (op === ">") q[param] = { $gt: v }; else throw new Error(`Operator "${op}" not recognized`); } queries.push(q); @@ -28,22 +30,63 @@ function filtersToQuery(filters) { else return { $or: queries }; } +function count(resource, filters, limit) { + return new Promise((resolve, reject) => { + let qs = {}; + if (filters) { + let q = filtersToQuery(filters); + if (resource === "devices") q = device.transposeQuery(q); + qs.query = JSON.stringify(q); + } + + if (limit) qs.limit = limit; + + let options = url.parse( + `${config.GENIEACS_NBI}${resource}?${querystring.stringify(qs)}` + ); + + options.method = "HEAD"; + + let _http = options.protocol === "https:" ? https : http; + + _http + .request(options, res => { + if (res.statusCode !== 200) { + reject(new Error(`Unexpected status code ${res.statusCode}`)); + res.resume(); + return; + } + resolve(+res.headers["total"]); + }) + .end(); + }); +} + function query(resource, filters, limit, callback) { return new Promise((resolve, reject) => { let ret; if (!callback) ret = []; - let q = {}; - if (filters) q.query = JSON.stringify(filtersToQuery(filters)); + let qs = {}; + if (filters) { + let q = filtersToQuery(filters); + if (resource === "devices") q = device.transposeQuery(q); + qs.query = JSON.stringify(q); + } - if (limit) q.limit = limit; + if (limit) qs.limit = limit; let options = url.parse( - `${config.GENIEACS_NBI}${resource}?${querystring.stringify(q)}` + `${config.GENIEACS_NBI}${resource}?${querystring.stringify(qs)}` ); let _http = options.protocol === "https:" ? https : http; _http.get(options, res => { + if (res.statusCode !== 200) { + reject(new Error(`Unexpected status code ${res.statusCode}`)); + res.resume(); + return; + } let chunks = []; let bytes = 0; res.on("data", chunk => { @@ -69,6 +112,7 @@ function query(resource, filters, limit, callback) { if (buf.length > 1) { let obj = JSON.parse(buf); + if (resource === "devices") obj = device.transpose(obj); if (ret) ret.push(obj); else callback(obj); } @@ -89,6 +133,8 @@ function query(resource, filters, limit, callback) { } if (buf.length > 1) { let obj = JSON.parse(buf); + if (resource === "devices") obj = device.transpose(obj); + if (ret) ret.push(obj); else callback(obj); } @@ -104,3 +150,4 @@ function query(resource, filters, limit, callback) { } exports.query = query; +exports.count = count; diff --git a/server/api.js b/server/api.js index cd09895..d7e3381 100644 --- a/server/api.js +++ b/server/api.js @@ -16,35 +16,35 @@ const resources = { for (let [resource, update] of Object.entries(resources)) { router.head(`/${resource}`, async ctx => { - let filters, limit; - if (ctx.request.query.query) - if (Array.isArray(ctx.request.query.query)) - filters = ctx.request.query.query.map(q => JSON.parse(q)); + let filter, limit; + if (ctx.request.query.filter) + if (Array.isArray(ctx.request.query.filter)) + filter = ctx.request.query.query.map(q => JSON.parse(q)); else { - filters = JSON.parse(ctx.request.query.query); - if (!Array.isArray(filters)) filters = [filters]; + filter = JSON.parse(ctx.request.query.filter); + if (!Array.isArray(filter)) filter = [filter]; } if (ctx.request.query.limit) limit = +ctx.request.query.limit; - let res = await apiFunctions.query(resource, filters, limit); - ctx.set("X-Total-Count", res.length); + let count = await apiFunctions.count(resource, filter, limit); + ctx.set("X-Total-Count", count); ctx.body = ""; }); router.get(`/${resource}`, async ctx => { - let filters, limit; - if (ctx.request.query.query) - if (Array.isArray(ctx.request.query.query)) - filters = ctx.request.query.query.map(q => JSON.parse(q)); + let filter, limit; + if (ctx.request.query.filter) + if (Array.isArray(ctx.request.query.filter)) + filter = ctx.request.query.query.map(q => JSON.parse(q)); else { - filters = JSON.parse(ctx.request.query.query); - if (!Array.isArray(filters)) filters = [filters]; + filter = JSON.parse(ctx.request.query.filter); + if (!Array.isArray(filter)) filter = [filter]; } if (ctx.request.query.limit) limit = +ctx.request.query.limit; - ctx.body = await apiFunctions.query(resource, filters, limit); + ctx.body = await apiFunctions.query(resource, filter, limit); }); router.head(`/${resource}/:id`, async (ctx, next) => { diff --git a/server/device.js b/server/device.js new file mode 100644 index 0000000..aefea8f --- /dev/null +++ b/server/device.js @@ -0,0 +1,152 @@ +"use strict"; + +function recursive(input, root, output, timestamp) { + for (let [name, tree] of Object.entries(input)) { + if (root.length === 0) + if (name === "_lastInform") { + output["Events.Inform"] = { + value: [timestamp, "xsd:dateTime"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + } else if (name === "_registered") { + output["Events.Registered"] = { + value: [timestamp, "xsd:dateTime"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + } else if (name === "_lastBoot") { + output["Events.1_BOOT"] = { + value: [timestamp, "xsd:dateTime"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + } else if (name === "_lastBootstrap") { + output["Events.0_BOOTSTRAP"] = { + value: [timestamp, "xsd:dateTime"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + } else if (name === "_id") { + output["DeviceID.ID"] = { + value: [tree, "xsd:string"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + } else if (name === "_deviceId") { + output["DeviceID.Manufacturer"] = { + value: [tree["_Manufacturer"], "xsd:string"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + output["DeviceID.OUI"] = { + value: [tree["_OUI"], "xsd:string"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + output["DeviceID.ProductClass"] = { + value: [tree["_ProductClass"], "xsd:string"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + output["DeviceID.SerialNumber"] = { + value: [tree["_SerialNumber"], "xsd:string"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + } else if (name === "_tags") { + output["Tags"] = { + writable: true, + writableTimestamp: timestamp, + object: true, + objectTimestamp: timestamp + }; + + for (let t of tree) + output[`Tags.${t}`] = { + value: [true, "xsd:boolean"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + } + + if (name.startsWith("_")) continue; + + let childrenTimestamp = timestamp; + + if (root.length === 0) childrenTimestamp = input["_timestamp"] || 1; + else if (input["_timestamp"] > timestamp) + childrenTimestamp = input["_timestamp"]; + + const attrs = {}; + if (tree["_value"]) { + attrs["value"] = [tree["_value"], tree["_type"]]; + attrs["valueTimestamp"] = childrenTimestamp; + attrs["object"] = false; + attrs["objectTimestamp"] = childrenTimestamp; + } else if (tree["_object"]) { + attrs["object"] = tree["_object"]; + attrs["objectTimestamp"] = childrenTimestamp; + } + + if (tree["_writable"]) { + attrs["writable"] = tree["_writable"]; + attrs["writableTimestamp"] = childrenTimestamp; + } + + let r = root.concat(name); + output[r.join(".")] = attrs; + + if (attrs["object"]) recursive(tree, r, output, childrenTimestamp); + } +} + +function transpose(device) { + const newDevice = {}; + const timestamp = new Date(device["_lastInform"] || 1).getTime(); + recursive(device, [], newDevice, timestamp); + return newDevice; +} + +function transposeQuery(query) { + const newQuery = {}; + + for (let [key, val] of Object.entries(query)) + if (key.startsWith("$")) newQuery[key] = transposeQuery(val); + else if (key === "DeviceID.ID") newQuery["_id"] = val; + + return newQuery; +} + +exports.transpose = transpose; +exports.transposeQuery = transposeQuery; From eda43083ec1afbfca352fe19b2286a254602c7e9 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Fri, 29 Dec 2017 15:14:21 -0800 Subject: [PATCH 005/106] Implement parser for SQL-like queries --- package.json | 3 +- server/filter-parser.js | 159 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 server/filter-parser.js diff --git a/package.json b/package.json index e7cc313..e31be69 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "koa-bodyparser": "^4.2.0", "koa-router": "^7.3.0", "koa-static": "^4.0.2", - "mithril": "^1.1.5" + "mithril": "^1.1.5", + "parsimmon": "^1.6.2" }, "devDependencies": { "cssnano": "^3.10.0", diff --git a/server/filter-parser.js b/server/filter-parser.js new file mode 100644 index 0000000..080143a --- /dev/null +++ b/server/filter-parser.js @@ -0,0 +1,159 @@ +"use strict"; + +const parsimmon = require("parsimmon"); + +// Turn escaped characters into real ones (e.g. "\\n" becomes "\n"). +function interpretEscapes(str) { + let escapes = { + b: "\b", + f: "\f", + n: "\n", + r: "\r", + t: "\t" + }; + return str.replace(/\\(u[0-9a-fA-F]{4}|[^u])/, (_, escape) => { + let type = escape.charAt(0); + let hex = escape.slice(1); + if (type === "u") return String.fromCharCode(parseInt(hex, 16)); + + if (escapes.hasOwnProperty(type)) return escapes[type]; + + return type; + }); +} + +const lang = parsimmon.createLanguage({ + ComparisonOperator: function(r) { + return parsimmon + .alt( + parsimmon.string(">="), + parsimmon.string("<>"), + parsimmon.string("<="), + parsimmon.string("="), + parsimmon.string(">"), + parsimmon.string("<") + ) + .skip(r._); + }, + IsNullOperator: function(r) { + return parsimmon + .alt( + parsimmon + .regexp(/is\s+null/i) + .result("IS NULL") + .desc("IS NULL"), + parsimmon + .regexp(/is\s+not\s+null/i) + .result("IS NOT NULL") + .desc("IS NOT NULL") + ) + .skip(r._); + }, + NotOperator: function(r) { + return parsimmon + .regexp(/not/i) + .result("NOT") + .skip(r._) + .desc("NOT"); + }, + AndOperator: function(r) { + return parsimmon + .regexp(/and/i) + .result("AND") + .skip(r._) + .desc("AND"); + }, + OrOperator: function(r) { + return parsimmon + .regexp(/or/i) + .result("OR") + .skip(r._) + .desc("OR"); + }, + Parameter: function(r) { + return parsimmon + .regexp(/[a-zA-Z0-9_.*]+/) + .skip(r._) + .desc("parameter"); + }, + StringValueSql: function(r) { + return parsimmon + .regexp(/'([^']*)'/, 1) + .atLeast(1) + .skip(r._) + .map(s => s.join("'")) + .desc("string"); + }, + StringValueJs: function(r) { + return parsimmon + .regexp(/"((?:\\.|.)*?)"/, 1) + .skip(r._) + .map(interpretEscapes) + .desc("string"); + }, + NumberValue: function(r) { + return parsimmon + .regexp(/-?(0|[1-9][0-9]*)([.][0-9]+)?([eE][+-]?[0-9]+)?/) + .skip(r._) + .map(Number) + .desc("number"); + }, + Value: function(r) { + return parsimmon.alt(r.NumberValue, r.StringValueSql, r.StringValueJs); + }, + Comparison: function(r) { + return parsimmon.alt( + parsimmon.seqMap(r.Parameter, r.IsNullOperator, (p, o) => [o, p]), + parsimmon.seqMap( + r.Parameter, + r.ComparisonOperator, + r.Value, + (p, o, v) => [o, p, v] + ) + ); + }, + Expression: function(r) { + function binaryLeft(operatorsParser, nextParser) { + return parsimmon.seqMap( + nextParser, + parsimmon.seq(operatorsParser, nextParser).many(), + (first, rest) => + rest.reduce((acc, ch) => { + let [op, another] = ch; + if (op === acc[0]) return acc.concat([another]); + if (op === another[0]) return [op, acc].concat(another.slice(1)); + return [op, acc, another]; + }, first) + ); + } + + function unary(operatorsParser, nextParser) { + return parsimmon.seq(operatorsParser, nextParser).or(nextParser); + } + + return binaryLeft( + r.OrOperator, + binaryLeft( + r.AndOperator, + unary( + r.NotOperator, + r.Comparison.or( + r.Expression.wrap( + parsimmon.string("("), + parsimmon.string(")").skip(r._) + ) + ) + ) + ) + ).trim(r._); + }, + _: function() { + return parsimmon.optWhitespace; + } +}); + +function parse(filter) { + return lang.Expression.tryParse(filter); +} + +exports.parse = parse; From ccf548081f182d8e738aca6dea69956cc4cfc4b8 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Mon, 1 Jan 2018 14:18:43 -0800 Subject: [PATCH 006/106] Implement filter in devices page --- client/config.js | 17 +++++++++ client/css/app.css | 10 ++++++ client/devices-page.js | 57 ++++++++++++++++++++++++----- client/filter-component.js | 41 +++++++++++++++++++++ server/api-functions.js | 73 ++++++++++++++++++++++++++++---------- server/api.js | 22 +++--------- server/device.js | 25 +++++++++++-- 7 files changed, 198 insertions(+), 47 deletions(-) create mode 100644 client/filter-component.js diff --git a/client/config.js b/client/config.js index 20f5748..566323d 100644 --- a/client/config.js +++ b/client/config.js @@ -2,6 +2,23 @@ export default { pageSize: 10, + filter: [ + { + label: "Serial number", + parameter: "DeviceID.SerialNumber", + type: "string" + }, + { + label: "Product class", + parameter: "DeviceID.ProductClass", + type: "string" + }, + { + label: "Tag", + parameter: "tag", + type: "string" + } + ], index: [ { label: "Serial number", diff --git a/client/css/app.css b/client/css/app.css index 5454683..75ae270 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -200,3 +200,13 @@ button { color: var(--color2); } } + +.filter { + margin-bottom: 0.5em; + & > input { + padding: 2px; + width: 30em; + font-size: 90%; + display: block; + } +} diff --git a/client/devices-page.js b/client/devices-page.js index ad6223d..ea6faba 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -3,13 +3,14 @@ import m from "mithril"; import config from "./config"; +import filterComponent from "./filter-component"; function load(filter, limit) { return new Promise((resolve, reject) => { m .request({ url: "/api/devices/", - data: { filter: JSON.stringify(filter), limit: limit } + data: { filter: filter, limit: limit } }) .then(res => { resolve(res); @@ -29,8 +30,9 @@ function count(filter, limit) { m .request({ method: "HEAD", - url: "/api/devices/", - data: { filter: JSON.stringify(filter), limit: limit }, + url: + "/api/devices/?" + + m.buildQueryString({ filter: filter, limit: limit }), extract: extract }) .then(res => { @@ -43,9 +45,45 @@ function count(filter, limit) { }); } +function preprocessFilter(filter) { + if (Array.isArray(filter)) { + filter = filter.map(f => { + f = f.trim(); + if (f.startsWith("(")) return f; + + let match = f.match(/([^=<>]+)([=<>]+)(.+)/); + + let param = match[1].trim(); + let op = match[2].trim(); + let value = match[3].trim(); + + try { + value = JSON.parse(value); + } catch (err) { + // eslint-disable-line no-empty + } + + for (let cf of config.filter) + if (param === cf.label) param = cf.parameter; + + if (param === "tag") { + let tag = value.replace(/[^a-zA-Z0-9]/g, "_"); + if (op === "=") return `Tags.${tag} is not null`; + else if (op === "<>") return `Tags.${tag} is null`; + } + + return `${param} ${op} ${JSON.stringify(value)}`; + }); + filter = "(" + filter.join(") AND (") + ")"; + } + return filter; +} + function init(args) { return new Promise((resolve, reject) => { - Promise.all([count(args.filter), load(args.filter, config.pageSize)]) + let filter = preprocessFilter(args.filter); + + Promise.all([count(filter), load(filter, config.pageSize)]) .then(res => { resolve({ filter: args.filter, total: res[0], devices: res[1] }); }) @@ -93,15 +131,18 @@ const component = { document.title = "Devices - GenieACS"; function showMore() { const lastDevice = vnode.attrs.devices[vnode.attrs.devices.length - 1]; - let f = Object.assign({}, vnode.attrs.filter, { - "DeviceID.ID>": lastDevice["DeviceID.ID"].value[0] - }); - load(f, config.pageSize).then(res => { + let f = (vnode.attrs.filter || []).concat( + `DeviceID.ID > "${lastDevice["DeviceID.ID"].value[0]}"` + ); + load(preprocessFilter(f), config.pageSize).then(res => { vnode.attrs.devices = vnode.attrs.devices.concat(res); + if (res.length < config.pageSize) + vnode.attrs.total = vnode.attrs.devices.length; }); } return [ m("h1", "Listing devices"), + m(filterComponent, { filterList: [].concat(vnode.attrs.filter) }), renderTable( vnode.attrs.devices, config.index, diff --git a/client/filter-component.js b/client/filter-component.js new file mode 100644 index 0000000..8f2f401 --- /dev/null +++ b/client/filter-component.js @@ -0,0 +1,41 @@ +"use strict"; + +import m from "mithril"; + +import config from "./config"; + +const component = { + view: vnode => { + let a = m( + "datalist#filters", + config.filter.map(f => m("option", { value: `${f.label} = ` })) + ); + + const filterList = vnode.attrs.filterList.filter(f => { + return !!f; + }); + filterList.push(""); + + return m( + "div.filter", + [m("b", "Filter"), a].concat( + filterList.map((fltr, idx) => { + return m("input", { + type: "string", + list: "filters", + value: fltr, + onchange: function(e) { + e.redraw = false; + filterList[idx] = this.value; + m.route.set("/devices", { + filter: filterList.filter(f => !!f) + }); + } + }); + }) + ) + ); + } +}; + +export default component; diff --git a/server/api-functions.js b/server/api-functions.js index 131dfe1..1a583ae 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -7,34 +7,68 @@ const querystring = require("querystring"); const config = require("./config"); const device = require("./device"); +const filterParser = require("./filter-parser"); const commaAscii = ",".charCodeAt(0); const newLineAscii = "\n".charCodeAt(0); -function filtersToQuery(filters) { - if (!Array.isArray(filters)) filters = [filters]; +function filterToQuery(filter, negate = false, res = {}) { + const op = filter[0]; - let queries = []; - for (let filter of filters) { - let q = {}; - for (let [k, v] of Object.entries(filter)) { - let [param, op] = k.split(/([^a-zA-Z0-9\-_.].*)/, 2); - if (!op || op === "=") q[param] = v; - else if (op === ">") q[param] = { $gt: v }; - else throw new Error(`Operator "${op}" not recognized`); + if ((!negate && op === "AND") || (negate && op === "OR")) { + for (let i = 1; i < filter.length; ++i) + filterToQuery(filter[i], negate, res); + } else if ((!negate && op === "OR") || (negate && op === "AND")) { + res["$or"] = res["$or"] || []; + + for (let i = 1; i < filter.length; ++i) + res["$or"].push(filterToQuery(filter[i], negate)); + } else if (op === "NOT") { + filterToQuery(filter[1], !negate, res); + } else if (op === "=") { + let p = (res[filter[1]] = res[filter[1]] || {}); + if (negate) p["$ne"] = filter[2]; + else p["$eq"] = filter[2]; + } else if (op === "<>") { + let p = (res[filter[1]] = res[filter[1]] || {}); + if (negate) { + p["$eq"] = filter[2]; + } else { + p["$ne"] = filter[2]; + p["$exists"] = true; } - queries.push(q); + } else if (op === ">") { + let p = (res[filter[1]] = res[filter[1]] || {}); + if (negate) p = p["$not"] = p["$not"] || {}; + p["$gt"] = filter[2]; + } else if (op === ">=") { + let p = (res[filter[1]] = res[filter[1]] || {}); + if (negate) p = p["$not"] = p["$not"] || {}; + p["$gte"] = filter[2]; + } else if (op === "<") { + let p = (res[filter[1]] = res[filter[1]] || {}); + if (negate) p = p["$not"] = p["$not"] || {}; + p["$lt"] = filter[2]; + } else if (op === "<=") { + let p = (res[filter[1]] = res[filter[1]] || {}); + if (negate) p = p["$not"] = p["$not"] || {}; + p["$lte"] = filter[2]; + } else if (op === "IS NULL") { + res[filter[1]] = { $exists: negate }; + } else if (op === "IS NOT NULL") { + res[filter[1]] = { $exists: !negate }; + } else { + throw new Error(`Unrecognized operator ${op}`); } - if (queries.length === 1) return queries[0]; - else return { $or: queries }; + return res; } -function count(resource, filters, limit) { +function count(resource, filter, limit) { return new Promise((resolve, reject) => { let qs = {}; - if (filters) { - let q = filtersToQuery(filters); + if (filter) { + let q = filterToQuery(filterParser.parse(filter)); if (resource === "devices") q = device.transposeQuery(q); qs.query = JSON.stringify(q); } @@ -62,14 +96,15 @@ function count(resource, filters, limit) { }); } -function query(resource, filters, limit, callback) { +function query(resource, filter, limit, callback) { return new Promise((resolve, reject) => { let ret; if (!callback) ret = []; let qs = {}; - if (filters) { - let q = filtersToQuery(filters); + if (filter) { + let q = filterToQuery(filterParser.parse(filter)); if (resource === "devices") q = device.transposeQuery(q); + qs.query = JSON.stringify(q); } diff --git a/server/api.js b/server/api.js index d7e3381..3bf2645 100644 --- a/server/api.js +++ b/server/api.js @@ -17,14 +17,7 @@ const resources = { for (let [resource, update] of Object.entries(resources)) { router.head(`/${resource}`, async ctx => { let filter, limit; - if (ctx.request.query.filter) - if (Array.isArray(ctx.request.query.filter)) - filter = ctx.request.query.query.map(q => JSON.parse(q)); - else { - filter = JSON.parse(ctx.request.query.filter); - if (!Array.isArray(filter)) filter = [filter]; - } - + if (ctx.request.query.filter) filter = ctx.request.query.filter; if (ctx.request.query.limit) limit = +ctx.request.query.limit; let count = await apiFunctions.count(resource, filter, limit); @@ -34,28 +27,21 @@ for (let [resource, update] of Object.entries(resources)) { router.get(`/${resource}`, async ctx => { let filter, limit; - if (ctx.request.query.filter) - if (Array.isArray(ctx.request.query.filter)) - filter = ctx.request.query.query.map(q => JSON.parse(q)); - else { - filter = JSON.parse(ctx.request.query.filter); - if (!Array.isArray(filter)) filter = [filter]; - } - + if (ctx.request.query.filter) filter = ctx.request.query.filter; if (ctx.request.query.limit) limit = +ctx.request.query.limit; ctx.body = await apiFunctions.query(resource, filter, limit); }); router.head(`/${resource}/:id`, async (ctx, next) => { - let filter = { _id: ctx.params.id }; + let filter = `DeviceID.ID = "${ctx.params.id}"`; let res = await apiFunctions.query(resource, filter); if (!res.length) return next(); ctx.body = ""; }); router.get(`/${resource}/:id`, async (ctx, next) => { - let filter = { _id: ctx.params.id }; + let filter = `DeviceID.ID = "${ctx.params.id}"`; let res = await apiFunctions.query(resource, filter); if (!res.length) return next(); ctx.body = res[0]; diff --git a/server/device.js b/server/device.js index aefea8f..fd57a79 100644 --- a/server/device.js +++ b/server/device.js @@ -142,8 +142,29 @@ function transposeQuery(query) { const newQuery = {}; for (let [key, val] of Object.entries(query)) - if (key.startsWith("$")) newQuery[key] = transposeQuery(val); - else if (key === "DeviceID.ID") newQuery["_id"] = val; + if (key.startsWith("$")) { + newQuery[key] = val.map(v => transposeQuery(v)); + } else if (key === "DeviceID.ID") { + newQuery["_id"] = val; + } else if (key === "DeviceID.SerialNumber") { + newQuery["_deviceId._SerialNumber"] = val; + } else if (key === "DeviceID.OUI") { + newQuery["_deviceId._OUI"] = val; + } else if (key === "DeviceID.Manufacturer") { + newQuery["_deviceId._Manufacturer"] = val; + } else if (key.startsWith("Tags.")) { + if (val["$exists"] === true) { + newQuery["_tags"] = newQuery["_tags"] || {}; + newQuery["_tags"]["$all"] = newQuery["_tags"]["$all"] || []; + newQuery["_tags"]["$all"].push(key.slice(5)); + } else if (val["$exists"] === false) { + newQuery["_tags"] = newQuery["_tags"] || {}; + newQuery["_tags"]["$nin"] = newQuery["_tags"]["$nin"] || []; + newQuery["_tags"]["$nin"].push(key.slice(5)); + } + } else { + newQuery[key] = val; + } return newQuery; } From 9253ffc584d4983527862a63672efcb039528a25 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 14 Jan 2018 19:43:17 -0800 Subject: [PATCH 007/106] Parse query functions --- server/filter-parser.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/server/filter-parser.js b/server/filter-parser.js index 080143a..2b38e19 100644 --- a/server/filter-parser.js +++ b/server/filter-parser.js @@ -98,8 +98,26 @@ const lang = parsimmon.createLanguage({ .map(Number) .desc("number"); }, + FuncValue: function(r) { + return parsimmon.seqMap( + parsimmon + .regexp(/([a-zA-Z0-0_]+)/, 1) + .skip(r._) + .desc("function"), + r.Value.sepBy(parsimmon.string(",").skip(r._)).wrap( + parsimmon.string("(").skip(r._), + parsimmon.string(")").skip(r._) + ), + (f, args) => ["FUNC", f.toUpperCase()].concat(args) + ); + }, Value: function(r) { - return parsimmon.alt(r.NumberValue, r.StringValueSql, r.StringValueJs); + return parsimmon.alt( + r.NumberValue, + r.StringValueSql, + r.StringValueJs, + r.FuncValue + ); }, Comparison: function(r) { return parsimmon.alt( From 0e6f08b68be43441bb8249fed35d3b1e5d33014f Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 14 Jan 2018 19:49:13 -0800 Subject: [PATCH 008/106] Implement function to stringify query --- server/filter-parser.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/server/filter-parser.js b/server/filter-parser.js index 2b38e19..13a77a5 100644 --- a/server/filter-parser.js +++ b/server/filter-parser.js @@ -174,4 +174,34 @@ function parse(filter) { return lang.Expression.tryParse(filter); } +function stringify(filter) { + function value(v) { + if (Array.isArray(v) && v[0].toUpperCase() === "FUNC") + return `${v[1]}(${v + .slice(2) + .map(e => value(e)) + .join(", ")})`; + return JSON.stringify(v); + } + + function expressions(v) { + return v.map(e => { + let str = stringify(e); + if (e[0] === "AND" || e[0] === "OR") return `(${str})`; + else return str; + }); + } + + const op = filter[0].toUpperCase(); + + if (["AND", "OR"].includes(op)) + return expressions(filter.slice(1)).join(` ${op} `); + else if (op === "NOT") return `NOT ${expressions(filter.slice(1))[0]}`; + else if ([">=", "<>", "<=", "=", ">", "<"].includes(op)) + return `${filter[1]} ${op} ${value(filter[2])}`; + else if (["IS NULL", "IS NOT NULL"].includes(op)) return `${filter[1]} ${op}`; + else throw new Error("Unrecognized operator"); +} + exports.parse = parse; +exports.stringify = stringify; From ea2e3707e392c6f602beaf6357bd0babecaacf25 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Fri, 19 Jan 2018 16:08:30 -0800 Subject: [PATCH 009/106] Support value expressions in filter --- common/.eslintrc.json | 9 ++ common/filter-parser.js | 324 ++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- server/api-functions.js | 28 +++- server/config.js | 2 + server/device.js | 18 +++ server/filter-parser.js | 207 ------------------------- 7 files changed, 378 insertions(+), 212 deletions(-) create mode 100644 common/.eslintrc.json create mode 100644 common/filter-parser.js delete mode 100644 server/filter-parser.js diff --git a/common/.eslintrc.json b/common/.eslintrc.json new file mode 100644 index 0000000..b46a7a5 --- /dev/null +++ b/common/.eslintrc.json @@ -0,0 +1,9 @@ +{ + "env": { + "node": true, + "browser": true + }, + "parserOptions": { + "ecmaVersion": 8 + } +} diff --git a/common/filter-parser.js b/common/filter-parser.js new file mode 100644 index 0000000..227436e --- /dev/null +++ b/common/filter-parser.js @@ -0,0 +1,324 @@ +"use strict"; + +const parsimmon = require("parsimmon"); + +// Turn escaped characters into real ones (e.g. "\\n" becomes "\n"). +function interpretEscapes(str) { + let escapes = { + b: "\b", + f: "\f", + n: "\n", + r: "\r", + t: "\t" + }; + return str.replace(/\\(u[0-9a-fA-F]{4}|[^u])/, (_, escape) => { + let type = escape.charAt(0); + let hex = escape.slice(1); + if (type === "u") return String.fromCharCode(parseInt(hex, 16)); + + if (escapes.hasOwnProperty(type)) return escapes[type]; + + return type; + }); +} + +function binaryLeft(operatorsParser, nextParser) { + return parsimmon.seqMap( + nextParser, + parsimmon.seq(operatorsParser, nextParser).many(), + (first, rest) => + rest.reduce((acc, ch) => { + let [op, another] = ch; + if (op === acc[0]) return acc.concat([another]); + if (op === another[0]) return [op, acc].concat(another.slice(1)); + return [op, acc, another]; + }, first) + ); +} + +const lang = parsimmon.createLanguage({ + ComparisonOperator: function() { + return parsimmon + .alt( + parsimmon.string(">="), + parsimmon.string("<>"), + parsimmon.string("<="), + parsimmon.string("="), + parsimmon.string(">"), + parsimmon.string("<") + ) + .skip(parsimmon.optWhitespace); + }, + IsNullOperator: function() { + return parsimmon + .alt( + parsimmon + .regexp(/is\s+null/i) + .result("IS NULL") + .desc("IS NULL"), + parsimmon + .regexp(/is\s+not\s+null/i) + .result("IS NOT NULL") + .desc("IS NOT NULL") + ) + .skip(parsimmon.optWhitespace); + }, + NotOperator: function() { + return parsimmon + .regexp(/not/i) + .result("NOT") + .skip(parsimmon.optWhitespace) + .desc("NOT"); + }, + AndOperator: function() { + return parsimmon + .regexp(/and/i) + .result("AND") + .skip(parsimmon.optWhitespace) + .desc("AND"); + }, + OrOperator: function() { + return parsimmon + .regexp(/or/i) + .result("OR") + .skip(parsimmon.optWhitespace) + .desc("OR"); + }, + Parameter: function(r) { + return parsimmon + .alt( + parsimmon.regexp(/[a-zA-Z0-9_.*]+/), + r.ValueExpression.wrap( + parsimmon.string("{").skip(parsimmon.optWhitespace), + parsimmon.string("}") + ) + ) + .atLeast(1) + .map(x => (x.length > 1 ? ["||"].concat(x) : x[0])) + .skip(parsimmon.optWhitespace) + .desc("parameter"); + }, + StringValueSql: function() { + return parsimmon + .regexp(/'([^']*)'/, 1) + .atLeast(1) + .skip(parsimmon.optWhitespace) + .map(s => s.join("'")) + .desc("string"); + }, + StringValueJs: function() { + return parsimmon + .regexp(/"((?:\\.|.)*?)"/, 1) + .skip(parsimmon.optWhitespace) + .map(interpretEscapes) + .desc("string"); + }, + NumberValue: function() { + return parsimmon + .regexp(/-?(0|[1-9][0-9]*)([.][0-9]+)?([eE][+-]?[0-9]+)?/) + .skip(parsimmon.optWhitespace) + .map(Number) + .desc("number"); + }, + FuncValue: function(r) { + return parsimmon.seqMap( + parsimmon + .regexp(/([a-zA-Z0-0_]+)/, 1) + .skip(parsimmon.optWhitespace) + .desc("function"), + r.Value.sepBy(parsimmon.string(",").skip(parsimmon.optWhitespace)).wrap( + parsimmon.string("(").skip(parsimmon.optWhitespace), + parsimmon.string(")").skip(parsimmon.optWhitespace) + ), + (f, args) => ["FUNC", f.toUpperCase()].concat(args) + ); + }, + Value: function(r) { + return parsimmon.alt( + r.NumberValue, + r.StringValueSql, + r.StringValueJs, + r.FuncValue + ); + }, + ValueExpression: function(r) { + return binaryLeft( + parsimmon.string("||"), + binaryLeft( + parsimmon + .alt(parsimmon.string("+"), parsimmon.string("-")) + .skip(parsimmon.optWhitespace), + binaryLeft( + parsimmon + .alt(parsimmon.string("*"), parsimmon.string("/")) + .skip(parsimmon.optWhitespace), + parsimmon.alt( + r.Value, + r.ValueExpression.wrap( + parsimmon.string("(").skip(parsimmon.optWhitespace), + parsimmon.string(")").skip(parsimmon.optWhitespace) + ) + ) + ) + ) + ); + }, + Comparison: function(r) { + return parsimmon.alt( + parsimmon.seqMap(r.Parameter, r.IsNullOperator, (p, o) => [o, p]), + parsimmon.seqMap( + r.Parameter, + r.ComparisonOperator, + r.ValueExpression, + (p, o, v) => [o, p, v] + ) + ); + }, + Expression: function(r) { + function unary(operatorsParser, nextParser) { + return parsimmon.seq(operatorsParser, nextParser).or(nextParser); + } + + return binaryLeft( + r.OrOperator, + binaryLeft( + r.AndOperator, + unary( + r.NotOperator, + r.Comparison.or( + r.Expression.wrap( + parsimmon.string("("), + parsimmon.string(")").skip(parsimmon.optWhitespace) + ) + ) + ) + ) + ).trim(parsimmon.optWhitespace); + } +}); + +function parse(filter) { + return lang.Expression.tryParse(filter); +} + +function parseExpression(exp) { + return lang.ValueExpression.tryParse(exp); +} + +function parseParameter(p) { + return lang.Parameter.tryParse(p); +} + +function stringify(filter) { + function value(v) { + if (Array.isArray(v)) + if (v[0].toUpperCase() === "FUNC") { + return `${v[1]}(${v + .slice(2) + .map(e => value(e)) + .join(", ")})`; + } else { + return v + .slice(1) + .map(e => value(e)) + .join(` ${v[0]} `); + } + + return JSON.stringify(v); + } + + function expressions(v) { + return v.map(e => { + let str = stringify(e); + if (e[0] === "AND" || e[0] === "OR") return `(${str})`; + else return str; + }); + } + + function parameter(v) { + if (Array.isArray(v) && v[0] === "||") + return v + .slice(1) + .map(x => { + if (Array.isArray(x)) return `{${value(x)}}`; + else return x; + }) + .join(""); + + return v; + } + + const op = filter[0].toUpperCase(); + + if (["AND", "OR"].includes(op)) + return expressions(filter.slice(1)).join(` ${op} `); + else if (op === "NOT") return `NOT ${expressions(filter.slice(1))[0]}`; + else if ([">=", "<>", "<=", "=", ">", "<"].includes(op)) + return `${parameter(filter[1])} ${op} ${value(filter[2])}`; + else if (["IS NULL", "IS NOT NULL"].includes(op)) + return `${parameter(filter[1])} ${op}`; + else throw new Error("Unrecognized operator"); +} + +function map(filter, callback) { + let clone; + for (let i = 1; i < filter.length; ++i) + if (Array.isArray(filter[i])) { + let sub = map(filter[i], callback); + if (sub != null && sub !== filter[i]) { + clone = clone || filter.slice(); + clone[i] = sub; + } + } + + return callback(clone || filter) || clone || filter; +} + +function and(filter1, filter2) { + if (!filter1) return filter2; + else if (!filter2) return filter1; + + let res = ["AND"]; + + if (filter1[0] === "AND") res = res.concat(filter1.slice(1)); + else res.push(filter1); + + if (filter2[0] === "AND") res = res.concat(filter2.slice(1)); + else res.push(filter2); + + return res; +} + +function evaluateExpressions(filter, now = Date.now()) { + return map(filter, exp => { + if (exp[0] === "FUNC" && exp[1] === "NOW") { + return now; + } else if (exp[0] === "*") { + let v = exp[1]; + for (let i = 2; i < exp.length; ++i) v *= exp[i]; + return v; + } else if (exp[0] === "/") { + let v = exp[1]; + for (let i = 2; i < exp.length; ++i) v /= exp[i]; + return v; + } else if (exp[0] === "+") { + let v = exp[1]; + for (let i = 2; i < exp.length; ++i) v += exp[i]; + return v; + } else if (exp[0] === "-") { + let v = exp[1]; + for (let i = 2; i < exp.length; ++i) v -= exp[i]; + return v; + } else if (exp[0] === "||") { + return exp.slice(1).join(""); + } + }); +} + +exports.parse = parse; +exports.parseExpression = parseExpression; +exports.parseParameter = parseParameter; +exports.stringify = stringify; +exports.map = map; +exports.and = and; +exports.evaluateExpressions = evaluateExpressions; diff --git a/package.json b/package.json index e31be69..c175433 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "lint": "eslint --fix server/*.js client/*.js;prettier --write server/*.js client/*.js client/css/*.css", + "lint": "eslint --fix common/*.js server/*.js client/*.js;prettier --write common/*.js server/*.js client/*.js client/css/*.css", "build": "npm run lint;webpack;postcss -c postcss.config.js client/css/app.css -o public/app.css", "start": "node server/server" }, diff --git a/server/api-functions.js b/server/api-functions.js index 1a583ae..7549ef0 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -7,17 +7,29 @@ const querystring = require("querystring"); const config = require("./config"); const device = require("./device"); -const filterParser = require("./filter-parser"); +const filterParser = require("../common/filter-parser"); const commaAscii = ",".charCodeAt(0); const newLineAscii = "\n".charCodeAt(0); +function unpackTimestamps(filter) { + return filterParser.map(filter, exp => { + if (["=", "<>", ">=", "<=", "<", ">"].includes(exp[0])) + if (typeof exp[2] === "number") { + let alt = exp.slice(); + alt[2] = new Date(alt[2]).toJSON(); + return ["OR", exp, alt]; + } + }); +} + function filterToQuery(filter, negate = false, res = {}) { const op = filter[0]; if ((!negate && op === "AND") || (negate && op === "OR")) { + res["$and"] = res["$and"] || []; for (let i = 1; i < filter.length; ++i) - filterToQuery(filter[i], negate, res); + res["$and"].push(filterToQuery(filter[i], negate)); } else if ((!negate && op === "OR") || (negate && op === "AND")) { res["$or"] = res["$or"] || []; @@ -68,7 +80,11 @@ function count(resource, filter, limit) { return new Promise((resolve, reject) => { let qs = {}; if (filter) { - let q = filterToQuery(filterParser.parse(filter)); + let f = filterParser.parse(filter); + f = filterParser.evaluateExpressions(f); + f = unpackTimestamps(f); + + let q = filterToQuery(f); if (resource === "devices") q = device.transposeQuery(q); qs.query = JSON.stringify(q); } @@ -102,7 +118,11 @@ function query(resource, filter, limit, callback) { if (!callback) ret = []; let qs = {}; if (filter) { - let q = filterToQuery(filterParser.parse(filter)); + let f = filterParser.parse(filter); + f = filterParser.evaluateExpressions(f); + f = unpackTimestamps(f); + + let q = filterToQuery(f); if (resource === "devices") q = device.transposeQuery(q); qs.query = JSON.stringify(q); diff --git a/server/config.js b/server/config.js index 3a7809a..69dd68d 100644 --- a/server/config.js +++ b/server/config.js @@ -1,4 +1,6 @@ "use strict"; +const filterParser = require("../common/filter-parser.js"); + exports.PORT = 3000; exports.GENIEACS_NBI = "http://127.0.0.1:7557/"; diff --git a/server/device.js b/server/device.js index fd57a79..e426076 100644 --- a/server/device.js +++ b/server/device.js @@ -150,6 +150,8 @@ function transposeQuery(query) { newQuery["_deviceId._SerialNumber"] = val; } else if (key === "DeviceID.OUI") { newQuery["_deviceId._OUI"] = val; + } else if (key === "DeviceID.ProductClass") { + newQuery["_deviceId._ProductClass"] = val; } else if (key === "DeviceID.Manufacturer") { newQuery["_deviceId._Manufacturer"] = val; } else if (key.startsWith("Tags.")) { @@ -162,6 +164,22 @@ function transposeQuery(query) { newQuery["_tags"]["$nin"] = newQuery["_tags"]["$nin"] || []; newQuery["_tags"]["$nin"].push(key.slice(5)); } + } else if (key === "tag") { + if (typeof val === "string") { + newQuery["_tags"] = newQuery["_tags"] || {}; + newQuery["_tags"]["$all"] = newQuery["_tags"]["$all"] || []; + newQuery["_tags"]["$all"].push(val); + } else if (val["$eq"]) { + newQuery["_tags"] = newQuery["_tags"] || {}; + newQuery["_tags"]["$all"] = newQuery["_tags"]["$all"] || []; + newQuery["_tags"]["$all"].push(val["$eq"]); + } else if (val["$ne"]) { + newQuery["_tags"] = newQuery["_tags"] || {}; + newQuery["_tags"]["$nin"] = newQuery["_tags"]["$nin"] || []; + newQuery["_tags"]["$nin"].push(val["$ne"]); + } + } else if (key === "Events.Inform") { + newQuery["_lastInform"] = val; } else { newQuery[key] = val; } diff --git a/server/filter-parser.js b/server/filter-parser.js deleted file mode 100644 index 13a77a5..0000000 --- a/server/filter-parser.js +++ /dev/null @@ -1,207 +0,0 @@ -"use strict"; - -const parsimmon = require("parsimmon"); - -// Turn escaped characters into real ones (e.g. "\\n" becomes "\n"). -function interpretEscapes(str) { - let escapes = { - b: "\b", - f: "\f", - n: "\n", - r: "\r", - t: "\t" - }; - return str.replace(/\\(u[0-9a-fA-F]{4}|[^u])/, (_, escape) => { - let type = escape.charAt(0); - let hex = escape.slice(1); - if (type === "u") return String.fromCharCode(parseInt(hex, 16)); - - if (escapes.hasOwnProperty(type)) return escapes[type]; - - return type; - }); -} - -const lang = parsimmon.createLanguage({ - ComparisonOperator: function(r) { - return parsimmon - .alt( - parsimmon.string(">="), - parsimmon.string("<>"), - parsimmon.string("<="), - parsimmon.string("="), - parsimmon.string(">"), - parsimmon.string("<") - ) - .skip(r._); - }, - IsNullOperator: function(r) { - return parsimmon - .alt( - parsimmon - .regexp(/is\s+null/i) - .result("IS NULL") - .desc("IS NULL"), - parsimmon - .regexp(/is\s+not\s+null/i) - .result("IS NOT NULL") - .desc("IS NOT NULL") - ) - .skip(r._); - }, - NotOperator: function(r) { - return parsimmon - .regexp(/not/i) - .result("NOT") - .skip(r._) - .desc("NOT"); - }, - AndOperator: function(r) { - return parsimmon - .regexp(/and/i) - .result("AND") - .skip(r._) - .desc("AND"); - }, - OrOperator: function(r) { - return parsimmon - .regexp(/or/i) - .result("OR") - .skip(r._) - .desc("OR"); - }, - Parameter: function(r) { - return parsimmon - .regexp(/[a-zA-Z0-9_.*]+/) - .skip(r._) - .desc("parameter"); - }, - StringValueSql: function(r) { - return parsimmon - .regexp(/'([^']*)'/, 1) - .atLeast(1) - .skip(r._) - .map(s => s.join("'")) - .desc("string"); - }, - StringValueJs: function(r) { - return parsimmon - .regexp(/"((?:\\.|.)*?)"/, 1) - .skip(r._) - .map(interpretEscapes) - .desc("string"); - }, - NumberValue: function(r) { - return parsimmon - .regexp(/-?(0|[1-9][0-9]*)([.][0-9]+)?([eE][+-]?[0-9]+)?/) - .skip(r._) - .map(Number) - .desc("number"); - }, - FuncValue: function(r) { - return parsimmon.seqMap( - parsimmon - .regexp(/([a-zA-Z0-0_]+)/, 1) - .skip(r._) - .desc("function"), - r.Value.sepBy(parsimmon.string(",").skip(r._)).wrap( - parsimmon.string("(").skip(r._), - parsimmon.string(")").skip(r._) - ), - (f, args) => ["FUNC", f.toUpperCase()].concat(args) - ); - }, - Value: function(r) { - return parsimmon.alt( - r.NumberValue, - r.StringValueSql, - r.StringValueJs, - r.FuncValue - ); - }, - Comparison: function(r) { - return parsimmon.alt( - parsimmon.seqMap(r.Parameter, r.IsNullOperator, (p, o) => [o, p]), - parsimmon.seqMap( - r.Parameter, - r.ComparisonOperator, - r.Value, - (p, o, v) => [o, p, v] - ) - ); - }, - Expression: function(r) { - function binaryLeft(operatorsParser, nextParser) { - return parsimmon.seqMap( - nextParser, - parsimmon.seq(operatorsParser, nextParser).many(), - (first, rest) => - rest.reduce((acc, ch) => { - let [op, another] = ch; - if (op === acc[0]) return acc.concat([another]); - if (op === another[0]) return [op, acc].concat(another.slice(1)); - return [op, acc, another]; - }, first) - ); - } - - function unary(operatorsParser, nextParser) { - return parsimmon.seq(operatorsParser, nextParser).or(nextParser); - } - - return binaryLeft( - r.OrOperator, - binaryLeft( - r.AndOperator, - unary( - r.NotOperator, - r.Comparison.or( - r.Expression.wrap( - parsimmon.string("("), - parsimmon.string(")").skip(r._) - ) - ) - ) - ) - ).trim(r._); - }, - _: function() { - return parsimmon.optWhitespace; - } -}); - -function parse(filter) { - return lang.Expression.tryParse(filter); -} - -function stringify(filter) { - function value(v) { - if (Array.isArray(v) && v[0].toUpperCase() === "FUNC") - return `${v[1]}(${v - .slice(2) - .map(e => value(e)) - .join(", ")})`; - return JSON.stringify(v); - } - - function expressions(v) { - return v.map(e => { - let str = stringify(e); - if (e[0] === "AND" || e[0] === "OR") return `(${str})`; - else return str; - }); - } - - const op = filter[0].toUpperCase(); - - if (["AND", "OR"].includes(op)) - return expressions(filter.slice(1)).join(` ${op} `); - else if (op === "NOT") return `NOT ${expressions(filter.slice(1))[0]}`; - else if ([">=", "<>", "<=", "=", ">", "<"].includes(op)) - return `${filter[1]} ${op} ${value(filter[2])}`; - else if (["IS NULL", "IS NOT NULL"].includes(op)) return `${filter[1]} ${op}`; - else throw new Error("Unrecognized operator"); -} - -exports.parse = parse; -exports.stringify = stringify; From a7e07cbde96da7fb4191d678a063e9165da714b1 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Fri, 19 Jan 2018 16:21:00 -0800 Subject: [PATCH 010/106] Improve devices page filter --- client/css/app.css | 16 +++++++++ client/devices-page.js | 66 ++++++++++++++------------------------ client/filter-component.js | 50 ++++++++++++++++++++++------- 3 files changed, 78 insertions(+), 54 deletions(-) diff --git a/client/css/app.css b/client/css/app.css index 75ae270..66d33ac 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -69,6 +69,9 @@ i { display: inline-block; border-top-left-radius: 6px; border-top-right-radius: 6px; + border-top: 1px solid var(--color1); + border-left: 1px solid var(--color1); + border-right: 1px solid var(--color1); transition: background-color var(--fade); &.active, &:hover { @@ -208,5 +211,18 @@ button { width: 30em; font-size: 90%; display: block; + border: 1px solid var(--color1); + background-color: var(--color3); + margin: 2px; + border-radius: 4px; + transition: border-color var(--fade), background-color var(--fade); + &.error { + background-color: #efcbcf; + border-color: #e6b2b7; + } + &:focus, + &:hover { + border-color: var(--color4); + } } } diff --git a/client/devices-page.js b/client/devices-page.js index ea6faba..192f049 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -4,6 +4,7 @@ import m from "mithril"; import config from "./config"; import filterComponent from "./filter-component"; +import * as filterParser from "../common/filter-parser"; function load(filter, limit) { return new Promise((resolve, reject) => { @@ -45,47 +46,14 @@ function count(filter, limit) { }); } -function preprocessFilter(filter) { - if (Array.isArray(filter)) { - filter = filter.map(f => { - f = f.trim(); - if (f.startsWith("(")) return f; - - let match = f.match(/([^=<>]+)([=<>]+)(.+)/); - - let param = match[1].trim(); - let op = match[2].trim(); - let value = match[3].trim(); - - try { - value = JSON.parse(value); - } catch (err) { - // eslint-disable-line no-empty - } - - for (let cf of config.filter) - if (param === cf.label) param = cf.parameter; - - if (param === "tag") { - let tag = value.replace(/[^a-zA-Z0-9]/g, "_"); - if (op === "=") return `Tags.${tag} is not null`; - else if (op === "<>") return `Tags.${tag} is null`; - } - - return `${param} ${op} ${JSON.stringify(value)}`; - }); - filter = "(" + filter.join(") AND (") + ")"; - } - return filter; -} - function init(args) { return new Promise((resolve, reject) => { - let filter = preprocessFilter(args.filter); + let filter; + if (args.filter) filter = filterParser.parse(args.filter); - Promise.all([count(filter), load(filter, config.pageSize)]) + Promise.all([count(args.filter), load(args.filter, config.pageSize)]) .then(res => { - resolve({ filter: args.filter, total: res[0], devices: res[1] }); + resolve({ filter: filter, total: res[0], devices: res[1] }); }) .catch(reject); }); @@ -129,20 +97,34 @@ function renderTable(devices, parameters, total, showMoreCallback) { const component = { view: vnode => { document.title = "Devices - GenieACS"; + function showMore() { const lastDevice = vnode.attrs.devices[vnode.attrs.devices.length - 1]; - let f = (vnode.attrs.filter || []).concat( - `DeviceID.ID > "${lastDevice["DeviceID.ID"].value[0]}"` - ); - load(preprocessFilter(f), config.pageSize).then(res => { + let f = filterParser.and(vnode.attrs.filter, [ + ">", + "DeviceID.ID", + lastDevice["DeviceID.ID"].value[0] + ]); + + load(filterParser.stringify(f), config.pageSize).then(res => { vnode.attrs.devices = vnode.attrs.devices.concat(res); if (res.length < config.pageSize) vnode.attrs.total = vnode.attrs.devices.length; }); } + + function onFilterChanged(filter) { + let params = {}; + if (filter) params.filter = filterParser.stringify(filter); + m.route.set("/devices", params); + } + return [ m("h1", "Listing devices"), - m(filterComponent, { filterList: [].concat(vnode.attrs.filter) }), + m(filterComponent, { + filter: vnode.attrs.filter, + onChange: onFilterChanged + }), renderTable( vnode.attrs.devices, config.index, diff --git a/client/filter-component.js b/client/filter-component.js index 8f2f401..220b721 100644 --- a/client/filter-component.js +++ b/client/filter-component.js @@ -3,33 +3,59 @@ import m from "mithril"; import config from "./config"; +import * as filterParser from "../common/filter-parser"; const component = { view: vnode => { let a = m( "datalist#filters", - config.filter.map(f => m("option", { value: `${f.label} = ` })) + config.filter.map(f => m("option", { value: `${f.parameter} = ` })) ); - const filterList = vnode.attrs.filterList.filter(f => { - return !!f; - }); - filterList.push(""); + if (!vnode.state.filterList) { + vnode.state.filterList = []; + if (vnode.attrs.filter) + if (vnode.attrs.filter[0] === "AND") + for (let i = 1; i < vnode.attrs.filter.length; ++i) + vnode.state.filterList.push( + filterParser.stringify(vnode.attrs.filter[i]) + ); + else + vnode.state.filterList.push( + filterParser.stringify(vnode.attrs.filter) + ); + vnode.state.filterList.push(""); + } + + function onChange() { + vnode.state.filterInvalid = 0; + vnode.state.filterList = vnode.state.filterList.filter( + f => f && f.trim() + ); + let filter = vnode.state.filterList.map((f, idx) => { + try { + return filterParser.parse(f); + } catch (err) { + vnode.state.filterInvalid |= 1 << idx; + } + }); + vnode.state.filterList.push(""); + if (!vnode.state.filterInvalid) + vnode.attrs.onChange(["AND"].concat(filter)); + } return m( "div.filter", [m("b", "Filter"), a].concat( - filterList.map((fltr, idx) => { + vnode.state.filterList.map((fltr, idx) => { return m("input", { type: "string", list: "filters", + class: `${(vnode.state.filterInvalid >> idx) & 1 ? "error" : ""}`, value: fltr, - onchange: function(e) { - e.redraw = false; - filterList[idx] = this.value; - m.route.set("/devices", { - filter: filterList.filter(f => !!f) - }); + onchange: e => { + vnode.state.filterList[idx] = e.target.value; + onChange(); } }); }) From 93c6a4dfcd11fa04e8553607b1bdccb82cb700a7 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Tue, 19 Dec 2017 01:35:31 -0600 Subject: [PATCH 011/106] Add overview page --- client/app.js | 2 +- client/config.js | 30 ++++++++- client/css/app.css | 62 ++++++++++++++++++ client/layout.js | 2 +- client/overview-page.js | 98 +++++++++++++++++++++++++-- client/pie-chart-component.js | 120 ++++++++++++++++++++++++++++++++++ 6 files changed, 304 insertions(+), 10 deletions(-) create mode 100644 client/pie-chart-component.js diff --git a/client/app.js b/client/app.js index 2781f38..74e0777 100644 --- a/client/app.js +++ b/client/app.js @@ -23,7 +23,7 @@ function pagify(pageName, page) { page .init(args) .then(st => { - if (!st) return m.reoute.set("/"); + if (!st) return m.route.set("/"); state = st; resolve(); }) diff --git a/client/config.js b/client/config.js index 566323d..ba32429 100644 --- a/client/config.js +++ b/client/config.js @@ -37,5 +37,33 @@ export default { parameter: "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress" } - ] + ], + overview: { + online: { + label: "Online devices", + charts: { + all: { + label: "All devices", + slices: { + onlineNow: { + label: "Online now", + color: "#31a354", + filter: "Events.Inform > NOW() - 5 * 60 * 1000" + }, + past24: { + label: "Past 24 hours", + color: "#addd8e", + filter: + "Events.Inform > (NOW() - 5 * 60 * 1000) - (24 * 60 * 60 * 1000) AND Events.Inform < (NOW() - 5 * 60 * 1000)" + }, + others: { + label: "Others", + color: "#f7fcb9", + filter: "Events.Inform < (NOW() - 5 * 60 * 1000) - (24 * 60 * 60 * 1000)" + } + } + } + } + } + } }; diff --git a/client/css/app.css b/client/css/app.css index 66d33ac..e381ac4 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -149,12 +149,14 @@ h1 { h2 { color: var(--color5); font-weight: bold; + font-size: 120%; margin-top: 1em; margin-bottom: 0.5em; } h3 { color: var(--color5); + font-size: 110%; margin-top: 1em; margin-bottom: 0.5em; } @@ -226,3 +228,63 @@ button { } } } + +.page-overview { + & > h1 { + text-align: center; + } +} + +.overview-chart-group { + display: flex; + justify-content: center; + + & h2 { + text-align: center; + } +} + +.pie-chart { + margin: 20px; + text-align: center; + + & > svg { + & > path { + stroke: var(--color1); + stroke-width: 1px; + } + + & > a { + transition: opacity var(--fade); + opacity: 0; + + & > text { + font-weight: bold; + opacity: 0.4; + } + + & > path { + stroke: var(--color4); + } + + &:hover { + opacity: 1; + } + } + } + + & > .legend { + text-align: left; + margin: 15px; + + & > .legend-line { + & > .color { + height: 12px; + width: 12px; + margin-right: 5px; + border: 1px solid var(--color1); + display: inline-block; + } + } + } +} diff --git a/client/layout.js b/client/layout.js index bacab47..47d7fb9 100644 --- a/client/layout.js +++ b/client/layout.js @@ -11,7 +11,7 @@ const layout = { m("img.logo", { src: "logo.svg" }), m(menu, { page: vnode.attrs.page }) ]), - m("#content", [vnode.children]) + m("#content", { class: `page-${vnode.attrs.page}` }, [vnode.children]) ]; } }; diff --git a/client/overview-page.js b/client/overview-page.js index 35c091f..38a74f6 100644 --- a/client/overview-page.js +++ b/client/overview-page.js @@ -1,21 +1,105 @@ "use strict"; import m from "mithril"; +import config from "./config"; +import pieChartComponent from "./pie-chart-component"; +import * as filterParser from "../common/filter-parser"; + +function count(filter, limit) { + return new Promise((resolve, reject) => { + function extract(xhr) { + return +xhr.getResponseHeader("x-total-count"); + } + m + .request({ + method: "HEAD", + url: + "/api/devices/?" + + m.buildQueryString({ filter: filter, limit: limit }), + extract: extract + }) + .then(res => { + resolve(res); + }) + .catch(err => { + err.message = err.message || "Unknown error"; + reject(err); + }); + }); +} + +function queryCharts(groups) { + const now = Date.now(); + function evalNow(exp) { + if (exp[0] == "FUNC" && exp[1] === "NOW" && exp.length == 2) return now; + } + + let calls = []; + groups = Object.assign({}, groups); + for (let [groupName, group] of Object.entries(groups)) { + groups[groupName] = group = Object.assign({}, group); + for (let [chartName, chart] of Object.entries(group.charts)) { + group.charts[chartName] = chart = Object.assign({}, chart); + for (let [sliceName, slice] of Object.entries(chart.slices)) { + chart.slices[sliceName] = slice = Object.assign({}, slice); + calls.push( + new Promise((resolve, reject) => { + let f = filterParser.map(filterParser.parse(slice.filter), evalNow); + count(filterParser.stringify(f)) + .then(c => { + slice.count = c; + resolve(); + }) + .catch(reject); + }) + ); + } + } + } + + return new Promise((resolve, reject) => { + Promise.all(calls) + .then(() => { + resolve(groups); + }) + .catch(reject); + }); +} const init = function() { - return new Promise(resolve => { - resolve({ message: "Hello world!" }); + return new Promise((resolve, reject) => { + queryCharts(config.overview) + .then(charts => { + resolve({ charts: charts }); + }) + .catch(err => { + err.message = err.message || "Unknown error"; + reject(err); + }); }); }; const component = { view: vnode => { document.title = "Overview - GenieACS"; - return m( - "a", - { href: "#!/devices/202BC1-BM632w-000000" }, - vnode.attrs.message - ); + let children = []; + for (let group of Object.values(vnode.attrs.charts)) { + if (group.label) children.push(m("h1", group.label)); + + let groupChildren = []; + for (let chart of Object.values(group.charts)) { + let chartChildren = []; + if (chart.label) chartChildren.push(m("h2", chart.label)); + + chartChildren.push(m(pieChartComponent, { chart: chart })); + + groupChildren.push(m(".overview-chart", chartChildren)); + } + + children.push(m(".overview-chart-group", groupChildren)); + } + + return children; } }; diff --git a/client/pie-chart-component.js b/client/pie-chart-component.js new file mode 100644 index 0000000..f21d511 --- /dev/null +++ b/client/pie-chart-component.js @@ -0,0 +1,120 @@ +"use strict"; + +import m from "mithril"; + +const drawChart = function(chartData) { + let slices = chartData.slices; + let total = Array.from(Object.values(chartData.slices)).reduce( + (a, s) => a + s.count, + 0 + ); + let legend = []; + let paths = []; + let links = []; + let currentProgressPercentage = 0; + let startX = Math.cos(2 * Math.PI * currentProgressPercentage) * 100; + let startY = Math.sin(2 * Math.PI * currentProgressPercentage) * 100; + let endX, endY; + + for (let slice of Object.values(slices)) { + let percent = total > 0 ? slice.count / total : 0; + legend.push( + m(".legend-line", [ + m("span.color", { + style: `background-color: ${slice.color} !important;` + }), + `${slice.label}: `, + m( + "a", + { + href: `/#!/devices/?${m.buildQueryString({ + filter: slice.filter + })}` + }, + slice.count + ), + ` (${(percent * 100).toFixed(2)}%)` + ]) + ); + + if (percent > 0) { + currentProgressPercentage += percent; + endX = Math.cos(2 * Math.PI * currentProgressPercentage) * 100; + endY = Math.sin(2 * Math.PI * currentProgressPercentage) * 100; + let isBigArc = percent > 0.5 ? 1 : 0; + + let sketch = + `M ${startX} ${startY} ` + // Move to the starting point + `A 100 100 0 ${isBigArc} 1 ${endX} ${endY} ` + // Draw an Arc from starting point to ending point + `L 0 0 z`; // complete the shape by drawing a line to the center of circle + + startX = endX; + startY = endY; + + paths.push( + m("path", { + d: sketch, + fill: slice.color + }) + ); + + let percentageX = + Math.cos(2 * Math.PI * (currentProgressPercentage - percent / 2)) * 50; + let percentageY = + Math.sin(2 * Math.PI * (currentProgressPercentage - percent / 2)) * 50; + + links.push( + m( + "a", + { + href: `/#!/devices/?${m.buildQueryString({ + filter: slice.filter + })}` + }, + [ + m("path", { + d: sketch, + "fill-opacity": 0 + }), + m( + "text", + { + x: percentageX, + y: percentageY, + "dominant-baseline": "middle", + "text-anchor": "middle" + }, + `${(percent * 100).toFixed(2)}%` + ) + ] + ) + ); + } + } + + 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) + ]); +}; + +const component = { + view: vnode => { + return drawChart(vnode.attrs.chart); + } +}; + +export default component; From 9b827c5f5e0924ea6c66352c43a6d51b6f3ad5ea Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Wed, 31 Jan 2018 14:36:33 -0800 Subject: [PATCH 012/106] Generalize store quering functions --- client/app.js | 64 ++++-- client/components.js | 16 ++ client/components/overview-dot.js | 37 ++++ client/components/parameter-list.js | 27 +++ client/components/parameter-table.js | 42 ++++ client/components/parameter.js | 30 +++ client/config.js | 99 +++------ client/css/app.css | 36 ++++ client/device-page.js | 41 ++++ client/devices-page.js | 115 ++++------ client/filter-component.js | 17 +- client/overview-page.js | 91 ++------ client/pie-chart-component.js | 10 +- client/store.js | 307 +++++++++++++++++++++++++++ common/filter-cnf.js | 138 ++++++++++++ common/filter-parser.js | 51 +---- common/filter.js | 157 ++++++++++++++ common/sat-solver.js | 175 +++++++++++++++ config.json | 82 +++++++ package.json | 2 +- public/index.html | 10 - server/api-functions.js | 23 +- server/api.js | 20 +- server/config.js | 63 +++++- server/device.js | 8 +- server/server.js | 22 +- 26 files changed, 1362 insertions(+), 321 deletions(-) create mode 100644 client/components.js create mode 100644 client/components/overview-dot.js create mode 100644 client/components/parameter-list.js create mode 100644 client/components/parameter-table.js create mode 100644 client/components/parameter.js create mode 100644 client/device-page.js create mode 100644 client/store.js create mode 100644 common/filter-cnf.js create mode 100644 common/filter.js create mode 100644 common/sat-solver.js create mode 100644 config.json delete mode 100644 public/index.html diff --git a/client/app.js b/client/app.js index 74e0777..d368804 100644 --- a/client/app.js +++ b/client/app.js @@ -3,41 +3,67 @@ import m from "mithril"; import layout from "./layout"; +import * as store from "./store"; import * as overviewPage from "./overview-page"; import * as devicesPage from "./devices-page"; +import * as devicePage from "./device-page"; import * as errorPage from "./error-page"; let state; +let lastRenderTimestamp = 0; +let pageVisitTimestamp = 0; +let fulfillTimeout = null; + +function fulfill() { + clearTimeout(fulfillTimeout); + fulfillTimeout = setTimeout(() => { + store.fulfill(lastRenderTimestamp, pageVisitTimestamp).then(updated => { + if (updated) m.redraw(); + }); + }, 100); +} + function pagify(pageName, page) { const component = { render: () => { - if (state && state.error) return m(errorPage.component, state); - else return m(layout, { page: pageName }, m(page.component, state)); + lastRenderTimestamp = Date.now(); + let c; + if (state && state.error) c = m(errorPage.component, state); + else c = m(layout, { page: pageName }, m(page.component, state)); + fulfill(); + return c; } }; - if (page.init) - component.onmatch = args => { - return new Promise(resolve => { - page - .init(args) - .then(st => { - if (!st) return m.route.set("/"); - state = st; - resolve(); - }) - .catch(err => { - state = { error: err.message }; - resolve(); - }); - }); - }; + component.onmatch = args => { + pageVisitTimestamp = Date.now(); + if (!page.init) { + fulfill(); + return; + } + + return new Promise(resolve => { + page + .init(args) + .then(st => { + if (!st) return m.route.set("/"); + state = st; + resolve(); + fulfill(); + }) + .catch(err => { + state = { error: err.message }; + resolve(); + }); + }); + }; return component; } m.route(document.body, "/overview", { "/overview": pagify("overview", overviewPage), - "/devices": pagify("devices", devicesPage) + "/devices": pagify("devices", devicesPage), + "/devices/:id": pagify("devices", devicePage) }); diff --git a/client/components.js b/client/components.js new file mode 100644 index 0000000..43dd888 --- /dev/null +++ b/client/components.js @@ -0,0 +1,16 @@ +"use strict"; + +import parameter from "./components/parameter"; +import parameterList from "./components/parameter-list"; +import parameterTable from "./components/parameter-table"; +import overviewDot from "./components/overview-dot"; + +function get(name) { + if (name === "parameter") return parameter; + else if (name === "parameter-list") return parameterList; + else if (name === "parameter-table") return parameterTable; + else if (name === "overview-dot") return overviewDot; + else throw new Error(`No such component '${name}'`); +} + +export { get }; diff --git a/client/components/overview-dot.js b/client/components/overview-dot.js new file mode 100644 index 0000000..228021c --- /dev/null +++ b/client/components/overview-dot.js @@ -0,0 +1,37 @@ +"use strict"; + +import m from "mithril"; +import * as config from "../config"; +import * as store from "../store"; + +const CHARTS = config.get("ui.overview.charts"); + +const component = { + view: vnode => { + const device = vnode.attrs.device; + const chart = CHARTS[vnode.attrs.chart]; + for (let slice of Object.values(chart.slices)) { + const filter = store.unpackFilter(slice.filter); + if (filter.test(device)) { + const dot = m( + "svg", + { + width: "1em", + height: "1em", + xmlns: "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink" + }, + m("circle", { + cx: "0.5em", + cy: "0.5em", + r: "0.4em", + fill: slice.color + }) + ); + return m("span.overview-dot", dot, `${slice.label}`); + } + } + } +}; + +export default component; diff --git a/client/components/parameter-list.js b/client/components/parameter-list.js new file mode 100644 index 0000000..ee36952 --- /dev/null +++ b/client/components/parameter-list.js @@ -0,0 +1,27 @@ +"use strict"; + +import m from "mithril"; +import * as components from "../components"; + +const component = { + view: vnode => { + const device = vnode.attrs.device; + const parameters = vnode.attrs.parameters; + const filtered = Object.values(parameters).filter( + p => p.parameter in device + ); + const rows = filtered.map(p => + m( + "tr", + m("th", p.label), + m( + "td", + m(components.get("parameter"), Object.assign({ device: device }, p)) + ) + ) + ); + return m("table.parameter-list", rows); + } +}; + +export default component; diff --git a/client/components/parameter-table.js b/client/components/parameter-table.js new file mode 100644 index 0000000..cbdd0d8 --- /dev/null +++ b/client/components/parameter-table.js @@ -0,0 +1,42 @@ +"use strict"; + +import m from "mithril"; +import * as components from "../components"; + +const component = { + view: vnode => { + const device = vnode.attrs.device; + const object = vnode.attrs.object; + const parameters = vnode.attrs.parameters; + + if (!device[object]) return null; + + const instances = new Set(); + for (let p in device) + if (p.startsWith(object)) + instances.add(p.slice(0, p.indexOf(".", object.length + 1))); + + const thead = m( + "thead", + m("tr", Object.values(parameters).map(p => m("th", p.label))) + ); + const rows = []; + for (let i of instances) { + const row = Object.values(parameters).map(p => + m( + "td", + m( + components.get("parameter"), + Object.assign({ device: device }, p, { + parameter: `${i}.${p.parameter}` + }) + ) + ) + ); + rows.push(m("tr", row)); + } + return m("table.parameter-table", thead, m("tbody", rows)); + } +}; + +export default component; diff --git a/client/components/parameter.js b/client/components/parameter.js new file mode 100644 index 0000000..6c6d47d --- /dev/null +++ b/client/components/parameter.js @@ -0,0 +1,30 @@ +"use strict"; + +import m from "mithril"; +import * as components from "../components"; + +const component = { + view: vnode => { + const param = vnode.attrs.device[vnode.attrs.parameter]; + if (!param || !param.value) return m("span.na", "N/A"); + let value = param.value[0]; + if (param.value[1] === "xsd:dateTime") + value = new Date(value).toISOString(); + + let meta; + if (vnode.attrs.meta) { + const metaAttrs = Object.assign( + { device: vnode.attrs.device, parameter: vnode.attrs.parameter }, + vnode.attrs.meta + ); + meta = m("span", [ + " (", + m(components.get(metaAttrs.type), metaAttrs), + ")" + ]); + } + return [m("span", { title: param.valueTimestamp }, value), meta]; + } +}; + +export default component; diff --git a/client/config.js b/client/config.js index ba32429..48ec035 100644 --- a/client/config.js +++ b/client/config.js @@ -1,69 +1,40 @@ "use strict"; -export default { - pageSize: 10, - filter: [ - { - label: "Serial number", - parameter: "DeviceID.SerialNumber", - type: "string" - }, - { - label: "Product class", - parameter: "DeviceID.ProductClass", - type: "string" - }, - { - label: "Tag", - parameter: "tag", - type: "string" - } - ], - index: [ - { - label: "Serial number", - parameter: "DeviceID.SerialNumber" - }, - { - label: "Product class", - parameter: "DeviceID.ProductClass" - }, - { - label: "Software version", - parameter: "InternetGatewayDevice.DeviceInfo.SoftwareVersion" - }, - { - label: "MAC", - parameter: - "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress" - } - ], - overview: { - online: { - label: "Online devices", - charts: { - all: { - label: "All devices", - slices: { - onlineNow: { - label: "Online now", - color: "#31a354", - filter: "Events.Inform > NOW() - 5 * 60 * 1000" - }, - past24: { - label: "Past 24 hours", - color: "#addd8e", - filter: - "Events.Inform > (NOW() - 5 * 60 * 1000) - (24 * 60 * 60 * 1000) AND Events.Inform < (NOW() - 5 * 60 * 1000)" - }, - others: { - label: "Others", - color: "#f7fcb9", - filter: "Events.Inform < (NOW() - 5 * 60 * 1000) - (24 * 60 * 60 * 1000)" - } - } +import * as Filter from "../common/filter"; + +const configFile = window.clientConfig; + +const cache = {}; + +function parse(name, value) { + if (/^ui\.overview\.charts\.[^.]+\.slices\.[^.]+\.filter$/.test(name)) + return new Filter(value); + + return value; +} + +function get(name) { + let v = cache[name]; + if (v === undefined) { + v = configFile[name]; + if (v != null) { + v = parse(name, v); + } else { + const prefix = `${name}.`; + for (let k of Object.keys(configFile)) + if (k.startsWith(prefix)) { + v = v || {}; + let ar = k.slice(prefix.length).split("."); + let ref = v; + for (let a of ar.slice(0, -1)) ref = ref[a] = ref[a] || {}; + ref[ar[ar.length - 1]] = parse(k, configFile[k]); } - } } + if (v) cache[name] = v; + else cache[name] = null; } -}; + + return v; +} + +export { get }; diff --git a/client/css/app.css b/client/css/app.css index e381ac4..4371e91 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -244,6 +244,42 @@ button { } } +table.parameter-list { + text-align: left; + & th { + padding-right: 1em; + } +} + +table.parameter-table { + border-spacing: 0; + text-align: left; + + & th { + color: var(--color4); + border-bottom: 2px solid var(--color4); + margin: 0; + padding: 0.2em 0.5em; + } + + & td { + margin: 0; + padding: 0.2em 0.5em; + } +} + +.overview-dot { + & > svg { + margin-bottom: -0.125em; + margin-left: 0.2em; + margin-right: 0.2em; + & > circle { + stroke: var(--color5); + stroke-width: 0.08em; + } + } +} + .pie-chart { margin: 20px; text-align: center; diff --git a/client/device-page.js b/client/device-page.js new file mode 100644 index 0000000..b3de49c --- /dev/null +++ b/client/device-page.js @@ -0,0 +1,41 @@ +"use strict"; + +import m from "mithril"; + +import * as config from "./config"; +import Filter from "../common/filter"; +import * as store from "./store"; +import * as components from "./components"; + +function init(args) { + return new Promise(resolve => { + resolve({ + deviceId: args.id, + deviceFilter: new Filter(["=", "DeviceID.ID", args.id]) + }); + }); +} + +const component = { + view: vnode => { + document.title = `${vnode.attrs.deviceId} - Devices - GenieACS`; + + let dev = store.fetch("devices", vnode.attrs.deviceFilter).value; + if (!dev.length) return "Loading"; + const conf = config.get("ui.device"); + const cmps = []; + + for (let c of Object.values(conf)) + cmps.push( + m(components.get(c["type"]), Object.assign({ device: dev[0] }, c)) + ); + + return [ + m("h1", vnode.attrs.deviceId), + m("span", dev[0]["InternetGatewayDevice.DeviceInfo.SerialNumber"]), + cmps + ]; + } +}; + +export { init, component }; diff --git a/client/devices-page.js b/client/devices-page.js index 192f049..55bbfde 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -2,78 +2,50 @@ import m from "mithril"; -import config from "./config"; +import * as config from "./config"; import filterComponent from "./filter-component"; import * as filterParser from "../common/filter-parser"; - -function load(filter, limit) { - return new Promise((resolve, reject) => { - m - .request({ - url: "/api/devices/", - data: { filter: filter, limit: limit } - }) - .then(res => { - resolve(res); - }) - .catch(err => { - err.message = err.message || "Unknown error"; - reject(err); - }); - }); -} - -function count(filter, limit) { - return new Promise((resolve, reject) => { - function extract(xhr) { - return +xhr.getResponseHeader("x-total-count"); - } - m - .request({ - method: "HEAD", - url: - "/api/devices/?" + - m.buildQueryString({ filter: filter, limit: limit }), - extract: extract - }) - .then(res => { - resolve(res); - }) - .catch(err => { - err.message = err.message || "Unknown error"; - reject(err); - }); - }); -} +import Filter from "../common/filter"; +import * as store from "./store"; +import * as components from "./components"; function init(args) { - return new Promise((resolve, reject) => { - let filter; - if (args.filter) filter = filterParser.parse(args.filter); - - Promise.all([count(args.filter), load(args.filter, config.pageSize)]) - .then(res => { - resolve({ filter: filter, total: res[0], devices: res[1] }); - }) - .catch(reject); + return new Promise(resolve => { + let filter = new Filter(args.filter); + resolve({ filter: filter }); }); } function renderTable(devices, parameters, total, showMoreCallback) { let labels = []; for (let param of parameters) labels.push(m("th", param.label)); + labels.push(m("th")); let rows = []; for (let device of devices) rows.push( m( "tr", - parameters.map(p => { - if (!device[p.parameter] || !device[p.parameter].value) - return m("td.na"); - if (!device[p.parameter].value[0] === "") return m("td.blank"); - return m("td", device[p.parameter].value[0]); - }) + parameters + .map(p => { + const attrs = Object.assign({ device: device }, p); + const comp = m(components.get("parameter"), attrs); + return m("td", comp); + }) + .concat( + m( + "td.table-row-links", + m( + "a", + { + href: `#!/devices/${encodeURIComponent( + device["DeviceID.ID"].value[0] + )}` + }, + "Show" + ) + ) + ) ) ); @@ -84,7 +56,7 @@ function renderTable(devices, parameters, total, showMoreCallback) { let tfoot = m( "tfoot", - m("tr", m("td", { colspan: labels.length }, footerElements)) + m("tr", m("td", { colspan: labels.length + 1 }, footerElements)) ); return m("table.table", [ @@ -99,18 +71,8 @@ const component = { document.title = "Devices - GenieACS"; function showMore() { - const lastDevice = vnode.attrs.devices[vnode.attrs.devices.length - 1]; - let f = filterParser.and(vnode.attrs.filter, [ - ">", - "DeviceID.ID", - lastDevice["DeviceID.ID"].value[0] - ]); - - load(filterParser.stringify(f), config.pageSize).then(res => { - vnode.attrs.devices = vnode.attrs.devices.concat(res); - if (res.length < config.pageSize) - vnode.attrs.total = vnode.attrs.devices.length; - }); + vnode.state.showCount = (vnode.state.showCount || 10) + 10; + m.redraw(); } function onFilterChanged(filter) { @@ -119,16 +81,23 @@ const component = { m.route.set("/devices", params); } + let devs = store.fetch( + "devices", + vnode.attrs.filter, + vnode.state.showCount || 10 + ); + let count = store.count("devices", vnode.attrs.filter); + return [ m("h1", "Listing devices"), m(filterComponent, { - filter: vnode.attrs.filter, + filter: vnode.attrs.filter ? vnode.attrs.filter.ast : null, onChange: onFilterChanged }), renderTable( - vnode.attrs.devices, - config.index, - vnode.attrs.total, + devs.value, + Object.values(config.get("ui.index")), + count.value, showMore ) ]; diff --git a/client/filter-component.js b/client/filter-component.js index 220b721..f1555e5 100644 --- a/client/filter-component.js +++ b/client/filter-component.js @@ -2,18 +2,21 @@ import m from "mithril"; -import config from "./config"; +import * as config from "./config"; import * as filterParser from "../common/filter-parser"; const component = { view: vnode => { let a = m( "datalist#filters", - config.filter.map(f => m("option", { value: `${f.parameter} = ` })) + Object.values(config.get("ui.filters")).map(f => + m("option", { value: `${f.parameter} = ` }) + ) ); - if (!vnode.state.filterList) { + if (vnode.attrs.filter !== vnode.state.filter) { vnode.state.filterList = []; + vnode.state.filter = vnode.attrs.filter; if (vnode.attrs.filter) if (vnode.attrs.filter[0] === "AND") for (let i = 1; i < vnode.attrs.filter.length; ++i) @@ -32,16 +35,18 @@ const component = { vnode.state.filterList = vnode.state.filterList.filter( f => f && f.trim() ); - let filter = vnode.state.filterList.map((f, idx) => { + let filterAst = vnode.state.filterList.map((f, idx) => { try { return filterParser.parse(f); } catch (err) { vnode.state.filterInvalid |= 1 << idx; } }); + if (filterAst.length) filterAst = ["AND"].concat(filterAst); + else filterAst = null; + vnode.state.filterList.push(""); - if (!vnode.state.filterInvalid) - vnode.attrs.onChange(["AND"].concat(filter)); + if (!vnode.state.filterInvalid) vnode.attrs.onChange(filterAst); } return m( diff --git a/client/overview-page.js b/client/overview-page.js index 38a74f6..30378eb 100644 --- a/client/overview-page.js +++ b/client/overview-page.js @@ -1,93 +1,44 @@ "use strict"; import m from "mithril"; -import config from "./config"; +import * as config from "./config"; +import * as store from "./store"; import pieChartComponent from "./pie-chart-component"; -import * as filterParser from "../common/filter-parser"; -function count(filter, limit) { - return new Promise((resolve, reject) => { - function extract(xhr) { - return +xhr.getResponseHeader("x-total-count"); - } - m - .request({ - method: "HEAD", - url: - "/api/devices/?" + - m.buildQueryString({ filter: filter, limit: limit }), - extract: extract - }) - .then(res => { - resolve(res); - }) - .catch(err => { - err.message = err.message || "Unknown error"; - reject(err); - }); - }); -} +const GROUPS = config.get("ui.overview.groups"); +const CHARTS = {}; +for (let group of Object.values(GROUPS)) + for (let chartName of Object.values(group["charts"])) + CHARTS[chartName] = config.get("ui.overview.charts")[chartName]; -function queryCharts(groups) { - const now = Date.now(); - function evalNow(exp) { - if (exp[0] == "FUNC" && exp[1] === "NOW" && exp.length == 2) return now; - } - - let calls = []; - groups = Object.assign({}, groups); - for (let [groupName, group] of Object.entries(groups)) { - groups[groupName] = group = Object.assign({}, group); - for (let [chartName, chart] of Object.entries(group.charts)) { - group.charts[chartName] = chart = Object.assign({}, chart); - for (let [sliceName, slice] of Object.entries(chart.slices)) { - chart.slices[sliceName] = slice = Object.assign({}, slice); - calls.push( - new Promise((resolve, reject) => { - let f = filterParser.map(filterParser.parse(slice.filter), evalNow); - count(filterParser.stringify(f)) - .then(c => { - slice.count = c; - resolve(); - }) - .catch(reject); - }) - ); - } +function queryCharts(charts) { + charts = Object.assign({}, charts); + for (let [chartName, chart] of Object.entries(charts)) { + charts[chartName] = chart = Object.assign({}, chart); + for (let [sliceName, slice] of Object.entries(chart.slices)) { + chart.slices[sliceName] = slice = Object.assign({}, slice); + slice.count = store.count("devices", slice.filter); } } - - return new Promise((resolve, reject) => { - Promise.all(calls) - .then(() => { - resolve(groups); - }) - .catch(reject); - }); + return charts; } const init = function() { - return new Promise((resolve, reject) => { - queryCharts(config.overview) - .then(charts => { - resolve({ charts: charts }); - }) - .catch(err => { - err.message = err.message || "Unknown error"; - reject(err); - }); + return new Promise(resolve => { + resolve({ charts: queryCharts(CHARTS) }); }); }; const component = { - view: vnode => { + view: () => { document.title = "Overview - GenieACS"; let children = []; - for (let group of Object.values(vnode.attrs.charts)) { + for (let group of Object.values(GROUPS)) { if (group.label) children.push(m("h1", group.label)); let groupChildren = []; - for (let chart of Object.values(group.charts)) { + for (let chartName of Object.values(group.charts)) { + const chart = CHARTS[chartName]; let chartChildren = []; if (chart.label) chartChildren.push(m("h2", chart.label)); diff --git a/client/pie-chart-component.js b/client/pie-chart-component.js index f21d511..025fd07 100644 --- a/client/pie-chart-component.js +++ b/client/pie-chart-component.js @@ -5,7 +5,7 @@ import m from "mithril"; const drawChart = function(chartData) { let slices = chartData.slices; let total = Array.from(Object.values(chartData.slices)).reduce( - (a, s) => a + s.count, + (a, s) => a + (s.count.value || 0), 0 ); let legend = []; @@ -17,7 +17,7 @@ const drawChart = function(chartData) { let endX, endY; for (let slice of Object.values(slices)) { - let percent = total > 0 ? slice.count / total : 0; + let percent = total > 0 ? (slice.count.value || 0) / total : 0; legend.push( m(".legend-line", [ m("span.color", { @@ -28,10 +28,10 @@ const drawChart = function(chartData) { "a", { href: `/#!/devices/?${m.buildQueryString({ - filter: slice.filter + filter: slice.filter.toString() })}` }, - slice.count + slice.count.value || 0 ), ` (${(percent * 100).toFixed(2)}%)` ]) @@ -68,7 +68,7 @@ const drawChart = function(chartData) { "a", { href: `/#!/devices/?${m.buildQueryString({ - filter: slice.filter + filter: slice.filter.toString() })}` }, [ diff --git a/client/store.js b/client/store.js new file mode 100644 index 0000000..991ec19 --- /dev/null +++ b/client/store.js @@ -0,0 +1,307 @@ +"use strict"; + +import m from "mithril"; +import Filter from "../common/filter"; +import * as config from "./config"; + +let fulfillTimestamp = 0; + +let unpackedFilters = new WeakMap(); + +const queries = { + filter: new WeakMap(), + last: new WeakMap(), + limit: new WeakMap(), + fulfilled: new WeakMap(), + fulfilling: new WeakSet(), + mapper: new WeakMap(), + accessed: new WeakMap(), + value: new WeakMap() +}; + +const resources = {}; +for (let r of ["devices"]) + resources[r] = { + objects: new Map(), + count: new Map(), + fetch: new Map(), + combinedFilter: null + }; + +class QueryResponse { + get fulfilled() { + queries.accessed.set(this, Date.now()); + return !!queries.fulfilled.get(this); + } + + get value() { + queries.accessed.set(this, Date.now()); + const mapper = queries.mapper.get(this); + const value = queries.value.get(this); + + if (mapper) return mapper(queries.value.get(this)); + else return value; + } +} + +function unpackFilter(filter) { + let f = unpackedFilters.get(filter); + if (!f) { + f = f.evaluateExpressions(fulfillTimestamp); + unpackedFilters.set(filter, f); + } + return f; +} + +function count(resourceType, filter) { + let queryResponse = resources[resourceType].count.get(filter.toString()); + if (queryResponse) return queryResponse; + + queryResponse = new QueryResponse(); + + resources[resourceType].count.set(filter.toString(), queryResponse); + queries.filter.set(queryResponse, filter); + return queryResponse; +} + +function limitFilter(resourceType, filter, last) { + if (resourceType === "devices") + return filter.and(new Filter(["<=", "DeviceID.ID", last])); + else return filter.and(new Filter(["<=", "_id", last])); +} + +function findMatches(resourceType, filter, limit) { + let value = []; + for (let [id, obj] of resources[resourceType].objects.entries()) + if (filter.test(obj)) value.push(id); + + value = value.sort(); + if (limit) value = value.slice(0, limit); + + return value; +} + +function inferQuery(resourceType, queryResponse) { + const limit = queries.limit.get(queryResponse); + let filter = queries.filter.get(queryResponse); + filter = unpackFilter(filter); + let last = queries.last.get(queryResponse); + if (last || !limit) { + if (last) filter = limitFilter(resourceType, filter, last); + if ( + resources[resourceType].combinedFilter && + filter.subset(resources[resourceType].combinedFilter) + ) + queries.fulfilled.set(queryResponse, fulfillTimestamp); + } + + queries.value.set(queryResponse, findMatches(resourceType, filter, limit)); +} + +function fetch(resourceType, filter, limit = 0) { + const key = `${limit}:${filter.toString()}`; + let queryResponse = resources[resourceType].fetch.get(key); + if (queryResponse) return queryResponse; + + queryResponse = new QueryResponse(); + resources[resourceType].fetch.set(key, queryResponse); + queries.filter.set(queryResponse, filter); + queries.limit.set(queryResponse, limit); + queries.mapper.set(queryResponse, list => + list.map(x => resources[resourceType].objects.get(x)) + ); + inferQuery(resourceType, queryResponse); + return queryResponse; +} + +function fulfill(accessTimestamp, _fulfillTimestamp) { + let updated = false; + + if (_fulfillTimestamp > fulfillTimestamp) { + unpackedFilters = new WeakMap(); + for (let resource of Object.values(resources)) + resource.combinedFilter = null; + fulfillTimestamp = _fulfillTimestamp; + } + + const allPromises = []; + + for (let [resourceType, resource] of Object.entries(resources)) + for (let queryResponse of resource.count.values()) { + if (queries.fulfilling.has(queryResponse)) continue; + if ( + queries.accessed.get(queryResponse) >= accessTimestamp && + !(fulfillTimestamp <= queries.fulfilled.get(queryResponse)) + ) { + queries.fulfilling.add(queryResponse); + allPromises.push( + new Promise((resolve, reject) => { + updated = true; + let filter = queries.filter.get(queryResponse); + filter = unpackFilter(filter); + m + .request({ + method: "HEAD", + url: + `/api/${resourceType}/?` + + m.buildQueryString({ + filter: filter.toString() + }), + extract: xhr => +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)); + }) + ); + } + } + + let toFetchAll = {}; + + for (let [resourceType, resource] of Object.entries(resources)) + for (let queryResponse of resource.fetch.values()) { + if (queries.fulfilling.has(queryResponse)) continue; + + if ( + queries.accessed.get(queryResponse) >= accessTimestamp && + !(fulfillTimestamp <= queries.fulfilled.get(queryResponse)) + ) { + queries.fulfilling.add(queryResponse); + toFetchAll[resourceType] = toFetchAll[resourceType] || []; + toFetchAll[resourceType].push(queryResponse); + const limit = queries.limit.get(queryResponse); + if (limit) + allPromises.push( + new Promise((resolve, reject) => { + updated = true; + let filter = queries.filter.get(queryResponse); + filter = unpackFilter(filter); + m + .request({ + method: "GET", + url: + `/api/${resourceType}/?` + + m.buildQueryString({ + filter: filter.toString(), + limit: 1, + skip: limit - 1, + projection: "_id" + }) + }) + .then(res => { + if (res.length) + if (resourceType === "devices") + queries.last.set( + queryResponse, + res[0]["DeviceID.ID"].value[0] + ); + else queries.last.set(queryResponse, res[0]["_id"]); + else queries.last.delete(queryResponse); + resolve(); + }) + .catch(reject); + }) + ); + } + } + + return new Promise((resolve, reject) => { + Promise.all(allPromises) + .then(() => { + const allPromises2 = []; + for (let [resourceType, toFetch] of Object.entries(toFetchAll)) { + let combinedFilter = null; + + toFetch = toFetch.filter(queryResponse => { + let filter = queries.filter.get(queryResponse); + filter = unpackFilter(filter); + let last = queries.last.get(queryResponse); + if (last) filter = limitFilter(resourceType, filter, last); + + if ( + resources[resourceType].combinedFilter && + filter.subset(resources[resourceType].combinedFilter) + ) { + queries.value.set( + queryResponse, + findMatches(resourceType, filter) + ); + queries.fulfilled.set(queryResponse, fulfillTimestamp); + queries.fulfilling.delete(queryResponse); + return false; + } + + combinedFilter = filter.or(combinedFilter); + return true; + }); + + if (!combinedFilter) return resolve(updated); + + updated = true; + let combinedFilterDiff = combinedFilter; + if (resources[resourceType].combinedFilter) + combinedFilterDiff = combinedFilterDiff.and( + resources[resourceType].combinedFilter.not() + ); + resources[resourceType].combinedFilter = combinedFilter.or( + resources[resourceType].combinedFilter + ); + + allPromises2.push( + new Promise((resolve2, reject2) => { + m + .request({ + method: "GET", + url: + `/api/${resourceType}/?` + + m.buildQueryString({ + filter: combinedFilterDiff.toString() + }) + }) + .then(res => { + for (let r of res) + if (resourceType === "devices") + resources.devices.objects.set( + r["DeviceID.ID"].value[0], + r + ); + else resources[resourceType].objects.set(r["_id"], r); + + for (let queryResponse of toFetch) { + let filter = queries.filter.get(queryResponse); + filter = unpackFilter(filter); + let last = queries.last.get(queryResponse); + if (last) filter = limitFilter(resourceType, filter, last); + + queries.value.set( + queryResponse, + findMatches(resourceType, filter) + ); + queries.fulfilled.set(queryResponse, fulfillTimestamp); + queries.fulfilling.delete(queryResponse); + } + resolve2(); + }) + .catch(reject2); + }) + ); + } + Promise.all(allPromises2) + .then(() => resolve(updated)) + .catch(reject); + }) + .catch(reject); + }); +} + +function getTimestamp() { + return fulfillTimestamp; +} + +export { count, fetch, fulfill, unpackFilter, getTimestamp }; diff --git a/common/filter-cnf.js b/common/filter-cnf.js new file mode 100644 index 0000000..20da585 --- /dev/null +++ b/common/filter-cnf.js @@ -0,0 +1,138 @@ +"use strict"; + +function collectRanges(filter, ranges = new Map()) { + if (["AND", "OR", "NOT"].includes(filter[0])) { + for (let i = 1; i < filter.length; ++i) collectRanges(filter[i], ranges); + } else if ([">", ">=", "<", "<=", "=", "<>"].includes(filter[0])) { + const key = `${filter[1]}:${typeof filter[2]}`; + let s = ranges.get(key); + if (!s) ranges.set(key, (s = new Set())); + s.add(filter[2]); + } + return ranges; +} + +function or(cnf1, cnf2) { + if (cnf1.length === 0) return cnf2; + else if (cnf2.length === 0) return cnf1; + + const res = []; + for (let i = 0; i < cnf1.length; ++i) + for (let j = i; j < cnf2.length; ++j) res.push(cnf1[i].concat(cnf2[j])); + + return res; +} + +function booleanCnf(filter) { + const ranges = collectRanges(filter); + for (let [k, v] of ranges) + if (k.endsWith(":number")) + ranges.set(k, Array.from(v).sort((a, b) => a - b)); + else ranges.set(k, Array.from(v).sort()); + + const expressions = new Map(); + const mutuallyExclusive = new Map(); + + function getIsNullVariable(param) { + const key = `${param}:null`; + let f = expressions.get(key); + if (!f) { + expressions.set(key, (f = expressions.size + 1)); + let m = mutuallyExclusive.get(param); + if (!m) mutuallyExclusive.set(param, (m = new Set())); + m.add(f); + } + return f; + } + + function getComparisonVariables(param, from, fromInclusive, to, toInclusive) { + const type = from != null ? typeof from : typeof to; + const range = ranges.get(`${param}:${type}`); + const vars = []; + + if (from == null) vars.push(`${param}:--${range[0]}`); + if (to == null) vars.push(`${param}:${range[range.length - 1]}--`); + if (fromInclusive) vars.push(`${param}:${from}`); + if (toInclusive) vars.push(`${param}:${to}`); + + for (let i = 1; i < range.length; ++i) + if ((from == null || range[i] > from) && (to == null || range[i] <= to)) { + vars.push(`${param}:${range[i - 1]}`); + vars.push(`${param}:${range[i]}`); + vars.push(`${param}:${range[i - 1]}--${range[i]}`); + } + + return vars.map(key => { + let f = expressions.get(key); + if (!f) { + expressions.set(key, (f = expressions.size + 1)); + let m = mutuallyExclusive.get(param); + if (!m) mutuallyExclusive.set(param, (m = new Set())); + m.add(f); + } + return f; + }); + } + + const comparisonOperators = { + "=": v => [v, true, v, true], + ">": v => [v, false, null, null], + ">=": v => [v, true, null, null], + "<": v => [null, null, v, false], + "<=": v => [null, null, v, true] + }; + + function recursive(clause, negate) { + const op = clause[0]; + + if ((op === "AND" && !negate) || (op === "OR" && negate)) { + let res = []; + for (let i = 1; i < clause.length; ++i) + res = res.concat(recursive(clause[i], negate)); + return res; + } else if ((op === "OR" && !negate) || (op === "AND" && negate)) { + let res = []; + for (let i = 1; i < clause.length; ++i) + res = or(res, recursive(clause[i], negate)); + return res; + } else if (op === "NOT") { + return recursive(clause[1], !negate); + } else if (op in comparisonOperators) { + const vars = getComparisonVariables( + clause[1], + ...comparisonOperators[op](clause[2]) + ); + if (negate) return vars.map(x => [0 - x]); + else return [vars]; + } else if (op === "<>") { + const vars = getComparisonVariables( + clause[1], + ...comparisonOperators["="](clause[2]) + ); + if (!negate) return vars.map(x => [0 - x]); + else return [vars]; + } else if (op === "IS NULL") { + const v = getIsNullVariable(clause[1]); + if (negate) return [[0 - v]]; + else return [[v]]; + } else if (op === "IS NOT NULL") { + const v = getIsNullVariable(clause[1]); + if (!negate) return [[0 - v]]; + else return [[v]]; + } else { + throw new Error(`Unknown operator ${op}`); + } + } + + const cnf = recursive(filter, false); + + for (let m of mutuallyExclusive.values()) { + let ar = Array.from(m); + for (let i = 0; i < ar.length; ++i) + for (let j = i + 1; j < ar.length; ++j) cnf.push([0 - ar[i], 0 - ar[j]]); + } + + return { vars: expressions.size, clauses: cnf }; +} + +exports.booleanCnf = booleanCnf; diff --git a/common/filter-parser.js b/common/filter-parser.js index 227436e..7be373e 100644 --- a/common/filter-parser.js +++ b/common/filter-parser.js @@ -205,10 +205,6 @@ function parseExpression(exp) { return lang.ValueExpression.tryParse(exp); } -function parseParameter(p) { - return lang.Parameter.tryParse(p); -} - function stringify(filter) { function value(v) { if (Array.isArray(v)) @@ -271,54 +267,13 @@ function map(filter, callback) { } } - return callback(clone || filter) || clone || filter; -} + let r = callback(clone || filter); + if (r != null) return r; -function and(filter1, filter2) { - if (!filter1) return filter2; - else if (!filter2) return filter1; - - let res = ["AND"]; - - if (filter1[0] === "AND") res = res.concat(filter1.slice(1)); - else res.push(filter1); - - if (filter2[0] === "AND") res = res.concat(filter2.slice(1)); - else res.push(filter2); - - return res; -} - -function evaluateExpressions(filter, now = Date.now()) { - return map(filter, exp => { - if (exp[0] === "FUNC" && exp[1] === "NOW") { - return now; - } else if (exp[0] === "*") { - let v = exp[1]; - for (let i = 2; i < exp.length; ++i) v *= exp[i]; - return v; - } else if (exp[0] === "/") { - let v = exp[1]; - for (let i = 2; i < exp.length; ++i) v /= exp[i]; - return v; - } else if (exp[0] === "+") { - let v = exp[1]; - for (let i = 2; i < exp.length; ++i) v += exp[i]; - return v; - } else if (exp[0] === "-") { - let v = exp[1]; - for (let i = 2; i < exp.length; ++i) v -= exp[i]; - return v; - } else if (exp[0] === "||") { - return exp.slice(1).join(""); - } - }); + return clone || filter; } exports.parse = parse; exports.parseExpression = parseExpression; -exports.parseParameter = parseParameter; exports.stringify = stringify; exports.map = map; -exports.and = and; -exports.evaluateExpressions = evaluateExpressions; diff --git a/common/filter.js b/common/filter.js new file mode 100644 index 0000000..c4afc6c --- /dev/null +++ b/common/filter.js @@ -0,0 +1,157 @@ +"use strict"; + +const filterParser = require("./filter-parser"); +const filterCnf = require("./filter-cnf"); +const satSolver = require("./sat-solver"); + +function* permute(arr) { + if (arr.length <= 1) { + for (let i = 0; i < arr[0]; ++i) yield [i]; + return; + } + + let slc = arr.slice(1); + for (let i = 0; i < arr[0]; ++i) + for (let innerArr of permute(slc)) yield [i].concat(innerArr); +} + +class Filter { + constructor(str) { + if (Array.isArray(str)) this.ast = str; + else if (str instanceof Filter) this.ast = str.ast; + else if (!str) this.ast = null; + else this.ast = filterParser.parse(str); + } + + toString() { + if (!this.string) + if (this.ast) this.string = filterParser.stringify(this.ast); + else this.string = ""; + + return this.string; + } + + or(fltr) { + if (!fltr || !this.ast) return this; + + let f = ["OR"]; + + if (this.ast[0] === "OR") f = f.concat(this.ast.slice(1)); + else f.push(this.ast); + + if (fltr.ast[0] === "OR") f = f.concat(fltr.ast.slice(1)); + else f.push(fltr.ast); + + return new Filter(f); + } + + and(fltr) { + if (!fltr) return this; + else if (!this.ast) return fltr; + + let f = ["AND"]; + + if (this.ast[0] === "AND") f = f.concat(this.ast.slice(1)); + else f.push(this.ast); + + if (fltr.ast[0] === "AND") f = f.concat(fltr.ast.slice(1)); + else f.push(fltr.ast); + + return new Filter(f); + } + + not() { + if (this.ast[0] === "NOT") return new Filter(this.ast[1]); + else return new Filter(["NOT", this.ast]); + } + + evaluateExpressions(now = Date.now()) { + if (!this.ast) return this; + let ast = filterParser.map(this.ast, exp => { + if (exp[0] === "FUNC" && exp[1] === "NOW") { + return now; + } else if (exp[0] === "*") { + let v = exp[1]; + for (let i = 2; i < exp.length; ++i) v *= exp[i]; + return v; + } else if (exp[0] === "/") { + let v = exp[1]; + for (let i = 2; i < exp.length; ++i) v /= exp[i]; + return v; + } else if (exp[0] === "+") { + let v = exp[1]; + for (let i = 2; i < exp.length; ++i) v += exp[i]; + return v; + } else if (exp[0] === "-") { + let v = exp[1]; + for (let i = 2; i < exp.length; ++i) v -= exp[i]; + return v; + } else if (exp[0] === "||") { + return exp.slice(1).join(""); + } + }); + + if (ast === this.ast) return this; + else return new Filter(ast); + } + + subset(fltr) { + if (!fltr.ast) return true; + else if (!this.ast) return false; + + const f = this.not() + .or(fltr) + .not(); + const { vars, clauses } = filterCnf.booleanCnf(f.ast); + return !satSolver.naiveDpll(clauses, vars); + } + + test(obj) { + if (!this.ast) return true; + return filterParser.map(this.ast, exp => { + const op = exp[0]; + + if (op === "AND") { + for (let i = 1; i < exp.length; ++i) if (!exp[i]) return false; + return true; + } else if (op === "OR") { + for (let i = 1; i < exp.length; ++i) if (exp[i]) return true; + return false; + } else if (op === "NOT") { + return !exp[1]; + } else if (op === "=") { + const val = + typeof obj[exp[1]] === "object" ? obj[exp[1]].value[0] : obj[exp[1]]; + return val === exp[2]; + } else if (op === "<>") { + const val = + typeof obj[exp[1]] === "object" ? obj[exp[1]].value[0] : obj[exp[1]]; + return val !== exp[2]; + } else if (op === ">=") { + const val = + typeof obj[exp[1]] === "object" ? obj[exp[1]].value[0] : obj[exp[1]]; + return typeof val === typeof exp[2] && val >= exp[2]; + } else if (op === ">") { + const val = + typeof obj[exp[1]] === "object" ? obj[exp[1]].value[0] : obj[exp[1]]; + return typeof val === typeof exp[2] && val > exp[2]; + } else if (op === "<=") { + const val = + typeof obj[exp[1]] === "object" ? obj[exp[1]].value[0] : obj[exp[1]]; + return typeof val === typeof exp[2] && val <= exp[2]; + } else if (op === "<") { + const val = + typeof obj[exp[1]] === "object" ? obj[exp[1]].value[0] : obj[exp[1]]; + return typeof val === typeof exp[2] && val < exp[2]; + } else if (op === "IS NULL") { + return obj[exp[1]] == null; + } else if (op === "IS NOT NULL") { + return obj[exp[1]] != null; + } else { + throw new Error(`Unsupported operator ${op}`); + } + }); + } +} + +module.exports = Filter; diff --git a/common/sat-solver.js b/common/sat-solver.js new file mode 100644 index 0000000..815c400 --- /dev/null +++ b/common/sat-solver.js @@ -0,0 +1,175 @@ +/** + * Naive DPLL solver for propositional logic adapted from + * https://github.com/tammet/logictools + * + * Origianl code by Tanel Tammet released under the MIT license. + * + * Check whether a clause set is satisfiable or contradictory, using a naive + * implementation of the dpll (Davis-Putnam-Logemann-Loveland) procedure + * combining truth table search with the resolution-like unit propagation. + * + * The standard pure-literal rule is not implemented in this naive version, + * since it is not really crucial for the algorithm. + * + * The code is very similar to the pure table solver, except we use a + * unit-propagating, derived-varlist-returing satisfiableAt. + */ +"use strict"; + +function satisfiableAt(clauses, varVals, varnr, val) { + if (varnr !== 0) varVals[varnr] = val; + + const propRes = unitPropagate(clauses, varVals); + if (propRes === 1) { + varVals[varnr] = 0; + return true; + } + + // if result false (-1) unitPropagate restores the varVals state itself + if (propRes === -1) { + varVals[varnr] = 0; + return false; + } + + // find next unassigned var to split + let nextVar = 0; + for (let i = 1; i < varVals.length; i++) + if (varVals[i] === 0) { + nextVar = i; + break; + } + + if (nextVar === 0) throw new Error("Error in satisfiableAt"); + + if ( + satisfiableAt(clauses, varVals, nextVar, 1) || + satisfiableAt(clauses, varVals, nextVar, -1) + ) + // not necessary to restore split var: search is halted anyway + return true; + + // restore split var + varVals[nextVar] = 0; + + // restore derived vars + for (let i = 0; i < propRes.length; i++) varVals[propRes[i]] = 0; + + return false; +} + +/** + * Check whether clauses are satisfiable under the varVals assignment, without + * search. + * + * Iterate through all clauses and check whether + * + * - all literals in the clause are assigned false: undo derived units (set + * values in varVals to 0) and return false immediately + * + * - a clause contains a literal assigned true: skip further actions on this + * clause + * + * - a single literal in the clause is unassigned, all the rest are assigned + * false: derive this literal as a unit clause. + * + * In case at least one literal is derived during the process above, iterate + * the whole process until no more new literals are derived. + * + * Returns: + * + * 1: all clauses were assigned true, hence the clause set is satisfiable. + * The whole search will terminate after that. + * + * -1: clause set is contradictory. Derived units have been restored. + * + * a list of derived variables: the status of the clause set is + * undetermined. The values of variables on the list will be restored (set to + * 0) during backtracking. + */ +function unitPropagate(clauses, varVals) { + // all vars with a value derived in the forthcoming main loop + const derivedVars = []; + + // the main loop is run, deriving new unit clauses, until either: + // - the clause set is detected to be unsatisfiable + // - the clause set is detected to be satisfiable + // - there are no more derived unit clauses + + for (;;) { + let allClausesFound = true; // set to false if we find an undetermined clause + const derivedQueue = []; // derived units (literals) during one iteration + for (let i = 0; i < clauses.length; i++) { + const clause = clauses[i]; + let clauseVal = 0; + let unassignedCount = 0; // count unassigned vars in clause + let unassignedLit = 0; // 0 means none found for this clause + + for (let j = 0; j < clause.length; j++) { + let lit = clause[j]; + let nr, polarity; + if (lit < 0) { + nr = 0 - lit; + polarity = -1; + } else { + nr = lit; + polarity = 1; + } + + if (varVals[nr] === polarity) { + clauseVal = 1; + break; + } else if (varVals[nr] === 0) { + unassignedCount++; + unassignedLit = lit; + } + } + + if (clauseVal === 1) continue; // clause is subsumed + + // clause is not subsumed by varVals + if (unassignedCount === 0) { + for (let j = 0; j < derivedVars.length; j++) + varVals[derivedVars[j]] = 0; // restore derived + return -1; + } else if (unassignedCount === 1) { + // unassignedLit is a derived literal + derivedQueue.push(unassignedLit); + } + + if (unassignedCount !== 0) allClausesFound = false; + } + + // if all clauses were subsumed or all the variables in a clause had a value + // no need to restore varVals: whole search finished + if (allClausesFound) return 1; + + // in case there are any derived units present, store to varVals and + // iterate the main loop + if (derivedQueue.length === 0) break; + + // some units were derived: iterate the main loop + for (let j = 0; j < derivedQueue.length; j++) { + let lit = derivedQueue[j]; + let nr, polarity; + if (lit < 0) { + nr = 0 - lit; + polarity = -1; + } else { + nr = lit; + polarity = 1; + } + varVals[nr] = polarity; + derivedVars.push(nr); + } + } + + return derivedVars; +} + +function naiveDpll(clauses, maxvarnr) { + // variable values are 0 if not set, 1 if positive, -1 if negative + const varVals = new Int32Array(maxvarnr + 1); + return satisfiableAt(clauses, varVals, 0, 0); +} + +exports.naiveDpll = naiveDpll; diff --git a/config.json b/config.json new file mode 100644 index 0000000..6759c01 --- /dev/null +++ b/config.json @@ -0,0 +1,82 @@ +{ + "server.port": 3000, + "server.nbi": "http://127.0.0.1:7557/", + "ui.pageSize": 10, + "ui.filters.1.label": "Serial number", + "ui.filters.1.parameter": "DeviceID.SerialNumber", + "ui.filters.1.type": "string", + "ui.filters.2.label": "Product class", + "ui.filters.2.parameter": "DeviceID.ProductClass", + "ui.filters.2.type": "string", + "ui.filters.3.label": "Tag", + "ui.filters.3.parameter": "tag", + "ui.filters.3.type": "string", + "ui.index.1.label": "Serial number", + "ui.index.1.parameter": "DeviceID.SerialNumber", + "ui.index.2.label": "Product class", + "ui.index.2.parameter": "DeviceID.ProductClass", + "ui.index.3.label": "Software version", + "ui.index.3.parameter": "InternetGatewayDevice.DeviceInfo.SoftwareVersion", + "ui.index.4.label": "MAC", + "ui.index.4.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress", + "ui.index.4.label": "IP", + "ui.index.4.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress", + "ui.index.5.label": "SSID", + "ui.index.5.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", + "ui.index.6.label": "Last inform", + "ui.index.6.parameter": "Events.Inform", + "ui.index.6.meta.type": "overview-dot", + "ui.index.6.meta.chart": "online", + "ui.overview.groups.online.label": "", + "ui.overview.groups.online.charts.1": "online", + "ui.overview.charts.online.label": "Online status", + "ui.overview.charts.online.slices.onlineNow.label": "Online now", + "ui.overview.charts.online.slices.onlineNow.color": "#31a354", + "ui.overview.charts.online.slices.onlineNow.filter": "Events.Inform > NOW() - 5 * 60 * 1000", + "ui.overview.charts.online.slices.past24.label": "Past 24 hours", + "ui.overview.charts.online.slices.past24.color": "#addd8e", + "ui.overview.charts.online.slices.past24.filter": "Events.Inform > (NOW() - 5 * 60 * 1000) - (24 * 60 * 60 * 1000) AND Events.Inform < (NOW() - 5 * 60 * 1000)", + "ui.overview.charts.online.slices.others.label": "Others", + "ui.overview.charts.online.slices.others.color": "#f7fcb9", + "ui.overview.charts.online.slices.others.filter": "Events.Inform < (NOW() - 5 * 60 * 1000) - (24 * 60 * 60 * 1000)", + "ui.device.1.type": "parameter-list", + "ui.device.1.parameters.1.label": "Last inform", + "ui.device.1.parameters.1.parameter": "Events.Inform", + "ui.device.1.parameters.1.meta.type": "overview-dot", + "ui.device.1.parameters.1.meta.chart": "online", + "ui.device.1.parameters.2.label": "Last boot", + "ui.device.1.parameters.2.parameter": "Events.1_BOOT", + "ui.device.1.parameters.3.label": "Last bootstrap", + "ui.device.1.parameters.3.parameter": "Events.0_BOOTSTRAP", + "ui.device.1.parameters.4.label": "Registered", + "ui.device.1.parameters.4.parameter": "Events.Registered", + "ui.device.2.type": "parameter-list", + "ui.device.2.parameters.1.label": "Serial number", + "ui.device.2.parameters.1.parameter": "InternetGatewayDevice.DeviceInfo.SerialNumber", + "ui.device.2.parameters.2.label": "Product class", + "ui.device.2.parameters.2.parameter": "InternetGatewayDevice.DeviceInfo.ProductClass", + "ui.device.2.parameters.3.label": "OUI", + "ui.device.2.parameters.3.parameter": "InternetGatewayDevice.DeviceInfo.ManufacturerOUI", + "ui.device.2.parameters.4.label": "Manufacturer", + "ui.device.2.parameters.4.parameter": "InternetGatewayDevice.DeviceInfo.Manufacturer", + "ui.device.2.parameters.5.label": "Hardware version", + "ui.device.2.parameters.5.parameter": "InternetGatewayDevice.DeviceInfo.HardwareVersion", + "ui.device.2.parameters.6.label": "Software version", + "ui.device.2.parameters.6.parameter": "InternetGatewayDevice.DeviceInfo.SoftwareVersion", + "ui.device.2.parameters.7.label": "MAC", + "ui.device.2.parameters.7.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress", + "ui.device.2.parameters.8.label": "IP", + "ui.device.2.parameters.8.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress", + "ui.device.2.parameters.8.label": "WLAN SSID", + "ui.device.2.parameters.8.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", + "ui.device.2.parameters.9.label": "WLAN passphrase", + "ui.device.2.parameters.9.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase", + "ui.device.3.type": "parameter-table", + "ui.device.3.object": "InternetGatewayDevice.LANDevice.1.Hosts.Host", + "ui.device.3.parameters.1.label": "Host name", + "ui.device.3.parameters.1.parameter": "HostName", + "ui.device.3.parameters.2.label": "IP address", + "ui.device.3.parameters.2.parameter": "IPAddress", + "ui.device.3.parameters.3.label": "MAC address", + "ui.device.3.parameters.3.parameter": "MACAddress" +} diff --git a/package.json b/package.json index c175433..c0c4b8b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "lint": "eslint --fix common/*.js server/*.js client/*.js;prettier --write common/*.js server/*.js client/*.js client/css/*.css", + "lint": "eslint --fix common/*.js server/*.js client/*.js client/components/*.js;prettier --write common/*.js server/*.js client/*.js client/components/*.js client/css/*.css", "build": "npm run lint;webpack;postcss -c postcss.config.js client/css/app.css -o public/app.css", "start": "node server/server" }, diff --git a/public/index.html b/public/index.html deleted file mode 100644 index cc4fe7c..0000000 --- a/public/index.html +++ /dev/null @@ -1,10 +0,0 @@ - - - GenieACS - - - - - - - diff --git a/server/api-functions.js b/server/api-functions.js index 7549ef0..e4b60e2 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -80,11 +80,10 @@ function count(resource, filter, limit) { return new Promise((resolve, reject) => { let qs = {}; if (filter) { - let f = filterParser.parse(filter); - f = filterParser.evaluateExpressions(f); - f = unpackTimestamps(f); + filter = filter.evaluateExpressions(); + let ast = unpackTimestamps(filter.ast); - let q = filterToQuery(f); + let q = filterToQuery(ast); if (resource === "devices") q = device.transposeQuery(q); qs.query = JSON.stringify(q); } @@ -92,7 +91,7 @@ function count(resource, filter, limit) { if (limit) qs.limit = limit; let options = url.parse( - `${config.GENIEACS_NBI}${resource}?${querystring.stringify(qs)}` + `${config.get("server.nbi")}${resource}?${querystring.stringify(qs)}` ); options.method = "HEAD"; @@ -112,26 +111,28 @@ function count(resource, filter, limit) { }); } -function query(resource, filter, limit, callback) { +function query(resource, filter, limit, skip, projection, callback) { return new Promise((resolve, reject) => { let ret; if (!callback) ret = []; let qs = {}; if (filter) { - let f = filterParser.parse(filter); - f = filterParser.evaluateExpressions(f); - f = unpackTimestamps(f); + filter = filter.evaluateExpressions(); + let ast = unpackTimestamps(filter.ast); - let q = filterToQuery(f); + let q = filterToQuery(ast); if (resource === "devices") q = device.transposeQuery(q); qs.query = JSON.stringify(q); } if (limit) qs.limit = limit; + if (skip) qs.skip = skip; + if (projection) qs.projection = projection; + qs.sort = JSON.stringify({ _id: 1 }); let options = url.parse( - `${config.GENIEACS_NBI}${resource}?${querystring.stringify(qs)}` + `${config.get("server.nbi")}${resource}?${querystring.stringify(qs)}` ); let _http = options.protocol === "https:" ? https : http; diff --git a/server/api.js b/server/api.js index 3bf2645..890c025 100644 --- a/server/api.js +++ b/server/api.js @@ -3,6 +3,7 @@ const Router = require("koa-router"); const apiFunctions = require("./api-functions"); +const Filter = require("../common/filter"); const router = new Router(); @@ -17,7 +18,7 @@ const resources = { for (let [resource, update] of Object.entries(resources)) { router.head(`/${resource}`, async ctx => { let filter, limit; - if (ctx.request.query.filter) filter = ctx.request.query.filter; + if (ctx.request.query.filter) filter = new Filter(ctx.request.query.filter); if (ctx.request.query.limit) limit = +ctx.request.query.limit; let count = await apiFunctions.count(resource, filter, limit); @@ -26,22 +27,29 @@ for (let [resource, update] of Object.entries(resources)) { }); router.get(`/${resource}`, async ctx => { - let filter, limit; - if (ctx.request.query.filter) filter = ctx.request.query.filter; + let filter, limit, skip; + if (ctx.request.query.filter) filter = new Filter(ctx.request.query.filter); if (ctx.request.query.limit) limit = +ctx.request.query.limit; + if (ctx.request.query.skip) skip = +ctx.request.query.skip; - ctx.body = await apiFunctions.query(resource, filter, limit); + ctx.body = await apiFunctions.query( + resource, + filter, + limit, + skip, + ctx.request.query.projection + ); }); router.head(`/${resource}/:id`, async (ctx, next) => { - let filter = `DeviceID.ID = "${ctx.params.id}"`; + let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); let res = await apiFunctions.query(resource, filter); if (!res.length) return next(); ctx.body = ""; }); router.get(`/${resource}/:id`, async (ctx, next) => { - let filter = `DeviceID.ID = "${ctx.params.id}"`; + let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); let res = await apiFunctions.query(resource, filter); if (!res.length) return next(); ctx.body = res[0]; diff --git a/server/config.js b/server/config.js index 69dd68d..665c071 100644 --- a/server/config.js +++ b/server/config.js @@ -1,6 +1,65 @@ "use strict"; const filterParser = require("../common/filter-parser.js"); +const Filter = require("../common/filter.js"); -exports.PORT = 3000; -exports.GENIEACS_NBI = "http://127.0.0.1:7557/"; +const configFile = require("../config.json"); + +const cache = {}; + +function parse(name, value) { + if (/^ui\.overview\.charts\.[^.]+\.slices\.[^.]+\.filter$/.test(name)) + return new Filter(value); + + return value; +} + +function get(name) { + let v = cache[name]; + if (v === undefined) { + v = configFile[name]; + if (v != null) { + v = parse(name, v); + } else { + const prefix = `${name}.`; + for (let k of Object.keys(configFile)) + if (k.startsWith(prefix)) { + v = v || {}; + let ar = k.slice(prefix.length).split("."); + let ref = v; + for (let a of ar.slice(0, -1)) ref = ref[a] = ref[a] || {}; + ref[ar[ar.length - 1]] = parse(k, configFile[k]); + } + } + if (v) cache[name] = v; + else cache[name] = null; + } + + return v; +} + +function getClientConfig() { + const regex = [ + /^ui\.pageSize$/, + /^ui\.filters\.[^.]+\.label$/, + /^ui\.filters\.[^.]+\.parameter$/, + /^ui\.filters\.[^.]+\.type$/, + /^ui\.index\./, + /^ui\.overview\.groups\./, + /^ui\.overview\.charts\./, + /^ui\.device/ + ]; + + const clientConfig = {}; + for (let [k, v] of Object.entries(configFile)) + for (let r of regex) + if (r.test(k)) { + clientConfig[k] = v; + break; + } + + return clientConfig; +} + +exports.get = get; +exports.getClientConfig = getClientConfig; diff --git a/server/device.js b/server/device.js index e426076..048a572 100644 --- a/server/device.js +++ b/server/device.js @@ -5,7 +5,7 @@ function recursive(input, root, output, timestamp) { if (root.length === 0) if (name === "_lastInform") { output["Events.Inform"] = { - value: [timestamp, "xsd:dateTime"], + value: [Date.parse(tree), "xsd:dateTime"], valueTimestamp: timestamp, writable: false, writableTimestamp: timestamp, @@ -14,7 +14,7 @@ function recursive(input, root, output, timestamp) { }; } else if (name === "_registered") { output["Events.Registered"] = { - value: [timestamp, "xsd:dateTime"], + value: [Date.parse(tree), "xsd:dateTime"], valueTimestamp: timestamp, writable: false, writableTimestamp: timestamp, @@ -23,7 +23,7 @@ function recursive(input, root, output, timestamp) { }; } else if (name === "_lastBoot") { output["Events.1_BOOT"] = { - value: [timestamp, "xsd:dateTime"], + value: [Date.parse(tree), "xsd:dateTime"], valueTimestamp: timestamp, writable: false, writableTimestamp: timestamp, @@ -32,7 +32,7 @@ function recursive(input, root, output, timestamp) { }; } else if (name === "_lastBootstrap") { output["Events.0_BOOTSTRAP"] = { - value: [timestamp, "xsd:dateTime"], + value: [Date.parse(tree), "xsd:dateTime"], valueTimestamp: timestamp, writable: false, writableTimestamp: timestamp, diff --git a/server/server.js b/server/server.js index ea0d898..c3e09e6 100644 --- a/server/server.js +++ b/server/server.js @@ -12,10 +12,28 @@ const router = new Router(); router.use("/api", api.routes(), api.allowedMethods()); +router.get("/", async ctx => { + ctx.body = ` + + + GenieACS + + + + + + + + + `; +}); + koa.use(router.routes()); koa.use(koaStatic("./public")); -koa.listen(config.PORT, () => { +koa.listen(config.get("server.port"), () => { // eslint-disable-next-line no-console - console.log(`Server listening on port ${config.PORT}`); + console.log(`Server listening on port ${config.get("server.port")}`); }); From b24c3626ac5575c4f2fb9064a042f71288d01976 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 23 Dec 2018 02:46:35 -0800 Subject: [PATCH 013/106] Implement task queue and notifications --- client/components.js | 4 + client/components/container.js | 17 ++ client/components/parameter-list.js | 5 +- client/components/parameter-table.js | 59 ++++- client/components/parameter.js | 37 ++- client/components/summon-button.js | 49 ++++ client/css/app.css | 184 ++++++++++++-- client/devices-page.js | 11 +- client/drawer-component.js | 344 +++++++++++++++++++++++++++ client/filter-component.js | 2 +- client/layout.js | 4 +- client/notifications.js | 21 ++ client/store.js | 42 +++- client/task-queue.js | 62 +++++ config.json | 79 +++--- package.json | 20 +- server/api-functions.js | 126 ++++++++++ server/api.js | 10 +- server/device.js | 4 +- server/server.js | 8 + 20 files changed, 1000 insertions(+), 88 deletions(-) create mode 100644 client/components/container.js create mode 100644 client/components/summon-button.js create mode 100644 client/drawer-component.js create mode 100644 client/notifications.js create mode 100644 client/task-queue.js diff --git a/client/components.js b/client/components.js index 43dd888..668a94c 100644 --- a/client/components.js +++ b/client/components.js @@ -4,12 +4,16 @@ import parameter from "./components/parameter"; import parameterList from "./components/parameter-list"; import parameterTable from "./components/parameter-table"; import overviewDot from "./components/overview-dot"; +import container from "./components/container"; +import summonButton from "./components/summon-button"; function get(name) { if (name === "parameter") return parameter; else if (name === "parameter-list") return parameterList; else if (name === "parameter-table") return parameterTable; else if (name === "overview-dot") return overviewDot; + else if (name === "container") return container; + else if (name === "summon-button") return summonButton; else throw new Error(`No such component '${name}'`); } diff --git a/client/components/container.js b/client/components/container.js new file mode 100644 index 0000000..e988e70 --- /dev/null +++ b/client/components/container.js @@ -0,0 +1,17 @@ +"use strict"; + +import m from "mithril"; +import * as components from "../components"; + +const component = { + view: vnode => { + let children = Object.values(vnode.attrs.components).map(c => { + const attrs = Object.assign({}, vnode.attrs, c); + return m(components.get(attrs.type), attrs); + }); + if (vnode.attrs.element) return m(vnode.attrs.element, children); + else return children; + } +}; + +export default component; diff --git a/client/components/parameter-list.js b/client/components/parameter-list.js index ee36952..d0e1af5 100644 --- a/client/components/parameter-list.js +++ b/client/components/parameter-list.js @@ -16,7 +16,10 @@ const component = { m("th", p.label), m( "td", - m(components.get("parameter"), Object.assign({ device: device }, p)) + m( + components.get(p.type || "parameter"), + Object.assign({ device: device }, p) + ) ) ) ); diff --git a/client/components/parameter-table.js b/client/components/parameter-table.js index cbdd0d8..40f9b5d 100644 --- a/client/components/parameter-table.js +++ b/client/components/parameter-table.js @@ -2,6 +2,7 @@ import m from "mithril"; import * as components from "../components"; +import * as taskQueue from "../task-queue"; const component = { view: vnode => { @@ -12,14 +13,18 @@ const component = { if (!device[object]) return null; const instances = new Set(); + const prefix = `${object}.`; for (let p in device) - if (p.startsWith(object)) - instances.add(p.slice(0, p.indexOf(".", object.length + 1))); + if (p.startsWith(prefix)) { + let i = p.indexOf(".", prefix.length); + if (i === -1) instances.add(p); + else instances.add(p.slice(0, i)); + } + + const headers = Object.values(parameters).map(p => m("th", p.label)); + + const thead = m("thead", m("tr", headers)); - const thead = m( - "thead", - m("tr", Object.values(parameters).map(p => m("th", p.label))) - ); const rows = []; for (let i of instances) { const row = Object.values(parameters).map(p => @@ -33,8 +38,50 @@ const component = { ) ) ); + if (device[i].writable === true) + row.push( + m( + "td", + m( + "a.delete", + { + onclick: () => { + taskQueue.queueTask({ + name: "deleteObject", + device: device["DeviceID.ID"].value[0], + objectName: i + }); + } + }, + "−" + ) + ) + ); rows.push(m("tr", row)); } + if (device[object].writable === true) + rows.push( + m( + "tr", + m("td", { colspan: headers.length }), + m( + "td", + m( + "a.add", + { + onclick: () => { + taskQueue.queueTask({ + name: "addObject", + device: device["DeviceID.ID"].value[0], + objectName: object + }); + } + }, + "+" + ) + ) + ) + ); return m("table.parameter-table", thead, m("tbody", rows)); } }; diff --git a/client/components/parameter.js b/client/components/parameter.js index 6c6d47d..1511784 100644 --- a/client/components/parameter.js +++ b/client/components/parameter.js @@ -1,7 +1,7 @@ "use strict"; import m from "mithril"; -import * as components from "../components"; +import * as taskQueue from "../task-queue"; const component = { view: vnode => { @@ -11,19 +11,30 @@ const component = { if (param.value[1] === "xsd:dateTime") value = new Date(value).toISOString(); - let meta; - if (vnode.attrs.meta) { - const metaAttrs = Object.assign( - { device: vnode.attrs.device, parameter: vnode.attrs.parameter }, - vnode.attrs.meta + let edit; + if (param.writable) + edit = m( + "a.edit", + { + onclick: () => { + taskQueue.stageSpv({ + name: "setParameterValues", + device: vnode.attrs.device["DeviceID.ID"].value[0], + parameterValues: [ + [vnode.attrs.parameter, param.value[0], param.value[1]] + ] + }); + } + }, + "✎" ); - meta = m("span", [ - " (", - m(components.get(metaAttrs.type), metaAttrs), - ")" - ]); - } - return [m("span", { title: param.valueTimestamp }, value), meta]; + + return m( + "span.parameter-value", + { title: param.valueTimestamp }, + value, + edit + ); } }; diff --git a/client/components/summon-button.js b/client/components/summon-button.js new file mode 100644 index 0000000..2e75669 --- /dev/null +++ b/client/components/summon-button.js @@ -0,0 +1,49 @@ +"use strict"; + +import m from "mithril"; +import * as taskQueue from "../task-queue"; +import * as store from "../store"; +import * as notifications from "../notifications"; + +const component = { + view: vnode => { + return m( + "a.summon", + { + onclick: () => { + const params = Object.values(vnode.attrs.parameters); + const task = { + name: "getParameterValues", + parameterNames: params, + device: vnode.attrs.device["DeviceID.ID"].value[0] + }; + + taskQueue + .commit([task], (deviceId, connectionRequestStatus, tasks2) => { + for (let t of tasks2) + if (t.status === "stale") taskQueue.deleteTask(t); + if (connectionRequestStatus !== "OK") + notifications.push( + "error", + `${deviceId}: ${connectionRequestStatus}` + ); + else if (tasks2[0].status === "stale") + notifications.push( + "error", + `${deviceId}: No contact from device` + ); + else if (tasks2[0].status === "fault") + notifications.push("error", `${deviceId}: Refresh faulted`); + else notifications.push("success", `${deviceId}: Summoned`); + }) + .then(() => { + store.fulfill(Date.now(), Date.now()); + }); + } + }, + "Summon" + ); + } +}; + +export default component; diff --git a/client/css/app.css b/client/css/app.css index 4371e91..b775d0e 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -45,12 +45,157 @@ i { font-style: italic; } +@keyframes blink { + from { + opacity: 1; + } + to { + opacity: 0.3; + } +} + #header { background-color: #f2eddf; border-bottom: 1px solid var(--color1); overflow: visible; position: relative; + & > .drawer-wrapper { + position: fixed; + width: 720px; + margin-left: -360px; + top: 0; + left: 50%; + & > .drawer { + background-color: rgba(255, 255, 255, 0.8); + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + border-bottom: 1px solid var(--color1); + border-left: 1px solid var(--color1); + border-right: 1px solid var(--color1); + margin-top: -1px; + box-shadow: 0 0 10px #ccc; + overflow: hidden; + transition: height var(--fade), opacity var(--fade); + + & > div { + padding: 0.3em; + } + + & > .status { + & > span { + margin-right: 1em; + } + & > .pending { + color: #770; + &.active { + animation: 0.5s linear 0s infinite alternate blink; + } + } + & > .stale { + color: #777; + } + & > .faulty { + color: #700; + } + & > .active { + font-weight: bold; + } + + & > .actions { + float: right; + & a { + float: right; + margin-left: 0.5em; + } + & > .commit { + font-weight: bold; + } + } + } + + & > .staging { + & .parameter { + font-style: italic; + max-width: 500px; + text-overflow: ellipsis; + display: inline-block; + overflow: hidden; + vertical-align: text-bottom; + } + + & input { + width: 100%; + padding: 0.3em; + margin-top: 0.3em; + margin-bottom: 0.3em; + } + & .actions { + text-align: right; + & a { + margin-left: 0.5em; + } + } + } + + & > .queue { + & a.retry { + float: right; + margin-left: 0.5em; + } + & a.cancel { + float: right; + margin-left: 0.5em; + } + & > .pending { + color: #770; + } + & > .stale { + color: #777; + } + & > .faulty { + color: #700; + } + & .parameter { + font-style: italic; + max-width: 400px; + text-overflow: ellipsis; + display: inline-block; + overflow: hidden; + vertical-align: text-bottom; + } + & .value { + max-width: 200px; + text-overflow: ellipsis; + display: inline-block; + overflow: hidden; + vertical-align: text-bottom; + white-space: nowrap; + } + } + } + & > .notifications-wrapper { + & > .notification { + padding: 0.3em; + left: 0; + right: 0; + background-color: rgba(255, 255, 255, 0.8); + border: 1px solid var(--color1); + border-radius: 6px; + box-shadow: 0 0 10px #ccc; + transition: opacity var(--fade), top var(--fade); + + &.error { + background-color: rgba(255, 200, 200, 0.8); + border: 1px solid rgb(255, 200, 200); + } + &.success { + background-color: rgba(200, 255, 200, 0.8); + border: 1px solid rgb(200, 255, 200); + } + } + } + } & > nav { display: inline-block; overflow: visible; @@ -206,26 +351,29 @@ button { } } +input { + display: block; + border: 1px solid var(--color1); + background-color: var(--color3); + border-radius: 4px; + transition: border-color var(--fade), background-color var(--fade); + &.error { + background-color: #efcbcf; + border-color: #e6b2b7; + } + &:focus, + &:hover { + border-color: var(--color4); + } +} + .filter { margin-bottom: 0.5em; & > input { - padding: 2px; - width: 30em; font-size: 90%; - display: block; - border: 1px solid var(--color1); - background-color: var(--color3); + width: 30em; margin: 2px; - border-radius: 4px; - transition: border-color var(--fade), background-color var(--fade); - &.error { - background-color: #efcbcf; - border-color: #e6b2b7; - } - &:focus, - &:hover { - border-color: var(--color4); - } + padding: 2px; } } @@ -280,6 +428,12 @@ table.parameter-table { } } +span.inform { + & > * { + margin-right: 0.5em; + } +} + .pie-chart { margin: 20px; text-align: center; diff --git a/client/devices-page.js b/client/devices-page.js index 55bbfde..14cb429 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -19,7 +19,6 @@ function init(args) { function renderTable(devices, parameters, total, showMoreCallback) { let labels = []; for (let param of parameters) labels.push(m("th", param.label)); - labels.push(m("th")); let rows = []; for (let device of devices) @@ -29,7 +28,7 @@ function renderTable(devices, parameters, total, showMoreCallback) { parameters .map(p => { const attrs = Object.assign({ device: device }, p); - const comp = m(components.get("parameter"), attrs); + const comp = m(components.get(attrs.type || "parameter"), attrs); return m("td", comp); }) .concat( @@ -49,14 +48,16 @@ function renderTable(devices, parameters, total, showMoreCallback) { ) ); - let footerElements = [`${devices.length}/${total}`]; + let footerElements = []; + if (total != null) footerElements.push(`${devices.length}/${total}`); + else footerElements.push(`${devices.length}`); if (devices.length < total) - footerElements.push(m("a", { onclick: showMoreCallback }, "Show more")); + footerElements.push(m("a", { onclick: showMoreCallback }, "More")); let tfoot = m( "tfoot", - m("tr", m("td", { colspan: labels.length + 1 }, footerElements)) + m("tr", m("td", { colspan: labels.length }, footerElements)) ); return m("table.table", [ diff --git a/client/drawer-component.js b/client/drawer-component.js new file mode 100644 index 0000000..ee8380a --- /dev/null +++ b/client/drawer-component.js @@ -0,0 +1,344 @@ +"use strict"; + +import m from "mithril"; + +import * as taskQueue from "./task-queue"; +import * as store from "./store"; +import * as notifications from "./notifications"; + +function mparam(param) { + return m("span.parameter", { title: param }, `${param}`); +} + +function mval(val) { + return m("span.value", { title: val }, `${val}`); +} + +function renderStaging(staging) { + let elements = []; + for (let s of staging) { + const queueFunc = () => { + staging.delete(s); + taskQueue.queueTask(s); + }; + const cancelFunc = () => { + staging.delete(s); + }; + + const input = m("input", { + value: s.parameterValues[0][1], + oninput: e => { + e.redraw = false; + s.parameterValues[0][1] = input.dom.value; + }, + onkeydown: e => { + if (e.keyCode === 13) queueFunc(); + else if (e.keyCode === 27) cancelFunc(); + else e.redraw = false; + }, + oncreate: vnode => { + vnode.dom.focus(); + vnode.dom.select(); + // Need to prevent scrolling on focus because + // we're animating height and using overflow: hidden + vnode.dom.parentNode.parentNode.scrollTop = 0; + } + }); + const queue = m("a", { onclick: queueFunc }, "Queue"); + + const cancel = m("a", { onclick: cancelFunc }, "Cancel"); + + elements.push( + m( + ".staging", + m("span", "Editing ", mparam(s.parameterValues[0][0])), + input, + m("div.actions", queue, cancel) + ) + ); + } + return elements; +} + +function renderQueue(queue) { + let details = []; + let devices = {}; + for (let t of queue) { + devices[t.device] = devices[t.device] || []; + devices[t.device].push(t); + } + + for (let [k, v] of Object.entries(devices)) { + details.push(m("strong", k)); + for (let t of v) { + let actions = [ + m( + "a.cancel", + { + onclick: () => { + taskQueue.deleteTask(t); + } + }, + "✕" + ) + ]; + if (t.status === "fault" || t.status === "stale") + actions.push( + m( + "a.retry", + { + onclick: () => { + taskQueue.queueTask(t); + } + }, + "↺" + ) + ); + + if (t.name === "setParameterValues") + details.push( + m( + `div.${t.status}`, + actions, + "Set ", + mparam(t.parameterValues[0][0]), + " to '", + mval(t.parameterValues[0][1]), + "'" + ) + ); + else if (t.name === "refreshObject") + details.push( + m(`div.${t.status}`, actions, "Refresh ", mparam(t.parameterName)) + ); + else if (t.name === "reboot") + details.push(m(`div.${t.status}`, actions, "Reboot")); + else if (t.name === "factoryReset") + details.push(m(`div.${t.status}`, actions, "Factory reset")); + else if (t.name === "addObject") + details.push( + m(`div.${t.status}`, actions, "Add ", mparam(t.objectName)) + ); + else if (t.name === "deleteObject") + details.push( + m(`div.${t.status}`, actions, "Delete ", mparam(t.objectName)) + ); + else if (t.name === "getParameterValues") + details.push( + m( + `div.${t.status}`, + actions, + `Refresh ${t.parameterNames.length} parameters` + ) + ); + else details.push(m(`div.${t.status}`, actions, t.name)); + } + } + + return details; +} + +function renderNotifications(notifs) { + const notificationElements = []; + + for (let n of notifs) + notificationElements.push( + m( + "div.notification", + { + class: n.type, + style: "position: absolute;opacity: 0", + oncreate: vnode => { + vnode.dom.style.opacity = 1; + }, + onbeforeremove: vnode => { + vnode.dom.style.opacity = 0; + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 500); + }); + }, + key: n.timestamp + }, + n.message + ) + ); + return notificationElements; +} + +const component = { + view: vnode => { + const queue = taskQueue.getQueue(); + const staging = taskQueue.getStaging(); + const notifs = notifications.getNotifications(); + + let drawerElement, statusElement; + let notificationElements = renderNotifications(notifs); + let stagingElements = renderStaging(staging); + let queueElements = renderQueue(queue); + + function repositionNotifications() { + let top = 10; + for (let c of notificationElements) { + c.dom.style.top = top; + top += c.dom.offsetHeight + 10; + } + } + + function resizeDrawer() { + let height = statusElement.dom.offsetTop + statusElement.dom.offsetHeight; + if (stagingElements.length) + for (let s of stagingElements) + height = Math.max(height, s.dom.offsetTop + s.dom.offsetHeight); + else if (vnode.state.mouseIn) + for (let c of drawerElement.children) + height = Math.max(height, c.dom.offsetTop + c.dom.offsetHeight); + drawerElement.dom.style.height = height; + } + + if (stagingElements.length + queueElements.length) { + const statusCount = { queued: 0, pending: 0, faulty: 0, stale: 0 }; + for (let t of queue) statusCount[t.status] += 1; + + let actions; + if (queueElements.length) + if (statusCount.queued) { + actions = m( + ".actions", + m("a.clear", { onclick: taskQueue.clear }, "Clear"), + m( + "a.commit", + { + onclick: () => { + let tasks = Array.from(taskQueue.getQueue()).filter( + t => t.status === "queued" + ); + taskQueue + .commit( + tasks, + (deviceId, connectionRequestStatus, tasks2) => { + if (connectionRequestStatus !== "OK") + notifications.push( + "error", + `${deviceId}: ${connectionRequestStatus}` + ); + else + for (let t of tasks2) + if (t.status === "stale") { + notifications.push( + "error", + `${deviceId}: No contact from device` + ); + return; + } else if (t.status === "fault") { + notifications.push( + "error", + `${deviceId}: Task(s) faulted` + ); + return; + } + + notifications.push( + "success", + `${deviceId}: Task(s) committed` + ); + } + ) + .then(() => { + store.fulfill(Date.now(), Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + throw err; + }); + } + }, + "Commit" + ) + ); + } else { + actions = m( + ".actions", + m("a.clear", { onclick: taskQueue.clear }, "Clear") + ); + } + + statusElement = m( + ".status", + m( + "span.queued", + { class: statusCount.queued ? "active" : "" }, + `Queued: ${statusCount.queued}` + ), + m( + "span.pending", + { class: statusCount.pending ? "active" : "" }, + `Pending: ${statusCount.pending}` + ), + m( + "span.faulty", + { class: statusCount.faulty ? "active" : "" }, + `Faulty: ${statusCount.faulty}` + ), + m( + "span.stale", + { class: statusCount.stale ? "active" : "" }, + `Stale: ${statusCount.stale}` + ), + actions + ); + + drawerElement = m( + ".drawer", + { + key: "drawer", + style: "opacity: 0;height: 0;", + oncreate: vnode2 => { + vnode.state.mouseIn = false; + vnode2.dom.style.opacity = 1; + resizeDrawer(); + }, + onmouseover: e => { + vnode.state.mouseIn = true; + resizeDrawer(); + e.redraw = false; + }, + onmouseleave: e => { + vnode.state.mouseIn = false; + resizeDrawer(); + e.redraw = false; + }, + onupdate: resizeDrawer, + onbeforeremove: vnode2 => { + vnode2.dom.onmouseover = vnode2.dom.onmouseleave = null; + vnode2.dom.style.opacity = 0; + vnode2.dom.style.height = 0; + return new Promise(resolve => { + setTimeout(resolve, 500); + }); + } + }, + statusElement, + stagingElements.length ? stagingElements : m(".queue", queueElements) + ); + } + + return m( + "div.drawer-wrapper", + drawerElement, + m( + "div.notifications-wrapper", + { + key: "notifications", + style: "position: relative;", + onupdate: repositionNotifications, + oncreate: repositionNotifications + }, + notificationElements + ) + ); + } +}; + +export default component; diff --git a/client/filter-component.js b/client/filter-component.js index f1555e5..9d1caac 100644 --- a/client/filter-component.js +++ b/client/filter-component.js @@ -54,7 +54,7 @@ const component = { [m("b", "Filter"), a].concat( vnode.state.filterList.map((fltr, idx) => { return m("input", { - type: "string", + type: "text", list: "filters", class: `${(vnode.state.filterInvalid >> idx) & 1 ? "error" : ""}`, value: fltr, diff --git a/client/layout.js b/client/layout.js index 47d7fb9..8177219 100644 --- a/client/layout.js +++ b/client/layout.js @@ -3,13 +3,15 @@ import m from "mithril"; import menu from "./menu"; +import drawerComponent from "./drawer-component"; const layout = { view: vnode => { return [ m("#header", [ m("img.logo", { src: "logo.svg" }), - m(menu, { page: vnode.attrs.page }) + m(menu, { page: vnode.attrs.page }), + m(drawerComponent) ]), m("#content", { class: `page-${vnode.attrs.page}` }, [vnode.children]) ]; diff --git a/client/notifications.js b/client/notifications.js new file mode 100644 index 0000000..48c3827 --- /dev/null +++ b/client/notifications.js @@ -0,0 +1,21 @@ +"use strict"; + +import m from "mithril"; + +const notifications = new Set(); + +function push(type, message) { + const n = { type: type, message: message, timestamp: Date.now() }; + notifications.add(n); + m.redraw(); + setTimeout(() => { + notifications.delete(n); + m.redraw(); + }, 4000); +} + +function getNotifications() { + return notifications; +} + +export { push, getNotifications }; diff --git a/client/store.js b/client/store.js index 991ec19..9d6e0b2 100644 --- a/client/store.js +++ b/client/store.js @@ -304,4 +304,44 @@ function getTimestamp() { return fulfillTimestamp; } -export { count, fetch, fulfill, unpackFilter, getTimestamp }; +function postTasks(tasks, callback) { + return new Promise((resolve, reject) => { + let devices = {}; + for (let t of tasks) { + t.status = "pending"; + devices[t.device] = devices[t.device] || []; + devices[t.device].push(t); + } + + let promises = []; + for (let [deviceId, tasks2] of Object.entries(devices)) + promises.push( + m.request({ + method: "POST", + url: `/api/devices/${encodeURIComponent(deviceId)}/tasks`, + data: tasks2, + extract: xhr => { + const connectionRequestStatus = xhr.getResponseHeader( + "Connection-Request" + ); + let st = JSON.parse(xhr.response); + for (let [i, t] of st.entries()) { + tasks2[i]._id = t._id; + tasks2[i].status = t.status; + tasks2[i].fault = t.fault; + } + if (callback) callback(deviceId, connectionRequestStatus, tasks2); + } + }) + ); + + Promise.all(promises) + .then(() => resolve(tasks)) + .catch(err => { + callback = null; + reject(err); + }); + }); +} + +export { count, fetch, fulfill, unpackFilter, getTimestamp, postTasks }; diff --git a/client/task-queue.js b/client/task-queue.js new file mode 100644 index 0000000..76a8d41 --- /dev/null +++ b/client/task-queue.js @@ -0,0 +1,62 @@ +"use strict"; + +import * as store from "./store"; + +const queue = new Set(); +const staging = new Set(); + +function queueTask(task) { + staging.delete(task); + task.status = "queued"; + queue.add(task); +} + +function deleteTask(task) { + staging.delete(task); + queue.delete(task); +} + +function getQueue() { + return queue; +} + +function clear() { + queue.clear(); +} + +function getStaging() { + return staging; +} + +function clearStaging() { + staging.clear(); +} + +function stageSpv(task) { + staging.add(task); +} + +function commit(tasks, callback) { + for (let t of tasks) { + t.status = "pending"; + queue.add(t); + } + + return store.postTasks(tasks, (deviceId, connectionRequestStatus, tasks2) => { + for (let t of tasks2) + if (t.status === "pending") t.status = "stale"; + else if (t.status === "done") queue.delete(t); + if (callback) callback(deviceId, connectionRequestStatus, tasks2); + }); +} + +export { + queueTask, + deleteTask, + clear, + getQueue, + getStaging, + clearStaging, + stageSpv, + commit +}; diff --git a/config.json b/config.json index 6759c01..24fa0c0 100644 --- a/config.json +++ b/config.json @@ -25,8 +25,11 @@ "ui.index.5.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", "ui.index.6.label": "Last inform", "ui.index.6.parameter": "Events.Inform", - "ui.index.6.meta.type": "overview-dot", - "ui.index.6.meta.chart": "online", + "ui.index.6.type": "container", + "ui.index.6.element": "span.inform", + "ui.index.6.components.1.type": "parameter", + "ui.index.6.components.2.type": "overview-dot", + "ui.index.6.components.2.chart": "online", "ui.overview.groups.online.label": "", "ui.overview.groups.online.charts.1": "online", "ui.overview.charts.online.label": "Online status", @@ -40,43 +43,53 @@ "ui.overview.charts.online.slices.others.color": "#f7fcb9", "ui.overview.charts.online.slices.others.filter": "Events.Inform < (NOW() - 5 * 60 * 1000) - (24 * 60 * 60 * 1000)", "ui.device.1.type": "parameter-list", + "ui.device.1.parameters.1.type": "container", "ui.device.1.parameters.1.label": "Last inform", + "ui.device.1.parameters.1.element": "span.inform", "ui.device.1.parameters.1.parameter": "Events.Inform", - "ui.device.1.parameters.1.meta.type": "overview-dot", - "ui.device.1.parameters.1.meta.chart": "online", + "ui.device.1.parameters.1.components.1.type": "parameter", + "ui.device.1.parameters.1.components.2.type": "overview-dot", + "ui.device.1.parameters.1.components.2.chart": "online", + "ui.device.1.parameters.1.components.3.type": "summon-button", + "ui.device.1.parameters.1.components.3.parameters.1": "InternetGatewayDevice.DeviceInfo.HardwareVersion", + "ui.device.1.parameters.1.components.3.parameters.2": "InternetGatewayDevice.DeviceInfo.SoftwareVersion", + "ui.device.1.parameters.1.components.3.parameters.3": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress", + "ui.device.1.parameters.1.components.3.parameters.4": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress", + "ui.device.1.parameters.1.components.3.parameters.5": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", + "ui.device.1.parameters.1.components.3.parameters.6": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase", + "ui.device.1.parameters.1.components.3.parameters.7": "InternetGatewayDevice.LANDevice.1.Hosts.Host", "ui.device.1.parameters.2.label": "Last boot", "ui.device.1.parameters.2.parameter": "Events.1_BOOT", "ui.device.1.parameters.3.label": "Last bootstrap", "ui.device.1.parameters.3.parameter": "Events.0_BOOTSTRAP", "ui.device.1.parameters.4.label": "Registered", "ui.device.1.parameters.4.parameter": "Events.Registered", - "ui.device.2.type": "parameter-list", - "ui.device.2.parameters.1.label": "Serial number", - "ui.device.2.parameters.1.parameter": "InternetGatewayDevice.DeviceInfo.SerialNumber", - "ui.device.2.parameters.2.label": "Product class", - "ui.device.2.parameters.2.parameter": "InternetGatewayDevice.DeviceInfo.ProductClass", - "ui.device.2.parameters.3.label": "OUI", - "ui.device.2.parameters.3.parameter": "InternetGatewayDevice.DeviceInfo.ManufacturerOUI", - "ui.device.2.parameters.4.label": "Manufacturer", - "ui.device.2.parameters.4.parameter": "InternetGatewayDevice.DeviceInfo.Manufacturer", - "ui.device.2.parameters.5.label": "Hardware version", - "ui.device.2.parameters.5.parameter": "InternetGatewayDevice.DeviceInfo.HardwareVersion", - "ui.device.2.parameters.6.label": "Software version", - "ui.device.2.parameters.6.parameter": "InternetGatewayDevice.DeviceInfo.SoftwareVersion", - "ui.device.2.parameters.7.label": "MAC", - "ui.device.2.parameters.7.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress", - "ui.device.2.parameters.8.label": "IP", - "ui.device.2.parameters.8.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress", - "ui.device.2.parameters.8.label": "WLAN SSID", - "ui.device.2.parameters.8.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", - "ui.device.2.parameters.9.label": "WLAN passphrase", - "ui.device.2.parameters.9.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase", - "ui.device.3.type": "parameter-table", - "ui.device.3.object": "InternetGatewayDevice.LANDevice.1.Hosts.Host", - "ui.device.3.parameters.1.label": "Host name", - "ui.device.3.parameters.1.parameter": "HostName", - "ui.device.3.parameters.2.label": "IP address", - "ui.device.3.parameters.2.parameter": "IPAddress", - "ui.device.3.parameters.3.label": "MAC address", - "ui.device.3.parameters.3.parameter": "MACAddress" + "ui.device.1.parameters.2.label": "Serial number", + "ui.device.1.parameters.2.parameter": "InternetGatewayDevice.DeviceInfo.SerialNumber", + "ui.device.1.parameters.3.label": "Product class", + "ui.device.1.parameters.3.parameter": "InternetGatewayDevice.DeviceInfo.ProductClass", + "ui.device.1.parameters.4.label": "OUI", + "ui.device.1.parameters.4.parameter": "InternetGatewayDevice.DeviceInfo.ManufacturerOUI", + "ui.device.1.parameters.5.label": "Manufacturer", + "ui.device.1.parameters.5.parameter": "InternetGatewayDevice.DeviceInfo.Manufacturer", + "ui.device.1.parameters.6.label": "Hardware version", + "ui.device.1.parameters.6.parameter": "InternetGatewayDevice.DeviceInfo.HardwareVersion", + "ui.device.1.parameters.7.label": "Software version", + "ui.device.1.parameters.7.parameter": "InternetGatewayDevice.DeviceInfo.SoftwareVersion", + "ui.device.1.parameters.8.label": "MAC", + "ui.device.1.parameters.8.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress", + "ui.device.1.parameters.9.label": "IP", + "ui.device.1.parameters.9.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress", + "ui.device.1.parameters.10.label": "WLAN SSID", + "ui.device.1.parameters.10.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", + "ui.device.1.parameters.11.label": "WLAN passphrase", + "ui.device.1.parameters.11.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase", + "ui.device.2.type": "parameter-table", + "ui.device.2.object": "InternetGatewayDevice.LANDevice.1.Hosts.Host", + "ui.device.2.parameters.1.label": "Host name", + "ui.device.2.parameters.1.parameter": "HostName", + "ui.device.2.parameters.2.label": "IP address", + "ui.device.2.parameters.2.parameter": "IPAddress", + "ui.device.2.parameters.3.label": "MAC address", + "ui.device.2.parameters.3.parameter": "MACAddress" } diff --git a/package.json b/package.json index c0c4b8b..407203c 100644 --- a/package.json +++ b/package.json @@ -10,20 +10,22 @@ }, "author": "Zaid Abdulla ", "dependencies": { - "koa": "^2.4.1", + "koa": "^2.5.0", + "koa-body": "^2.5.0", "koa-bodyparser": "^4.2.0", - "koa-router": "^7.3.0", + "koa-compress": "^2.0.0", + "koa-router": "^7.4.0", "koa-static": "^4.0.2", - "mithril": "^1.1.5", - "parsimmon": "^1.6.2" + "mithril": "^1.1.6", + "parsimmon": "^1.7.2" }, "devDependencies": { "cssnano": "^3.10.0", - "eslint": "^4.12.1", + "eslint": "^4.19.1", "postcss-cli": "^4.1.1", - "postcss-cssnext": "^3.0.2", - "postcss-import": "^11.0.0", - "prettier": "^1.8.2", - "webpack": "^3.9.1" + "postcss-cssnext": "^3.1.0", + "postcss-import": "^11.1.0", + "prettier": "^1.11.1", + "webpack": "^3.11.0" } } diff --git a/server/api-functions.js b/server/api-functions.js index e4b60e2..db685d9 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -8,6 +8,7 @@ const querystring = require("querystring"); const config = require("./config"); const device = require("./device"); const filterParser = require("../common/filter-parser"); +const Filter = require("../common/filter"); const commaAscii = ",".charCodeAt(0); const newLineAscii = "\n".charCodeAt(0); @@ -205,5 +206,130 @@ function query(resource, filter, limit, skip, projection, callback) { }); } +function deleteResource(resource, id) { + return new Promise((resolve, reject) => { + let options = url.parse( + `${config.get("server.nbi")}${resource}/${encodeURIComponent(id)}` + ); + + options.method = "DELETE"; + + let _http = options.protocol === "https:" ? https : http; + + _http + .request(options, res => { + res.resume(); + if (res.statusCode === 200) resolve(true); + else if (res.statusCode === 404) resolve(false); + else reject(new Error(`Unexpected status code ${res.statusCode}`)); + }) + .end(); + }); +} + +function postTask(task, connectionRequest, callback) { + let options = url.parse( + `${config.get("server.nbi")}devices/${task.device}/tasks${ + connectionRequest ? "?connection_request" : "" + }` + ); + options.method = "POST"; + let _http = options.protocol === "https:" ? https : http; + + _http + .request(options, res => { + if (res.statusCode !== 200 && res.statusCode !== 202) { + callback(new Error(`Unexpected status code ${res.statusCode}`)); + res.resume(); + return; + } + const chunks = []; + let bytes = 0; + res.on("data", chunk => { + chunks.push(chunk); + bytes += chunk.length; + }); + + res.on("end", () => { + let buf = new Buffer(bytes); + let o = 0; + for (let c of chunks) { + c.copy(buf, o, 0, c.length); + o += c.length; + } + try { + let t = JSON.parse(buf); + let connectionRequestStatus; + if (connectionRequest) { + connectionRequestStatus = "OK"; + if ( + res.statusCode === 202 && + res.statusMessage !== "Task queued but not processed" && + res.statusMessage !== "Task faulte" + ) + connectionRequestStatus = res.statusMessage; + } + callback(null, t, connectionRequestStatus); + } catch (err) { + callback(err); + } + }); + }) + .end(JSON.stringify(task)); +} + +function postTasks(deviceId, tasks) { + return new Promise((resolve, reject) => { + let connectionRequestStatus; + let promises = []; + for (let [idx, task] of tasks.entries()) { + task.device = deviceId; + promises.push( + new Promise((res, rej) => { + let conReq = idx === tasks.length - 1; + delete task._id; + task.expiry = 5; + postTask(task, conReq, (err, t, crs) => { + if (err) return rej(err); + if (conReq) connectionRequestStatus = crs; + res({ _id: t._id, status: "pending" }); + }); + }) + ); + } + + Promise.all(promises) + .then(statuses => { + if (connectionRequestStatus !== "OK") + return resolve({ + connectionRequest: connectionRequestStatus, + tasks: statuses + }); + Promise.all( + statuses.map(s => query("tasks", new Filter(["=", "_id", s._id]))) + ) + .then(res => { + for (let [i, r] of res.entries()) { + if (!r[0]) { + statuses[i].status = "done"; + } else if (r[0].fault) { + statuses[i].status = "fault"; + statuses[i].fault = r[0].fault; + } + deleteResource("tasks", statuses[i]._id); + } + + resolve({ + connectionRequest: connectionRequestStatus, + tasks: statuses + }); + }) + .catch(reject); + }) + .catch(reject); + }); +} + exports.query = query; exports.count = count; +exports.postTasks = postTasks; diff --git a/server/api.js b/server/api.js index 890c025..01b84a4 100644 --- a/server/api.js +++ b/server/api.js @@ -12,7 +12,8 @@ const resources = { presets: true, provisions: true, files: true, - virtual_parameters: true + virtual_parameters: true, + tasks: false }; for (let [resource, update] of Object.entries(resources)) { @@ -60,4 +61,11 @@ for (let [resource, update] of Object.entries(resources)) { } } +router.post("/devices/:id/tasks", async ctx => { + let res = await apiFunctions.postTasks(ctx.params.id, ctx.request.body); + // TODO 404 if no such device + ctx.set("Connection-Request", res.connectionRequest); + ctx.body = res.tasks; +}); + module.exports = router; diff --git a/server/device.js b/server/device.js index 048a572..99470f2 100644 --- a/server/device.js +++ b/server/device.js @@ -114,12 +114,12 @@ function recursive(input, root, output, timestamp) { attrs["valueTimestamp"] = childrenTimestamp; attrs["object"] = false; attrs["objectTimestamp"] = childrenTimestamp; - } else if (tree["_object"]) { + } else if (tree["_object"] != null) { attrs["object"] = tree["_object"]; attrs["objectTimestamp"] = childrenTimestamp; } - if (tree["_writable"]) { + if (tree["_writable"] != null) { attrs["writable"] = tree["_writable"]; attrs["writableTimestamp"] = childrenTimestamp; } diff --git a/server/server.js b/server/server.js index c3e09e6..a39489f 100644 --- a/server/server.js +++ b/server/server.js @@ -3,6 +3,8 @@ const Koa = require("koa"); const Router = require("koa-router"); const koaStatic = require("koa-static"); +const koaCompress = require("koa-compress"); +const koaBody = require("koa-body"); const config = require("./config"); const api = require("./api"); @@ -10,6 +12,7 @@ const api = require("./api"); const koa = new Koa(); const router = new Router(); +koa.use(koaBody()); router.use("/api", api.routes(), api.allowedMethods()); router.get("/", async ctx => { @@ -30,6 +33,11 @@ router.get("/", async ctx => { `; }); +koa.use( + koaCompress({ + flush: require("zlib").Z_SYNC_FLUSH + }) +); koa.use(router.routes()); koa.use(koaStatic("./public")); From 3cfac28fb1cad9720757044d1d02f2123886b78f Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 23 Dec 2018 02:47:42 -0800 Subject: [PATCH 014/106] Implement faults page --- client/app.js | 4 +- client/components.js | 2 + client/components/device-faults.js | 71 +++++++++ client/components/parameter-table.js | 18 ++- client/components/parameter.js | 3 +- client/components/summon-button.js | 9 +- client/css/app.css | 127 ++++++---------- client/devices-page.js | 112 ++++++++++---- client/drawer-component.js | 216 +++++++++++++++------------ client/faults-page.js | 188 +++++++++++++++++++++++ client/filter-component.js | 6 +- client/menu.js | 5 + client/store.js | 68 ++++++--- config.json | 3 +- server/api-functions.js | 33 ++-- server/api.js | 24 ++- 16 files changed, 643 insertions(+), 246 deletions(-) create mode 100644 client/components/device-faults.js create mode 100644 client/faults-page.js diff --git a/client/app.js b/client/app.js index d368804..9a9ef0d 100644 --- a/client/app.js +++ b/client/app.js @@ -8,6 +8,7 @@ import * as overviewPage from "./overview-page"; import * as devicesPage from "./devices-page"; import * as devicePage from "./device-page"; import * as errorPage from "./error-page"; +import * as faultsPage from "./faults-page"; let state; @@ -65,5 +66,6 @@ function pagify(pageName, page) { m.route(document.body, "/overview", { "/overview": pagify("overview", overviewPage), "/devices": pagify("devices", devicesPage), - "/devices/:id": pagify("devices", devicePage) + "/devices/:id": pagify("devices", devicePage), + "/faults": pagify("faults", faultsPage) }); diff --git a/client/components.js b/client/components.js index 668a94c..bbaf75b 100644 --- a/client/components.js +++ b/client/components.js @@ -6,6 +6,7 @@ import parameterTable from "./components/parameter-table"; import overviewDot from "./components/overview-dot"; import container from "./components/container"; import summonButton from "./components/summon-button"; +import deviceFaults from "./components/device-faults"; function get(name) { if (name === "parameter") return parameter; @@ -14,6 +15,7 @@ function get(name) { else if (name === "overview-dot") return overviewDot; else if (name === "container") return container; else if (name === "summon-button") return summonButton; + else if (name === "device-faults") return deviceFaults; else throw new Error(`No such component '${name}'`); } diff --git a/client/components/device-faults.js b/client/components/device-faults.js new file mode 100644 index 0000000..7c9d928 --- /dev/null +++ b/client/components/device-faults.js @@ -0,0 +1,71 @@ +"use strict"; + +import m from "mithril"; +import * as store from "../store"; +import Filter from "../../common/filter"; +import * as notifications from "../notifications"; + +const component = { + view: vnode => { + const device = vnode.attrs.device; + const deviceId = device["DeviceID.ID"].value[0]; + const faults = store.fetch( + "faults", + new Filter([ + "AND", + [">", "_id", `${deviceId}:`], + ["<", "_id", `${deviceId}:zzzz`] + ]) + ); + + const headers = ["Channel", "Code", "Message", "Retries", "Timestamp"].map( + l => m("th", l) + ); + const thead = m("thead", m("tr", headers)); + + const rows = []; + for (let f of faults.value) + rows.push([ + m("td", f["channel"]), + m("td", f["code"]), + m("td", f["message"]), + m("td", f["retries"]), + m("td", f["timestamp"]), + m( + "td", + m( + "button", + { + title: "Delete fault", + onclick: e => { + e.redraw = false; + store + .deleteResource("faults", f["_id"]) + .then(() => { + notifications.push("success", "Fault deleted"); + store.fulfill(Date.now(), Date.now()); + m.redraw(); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } + }, + "✕" + ) + ) + ]); + + let tbody; + if (rows.length) tbody = m("tbody", rows.map(r => m("tr", r))); + else + tbody = m( + "tbody", + m("tr.empty", m("td", { colspan: headers.length }, "No faults")) + ); + + return [m("h2", "Faults"), m("table.table", thead, tbody)]; + } +}; + +export default component; diff --git a/client/components/parameter-table.js b/client/components/parameter-table.js index 40f9b5d..b11fb29 100644 --- a/client/components/parameter-table.js +++ b/client/components/parameter-table.js @@ -43,8 +43,9 @@ const component = { m( "td", m( - "a.delete", + "button", { + title: "Delete this instance", onclick: () => { taskQueue.queueTask({ name: "deleteObject", @@ -53,12 +54,18 @@ const component = { }); } }, - "−" + "✕" ) ) ); rows.push(m("tr", row)); } + + if (!rows.length) + rows.push( + m("tr.empty", m("td", { colspan: headers.length }, "No instances")) + ); + if (device[object].writable === true) rows.push( m( @@ -67,8 +74,9 @@ const component = { m( "td", m( - "a.add", + "button", { + title: "Create a new instance", onclick: () => { taskQueue.queueTask({ name: "addObject", @@ -77,12 +85,12 @@ const component = { }); } }, - "+" + "🞢" ) ) ) ); - return m("table.parameter-table", thead, m("tbody", rows)); + return m("table.table", thead, m("tbody", rows)); } }; diff --git a/client/components/parameter.js b/client/components/parameter.js index 1511784..7a8e520 100644 --- a/client/components/parameter.js +++ b/client/components/parameter.js @@ -14,8 +14,9 @@ const component = { let edit; if (param.writable) edit = m( - "a.edit", + "button", { + title: "Edit parameter value", onclick: () => { taskQueue.stageSpv({ name: "setParameterValues", diff --git a/client/components/summon-button.js b/client/components/summon-button.js index 2e75669..e814e20 100644 --- a/client/components/summon-button.js +++ b/client/components/summon-button.js @@ -8,9 +8,11 @@ import * as notifications from "../notifications"; const component = { view: vnode => { return m( - "a.summon", + "button.primary", { - onclick: () => { + title: "Initiate session and refresh basic parameters", + onclick: e => { + e.target.disabled = true; const params = Object.values(vnode.attrs.parameters); const task = { name: "getParameterValues", @@ -37,7 +39,8 @@ const component = { else notifications.push("success", `${deviceId}: Summoned`); }) .then(() => { - store.fulfill(Date.now(), Date.now()); + e.target.disabled = false; + store.fulfill(0, Date.now()); }); } }, diff --git a/client/css/app.css b/client/css/app.css index b775d0e..77bd893 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -11,6 +11,8 @@ --color5: #002f2f; --fade: 0.2s; + + --disabled: #666; } body, @@ -83,6 +85,7 @@ i { } & > .status { + display: flex; & > span { margin-right: 1em; } @@ -95,7 +98,7 @@ i { & > .stale { color: #777; } - & > .faulty { + & > .fault { color: #700; } & > .active { @@ -103,14 +106,8 @@ i { } & > .actions { - float: right; - & a { - float: right; - margin-left: 0.5em; - } - & > .commit { - font-weight: bold; - } + flex-grow: 1; + text-align: right; } } @@ -132,28 +129,17 @@ i { } & .actions { text-align: right; - & a { - margin-left: 0.5em; - } } } & > .queue { - & a.retry { - float: right; - margin-left: 0.5em; - } - & a.cancel { - float: right; - margin-left: 0.5em; - } & > .pending { color: #770; } & > .stale { color: #777; } - & > .faulty { + & > .fault { color: #700; } & .parameter { @@ -172,6 +158,13 @@ i { vertical-align: text-bottom; white-space: nowrap; } + & > div { + display: flex; + & > .actions { + flex-grow: 1; + text-align: right; + } + } } } & > .notifications-wrapper { @@ -263,24 +256,29 @@ table.table { & td { margin: 0; padding: 0.2em 0.5em; - border-bottom: 1px dotted var(--color1); - & .action a { - visibility: hidden; - } - } - - & tbody tr:hover td { - & .action { - visibility: visible; - } - - transition: background-color var(--fade); - background-color: var(--color3); } & tfoot td > a { margin-left: 1em; } + + &.highlight > tbody > tr { + & > td { + border-bottom: 1px dotted var(--color1); + } + transition: background-color var(--fade); + &:hover { + background-color: var(--color3); + } + } + + & tbody > tr.empty { + text-align: center; + color: var(--disabled); + &:hover { + background-color: initial; + } + } } h1 { @@ -314,40 +312,28 @@ input::-moz-focus-inner { input[type="button"], button { - display: inline-block; - min-width: 6em; - position: relative; - top: 0px; - vertical-align: baseline; - margin: 0 0.1em; - outline: none; - cursor: pointer; - text-align: center; - text-decoration: none; - padding: 0.1em 0.6em; - border-top-left-radius: 1em; - border-bottom-right-radius: 1em; - border-top-right-radius: 0.2em; - border-bottom-left-radius: 0.2em; - + background: transparent; + border: 0; color: var(--color4); + border-radius: 4px; transition: background-color var(--fade), color var(--fade); - - &:link, - &:visited { - color: var(--color4); - background-color: var(--color1); + &.primary { + border: 1px solid var(--color1); + background-color: var(--color3); } - - &:hover, - &:active { - color: var(--color1); + &.critical { + border: 1px solid var(--color1); + background-color: var(--color3); + font-weight: bold; + color: #900; + } + &:hover:enabled { background-color: var(--color4); + color: var(--color3); } - &:disabled { - background-color: var(--color1); - color: var(--color2); + color: var(--disabled); + cursor: not-allowed; } } @@ -399,23 +385,6 @@ table.parameter-list { } } -table.parameter-table { - border-spacing: 0; - text-align: left; - - & th { - color: var(--color4); - border-bottom: 2px solid var(--color4); - margin: 0; - padding: 0.2em 0.5em; - } - - & td { - margin: 0; - padding: 0.2em 0.5em; - } -} - .overview-dot { & > svg { margin-bottom: -0.125em; diff --git a/client/devices-page.js b/client/devices-page.js index 14cb429..354d69d 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -16,51 +16,102 @@ function init(args) { }); } -function renderTable(devices, parameters, total, showMoreCallback) { - let labels = []; +function renderTable( + devicesResponse, + parameters, + total, + selected, + showMoreCallback +) { + const devices = devicesResponse.value; + const selectAll = m("input", { + type: "checkbox", + checked: devices.length && selected.size === devices.length, + onchange: e => { + for (let d of devices) + if (e.target.checked) selected.add(d["DeviceID.ID"].value[0]); + else selected.delete(d["DeviceID.ID"].value[0]); + }, + disabled: !total + }); + + let labels = [m("th", selectAll)]; for (let param of parameters) labels.push(m("th", param.label)); let rows = []; - for (let device of devices) + for (let device of devices) { + let checkbox = m("input", { + type: "checkbox", + checked: selected.has(device["DeviceID.ID"].value[0]), + onchange: e => { + if (e.target.checked) selected.add(device["DeviceID.ID"].value[0]); + else selected.delete(device["DeviceID.ID"].value[0]); + } + }); + rows.push( m( "tr", - parameters - .map(p => { - const attrs = Object.assign({ device: device }, p); - const comp = m(components.get(attrs.type || "parameter"), attrs); - return m("td", comp); - }) - .concat( - m( - "td.table-row-links", - m( - "a", - { - href: `#!/devices/${encodeURIComponent( - device["DeviceID.ID"].value[0] - )}` - }, - "Show" - ) - ) + { + onclick: e => { + if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { + e.redraw = false; + return; + } + + if (!selected.delete(device["DeviceID.ID"].value[0])) + selected.add(device["DeviceID.ID"].value[0]); + } + }, + m("td", checkbox), + parameters.map(p => { + const attrs = Object.assign({ device: device }, p); + const comp = m(components.get(attrs.type || "parameter"), attrs); + return m("td", comp); + }), + m( + "td.table-row-links", + m( + "a", + { + href: `#!/devices/${encodeURIComponent( + device["DeviceID.ID"].value[0] + )}` + }, + "Show" ) + ) ) ); + } + + if (!rows.length) + rows.push( + m("tr.empty", m("td", { colspan: parameters.length + 1 }, "No devices")) + ); let footerElements = []; if (total != null) footerElements.push(`${devices.length}/${total}`); else footerElements.push(`${devices.length}`); - if (devices.length < total) - footerElements.push(m("a", { onclick: showMoreCallback }, "More")); + footerElements.push( + m( + "button", + { + title: "Show more devices", + onclick: showMoreCallback, + disabled: devices.length >= total || !devicesResponse.fulfilled + }, + "More" + ) + ); let tfoot = m( "tfoot", m("tr", m("td", { colspan: labels.length }, footerElements)) ); - return m("table.table", [ + return m("table.table.highlight", [ m("thead", m("tr", labels)), m("tbody", rows), tfoot @@ -89,16 +140,25 @@ const component = { ); let count = store.count("devices", vnode.attrs.filter); + let selected = new Set(); + if (vnode.state.selected) + for (let d of devs.value) + if (vnode.state.selected.has(d["DeviceID.ID"].value[0])) + selected.add(d["DeviceID.ID"].value[0]); + vnode.state.selected = selected; + return [ m("h1", "Listing devices"), m(filterComponent, { + predefined: Object.values(config.get("ui.filters")), filter: vnode.attrs.filter ? vnode.attrs.filter.ast : null, onChange: onFilterChanged }), renderTable( - devs.value, + devs, Object.values(config.get("ui.index")), count.value, + selected, showMore ) ]; diff --git a/client/drawer-component.js b/client/drawer-component.js index ee8380a..3af1f3c 100644 --- a/client/drawer-component.js +++ b/client/drawer-component.js @@ -44,9 +44,17 @@ function renderStaging(staging) { vnode.dom.parentNode.parentNode.scrollTop = 0; } }); - const queue = m("a", { onclick: queueFunc }, "Queue"); - const cancel = m("a", { onclick: cancelFunc }, "Cancel"); + const queue = m( + "button.primary", + { title: "Queue task", onclick: queueFunc }, + "Queue" + ); + const cancel = m( + "button", + { title: "Cancel edit", onclick: cancelFunc }, + "Cancel" + ); elements.push( m( @@ -71,22 +79,14 @@ function renderQueue(queue) { for (let [k, v] of Object.entries(devices)) { details.push(m("strong", k)); for (let t of v) { - let actions = [ - m( - "a.cancel", - { - onclick: () => { - taskQueue.deleteTask(t); - } - }, - "✕" - ) - ]; + let actions = []; + if (t.status === "fault" || t.status === "stale") actions.push( m( - "a.retry", + "button", { + title: "Retry this task", onclick: () => { taskQueue.queueTask(t); } @@ -95,43 +95,73 @@ function renderQueue(queue) { ) ); + actions.push( + m( + "button", + { + title: "Remove this task", + onclick: () => { + taskQueue.deleteTask(t); + } + }, + "✕" + ) + ); + if (t.name === "setParameterValues") details.push( m( `div.${t.status}`, - actions, - "Set ", - mparam(t.parameterValues[0][0]), - " to '", - mval(t.parameterValues[0][1]), - "'" + m( + "span", + "Set ", + mparam(t.parameterValues[0][0]), + " to '", + mval(t.parameterValues[0][1]), + "'" + ), + m(".actions", actions) ) ); else if (t.name === "refreshObject") details.push( - m(`div.${t.status}`, actions, "Refresh ", mparam(t.parameterName)) + m( + `div.${t.status}`, + m("span", "Refresh ", mparam(t.parameterName)), + m(".actions", actions) + ) ); else if (t.name === "reboot") - details.push(m(`div.${t.status}`, actions, "Reboot")); + details.push(m(`div.${t.status}`, "Reboot", m(".actions", actions))); else if (t.name === "factoryReset") - details.push(m(`div.${t.status}`, actions, "Factory reset")); + details.push( + m(`div.${t.status}`, "Factory reset", m(".actions", actions)) + ); else if (t.name === "addObject") details.push( - m(`div.${t.status}`, actions, "Add ", mparam(t.objectName)) + m( + `div.${t.status}`, + m("span", "Add ", mparam(t.objectName)), + m(".actions", actions) + ) ); else if (t.name === "deleteObject") details.push( - m(`div.${t.status}`, actions, "Delete ", mparam(t.objectName)) + m( + `div.${t.status}`, + m("span", "Delete ", mparam(t.objectName)), + m(".actions", actions) + ) ); else if (t.name === "getParameterValues") details.push( m( `div.${t.status}`, - actions, - `Refresh ${t.parameterNames.length} parameters` + `Refresh ${t.parameterNames.length} parameters`, + m(".actions", actions) ) ); - else details.push(m(`div.${t.status}`, actions, t.name)); + else details.push(m(`div.${t.status}`, t.name, m(".actions", actions))); } } @@ -198,71 +228,71 @@ const component = { } if (stagingElements.length + queueElements.length) { - const statusCount = { queued: 0, pending: 0, faulty: 0, stale: 0 }; + const statusCount = { queued: 0, pending: 0, fault: 0, stale: 0 }; for (let t of queue) statusCount[t.status] += 1; - let actions; - if (queueElements.length) - if (statusCount.queued) { - actions = m( - ".actions", - m("a.clear", { onclick: taskQueue.clear }, "Clear"), - m( - "a.commit", - { - onclick: () => { - let tasks = Array.from(taskQueue.getQueue()).filter( - t => t.status === "queued" - ); - taskQueue - .commit( - tasks, - (deviceId, connectionRequestStatus, tasks2) => { - if (connectionRequestStatus !== "OK") - notifications.push( - "error", - `${deviceId}: ${connectionRequestStatus}` - ); - else - for (let t of tasks2) - if (t.status === "stale") { - notifications.push( - "error", - `${deviceId}: No contact from device` - ); - return; - } else if (t.status === "fault") { - notifications.push( - "error", - `${deviceId}: Task(s) faulted` - ); - return; - } + let actions = m( + ".actions", + m( + "button.primary", + { + title: "Commit queued tasks", + disabled: !statusCount.queued, + onclick: () => { + let tasks = Array.from(taskQueue.getQueue()).filter( + t => t.status === "queued" + ); + taskQueue + .commit(tasks, (deviceId, connectionRequestStatus, tasks2) => { + if (connectionRequestStatus !== "OK") { + notifications.push( + "error", + `${deviceId}: ${connectionRequestStatus}` + ); + return; + } - notifications.push( - "success", - `${deviceId}: Task(s) committed` - ); - } - ) - .then(() => { - store.fulfill(Date.now(), Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - throw err; - }); - } - }, - "Commit" - ) - ); - } else { - actions = m( - ".actions", - m("a.clear", { onclick: taskQueue.clear }, "Clear") - ); - } + for (let t of tasks2) + if (t.status === "stale") { + notifications.push( + "error", + `${deviceId}: No contact from device` + ); + return; + } else if (t.status === "fault") { + notifications.push( + "error", + `${deviceId}: Task(s) faulted` + ); + return; + } + + notifications.push( + "success", + `${deviceId}: Task(s) committed` + ); + }) + .then(() => { + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + throw err; + }); + } + }, + "Commit" + ), + m( + "button", + { + title: "Clear tasks", + onclick: taskQueue.clear, + disabled: !queueElements.length + }, + "Clear" + ) + ); statusElement = m( ".status", @@ -277,9 +307,9 @@ const component = { `Pending: ${statusCount.pending}` ), m( - "span.faulty", - { class: statusCount.faulty ? "active" : "" }, - `Faulty: ${statusCount.faulty}` + "span.fault", + { class: statusCount.fault ? "active" : "" }, + `Fault: ${statusCount.fault}` ), m( "span.stale", diff --git a/client/faults-page.js b/client/faults-page.js new file mode 100644 index 0000000..571ef10 --- /dev/null +++ b/client/faults-page.js @@ -0,0 +1,188 @@ +"use strict"; + +import m from "mithril"; + +import filterComponent from "./filter-component"; +import * as filterParser from "../common/filter-parser"; +import Filter from "../common/filter"; +import * as store from "./store"; +import * as notifications from "./notifications"; + +function init(args) { + return new Promise(resolve => { + let filter = new Filter(args.filter); + resolve({ filter: filter }); + }); +} + +function renderTable(faultsResponse, total, selected, showMoreCallback) { + const faults = faultsResponse.value; + const selectAll = m("input", { + type: "checkbox", + checked: faults.length && selected.size === faults.length, + onchange: e => { + for (let f of faults) + if (e.target.checked) selected.add(f["_id"]); + else selected.delete(f["_id"]); + }, + disabled: !total + }); + + const labels = [ + selectAll, + "Device", + "Channel", + "Code", + "Message", + "Retries", + "Timestamp" + ].map(l => m("th", l)); + + let rows = []; + for (let f of faults) { + let checkbox = m("input", { + type: "checkbox", + checked: selected.has(f["_id"]), + onchange: e => { + if (e.target.checked) selected.add(f["_id"]); + else selected.delete(f["_id"]); + }, + onclick: e => { + e.stopPropagation(); + e.redraw = false; + } + }); + + const deviceHref = `#!/devices/${encodeURIComponent(f["device"])}`; + + rows.push( + m( + "tr", + { + onclick: e => { + if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { + e.redraw = false; + return; + } + + if (!selected.delete(f["_id"])) selected.add(f["_id"]); + } + }, + m("td", checkbox), + m("td", m("a", { href: deviceHref }, f["device"])), + m("td", f["channel"]), + m("td", f["code"]), + m("td", f["message"]), + m("td", f["retries"]), + m("td", f["timestamp"]) + ) + ); + } + + if (!rows.length) + rows.push(m("tr.empty", m("td", { colspan: 7 }, "No faults"))); + + let footerElements = []; + if (total != null) footerElements.push(`${faults.length}/${total}`); + else footerElements.push(`${faults.length}`); + + footerElements.push( + m( + "button", + { + title: "Show more faults", + onclick: showMoreCallback, + disabled: faults.length >= total || !faultsResponse.fulfilled + }, + "More" + ) + ); + + let tfoot = m( + "tfoot", + m("tr", m("td", { colspan: labels.length }, footerElements)) + ); + + const buttons = [ + m( + "button.primary", + { + title: "Delete selected faults", + disabled: !selected.size, + onclick: e => { + e.redraw = false; + e.target.disabled = true; + Promise.all( + Array.from(selected).map(id => store.deleteResource("faults", id)) + ) + .then(res => { + notifications.push("success", `${res.length} faults deleted`); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } + }, + "Delete" + ) + ]; + + return [ + m( + "table.table.highlight", + m("thead", m("tr", labels)), + m("tbody", rows), + tfoot + ), + buttons + ]; +} + +const component = { + view: vnode => { + document.title = "Faults - GenieACS"; + + function showMore() { + vnode.state.showCount = (vnode.state.showCount || 10) + 10; + m.redraw(); + } + + function onFilterChanged(filter) { + let params = {}; + if (filter) params.filter = filterParser.stringify(filter); + m.route.set("/faults", params); + } + + let faults = store.fetch( + "faults", + vnode.attrs.filter, + vnode.state.showCount || 10 + ); + let count = store.count("faults", vnode.attrs.filter); + + let selected = new Set(); + if (vnode.state.selected) + for (let f of faults.value) + if (vnode.state.selected.has(f["_id"])) selected.add(f["_id"]); + vnode.state.selected = selected; + + return [ + m("h1", "Listing faults"), + m(filterComponent, { + predefined: [ + { parameter: "device" }, + { parameter: "channel" }, + { parameter: "code" }, + { parameter: "retries" }, + { parameter: "timestamp" } + ], + filter: vnode.attrs.filter ? vnode.attrs.filter.ast : null, + onChange: onFilterChanged + }), + renderTable(faults, count.value, selected, showMore) + ]; + } +}; + +export { init, component }; diff --git a/client/filter-component.js b/client/filter-component.js index 9d1caac..e362da4 100644 --- a/client/filter-component.js +++ b/client/filter-component.js @@ -2,16 +2,14 @@ import m from "mithril"; -import * as config from "./config"; import * as filterParser from "../common/filter-parser"; const component = { view: vnode => { + const predefined = vnode.attrs.predefined || []; let a = m( "datalist#filters", - Object.values(config.get("ui.filters")).map(f => - m("option", { value: `${f.parameter} = ` }) - ) + predefined.map(f => m("option", { value: `${f.parameter} = ` })) ); if (vnode.attrs.filter !== vnode.state.filter) { diff --git a/client/menu.js b/client/menu.js index d7c32bb..6a94754 100644 --- a/client/menu.js +++ b/client/menu.js @@ -18,6 +18,11 @@ const menu = { "li", { class: active["devices"] }, m("a", { href: "#!/devices" }, "Devices") + ), + m( + "li", + { class: active["faults"] }, + m("a", { href: "#!/faults" }, "Faults") ) ]) ); diff --git a/client/store.js b/client/store.js index 9d6e0b2..76333c5 100644 --- a/client/store.js +++ b/client/store.js @@ -20,7 +20,7 @@ const queries = { }; const resources = {}; -for (let r of ["devices"]) +for (let r of ["devices", "faults"]) resources[r] = { objects: new Map(), count: new Map(), @@ -127,12 +127,15 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { const allPromises = []; for (let [resourceType, resource] of Object.entries(resources)) - for (let queryResponse of resource.count.values()) { + for (let [queryResponseKey, queryResponse] of resource.count) { + if (!(queries.accessed.get(queryResponse) >= accessTimestamp)) { + resource.count.delete(queryResponseKey); + continue; + } + if (queries.fulfilling.has(queryResponse)) continue; - if ( - queries.accessed.get(queryResponse) >= accessTimestamp && - !(fulfillTimestamp <= queries.fulfilled.get(queryResponse)) - ) { + + if (!(fulfillTimestamp <= queries.fulfilled.get(queryResponse))) { queries.fulfilling.add(queryResponse); allPromises.push( new Promise((resolve, reject) => { @@ -165,13 +168,15 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { let toFetchAll = {}; for (let [resourceType, resource] of Object.entries(resources)) - for (let queryResponse of resource.fetch.values()) { + for (let [queryResponseKey, queryResponse] of resource.fetch) { + if (!(queries.accessed.get(queryResponse) >= accessTimestamp)) { + resource.fetch.delete(queryResponseKey); + continue; + } + if (queries.fulfilling.has(queryResponse)) continue; - if ( - queries.accessed.get(queryResponse) >= accessTimestamp && - !(fulfillTimestamp <= queries.fulfilled.get(queryResponse)) - ) { + if (!(fulfillTimestamp <= queries.fulfilled.get(queryResponse))) { queries.fulfilling.add(queryResponse); toFetchAll[resourceType] = toFetchAll[resourceType] || []; toFetchAll[resourceType].push(queryResponse); @@ -244,6 +249,9 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { if (!combinedFilter) return resolve(updated); updated = true; + let deleted = new Set(); + if (!resources[resourceType].combinedFilter) + deleted = new Set(resources[resourceType].objects.keys()); let combinedFilterDiff = combinedFilter; if (resources[resourceType].combinedFilter) combinedFilterDiff = combinedFilterDiff.and( @@ -265,13 +273,20 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { }) }) .then(res => { - for (let r of res) - if (resourceType === "devices") - resources.devices.objects.set( - r["DeviceID.ID"].value[0], - r - ); - else resources[resourceType].objects.set(r["_id"], r); + for (let r of res) { + const id = + resourceType === "devices" + ? r["DeviceID.ID"].value[0] + : r["_id"]; + resources[resourceType].objects.set(id, r); + deleted.delete(id); + } + + for (let d of deleted) { + const obj = resources[resourceType].objects.get(d); + if (combinedFilterDiff.test(obj)) + resources[resourceType].objects.delete(d); + } for (let queryResponse of toFetch) { let filter = queries.filter.get(queryResponse); @@ -344,4 +359,19 @@ function postTasks(tasks, callback) { }); } -export { count, fetch, fulfill, unpackFilter, getTimestamp, postTasks }; +function deleteResource(resourceType, id) { + return m.request({ + method: "DELETE", + url: `/api/${resourceType}/${encodeURIComponent(id)}` + }); +} + +export { + count, + fetch, + fulfill, + unpackFilter, + getTimestamp, + postTasks, + deleteResource +}; diff --git a/config.json b/config.json index 24fa0c0..780bb8d 100644 --- a/config.json +++ b/config.json @@ -91,5 +91,6 @@ "ui.device.2.parameters.2.label": "IP address", "ui.device.2.parameters.2.parameter": "IPAddress", "ui.device.2.parameters.3.label": "MAC address", - "ui.device.2.parameters.3.parameter": "MACAddress" + "ui.device.2.parameters.3.parameter": "MACAddress", + "ui.device.3.type": "device-faults" } diff --git a/server/api-functions.js b/server/api-functions.js index db685d9..7e066ba 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -265,7 +265,7 @@ function postTask(task, connectionRequest, callback) { if ( res.statusCode === 202 && res.statusMessage !== "Task queued but not processed" && - res.statusMessage !== "Task faulte" + res.statusMessage !== "Task faulted" ) connectionRequestStatus = res.statusMessage; } @@ -305,18 +305,28 @@ function postTasks(deviceId, tasks) { connectionRequest: connectionRequestStatus, tasks: statuses }); - Promise.all( - statuses.map(s => query("tasks", new Filter(["=", "_id", s._id]))) - ) + + let promises2 = []; + for (let s of statuses) { + promises2.push(query("tasks", new Filter(["=", "_id", s._id]))); + promises2.push( + query( + "faults", + new Filter(["=", "_id", `${deviceId}:task_${s._id}`]) + ) + ); + } + + Promise.all(promises2) .then(res => { - for (let [i, r] of res.entries()) { - if (!r[0]) { - statuses[i].status = "done"; - } else if (r[0].fault) { - statuses[i].status = "fault"; - statuses[i].fault = r[0].fault; + for (let [i, r] of statuses.entries()) { + if (res[i * 2].length === 0) { + r.status = "done"; + } else if (res[i * 2 + 1].length === 1) { + r.status = "fault"; + r.fault = res[i * 2 + 1][0]; } - deleteResource("tasks", statuses[i]._id); + deleteResource("tasks", r._id); } resolve({ @@ -333,3 +343,4 @@ function postTasks(deviceId, tasks) { exports.query = query; exports.count = count; exports.postTasks = postTasks; +exports.deleteResource = deleteResource; diff --git a/server/api.js b/server/api.js index 01b84a4..8c6ccc2 100644 --- a/server/api.js +++ b/server/api.js @@ -13,6 +13,7 @@ const resources = { provisions: true, files: true, virtual_parameters: true, + faults: true, tasks: false }; @@ -22,6 +23,12 @@ for (let [resource, update] of Object.entries(resources)) { if (ctx.request.query.filter) filter = new Filter(ctx.request.query.filter); if (ctx.request.query.limit) limit = +ctx.request.query.limit; + // Exclude temporary tasks and faults + if (resource === "tasks" || resource === "faults") { + let f = new Filter(["NOT", ["<", "expiry", Date.now() + 60000]]); + filter = f.and(filter); + } + let count = await apiFunctions.count(resource, filter, limit); ctx.set("X-Total-Count", count); ctx.body = ""; @@ -33,6 +40,12 @@ for (let [resource, update] of Object.entries(resources)) { if (ctx.request.query.limit) limit = +ctx.request.query.limit; if (ctx.request.query.skip) skip = +ctx.request.query.skip; + // Exclude temporary tasks and faults + if (resource === "tasks" || resource === "faults") { + let f = new Filter(["NOT", ["<", "expiry", Date.now() + 60000]]); + filter = f.and(filter); + } + ctx.body = await apiFunctions.query( resource, filter, @@ -56,9 +69,14 @@ for (let [resource, update] of Object.entries(resources)) { ctx.body = res[0]; }); - if (update) { - // TODO add PUT, PATCH, DELETE routes - } + // TODO add PUT, PATCH routes + if (update) + router.delete(`/${resource}/:id`, async ctx => { + let found = await apiFunctions.deleteResource(resource, ctx.params.id); + if (found) ctx.status = 200; + else ctx.status = 404; + ctx.body = ""; + }); } router.post("/devices/:id/tasks", async ctx => { From 957417837e3f6bf860ee13237e43511f1ef9ef16 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 23 Dec 2018 03:26:12 -0800 Subject: [PATCH 015/106] Some refactoring --- client/components/parameter-list.js | 40 +++++++++++++++++++--------- client/components/parameter-table.js | 29 +++++++++++++++----- client/components/parameter.js | 10 +++---- client/components/summon-button.js | 14 ++++++++-- client/devices-page.js | 14 +++++++--- client/store.js | 31 +++++++++++++++++---- common/filter-parser.js | 32 ++++++++++++++++++++++ common/filter.js | 25 +++-------------- config.json | 1 + server/device.js | 4 +-- 10 files changed, 142 insertions(+), 58 deletions(-) diff --git a/client/components/parameter-list.js b/client/components/parameter-list.js index d0e1af5..9636851 100644 --- a/client/components/parameter-list.js +++ b/client/components/parameter-list.js @@ -2,27 +2,41 @@ import m from "mithril"; import * as components from "../components"; +import * as store from "../store"; +import * as filterParser from "../../common/filter-parser"; const component = { + oninit: vnode => { + vnode.state.parameters = Object.values(vnode.attrs.parameters).map( + parameter => + Object.assign({}, parameter, { + parameter: filterParser.parseParameter(parameter.parameter) + }) + ); + }, view: vnode => { const device = vnode.attrs.device; - const parameters = vnode.attrs.parameters; - const filtered = Object.values(parameters).filter( - p => p.parameter in device - ); - const rows = filtered.map(p => - m( - "tr", - m("th", p.label), + + const rows = []; + for (let parameter of vnode.state.parameters) { + let p = store.evaluateExpression(parameter.parameter, device); + + if (!(p in device)) continue; + rows.push( m( - "td", + "tr", + m("th", parameter.label), m( - components.get(p.type || "parameter"), - Object.assign({ device: device }, p) + "td", + m( + components.get(parameter.type || "parameter"), + Object.assign({}, parameter, { device: device, parameter: p }) + ) ) ) - ) - ); + ); + } + return m("table.parameter-list", rows); } }; diff --git a/client/components/parameter-table.js b/client/components/parameter-table.js index b11fb29..f3d1d4f 100644 --- a/client/components/parameter-table.js +++ b/client/components/parameter-table.js @@ -3,12 +3,23 @@ import m from "mithril"; import * as components from "../components"; import * as taskQueue from "../task-queue"; +import * as store from "../store"; +import * as filterParser from "../../common/filter-parser"; const component = { + oninit: vnode => { + vnode.state.object = filterParser.parseParameter(vnode.attrs.object); + vnode.state.parameters = Object.values(vnode.attrs.parameters).map( + parameter => { + let p = filterParser.parseParameter(parameter.parameter); + return Object.assign({}, parameter, { parameter: p }); + } + ); + }, view: vnode => { const device = vnode.attrs.device; - const object = vnode.attrs.object; - const parameters = vnode.attrs.parameters; + const object = store.evaluateExpression(vnode.state.object, device); + const parameters = vnode.state.parameters; if (!device[object]) return null; @@ -27,17 +38,19 @@ const component = { const rows = []; for (let i of instances) { - const row = Object.values(parameters).map(p => + const row = parameters.map(p => m( "td", m( components.get("parameter"), - Object.assign({ device: device }, p, { - parameter: `${i}.${p.parameter}` + Object.assign({}, p, { + device: device, + parameter: `${i}.${store.evaluateExpression(p.parameter, device)}` }) ) ) ); + if (device[i].writable === true) row.push( m( @@ -90,7 +103,11 @@ const component = { ) ) ); - return m("table.table", thead, m("tbody", rows)); + + let label; + if (vnode.attrs.label) label = m("h2", vnode.attrs.label); + + return [label, m("table.table", thead, m("tbody", rows))]; } }; diff --git a/client/components/parameter.js b/client/components/parameter.js index 7a8e520..0e75687 100644 --- a/client/components/parameter.js +++ b/client/components/parameter.js @@ -5,7 +5,9 @@ import * as taskQueue from "../task-queue"; const component = { view: vnode => { - const param = vnode.attrs.device[vnode.attrs.parameter]; + const device = vnode.attrs.device; + const parameter = vnode.attrs.parameter; + const param = device[parameter]; if (!param || !param.value) return m("span.na", "N/A"); let value = param.value[0]; if (param.value[1] === "xsd:dateTime") @@ -20,10 +22,8 @@ const component = { onclick: () => { taskQueue.stageSpv({ name: "setParameterValues", - device: vnode.attrs.device["DeviceID.ID"].value[0], - parameterValues: [ - [vnode.attrs.parameter, param.value[0], param.value[1]] - ] + device: device["DeviceID.ID"].value[0], + parameterValues: [[parameter, param.value[0], param.value[1]]] }); } }, diff --git a/client/components/summon-button.js b/client/components/summon-button.js index e814e20..81c2f7f 100644 --- a/client/components/summon-button.js +++ b/client/components/summon-button.js @@ -4,20 +4,30 @@ import m from "mithril"; import * as taskQueue from "../task-queue"; import * as store from "../store"; import * as notifications from "../notifications"; +import * as filterParser from "../../common/filter-parser"; const component = { + oninit: vnode => { + vnode.state.parameters = Object.values(vnode.attrs.parameters).map( + parameter => filterParser.parseParameter(parameter) + ); + }, view: vnode => { + const device = vnode.attrs.device; + return m( "button.primary", { title: "Initiate session and refresh basic parameters", onclick: e => { e.target.disabled = true; - const params = Object.values(vnode.attrs.parameters); + const params = vnode.state.parameters.map(p => + store.evaluateExpression(p, device) + ); const task = { name: "getParameterValues", parameterNames: params, - device: vnode.attrs.device["DeviceID.ID"].value[0] + device: device["DeviceID.ID"].value[0] }; taskQueue diff --git a/client/devices-page.js b/client/devices-page.js index 354d69d..0bf2109 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -12,7 +12,12 @@ import * as components from "./components"; function init(args) { return new Promise(resolve => { let filter = new Filter(args.filter); - resolve({ filter: filter }); + let indexParameters = Object.values(config.get("ui.index")).map(p => + Object.assign({}, p, { + parameter: filterParser.parseParameter(p.parameter) + }) + ); + resolve({ filter: filter, indexParameters: indexParameters }); }); } @@ -65,7 +70,10 @@ function renderTable( }, m("td", checkbox), parameters.map(p => { - const attrs = Object.assign({ device: device }, p); + const attrs = Object.assign({}, p, { + device: device, + parameter: store.evaluateExpression(p.parameter, device) + }); const comp = m(components.get(attrs.type || "parameter"), attrs); return m("td", comp); }), @@ -156,7 +164,7 @@ const component = { }), renderTable( devs, - Object.values(config.get("ui.index")), + vnode.attrs.indexParameters, count.value, selected, showMore diff --git a/client/store.js b/client/store.js index 76333c5..c9adbf1 100644 --- a/client/store.js +++ b/client/store.js @@ -3,10 +3,12 @@ import m from "mithril"; import Filter from "../common/filter"; import * as config from "./config"; +import * as filterParser from "../common/filter-parser"; let fulfillTimestamp = 0; -let unpackedFilters = new WeakMap(); +let unpackedFiltersCache = new WeakMap(); +let expressionsCache = new WeakMap(); const queries = { filter: new WeakMap(), @@ -45,10 +47,10 @@ class QueryResponse { } function unpackFilter(filter) { - let f = unpackedFilters.get(filter); + let f = unpackedFiltersCache.get(filter); if (!f) { f = f.evaluateExpressions(fulfillTimestamp); - unpackedFilters.set(filter, f); + unpackedFiltersCache.set(filter, f); } return f; } @@ -118,7 +120,9 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { let updated = false; if (_fulfillTimestamp > fulfillTimestamp) { - unpackedFilters = new WeakMap(); + unpackedFiltersCache = new WeakMap(); + expressionsCache = new WeakMap(); + for (let resource of Object.values(resources)) resource.combinedFilter = null; fulfillTimestamp = _fulfillTimestamp; @@ -366,6 +370,22 @@ function deleteResource(resourceType, id) { }); } +function evaluateExpression(exp, device) { + if (!Array.isArray(exp)) return exp; + + let exps = expressionsCache.get(exp); + if (!exps) expressionsCache.set(exp, (exps = new WeakMap())); + + if (!exps.has(device)) { + let v = filterParser.evaluateExpressions(exp, e => { + if (e[0] === "FUNC" && e[1] === "NOW") return fulfillTimestamp; + }); + exps.set(device, v); + } + + return exps.get(device); +} + export { count, fetch, @@ -373,5 +393,6 @@ export { unpackFilter, getTimestamp, postTasks, - deleteResource + deleteResource, + evaluateExpression }; diff --git a/common/filter-parser.js b/common/filter-parser.js index 7be373e..3063de3 100644 --- a/common/filter-parser.js +++ b/common/filter-parser.js @@ -205,6 +205,36 @@ function parseExpression(exp) { return lang.ValueExpression.tryParse(exp); } +function evaluateExpressions(ast, funcCallback) { + return map(ast, exp => { + if (exp[0] === "FUNC") { + return funcCallback(exp); + } else if (exp[0] === "*") { + let v = exp[1]; + for (let i = 2; i < exp.length; ++i) v *= exp[i]; + return v; + } else if (exp[0] === "/") { + let v = exp[1]; + for (let i = 2; i < exp.length; ++i) v /= exp[i]; + return v; + } else if (exp[0] === "+") { + let v = exp[1]; + for (let i = 2; i < exp.length; ++i) v += exp[i]; + return v; + } else if (exp[0] === "-") { + let v = exp[1]; + for (let i = 2; i < exp.length; ++i) v -= exp[i]; + return v; + } else if (exp[0] === "||") { + return exp.slice(1).join(""); + } + }); +} + +function parseParameter(p) { + return lang.Parameter.tryParse(p); +} + function stringify(filter) { function value(v) { if (Array.isArray(v)) @@ -275,5 +305,7 @@ function map(filter, callback) { exports.parse = parse; exports.parseExpression = parseExpression; +exports.evaluateExpressions = evaluateExpressions; +exports.parseParameter = parseParameter; exports.stringify = stringify; exports.map = map; diff --git a/common/filter.js b/common/filter.js index c4afc6c..4a25086 100644 --- a/common/filter.js +++ b/common/filter.js @@ -67,28 +67,9 @@ class Filter { evaluateExpressions(now = Date.now()) { if (!this.ast) return this; - let ast = filterParser.map(this.ast, exp => { - if (exp[0] === "FUNC" && exp[1] === "NOW") { - return now; - } else if (exp[0] === "*") { - let v = exp[1]; - for (let i = 2; i < exp.length; ++i) v *= exp[i]; - return v; - } else if (exp[0] === "/") { - let v = exp[1]; - for (let i = 2; i < exp.length; ++i) v /= exp[i]; - return v; - } else if (exp[0] === "+") { - let v = exp[1]; - for (let i = 2; i < exp.length; ++i) v += exp[i]; - return v; - } else if (exp[0] === "-") { - let v = exp[1]; - for (let i = 2; i < exp.length; ++i) v -= exp[i]; - return v; - } else if (exp[0] === "||") { - return exp.slice(1).join(""); - } + + let ast = filterParser.evaluateExpressions(this.ast, exp => { + if (exp[0] === "FUNC" && exp[1] === "NOW") return now; }); if (ast === this.ast) return this; diff --git a/config.json b/config.json index 780bb8d..c0a7db6 100644 --- a/config.json +++ b/config.json @@ -85,6 +85,7 @@ "ui.device.1.parameters.11.label": "WLAN passphrase", "ui.device.1.parameters.11.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase", "ui.device.2.type": "parameter-table", + "ui.device.2.label": "WLAN hosts", "ui.device.2.object": "InternetGatewayDevice.LANDevice.1.Hosts.Host", "ui.device.2.parameters.1.label": "Host name", "ui.device.2.parameters.1.parameter": "HostName", diff --git a/server/device.js b/server/device.js index 99470f2..28da8d0 100644 --- a/server/device.js +++ b/server/device.js @@ -109,9 +109,9 @@ function recursive(input, root, output, timestamp) { childrenTimestamp = input["_timestamp"]; const attrs = {}; - if (tree["_value"]) { + if (tree["_value"] != null) { attrs["value"] = [tree["_value"], tree["_type"]]; - attrs["valueTimestamp"] = childrenTimestamp; + attrs["valueTimestamp"] = tree["_timestamp"] || childrenTimestamp; attrs["object"] = false; attrs["objectTimestamp"] = childrenTimestamp; } else if (tree["_object"] != null) { From 3786f71565e5b4bf1da274e6879cedc421919880 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Tue, 24 Apr 2018 01:18:37 -0700 Subject: [PATCH 016/106] Implement all params component --- client/components.js | 2 + client/components/all-parameters.js | 117 ++++++++++++++++++++++++++++ client/css/app.css | 43 ++++++++++ config.json | 3 +- 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 client/components/all-parameters.js diff --git a/client/components.js b/client/components.js index bbaf75b..277f209 100644 --- a/client/components.js +++ b/client/components.js @@ -7,6 +7,7 @@ import overviewDot from "./components/overview-dot"; import container from "./components/container"; import summonButton from "./components/summon-button"; import deviceFaults from "./components/device-faults"; +import allParameters from "./components/all-parameters"; function get(name) { if (name === "parameter") return parameter; @@ -16,6 +17,7 @@ function get(name) { else if (name === "container") return container; else if (name === "summon-button") return summonButton; else if (name === "device-faults") return deviceFaults; + else if (name === "all-parameters") return allParameters; else throw new Error(`No such component '${name}'`); } diff --git a/client/components/all-parameters.js b/client/components/all-parameters.js new file mode 100644 index 0000000..984bc69 --- /dev/null +++ b/client/components/all-parameters.js @@ -0,0 +1,117 @@ +"use strict"; + +import m from "mithril"; +import * as components from "../components"; +import * as taskQueue from "../task-queue"; + +function escapeRegExp(str) { + return str.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&"); +} + +const component = { + view: vnode => { + const device = vnode.attrs.device; + + let search = m("input", { + type: "text", + placeholder: "Search parameters", + oninput: e => { + vnode.state.searchString = e.target.value; + } + }); + + let instanceRegex = /\.[0-9]+$/; + let re; + if (vnode.state.searchString) { + let keywords = vnode.state.searchString.split(" ").filter(s => s); + if (keywords.length) + re = new RegExp(keywords.map(s => escapeRegExp(s)).join(".*"), "i"); + } + + const rows = Object.keys(device) + .sort() + .map(k => { + let p = device[k]; + let val = []; + let attrs = {}; + if (re) { + let str = p.value && p.value[0] ? `${k} ${p.value[0]}` : k; + if (!re.test(str)) attrs.style = "display: none;"; + } + + if (p.object === false) + val.push( + m( + components.get("parameter"), + Object.assign({ device: device, parameter: k }) + ) + ); + else if (p.object && p.writable) + if (instanceRegex.test(k)) { + val.push( + m( + "button", + { + title: "Delete this instance", + onclick: () => { + taskQueue.queueTask({ + name: "deleteObject", + device: device["DeviceID.ID"].value[0], + objectName: k + }); + } + }, + "✕" + ) + ); + } else { + val.push( + m( + "button", + { + title: "Create a new instance", + onclick: () => { + taskQueue.queueTask({ + name: "addObject", + device: device["DeviceID.ID"].value[0], + objectName: k + }); + } + }, + "🞢" + ) + ); + } + + val.push( + m( + "button", + { + title: "Refresh tree", + onclick: () => { + taskQueue.queueTask({ + name: "getParameterValues", + device: device["DeviceID.ID"].value[0], + parameterNames: [k] + }); + } + }, + "↺" + ) + ); + + return m("tr", attrs, m("td.left", k), m("td.right", val)); + }); + + return [ + m("h2", "Device parameters"), + m( + ".all-parameters", + search, + m(".parameter-list", m("table", m("tbody", rows))) + ) + ]; + } +}; + +export default component; diff --git a/client/css/app.css b/client/css/app.css index 77bd893..0a27ddd 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -447,3 +447,46 @@ span.inform { } } } + +span.na { + color: var(--disabled); +} + +.all-parameters { + clear: both; + font-family: monospace; + & > input { + font-family: monospace; + margin-bottom: 2px; + padding: 4px; + width: 600px; + } + + & > .parameter-list { + overflow-y: scroll; + height: 200px; + + & > table { + width: 100%; + border-spacing: 0; + & > tbody { + & > tr { + & > td { + border-bottom: 1px dotted var(--color1); + } + transition: background-color var(--fade); + &:hover { + background-color: var(--color3); + } + + & > td.left { + } + & > td.right { + text-align: right; + padding-right: 0.5em; + } + } + } + } + } +} diff --git a/config.json b/config.json index c0a7db6..c7aa865 100644 --- a/config.json +++ b/config.json @@ -93,5 +93,6 @@ "ui.device.2.parameters.2.parameter": "IPAddress", "ui.device.2.parameters.3.label": "MAC address", "ui.device.2.parameters.3.parameter": "MACAddress", - "ui.device.3.type": "device-faults" + "ui.device.3.type": "device-faults", + "ui.device.4.type": "all-parameters" } From 8e128500d025dc4b128d66cd8e516a9f638646e3 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Tue, 24 Apr 2018 01:29:37 -0700 Subject: [PATCH 017/106] Implement reboot, reset, and delete --- client/components.js | 2 + client/components/device-actions.js | 73 +++++++++++++++ client/css/app.css | 10 ++- client/devices-page.js | 133 +++++++++++++++++++++++++++- config.json | 3 +- server/api.js | 2 +- 6 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 client/components/device-actions.js diff --git a/client/components.js b/client/components.js index 277f209..16c30e5 100644 --- a/client/components.js +++ b/client/components.js @@ -8,6 +8,7 @@ import container from "./components/container"; import summonButton from "./components/summon-button"; import deviceFaults from "./components/device-faults"; import allParameters from "./components/all-parameters"; +import deviceActions from "./components/device-actions"; function get(name) { if (name === "parameter") return parameter; @@ -18,6 +19,7 @@ function get(name) { else if (name === "summon-button") return summonButton; else if (name === "device-faults") return deviceFaults; else if (name === "all-parameters") return allParameters; + else if (name === "device-actions") return deviceActions; else throw new Error(`No such component '${name}'`); } diff --git a/client/components/device-actions.js b/client/components/device-actions.js new file mode 100644 index 0000000..30b4bfd --- /dev/null +++ b/client/components/device-actions.js @@ -0,0 +1,73 @@ +"use strict"; + +import m from "mithril"; +import * as taskQueue from "../task-queue"; +import * as notifications from "../notifications"; +import * as store from "../store"; + +const component = { + view: vnode => { + const device = vnode.attrs.device; + + const buttons = []; + + buttons.push( + m( + "button.primary", + { + title: "Reboot device", + onclick: () => { + taskQueue.queueTask({ + name: "reboot", + device: device["DeviceID.ID"].value[0] + }); + } + }, + "Reboot" + ) + ); + + buttons.push( + m( + "button.critical", + { + title: "Factory reset device", + onclick: () => { + taskQueue.queueTask({ + name: "factoryReset", + device: device["DeviceID.ID"].value[0] + }); + } + }, + "Reset" + ) + ); + + buttons.push( + m( + "button.primary", + { + title: "Delete device", + onclick: () => { + if (!confirm("Deleting this device. Are you sure?")) return; + + store + .deleteResource("devices", device["DeviceID.ID"].value[0]) + .then(() => { + notifications.push("success", "Device deleted"); + m.route.set("/devices"); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } + }, + "Delete" + ) + ); + + return m(".device-actions", buttons); + } +}; + +export default component; diff --git a/client/css/app.css b/client/css/app.css index 0a27ddd..0da3c05 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -324,8 +324,7 @@ button { &.critical { border: 1px solid var(--color1); background-color: var(--color3); - font-weight: bold; - color: #900; + color: #700; } &:hover:enabled { background-color: var(--color4); @@ -490,3 +489,10 @@ span.na { } } } + +.device-actions { + margin-top: 10px; + & > button { + margin-right: 10px; + } +} diff --git a/client/devices-page.js b/client/devices-page.js index 0bf2109..4645494 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -8,6 +8,8 @@ import * as filterParser from "../common/filter-parser"; import Filter from "../common/filter"; import * as store from "./store"; import * as components from "./components"; +import * as taskQueue from "./task-queue"; +import * as notifications from "./notifications"; function init(args) { return new Promise(resolve => { @@ -126,6 +128,134 @@ function renderTable( ]); } +function renderActions(selected) { + const buttons = []; + + buttons.push( + m( + "button.primary", + { + title: "Reboot selected devices", + disabled: !selected.size, + onclick: () => { + for (let d of selected) + taskQueue.queueTask({ + name: "reboot", + device: d + }); + } + }, + "Reboot" + ) + ); + + buttons.push( + m( + "button.critical", + { + title: "Factory reset selected devices", + disabled: !selected.size, + onclick: () => { + for (let d of selected) + taskQueue.queueTask({ + name: "factoryReset", + device: d + }); + } + }, + "Reset" + ) + ); + + buttons.push( + m( + "button.primary", + { + title: "Delete selected devices", + disabled: !selected.size, + onclick: () => { + const ids = Array.from(selected); + if (!confirm(`Deleting ${ids.length} devices. Are you sure?`)) return; + + Promise.all(ids.map(id => store.deleteResource("devices", id))) + .then(() => { + notifications.push("success", `${ids.size} devices deleted`); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } + }, + "Delete" + ) + ); + + buttons.push( + m( + "button.primary", + { + title: "Tag selected devices", + disabled: !selected.size, + onclick: () => { + const ids = Array.from(selected); + const tag = prompt(`Enter tag to assign to ${ids.length} devices:`); + if (!tag) return; + + Promise.all( + ids.map(id => { + store.updateTags(id, { [tag]: true }); + }) + ) + .then(() => { + notifications.push( + "success", + `Tag '${tag}' assigned to ${ids.length} devices` + ); + store.fulfill(0, Date.now()); + }) + .catch(err => notifications.push("error", err.message)); + } + }, + "Tag" + ) + ); + + buttons.push( + m( + "button.primary", + { + title: "Untag selected devices", + disabled: !selected.size, + onclick: () => { + const ids = Array.from(selected); + const tag = prompt( + `Enter tag to unassign from ${ids.length} devices:` + ); + if (!tag) return; + + Promise.all( + ids.map(id => { + store.updateTags(id, { [tag]: false }); + }) + ) + .then(() => { + notifications.push( + "success", + `Tag '${tag}' unassigned from ${ids.length} devices` + ); + store.fulfill(0, Date.now()); + }) + .catch(err => notifications.push("error", err.message)); + } + }, + "Untag" + ) + ); + + return m(".device-actions", buttons); +} + const component = { view: vnode => { document.title = "Devices - GenieACS"; @@ -168,7 +298,8 @@ const component = { count.value, selected, showMore - ) + ), + renderActions(selected) ]; } }; diff --git a/config.json b/config.json index c7aa865..ac6c194 100644 --- a/config.json +++ b/config.json @@ -94,5 +94,6 @@ "ui.device.2.parameters.3.label": "MAC address", "ui.device.2.parameters.3.parameter": "MACAddress", "ui.device.3.type": "device-faults", - "ui.device.4.type": "all-parameters" + "ui.device.4.type": "all-parameters", + "ui.device.5.type": "device-actions" } diff --git a/server/api.js b/server/api.js index 8c6ccc2..3b99eb6 100644 --- a/server/api.js +++ b/server/api.js @@ -8,7 +8,7 @@ const Filter = require("../common/filter"); const router = new Router(); const resources = { - devices: false, + devices: true, presets: true, provisions: true, files: true, From 16b10a698b6b9bf0ea9103ff31a71ccd238f902d Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 23 Dec 2018 03:30:31 -0800 Subject: [PATCH 018/106] Implement tags --- client/components.js | 2 + client/components/tags.js | 76 +++++++++++++++++++++++++ client/css/app.css | 16 ++++++ client/store.js | 9 +++ config.json | 113 ++++++++++++++++++++------------------ server/api-functions.js | 28 ++++++++++ server/api.js | 5 ++ 7 files changed, 195 insertions(+), 54 deletions(-) create mode 100644 client/components/tags.js diff --git a/client/components.js b/client/components.js index 16c30e5..bfcbc13 100644 --- a/client/components.js +++ b/client/components.js @@ -9,6 +9,7 @@ import summonButton from "./components/summon-button"; import deviceFaults from "./components/device-faults"; import allParameters from "./components/all-parameters"; import deviceActions from "./components/device-actions"; +import tags from "./components/tags"; function get(name) { if (name === "parameter") return parameter; @@ -20,6 +21,7 @@ function get(name) { else if (name === "device-faults") return deviceFaults; else if (name === "all-parameters") return allParameters; else if (name === "device-actions") return deviceActions; + else if (name === "tags") return tags; else throw new Error(`No such component '${name}'`); } diff --git a/client/components/tags.js b/client/components/tags.js new file mode 100644 index 0000000..6ba3750 --- /dev/null +++ b/client/components/tags.js @@ -0,0 +1,76 @@ +"use strict"; + +import m from "mithril"; +import * as notifications from "../notifications"; +import * as store from "../store"; + +const component = { + view: vnode => { + const device = vnode.attrs.device; + let writable = true; + if (vnode.attrs.writable != null) writable = vnode.attrs.writable; + + const tags = []; + for (let p of Object.keys(device)) + if (p.startsWith("Tags.")) tags.push(p.slice(5)); + + tags.sort(); + + if (!writable) return m(".tags", tags.map(t => m("span.tag", t))); + + return m( + ".tags", + tags.map(tag => + m( + "span.tag", + tag, + m( + "button", + { + onclick: e => { + e.target.disabled = true; + store + .updateTags(device["DeviceID.ID"].value[0], { [tag]: false }) + .then(() => { + e.target.disabled = false; + notifications.push("success", `Tag '${tag}' unassigned`); + store.fulfill(0, Date.now()); + }) + .catch(err => notifications.push("error", err.message)); + } + }, + "✕" + ) + ) + ), + m( + "span.tag.writable", + m( + "button", + { + onclick: e => { + e.target.disabled = true; + const tag = prompt(`Enter tag to assign to device:`); + if (!tag) { + e.target.disabled = false; + return; + } + + store + .updateTags(device["DeviceID.ID"].value[0], { [tag]: true }) + .then(() => { + e.target.disabled = false; + notifications.push("success", `Tag '${tag}' assigned`); + store.fulfill(0, Date.now()); + }) + .catch(err => notifications.push("error", err.message)); + } + }, + "🞢" + ) + ) + ); + } +}; + +export default component; diff --git a/client/css/app.css b/client/css/app.css index 0da3c05..07be138 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -496,3 +496,19 @@ span.na { margin-right: 10px; } } + +span.tag { + display: inline-block; + background-image: url('data:image/svg+xml;utf-8,'); + background-size: cover; + background-repeat: no-repeat; + border-radius: 0.2em; + padding-left: 1em; + padding-right: 0.2em; +} + +.tags { + & > .tag { + margin-right: 0.5em; + } +} diff --git a/client/store.js b/client/store.js index c9adbf1..33e2493 100644 --- a/client/store.js +++ b/client/store.js @@ -363,6 +363,14 @@ function postTasks(tasks, callback) { }); } +function updateTags(deviceId, tags) { + return m.request({ + method: "POST", + url: `/api/devices/${encodeURIComponent(deviceId)}/tags`, + data: tags + }); +} + function deleteResource(resourceType, id) { return m.request({ method: "DELETE", @@ -393,6 +401,7 @@ export { unpackFilter, getTimestamp, postTasks, + updateTags, deleteResource, evaluateExpression }; diff --git a/config.json b/config.json index ac6c194..593b541 100644 --- a/config.json +++ b/config.json @@ -30,6 +30,10 @@ "ui.index.6.components.1.type": "parameter", "ui.index.6.components.2.type": "overview-dot", "ui.index.6.components.2.chart": "online", + "ui.index.7.label": "Tags", + "ui.index.7.parameter": "Tags", + "ui.index.7.type": "tags", + "ui.index.7.writable": false, "ui.overview.groups.online.label": "", "ui.overview.groups.online.charts.1": "online", "ui.overview.charts.online.label": "Online status", @@ -42,58 +46,59 @@ "ui.overview.charts.online.slices.others.label": "Others", "ui.overview.charts.online.slices.others.color": "#f7fcb9", "ui.overview.charts.online.slices.others.filter": "Events.Inform < (NOW() - 5 * 60 * 1000) - (24 * 60 * 60 * 1000)", - "ui.device.1.type": "parameter-list", - "ui.device.1.parameters.1.type": "container", - "ui.device.1.parameters.1.label": "Last inform", - "ui.device.1.parameters.1.element": "span.inform", - "ui.device.1.parameters.1.parameter": "Events.Inform", - "ui.device.1.parameters.1.components.1.type": "parameter", - "ui.device.1.parameters.1.components.2.type": "overview-dot", - "ui.device.1.parameters.1.components.2.chart": "online", - "ui.device.1.parameters.1.components.3.type": "summon-button", - "ui.device.1.parameters.1.components.3.parameters.1": "InternetGatewayDevice.DeviceInfo.HardwareVersion", - "ui.device.1.parameters.1.components.3.parameters.2": "InternetGatewayDevice.DeviceInfo.SoftwareVersion", - "ui.device.1.parameters.1.components.3.parameters.3": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress", - "ui.device.1.parameters.1.components.3.parameters.4": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress", - "ui.device.1.parameters.1.components.3.parameters.5": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", - "ui.device.1.parameters.1.components.3.parameters.6": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase", - "ui.device.1.parameters.1.components.3.parameters.7": "InternetGatewayDevice.LANDevice.1.Hosts.Host", - "ui.device.1.parameters.2.label": "Last boot", - "ui.device.1.parameters.2.parameter": "Events.1_BOOT", - "ui.device.1.parameters.3.label": "Last bootstrap", - "ui.device.1.parameters.3.parameter": "Events.0_BOOTSTRAP", - "ui.device.1.parameters.4.label": "Registered", - "ui.device.1.parameters.4.parameter": "Events.Registered", - "ui.device.1.parameters.2.label": "Serial number", - "ui.device.1.parameters.2.parameter": "InternetGatewayDevice.DeviceInfo.SerialNumber", - "ui.device.1.parameters.3.label": "Product class", - "ui.device.1.parameters.3.parameter": "InternetGatewayDevice.DeviceInfo.ProductClass", - "ui.device.1.parameters.4.label": "OUI", - "ui.device.1.parameters.4.parameter": "InternetGatewayDevice.DeviceInfo.ManufacturerOUI", - "ui.device.1.parameters.5.label": "Manufacturer", - "ui.device.1.parameters.5.parameter": "InternetGatewayDevice.DeviceInfo.Manufacturer", - "ui.device.1.parameters.6.label": "Hardware version", - "ui.device.1.parameters.6.parameter": "InternetGatewayDevice.DeviceInfo.HardwareVersion", - "ui.device.1.parameters.7.label": "Software version", - "ui.device.1.parameters.7.parameter": "InternetGatewayDevice.DeviceInfo.SoftwareVersion", - "ui.device.1.parameters.8.label": "MAC", - "ui.device.1.parameters.8.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress", - "ui.device.1.parameters.9.label": "IP", - "ui.device.1.parameters.9.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress", - "ui.device.1.parameters.10.label": "WLAN SSID", - "ui.device.1.parameters.10.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", - "ui.device.1.parameters.11.label": "WLAN passphrase", - "ui.device.1.parameters.11.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase", - "ui.device.2.type": "parameter-table", - "ui.device.2.label": "WLAN hosts", - "ui.device.2.object": "InternetGatewayDevice.LANDevice.1.Hosts.Host", - "ui.device.2.parameters.1.label": "Host name", - "ui.device.2.parameters.1.parameter": "HostName", - "ui.device.2.parameters.2.label": "IP address", - "ui.device.2.parameters.2.parameter": "IPAddress", - "ui.device.2.parameters.3.label": "MAC address", - "ui.device.2.parameters.3.parameter": "MACAddress", - "ui.device.3.type": "device-faults", - "ui.device.4.type": "all-parameters", - "ui.device.5.type": "device-actions" + "ui.device.10.type": "tags", + "ui.device.30.type": "parameter-list", + "ui.device.30.parameters.1.type": "container", + "ui.device.30.parameters.1.label": "Last inform", + "ui.device.30.parameters.1.element": "span.inform", + "ui.device.30.parameters.1.parameter": "Events.Inform", + "ui.device.30.parameters.1.components.1.type": "parameter", + "ui.device.30.parameters.1.components.2.type": "overview-dot", + "ui.device.30.parameters.1.components.2.chart": "online", + "ui.device.30.parameters.1.components.3.type": "summon-button", + "ui.device.30.parameters.1.components.3.parameters.1": "InternetGatewayDevice.DeviceInfo.HardwareVersion", + "ui.device.30.parameters.1.components.3.parameters.2": "InternetGatewayDevice.DeviceInfo.SoftwareVersion", + "ui.device.30.parameters.1.components.3.parameters.3": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress", + "ui.device.30.parameters.1.components.3.parameters.4": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress", + "ui.device.30.parameters.1.components.3.parameters.5": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", + "ui.device.30.parameters.1.components.3.parameters.6": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase", + "ui.device.30.parameters.1.components.3.parameters.7": "InternetGatewayDevice.LANDevice.1.Hosts.Host", + "ui.device.30.parameters.2.label": "Last boot", + "ui.device.30.parameters.2.parameter": "Events.1_BOOT", + "ui.device.30.parameters.3.label": "Last bootstrap", + "ui.device.30.parameters.3.parameter": "Events.0_BOOTSTRAP", + "ui.device.30.parameters.4.label": "Registered", + "ui.device.30.parameters.4.parameter": "Events.Registered", + "ui.device.30.parameters.2.label": "Serial number", + "ui.device.30.parameters.2.parameter": "InternetGatewayDevice.DeviceInfo.SerialNumber", + "ui.device.30.parameters.3.label": "Product class", + "ui.device.30.parameters.3.parameter": "InternetGatewayDevice.DeviceInfo.ProductClass", + "ui.device.30.parameters.4.label": "OUI", + "ui.device.30.parameters.4.parameter": "InternetGatewayDevice.DeviceInfo.ManufacturerOUI", + "ui.device.30.parameters.5.label": "Manufacturer", + "ui.device.30.parameters.5.parameter": "InternetGatewayDevice.DeviceInfo.Manufacturer", + "ui.device.30.parameters.6.label": "Hardware version", + "ui.device.30.parameters.6.parameter": "InternetGatewayDevice.DeviceInfo.HardwareVersion", + "ui.device.30.parameters.7.label": "Software version", + "ui.device.30.parameters.7.parameter": "InternetGatewayDevice.DeviceInfo.SoftwareVersion", + "ui.device.30.parameters.8.label": "MAC", + "ui.device.30.parameters.8.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress", + "ui.device.30.parameters.9.label": "IP", + "ui.device.30.parameters.9.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress", + "ui.device.30.parameters.10.label": "WLAN SSID", + "ui.device.30.parameters.10.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", + "ui.device.30.parameters.11.label": "WLAN passphrase", + "ui.device.30.parameters.11.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase", + "ui.device.40.type": "parameter-table", + "ui.device.40.label": "WLAN hosts", + "ui.device.40.object": "InternetGatewayDevice.LANDevice.1.Hosts.Host", + "ui.device.40.parameters.1.label": "Host name", + "ui.device.40.parameters.1.parameter": "HostName", + "ui.device.40.parameters.2.label": "IP address", + "ui.device.40.parameters.2.parameter": "IPAddress", + "ui.device.40.parameters.3.label": "MAC address", + "ui.device.40.parameters.3.parameter": "MACAddress", + "ui.device.50.type": "device-faults", + "ui.device.60.type": "all-parameters", + "ui.device.70.type": "device-actions" } diff --git a/server/api-functions.js b/server/api-functions.js index 7e066ba..52ff915 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -340,7 +340,35 @@ function postTasks(deviceId, tasks) { }); } +function updateTags(deviceId, tags) { + return Promise.all( + Object.entries(tags).map( + ([tag, onOff]) => + new Promise((resolve, reject) => { + const options = url.parse( + `${config.get( + "server.nbi" + )}devices/${deviceId}/tags/${encodeURIComponent(tag)}` + ); + if (onOff) options.method = "POST"; + else options.method = "DELETE"; + let _http = options.protocol === "https:" ? https : http; + + _http + .request(options, res => { + res.resume(); + if (res.statusCode === 200) resolve(); + else + reject(new Error(`Unexpected status code ${res.statusCode}`)); + }) + .end(); + }) + ) + ); +} + exports.query = query; exports.count = count; exports.postTasks = postTasks; exports.deleteResource = deleteResource; +exports.updateTags = updateTags; diff --git a/server/api.js b/server/api.js index 3b99eb6..043b5fb 100644 --- a/server/api.js +++ b/server/api.js @@ -86,4 +86,9 @@ router.post("/devices/:id/tasks", async ctx => { ctx.body = res.tasks; }); +router.post("/devices/:id/tags", async ctx => { + await apiFunctions.updateTags(ctx.params.id, ctx.request.body); + ctx.body = ""; +}); + module.exports = router; From e1bdcda191b9c97de51ccc77b4c6f529184c907a Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 23 Dec 2018 03:33:35 -0800 Subject: [PATCH 019/106] Support nested parameter tables --- client/components/parameter-table.js | 12 ++++++++---- client/css/app.css | 4 ++++ config.json | 14 +++++++------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/client/components/parameter-table.js b/client/components/parameter-table.js index f3d1d4f..be5d286 100644 --- a/client/components/parameter-table.js +++ b/client/components/parameter-table.js @@ -8,8 +8,8 @@ import * as filterParser from "../../common/filter-parser"; const component = { oninit: vnode => { - vnode.state.object = filterParser.parseParameter(vnode.attrs.object); - vnode.state.parameters = Object.values(vnode.attrs.parameters).map( + vnode.state.object = filterParser.parseParameter(vnode.attrs.parameter); + vnode.state.parameters = Object.values(vnode.attrs.childParameters).map( parameter => { let p = filterParser.parseParameter(parameter.parameter); return Object.assign({}, parameter, { parameter: p }); @@ -42,10 +42,14 @@ const component = { m( "td", m( - components.get("parameter"), + components.get(p.type || "parameter"), Object.assign({}, p, { device: device, - parameter: `${i}.${store.evaluateExpression(p.parameter, device)}` + parameter: `${i}.${store.evaluateExpression( + p.parameter, + device + )}`, + label: "" }) ) ) diff --git a/client/css/app.css b/client/css/app.css index 07be138..55259a7 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -245,6 +245,10 @@ table.table { border-spacing: 0; text-align: left; + & table.table { + font-size: 90%; + } + & th { color: var(--color4); font-weight: bold; diff --git a/config.json b/config.json index 593b541..b84f8b7 100644 --- a/config.json +++ b/config.json @@ -91,13 +91,13 @@ "ui.device.30.parameters.11.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase", "ui.device.40.type": "parameter-table", "ui.device.40.label": "WLAN hosts", - "ui.device.40.object": "InternetGatewayDevice.LANDevice.1.Hosts.Host", - "ui.device.40.parameters.1.label": "Host name", - "ui.device.40.parameters.1.parameter": "HostName", - "ui.device.40.parameters.2.label": "IP address", - "ui.device.40.parameters.2.parameter": "IPAddress", - "ui.device.40.parameters.3.label": "MAC address", - "ui.device.40.parameters.3.parameter": "MACAddress", + "ui.device.40.parameter": "InternetGatewayDevice.LANDevice.1.Hosts.Host", + "ui.device.40.childParameters.1.label": "Host name", + "ui.device.40.childParameters.1.parameter": "HostName", + "ui.device.40.childParameters.2.label": "IP address", + "ui.device.40.childParameters.2.parameter": "IPAddress", + "ui.device.40.childParameters.3.label": "MAC address", + "ui.device.40.childParameters.3.parameter": "MACAddress", "ui.device.50.type": "device-faults", "ui.device.60.type": "all-parameters", "ui.device.70.type": "device-actions" From 327cb78f1a5be8df03f905f33d2214939ec34d2d Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 23 Dec 2018 03:41:18 -0800 Subject: [PATCH 020/106] Implement authentication and access control --- client/app.js | 14 +++-- client/components/device-actions.js | 5 +- client/components/summon-button.js | 44 +++++++++------ client/components/tags.js | 20 ++++--- client/css/app.css | 10 ++++ client/device-page.js | 13 +++-- client/devices-page.js | 84 +++++++++++++++++------------ client/drawer-component.js | 55 ++++++++++--------- client/error-page.js | 4 +- client/faults-page.js | 11 ++-- client/layout.js | 2 + client/login-page.js | 72 +++++++++++++++++++++++++ client/menu.js | 23 +++++--- client/overview-page.js | 9 ++-- client/store.js | 80 ++++++++++++++------------- client/task-queue.js | 30 ++++++++--- client/user-menu.js | 49 +++++++++++++++++ common/authorizer.js | 78 +++++++++++++++++++++++++++ common/filter.js | 8 +-- common/validators/pass.js | 5 ++ common/validators/test.js | 14 +++++ config.json | 11 ++++ package.json | 4 +- server/api-functions.js | 4 +- server/api.js | 51 ++++++++++++++---- server/config.js | 7 +++ server/server.js | 60 +++++++++++++++++++++ 27 files changed, 600 insertions(+), 167 deletions(-) create mode 100644 client/login-page.js create mode 100644 client/user-menu.js create mode 100644 common/authorizer.js create mode 100644 common/validators/pass.js create mode 100644 common/validators/test.js diff --git a/client/app.js b/client/app.js index 9a9ef0d..fef3830 100644 --- a/client/app.js +++ b/client/app.js @@ -4,11 +4,15 @@ import m from "mithril"; import layout from "./layout"; import * as store from "./store"; +import * as loginPage from "./login-page"; import * as overviewPage from "./overview-page"; import * as devicesPage from "./devices-page"; import * as devicePage from "./device-page"; import * as errorPage from "./error-page"; import * as faultsPage from "./faults-page"; +import Authorizer from "../common/authorizer"; + +window.authorizer = new Authorizer(window.permissionSets); let state; @@ -29,17 +33,18 @@ function pagify(pageName, page) { const component = { render: () => { lastRenderTimestamp = Date.now(); - let c; - if (state && state.error) c = m(errorPage.component, state); - else c = m(layout, { page: pageName }, m(page.component, state)); + let p; + if (state && state.error) p = m(errorPage.component, state); + else p = m(page.component, state); fulfill(); - return c; + return m(layout, { page: pageName }, p); } }; component.onmatch = args => { pageVisitTimestamp = Date.now(); if (!page.init) { + state = null; fulfill(); return; } @@ -64,6 +69,7 @@ function pagify(pageName, page) { } m.route(document.body, "/overview", { + "/login": pagify("login", loginPage), "/overview": pagify("overview", overviewPage), "/devices": pagify("devices", devicesPage), "/devices/:id": pagify("devices", devicePage), diff --git a/client/components/device-actions.js b/client/components/device-actions.js index 30b4bfd..ccfd728 100644 --- a/client/components/device-actions.js +++ b/client/components/device-actions.js @@ -50,11 +50,12 @@ const component = { title: "Delete device", onclick: () => { if (!confirm("Deleting this device. Are you sure?")) return; + const deviceId = device["DeviceID.ID"].value[0]; store - .deleteResource("devices", device["DeviceID.ID"].value[0]) + .deleteResource("devices", deviceId) .then(() => { - notifications.push("success", "Device deleted"); + notifications.push("success", `${deviceId}: Device deleted`); m.route.set("/devices"); }) .catch(err => { diff --git a/client/components/summon-button.js b/client/components/summon-button.js index 81c2f7f..850b8cb 100644 --- a/client/components/summon-button.js +++ b/client/components/summon-button.js @@ -31,23 +31,33 @@ const component = { }; taskQueue - .commit([task], (deviceId, connectionRequestStatus, tasks2) => { - for (let t of tasks2) - if (t.status === "stale") taskQueue.deleteTask(t); - if (connectionRequestStatus !== "OK") - notifications.push( - "error", - `${deviceId}: ${connectionRequestStatus}` - ); - else if (tasks2[0].status === "stale") - notifications.push( - "error", - `${deviceId}: No contact from device` - ); - else if (tasks2[0].status === "fault") - notifications.push("error", `${deviceId}: Refresh faulted`); - else notifications.push("success", `${deviceId}: Summoned`); - }) + .commit( + [task], + (deviceId, err, connectionRequestStatus, tasks2) => { + if (err) + return notifications.push( + "error", + `${deviceId}: ${err.message}` + ); + + for (let t of tasks2) + if (t.status === "stale") taskQueue.deleteTask(t); + + if (connectionRequestStatus !== "OK") + notifications.push( + "error", + `${deviceId}: ${connectionRequestStatus}` + ); + else if (tasks2[0].status === "stale") + notifications.push( + "error", + `${deviceId}: No contact from device` + ); + else if (tasks2[0].status === "fault") + notifications.push("error", `${deviceId}: Refresh faulted`); + else notifications.push("success", `${deviceId}: Summoned`); + } + ) .then(() => { e.target.disabled = false; store.fulfill(0, Date.now()); diff --git a/client/components/tags.js b/client/components/tags.js index 6ba3750..76bf2cf 100644 --- a/client/components/tags.js +++ b/client/components/tags.js @@ -29,14 +29,18 @@ const component = { { onclick: e => { e.target.disabled = true; + const deviceId = device["DeviceID.ID"].value[0]; store - .updateTags(device["DeviceID.ID"].value[0], { [tag]: false }) + .updateTags(deviceId, { [tag]: false }) .then(() => { e.target.disabled = false; - notifications.push("success", `Tag '${tag}' unassigned`); + notifications.push("success", `${deviceId}: Tags updated`); store.fulfill(0, Date.now()); }) - .catch(err => notifications.push("error", err.message)); + .catch(err => { + e.target.disabled = false; + notifications.push("error", `${deviceId}: ${err.message}`); + }); } }, "✕" @@ -50,6 +54,7 @@ const component = { { onclick: e => { e.target.disabled = true; + const deviceId = device["DeviceID.ID"].value[0]; const tag = prompt(`Enter tag to assign to device:`); if (!tag) { e.target.disabled = false; @@ -57,13 +62,16 @@ const component = { } store - .updateTags(device["DeviceID.ID"].value[0], { [tag]: true }) + .updateTags(deviceId, { [tag]: true }) .then(() => { e.target.disabled = false; - notifications.push("success", `Tag '${tag}' assigned`); + notifications.push("success", `${deviceId}: Tags updated`); store.fulfill(0, Date.now()); }) - .catch(err => notifications.push("error", err.message)); + .catch(err => { + e.target.disabled = false; + notifications.push("error", `${deviceId}: ${err.message}`); + }); } }, "🞢" diff --git a/client/css/app.css b/client/css/app.css index 55259a7..b0bae5a 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -229,11 +229,17 @@ i { } } } + & > .logo { margin: 10px; display: inline; height: 56px; } + + & > .user-menu { + float: right; + margin: 5px; + } } #content { @@ -308,6 +314,10 @@ h3 { margin-bottom: 0.5em; } +p.error { + color: #a00; +} + /* Remove button padding in FF */ input::-moz-focus-inner { border: 0; diff --git a/client/device-page.js b/client/device-page.js index b3de49c..8eef07e 100644 --- a/client/device-page.js +++ b/client/device-page.js @@ -8,11 +8,14 @@ import * as store from "./store"; import * as components from "./components"; function init(args) { - return new Promise(resolve => { - resolve({ - deviceId: args.id, - deviceFilter: new Filter(["=", "DeviceID.ID", args.id]) - }); + if (!window.authorizer.hasAccess("devices", 2)) + return Promise.reject( + new Error("You are not authorized to view this page") + ); + + return Promise.resolve({ + deviceId: args.id, + deviceFilter: new Filter(["=", "DeviceID.ID", args.id]) }); } diff --git a/client/devices-page.js b/client/devices-page.js index 4645494..3c2a823 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -12,7 +12,10 @@ import * as taskQueue from "./task-queue"; import * as notifications from "./notifications"; function init(args) { - return new Promise(resolve => { + return new Promise((resolve, reject) => { + if (!window.authorizer.hasAccess("devices", 2)) + return reject(new Error("You are not authorized to view this page")); + let filter = new Filter(args.filter); let indexParameters = Object.values(config.get("ui.index")).map(p => Object.assign({}, p, { @@ -177,14 +180,21 @@ function renderActions(selected) { const ids = Array.from(selected); if (!confirm(`Deleting ${ids.length} devices. Are you sure?`)) return; - Promise.all(ids.map(id => store.deleteResource("devices", id))) - .then(() => { - notifications.push("success", `${ids.size} devices deleted`); - store.fulfill(0, Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - }); + let counter = 1; + for (let id of ids) { + ++counter; + store + .deleteResource("devices", id) + .then(() => { + notifications.push("success", `${id}: Deleted`); + if (--counter === 0) store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", `${id}: ${err.message}`); + if (--counter === 0) store.fulfill(0, Date.now()); + }); + } + if (--counter === 0) store.fulfill(0, Date.now()); } }, "Delete" @@ -202,19 +212,21 @@ function renderActions(selected) { const tag = prompt(`Enter tag to assign to ${ids.length} devices:`); if (!tag) return; - Promise.all( - ids.map(id => { - store.updateTags(id, { [tag]: true }); - }) - ) - .then(() => { - notifications.push( - "success", - `Tag '${tag}' assigned to ${ids.length} devices` - ); - store.fulfill(0, Date.now()); - }) - .catch(err => notifications.push("error", err.message)); + let counter = 1; + for (let id of ids) { + ++counter; + store + .updateTags(id, { [tag]: true }) + .then(() => { + notifications.push("success", `${id}: Tags updated`); + if (--counter === 0) store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", `${id}: ${err.message}`); + if (--counter === 0) store.fulfill(0, Date.now()); + }); + } + if (--counter === 0) store.fulfill(0, Date.now()); } }, "Tag" @@ -234,19 +246,21 @@ function renderActions(selected) { ); if (!tag) return; - Promise.all( - ids.map(id => { - store.updateTags(id, { [tag]: false }); - }) - ) - .then(() => { - notifications.push( - "success", - `Tag '${tag}' unassigned from ${ids.length} devices` - ); - store.fulfill(0, Date.now()); - }) - .catch(err => notifications.push("error", err.message)); + let counter = 1; + for (let id of ids) { + ++counter; + store + .updateTags(id, { [tag]: false }) + .then(() => { + notifications.push("success", `${id}: Tags updated`); + if (--counter === 0) store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", `${id}: ${err.message}`); + if (--counter === 0) store.fulfill(0, Date.now()); + }); + } + if (--counter === 0) store.fulfill(0, Date.now()); } }, "Untag" diff --git a/client/drawer-component.js b/client/drawer-component.js index 3af1f3c..f40a0ac 100644 --- a/client/drawer-component.js +++ b/client/drawer-component.js @@ -243,41 +243,46 @@ const component = { t => t.status === "queued" ); taskQueue - .commit(tasks, (deviceId, connectionRequestStatus, tasks2) => { - if (connectionRequestStatus !== "OK") { - notifications.push( - "error", - `${deviceId}: ${connectionRequestStatus}` - ); - return; - } - - for (let t of tasks2) - if (t.status === "stale") { - notifications.push( + .commit( + tasks, + (deviceId, err, connectionRequestStatus, tasks2) => { + if (err) + return notifications.push( "error", - `${deviceId}: No contact from device` + `${deviceId}: ${err.message}` ); - return; - } else if (t.status === "fault") { + + if (connectionRequestStatus !== "OK") { notifications.push( "error", - `${deviceId}: Task(s) faulted` + `${deviceId}: ${connectionRequestStatus}` ); return; } - notifications.push( - "success", - `${deviceId}: Task(s) committed` - ); - }) + for (let t of tasks2) + if (t.status === "stale") { + notifications.push( + "error", + `${deviceId}: No contact from device` + ); + return; + } else if (t.status === "fault") { + notifications.push( + "error", + `${deviceId}: Task(s) faulted` + ); + return; + } + + notifications.push( + "success", + `${deviceId}: Task(s) committed` + ); + } + ) .then(() => { store.fulfill(0, Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - throw err; }); } }, diff --git a/client/error-page.js b/client/error-page.js index 4398d87..afe4f49 100644 --- a/client/error-page.js +++ b/client/error-page.js @@ -1,9 +1,11 @@ "use strict"; +import m from "mithril"; + const component = { view: function(vnode) { document.title = "Error! - GenieACS"; - return vnode.attrs.error; + return m("p.error", vnode.attrs.error); } }; diff --git a/client/faults-page.js b/client/faults-page.js index 571ef10..391138b 100644 --- a/client/faults-page.js +++ b/client/faults-page.js @@ -9,10 +9,13 @@ import * as store from "./store"; import * as notifications from "./notifications"; function init(args) { - return new Promise(resolve => { - let filter = new Filter(args.filter); - resolve({ filter: filter }); - }); + if (!window.authorizer.hasAccess("devices", 2)) + return Promise.reject( + new Error("You are not authorized to view this page") + ); + + let filter = new Filter(args.filter); + return Promise.resolve({ filter: filter }); } function renderTable(faultsResponse, total, selected, showMoreCallback) { diff --git a/client/layout.js b/client/layout.js index 8177219..c4bc4b1 100644 --- a/client/layout.js +++ b/client/layout.js @@ -4,12 +4,14 @@ import m from "mithril"; import menu from "./menu"; import drawerComponent from "./drawer-component"; +import userMenu from "./user-menu"; const layout = { view: vnode => { return [ m("#header", [ m("img.logo", { src: "logo.svg" }), + m(userMenu), m(menu, { page: vnode.attrs.page }), m(drawerComponent) ]), diff --git a/client/login-page.js b/client/login-page.js new file mode 100644 index 0000000..ee12d77 --- /dev/null +++ b/client/login-page.js @@ -0,0 +1,72 @@ +"use strict"; + +import m from "mithril"; + +import * as store from "./store"; +import * as notifications from "./notifications"; + +function init(args) { + return Promise.resolve(args); +} + +const component = { + view: vnode => { + if (window.username) m.route.set(vnode.attrs.continue || "/"); + + document.title = "Login - GenieACS"; + return [ + m("h1", "Log in"), + m( + "form", + m( + "p", + m("label", { for: "username" }, "Username"), + m("input", { + name: "username", + type: "text", + value: vnode.state.username, + oncreate: vnode2 => { + vnode2.dom.focus(); + }, + oninput: m.withAttr("value", v => (vnode.state.username = v)) + }) + ), + m( + "p", + m("label", { for: "password" }, "Password"), + m("input", { + name: "password", + type: "password", + value: vnode.state.password, + oninput: m.withAttr("value", v => (vnode.state.password = v)) + }) + ), + m( + "p", + m( + "button.primary", + { + type: "submit", + onclick: e => { + e.target.disabled = true; + store + .logIn(vnode.state.username, vnode.state.password) + .then(() => { + location.reload(); + }) + .catch(err => { + notifications.push("error", err.message); + e.target.disabled = false; + }); + return false; + } + }, + "Login" + ) + ) + ) + ]; + } +}; + +export { init, component }; diff --git a/client/menu.js b/client/menu.js index 6a94754..05b3b94 100644 --- a/client/menu.js +++ b/client/menu.js @@ -6,26 +6,35 @@ const menu = { view: vnode => { const active = { [vnode.attrs.page]: "active" }; - return m( - "nav", - m("ul", [ + let tabs = []; + if (window.authorizer.hasAccess("devices", 1)) + tabs.push( m( "li", { class: active["overview"] }, m("a", { href: "#!/overview" }, "Overview") - ), + ) + ); + + if (window.authorizer.hasAccess("devices", 2)) + tabs.push( m( "li", { class: active["devices"] }, m("a", { href: "#!/devices" }, "Devices") - ), + ) + ); + + if (window.authorizer.hasAccess("faults", 2)) + tabs.push( m( "li", { class: active["faults"] }, m("a", { href: "#!/faults" }, "Faults") ) - ]) - ); + ); + + return m("nav", m("ul", tabs)); } }; diff --git a/client/overview-page.js b/client/overview-page.js index 30378eb..740bfc6 100644 --- a/client/overview-page.js +++ b/client/overview-page.js @@ -24,9 +24,12 @@ function queryCharts(charts) { } const init = function() { - return new Promise(resolve => { - resolve({ charts: queryCharts(CHARTS) }); - }); + if (!window.authorizer.hasAccess("devices", 1)) + return Promise.reject( + new Error("You are not authorized to view this page") + ); + + return Promise.resolve({ charts: queryCharts(CHARTS) }); }; const component = { diff --git a/client/store.js b/client/store.js index 33e2493..5ef7948 100644 --- a/client/store.js +++ b/client/store.js @@ -323,43 +323,33 @@ function getTimestamp() { return fulfillTimestamp; } -function postTasks(tasks, callback) { +function postTasks(deviceId, tasks) { + for (let t of tasks) { + t.status = "pending"; + t.device = deviceId; + } + return new Promise((resolve, reject) => { - let devices = {}; - for (let t of tasks) { - t.status = "pending"; - devices[t.device] = devices[t.device] || []; - devices[t.device].push(t); - } - - let promises = []; - for (let [deviceId, tasks2] of Object.entries(devices)) - promises.push( - m.request({ - method: "POST", - url: `/api/devices/${encodeURIComponent(deviceId)}/tasks`, - data: tasks2, - extract: xhr => { - const connectionRequestStatus = xhr.getResponseHeader( - "Connection-Request" - ); - let st = JSON.parse(xhr.response); - for (let [i, t] of st.entries()) { - tasks2[i]._id = t._id; - tasks2[i].status = t.status; - tasks2[i].fault = t.fault; - } - if (callback) callback(deviceId, connectionRequestStatus, tasks2); + 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" + ); + let st = JSON.parse(xhr.response); + for (let [i, t] of st.entries()) { + tasks[i]._id = t._id; + tasks[i].status = t.status; + tasks[i].fault = t.fault; } - }) - ); - - Promise.all(promises) - .then(() => resolve(tasks)) - .catch(err => { - callback = null; - reject(err); - }); + resolve(connectionRequestStatus); + } + }) + .catch(reject); }); } @@ -394,6 +384,22 @@ function evaluateExpression(exp, device) { return exps.get(device); } +function logIn(username, password) { + return m.request({ + method: "POST", + url: "/login", + background: true, + data: { username, password } + }); +} + +function logOut() { + return m.request({ + method: "POST", + url: "/logout" + }); +} + export { count, fetch, @@ -403,5 +409,7 @@ export { postTasks, updateTags, deleteResource, - evaluateExpression + evaluateExpression, + logIn, + logOut }; diff --git a/client/task-queue.js b/client/task-queue.js index 76a8d41..b856a1e 100644 --- a/client/task-queue.js +++ b/client/task-queue.js @@ -37,16 +37,32 @@ function stageSpv(task) { } function commit(tasks, callback) { + let devices = {}; for (let t of tasks) { - t.status = "pending"; - queue.add(t); + devices[t.device] = devices[t.device] || []; + devices[t.device].push(t); + queueTask(t); } - return store.postTasks(tasks, (deviceId, connectionRequestStatus, tasks2) => { - for (let t of tasks2) - if (t.status === "pending") t.status = "stale"; - else if (t.status === "done") queue.delete(t); - if (callback) callback(deviceId, connectionRequestStatus, tasks2); + return new Promise(resolve => { + let counter = 1; + for (let [deviceId, tasks2] of Object.entries(devices)) + store + .postTasks(deviceId, tasks) + .then(connectionRequestStatus => { + for (let t of tasks2) + if (t.status === "pending") t.status = "stale"; + else if (t.status === "done") queue.delete(t); + callback(deviceId, null, connectionRequestStatus, tasks2); + if (--counter === 0) resolve(); + }) + .catch(err => { + for (let t of tasks2) t.status = "stale"; + callback(deviceId, err, null, tasks2); + if (--counter === 0) resolve(); + }); + + if (--counter === 0) resolve(); }); } diff --git a/client/user-menu.js b/client/user-menu.js new file mode 100644 index 0000000..7eeff90 --- /dev/null +++ b/client/user-menu.js @@ -0,0 +1,49 @@ +"use strict"; + +import m from "mithril"; + +import * as store from "./store"; +import * as notifications from "./notifications"; + +const userMenu = { + view: () => { + if (window.username) + return m( + "div.user-menu", + window.username, + m( + "button", + { + onclick: e => { + e.target.disabled = true; + store + .logOut() + .then(() => { + location.hash = ""; + location.reload(); + }) + .catch(err => { + e.target.disabled = false; + notifications.push("error", err.message); + }); + return false; + } + }, + "Log out" + ) + ); + else + return m( + "div.user-menu", + m( + "a", + { + href: "#!/login?" + m.buildQueryString({ continue: m.route.get() }) + }, + "Log in" + ) + ); + } +}; + +export default userMenu; diff --git a/common/authorizer.js b/common/authorizer.js new file mode 100644 index 0000000..b7bae2c --- /dev/null +++ b/common/authorizer.js @@ -0,0 +1,78 @@ +"use strict"; + +const validators = { + pass: require("./validators/pass"), + test: require("./validators/test") +}; + +class Authorizer { + constructor(permissionSets) { + this.permissionSets = permissionSets; + this.validtorCache = new WeakMap(); + this.hasAccessCache = {}; + } + + hasAccess(resourceType, access) { + const cacheKey = `${resourceType}-${access}`; + if (cacheKey in this.hasAccessCache) + return this.hasAccessCache[cacheKey]; + + let has = false; + for (const permissionSet of this.permissionSets) { + for (const perm of permissionSet) { + if (perm[resourceType]) { + if (perm[resourceType].access >= access) { + has = true; + break; + } + } + } + } + + this.hasAccessCache[cacheKey] = has; + return has; + } + + getValidator(resourceType, resource) { + if (this.validtorCache.has(resource)) + return this.validtorCache.get(resource); + + let funcs = {}; + + for (let permissionSet of this.permissionSets) + for (let perm of permissionSet) + if (perm[resourceType]) { + if (perm[resourceType].access >= 3) { + if (perm[resourceType].validate) + for (let [k, v] of Object.entries(perm[resourceType].validate)) + funcs[k] = v; + } else { + funcs = {}; + } + } + + const validator = (mutationType, mutation, any) => { + let valid = false; + for (let [k, v] of Object.entries(funcs)) + if (v) { + let res = validators[k]( + resourceType, + resource, + mutationType, + mutation, + any + ); + + if (res > 0) valid = true; + else if (res < 0) return false; + } + + return valid; + }; + + this.validtorCache.set(resource, validator); + return validator; + } +} + +module.exports = Authorizer; diff --git a/common/filter.js b/common/filter.js index 4a25086..a0ebd62 100644 --- a/common/filter.js +++ b/common/filter.js @@ -17,9 +17,10 @@ function* permute(arr) { class Filter { constructor(str) { - if (Array.isArray(str)) this.ast = str; + if (!str) this.ast = null; + else if (Array.isArray(str)) this.ast = str; else if (str instanceof Filter) this.ast = str.ast; - else if (!str) this.ast = null; + else if (Array.isArray(str.ast) || str.ast === null) this.ast = str.ast; else this.ast = filterParser.parse(str); } @@ -33,6 +34,7 @@ class Filter { or(fltr) { if (!fltr || !this.ast) return this; + else if (!fltr.ast) return fltr; let f = ["OR"]; @@ -46,7 +48,7 @@ class Filter { } and(fltr) { - if (!fltr) return this; + if (!fltr || !fltr.ast) return this; else if (!this.ast) return fltr; let f = ["AND"]; diff --git a/common/validators/pass.js b/common/validators/pass.js new file mode 100644 index 0000000..e01267a --- /dev/null +++ b/common/validators/pass.js @@ -0,0 +1,5 @@ +function pass() { + return 1; +} + +module.exports = pass; diff --git a/common/validators/test.js b/common/validators/test.js new file mode 100644 index 0000000..6795cbc --- /dev/null +++ b/common/validators/test.js @@ -0,0 +1,14 @@ +function test(resourceType, resource) { + if (resourceType === "devices") { + if (resource["Tags.test"] && resource["Tags.test"].value[0]) return 1; + } else if (resourceType === "presets") { + if (resource["_id"].startsWith("test-")) + // TODO check "test" tag too + return 1; + } else if (resourceType === "provisions") { + if (resource["_id"].startsWith("test-")) return 1; + } + return -1; +} + +module.exports = test; diff --git a/config.json b/config.json index b84f8b7..fd29d20 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,17 @@ { "server.port": 3000, "server.nbi": "http://127.0.0.1:7557/", + "server.jwtSecret": "jwtSecret", + "auth.simple.users.admin.password": "admin", + "auth.simple.users.admin.roles": "admin", + "auth.simple.users.view.password": "view", + "auth.simple.users.view.roles": "view", + "permissions.admin.1.devices.access": 3, + "permissions.admin.1.devices.validate.pass": true, + "permissions.admin.1.faults.access": 3, + "permissions.admin.1.faults.validate.pass": true, + "permissions.view.1.devices.access": 2, + "permissions.view.1.faults.access": 2, "ui.pageSize": 10, "ui.filters.1.label": "Serial number", "ui.filters.1.parameter": "DeviceID.SerialNumber", diff --git a/package.json b/package.json index 407203c..345137e 100644 --- a/package.json +++ b/package.json @@ -4,16 +4,18 @@ "description": "", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "lint": "eslint --fix common/*.js server/*.js client/*.js client/components/*.js;prettier --write common/*.js server/*.js client/*.js client/components/*.js client/css/*.css", + "lint": "eslint --fix common/*.js common/validators/*.js server/*.js client/*.js client/components/*.js;prettier --write common/*.js common/validators/*.js server/*.js client/*.js client/components/*.js client/css/*.css", "build": "npm run lint;webpack;postcss -c postcss.config.js client/css/app.css -o public/app.css", "start": "node server/server" }, "author": "Zaid Abdulla ", "dependencies": { + "jsonwebtoken": "^8.2.1", "koa": "^2.5.0", "koa-body": "^2.5.0", "koa-bodyparser": "^4.2.0", "koa-compress": "^2.0.0", + "koa-jwt": "^3.3.1", "koa-router": "^7.4.0", "koa-static": "^4.0.2", "mithril": "^1.1.6", diff --git a/server/api-functions.js b/server/api-functions.js index 52ff915..8eaf815 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -80,7 +80,7 @@ function filterToQuery(filter, negate = false, res = {}) { function count(resource, filter, limit) { return new Promise((resolve, reject) => { let qs = {}; - if (filter) { + if (filter && filter.ast) { filter = filter.evaluateExpressions(); let ast = unpackTimestamps(filter.ast); @@ -117,7 +117,7 @@ function query(resource, filter, limit, skip, projection, callback) { let ret; if (!callback) ret = []; let qs = {}; - if (filter) { + if (filter && filter.ast) { filter = filter.evaluateExpressions(); let ast = unpackTimestamps(filter.ast); diff --git a/server/api.js b/server/api.js index 043b5fb..c9b78fb 100644 --- a/server/api.js +++ b/server/api.js @@ -18,11 +18,13 @@ const resources = { }; for (let [resource, update] of Object.entries(resources)) { - router.head(`/${resource}`, async ctx => { + router.head(`/${resource}`, async (ctx, next) => { let filter, limit; if (ctx.request.query.filter) filter = new Filter(ctx.request.query.filter); if (ctx.request.query.limit) limit = +ctx.request.query.limit; + if (!ctx.state.authorizer.hasAccess(resource, 1)) return next(); + // Exclude temporary tasks and faults if (resource === "tasks" || resource === "faults") { let f = new Filter(["NOT", ["<", "expiry", Date.now() + 60000]]); @@ -34,12 +36,14 @@ for (let [resource, update] of Object.entries(resources)) { ctx.body = ""; }); - router.get(`/${resource}`, async ctx => { + router.get(`/${resource}`, async (ctx, next) => { let filter, limit, skip; if (ctx.request.query.filter) filter = new Filter(ctx.request.query.filter); if (ctx.request.query.limit) limit = +ctx.request.query.limit; if (ctx.request.query.skip) skip = +ctx.request.query.skip; + if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); + // Exclude temporary tasks and faults if (resource === "tasks" || resource === "faults") { let f = new Filter(["NOT", ["<", "expiry", Date.now() + 60000]]); @@ -57,6 +61,8 @@ for (let [resource, update] of Object.entries(resources)) { router.head(`/${resource}/:id`, async (ctx, next) => { let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); + if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); + let res = await apiFunctions.query(resource, filter); if (!res.length) return next(); ctx.body = ""; @@ -64,6 +70,8 @@ for (let [resource, update] of Object.entries(resources)) { router.get(`/${resource}/:id`, async (ctx, next) => { let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); + if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); + let res = await apiFunctions.query(resource, filter); if (!res.length) return next(); ctx.body = res[0]; @@ -71,22 +79,47 @@ for (let [resource, update] of Object.entries(resources)) { // TODO add PUT, PATCH routes if (update) - router.delete(`/${resource}/:id`, async ctx => { - let found = await apiFunctions.deleteResource(resource, ctx.params.id); - if (found) ctx.status = 200; - else ctx.status = 404; + router.delete(`/${resource}/:id`, async (ctx, next) => { + const authorizer = ctx.state.authorizer; + let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); + if (!authorizer.hasAccess(resource, 2)) return next(); + let res = await apiFunctions.query(resource, filter); + if (!res.length) return next(); + + const validate = authorizer.getValidator(resource, res[0]); + if (!validate("delete")) return (ctx.status = 403); + + await apiFunctions.deleteResource(resource, ctx.params.id); ctx.body = ""; }); } -router.post("/devices/:id/tasks", async ctx => { +router.post("/devices/:id/tasks", async (ctx, next) => { + const authorizer = ctx.state.authorizer; + let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); + if (!authorizer.hasAccess("devices", 2)) return next(); + let devices = await apiFunctions.query("devices", filter); + if (!devices.length) return next(); + + const validate = authorizer.getValidator("devices", devices[0]); + for (let t of ctx.request.body) + if (!validate("task", t)) return (ctx.status = 403); + let res = await apiFunctions.postTasks(ctx.params.id, ctx.request.body); - // TODO 404 if no such device ctx.set("Connection-Request", res.connectionRequest); ctx.body = res.tasks; }); -router.post("/devices/:id/tags", async ctx => { +router.post("/devices/:id/tags", async (ctx, next) => { + const authorizer = ctx.state.authorizer; + let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); + if (!authorizer.hasAccess("devices", 2)) return next(); + let res = await apiFunctions.query("devices", filter); + if (!res.length) return next(); + + const validate = authorizer.getValidator("devices", res[0]); + if (!validate("tags", ctx.request.body)) return (ctx.status = 403); + await apiFunctions.updateTags(ctx.params.id, ctx.request.body); ctx.body = ""; }); diff --git a/server/config.js b/server/config.js index 665c071..c94460a 100644 --- a/server/config.js +++ b/server/config.js @@ -10,6 +10,13 @@ const cache = {}; function parse(name, value) { if (/^ui\.overview\.charts\.[^.]+\.slices\.[^.]+\.filter$/.test(name)) return new Filter(value); + else if (/^permissions\.[^.]+\.[^.]+\.devices\.filter$/.test(name)) + return new Filter(value); + else if (/^auth\.[^.]+\.users\.[^.]+\.roles$/.test(name)) + return value + .split(",") + .map(x => x.trim()) + .sort(); return value; } diff --git a/server/server.js b/server/server.js index a39489f..6785e11 100644 --- a/server/server.js +++ b/server/server.js @@ -5,17 +5,73 @@ const Router = require("koa-router"); const koaStatic = require("koa-static"); const koaCompress = require("koa-compress"); const koaBody = require("koa-body"); +const koaJwt = require("koa-jwt"); +const jwt = require("jsonwebtoken"); const config = require("./config"); const api = require("./api"); +const Authorizer = require("../common/authorizer"); const koa = new Koa(); const router = new Router(); +const JWT_SECRET = config.get("server.jwtSecret"); +const JWT_COOKIE = "genieacs-ui-jwt"; + +function getPermissionSets(roles) { + const allPermissions = config.get("permissions"); + const permissionSets = roles.map(r => Object.values(allPermissions[r] || {})); + return permissionSets; +} + +koa.use( + koaJwt({ + secret: JWT_SECRET, + passthrough: true, + cookie: JWT_COOKIE + }) +); + +koa.use(async (ctx, next) => { + if (ctx.state.user && ctx.state.user.roles) + ctx.state.authorizer = new Authorizer( + getPermissionSets(ctx.state.user.roles) + ); + else ctx.state.authorizer = new Authorizer([]); + + return next(); +}); + +router.post("/login", async ctx => { + const username = ctx.request.body.username; + const password = ctx.request.body.password; + + const user = config.get(`auth.simple.users.${username}`); + + if (!user || user.password !== password) { + ctx.status = 400; + ctx.body = "Incorrect username or password"; + return; + } + + let token = jwt.sign({ username: username, roles: user.roles }, JWT_SECRET); + ctx.cookies.set(JWT_COOKIE, token); + ctx.body = JSON.stringify(token); +}); + +router.post("/logout", async ctx => { + ctx.cookies.set(JWT_COOKIE); // Delete cookie + ctx.body = ""; +}); + koa.use(koaBody()); router.use("/api", api.routes(), api.allowedMethods()); router.get("/", async ctx => { + let permissionSets = []; + if (ctx.state.user && ctx.state.user.roles) + permissionSets = getPermissionSets(ctx.state.user.roles); + ctx.body = ` @@ -26,6 +82,10 @@ router.get("/", async ctx => { From 12cdc8a6f1173f1304599c661bcfa3abdc9dada0 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 23 Dec 2018 04:43:59 -0800 Subject: [PATCH 021/106] Add functions for DB access --- client/css/app.css | 5 +++ config.json | 1 + package.json | 1 + server/api-functions.js | 13 ++++---- server/api.js | 53 ++++++++++++++++++++------------ server/db.js | 67 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 114 insertions(+), 26 deletions(-) create mode 100644 server/db.js diff --git a/client/css/app.css b/client/css/app.css index b0bae5a..5e46ab5 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -266,6 +266,11 @@ table.table { & td { margin: 0; padding: 0.2em 0.5em; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 400px; } & tfoot td > a { diff --git a/config.json b/config.json index fd29d20..685fbf0 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,7 @@ { "server.port": 3000, "server.nbi": "http://127.0.0.1:7557/", + "server.mongodbConnectionUrl": "mongodb://127.0.0.1:27017", "server.jwtSecret": "jwtSecret", "auth.simple.users.admin.password": "admin", "auth.simple.users.admin.roles": "admin", diff --git a/package.json b/package.json index 345137e..4789d22 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "koa-router": "^7.4.0", "koa-static": "^4.0.2", "mithril": "^1.1.6", + "mongodb": "^3.0.10", "parsimmon": "^1.7.2" }, "devDependencies": { diff --git a/server/api-functions.js b/server/api-functions.js index 8eaf815..78a8bb2 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -24,20 +24,20 @@ function unpackTimestamps(filter) { }); } -function filterToQuery(filter, negate = false, res = {}) { +function filterToMongoQuery(filter, negate = false, res = {}) { const op = filter[0]; if ((!negate && op === "AND") || (negate && op === "OR")) { res["$and"] = res["$and"] || []; for (let i = 1; i < filter.length; ++i) - res["$and"].push(filterToQuery(filter[i], negate)); + res["$and"].push(filterToMongoQuery(filter[i], negate)); } else if ((!negate && op === "OR") || (negate && op === "AND")) { res["$or"] = res["$or"] || []; for (let i = 1; i < filter.length; ++i) - res["$or"].push(filterToQuery(filter[i], negate)); + res["$or"].push(filterToMongoQuery(filter[i], negate)); } else if (op === "NOT") { - filterToQuery(filter[1], !negate, res); + filterToMongoQuery(filter[1], !negate, res); } else if (op === "=") { let p = (res[filter[1]] = res[filter[1]] || {}); if (negate) p["$ne"] = filter[2]; @@ -84,7 +84,7 @@ function count(resource, filter, limit) { filter = filter.evaluateExpressions(); let ast = unpackTimestamps(filter.ast); - let q = filterToQuery(ast); + let q = filterToMongoQuery(ast); if (resource === "devices") q = device.transposeQuery(q); qs.query = JSON.stringify(q); } @@ -121,7 +121,7 @@ function query(resource, filter, limit, skip, projection, callback) { filter = filter.evaluateExpressions(); let ast = unpackTimestamps(filter.ast); - let q = filterToQuery(ast); + let q = filterToMongoQuery(ast); if (resource === "devices") q = device.transposeQuery(q); qs.query = JSON.stringify(q); @@ -372,3 +372,4 @@ exports.count = count; exports.postTasks = postTasks; exports.deleteResource = deleteResource; exports.updateTags = updateTags; +exports.filterToMongoQuery = filterToMongoQuery; diff --git a/server/api.js b/server/api.js index c9b78fb..cf47d7d 100644 --- a/server/api.js +++ b/server/api.js @@ -2,22 +2,26 @@ const Router = require("koa-router"); +const db = require("./db"); const apiFunctions = require("./api-functions"); const Filter = require("../common/filter"); const router = new Router(); +const RESOURCE_DELETE = 1 << 0; +const RESOURCE_DB = 1 << 1; + const resources = { - devices: true, - presets: true, - provisions: true, - files: true, - virtual_parameters: true, - faults: true, - tasks: false + devices: 0 | RESOURCE_DELETE, + presets: 0 | RESOURCE_DELETE, + provisions: 0 | RESOURCE_DELETE, + files: 0 | RESOURCE_DELETE, + virtual_parameters: 0 | RESOURCE_DELETE, + faults: 0 | RESOURCE_DELETE, + tasks: 0 }; -for (let [resource, update] of Object.entries(resources)) { +for (let [resource, flags] of Object.entries(resources)) { router.head(`/${resource}`, async (ctx, next) => { let filter, limit; if (ctx.request.query.filter) filter = new Filter(ctx.request.query.filter); @@ -31,16 +35,20 @@ for (let [resource, update] of Object.entries(resources)) { filter = f.and(filter); } - let count = await apiFunctions.count(resource, filter, limit); + let count; + if (flags & RESOURCE_DB) count = await db.count(resource, filter, limit); + else count = await apiFunctions.count(resource, filter, limit); + ctx.set("X-Total-Count", count); ctx.body = ""; }); router.get(`/${resource}`, async (ctx, next) => { - let filter, limit, skip; + let filter, limit, skip, projection; if (ctx.request.query.filter) filter = new Filter(ctx.request.query.filter); if (ctx.request.query.limit) limit = +ctx.request.query.limit; if (ctx.request.query.skip) skip = +ctx.request.query.skip; + if (ctx.request.query.projection) projection = ctx.request.query.projection; if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); @@ -50,20 +58,23 @@ for (let [resource, update] of Object.entries(resources)) { filter = f.and(filter); } - ctx.body = await apiFunctions.query( - resource, - filter, - limit, - skip, - ctx.request.query.projection - ); + let res; + if (flags & RESOURCE_DB) + res = await db.query(resource, filter, limit, skip); + else + res = await apiFunctions.query(resource, filter, limit, skip, projection); + + ctx.body = res; }); router.head(`/${resource}/:id`, async (ctx, next) => { let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); - let res = await apiFunctions.query(resource, filter); + let res; + if (flags & RESOURCE_DB) res = await db.query(resource, filter); + else res = await apiFunctions.query(resource, filter); + if (!res.length) return next(); ctx.body = ""; }); @@ -72,13 +83,15 @@ for (let [resource, update] of Object.entries(resources)) { let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); - let res = await apiFunctions.query(resource, filter); + let res; + if (flags & RESOURCE_DB) res = await db.query(resource, filter); + else res = await apiFunctions.query(resource, filter); if (!res.length) return next(); ctx.body = res[0]; }); // TODO add PUT, PATCH routes - if (update) + if (flags & RESOURCE_DELETE && !(flags & RESOURCE_DB)) router.delete(`/${resource}/:id`, async (ctx, next) => { const authorizer = ctx.state.authorizer; let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..0cb0418 --- /dev/null +++ b/server/db.js @@ -0,0 +1,67 @@ +"use strict"; + +const MongoClient = require("mongodb").MongoClient; +const config = require("./config"); +const apiFunctions = require("./api-functions"); + +let _client = null; + +function getClient() { + if (_client) return Promise.resolve(_client); + + return new Promise((resolve, reject) => { + const CONNECTION_URL = config.get("server.mongodbConnectionUrl"); + MongoClient.connect(CONNECTION_URL, (err, client) => { + if (err) return reject(err); + resolve((_client = client.db("genieacs-ui"))); + }); + }); +} + +function query(resource, filter, limit, skip, callback) { + let q; + if (filter && filter.ast) q = apiFunctions.filterToMongoQuery(filter.ast); + + return new Promise((resolve, reject) => { + getClient().then(client => { + const collection = client.collection(resource); + if (!callback) + collection + .find(q) + .skip(skip || 0) + .limit(limit || 0) + .toArray((err, docs) => { + if (err) return reject(err); + return resolve(docs); + }); + else + collection.find(q).forEach( + doc => { + callback(doc); + }, + err => { + if (err) reject(err); + else resolve(); + } + ); + }); + }); +} + +function count(resource, filter) { + let q; + if (filter && filter.ast) q = apiFunctions.filterToMongoQuery(filter.ast); + + return new Promise((resolve, reject) => { + getClient().then(client => { + const collection = client.collection(resource); + collection.find(q).count((err, c) => { + if (err) reject(err); + else resolve(c); + }); + }); + }); +} + +exports.query = query; +exports.count = count; From d222e488c23c717f79a3b10e905c15deccdebbb4 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Mon, 11 Jun 2018 21:06:58 -0700 Subject: [PATCH 022/106] Fix taskQueue premature promise resolution --- client/task-queue.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/task-queue.js b/client/task-queue.js index b856a1e..e221e7e 100644 --- a/client/task-queue.js +++ b/client/task-queue.js @@ -46,7 +46,8 @@ function commit(tasks, callback) { return new Promise(resolve => { let counter = 1; - for (let [deviceId, tasks2] of Object.entries(devices)) + for (let [deviceId, tasks2] of Object.entries(devices)) { + ++counter; store .postTasks(deviceId, tasks) .then(connectionRequestStatus => { @@ -61,6 +62,7 @@ function commit(tasks, callback) { callback(deviceId, err, null, tasks2); if (--counter === 0) resolve(); }); + } if (--counter === 0) resolve(); }); From 8b540c2a97fa9206af7081d33c4d6842ee52f436 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 23 Dec 2018 04:48:07 -0800 Subject: [PATCH 023/106] Format timestamps according to client locale --- client/components/parameter.js | 2 +- client/faults-page.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/components/parameter.js b/client/components/parameter.js index 0e75687..0b339ec 100644 --- a/client/components/parameter.js +++ b/client/components/parameter.js @@ -11,7 +11,7 @@ const component = { if (!param || !param.value) return m("span.na", "N/A"); let value = param.value[0]; if (param.value[1] === "xsd:dateTime") - value = new Date(value).toISOString(); + value = new Date(value).toLocaleString(); let edit; if (param.writable) diff --git a/client/faults-page.js b/client/faults-page.js index 391138b..8faf397 100644 --- a/client/faults-page.js +++ b/client/faults-page.js @@ -9,7 +9,7 @@ import * as store from "./store"; import * as notifications from "./notifications"; function init(args) { - if (!window.authorizer.hasAccess("devices", 2)) + if (!window.authorizer.hasAccess("faults", 2)) return Promise.reject( new Error("You are not authorized to view this page") ); @@ -77,7 +77,7 @@ function renderTable(faultsResponse, total, selected, showMoreCallback) { m("td", f["code"]), m("td", f["message"]), m("td", f["retries"]), - m("td", f["timestamp"]) + m("td", new Date(f["timestamp"]).toLocaleString()) ) ); } From 722355ca57dee7b259d330457894ad4262733243 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 23 Dec 2018 04:51:54 -0800 Subject: [PATCH 024/106] Implement ping component --- client/components.js | 2 ++ client/components/ping.js | 54 +++++++++++++++++++++++++++++++++++++ client/css/app.css | 2 ++ client/store.js | 9 ++++++- config.json | 1 + server/api-functions.js | 56 +++++++++++++++++++++++++++++++++++++++ server/api.js | 4 +++ 7 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 client/components/ping.js diff --git a/client/components.js b/client/components.js index bfcbc13..458ed66 100644 --- a/client/components.js +++ b/client/components.js @@ -10,6 +10,7 @@ import deviceFaults from "./components/device-faults"; import allParameters from "./components/all-parameters"; import deviceActions from "./components/device-actions"; import tags from "./components/tags"; +import ping from "./components/ping"; function get(name) { if (name === "parameter") return parameter; @@ -22,6 +23,7 @@ function get(name) { else if (name === "all-parameters") return allParameters; else if (name === "device-actions") return deviceActions; else if (name === "tags") return tags; + else if (name === "ping") return ping; else throw new Error(`No such component '${name}'`); } diff --git a/client/components/ping.js b/client/components/ping.js new file mode 100644 index 0000000..89facae --- /dev/null +++ b/client/components/ping.js @@ -0,0 +1,54 @@ +"use strict"; + +import m from "mithril"; +import * as store from "../store"; + +const REFRESH_INTERVAL = 3000; + +const component = { + oninit: vnode => { + vnode.state.timestamp = 0; + vnode.state.ping = null; + vnode.state.host = null; + vnode.state.timeout = null; + }, + view: vnode => { + let refresh = () => { + const device = vnode.attrs.device; + let param = + device["InternetGatewayDevice.ManagementServer.ConnectionRequestURL"]; + if (!param) + param = device["Device.ManagementServer.ConnectionRequestURL"]; + if (!param || !param.value) return; + + const url = new URL(param.value[0]); + vnode.state.host = url.hostname; + vnode.state.timestamp = Date.now(); + store + .ping(vnode.state.host) + .then(res => { + vnode.state.ping = res; + }) + .catch(() => { + // Do nothing + }); + }; + + let t = vnode.state.timestamp + REFRESH_INTERVAL - Date.now(); + clearTimeout(vnode.state.timeout); + if (t <= 0) refresh(); + else vnode.state.timeout = setTimeout(refresh, t); + + if (vnode.state.host) { + let status = ""; + if (vnode.state.ping) + if (vnode.state.ping.avg != null) + status = `${Math.trunc(vnode.state.ping.avg)} ms`; + else status = "Unreachable"; + + return m("div", `Pinging ${vnode.state.host}: ${status}`); + } + } +}; + +export default component; diff --git a/client/css/app.css b/client/css/app.css index 5e46ab5..6e753aa 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -397,8 +397,10 @@ input { } table.parameter-list { + border-spacing: 0; text-align: left; & th { + padding: 0; padding-right: 1em; } } diff --git a/client/store.js b/client/store.js index 5ef7948..8519c37 100644 --- a/client/store.js +++ b/client/store.js @@ -400,6 +400,12 @@ function logOut() { }); } +function ping(host) { + return m.request({ + url: `/api/ping/${encodeURIComponent(host)}` + }); +} + export { count, fetch, @@ -411,5 +417,6 @@ export { deleteResource, evaluateExpression, logIn, - logOut + logOut, + ping }; diff --git a/config.json b/config.json index 685fbf0..31bfa93 100644 --- a/config.json +++ b/config.json @@ -59,6 +59,7 @@ "ui.overview.charts.online.slices.others.color": "#f7fcb9", "ui.overview.charts.online.slices.others.filter": "Events.Inform < (NOW() - 5 * 60 * 1000) - (24 * 60 * 60 * 1000)", "ui.device.10.type": "tags", + "ui.device.25.type": "ping", "ui.device.30.type": "parameter-list", "ui.device.30.parameters.1.type": "container", "ui.device.30.parameters.1.label": "Last inform", diff --git a/server/api-functions.js b/server/api-functions.js index 78a8bb2..8353690 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -367,9 +367,65 @@ function updateTags(deviceId, tags) { ); } +function ping(host) { + return new Promise((resolve, reject) => { + const options = url.parse( + `${config.get("server.nbi")}ping/${encodeURIComponent(host)}` + ); + + let _http = options.protocol === "https:" ? https : http; + _http + .request(options, res => { + if (res.statusCode === 404) { + res.resume(); + return resolve({}); + } + + if (res.statusCode !== 200) { + res.resume(); + return reject(new Error(`Unexpected status code ${res.statusCode}`)); + } + + const chunks = []; + let bytes = 0; + res.on("data", chunk => { + chunks.push(chunk); + bytes += chunk.length; + }); + + res.on("end", () => { + let buf = new Buffer(bytes); + let o = 0; + for (let c of chunks) { + c.copy(buf, o, 0, c.length); + o += c.length; + } + let m = buf + .toString() + .match( + /(\d) packets transmitted, (\d) received, ([\d.%]+) packet loss[^]*([\d.]+)\/([\d.]+)\/([\d.]+)\/([\d.]+)/ + ); + if (!m) return reject(new Error("Could not parse ping response")); + + resolve({ + packetsTransmitted: +m[1], + packetsReceived: +m[2], + packetLoss: m[3], + min: +m[4], + avg: +m[5], + max: +m[6], + mdev: +m[7] + }); + }); + }) + .end(); + }); +} + exports.query = query; exports.count = count; exports.postTasks = postTasks; exports.deleteResource = deleteResource; exports.updateTags = updateTags; exports.filterToMongoQuery = filterToMongoQuery; +exports.ping = ping; diff --git a/server/api.js b/server/api.js index cf47d7d..89a72a2 100644 --- a/server/api.js +++ b/server/api.js @@ -137,4 +137,8 @@ router.post("/devices/:id/tags", async (ctx, next) => { ctx.body = ""; }); +router.get("/ping/:host", async ctx => { + ctx.body = await apiFunctions.ping(ctx.params.host); +}); + module.exports = router; From f150d4448514cb389aefaf491659d06473caf359 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 24 Jun 2018 13:15:35 -0700 Subject: [PATCH 025/106] Fix special case filter by tag --- client/store.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/client/store.js b/client/store.js index 8519c37..785fac2 100644 --- a/client/store.js +++ b/client/store.js @@ -73,6 +73,16 @@ function limitFilter(resourceType, filter, last) { } function findMatches(resourceType, filter, limit) { + // Handle "tag =" and "tag <>" special cases + if (resourceType === "devices" && filter.ast) { + const ast = filterParser.map(filter.ast, e => { + if (e[1] === "tag") + if (e[0] === "=") return ["=", `Tags.${e[2]}`, true]; + else if (e[0] === "<>") return ["NOT", ["=", `Tags.${e[2]}`, true]]; + }); + filter = new Filter(ast); + } + let value = []; for (let [id, obj] of resources[resourceType].objects.entries()) if (filter.test(obj)) value.push(id); From cfb62adfe8770c174b3fcb19073759677e8b30eb Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 23 Dec 2018 04:55:01 -0800 Subject: [PATCH 026/106] Fix GET,HEAD,DELETE 404 bug --- client/store.js | 2 +- server/api.js | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/client/store.js b/client/store.js index 785fac2..410f9f4 100644 --- a/client/store.js +++ b/client/store.js @@ -22,7 +22,7 @@ const queries = { }; const resources = {}; -for (let r of ["devices", "faults"]) +for (let r of ["devices", "faults", "files"]) resources[r] = { objects: new Map(), count: new Map(), diff --git a/server/api.js b/server/api.js index 89a72a2..587ca9c 100644 --- a/server/api.js +++ b/server/api.js @@ -11,6 +11,16 @@ const router = new Router(); const RESOURCE_DELETE = 1 << 0; const RESOURCE_DB = 1 << 1; +const RESOURCE_IDS = { + devices: "DeviceID.ID", + presets: "_id", + provisions: "_id", + files: "_id", + virtual_parameters: "_id", + faults: "_id", + tasks: "_id" +}; + const resources = { devices: 0 | RESOURCE_DELETE, presets: 0 | RESOURCE_DELETE, @@ -68,7 +78,7 @@ for (let [resource, flags] of Object.entries(resources)) { }); router.head(`/${resource}/:id`, async (ctx, next) => { - let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); + let filter = new Filter(`${RESOURCE_IDS[resource]} = "${ctx.params.id}"`); if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); let res; @@ -80,7 +90,7 @@ for (let [resource, flags] of Object.entries(resources)) { }); router.get(`/${resource}/:id`, async (ctx, next) => { - let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); + let filter = new Filter(`${RESOURCE_IDS[resource]} = "${ctx.params.id}"`); if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); let res; @@ -94,7 +104,7 @@ for (let [resource, flags] of Object.entries(resources)) { if (flags & RESOURCE_DELETE && !(flags & RESOURCE_DB)) router.delete(`/${resource}/:id`, async (ctx, next) => { const authorizer = ctx.state.authorizer; - let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); + let filter = new Filter(`${RESOURCE_IDS[resource]} = "${ctx.params.id}"`); if (!authorizer.hasAccess(resource, 2)) return next(); let res = await apiFunctions.query(resource, filter); if (!res.length) return next(); From 99e7c8a14c1d98166f412be701d954138838c7d6 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 24 Jun 2018 13:20:35 -0700 Subject: [PATCH 027/106] Implement download tasks --- client/components/device-actions.js | 16 ++++ client/drawer-component.js | 139 ++++++++++++++++++++++------ client/task-queue.js | 5 + config.json | 2 + 4 files changed, 134 insertions(+), 28 deletions(-) diff --git a/client/components/device-actions.js b/client/components/device-actions.js index ccfd728..523c123 100644 --- a/client/components/device-actions.js +++ b/client/components/device-actions.js @@ -43,6 +43,22 @@ const component = { ) ); + buttons.push( + m( + "button.critical", + { + title: "Push a firmware or a config file", + onclick: () => { + taskQueue.stageDownload({ + name: "download", + device: device["DeviceID.ID"].value[0] + }); + } + }, + "Push file" + ) + ); + buttons.push( m( "button.primary", diff --git a/client/drawer-component.js b/client/drawer-component.js index f40a0ac..1631d74 100644 --- a/client/drawer-component.js +++ b/client/drawer-component.js @@ -5,6 +5,7 @@ import m from "mithril"; import * as taskQueue from "./task-queue"; import * as store from "./store"; import * as notifications from "./notifications"; +import Filter from "../common/filter"; function mparam(param) { return m("span.parameter", { title: param }, `${param}`); @@ -14,6 +15,101 @@ function mval(val) { return m("span.value", { title: val }, `${val}`); } +function renderStagingSpv(task, queueFunc, cancelFunc) { + const input = m("input", { + value: task.parameterValues[0][1], + oninput: e => { + e.redraw = false; + task.parameterValues[0][1] = input.dom.value; + }, + onkeydown: e => { + if (e.keyCode === 13) queueFunc(); + else if (e.keyCode === 27) cancelFunc(); + else e.redraw = false; + }, + oncreate: vnode => { + vnode.dom.focus(); + vnode.dom.select(); + // Need to prevent scrolling on focus because + // we're animating height and using overflow: hidden + vnode.dom.parentNode.parentNode.scrollTop = 0; + } + }); + + return [m("span", "Editing ", mparam(task.parameterValues[0][0])), input]; +} + +function renderStagingDownload(task) { + task.invalid = !task.fileName || !task.fileType; + const files = store.fetch("files", new Filter()); + const idParts = task.device.split("-"); + const oui = decodeURIComponent(idParts[0]); + const productClass = + idParts.length === 3 ? decodeURIComponent(idParts[1]) : ""; + + const typesList = [ + "", + "1 Firmware Upgrade Image", + "2 Web Content", + "3 Vendor Configuration File", + "4 Tone File", + "5 Ringer File" + ].map(t => + m( + "option", + { + disabled: !t, + value: t, + selected: (task.fileType || "") === t, + onclick: () => { + task.fileType = t; + } + }, + t + ) + ); + + const filesList = [""] + .concat( + files.value + .filter( + f => + (!f.metadata.oui || f.metadata.oui === oui) && + (!f.metadata.productClass || + f.metadata.productClass === productClass) + ) + .map(f => f._id) + ) + .map(f => + m( + "option", + { + disabled: !f, + value: f, + selected: (task.fileName || "") === f, + onclick: () => { + task.fileName = f; + task.fileType = ""; + for (let file of files.value) + if (file._id === f) task.fileType = file.metadata.fileType; + } + }, + f + ) + ); + + return [ + "Push ", + m( + "select", + { disabled: files.fulfilling, style: "width: 350px" }, + filesList + ), + " as ", + m("select", typesList) + ]; +} + function renderStaging(staging) { let elements = []; for (let s of staging) { @@ -25,29 +121,15 @@ function renderStaging(staging) { staging.delete(s); }; - const input = m("input", { - value: s.parameterValues[0][1], - oninput: e => { - e.redraw = false; - s.parameterValues[0][1] = input.dom.value; - }, - onkeydown: e => { - if (e.keyCode === 13) queueFunc(); - else if (e.keyCode === 27) cancelFunc(); - else e.redraw = false; - }, - oncreate: vnode => { - vnode.dom.focus(); - vnode.dom.select(); - // Need to prevent scrolling on focus because - // we're animating height and using overflow: hidden - vnode.dom.parentNode.parentNode.scrollTop = 0; - } - }); + let elms; + if (s.name === "setParameterValues") + elms = renderStagingSpv(s, queueFunc, cancelFunc); + else if (s.name === "download") + elms = renderStagingDownload(s, queueFunc, cancelFunc); const queue = m( "button.primary", - { title: "Queue task", onclick: queueFunc }, + { title: "Queue task", onclick: queueFunc, disabled: s.invalid }, "Queue" ); const cancel = m( @@ -56,14 +138,7 @@ function renderStaging(staging) { "Cancel" ); - elements.push( - m( - ".staging", - m("span", "Editing ", mparam(s.parameterValues[0][0])), - input, - m("div.actions", queue, cancel) - ) - ); + elements.push(m(".staging", elms, m("div.actions", queue, cancel))); } return elements; } @@ -161,6 +236,14 @@ function renderQueue(queue) { m(".actions", actions) ) ); + else if (t.name === "download") + details.push( + m( + `div.${t.status}`, + `Push file: ${t.fileName} (${t.fileType})`, + m(".actions", actions) + ) + ); else details.push(m(`div.${t.status}`, t.name, m(".actions", actions))); } } diff --git a/client/task-queue.js b/client/task-queue.js index e221e7e..df0b9db 100644 --- a/client/task-queue.js +++ b/client/task-queue.js @@ -36,6 +36,10 @@ function stageSpv(task) { staging.add(task); } +function stageDownload(task) { + staging.add(task); +} + function commit(tasks, callback) { let devices = {}; for (let t of tasks) { @@ -76,5 +80,6 @@ export { getStaging, clearStaging, stageSpv, + stageDownload, commit }; diff --git a/config.json b/config.json index 31bfa93..6016c8c 100644 --- a/config.json +++ b/config.json @@ -11,8 +11,10 @@ "permissions.admin.1.devices.validate.pass": true, "permissions.admin.1.faults.access": 3, "permissions.admin.1.faults.validate.pass": true, + "permissions.admin.1.files.access": 3, "permissions.view.1.devices.access": 2, "permissions.view.1.faults.access": 2, + "permissions.view.1.files.access": 2, "ui.pageSize": 10, "ui.filters.1.label": "Serial number", "ui.filters.1.parameter": "DeviceID.SerialNumber", From 581a51096084bdd272a505b335db83620c89c680 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Mon, 25 Jun 2018 15:13:02 -0700 Subject: [PATCH 028/106] Implement overlay popup --- client/css/app.css | 24 ++++++++++++++++++ client/layout.js | 4 ++- client/overlay.js | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 client/overlay.js diff --git a/client/css/app.css b/client/css/app.css index 6e753aa..7abbdcf 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -533,3 +533,27 @@ span.tag { margin-right: 0.5em; } } + +.overlay-wrapper { + position: fixed; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.2); + z-index: 100; + transition: opacity var(--fade), top var(--fade); + display: flex; + align-items: center; + justify-content: center; + + & > .overlay { + max-width: 90%; + max-height: 90%; + background-color: white; + border: 1px solid var(--color1); + border-radius: 10px; + padding: 10px; + box-shadow: 0 0 15px #aaa; + } +} diff --git a/client/layout.js b/client/layout.js index c4bc4b1..b2d3ecf 100644 --- a/client/layout.js +++ b/client/layout.js @@ -5,6 +5,7 @@ import m from "mithril"; import menu from "./menu"; import drawerComponent from "./drawer-component"; import userMenu from "./user-menu"; +import * as overlay from "./overlay"; const layout = { view: vnode => { @@ -15,7 +16,8 @@ const layout = { m(menu, { page: vnode.attrs.page }), m(drawerComponent) ]), - m("#content", { class: `page-${vnode.attrs.page}` }, [vnode.children]) + m("#content", { class: `page-${vnode.attrs.page}` }, [vnode.children]), + overlay.render() ]; } }; diff --git a/client/overlay.js b/client/overlay.js new file mode 100644 index 0000000..9cc6c1e --- /dev/null +++ b/client/overlay.js @@ -0,0 +1,61 @@ +"use strict"; + +import m from "mithril"; + +let overlayCallback = null; + +function open(callback) { + overlayCallback = callback; +} + +function close(callback) { + if (callback === overlayCallback) { + overlayCallback = null; + return true; + } + + return false; +} + +function render() { + if (overlayCallback) + return m( + ".overlay-wrapper", + { + tabindex: 0, + onclick: () => { + close(overlayCallback); + }, + style: "opacity: 0", + oncreate: vnode => { + vnode.dom.focus(); + vnode.dom.style.opacity = 1; + }, + onbeforeremove: vnode => { + vnode.dom.style.opacity = 0; + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 500); + }); + } + }, + m( + ".overlay", + { + onclick: e => { + e.stopPropagation(); + } + }, + overlayCallback() + ) + ); + + return null; +} + +document.addEventListener("keydown", e => { + if (e.keyCode === 27 && close(overlayCallback)) m.redraw(); +}); + +export { open, close, render }; From 30dbf0cf406608b5ef0d57109b20d356bebe71de Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Fri, 29 Jun 2018 22:40:29 -0700 Subject: [PATCH 029/106] Use nested config structure --- client/app.js | 3 + client/components/overview-dot.js | 10 +- client/config.js | 39 +--- client/device-page.js | 4 +- client/devices-page.js | 6 +- client/overview-page.js | 16 +- client/pie-chart-component.js | 4 +- client/store.js | 3 +- common/filter.js | 5 + common/func-cache.js | 27 +++ config.json | 374 ++++++++++++++++++++---------- server/api-functions.js | 16 +- server/config.js | 93 +++----- server/db.js | 2 +- server/server.js | 15 +- 15 files changed, 366 insertions(+), 251 deletions(-) create mode 100644 common/func-cache.js diff --git a/client/app.js b/client/app.js index fef3830..ae72780 100644 --- a/client/app.js +++ b/client/app.js @@ -11,6 +11,7 @@ import * as devicePage from "./device-page"; import * as errorPage from "./error-page"; import * as faultsPage from "./faults-page"; import Authorizer from "../common/authorizer"; +import * as funcCache from "../common/func-cache"; window.authorizer = new Authorizer(window.permissionSets); @@ -68,6 +69,8 @@ function pagify(pageName, page) { return component; } +setInterval(funcCache.purge, 120000); + m.route(document.body, "/overview", { "/login": pagify("login", loginPage), "/overview": pagify("overview", overviewPage), diff --git a/client/components/overview-dot.js b/client/components/overview-dot.js index 228021c..e306be8 100644 --- a/client/components/overview-dot.js +++ b/client/components/overview-dot.js @@ -1,17 +1,21 @@ "use strict"; import m from "mithril"; -import * as config from "../config"; +import config from "../config"; import * as store from "../store"; +import * as funcCache from "../../common/func-cache"; +import Filter from "../../common/filter"; -const CHARTS = config.get("ui.overview.charts"); +const CHARTS = config.ui.overview.charts; const component = { view: vnode => { const device = vnode.attrs.device; const chart = CHARTS[vnode.attrs.chart]; for (let slice of Object.values(chart.slices)) { - const filter = store.unpackFilter(slice.filter); + const filter = store.unpackFilter( + funcCache.get(Filter.parse, slice.filter) + ); if (filter.test(device)) { const dot = m( "svg", diff --git a/client/config.js b/client/config.js index 48ec035..8a1dfcf 100644 --- a/client/config.js +++ b/client/config.js @@ -1,40 +1,3 @@ "use strict"; -import * as Filter from "../common/filter"; - -const configFile = window.clientConfig; - -const cache = {}; - -function parse(name, value) { - if (/^ui\.overview\.charts\.[^.]+\.slices\.[^.]+\.filter$/.test(name)) - return new Filter(value); - - return value; -} - -function get(name) { - let v = cache[name]; - if (v === undefined) { - v = configFile[name]; - if (v != null) { - v = parse(name, v); - } else { - const prefix = `${name}.`; - for (let k of Object.keys(configFile)) - if (k.startsWith(prefix)) { - v = v || {}; - let ar = k.slice(prefix.length).split("."); - let ref = v; - for (let a of ar.slice(0, -1)) ref = ref[a] = ref[a] || {}; - ref[ar[ar.length - 1]] = parse(k, configFile[k]); - } - } - if (v) cache[name] = v; - else cache[name] = null; - } - - return v; -} - -export { get }; +export default window.clientConfig; diff --git a/client/device-page.js b/client/device-page.js index 8eef07e..a42a045 100644 --- a/client/device-page.js +++ b/client/device-page.js @@ -2,7 +2,7 @@ import m from "mithril"; -import * as config from "./config"; +import config from "./config"; import Filter from "../common/filter"; import * as store from "./store"; import * as components from "./components"; @@ -25,7 +25,7 @@ const component = { let dev = store.fetch("devices", vnode.attrs.deviceFilter).value; if (!dev.length) return "Loading"; - const conf = config.get("ui.device"); + const conf = config.ui.device; const cmps = []; for (let c of Object.values(conf)) diff --git a/client/devices-page.js b/client/devices-page.js index 3c2a823..777e947 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -2,7 +2,7 @@ import m from "mithril"; -import * as config from "./config"; +import config from "./config"; import filterComponent from "./filter-component"; import * as filterParser from "../common/filter-parser"; import Filter from "../common/filter"; @@ -17,7 +17,7 @@ function init(args) { return reject(new Error("You are not authorized to view this page")); let filter = new Filter(args.filter); - let indexParameters = Object.values(config.get("ui.index")).map(p => + let indexParameters = Object.values(config.ui.index).map(p => Object.assign({}, p, { parameter: filterParser.parseParameter(p.parameter) }) @@ -302,7 +302,7 @@ const component = { return [ m("h1", "Listing devices"), m(filterComponent, { - predefined: Object.values(config.get("ui.filters")), + predefined: Object.values(config.ui.filters), filter: vnode.attrs.filter ? vnode.attrs.filter.ast : null, onChange: onFilterChanged }), diff --git a/client/overview-page.js b/client/overview-page.js index 740bfc6..362902a 100644 --- a/client/overview-page.js +++ b/client/overview-page.js @@ -1,23 +1,27 @@ "use strict"; import m from "mithril"; -import * as config from "./config"; +import config from "./config"; import * as store from "./store"; import pieChartComponent from "./pie-chart-component"; +import Filter from "../common/filter"; +import * as funcCache from "../common/func-cache"; -const GROUPS = config.get("ui.overview.groups"); +const GROUPS = config.ui.overview.groups; const CHARTS = {}; for (let group of Object.values(GROUPS)) for (let chartName of Object.values(group["charts"])) - CHARTS[chartName] = config.get("ui.overview.charts")[chartName]; + CHARTS[chartName] = config.ui.overview.charts[chartName]; function queryCharts(charts) { charts = Object.assign({}, charts); for (let [chartName, chart] of Object.entries(charts)) { charts[chartName] = chart = Object.assign({}, chart); + chart.slices = Object.assign({}, chart.slices); for (let [sliceName, slice] of Object.entries(chart.slices)) { + const filter = funcCache.get(Filter.parse, slice.filter); chart.slices[sliceName] = slice = Object.assign({}, slice); - slice.count = store.count("devices", slice.filter); + slice.count = store.count("devices", filter); } } return charts; @@ -33,7 +37,7 @@ const init = function() { }; const component = { - view: () => { + view: vnode => { document.title = "Overview - GenieACS"; let children = []; for (let group of Object.values(GROUPS)) { @@ -41,7 +45,7 @@ const component = { let groupChildren = []; for (let chartName of Object.values(group.charts)) { - const chart = CHARTS[chartName]; + const chart = vnode.attrs.charts[chartName]; let chartChildren = []; if (chart.label) chartChildren.push(m("h2", chart.label)); diff --git a/client/pie-chart-component.js b/client/pie-chart-component.js index 025fd07..745e116 100644 --- a/client/pie-chart-component.js +++ b/client/pie-chart-component.js @@ -28,7 +28,7 @@ const drawChart = function(chartData) { "a", { href: `/#!/devices/?${m.buildQueryString({ - filter: slice.filter.toString() + filter: slice.filter })}` }, slice.count.value || 0 @@ -68,7 +68,7 @@ const drawChart = function(chartData) { "a", { href: `/#!/devices/?${m.buildQueryString({ - filter: slice.filter.toString() + filter: slice.filter })}` }, [ diff --git a/client/store.js b/client/store.js index 410f9f4..ac8435f 100644 --- a/client/store.js +++ b/client/store.js @@ -2,8 +2,9 @@ import m from "mithril"; import Filter from "../common/filter"; -import * as config from "./config"; +import config from "./config"; import * as filterParser from "../common/filter-parser"; +import * as funcCache from "../common/func-cache"; let fulfillTimestamp = 0; diff --git a/common/filter.js b/common/filter.js index a0ebd62..24788d1 100644 --- a/common/filter.js +++ b/common/filter.js @@ -3,6 +3,7 @@ const filterParser = require("./filter-parser"); const filterCnf = require("./filter-cnf"); const satSolver = require("./sat-solver"); +const funcCache = require("./func-cache"); function* permute(arr) { if (arr.length <= 1) { @@ -24,6 +25,10 @@ class Filter { else this.ast = filterParser.parse(str); } + static parse(str) { + return new Filter(str); + } + toString() { if (!this.string) if (this.ast) this.string = filterParser.stringify(this.ast); diff --git a/common/func-cache.js b/common/func-cache.js new file mode 100644 index 0000000..837f01d --- /dev/null +++ b/common/func-cache.js @@ -0,0 +1,27 @@ +"use strict"; + +let cache1 = new WeakMap(); +let cache2 = new WeakMap(); + +function get(func, k) { + let c1 = cache1.get(func); + if (!c1) cache1.set(func, (c1 = new Map())); + if (c1.has(k)) return c1.get(k); + const c2 = cache2.get(func); + const v = c2 && c2.has(k) ? c2.get(k) : func(k); + c1.set(k, v); + return v; +} + +function getter(func) { + return k => get(func, k); +} + +function purge() { + cache2 = cache1; + cache1 = new WeakMap(); +} + +exports.get = get; +exports.getter = getter; +exports.purge = purge; diff --git a/config.json b/config.json index 6016c8c..f1286fd 100644 --- a/config.json +++ b/config.json @@ -1,119 +1,259 @@ { - "server.port": 3000, - "server.nbi": "http://127.0.0.1:7557/", - "server.mongodbConnectionUrl": "mongodb://127.0.0.1:27017", - "server.jwtSecret": "jwtSecret", - "auth.simple.users.admin.password": "admin", - "auth.simple.users.admin.roles": "admin", - "auth.simple.users.view.password": "view", - "auth.simple.users.view.roles": "view", - "permissions.admin.1.devices.access": 3, - "permissions.admin.1.devices.validate.pass": true, - "permissions.admin.1.faults.access": 3, - "permissions.admin.1.faults.validate.pass": true, - "permissions.admin.1.files.access": 3, - "permissions.view.1.devices.access": 2, - "permissions.view.1.faults.access": 2, - "permissions.view.1.files.access": 2, - "ui.pageSize": 10, - "ui.filters.1.label": "Serial number", - "ui.filters.1.parameter": "DeviceID.SerialNumber", - "ui.filters.1.type": "string", - "ui.filters.2.label": "Product class", - "ui.filters.2.parameter": "DeviceID.ProductClass", - "ui.filters.2.type": "string", - "ui.filters.3.label": "Tag", - "ui.filters.3.parameter": "tag", - "ui.filters.3.type": "string", - "ui.index.1.label": "Serial number", - "ui.index.1.parameter": "DeviceID.SerialNumber", - "ui.index.2.label": "Product class", - "ui.index.2.parameter": "DeviceID.ProductClass", - "ui.index.3.label": "Software version", - "ui.index.3.parameter": "InternetGatewayDevice.DeviceInfo.SoftwareVersion", - "ui.index.4.label": "MAC", - "ui.index.4.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress", - "ui.index.4.label": "IP", - "ui.index.4.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress", - "ui.index.5.label": "SSID", - "ui.index.5.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", - "ui.index.6.label": "Last inform", - "ui.index.6.parameter": "Events.Inform", - "ui.index.6.type": "container", - "ui.index.6.element": "span.inform", - "ui.index.6.components.1.type": "parameter", - "ui.index.6.components.2.type": "overview-dot", - "ui.index.6.components.2.chart": "online", - "ui.index.7.label": "Tags", - "ui.index.7.parameter": "Tags", - "ui.index.7.type": "tags", - "ui.index.7.writable": false, - "ui.overview.groups.online.label": "", - "ui.overview.groups.online.charts.1": "online", - "ui.overview.charts.online.label": "Online status", - "ui.overview.charts.online.slices.onlineNow.label": "Online now", - "ui.overview.charts.online.slices.onlineNow.color": "#31a354", - "ui.overview.charts.online.slices.onlineNow.filter": "Events.Inform > NOW() - 5 * 60 * 1000", - "ui.overview.charts.online.slices.past24.label": "Past 24 hours", - "ui.overview.charts.online.slices.past24.color": "#addd8e", - "ui.overview.charts.online.slices.past24.filter": "Events.Inform > (NOW() - 5 * 60 * 1000) - (24 * 60 * 60 * 1000) AND Events.Inform < (NOW() - 5 * 60 * 1000)", - "ui.overview.charts.online.slices.others.label": "Others", - "ui.overview.charts.online.slices.others.color": "#f7fcb9", - "ui.overview.charts.online.slices.others.filter": "Events.Inform < (NOW() - 5 * 60 * 1000) - (24 * 60 * 60 * 1000)", - "ui.device.10.type": "tags", - "ui.device.25.type": "ping", - "ui.device.30.type": "parameter-list", - "ui.device.30.parameters.1.type": "container", - "ui.device.30.parameters.1.label": "Last inform", - "ui.device.30.parameters.1.element": "span.inform", - "ui.device.30.parameters.1.parameter": "Events.Inform", - "ui.device.30.parameters.1.components.1.type": "parameter", - "ui.device.30.parameters.1.components.2.type": "overview-dot", - "ui.device.30.parameters.1.components.2.chart": "online", - "ui.device.30.parameters.1.components.3.type": "summon-button", - "ui.device.30.parameters.1.components.3.parameters.1": "InternetGatewayDevice.DeviceInfo.HardwareVersion", - "ui.device.30.parameters.1.components.3.parameters.2": "InternetGatewayDevice.DeviceInfo.SoftwareVersion", - "ui.device.30.parameters.1.components.3.parameters.3": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress", - "ui.device.30.parameters.1.components.3.parameters.4": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress", - "ui.device.30.parameters.1.components.3.parameters.5": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", - "ui.device.30.parameters.1.components.3.parameters.6": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase", - "ui.device.30.parameters.1.components.3.parameters.7": "InternetGatewayDevice.LANDevice.1.Hosts.Host", - "ui.device.30.parameters.2.label": "Last boot", - "ui.device.30.parameters.2.parameter": "Events.1_BOOT", - "ui.device.30.parameters.3.label": "Last bootstrap", - "ui.device.30.parameters.3.parameter": "Events.0_BOOTSTRAP", - "ui.device.30.parameters.4.label": "Registered", - "ui.device.30.parameters.4.parameter": "Events.Registered", - "ui.device.30.parameters.2.label": "Serial number", - "ui.device.30.parameters.2.parameter": "InternetGatewayDevice.DeviceInfo.SerialNumber", - "ui.device.30.parameters.3.label": "Product class", - "ui.device.30.parameters.3.parameter": "InternetGatewayDevice.DeviceInfo.ProductClass", - "ui.device.30.parameters.4.label": "OUI", - "ui.device.30.parameters.4.parameter": "InternetGatewayDevice.DeviceInfo.ManufacturerOUI", - "ui.device.30.parameters.5.label": "Manufacturer", - "ui.device.30.parameters.5.parameter": "InternetGatewayDevice.DeviceInfo.Manufacturer", - "ui.device.30.parameters.6.label": "Hardware version", - "ui.device.30.parameters.6.parameter": "InternetGatewayDevice.DeviceInfo.HardwareVersion", - "ui.device.30.parameters.7.label": "Software version", - "ui.device.30.parameters.7.parameter": "InternetGatewayDevice.DeviceInfo.SoftwareVersion", - "ui.device.30.parameters.8.label": "MAC", - "ui.device.30.parameters.8.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress", - "ui.device.30.parameters.9.label": "IP", - "ui.device.30.parameters.9.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress", - "ui.device.30.parameters.10.label": "WLAN SSID", - "ui.device.30.parameters.10.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", - "ui.device.30.parameters.11.label": "WLAN passphrase", - "ui.device.30.parameters.11.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase", - "ui.device.40.type": "parameter-table", - "ui.device.40.label": "WLAN hosts", - "ui.device.40.parameter": "InternetGatewayDevice.LANDevice.1.Hosts.Host", - "ui.device.40.childParameters.1.label": "Host name", - "ui.device.40.childParameters.1.parameter": "HostName", - "ui.device.40.childParameters.2.label": "IP address", - "ui.device.40.childParameters.2.parameter": "IPAddress", - "ui.device.40.childParameters.3.label": "MAC address", - "ui.device.40.childParameters.3.parameter": "MACAddress", - "ui.device.50.type": "device-faults", - "ui.device.60.type": "all-parameters", - "ui.device.70.type": "device-actions" + "server": { + "port": 3000, + "nbi": "http://127.0.0.1:7557/", + "mongodbConnectionUrl": "mongodb://127.0.0.1:27017", + "jwtSecret": "jwtSecret" + }, + "auth": { + "simple": { + "users": { + "admin": { + "password": "admin", + "roles": "admin" + }, + "view": { + "password": "view", + "roles": "view" + } + } + } + }, + "permissions": { + "admin": [ + { + "devices": { + "access": 3, + "validate": { + "pass": true + } + }, + "faults": { + "access": 3, + "validate": { + "pass": true + } + }, + "files": { + "access": 3 + } + } + ], + "view": [ + { + "devices": { + "access": 2 + }, + "faults": { + "access": 2 + }, + "files": { + "access": 2 + } + } + ] + }, + "ui": { + "pageSize": 10, + "filters": [ + { + "label": "Serial number", + "parameter": "DeviceID.SerialNumber", + "type": "string" + }, + { + "label": "Product class", + "parameter": "DeviceID.ProductClass", + "type": "string" + }, + { + "label": "Tag", + "parameter": "tag", + "type": "string" + } + ], + "index": [ + { + "label": "Serial number", + "parameter": "DeviceID.SerialNumber" + }, + { + "label": "Product class", + "parameter": "DeviceID.ProductClass" + }, + { + "label": "Software version", + "parameter": "InternetGatewayDevice.DeviceInfo.SoftwareVersion" + }, + { + "label": "IP", + "parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress" + }, + { + "label": "SSID", + "parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID" + }, + { + "label": "Last inform", + "parameter": "Events.Inform", + "type": "container", + "element": "span.inform", + "components": [ + { + "type": "parameter" + }, + { + "type": "overview-dot", + "chart": "online" + } + ] + }, + { + "label": "Tags", + "parameter": "Tags", + "type": "tags", + "writable": false + } + ], + "overview": { + "groups": { + "online": { + "label": "", + "charts": [ + "online" + ] + } + }, + "charts": { + "online": { + "label": "Online status", + "slices": { + "onlineNow": { + "label": "Online now", + "color": "#31a354", + "filter": "Events.Inform > NOW() - 5 * 60 * 1000" + }, + "past24": { + "label": "Past 24 hours", + "color": "#addd8e", + "filter": "Events.Inform > (NOW() - 5 * 60 * 1000) - (24 * 60 * 60 * 1000) AND Events.Inform < (NOW() - 5 * 60 * 1000)" + }, + "others": { + "label": "Others", + "color": "#f7fcb9", + "filter": "Events.Inform < (NOW() - 5 * 60 * 1000) - (24 * 60 * 60 * 1000)" + } + } + } + } + }, + "device": [ + { + "type": "tags" + }, + { + "type": "ping" + }, + { + "type": "parameter-list", + "parameters": [ + { + "type": "container", + "label": "Last inform", + "element": "span.inform", + "parameter": "Events.Inform", + "components": [ + { + "type": "parameter" + }, + { + "type": "overview-dot", + "chart": "online" + }, + { + "type": "summon-button", + "parameters": [ + "InternetGatewayDevice.DeviceInfo.HardwareVersion", + "InternetGatewayDevice.DeviceInfo.SoftwareVersion", + "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress", + "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress", + "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", + "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase", + "InternetGatewayDevice.LANDevice.1.Hosts.Host" + ] + } + ] + }, + { + "label": "Serial number", + "parameter": "InternetGatewayDevice.DeviceInfo.SerialNumber" + }, + { + "label": "Product class", + "parameter": "InternetGatewayDevice.DeviceInfo.ProductClass" + }, + { + "label": "OUI", + "parameter": "InternetGatewayDevice.DeviceInfo.ManufacturerOUI" + }, + { + "label": "Manufacturer", + "parameter": "InternetGatewayDevice.DeviceInfo.Manufacturer" + }, + { + "label": "Hardware version", + "parameter": "InternetGatewayDevice.DeviceInfo.HardwareVersion" + }, + { + "label": "Software version", + "parameter": "InternetGatewayDevice.DeviceInfo.SoftwareVersion" + }, + { + "label": "MAC", + "parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress" + }, + { + "label": "IP", + "parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress" + }, + { + "label": "WLAN SSID", + "parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID" + }, + { + "label": "WLAN passphrase", + "parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase" + } + ] + }, + { + "type": "parameter-table", + "label": "WLAN hosts", + "parameter": "InternetGatewayDevice.LANDevice.1.Hosts.Host", + "childParameters": [ + { + "label": "Host name", + "parameter": "HostName" + }, + { + "label": "IP address", + "parameter": "IPAddress" + }, + { + "label": "MAC address", + "parameter": "MACAddress" + } + ] + }, + { + "type": "device-faults" + }, + { + "type": "all-parameters" + }, + { + "type": "device-actions" + } + ] + } } diff --git a/server/api-functions.js b/server/api-functions.js index 8353690..c10c476 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -92,7 +92,7 @@ function count(resource, filter, limit) { if (limit) qs.limit = limit; let options = url.parse( - `${config.get("server.nbi")}${resource}?${querystring.stringify(qs)}` + `${config.server.nbi}${resource}?${querystring.stringify(qs)}` ); options.method = "HEAD"; @@ -133,7 +133,7 @@ function query(resource, filter, limit, skip, projection, callback) { qs.sort = JSON.stringify({ _id: 1 }); let options = url.parse( - `${config.get("server.nbi")}${resource}?${querystring.stringify(qs)}` + `${config.server.nbi}${resource}?${querystring.stringify(qs)}` ); let _http = options.protocol === "https:" ? https : http; @@ -209,7 +209,7 @@ function query(resource, filter, limit, skip, projection, callback) { function deleteResource(resource, id) { return new Promise((resolve, reject) => { let options = url.parse( - `${config.get("server.nbi")}${resource}/${encodeURIComponent(id)}` + `${config.server.nbi}${resource}/${encodeURIComponent(id)}` ); options.method = "DELETE"; @@ -229,7 +229,7 @@ function deleteResource(resource, id) { function postTask(task, connectionRequest, callback) { let options = url.parse( - `${config.get("server.nbi")}devices/${task.device}/tasks${ + `${config.server.nbi}devices/${task.device}/tasks${ connectionRequest ? "?connection_request" : "" }` ); @@ -346,9 +346,9 @@ function updateTags(deviceId, tags) { ([tag, onOff]) => new Promise((resolve, reject) => { const options = url.parse( - `${config.get( - "server.nbi" - )}devices/${deviceId}/tags/${encodeURIComponent(tag)}` + `${config.server.nbi}devices/${deviceId}/tags/${encodeURIComponent( + tag + )}` ); if (onOff) options.method = "POST"; else options.method = "DELETE"; @@ -370,7 +370,7 @@ function updateTags(deviceId, tags) { function ping(host) { return new Promise((resolve, reject) => { const options = url.parse( - `${config.get("server.nbi")}ping/${encodeURIComponent(host)}` + `${config.server.nbi}ping/${encodeURIComponent(host)}` ); let _http = options.protocol === "https:" ? https : http; diff --git a/server/config.js b/server/config.js index c94460a..b7c5a92 100644 --- a/server/config.js +++ b/server/config.js @@ -1,72 +1,37 @@ "use strict"; -const filterParser = require("../common/filter-parser.js"); -const Filter = require("../common/filter.js"); - const configFile = require("../config.json"); -const cache = {}; - -function parse(name, value) { - if (/^ui\.overview\.charts\.[^.]+\.slices\.[^.]+\.filter$/.test(name)) - return new Filter(value); - else if (/^permissions\.[^.]+\.[^.]+\.devices\.filter$/.test(name)) - return new Filter(value); - else if (/^auth\.[^.]+\.users\.[^.]+\.roles$/.test(name)) - return value - .split(",") - .map(x => x.trim()) - .sort(); - - return value; -} - -function get(name) { - let v = cache[name]; - if (v === undefined) { - v = configFile[name]; - if (v != null) { - v = parse(name, v); - } else { - const prefix = `${name}.`; - for (let k of Object.keys(configFile)) - if (k.startsWith(prefix)) { - v = v || {}; - let ar = k.slice(prefix.length).split("."); - let ref = v; - for (let a of ar.slice(0, -1)) ref = ref[a] = ref[a] || {}; - ref[ar[ar.length - 1]] = parse(k, configFile[k]); - } - } - if (v) cache[name] = v; - else cache[name] = null; - } - - return v; -} +let config; function getClientConfig() { - const regex = [ - /^ui\.pageSize$/, - /^ui\.filters\.[^.]+\.label$/, - /^ui\.filters\.[^.]+\.parameter$/, - /^ui\.filters\.[^.]+\.type$/, - /^ui\.index\./, - /^ui\.overview\.groups\./, - /^ui\.overview\.charts\./, - /^ui\.device/ - ]; - - const clientConfig = {}; - for (let [k, v] of Object.entries(configFile)) - for (let r of regex) - if (r.test(k)) { - clientConfig[k] = v; - break; - } - - return clientConfig; + return Object.assign({}, config, { + server: null, + auth: null, + permissions: null + }); } -exports.get = get; -exports.getClientConfig = getClientConfig; +function loadConfig(conf, root = {}) { + for (const [key, value] of Object.entries(conf)) { + const keys = key.split("."); + let ref = root; + while (keys.length > 1) { + const k = keys.shift(); + if (typeof ref[k] !== "object") ref[k] = {}; + ref = ref[k]; + } + + if (typeof value === "object") { + if (typeof ref[keys[0]] !== "object") ref[keys[0]] = {}; + loadConfig(value, ref[keys[0]]); + } else { + ref[keys[0]] = value; + } + } + return root; +} + +config = loadConfig(configFile); + +module.exports = Object.assign({}, config, { getClientConfig }); diff --git a/server/db.js b/server/db.js index 0cb0418..088c7d6 100644 --- a/server/db.js +++ b/server/db.js @@ -10,7 +10,7 @@ function getClient() { if (_client) return Promise.resolve(_client); return new Promise((resolve, reject) => { - const CONNECTION_URL = config.get("server.mongodbConnectionUrl"); + const CONNECTION_URL = config.server.mongodbConnectionUrl; MongoClient.connect(CONNECTION_URL, (err, client) => { if (err) return reject(err); resolve((_client = client.db("genieacs-ui"))); diff --git a/server/server.js b/server/server.js index 6785e11..bf5bdfd 100644 --- a/server/server.js +++ b/server/server.js @@ -15,11 +15,11 @@ const Authorizer = require("../common/authorizer"); const koa = new Koa(); const router = new Router(); -const JWT_SECRET = config.get("server.jwtSecret"); +const JWT_SECRET = config.server.jwtSecret; const JWT_COOKIE = "genieacs-ui-jwt"; function getPermissionSets(roles) { - const allPermissions = config.get("permissions"); + const allPermissions = config.permissions; const permissionSets = roles.map(r => Object.values(allPermissions[r] || {})); return permissionSets; } @@ -46,7 +46,7 @@ router.post("/login", async ctx => { const username = ctx.request.body.username; const password = ctx.request.body.password; - const user = config.get(`auth.simple.users.${username}`); + const user = config.auth.simple.users[username]; if (!user || user.password !== password) { ctx.status = 400; @@ -54,7 +54,10 @@ router.post("/login", async ctx => { return; } - let token = jwt.sign({ username: username, roles: user.roles }, JWT_SECRET); + let token = jwt.sign( + { username: username, roles: user.roles.split(",") }, + JWT_SECRET + ); ctx.cookies.set(JWT_COOKIE, token); ctx.body = JSON.stringify(token); }); @@ -101,7 +104,7 @@ koa.use( koa.use(router.routes()); koa.use(koaStatic("./public")); -koa.listen(config.get("server.port"), () => { +koa.listen(config.server.port, () => { // eslint-disable-next-line no-console - console.log(`Server listening on port ${config.get("server.port")}`); + console.log(`Server listening on port ${config.server.port}`); }); From 7194fcab44807df438b3a802e85d0b4209bbdd35 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sat, 30 Jun 2018 13:11:13 -0700 Subject: [PATCH 030/106] Move component labels to containers --- client/components/all-parameters.js | 13 +++++-------- client/components/container.js | 11 +++++++++++ client/components/device-faults.js | 2 +- client/css/app.css | 10 +++++++++- config.json | 22 ++++++++++++++++++++-- 5 files changed, 46 insertions(+), 12 deletions(-) diff --git a/client/components/all-parameters.js b/client/components/all-parameters.js index 984bc69..4c5e6e9 100644 --- a/client/components/all-parameters.js +++ b/client/components/all-parameters.js @@ -103,14 +103,11 @@ const component = { return m("tr", attrs, m("td.left", k), m("td.right", val)); }); - return [ - m("h2", "Device parameters"), - m( - ".all-parameters", - search, - m(".parameter-list", m("table", m("tbody", rows))) - ) - ]; + return m( + ".all-parameters", + search, + m(".parameter-list", m("table", m("tbody", rows))) + ); } }; diff --git a/client/components/container.js b/client/components/container.js index e988e70..e907090 100644 --- a/client/components/container.js +++ b/client/components/container.js @@ -2,10 +2,21 @@ import m from "mithril"; import * as components from "../components"; +import * as store from "../store"; +import Filter from "../../common/filter"; +import * as funcCache from "../../common/func-cache"; const component = { view: vnode => { + if (vnode.attrs.filter) { + const filter = store.unpackFilter( + funcCache.get(Filter.parse, vnode.attrs.filter) + ); + if (!filter.test(vnode.attrs.device)) return; + } + let children = Object.values(vnode.attrs.components).map(c => { + if (typeof c !== "object") return `${c}`; const attrs = Object.assign({}, vnode.attrs, c); return m(components.get(attrs.type), attrs); }); diff --git a/client/components/device-faults.js b/client/components/device-faults.js index 7c9d928..a2ce634 100644 --- a/client/components/device-faults.js +++ b/client/components/device-faults.js @@ -64,7 +64,7 @@ const component = { m("tr.empty", m("td", { colspan: headers.length }, "No faults")) ); - return [m("h2", "Faults"), m("table.table", thead, tbody)]; + return m("table.table", thead, tbody); } }; diff --git a/client/css/app.css b/client/css/app.css index 7abbdcf..5d477ca 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -473,7 +473,6 @@ span.na { } .all-parameters { - clear: both; font-family: monospace; & > input { font-family: monospace; @@ -511,6 +510,15 @@ span.na { } } +.container-right { + float: right; + clear: right; +} + +.container-full-width { + clear: both; +} + .device-actions { margin-top: 10px; & > button { diff --git a/config.json b/config.json index f1286fd..e83fbce 100644 --- a/config.json +++ b/config.json @@ -246,10 +246,28 @@ ] }, { - "type": "device-faults" + "type": "container", + "element": "div", + "components": [ + { + "type": "container", + "element": "h3", + "components": ["Faults"] + }, + {"type": "device-faults"} + ] }, { - "type": "all-parameters" + "type": "container", + "element": "div.container-full-width", + "components": [ + { + "type": "container", + "element": "h3", + "components": ["All parameters"] + }, + {"type": "all-parameters"} + ] }, { "type": "device-actions" From c2d13e836426cb79cc2357f8bf967a00ea0f2e00 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Tue, 17 Jul 2018 00:37:09 -0700 Subject: [PATCH 031/106] Replace Filter class with expression structure This is in order to be able to support UPPER() and LOWER() functions in queries which are needed for case-insensitive search. --- client/app.js | 3 - client/components/container.js | 8 +- client/components/device-faults.js | 14 +- client/components/overview-dot.js | 8 +- client/components/parameter-list.js | 11 +- client/components/parameter-table.js | 16 +- client/components/summon-button.js | 11 +- client/device-page.js | 3 +- client/devices-page.js | 27 ++- client/drawer-component.js | 3 +- client/faults-page.js | 17 +- client/filter-component.js | 20 +- client/overview-page.js | 4 +- client/store.js | 120 +++++---- common/authorizer.js | 10 +- common/expression-cnf.js | 172 +++++++++++++ ...{filter-parser.js => expression-parser.js} | 227 +++++++++--------- common/expression.js | 188 +++++++++++++++ common/filter-cnf.js | 138 ----------- common/filter.js | 145 ----------- common/func-cache.js | 2 + server/api-functions.js | 83 ++++--- server/api.js | 42 ++-- server/db.js | 6 +- server/server.js | 16 +- 25 files changed, 716 insertions(+), 578 deletions(-) create mode 100644 common/expression-cnf.js rename common/{filter-parser.js => expression-parser.js} (58%) create mode 100644 common/expression.js delete mode 100644 common/filter-cnf.js delete mode 100644 common/filter.js diff --git a/client/app.js b/client/app.js index ae72780..fef3830 100644 --- a/client/app.js +++ b/client/app.js @@ -11,7 +11,6 @@ import * as devicePage from "./device-page"; import * as errorPage from "./error-page"; import * as faultsPage from "./faults-page"; import Authorizer from "../common/authorizer"; -import * as funcCache from "../common/func-cache"; window.authorizer = new Authorizer(window.permissionSets); @@ -69,8 +68,6 @@ function pagify(pageName, page) { return component; } -setInterval(funcCache.purge, 120000); - m.route(document.body, "/overview", { "/login": pagify("login", loginPage), "/overview": pagify("overview", overviewPage), diff --git a/client/components/container.js b/client/components/container.js index e907090..19603c3 100644 --- a/client/components/container.js +++ b/client/components/container.js @@ -3,16 +3,14 @@ import m from "mithril"; import * as components from "../components"; import * as store from "../store"; -import Filter from "../../common/filter"; +import * as expression from "../../common/expression"; import * as funcCache from "../../common/func-cache"; const component = { view: vnode => { if (vnode.attrs.filter) { - const filter = store.unpackFilter( - funcCache.get(Filter.parse, vnode.attrs.filter) - ); - if (!filter.test(vnode.attrs.device)) return; + const filter = funcCache.get(expression.parse, vnode.attrs.filter); + if (!store.evaluateExpression(filter, vnode.attrs.device)) return; } let children = Object.values(vnode.attrs.components).map(c => { diff --git a/client/components/device-faults.js b/client/components/device-faults.js index a2ce634..74145bb 100644 --- a/client/components/device-faults.js +++ b/client/components/device-faults.js @@ -2,21 +2,17 @@ import m from "mithril"; import * as store from "../store"; -import Filter from "../../common/filter"; import * as notifications from "../notifications"; const component = { view: vnode => { const device = vnode.attrs.device; const deviceId = device["DeviceID.ID"].value[0]; - const faults = store.fetch( - "faults", - new Filter([ - "AND", - [">", "_id", `${deviceId}:`], - ["<", "_id", `${deviceId}:zzzz`] - ]) - ); + const faults = store.fetch("faults", [ + "AND", + [">", ["PARAM", "_id"], `${deviceId}:`], + ["<", ["PARAM", "_id"], `${deviceId}:zzzz`] + ]); const headers = ["Channel", "Code", "Message", "Retries", "Timestamp"].map( l => m("th", l) diff --git a/client/components/overview-dot.js b/client/components/overview-dot.js index e306be8..918e927 100644 --- a/client/components/overview-dot.js +++ b/client/components/overview-dot.js @@ -4,7 +4,7 @@ import m from "mithril"; import config from "../config"; import * as store from "../store"; import * as funcCache from "../../common/func-cache"; -import Filter from "../../common/filter"; +import * as expression from "../../common/expression"; const CHARTS = config.ui.overview.charts; @@ -13,10 +13,8 @@ const component = { const device = vnode.attrs.device; const chart = CHARTS[vnode.attrs.chart]; for (let slice of Object.values(chart.slices)) { - const filter = store.unpackFilter( - funcCache.get(Filter.parse, slice.filter) - ); - if (filter.test(device)) { + const filter = funcCache.get(expression.parse, slice.filter); + if (store.evaluateExpression(filter, device)) { const dot = m( "svg", { diff --git a/client/components/parameter-list.js b/client/components/parameter-list.js index 9636851..77a9e31 100644 --- a/client/components/parameter-list.js +++ b/client/components/parameter-list.js @@ -3,14 +3,21 @@ import m from "mithril"; import * as components from "../components"; import * as store from "../store"; -import * as filterParser from "../../common/filter-parser"; +import * as funcCache from "../../common/func-cache"; +import * as expression from "../../common/expression"; + +const parseParameter = funcCache.getter(p => { + p = expression.parse(p); + if (Array.isArray(p) && p[0] === "PARAM") p = p[1]; + return p; +}); const component = { oninit: vnode => { vnode.state.parameters = Object.values(vnode.attrs.parameters).map( parameter => Object.assign({}, parameter, { - parameter: filterParser.parseParameter(parameter.parameter) + parameter: parseParameter(parameter.parameter) }) ); }, diff --git a/client/components/parameter-table.js b/client/components/parameter-table.js index be5d286..84bb939 100644 --- a/client/components/parameter-table.js +++ b/client/components/parameter-table.js @@ -4,15 +4,23 @@ import m from "mithril"; import * as components from "../components"; import * as taskQueue from "../task-queue"; import * as store from "../store"; -import * as filterParser from "../../common/filter-parser"; +import * as expression from "../../common/expression"; +import * as funcCache from "../../common/func-cache"; + +const parseParameter = funcCache.getter(p => { + p = expression.parse(p); + if (Array.isArray(p) && p[0] === "PARAM") p = p[1]; + return p; +}); const component = { oninit: vnode => { - vnode.state.object = filterParser.parseParameter(vnode.attrs.parameter); + vnode.state.object = parseParameter(vnode.attrs.parameter); vnode.state.parameters = Object.values(vnode.attrs.childParameters).map( parameter => { - let p = filterParser.parseParameter(parameter.parameter); - return Object.assign({}, parameter, { parameter: p }); + return Object.assign({}, parameter, { + parameter: parseParameter(parameter.parameter) + }); } ); }, diff --git a/client/components/summon-button.js b/client/components/summon-button.js index 850b8cb..f379049 100644 --- a/client/components/summon-button.js +++ b/client/components/summon-button.js @@ -4,12 +4,19 @@ import m from "mithril"; import * as taskQueue from "../task-queue"; import * as store from "../store"; import * as notifications from "../notifications"; -import * as filterParser from "../../common/filter-parser"; +import * as funcCache from "../../common/func-cache"; +import * as expression from "../../common/expression"; + +const parseParameter = funcCache.getter(p => { + p = expression.parse(p); + if (Array.isArray(p) && p[0] === "PARAM") p = p[1]; + return p; +}); const component = { oninit: vnode => { vnode.state.parameters = Object.values(vnode.attrs.parameters).map( - parameter => filterParser.parseParameter(parameter) + parameter => parseParameter(parameter) ); }, view: vnode => { diff --git a/client/device-page.js b/client/device-page.js index a42a045..907c6cd 100644 --- a/client/device-page.js +++ b/client/device-page.js @@ -3,7 +3,6 @@ import m from "mithril"; import config from "./config"; -import Filter from "../common/filter"; import * as store from "./store"; import * as components from "./components"; @@ -15,7 +14,7 @@ function init(args) { return Promise.resolve({ deviceId: args.id, - deviceFilter: new Filter(["=", "DeviceID.ID", args.id]) + deviceFilter: ["=", ["PARAM", "DeviceID.ID"], args.id] }); } diff --git a/client/devices-page.js b/client/devices-page.js index 777e947..a508715 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -4,24 +4,24 @@ import m from "mithril"; import config from "./config"; import filterComponent from "./filter-component"; -import * as filterParser from "../common/filter-parser"; -import Filter from "../common/filter"; import * as store from "./store"; import * as components from "./components"; import * as taskQueue from "./task-queue"; import * as notifications from "./notifications"; +import * as expression from "../common/expression"; function init(args) { return new Promise((resolve, reject) => { if (!window.authorizer.hasAccess("devices", 2)) return reject(new Error("You are not authorized to view this page")); - let filter = new Filter(args.filter); - let indexParameters = Object.values(config.ui.index).map(p => - Object.assign({}, p, { - parameter: filterParser.parseParameter(p.parameter) - }) - ); + let filter = null; + if (args.filter != null) filter = expression.parse(`${args.filter}`); + let indexParameters = Object.values(config.ui.index).map(p => { + let param = expression.parse(p.parameter); + if (Array.isArray(param) && param[0] === "PARAM") param = param[1]; + return Object.assign({}, p, { parameter: param }); + }); resolve({ filter: filter, indexParameters: indexParameters }); }); } @@ -281,16 +281,19 @@ const component = { function onFilterChanged(filter) { let params = {}; - if (filter) params.filter = filterParser.stringify(filter); + if (filter != null) params.filter = expression.stringify(filter); m.route.set("/devices", params); } let devs = store.fetch( "devices", - vnode.attrs.filter, + vnode.attrs.filter == null ? true : vnode.attrs.filter, vnode.state.showCount || 10 ); - let count = store.count("devices", vnode.attrs.filter); + let count = store.count( + "devices", + vnode.attrs.filter == null ? true : vnode.attrs.filter + ); let selected = new Set(); if (vnode.state.selected) @@ -303,7 +306,7 @@ const component = { m("h1", "Listing devices"), m(filterComponent, { predefined: Object.values(config.ui.filters), - filter: vnode.attrs.filter ? vnode.attrs.filter.ast : null, + filter: vnode.attrs.filter, onChange: onFilterChanged }), renderTable( diff --git a/client/drawer-component.js b/client/drawer-component.js index 1631d74..6bb44bc 100644 --- a/client/drawer-component.js +++ b/client/drawer-component.js @@ -5,7 +5,6 @@ import m from "mithril"; import * as taskQueue from "./task-queue"; import * as store from "./store"; import * as notifications from "./notifications"; -import Filter from "../common/filter"; function mparam(param) { return m("span.parameter", { title: param }, `${param}`); @@ -41,7 +40,7 @@ function renderStagingSpv(task, queueFunc, cancelFunc) { function renderStagingDownload(task) { task.invalid = !task.fileName || !task.fileType; - const files = store.fetch("files", new Filter()); + const files = store.fetch("files", true); const idParts = task.device.split("-"); const oui = decodeURIComponent(idParts[0]); const productClass = diff --git a/client/faults-page.js b/client/faults-page.js index 8faf397..6653d8e 100644 --- a/client/faults-page.js +++ b/client/faults-page.js @@ -3,10 +3,9 @@ import m from "mithril"; import filterComponent from "./filter-component"; -import * as filterParser from "../common/filter-parser"; -import Filter from "../common/filter"; import * as store from "./store"; import * as notifications from "./notifications"; +import * as expression from "../common/expression"; function init(args) { if (!window.authorizer.hasAccess("faults", 2)) @@ -14,7 +13,8 @@ function init(args) { new Error("You are not authorized to view this page") ); - let filter = new Filter(args.filter); + let filter = null; + if (args.filter != null) filter = expression.parse(`${args.filter}`); return Promise.resolve({ filter: filter }); } @@ -153,16 +153,19 @@ const component = { function onFilterChanged(filter) { let params = {}; - if (filter) params.filter = filterParser.stringify(filter); + if (filter != null) params.filter = expression.stringify(filter); m.route.set("/faults", params); } let faults = store.fetch( "faults", - vnode.attrs.filter, + vnode.attrs.filter == null ? true : vnode.attrs.filter, vnode.state.showCount || 10 ); - let count = store.count("faults", vnode.attrs.filter); + let count = store.count( + "faults", + vnode.attrs.filter == null ? true : vnode.attrs.filter + ); let selected = new Set(); if (vnode.state.selected) @@ -180,7 +183,7 @@ const component = { { parameter: "retries" }, { parameter: "timestamp" } ], - filter: vnode.attrs.filter ? vnode.attrs.filter.ast : null, + filter: vnode.attrs.filter, onChange: onFilterChanged }), renderTable(faults, count.value, selected, showMore) diff --git a/client/filter-component.js b/client/filter-component.js index e362da4..34c4df7 100644 --- a/client/filter-component.js +++ b/client/filter-component.js @@ -2,7 +2,7 @@ import m from "mithril"; -import * as filterParser from "../common/filter-parser"; +import * as expression from "../common/expression"; const component = { view: vnode => { @@ -15,16 +15,14 @@ const component = { if (vnode.attrs.filter !== vnode.state.filter) { vnode.state.filterList = []; vnode.state.filter = vnode.attrs.filter; - if (vnode.attrs.filter) + if (vnode.attrs.filter != null) if (vnode.attrs.filter[0] === "AND") for (let i = 1; i < vnode.attrs.filter.length; ++i) vnode.state.filterList.push( - filterParser.stringify(vnode.attrs.filter[i]) + expression.stringify(vnode.attrs.filter[i]) ); else - vnode.state.filterList.push( - filterParser.stringify(vnode.attrs.filter) - ); + vnode.state.filterList.push(expression.stringify(vnode.attrs.filter)); vnode.state.filterList.push(""); } @@ -33,18 +31,18 @@ const component = { vnode.state.filterList = vnode.state.filterList.filter( f => f && f.trim() ); - let filterAst = vnode.state.filterList.map((f, idx) => { + let filter = vnode.state.filterList.map((f, idx) => { try { - return filterParser.parse(f); + return expression.parse(f); } catch (err) { vnode.state.filterInvalid |= 1 << idx; } }); - if (filterAst.length) filterAst = ["AND"].concat(filterAst); - else filterAst = null; + if (filter.length) filter = ["AND"].concat(filter); + else filter = null; vnode.state.filterList.push(""); - if (!vnode.state.filterInvalid) vnode.attrs.onChange(filterAst); + if (!vnode.state.filterInvalid) vnode.attrs.onChange(filter); } return m( diff --git a/client/overview-page.js b/client/overview-page.js index 362902a..302cc50 100644 --- a/client/overview-page.js +++ b/client/overview-page.js @@ -4,8 +4,8 @@ import m from "mithril"; import config from "./config"; import * as store from "./store"; import pieChartComponent from "./pie-chart-component"; -import Filter from "../common/filter"; import * as funcCache from "../common/func-cache"; +import * as expression from "../common/expression"; const GROUPS = config.ui.overview.groups; const CHARTS = {}; @@ -19,7 +19,7 @@ function queryCharts(charts) { charts[chartName] = chart = Object.assign({}, chart); chart.slices = Object.assign({}, chart.slices); for (let [sliceName, slice] of Object.entries(chart.slices)) { - const filter = funcCache.get(Filter.parse, slice.filter); + const filter = funcCache.get(expression.parse, slice.filter); chart.slices[sliceName] = slice = Object.assign({}, slice); slice.count = store.count("devices", filter); } diff --git a/client/store.js b/client/store.js index ac8435f..02ba2c3 100644 --- a/client/store.js +++ b/client/store.js @@ -1,15 +1,15 @@ "use strict"; import m from "mithril"; -import Filter from "../common/filter"; import config from "./config"; -import * as filterParser from "../common/filter-parser"; +import * as expressionParser from "../common/expression-parser"; +import * as expression from "../common/expression"; import * as funcCache from "../common/func-cache"; let fulfillTimestamp = 0; -let unpackedFiltersCache = new WeakMap(); -let expressionsCache = new WeakMap(); +let unpackExpressionCache = new WeakMap(); +let evaluateExpressionCache = new WeakMap(); const queries = { filter: new WeakMap(), @@ -47,46 +47,54 @@ class QueryResponse { } } -function unpackFilter(filter) { - let f = unpackedFiltersCache.get(filter); - if (!f) { - f = f.evaluateExpressions(fulfillTimestamp); - unpackedFiltersCache.set(filter, f); +function unpackExpression(exp) { + if (!Array.isArray(exp)) return exp; + let e = unpackExpressionCache.get(exp); + if (e === undefined) { + e = expression.evaluate(e, null, fulfillTimestamp); + unpackExpressionCache.set(exp, e); } - return f; + return e; } function count(resourceType, filter) { - let queryResponse = resources[resourceType].count.get(filter.toString()); + const filterStr = funcCache.get(expression.stringify, filter); + let queryResponse = resources[resourceType].count.get(filterStr); if (queryResponse) return queryResponse; queryResponse = new QueryResponse(); - resources[resourceType].count.set(filter.toString(), queryResponse); + resources[resourceType].count.set(filterStr, queryResponse); queries.filter.set(queryResponse, filter); return queryResponse; } function limitFilter(resourceType, filter, last) { if (resourceType === "devices") - return filter.and(new Filter(["<=", "DeviceID.ID", last])); - else return filter.and(new Filter(["<=", "_id", last])); + return expression.and(filter, ["<=", "DeviceID.ID", last]); + else return expression.and(filter, ["<=", ["PARAM", "_id"], last]); } function findMatches(resourceType, filter, limit) { // Handle "tag =" and "tag <>" special cases - if (resourceType === "devices" && filter.ast) { - const ast = filterParser.map(filter.ast, e => { - if (e[1] === "tag") - if (e[0] === "=") return ["=", `Tags.${e[2]}`, true]; - else if (e[0] === "<>") return ["NOT", ["=", `Tags.${e[2]}`, true]]; + if (resourceType === "devices") + filter = expressionParser.map(filter, e => { + if ( + Array.isArray(e) && + Array.isArray(e[1]) && + e[1][0] === "PARAM" && + e[1][0] === "tag" + ) + if (e[0] === "=") return ["=", ["PARAM", `Tags.${e[2]}`], true]; + else if (e[0] === "<>") + return ["NOT", ["=", ["PARAM", `Tags.${e[2]}`], true]]; + return e; }); - filter = new Filter(ast); - } let value = []; for (let [id, obj] of resources[resourceType].objects.entries()) - if (filter.test(obj)) value.push(id); + if (expression.evaluate(filter, obj, fulfillTimestamp)) + value.push(id); value = value.sort(); if (limit) value = value.slice(0, limit); @@ -97,13 +105,13 @@ function findMatches(resourceType, filter, limit) { function inferQuery(resourceType, queryResponse) { const limit = queries.limit.get(queryResponse); let filter = queries.filter.get(queryResponse); - filter = unpackFilter(filter); + filter = unpackExpression(filter); let last = queries.last.get(queryResponse); if (last || !limit) { if (last) filter = limitFilter(resourceType, filter, last); if ( resources[resourceType].combinedFilter && - filter.subset(resources[resourceType].combinedFilter) + expression.subset(filter, resources[resourceType].combinedFilter) ) queries.fulfilled.set(queryResponse, fulfillTimestamp); } @@ -112,7 +120,8 @@ function inferQuery(resourceType, queryResponse) { } function fetch(resourceType, filter, limit = 0) { - const key = `${limit}:${filter.toString()}`; + const filterStr = funcCache.get(expression.stringify, filter); + const key = `${limit}:${filterStr}`; let queryResponse = resources[resourceType].fetch.get(key); if (queryResponse) return queryResponse; @@ -131,8 +140,8 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { let updated = false; if (_fulfillTimestamp > fulfillTimestamp) { - unpackedFiltersCache = new WeakMap(); - expressionsCache = new WeakMap(); + unpackExpressionCache = new WeakMap(); + evaluateExpressionCache = new WeakMap(); for (let resource of Object.values(resources)) resource.combinedFilter = null; @@ -156,14 +165,14 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { new Promise((resolve, reject) => { updated = true; let filter = queries.filter.get(queryResponse); - filter = unpackFilter(filter); + filter = unpackExpression(filter); m .request({ method: "HEAD", url: `/api/${resourceType}/?` + m.buildQueryString({ - filter: filter.toString() + filter: funcCache.get(expression.stringify, filter) }), extract: xhr => +xhr.getResponseHeader("x-total-count"), background: true @@ -201,14 +210,14 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { new Promise((resolve, reject) => { updated = true; let filter = queries.filter.get(queryResponse); - filter = unpackFilter(filter); + filter = unpackExpression(filter); m .request({ method: "GET", url: `/api/${resourceType}/?` + m.buildQueryString({ - filter: filter.toString(), + filter: funcCache.get(expression.stringify, filter), limit: 1, skip: limit - 1, projection: "_id" @@ -240,13 +249,13 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { toFetch = toFetch.filter(queryResponse => { let filter = queries.filter.get(queryResponse); - filter = unpackFilter(filter); + filter = unpackExpression(filter); let last = queries.last.get(queryResponse); if (last) filter = limitFilter(resourceType, filter, last); if ( resources[resourceType].combinedFilter && - filter.subset(resources[resourceType].combinedFilter) + expression.subset(filter, resources[resourceType].combinedFilter) ) { queries.value.set( queryResponse, @@ -257,11 +266,11 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { return false; } - combinedFilter = filter.or(combinedFilter); + combinedFilter = expression.or(combinedFilter, filter); return true; }); - if (!combinedFilter) return resolve(updated); + if (combinedFilter == null) continue; updated = true; let deleted = new Set(); @@ -269,10 +278,12 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { deleted = new Set(resources[resourceType].objects.keys()); let combinedFilterDiff = combinedFilter; if (resources[resourceType].combinedFilter) - combinedFilterDiff = combinedFilterDiff.and( - resources[resourceType].combinedFilter.not() + combinedFilterDiff = expression.and( + combinedFilterDiff, + expression.not(resources[resourceType].combinedFilter) ); - resources[resourceType].combinedFilter = combinedFilter.or( + resources[resourceType].combinedFilter = expression.or( + combinedFilter, resources[resourceType].combinedFilter ); @@ -284,7 +295,10 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { url: `/api/${resourceType}/?` + m.buildQueryString({ - filter: combinedFilterDiff.toString() + filter: funcCache.get( + expression.stringify, + combinedFilterDiff + ) }) }) .then(res => { @@ -299,13 +313,19 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { for (let d of deleted) { const obj = resources[resourceType].objects.get(d); - if (combinedFilterDiff.test(obj)) + if ( + expression.evaluate( + combinedFilterDiff, + obj, + fulfillTimestamp + ) + ) resources[resourceType].objects.delete(d); } for (let queryResponse of toFetch) { let filter = queries.filter.get(queryResponse); - filter = unpackFilter(filter); + filter = unpackExpression(filter); let last = queries.last.get(queryResponse); if (last) filter = limitFilter(resourceType, filter, last); @@ -379,20 +399,18 @@ function deleteResource(resourceType, id) { }); } -function evaluateExpression(exp, device) { +function evaluateExpression(exp, obj) { if (!Array.isArray(exp)) return exp; - let exps = expressionsCache.get(exp); - if (!exps) expressionsCache.set(exp, (exps = new WeakMap())); + let exps = evaluateExpressionCache.get(exp); + if (!exps) evaluateExpressionCache.set(exp, (exps = new WeakMap())); - if (!exps.has(device)) { - let v = filterParser.evaluateExpressions(exp, e => { - if (e[0] === "FUNC" && e[1] === "NOW") return fulfillTimestamp; - }); - exps.set(device, v); + if (!exps.has(obj)) { + let v = expression.evaluate(exp, obj, fulfillTimestamp); + exps.set(obj, v); } - return exps.get(device); + return exps.get(obj); } function logIn(username, password) { @@ -421,7 +439,7 @@ export { count, fetch, fulfill, - unpackFilter, + unpackExpression, getTimestamp, postTasks, updateTags, diff --git a/common/authorizer.js b/common/authorizer.js index b7bae2c..03e0122 100644 --- a/common/authorizer.js +++ b/common/authorizer.js @@ -1,5 +1,7 @@ "use strict"; +const expression = require("./expression"); + const validators = { pass: require("./validators/pass"), test: require("./validators/test") @@ -18,15 +20,13 @@ class Authorizer { return this.hasAccessCache[cacheKey]; let has = false; - for (const permissionSet of this.permissionSets) { - for (const perm of permissionSet) { - if (perm[resourceType]) { + for (let permissionSet of this.permissionSets) { + for (let perm of permissionSet) + if (perm[resourceType]) if (perm[resourceType].access >= access) { has = true; break; } - } - } } this.hasAccessCache[cacheKey] = has; diff --git a/common/expression-cnf.js b/common/expression-cnf.js new file mode 100644 index 0000000..d2ea2ce --- /dev/null +++ b/common/expression-cnf.js @@ -0,0 +1,172 @@ +"use strict"; + +function collectRanges(exp, ranges = new Map()) { + if (["AND", "OR", "NOT"].includes(exp[0])) + for (let i = 1; i < exp.length; ++i) collectRanges(exp[i], ranges); + else if ([">", ">=", "<", "<=", "=", "<>"].includes(exp[0])) + if (!Array.isArray(exp[2])) { + const key = `${JSON.stringify(exp[1])}:${typeof exp[2]}`; + let s = ranges.get(key); + if (!s) ranges.set(key, (s = new Set())); + s.add(exp[2]); + } else if (!Array.isArray(exp[1])) { + const key = `${JSON.stringify(exp[2])}:${typeof exp[1]}`; + let s = ranges.get(key); + if (!s) ranges.set(key, (s = new Set())); + s.add(exp[2]); + } + + return ranges; +} + +function or(cnf1, cnf2) { + if (cnf1.length === 0) return cnf2; + else if (cnf2.length === 0) return cnf1; + + const res = []; + for (let i = 0; i < cnf1.length; ++i) + for (let j = i; j < cnf2.length; ++j) res.push(cnf1[i].concat(cnf2[j])); + + return res; +} + +function booleanCnf(exp) { + const ranges = collectRanges(exp); + for (let [k, v] of ranges) + if (k.endsWith(":number")) + ranges.set(k, Array.from(v).sort((a, b) => a - b)); + else ranges.set(k, Array.from(v).sort()); + + const expressions = new Map(); + const mutuallyExclusive = new Map(); + + function getVariable(lhs, op, rhs = "") { + const key = `${lhs}:${op}:${rhs}`; + let f = expressions.get(key); + if (!f) { + expressions.set(key, (f = expressions.size + 1)); + let m = mutuallyExclusive.get(lhs); + if (!m) mutuallyExclusive.set(lhs, (m = new Set())); + m.add(f); + if (rhs || key !== "null") m.add(getVariable(lhs, "null")); + } + return f; + } + + function getComparisonVariables(lhs, from, fromInclusive, to, toInclusive) { + const type = from != null ? typeof from : typeof to; + const range = ranges.get(`${lhs}:${type}`); + const vars = []; + + if (from == null) vars.push(`${lhs}:--${range[0]}`); + if (to == null) vars.push(`${lhs}:${range[range.length - 1]}--`); + if (fromInclusive) vars.push(`${lhs}:${from}`); + if (toInclusive) vars.push(`${lhs}:${to}`); + + for (let i = 1; i < range.length; ++i) + if ((from == null || range[i] > from) && (to == null || range[i] <= to)) { + vars.push(`${lhs}:${range[i - 1]}`); + vars.push(`${lhs}:${range[i]}`); + vars.push(`${lhs}:${range[i - 1]}--${range[i]}`); + } + + return vars.map(key => { + let f = expressions.get(key); + if (!f) { + expressions.set(key, (f = expressions.size + 1)); + let m = mutuallyExclusive.get(lhs); + if (!m) mutuallyExclusive.set(lhs, (m = new Set())); + m.add(f); + } + return f; + }); + } + + const comparisonOperators = { + "=": v => [v, true, v, true], + ">": v => [v, false, null, null], + ">=": v => [v, true, null, null], + "<": v => [null, null, v, false], + "<=": v => [null, null, v, true] + }; + + function recursive(clause, negate) { + let op = clause[0]; + + if ((op === "AND" && !negate) || (op === "OR" && negate)) { + let res = []; + for (let i = 1; i < clause.length; ++i) + res = res.concat(recursive(clause[i], negate)); + return res; + } else if ((op === "OR" && !negate) || (op === "AND" && negate)) { + let res = []; + for (let i = 1; i < clause.length; ++i) + res = or(res, recursive(clause[i], negate)); + return res; + } else if (op === "NOT") { + return recursive(clause[1], !negate); + } else if (op === "<>") { + const vars = getComparisonVariables( + JSON.stringify(clause[1]), + ...comparisonOperators["="](clause[2]) + ); + if (!negate) return vars.map(x => [0 - x]); + else return [vars]; + } else if (op === "IS NULL") { + const v = getVariable(JSON.stringify(clause[1]), "null"); + if (negate) return [[0 - v]]; + else return [[v]]; + } else if (op === "IS NOT NULL") { + const v = getVariable(JSON.stringify(clause[1]), "null"); + if (!negate) return [[0 - v]]; + else return [[v]]; + } else if (op in comparisonOperators) { + if (!Array.isArray(clause[2])) { + const vars = getComparisonVariables( + JSON.stringify(clause[1]), + ...comparisonOperators[op](clause[2]) + ); + if (negate) return vars.map(x => [0 - x]); + else return [vars]; + } else if (!Array.isArray(clause[1])) { + op = { ">": "<", ">=": "<=", "<": ">", "<=": ">=" }[op] || op; + const vars = getComparisonVariables( + JSON.stringify(clause[2]), + ...comparisonOperators[op](clause[1]) + ); + if (negate) return vars.map(x => [0 - x]); + else return [vars]; + } else { + let lhs = JSON.stringify(clause[1]); + let rhs = JSON.stringify(clause[2]); + // For consistency + if (rhs > lhs) { + let t = rhs; + rhs = lhs; + lhs = t; + op = { ">": "<", ">=": "<=", "<": ">", "<=": ">=" }[op] || op; + } + + const v = getVariable(lhs, op, rhs); + if (negate) return [[0 - v]]; + else return [[v]]; + } + } else { + const v = getVariable(JSON.stringify(clause), ""); + if (negate) return [[0 - v]]; + else return [[v]]; + } + } + + const cnf = recursive(exp, false); + + for (let m of mutuallyExclusive.values()) { + let ar = Array.from(m); + for (let i = 0; i < ar.length; ++i) + for (let j = i + 1; j < ar.length; ++j) cnf.push([0 - ar[i], 0 - ar[j]]); + } + + return { vars: expressions.size, clauses: cnf }; +} + +exports.booleanCnf = booleanCnf; diff --git a/common/filter-parser.js b/common/expression-parser.js similarity index 58% rename from common/filter-parser.js rename to common/expression-parser.js index 3063de3..db235cb 100644 --- a/common/filter-parser.js +++ b/common/expression-parser.js @@ -22,6 +22,21 @@ function interpretEscapes(str) { }); } +function map(exp, callback) { + if (!Array.isArray(exp)) return callback(exp); + + let clone; + for (let i = 1; i < exp.length; ++i) { + let sub = map(exp[i], callback); + if (sub !== exp[i]) { + clone = clone || exp.slice(); + clone[i] = sub; + } + } + + return callback(clone || exp); +} + function binaryLeft(operatorsParser, nextParser) { return parsimmon.seqMap( nextParser, @@ -29,8 +44,9 @@ function binaryLeft(operatorsParser, nextParser) { (first, rest) => rest.reduce((acc, ch) => { let [op, another] = ch; - if (op === acc[0]) return acc.concat([another]); - if (op === another[0]) return [op, acc].concat(another.slice(1)); + if (Array.isArray(acc) && op === acc[0]) return acc.concat([another]); + if (Array.isArray(another) && op === another[0]) + return [op, acc].concat(another.slice(1)); return [op, acc, another]; }, first) ); @@ -61,12 +77,14 @@ const lang = parsimmon.createLanguage({ .result("IS NOT NULL") .desc("IS NOT NULL") ) + .notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/)) .skip(parsimmon.optWhitespace); }, NotOperator: function() { return parsimmon .regexp(/not/i) .result("NOT") + .notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/)) .skip(parsimmon.optWhitespace) .desc("NOT"); }, @@ -74,6 +92,7 @@ const lang = parsimmon.createLanguage({ return parsimmon .regexp(/and/i) .result("AND") + .notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/)) .skip(parsimmon.optWhitespace) .desc("AND"); }, @@ -81,6 +100,7 @@ const lang = parsimmon.createLanguage({ return parsimmon .regexp(/or/i) .result("OR") + .notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/)) .skip(parsimmon.optWhitespace) .desc("OR"); }, @@ -88,13 +108,13 @@ const lang = parsimmon.createLanguage({ return parsimmon .alt( parsimmon.regexp(/[a-zA-Z0-9_.*]+/), - r.ValueExpression.wrap( + r.Expression.wrap( parsimmon.string("{").skip(parsimmon.optWhitespace), parsimmon.string("}") ) ) .atLeast(1) - .map(x => (x.length > 1 ? ["||"].concat(x) : x[0])) + .map(x => ["PARAM", x.length > 1 ? ["||"].concat(x) : x[0]]) .skip(parsimmon.optWhitespace) .desc("parameter"); }, @@ -120,13 +140,38 @@ const lang = parsimmon.createLanguage({ .map(Number) .desc("number"); }, + BooleanValue: function() { + return parsimmon + .alt( + parsimmon + .regexp(/true/i) + .result(true) + .desc("TRUE"), + parsimmon + .regexp(/false/i) + .result(false) + .desc("FALSE") + ) + .notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/)) + .skip(parsimmon.optWhitespace); + }, + NullValue: function() { + return parsimmon + .regexp(/null/i) + .notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/)) + .skip(parsimmon.optWhitespace) + .result(null) + .desc("NULL"); + }, FuncValue: function(r) { return parsimmon.seqMap( parsimmon - .regexp(/([a-zA-Z0-0_]+)/, 1) + .regexp(/([a-zA-Z0-9_]+)/, 1) .skip(parsimmon.optWhitespace) .desc("function"), - r.Value.sepBy(parsimmon.string(",").skip(parsimmon.optWhitespace)).wrap( + r.ValueExpression.sepBy( + parsimmon.string(",").skip(parsimmon.optWhitespace) + ).wrap( parsimmon.string("(").skip(parsimmon.optWhitespace), parsimmon.string(")").skip(parsimmon.optWhitespace) ), @@ -135,6 +180,8 @@ const lang = parsimmon.createLanguage({ }, Value: function(r) { return parsimmon.alt( + r.NullValue, + r.BooleanValue, r.NumberValue, r.StringValueSql, r.StringValueJs, @@ -143,7 +190,7 @@ const lang = parsimmon.createLanguage({ }, ValueExpression: function(r) { return binaryLeft( - parsimmon.string("||"), + parsimmon.string("||").skip(parsimmon.optWhitespace), binaryLeft( parsimmon .alt(parsimmon.string("+"), parsimmon.string("-")) @@ -154,7 +201,8 @@ const lang = parsimmon.createLanguage({ .skip(parsimmon.optWhitespace), parsimmon.alt( r.Value, - r.ValueExpression.wrap( + r.Parameter, + r.Expression.wrap( parsimmon.string("(").skip(parsimmon.optWhitespace), parsimmon.string(")").skip(parsimmon.optWhitespace) ) @@ -167,7 +215,7 @@ const lang = parsimmon.createLanguage({ return parsimmon.alt( parsimmon.seqMap(r.Parameter, r.IsNullOperator, (p, o) => [o, p]), parsimmon.seqMap( - r.Parameter, + r.ValueExpression, r.ComparisonOperator, r.ValueExpression, (p, o, v) => [o, p, v] @@ -183,129 +231,78 @@ const lang = parsimmon.createLanguage({ r.OrOperator, binaryLeft( r.AndOperator, - unary( - r.NotOperator, - r.Comparison.or( - r.Expression.wrap( - parsimmon.string("("), - parsimmon.string(")").skip(parsimmon.optWhitespace) - ) - ) - ) + unary(r.NotOperator, r.Comparison.or(r.ValueExpression)) ) ).trim(parsimmon.optWhitespace); } }); -function parse(filter) { - return lang.Expression.tryParse(filter); +function parse(str) { + if (!str) return null; + return lang.Expression.tryParse(str); } -function parseExpression(exp) { - return lang.ValueExpression.tryParse(exp); -} +function stringify(exp) { + if (!Array.isArray(exp)) return JSON.stringify(exp); -function evaluateExpressions(ast, funcCallback) { - return map(ast, exp => { - if (exp[0] === "FUNC") { - return funcCallback(exp); - } else if (exp[0] === "*") { - let v = exp[1]; - for (let i = 2; i < exp.length; ++i) v *= exp[i]; - return v; - } else if (exp[0] === "/") { - let v = exp[1]; - for (let i = 2; i < exp.length; ++i) v /= exp[i]; - return v; - } else if (exp[0] === "+") { - let v = exp[1]; - for (let i = 2; i < exp.length; ++i) v += exp[i]; - return v; - } else if (exp[0] === "-") { - let v = exp[1]; - for (let i = 2; i < exp.length; ++i) v -= exp[i]; - return v; - } else if (exp[0] === "||") { - return exp.slice(1).join(""); - } - }); -} + const opLevels = { + OR: 10, + AND: 11, + NOT: 12, + "=": 20, + "<>": 21, + ">": 22, + ">=": 23, + "<": 24, + "<=": 25, + "||": 30, + "+": 31, + "-": 31, + "*": 32, + "/": 32 + }; -function parseParameter(p) { - return lang.Parameter.tryParse(p); -} + const op = exp[0].toUpperCase(); -function stringify(filter) { - function value(v) { - if (Array.isArray(v)) - if (v[0].toUpperCase() === "FUNC") { - return `${v[1]}(${v - .slice(2) - .map(e => value(e)) - .join(", ")})`; - } else { - return v - .slice(1) - .map(e => value(e)) - .join(` ${v[0]} `); - } - - return JSON.stringify(v); - } - - function expressions(v) { - return v.map(e => { - let str = stringify(e); - if (e[0] === "AND" || e[0] === "OR") return `(${str})`; - else return str; - }); - } - - function parameter(v) { - if (Array.isArray(v) && v[0] === "||") - return v + if (op === "FUNC") { + return `${exp[1]}(${exp + .slice(2) + .map(e => stringify(e)) + .join(", ")})`; + } else if (op === "PARAM") { + if (typeof exp[1] === "string") return exp[1]; + else if (Array.isArray(exp[1]) && exp[1][0] === "||") + return exp[1] .slice(1) - .map(x => { - if (Array.isArray(x)) return `{${value(x)}}`; - else return x; + .map(p => { + if (typeof p === "string") return p; + else return `{${stringify(p)}}`; }) .join(""); + else return `{${stringify(exp[1])}}`; + } else if (op === "IS NULL" || op === "IS NOT NULL") { + return `${stringify(exp[1])} ${op}`; + } else if (op in opLevels) { + const parts = exp.slice(1).map((e, i) => { + if ( + Array.isArray(e) && + (opLevels[e[0]] < opLevels[exp[0]] || + (opLevels[e[0]] < opLevels[exp[0]] && i > 0) || + (opLevels[exp[0]] >= 20 && + opLevels[e[0]] >= 20 && + opLevels[e[0]] < 30)) + ) + return `(${stringify(e)})`; + else return stringify(e); + }); - return v; + if (op === "NOT") return `${op} ${parts[0]}`; + else return parts.join(` ${op} `); + } else { + throw new Error(`Unrecognized operator ${exp[0]}`); } - - const op = filter[0].toUpperCase(); - - if (["AND", "OR"].includes(op)) - return expressions(filter.slice(1)).join(` ${op} `); - else if (op === "NOT") return `NOT ${expressions(filter.slice(1))[0]}`; - else if ([">=", "<>", "<=", "=", ">", "<"].includes(op)) - return `${parameter(filter[1])} ${op} ${value(filter[2])}`; - else if (["IS NULL", "IS NOT NULL"].includes(op)) - return `${parameter(filter[1])} ${op}`; - else throw new Error("Unrecognized operator"); -} - -function map(filter, callback) { - let clone; - for (let i = 1; i < filter.length; ++i) - if (Array.isArray(filter[i])) { - let sub = map(filter[i], callback); - if (sub != null && sub !== filter[i]) { - clone = clone || filter.slice(); - clone[i] = sub; - } - } - - let r = callback(clone || filter); - if (r != null) return r; - - return clone || filter; } exports.parse = parse; -exports.parseExpression = parseExpression; -exports.evaluateExpressions = evaluateExpressions; -exports.parseParameter = parseParameter; exports.stringify = stringify; exports.map = map; diff --git a/common/expression.js b/common/expression.js new file mode 100644 index 0000000..14e6c53 --- /dev/null +++ b/common/expression.js @@ -0,0 +1,188 @@ +"use strict"; + +const expressionParser = require("./expression-parser"); +const expressionCnf = require("./expression-cnf"); +const satSolver = require("./sat-solver"); + +const isArray = Array.isArray; + +function* permute(arr) { + if (arr.length <= 1) { + for (let i = 0; i < arr[0]; ++i) yield [i]; + return; + } + + let slc = arr.slice(1); + for (let i = 0; i < arr[0]; ++i) + for (let innerArr of permute(slc)) yield [i].concat(innerArr); +} + +function reduce(exp, callback) { + let loop = true; + while (loop) { + loop = false; + for (let i = 2; i < exp.length; ++i) { + let res = callback(exp[i - 1], exp[i]); + if (res !== undefined) { + loop = true; + exp = exp.slice(); + exp.splice(i - 1, 2, res); + } + } + } + if (exp.length === 2) return exp[1]; + return exp; +} + +function evaluate(exp, obj, now) { + return expressionParser.map(exp, e => { + if (!isArray(e)) return e; + + if (e[0] === "FUNC") { + if (e[1] === "NOW") { + if (now) return now; + } else if (e[1] === "UPPER") { + if (e[2] == null) return null; + if (!isArray(e[2])) return `${e[2]}`.toUpperCase(); + } else if (e[1] === "LOWER") { + if (e[12] == null) return null; + if (!isArray(e[2])) return `${e[2]}`.toLowerCase(); + } + } else if (e[0] === "PARAM") { + if (e[1] == null) return null; + if (obj && !isArray(e[1])) + if (typeof obj[e[1]] === "object") + return obj[e[1]].value ? obj[e[1]].value[0] : null; + else return obj[e[1]]; + } else if (e[0] === "AND") { + return reduce(e, (a, b) => { + if (!isArray(a)) return a ? b : a; + if (!isArray(b)) return b ? a : b; + }); + } else if (e[0] === "OR") { + return reduce(e, (a, b) => { + if (!isArray(a)) return a ? a : b; + if (!isArray(b)) return b ? b : a; + }); + } else if (e[0] === "NOT") { + if (!isArray(e[1])) return !e[1]; + else if (e[1][0] === "NOT") return e[1][1]; + } else if (e[0] === "IS NULL") { + if (isArray(e[1])) return e; + return e[1] == null; + } else if (e[0] === "IS NOT NULL") { + if (isArray(e[1])) return e; + return e[1] != null; + } else if (e[0] === "=") { + if (isArray(e[1]) || isArray(e[2])) return e; + if (e[1] == null || e[2] == null) return null; + return e[1] === e[2]; + } else if (e[0] === "<>") { + if (isArray(e[1]) || isArray(e[2])) return e; + if (e[1] == null || e[2] == null) return null; + return e[1] !== e[2]; + } else if (e[0] === ">") { + if (isArray(e[1]) || isArray(e[2])) return e; + if (e[1] == null || e[2] == null) return null; + return e[1] > e[2]; + } else if (e[0] === ">=") { + if (isArray(e[1]) || isArray(e[2])) return e; + if (e[1] == null || e[2] == null) return null; + return e[1] >= e[2]; + } else if (e[0] === "<") { + if (isArray(e[1]) || isArray(e[2])) return e; + if (e[1] == null || e[2] == null) return null; + return e[1] < e[2]; + } else if (e[0] === "<=") { + if (isArray(e[1]) || isArray(e[2])) return e; + if (e[1] == null || e[2] == null) return null; + return e[1] <= e[2]; + } else if (e[0] === "*") { + return reduce(e, (a, b) => { + if (!isArray(a) && !isArray(b)) { + if (a == null || b == null) return null; + return a * b; + } + }); + } else if (e[0] === "/") { + return reduce(e, (a, b) => { + if (!isArray(a) && !isArray(b)) { + if (a == null || b == null) return null; + return a / b; + } + }); + } else if (e[0] === "+") { + return reduce(e, (a, b) => { + if (!isArray(a) && !isArray(b)) { + if (a == null || b == null) return null; + return a + b; + } + }); + } else if (e[0] === "-") { + return reduce(e, (a, b) => { + if (!isArray(a) && !isArray(b)) { + if (a == null || b == null) return null; + return a - b; + } + }); + } else if (e[0] === "||") { + return reduce(e, (a, b) => { + if (!isArray(a) && !isArray(b)) { + if (a == null || b == null) return null; + return `${a}${b}`; + } + }); + } + return e; + }); +} + +function and(exp1, exp2) { + if (!isArray(exp1)) return exp1 ? exp2 : exp1; + if (!isArray(exp2)) return exp2 ? exp1 : exp2; + + let res = ["AND"]; + + if (exp1[0] === "AND") res = res.concat(exp1.slice(1)); + else res.push(exp1); + + if (exp2[0] === "AND") res = res.concat(exp2.slice(1)); + else res.push(exp2); + + return res; +} + +function or(exp1, exp2) { + if (!isArray(exp1)) return exp1 ? exp1 : exp2; + if (!isArray(exp2)) return exp2 ? exp2 : exp1; + + let res = ["OR"]; + + if (exp1[0] === "OR") res = res.concat(exp1.slice(1)); + else res.push(exp1); + + if (exp2[0] === "OR") res = res.concat(exp2.slice(1)); + else res.push(exp2); + + return res; +} + +function not(exp) { + if (isArray(exp) && exp[0] === "NOT") return exp[1]; + return ["NOT", exp]; +} + +function subset(exp1, exp2) { + const e = evaluate(["NOT", ["OR", ["NOT", exp1], exp2]]); + if (!isArray(e)) return !e; + const { vars, clauses } = expressionCnf.booleanCnf(e); + return !satSolver.naiveDpll(clauses, vars); +} + +exports.evaluate = evaluate; +exports.and = and; +exports.or = or; +exports.not = not; +exports.subset = subset; +exports.parse = expressionParser.parse; +exports.stringify = expressionParser.stringify; diff --git a/common/filter-cnf.js b/common/filter-cnf.js deleted file mode 100644 index 20da585..0000000 --- a/common/filter-cnf.js +++ /dev/null @@ -1,138 +0,0 @@ -"use strict"; - -function collectRanges(filter, ranges = new Map()) { - if (["AND", "OR", "NOT"].includes(filter[0])) { - for (let i = 1; i < filter.length; ++i) collectRanges(filter[i], ranges); - } else if ([">", ">=", "<", "<=", "=", "<>"].includes(filter[0])) { - const key = `${filter[1]}:${typeof filter[2]}`; - let s = ranges.get(key); - if (!s) ranges.set(key, (s = new Set())); - s.add(filter[2]); - } - return ranges; -} - -function or(cnf1, cnf2) { - if (cnf1.length === 0) return cnf2; - else if (cnf2.length === 0) return cnf1; - - const res = []; - for (let i = 0; i < cnf1.length; ++i) - for (let j = i; j < cnf2.length; ++j) res.push(cnf1[i].concat(cnf2[j])); - - return res; -} - -function booleanCnf(filter) { - const ranges = collectRanges(filter); - for (let [k, v] of ranges) - if (k.endsWith(":number")) - ranges.set(k, Array.from(v).sort((a, b) => a - b)); - else ranges.set(k, Array.from(v).sort()); - - const expressions = new Map(); - const mutuallyExclusive = new Map(); - - function getIsNullVariable(param) { - const key = `${param}:null`; - let f = expressions.get(key); - if (!f) { - expressions.set(key, (f = expressions.size + 1)); - let m = mutuallyExclusive.get(param); - if (!m) mutuallyExclusive.set(param, (m = new Set())); - m.add(f); - } - return f; - } - - function getComparisonVariables(param, from, fromInclusive, to, toInclusive) { - const type = from != null ? typeof from : typeof to; - const range = ranges.get(`${param}:${type}`); - const vars = []; - - if (from == null) vars.push(`${param}:--${range[0]}`); - if (to == null) vars.push(`${param}:${range[range.length - 1]}--`); - if (fromInclusive) vars.push(`${param}:${from}`); - if (toInclusive) vars.push(`${param}:${to}`); - - for (let i = 1; i < range.length; ++i) - if ((from == null || range[i] > from) && (to == null || range[i] <= to)) { - vars.push(`${param}:${range[i - 1]}`); - vars.push(`${param}:${range[i]}`); - vars.push(`${param}:${range[i - 1]}--${range[i]}`); - } - - return vars.map(key => { - let f = expressions.get(key); - if (!f) { - expressions.set(key, (f = expressions.size + 1)); - let m = mutuallyExclusive.get(param); - if (!m) mutuallyExclusive.set(param, (m = new Set())); - m.add(f); - } - return f; - }); - } - - const comparisonOperators = { - "=": v => [v, true, v, true], - ">": v => [v, false, null, null], - ">=": v => [v, true, null, null], - "<": v => [null, null, v, false], - "<=": v => [null, null, v, true] - }; - - function recursive(clause, negate) { - const op = clause[0]; - - if ((op === "AND" && !negate) || (op === "OR" && negate)) { - let res = []; - for (let i = 1; i < clause.length; ++i) - res = res.concat(recursive(clause[i], negate)); - return res; - } else if ((op === "OR" && !negate) || (op === "AND" && negate)) { - let res = []; - for (let i = 1; i < clause.length; ++i) - res = or(res, recursive(clause[i], negate)); - return res; - } else if (op === "NOT") { - return recursive(clause[1], !negate); - } else if (op in comparisonOperators) { - const vars = getComparisonVariables( - clause[1], - ...comparisonOperators[op](clause[2]) - ); - if (negate) return vars.map(x => [0 - x]); - else return [vars]; - } else if (op === "<>") { - const vars = getComparisonVariables( - clause[1], - ...comparisonOperators["="](clause[2]) - ); - if (!negate) return vars.map(x => [0 - x]); - else return [vars]; - } else if (op === "IS NULL") { - const v = getIsNullVariable(clause[1]); - if (negate) return [[0 - v]]; - else return [[v]]; - } else if (op === "IS NOT NULL") { - const v = getIsNullVariable(clause[1]); - if (!negate) return [[0 - v]]; - else return [[v]]; - } else { - throw new Error(`Unknown operator ${op}`); - } - } - - const cnf = recursive(filter, false); - - for (let m of mutuallyExclusive.values()) { - let ar = Array.from(m); - for (let i = 0; i < ar.length; ++i) - for (let j = i + 1; j < ar.length; ++j) cnf.push([0 - ar[i], 0 - ar[j]]); - } - - return { vars: expressions.size, clauses: cnf }; -} - -exports.booleanCnf = booleanCnf; diff --git a/common/filter.js b/common/filter.js deleted file mode 100644 index 24788d1..0000000 --- a/common/filter.js +++ /dev/null @@ -1,145 +0,0 @@ -"use strict"; - -const filterParser = require("./filter-parser"); -const filterCnf = require("./filter-cnf"); -const satSolver = require("./sat-solver"); -const funcCache = require("./func-cache"); - -function* permute(arr) { - if (arr.length <= 1) { - for (let i = 0; i < arr[0]; ++i) yield [i]; - return; - } - - let slc = arr.slice(1); - for (let i = 0; i < arr[0]; ++i) - for (let innerArr of permute(slc)) yield [i].concat(innerArr); -} - -class Filter { - constructor(str) { - if (!str) this.ast = null; - else if (Array.isArray(str)) this.ast = str; - else if (str instanceof Filter) this.ast = str.ast; - else if (Array.isArray(str.ast) || str.ast === null) this.ast = str.ast; - else this.ast = filterParser.parse(str); - } - - static parse(str) { - return new Filter(str); - } - - toString() { - if (!this.string) - if (this.ast) this.string = filterParser.stringify(this.ast); - else this.string = ""; - - return this.string; - } - - or(fltr) { - if (!fltr || !this.ast) return this; - else if (!fltr.ast) return fltr; - - let f = ["OR"]; - - if (this.ast[0] === "OR") f = f.concat(this.ast.slice(1)); - else f.push(this.ast); - - if (fltr.ast[0] === "OR") f = f.concat(fltr.ast.slice(1)); - else f.push(fltr.ast); - - return new Filter(f); - } - - and(fltr) { - if (!fltr || !fltr.ast) return this; - else if (!this.ast) return fltr; - - let f = ["AND"]; - - if (this.ast[0] === "AND") f = f.concat(this.ast.slice(1)); - else f.push(this.ast); - - if (fltr.ast[0] === "AND") f = f.concat(fltr.ast.slice(1)); - else f.push(fltr.ast); - - return new Filter(f); - } - - not() { - if (this.ast[0] === "NOT") return new Filter(this.ast[1]); - else return new Filter(["NOT", this.ast]); - } - - evaluateExpressions(now = Date.now()) { - if (!this.ast) return this; - - let ast = filterParser.evaluateExpressions(this.ast, exp => { - if (exp[0] === "FUNC" && exp[1] === "NOW") return now; - }); - - if (ast === this.ast) return this; - else return new Filter(ast); - } - - subset(fltr) { - if (!fltr.ast) return true; - else if (!this.ast) return false; - - const f = this.not() - .or(fltr) - .not(); - const { vars, clauses } = filterCnf.booleanCnf(f.ast); - return !satSolver.naiveDpll(clauses, vars); - } - - test(obj) { - if (!this.ast) return true; - return filterParser.map(this.ast, exp => { - const op = exp[0]; - - if (op === "AND") { - for (let i = 1; i < exp.length; ++i) if (!exp[i]) return false; - return true; - } else if (op === "OR") { - for (let i = 1; i < exp.length; ++i) if (exp[i]) return true; - return false; - } else if (op === "NOT") { - return !exp[1]; - } else if (op === "=") { - const val = - typeof obj[exp[1]] === "object" ? obj[exp[1]].value[0] : obj[exp[1]]; - return val === exp[2]; - } else if (op === "<>") { - const val = - typeof obj[exp[1]] === "object" ? obj[exp[1]].value[0] : obj[exp[1]]; - return val !== exp[2]; - } else if (op === ">=") { - const val = - typeof obj[exp[1]] === "object" ? obj[exp[1]].value[0] : obj[exp[1]]; - return typeof val === typeof exp[2] && val >= exp[2]; - } else if (op === ">") { - const val = - typeof obj[exp[1]] === "object" ? obj[exp[1]].value[0] : obj[exp[1]]; - return typeof val === typeof exp[2] && val > exp[2]; - } else if (op === "<=") { - const val = - typeof obj[exp[1]] === "object" ? obj[exp[1]].value[0] : obj[exp[1]]; - return typeof val === typeof exp[2] && val <= exp[2]; - } else if (op === "<") { - const val = - typeof obj[exp[1]] === "object" ? obj[exp[1]].value[0] : obj[exp[1]]; - return typeof val === typeof exp[2] && val < exp[2]; - } else if (op === "IS NULL") { - return obj[exp[1]] == null; - } else if (op === "IS NOT NULL") { - return obj[exp[1]] != null; - } else { - throw new Error(`Unsupported operator ${op}`); - } - }); - } -} - -module.exports = Filter; diff --git a/common/func-cache.js b/common/func-cache.js index 837f01d..4c9f081 100644 --- a/common/func-cache.js +++ b/common/func-cache.js @@ -22,6 +22,8 @@ function purge() { cache1 = new WeakMap(); } +setInterval(purge, 120000); + exports.get = get; exports.getter = getter; exports.purge = purge; diff --git a/server/api-functions.js b/server/api-functions.js index c10c476..e41cd92 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -7,20 +7,22 @@ const querystring = require("querystring"); const config = require("./config"); const device = require("./device"); -const filterParser = require("../common/filter-parser"); -const Filter = require("../common/filter"); +const expressionParser = require("../common/expression-parser"); +const expression = require("../common/expression"); const commaAscii = ",".charCodeAt(0); const newLineAscii = "\n".charCodeAt(0); function unpackTimestamps(filter) { - return filterParser.map(filter, exp => { + if (!Array.isArray(filter)) return filter; + return expressionParser.map(filter, exp => { if (["=", "<>", ">=", "<=", "<", ">"].includes(exp[0])) - if (typeof exp[2] === "number") { + if (Array.isArray(exp) && typeof exp[2] === "number") { let alt = exp.slice(); alt[2] = new Date(alt[2]).toJSON(); return ["OR", exp, alt]; } + return exp; }); } @@ -39,37 +41,42 @@ function filterToMongoQuery(filter, negate = false, res = {}) { } else if (op === "NOT") { filterToMongoQuery(filter[1], !negate, res); } else if (op === "=") { - let p = (res[filter[1]] = res[filter[1]] || {}); + const param = filter[1][1]; + let p = (res[param] = res[param] || {}); if (negate) p["$ne"] = filter[2]; else p["$eq"] = filter[2]; } else if (op === "<>") { - let p = (res[filter[1]] = res[filter[1]] || {}); - if (negate) { - p["$eq"] = filter[2]; - } else { - p["$ne"] = filter[2]; - p["$exists"] = true; - } + const param = filter[1][1]; + let p = (res[param] = res[param] || {}); + if (negate) p = p["$not"] = p["$not"] || {}; + p["$ne"] = filter[2]; + p["$exists"] = true; } else if (op === ">") { - let p = (res[filter[1]] = res[filter[1]] || {}); + const param = filter[1][1]; + let p = (res[param] = res[param] || {}); if (negate) p = p["$not"] = p["$not"] || {}; p["$gt"] = filter[2]; } else if (op === ">=") { - let p = (res[filter[1]] = res[filter[1]] || {}); + const param = filter[1][1]; + let p = (res[param] = res[param] || {}); if (negate) p = p["$not"] = p["$not"] || {}; p["$gte"] = filter[2]; } else if (op === "<") { - let p = (res[filter[1]] = res[filter[1]] || {}); + const param = filter[1][1]; + let p = (res[param] = res[param] || {}); if (negate) p = p["$not"] = p["$not"] || {}; p["$lt"] = filter[2]; } else if (op === "<=") { - let p = (res[filter[1]] = res[filter[1]] || {}); + const param = filter[1][1]; + let p = (res[param] = res[param] || {}); if (negate) p = p["$not"] = p["$not"] || {}; p["$lte"] = filter[2]; } else if (op === "IS NULL") { - res[filter[1]] = { $exists: negate }; + const param = filter[1][1]; + res[param] = { $exists: negate }; } else if (op === "IS NOT NULL") { - res[filter[1]] = { $exists: !negate }; + const param = filter[1][1]; + res[param] = { $exists: !negate }; } else { throw new Error(`Unrecognized operator ${op}`); } @@ -80,14 +87,15 @@ function filterToMongoQuery(filter, negate = false, res = {}) { function count(resource, filter, limit) { return new Promise((resolve, reject) => { let qs = {}; - if (filter && filter.ast) { - filter = filter.evaluateExpressions(); - let ast = unpackTimestamps(filter.ast); + filter = expression.evaluate(filter, null, Date.now()); + filter = unpackTimestamps(filter); - let q = filterToMongoQuery(ast); - if (resource === "devices") q = device.transposeQuery(q); - qs.query = JSON.stringify(q); - } + let q = {}; + if (Array.isArray(filter)) q = filterToMongoQuery(filter); + else if (filter != null && !filter) return resolve(0); + + if (resource === "devices") q = device.transposeQuery(q); + qs.query = JSON.stringify(q); if (limit) qs.limit = limit; @@ -117,15 +125,15 @@ function query(resource, filter, limit, skip, projection, callback) { let ret; if (!callback) ret = []; let qs = {}; - if (filter && filter.ast) { - filter = filter.evaluateExpressions(); - let ast = unpackTimestamps(filter.ast); + filter = expression.evaluate(filter, null, Date.now()); + filter = unpackTimestamps(filter); - let q = filterToMongoQuery(ast); - if (resource === "devices") q = device.transposeQuery(q); + let q = {}; + if (Array.isArray(filter)) q = filterToMongoQuery(filter); + else if (filter != null && !filter) return resolve(ret); - qs.query = JSON.stringify(q); - } + if (resource === "devices") q = device.transposeQuery(q); + qs.query = JSON.stringify(q); if (limit) qs.limit = limit; if (skip) qs.skip = skip; @@ -308,12 +316,13 @@ function postTasks(deviceId, tasks) { let promises2 = []; for (let s of statuses) { - promises2.push(query("tasks", new Filter(["=", "_id", s._id]))); + promises2.push(query("tasks", ["=", ["PARAM", "_id"], s._id])); promises2.push( - query( - "faults", - new Filter(["=", "_id", `${deviceId}:task_${s._id}`]) - ) + query("faults", [ + "=", + ["PARAM", "_id"], + `${deviceId}:task_${s._id}` + ]) ); } diff --git a/server/api.js b/server/api.js index 587ca9c..37ec195 100644 --- a/server/api.js +++ b/server/api.js @@ -4,7 +4,7 @@ const Router = require("koa-router"); const db = require("./db"); const apiFunctions = require("./api-functions"); -const Filter = require("../common/filter"); +const expression = require("../common/expression"); const router = new Router(); @@ -33,17 +33,20 @@ const resources = { for (let [resource, flags] of Object.entries(resources)) { router.head(`/${resource}`, async (ctx, next) => { - let filter, limit; - if (ctx.request.query.filter) filter = new Filter(ctx.request.query.filter); + let filter = true; + let limit; + if (ctx.request.query.filter) + filter = expression.parse(ctx.request.query.filter); if (ctx.request.query.limit) limit = +ctx.request.query.limit; if (!ctx.state.authorizer.hasAccess(resource, 1)) return next(); // Exclude temporary tasks and faults - if (resource === "tasks" || resource === "faults") { - let f = new Filter(["NOT", ["<", "expiry", Date.now() + 60000]]); - filter = f.and(filter); - } + if (resource === "tasks" || resource === "faults") + filter = expression.and(filter, [ + "NOT", + ["<", ["PARAM", "expiry"], Date.now() + 60000] + ]); let count; if (flags & RESOURCE_DB) count = await db.count(resource, filter, limit); @@ -54,8 +57,10 @@ for (let [resource, flags] of Object.entries(resources)) { }); router.get(`/${resource}`, async (ctx, next) => { - let filter, limit, skip, projection; - if (ctx.request.query.filter) filter = new Filter(ctx.request.query.filter); + let filter = true; + let limit, skip, projection; + if (ctx.request.query.filter) + filter = expression.parse(ctx.request.query.filter); if (ctx.request.query.limit) limit = +ctx.request.query.limit; if (ctx.request.query.skip) skip = +ctx.request.query.skip; if (ctx.request.query.projection) projection = ctx.request.query.projection; @@ -63,10 +68,11 @@ for (let [resource, flags] of Object.entries(resources)) { if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); // Exclude temporary tasks and faults - if (resource === "tasks" || resource === "faults") { - let f = new Filter(["NOT", ["<", "expiry", Date.now() + 60000]]); - filter = f.and(filter); - } + if (resource === "tasks" || resource === "faults") + filter = expression.and(filter, [ + "NOT", + ["<", ["PARAM", "expiry"], Date.now() + 60000] + ]); let res; if (flags & RESOURCE_DB) @@ -78,7 +84,7 @@ for (let [resource, flags] of Object.entries(resources)) { }); router.head(`/${resource}/:id`, async (ctx, next) => { - let filter = new Filter(`${RESOURCE_IDS[resource]} = "${ctx.params.id}"`); + let filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); let res; @@ -90,7 +96,7 @@ for (let [resource, flags] of Object.entries(resources)) { }); router.get(`/${resource}/:id`, async (ctx, next) => { - let filter = new Filter(`${RESOURCE_IDS[resource]} = "${ctx.params.id}"`); + let filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); let res; @@ -104,7 +110,7 @@ for (let [resource, flags] of Object.entries(resources)) { if (flags & RESOURCE_DELETE && !(flags & RESOURCE_DB)) router.delete(`/${resource}/:id`, async (ctx, next) => { const authorizer = ctx.state.authorizer; - let filter = new Filter(`${RESOURCE_IDS[resource]} = "${ctx.params.id}"`); + let filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; if (!authorizer.hasAccess(resource, 2)) return next(); let res = await apiFunctions.query(resource, filter); if (!res.length) return next(); @@ -119,7 +125,7 @@ for (let [resource, flags] of Object.entries(resources)) { router.post("/devices/:id/tasks", async (ctx, next) => { const authorizer = ctx.state.authorizer; - let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); + let filter = ["=", ["PARAM", "DeviceID.ID"], ctx.params.id]; if (!authorizer.hasAccess("devices", 2)) return next(); let devices = await apiFunctions.query("devices", filter); if (!devices.length) return next(); @@ -135,7 +141,7 @@ router.post("/devices/:id/tasks", async (ctx, next) => { router.post("/devices/:id/tags", async (ctx, next) => { const authorizer = ctx.state.authorizer; - let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); + let filter = ["=", ["PARAM", "DeviceID.ID"], ctx.params.id]; if (!authorizer.hasAccess("devices", 2)) return next(); let res = await apiFunctions.query("devices", filter); if (!res.length) return next(); diff --git a/server/db.js b/server/db.js index 088c7d6..5147001 100644 --- a/server/db.js +++ b/server/db.js @@ -20,7 +20,8 @@ function getClient() { function query(resource, filter, limit, skip, callback) { let q; - if (filter && filter.ast) q = apiFunctions.filterToMongoQuery(filter.ast); + if (Array.isArray(filter)) q = apiFunctions.filterToMongoQuery(filter); + else if (filter != null && !filter) return Promise.resolve([]); return new Promise((resolve, reject) => { getClient().then(client => { @@ -50,7 +51,8 @@ function query(resource, filter, limit, skip, callback) { function count(resource, filter) { let q; - if (filter && filter.ast) q = apiFunctions.filterToMongoQuery(filter.ast); + if (Array.isArray(filter)) q = apiFunctions.filterToMongoQuery(filter); + else if (filter != null && !filter) return Promise.resolve([]); return new Promise((resolve, reject) => { getClient().then(client => { diff --git a/server/server.js b/server/server.js index bf5bdfd..62b64ad 100644 --- a/server/server.js +++ b/server/server.js @@ -11,6 +11,7 @@ const jwt = require("jsonwebtoken"); const config = require("./config"); const api = require("./api"); const Authorizer = require("../common/authorizer"); +const expression = require("../common/expression"); const koa = new Koa(); const router = new Router(); @@ -20,7 +21,20 @@ const JWT_COOKIE = "genieacs-ui-jwt"; function getPermissionSets(roles) { const allPermissions = config.permissions; - const permissionSets = roles.map(r => Object.values(allPermissions[r] || {})); + const permissionSets = roles.map(role => { + return Object.values(allPermissions[role] || {}).map(p => { + p = Object.assign({}, p); + for (let [k, v] of Object.entries(p)) { + p[k] = v = Object.assign({}, v); + if (["devices", "faults", "files"].includes(k)) + if (v.filter == null || v.filter === "") v.filter = true; + else { + v.filter = expression.parse(v.filter); + } + } + return p; + }); + }); return permissionSets; } From 2319f8d698f6e99d4f125f0f144698a3872cb41f Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Tue, 17 Jul 2018 00:40:14 -0700 Subject: [PATCH 032/106] Add .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f77bef --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*~ +node_modules +public \ No newline at end of file From db900622a0e2c5a8a846d094c0fefffc69c3624a Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Fri, 20 Jul 2018 17:08:13 -0700 Subject: [PATCH 033/106] Fetch directly from db instead of API --- client/store.js | 9 +- common/expression.js | 16 +- server/api-functions.js | 164 +----------------- server/api.js | 41 ++--- server/db.js | 89 +++++++--- server/device.js | 191 --------------------- server/mongodb-functions.js | 320 ++++++++++++++++++++++++++++++++++++ 7 files changed, 423 insertions(+), 407 deletions(-) delete mode 100644 server/device.js create mode 100644 server/mongodb-functions.js diff --git a/client/store.js b/client/store.js index 02ba2c3..dc2025b 100644 --- a/client/store.js +++ b/client/store.js @@ -71,7 +71,7 @@ function count(resourceType, filter) { function limitFilter(resourceType, filter, last) { if (resourceType === "devices") - return expression.and(filter, ["<=", "DeviceID.ID", last]); + return expression.and(filter, ["<=", ["PARAM", "DeviceID.ID"], last]); else return expression.and(filter, ["<=", ["PARAM", "_id"], last]); } @@ -83,11 +83,10 @@ function findMatches(resourceType, filter, limit) { Array.isArray(e) && Array.isArray(e[1]) && e[1][0] === "PARAM" && - e[1][0] === "tag" + e[1][1] === "tag" ) - if (e[0] === "=") return ["=", ["PARAM", `Tags.${e[2]}`], true]; - else if (e[0] === "<>") - return ["NOT", ["=", ["PARAM", `Tags.${e[2]}`], true]]; + if (e[0] === "=") return ["IS NOT NULL", ["PARAM", `Tags.${e[2]}`]]; + else if (e[0] === "<>") return ["IS NULL", ["PARAM", `Tags.${e[2]}`]]; return e; }); diff --git a/common/expression.js b/common/expression.js index 14e6c53..1e8d84f 100644 --- a/common/expression.js +++ b/common/expression.js @@ -50,10 +50,12 @@ function evaluate(exp, obj, now) { } } else if (e[0] === "PARAM") { if (e[1] == null) return null; - if (obj && !isArray(e[1])) - if (typeof obj[e[1]] === "object") - return obj[e[1]].value ? obj[e[1]].value[0] : null; - else return obj[e[1]]; + if (obj && !isArray(e[1])) { + let v = obj[e[1]]; + if (typeof v === "object") v = v.value ? v.value[0] : null; + if (v == null) return null; + return v; + } } else if (e[0] === "AND") { return reduce(e, (a, b) => { if (!isArray(a)) return a ? b : a; @@ -69,10 +71,12 @@ function evaluate(exp, obj, now) { else if (e[1][0] === "NOT") return e[1][1]; } else if (e[0] === "IS NULL") { if (isArray(e[1])) return e; - return e[1] == null; + else if (e[1] == null) return true; + else return null; } else if (e[0] === "IS NOT NULL") { if (isArray(e[1])) return e; - return e[1] != null; + else if (e[1] != null) return true; + else return null; } else if (e[0] === "=") { if (isArray(e[1]) || isArray(e[2])) return e; if (e[1] == null || e[2] == null) return null; diff --git a/server/api-functions.js b/server/api-functions.js index e41cd92..52794ce 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -3,29 +3,11 @@ const http = require("http"); const https = require("https"); const url = require("url"); -const querystring = require("querystring"); const config = require("./config"); -const device = require("./device"); -const expressionParser = require("../common/expression-parser"); +const db = require("./db"); const expression = require("../common/expression"); -const commaAscii = ",".charCodeAt(0); -const newLineAscii = "\n".charCodeAt(0); - -function unpackTimestamps(filter) { - if (!Array.isArray(filter)) return filter; - return expressionParser.map(filter, exp => { - if (["=", "<>", ">=", "<=", "<", ">"].includes(exp[0])) - if (Array.isArray(exp) && typeof exp[2] === "number") { - let alt = exp.slice(); - alt[2] = new Date(alt[2]).toJSON(); - return ["OR", exp, alt]; - } - return exp; - }); -} - function filterToMongoQuery(filter, negate = false, res = {}) { const op = filter[0]; @@ -84,136 +66,6 @@ function filterToMongoQuery(filter, negate = false, res = {}) { return res; } -function count(resource, filter, limit) { - return new Promise((resolve, reject) => { - let qs = {}; - filter = expression.evaluate(filter, null, Date.now()); - filter = unpackTimestamps(filter); - - let q = {}; - if (Array.isArray(filter)) q = filterToMongoQuery(filter); - else if (filter != null && !filter) return resolve(0); - - if (resource === "devices") q = device.transposeQuery(q); - qs.query = JSON.stringify(q); - - if (limit) qs.limit = limit; - - let options = url.parse( - `${config.server.nbi}${resource}?${querystring.stringify(qs)}` - ); - - options.method = "HEAD"; - - let _http = options.protocol === "https:" ? https : http; - - _http - .request(options, res => { - if (res.statusCode !== 200) { - reject(new Error(`Unexpected status code ${res.statusCode}`)); - res.resume(); - return; - } - resolve(+res.headers["total"]); - }) - .end(); - }); -} - -function query(resource, filter, limit, skip, projection, callback) { - return new Promise((resolve, reject) => { - let ret; - if (!callback) ret = []; - let qs = {}; - filter = expression.evaluate(filter, null, Date.now()); - filter = unpackTimestamps(filter); - - let q = {}; - if (Array.isArray(filter)) q = filterToMongoQuery(filter); - else if (filter != null && !filter) return resolve(ret); - - if (resource === "devices") q = device.transposeQuery(q); - qs.query = JSON.stringify(q); - - if (limit) qs.limit = limit; - if (skip) qs.skip = skip; - if (projection) qs.projection = projection; - qs.sort = JSON.stringify({ _id: 1 }); - - let options = url.parse( - `${config.server.nbi}${resource}?${querystring.stringify(qs)}` - ); - - let _http = options.protocol === "https:" ? https : http; - - _http.get(options, res => { - if (res.statusCode !== 200) { - reject(new Error(`Unexpected status code ${res.statusCode}`)); - res.resume(); - return; - } - let chunks = []; - let bytes = 0; - res.on("data", chunk => { - let i; - while ((i = chunk.indexOf(newLineAscii)) !== -1) { - let buf; - if (chunk[chunk.length - 2] === commaAscii) { - i -= 1; - buf = new Buffer(bytes + i); - chunk.copy(buf, buf.length - i, 0, i); - chunk = chunk.slice(i + 2); - } else { - buf = new Buffer(bytes + i); - chunk.copy(buf, buf.length - i, 0, i); - chunk = chunk.slice(i + 1); - } - - let o = 0; - for (let c of chunks) { - c.copy(buf, o, 0, c.length); - o += c.length; - } - - if (buf.length > 1) { - let obj = JSON.parse(buf); - if (resource === "devices") obj = device.transpose(obj); - if (ret) ret.push(obj); - else callback(obj); - } - - bytes = 0; - chunks = []; - } - bytes += chunk.length; - chunks.push(chunk); - }); - - res.on("end", () => { - let buf = new Buffer(bytes); - let o = 0; - for (let c of chunks) { - c.copy(buf, o, 0, c.length); - o += c.length; - } - if (buf.length > 1) { - let obj = JSON.parse(buf); - if (resource === "devices") obj = device.transpose(obj); - - if (ret) ret.push(obj); - else callback(obj); - } - resolve(ret); - }); - - res.on("aborted", () => { - res.removeAllListeners("end"); - reject(new Error("Timeout")); - }); - }); - }); -} - function deleteResource(resource, id) { return new Promise((resolve, reject) => { let options = url.parse( @@ -237,7 +89,7 @@ function deleteResource(resource, id) { function postTask(task, connectionRequest, callback) { let options = url.parse( - `${config.server.nbi}devices/${task.device}/tasks${ + `${config.server.nbi}devices/${encodeURIComponent(task.device)}/tasks${ connectionRequest ? "?connection_request" : "" }` ); @@ -316,9 +168,9 @@ function postTasks(deviceId, tasks) { let promises2 = []; for (let s of statuses) { - promises2.push(query("tasks", ["=", ["PARAM", "_id"], s._id])); + promises2.push(db.query("tasks", ["=", ["PARAM", "_id"], s._id])); promises2.push( - query("faults", [ + db.query("faults", [ "=", ["PARAM", "_id"], `${deviceId}:task_${s._id}` @@ -355,9 +207,9 @@ function updateTags(deviceId, tags) { ([tag, onOff]) => new Promise((resolve, reject) => { const options = url.parse( - `${config.server.nbi}devices/${deviceId}/tags/${encodeURIComponent( - tag - )}` + `${config.server.nbi}devices/${encodeURIComponent( + deviceId + )}/tags/${encodeURIComponent(tag)}` ); if (onOff) options.method = "POST"; else options.method = "DELETE"; @@ -431,8 +283,6 @@ function ping(host) { }); } -exports.query = query; -exports.count = count; exports.postTasks = postTasks; exports.deleteResource = deleteResource; exports.updateTags = updateTags; diff --git a/server/api.js b/server/api.js index 37ec195..ff472db 100644 --- a/server/api.js +++ b/server/api.js @@ -9,7 +9,6 @@ const expression = require("../common/expression"); const router = new Router(); const RESOURCE_DELETE = 1 << 0; -const RESOURCE_DB = 1 << 1; const RESOURCE_IDS = { devices: "DeviceID.ID", @@ -34,10 +33,8 @@ const resources = { for (let [resource, flags] of Object.entries(resources)) { router.head(`/${resource}`, async (ctx, next) => { let filter = true; - let limit; if (ctx.request.query.filter) filter = expression.parse(ctx.request.query.filter); - if (ctx.request.query.limit) limit = +ctx.request.query.limit; if (!ctx.state.authorizer.hasAccess(resource, 1)) return next(); @@ -48,22 +45,23 @@ for (let [resource, flags] of Object.entries(resources)) { ["<", ["PARAM", "expiry"], Date.now() + 60000] ]); - let count; - if (flags & RESOURCE_DB) count = await db.count(resource, filter, limit); - else count = await apiFunctions.count(resource, filter, limit); + let count = await db.count(resource, filter); ctx.set("X-Total-Count", count); ctx.body = ""; }); router.get(`/${resource}`, async (ctx, next) => { + let options = {}; let filter = true; - let limit, skip, projection; if (ctx.request.query.filter) filter = expression.parse(ctx.request.query.filter); - if (ctx.request.query.limit) limit = +ctx.request.query.limit; - if (ctx.request.query.skip) skip = +ctx.request.query.skip; - if (ctx.request.query.projection) projection = ctx.request.query.projection; + if (ctx.request.query.limit) options.limit = +ctx.request.query.limit; + if (ctx.request.query.skip) options.skip = +ctx.request.query.skip; + if (ctx.request.query.projection) + options.projection = ctx.request.query.projection + .split(",") + .reduce((obj, k) => Object.assign(obj, { [k]: 1 }), {}); if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); @@ -74,11 +72,7 @@ for (let [resource, flags] of Object.entries(resources)) { ["<", ["PARAM", "expiry"], Date.now() + 60000] ]); - let res; - if (flags & RESOURCE_DB) - res = await db.query(resource, filter, limit, skip); - else - res = await apiFunctions.query(resource, filter, limit, skip, projection); + let res = await db.query(resource, filter, options); ctx.body = res; }); @@ -87,9 +81,7 @@ for (let [resource, flags] of Object.entries(resources)) { let filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); - let res; - if (flags & RESOURCE_DB) res = await db.query(resource, filter); - else res = await apiFunctions.query(resource, filter); + let res = await db.query(resource, filter); if (!res.length) return next(); ctx.body = ""; @@ -99,20 +91,19 @@ for (let [resource, flags] of Object.entries(resources)) { let filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); - let res; - if (flags & RESOURCE_DB) res = await db.query(resource, filter); - else res = await apiFunctions.query(resource, filter); + let res = await db.query(resource, filter); + if (!res.length) return next(); ctx.body = res[0]; }); // TODO add PUT, PATCH routes - if (flags & RESOURCE_DELETE && !(flags & RESOURCE_DB)) + if (flags & RESOURCE_DELETE) router.delete(`/${resource}/:id`, async (ctx, next) => { const authorizer = ctx.state.authorizer; let filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; if (!authorizer.hasAccess(resource, 2)) return next(); - let res = await apiFunctions.query(resource, filter); + let res = await db.query(resource, filter); if (!res.length) return next(); const validate = authorizer.getValidator(resource, res[0]); @@ -127,7 +118,7 @@ router.post("/devices/:id/tasks", async (ctx, next) => { const authorizer = ctx.state.authorizer; let filter = ["=", ["PARAM", "DeviceID.ID"], ctx.params.id]; if (!authorizer.hasAccess("devices", 2)) return next(); - let devices = await apiFunctions.query("devices", filter); + let devices = await db.query("devices", filter); if (!devices.length) return next(); const validate = authorizer.getValidator("devices", devices[0]); @@ -143,7 +134,7 @@ router.post("/devices/:id/tags", async (ctx, next) => { const authorizer = ctx.state.authorizer; let filter = ["=", ["PARAM", "DeviceID.ID"], ctx.params.id]; if (!authorizer.hasAccess("devices", 2)) return next(); - let res = await apiFunctions.query("devices", filter); + let res = await db.query("devices", filter); if (!res.length) return next(); const validate = authorizer.getValidator("devices", res[0]); diff --git a/server/db.js b/server/db.js index 5147001..f0fa4b8 100644 --- a/server/db.js +++ b/server/db.js @@ -1,43 +1,75 @@ "use strict"; -const MongoClient = require("mongodb").MongoClient; +const mongodb = require("mongodb"); const config = require("./config"); -const apiFunctions = require("./api-functions"); +const mongodbFunctions = require("./mongodb-functions"); +const expression = require("../common/expression"); -let _client = null; +const _client = {}; -function getClient() { - if (_client) return Promise.resolve(_client); +const RESOURCE_DB = { + devices: "genieacs", + faults: "genieacs", + tasks: "genieacs", + presets: "genieacs", + provisions: "genieacs", + files: "genieacs" +}; + +function getClient(db) { + if (_client[db]) return Promise.resolve(_client[db]); return new Promise((resolve, reject) => { const CONNECTION_URL = config.server.mongodbConnectionUrl; - MongoClient.connect(CONNECTION_URL, (err, client) => { + mongodb.MongoClient.connect(CONNECTION_URL, (err, client) => { if (err) return reject(err); - resolve((_client = client.db("genieacs-ui"))); + resolve((_client[db] = client.db(db))); }); }); } -function query(resource, filter, limit, skip, callback) { +function query(resource, filter, options, callback) { + options = options || {}; let q; - if (Array.isArray(filter)) q = apiFunctions.filterToMongoQuery(filter); - else if (filter != null && !filter) return Promise.resolve([]); + filter = expression.evaluate(filter, null, null, Date.now()); + + if (Array.isArray(filter)) { + if (resource === "devices") + filter = mongodbFunctions.processDeviceFilter(filter); + else if (resource === "tasks") + filter = mongodbFunctions.processTasksFilter(filter); + else if (resource === "faults") + filter = mongodbFunctions.processFaultsFilter(filter); + q = mongodbFunctions.filterToMongoQuery(filter); + } else if (filter != null && !filter) { + return Promise.resolve([]); + } return new Promise((resolve, reject) => { - getClient().then(client => { + getClient(RESOURCE_DB[resource]).then(client => { const collection = client.collection(resource); + let cursor = collection.find( + q, + resource === "Devices" + ? mongodbFunctions.processDeviceProjection(options.projection) + : options.projection + ); + if (options.skip) cursor = cursor.skip(options.skip); + if (options.limit) cursor = cursor.limit(options.limit); + cursor = cursor.sort({ _id: 1 }); + if (!callback) - collection - .find(q) - .skip(skip || 0) - .limit(limit || 0) - .toArray((err, docs) => { - if (err) return reject(err); - return resolve(docs); - }); + cursor.toArray((err, docs) => { + if (err) return reject(err); + if (resource === "devices") + docs = docs.map(d => mongodbFunctions.flattenDevice(d)); + return resolve(docs); + }); else - collection.find(q).forEach( + cursor.forEach( doc => { + if (resource === "devices") + doc = mongodbFunctions.flattenDevice(doc); callback(doc); }, err => { @@ -51,11 +83,22 @@ function query(resource, filter, limit, skip, callback) { function count(resource, filter) { let q; - if (Array.isArray(filter)) q = apiFunctions.filterToMongoQuery(filter); - else if (filter != null && !filter) return Promise.resolve([]); + filter = expression.evaluate(filter, null, null, Date.now()); + + if (Array.isArray(filter)) { + if (resource === "devices") + filter = mongodbFunctions.processDeviceFilter(filter); + else if (resource === "tasks") + filter = mongodbFunctions.processTasksFilter(filter); + else if (resource === "faults") + filter = mongodbFunctions.processFaultsFilter(filter); + q = mongodbFunctions.filterToMongoQuery(filter); + } else if (filter != null && !filter) { + return Promise.resolve([]); + } return new Promise((resolve, reject) => { - getClient().then(client => { + getClient(RESOURCE_DB[resource]).then(client => { const collection = client.collection(resource); collection.find(q).count((err, c) => { if (err) reject(err); diff --git a/server/device.js b/server/device.js deleted file mode 100644 index 28da8d0..0000000 --- a/server/device.js +++ /dev/null @@ -1,191 +0,0 @@ -"use strict"; - -function recursive(input, root, output, timestamp) { - for (let [name, tree] of Object.entries(input)) { - if (root.length === 0) - if (name === "_lastInform") { - output["Events.Inform"] = { - value: [Date.parse(tree), "xsd:dateTime"], - valueTimestamp: timestamp, - writable: false, - writableTimestamp: timestamp, - object: false, - objectTimestamp: timestamp - }; - } else if (name === "_registered") { - output["Events.Registered"] = { - value: [Date.parse(tree), "xsd:dateTime"], - valueTimestamp: timestamp, - writable: false, - writableTimestamp: timestamp, - object: false, - objectTimestamp: timestamp - }; - } else if (name === "_lastBoot") { - output["Events.1_BOOT"] = { - value: [Date.parse(tree), "xsd:dateTime"], - valueTimestamp: timestamp, - writable: false, - writableTimestamp: timestamp, - object: false, - objectTimestamp: timestamp - }; - } else if (name === "_lastBootstrap") { - output["Events.0_BOOTSTRAP"] = { - value: [Date.parse(tree), "xsd:dateTime"], - valueTimestamp: timestamp, - writable: false, - writableTimestamp: timestamp, - object: false, - objectTimestamp: timestamp - }; - } else if (name === "_id") { - output["DeviceID.ID"] = { - value: [tree, "xsd:string"], - valueTimestamp: timestamp, - writable: false, - writableTimestamp: timestamp, - object: false, - objectTimestamp: timestamp - }; - } else if (name === "_deviceId") { - output["DeviceID.Manufacturer"] = { - value: [tree["_Manufacturer"], "xsd:string"], - valueTimestamp: timestamp, - writable: false, - writableTimestamp: timestamp, - object: false, - objectTimestamp: timestamp - }; - output["DeviceID.OUI"] = { - value: [tree["_OUI"], "xsd:string"], - valueTimestamp: timestamp, - writable: false, - writableTimestamp: timestamp, - object: false, - objectTimestamp: timestamp - }; - output["DeviceID.ProductClass"] = { - value: [tree["_ProductClass"], "xsd:string"], - valueTimestamp: timestamp, - writable: false, - writableTimestamp: timestamp, - object: false, - objectTimestamp: timestamp - }; - output["DeviceID.SerialNumber"] = { - value: [tree["_SerialNumber"], "xsd:string"], - valueTimestamp: timestamp, - writable: false, - writableTimestamp: timestamp, - object: false, - objectTimestamp: timestamp - }; - } else if (name === "_tags") { - output["Tags"] = { - writable: true, - writableTimestamp: timestamp, - object: true, - objectTimestamp: timestamp - }; - - for (let t of tree) - output[`Tags.${t}`] = { - value: [true, "xsd:boolean"], - valueTimestamp: timestamp, - writable: false, - writableTimestamp: timestamp, - object: false, - objectTimestamp: timestamp - }; - } - - if (name.startsWith("_")) continue; - - let childrenTimestamp = timestamp; - - if (root.length === 0) childrenTimestamp = input["_timestamp"] || 1; - else if (input["_timestamp"] > timestamp) - childrenTimestamp = input["_timestamp"]; - - const attrs = {}; - if (tree["_value"] != null) { - attrs["value"] = [tree["_value"], tree["_type"]]; - attrs["valueTimestamp"] = tree["_timestamp"] || childrenTimestamp; - attrs["object"] = false; - attrs["objectTimestamp"] = childrenTimestamp; - } else if (tree["_object"] != null) { - attrs["object"] = tree["_object"]; - attrs["objectTimestamp"] = childrenTimestamp; - } - - if (tree["_writable"] != null) { - attrs["writable"] = tree["_writable"]; - attrs["writableTimestamp"] = childrenTimestamp; - } - - let r = root.concat(name); - output[r.join(".")] = attrs; - - if (attrs["object"]) recursive(tree, r, output, childrenTimestamp); - } -} - -function transpose(device) { - const newDevice = {}; - const timestamp = new Date(device["_lastInform"] || 1).getTime(); - recursive(device, [], newDevice, timestamp); - return newDevice; -} - -function transposeQuery(query) { - const newQuery = {}; - - for (let [key, val] of Object.entries(query)) - if (key.startsWith("$")) { - newQuery[key] = val.map(v => transposeQuery(v)); - } else if (key === "DeviceID.ID") { - newQuery["_id"] = val; - } else if (key === "DeviceID.SerialNumber") { - newQuery["_deviceId._SerialNumber"] = val; - } else if (key === "DeviceID.OUI") { - newQuery["_deviceId._OUI"] = val; - } else if (key === "DeviceID.ProductClass") { - newQuery["_deviceId._ProductClass"] = val; - } else if (key === "DeviceID.Manufacturer") { - newQuery["_deviceId._Manufacturer"] = val; - } else if (key.startsWith("Tags.")) { - if (val["$exists"] === true) { - newQuery["_tags"] = newQuery["_tags"] || {}; - newQuery["_tags"]["$all"] = newQuery["_tags"]["$all"] || []; - newQuery["_tags"]["$all"].push(key.slice(5)); - } else if (val["$exists"] === false) { - newQuery["_tags"] = newQuery["_tags"] || {}; - newQuery["_tags"]["$nin"] = newQuery["_tags"]["$nin"] || []; - newQuery["_tags"]["$nin"].push(key.slice(5)); - } - } else if (key === "tag") { - if (typeof val === "string") { - newQuery["_tags"] = newQuery["_tags"] || {}; - newQuery["_tags"]["$all"] = newQuery["_tags"]["$all"] || []; - newQuery["_tags"]["$all"].push(val); - } else if (val["$eq"]) { - newQuery["_tags"] = newQuery["_tags"] || {}; - newQuery["_tags"]["$all"] = newQuery["_tags"]["$all"] || []; - newQuery["_tags"]["$all"].push(val["$eq"]); - } else if (val["$ne"]) { - newQuery["_tags"] = newQuery["_tags"] || {}; - newQuery["_tags"]["$nin"] = newQuery["_tags"]["$nin"] || []; - newQuery["_tags"]["$nin"].push(val["$ne"]); - } - } else if (key === "Events.Inform") { - newQuery["_lastInform"] = val; - } else { - newQuery[key] = val; - } - - return newQuery; -} - -exports.transpose = transpose; -exports.transposeQuery = transposeQuery; diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js new file mode 100644 index 0000000..a4751b1 --- /dev/null +++ b/server/mongodb-functions.js @@ -0,0 +1,320 @@ +"use strict"; +const mongodb = require("mongodb"); +const expressionParser = require("../common/expression-parser"); + +function processDeviceFilter(filter) { + return expressionParser.map(filter, exp => { + if (!Array.isArray(exp)) return exp; + + if (exp[0] === "PARAM") { + const p = exp[1]; + if (p === "DeviceID.ID") return ["PARAM", "_id"]; + else if (p === "DeviceID") return ["PARAM", "_deviceId"]; + else if (p.startsWith("DeviceID.")) + return ["PARAM", "_deviceId._" + p.slice(9)]; + else if (p === "Tags") return ["PARAM", "_tags"]; + else if (p === "tag") return ["PARAM", "_tags"]; + else if (p === "Events.Inform") return ["PARAM", "_lastInform"]; + else if (p === "Events.Registered") return ["PARAM", "_registered"]; + else if (p === "Events.0_BOOTSTRAP") return ["PARAM", "_lastBootstrap"]; + else if (p === "Events.1_BOOT") return ["PARAM", "_lastBoot"]; + } else if ( + Array.isArray(exp[1]) && + exp[1][0] === "PARAM" && + exp[1][1].startsWith("Tags.") + ) { + const t = exp[1][1].slice(5); + if (exp[0] === "IS NULL") return ["<>", ["PARAM", "_tags"], t]; + else if (exp[0] === "IS NOT NULL") return ["=", ["PARAM", "_tags"], t]; + else if (exp[0] === "=" && exp[2] === true) + return ["=", ["PARAM", "_tags"], t]; + } else if (["=", "<>", ">", ">=", "<", "<="].includes(exp[0])) { + let e = exp.slice(); + if ( + Array.isArray(exp[1]) && + exp[1][0] === "PARAM" && + typeof exp[1][1] === "string" && + !exp[1][1].startsWith("_") + ) + e[1] = ["PARAM", `${exp[1][1]}._value`]; + if (typeof e[2] === "number") { + let alt = e.slice(); + alt[2] = new Date(e[2]); + e = ["OR", e, alt]; + } + return e; + } + return exp; + }); +} + +function processTasksFilter(filter) { + return expressionParser.map(filter, exp => { + if (!Array.isArray(exp)) return exp; + if (["=", "<>", ">", ">=", "<", "<="].includes(exp[0])) { + let e = exp.slice(); + if (e[1][0] === "PARAM" && e[1][1] === "_id") + e[2] = new mongodb.ObjectID(e[2]); + else if (e[1][0] === "PARAM" && e[1][1] === "timestamp") + e[2] = new Date(e[2]); + else if (e[1][0] === "PARAM" && e[1][1] === "expiry") + e[2] = new Date(e[2]); + return e; + } + return exp; + }); +} + +function processFaultsFilter(filter) { + return expressionParser.map(filter, exp => { + if (!Array.isArray(exp)) return exp; + if (["=", "<>", ">", ">=", "<", "<="].includes(exp[0])) { + let e = exp.slice(); + if (e[1][0] === "PARAM" && e[1][1] === "timestamp") e[2] = new Date(e[2]); + else if (e[1][0] === "PARAM" && e[1][1] === "expiry") + e[2] = new Date(e[2]); + return e; + } + return exp; + }); +} + +function filterToMongoQuery(filter, negate = false, res = {}) { + const op = filter[0]; + + if ((!negate && op === "AND") || (negate && op === "OR")) { + res["$and"] = res["$and"] || []; + for (let i = 1; i < filter.length; ++i) + res["$and"].push(filterToMongoQuery(filter[i], negate)); + } else if ((!negate && op === "OR") || (negate && op === "AND")) { + res["$or"] = res["$or"] || []; + + for (let i = 1; i < filter.length; ++i) + res["$or"].push(filterToMongoQuery(filter[i], negate)); + } else if (op === "NOT") { + filterToMongoQuery(filter[1], !negate, res); + } else if (op === "=") { + const param = filter[1][1]; + let p = (res[param] = res[param] || {}); + if (negate) p["$ne"] = filter[2]; + else p["$eq"] = filter[2]; + } else if (op === "<>") { + const param = filter[1][1]; + let p = (res[param] = res[param] || {}); + if (negate) p = p["$not"] = p["$not"] || {}; + p["$ne"] = filter[2]; + p["$exists"] = true; + } else if (op === ">") { + const param = filter[1][1]; + let p = (res[param] = res[param] || {}); + if (negate) p = p["$not"] = p["$not"] || {}; + p["$gt"] = filter[2]; + } else if (op === ">=") { + const param = filter[1][1]; + let p = (res[param] = res[param] || {}); + if (negate) p = p["$not"] = p["$not"] || {}; + p["$gte"] = filter[2]; + } else if (op === "<") { + const param = filter[1][1]; + let p = (res[param] = res[param] || {}); + if (negate) p = p["$not"] = p["$not"] || {}; + p["$lt"] = filter[2]; + } else if (op === "<=") { + const param = filter[1][1]; + let p = (res[param] = res[param] || {}); + if (negate) p = p["$not"] = p["$not"] || {}; + p["$lte"] = filter[2]; + } else if (op === "IS NULL") { + const param = filter[1][1]; + res[param] = { $exists: negate }; + } else if (op === "IS NOT NULL") { + const param = filter[1][1]; + res[param] = { $exists: !negate }; + } else { + throw new Error(`Unrecognized operator ${op}`); + } + + return res; +} + +function processDeviceProjection(projection) { + if (!projection) return projection; + const p = {}; + for (const [k, v] of Object.entries(projection)) + if (k === "DeviceID.ID") { + p["_id"] = 1; + } else if (k.startsWith("DeviceID")) { + p["_deviceId._SerialNumber"] = v; + p["_deviceId._OUI"] = v; + p["_deviceId._ProductClass"] = v; + p["_deviceId._Manufacturer"] = v; + } else if (k.startsWith("Tags")) { + p["_tags"] = v; + } else if (k.startsWith("Events")) { + p["_lastInform"] = v; + p["_registered"] = v; + p["_lastBoot"] = v; + p["_lastBootstrap"] = v; + } else { + p[k] = v; + } + + return p; +} + +function processDeviceSort(sort) { + if (!sort) return sort; + const s = {}; + for (const [k, v] of Object.entries(sort)) + if (k === "DeviceID.ID") s["_id"] = v; + else if (k === "Events.Inform") s["_lastInform"] = v; + else s[k] = v; + + return s; +} + +function flattenDevice(device) { + function recursive(input, root, output, timestamp) { + for (let [name, tree] of Object.entries(input)) { + if (root.length === 0) + if (name === "_lastInform") { + output["Events.Inform"] = { + value: [Date.parse(tree), "xsd:dateTime"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + } else if (name === "_registered") { + output["Events.Registered"] = { + value: [Date.parse(tree), "xsd:dateTime"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + } else if (name === "_lastBoot") { + output["Events.1_BOOT"] = { + value: [Date.parse(tree), "xsd:dateTime"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + } else if (name === "_lastBootstrap") { + output["Events.0_BOOTSTRAP"] = { + value: [Date.parse(tree), "xsd:dateTime"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + } else if (name === "_id") { + output["DeviceID.ID"] = { + value: [tree, "xsd:string"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + } else if (name === "_deviceId") { + output["DeviceID.Manufacturer"] = { + value: [tree["_Manufacturer"], "xsd:string"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + output["DeviceID.OUI"] = { + value: [tree["_OUI"], "xsd:string"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + output["DeviceID.ProductClass"] = { + value: [tree["_ProductClass"], "xsd:string"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + output["DeviceID.SerialNumber"] = { + value: [tree["_SerialNumber"], "xsd:string"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + } else if (name === "_tags") { + output["Tags"] = { + writable: true, + writableTimestamp: timestamp, + object: true, + objectTimestamp: timestamp + }; + + for (let t of tree) + output[`Tags.${t}`] = { + value: [true, "xsd:boolean"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + } + + if (name.startsWith("_")) continue; + + let childrenTimestamp = timestamp; + + if (root.length === 0) childrenTimestamp = +(input["_timestamp"] || 1); + else if (+input["_timestamp"] > timestamp) + childrenTimestamp = +input["_timestamp"]; + + const attrs = {}; + if (tree["_value"] != null) { + attrs["value"] = [tree["_value"], tree["_type"]]; + attrs["valueTimestamp"] = +(tree["_timestamp"] || childrenTimestamp); + attrs["object"] = false; + attrs["objectTimestamp"] = childrenTimestamp; + } else if (tree["_object"] != null) { + attrs["object"] = tree["_object"]; + attrs["objectTimestamp"] = childrenTimestamp; + } + + if (tree["_writable"] != null) { + attrs["writable"] = tree["_writable"]; + attrs["writableTimestamp"] = childrenTimestamp; + } + + let r = root.concat(name); + output[r.join(".")] = attrs; + + if (attrs["object"]) recursive(tree, r, output, childrenTimestamp); + } + } + + const newDevice = {}; + const timestamp = new Date(device["_lastInform"] || 1).getTime(); + recursive(device, [], newDevice, timestamp); + return newDevice; +} + +exports.processDeviceFilter = processDeviceFilter; +exports.processTasksFilter = processTasksFilter; +exports.processFaultsFilter = processFaultsFilter; +exports.filterToMongoQuery = filterToMongoQuery; +exports.processDeviceProjection = processDeviceProjection; +exports.processDeviceSort = processDeviceSort; +exports.flattenDevice = flattenDevice; From b77f98a8cb346c3acb9428b48591e965099a69b7 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 22 Jul 2018 19:54:07 -0700 Subject: [PATCH 034/106] Implement query sorting --- client/devices-page.js | 2 +- client/faults-page.js | 2 +- client/store.js | 171 ++++++++++++++++++++++++++++++--------- common/expression-cnf.js | 4 +- server/api.js | 2 + server/db.js | 14 +++- 6 files changed, 151 insertions(+), 44 deletions(-) diff --git a/client/devices-page.js b/client/devices-page.js index a508715..6ca53f4 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -288,7 +288,7 @@ const component = { let devs = store.fetch( "devices", vnode.attrs.filter == null ? true : vnode.attrs.filter, - vnode.state.showCount || 10 + { limit: vnode.state.showCount || 10 } ); let count = store.count( "devices", diff --git a/client/faults-page.js b/client/faults-page.js index 6653d8e..a80294f 100644 --- a/client/faults-page.js +++ b/client/faults-page.js @@ -160,7 +160,7 @@ const component = { let faults = store.fetch( "faults", vnode.attrs.filter == null ? true : vnode.attrs.filter, - vnode.state.showCount || 10 + { limit: vnode.state.showCount || 10 } ); let count = store.count( "faults", diff --git a/client/store.js b/client/store.js index dc2025b..6663f1e 100644 --- a/client/store.js +++ b/client/store.js @@ -13,11 +13,11 @@ let evaluateExpressionCache = new WeakMap(); const queries = { filter: new WeakMap(), - last: new WeakMap(), + bookmark: new WeakMap(), limit: new WeakMap(), + sort: new WeakMap(), fulfilled: new WeakMap(), fulfilling: new WeakSet(), - mapper: new WeakMap(), accessed: new WeakMap(), value: new WeakMap() }; @@ -39,11 +39,7 @@ class QueryResponse { get value() { queries.accessed.set(this, Date.now()); - const mapper = queries.mapper.get(this); - const value = queries.value.get(this); - - if (mapper) return mapper(queries.value.get(this)); - else return value; + return queries.value.get(this); } } @@ -69,13 +65,89 @@ function count(resourceType, filter) { return queryResponse; } -function limitFilter(resourceType, filter, last) { - if (resourceType === "devices") - return expression.and(filter, ["<=", ["PARAM", "DeviceID.ID"], last]); - else return expression.and(filter, ["<=", ["PARAM", "_id"], last]); +function limitFilter(filter, sort, bookmark) { + const sortSort = (a, b) => Math.abs(b[1]) - Math.abs(a[1]); + const arr = Object.entries(sort) + .sort(sortSort) + .reverse(); + return expression.and( + filter, + arr.reduce((cur, kv) => { + const [param, asc] = kv; + if (asc <= 0) { + if (bookmark[param] == null) + return expression.or( + ["IS NOT NULL", ["PARAM", param]], + expression.and(["IS NULL", ["PARAM", param]], cur) + ); + + let f = null; + + if (typeof bookmark[param] !== "string") + f = expression.or(f, [">=", ["PARAM", param], ""]); + + f = expression.or(f, [">", ["PARAM", param], bookmark[param]]); + return expression.or( + f, + expression.and(["=", ["PARAM", param], bookmark[param]], cur) + ); + } else { + let f = ["IS NULL", ["PARAM", param]]; + if (bookmark[param] == null) return expression.and(f, cur); + + if (typeof bookmark[param] !== "number") { + f = expression.or(f, [">=", ["PARAM", param], 0]); + f = expression.or(f, ["<", ["PARAM", param], 0]); + } + + f = expression.or(f, ["<", ["PARAM", param], bookmark[param]]); + + return expression.or( + f, + expression.and(["=", ["PARAM", param], bookmark[param]], cur) + ); + } + }, true) + ); } -function findMatches(resourceType, filter, limit) { +function compareFunction(sort) { + const sortEntries = Object.entries(sort).sort( + (a, b) => Math.abs(b[1]) - Math.abs(a[1]) + ); + + return (a, b) => { + for (const [param, asc] of sortEntries) { + let v1 = a[param]; + let v2 = b[param]; + if (v1 != null && typeof v1 === "object") + if (v1.value) v1 = v1.value[0]; + else v1 = null; + + if (v2 != null && typeof v2 === "object") + if (v2.value) v2 = v2.value[0]; + else v2 = null; + + if (v1 > v2) { + return asc; + } else if (v1 < v2) { + return asc * -1; + } else if (v1 !== v2) { + const w = { + null: 1, + number: 2, + string: 3 + }; + const w1 = w[v1 == null ? "null" : typeof v1] || 4; + const w2 = w[v2 == null ? "null" : typeof v2] || 4; + return Math.max(-1, Math.min(1, w1 - w2)) * asc; + } + } + return 0; + }; +} + +function findMatches(resourceType, filter, sort, limit) { // Handle "tag =" and "tag <>" special cases if (resourceType === "devices") filter = expressionParser.map(filter, e => { @@ -91,11 +163,11 @@ function findMatches(resourceType, filter, limit) { }); let value = []; - for (let [id, obj] of resources[resourceType].objects.entries()) + for (let obj of resources[resourceType].objects.values()) if (expression.evaluate(filter, obj, fulfillTimestamp)) - value.push(id); + value.push(obj); - value = value.sort(); + value = value.sort(compareFunction(sort)); if (limit) value = value.slice(0, limit); return value; @@ -105,9 +177,10 @@ function inferQuery(resourceType, queryResponse) { const limit = queries.limit.get(queryResponse); let filter = queries.filter.get(queryResponse); filter = unpackExpression(filter); - let last = queries.last.get(queryResponse); - if (last || !limit) { - if (last) filter = limitFilter(resourceType, filter, last); + let bookmark = queries.bookmark.get(queryResponse); + const sort = queries.sort.get(queryResponse); + if (bookmark || !limit) { + if (bookmark) filter = limitFilter(filter, sort, bookmark); if ( resources[resourceType].combinedFilter && expression.subset(filter, resources[resourceType].combinedFilter) @@ -115,12 +188,21 @@ function inferQuery(resourceType, queryResponse) { queries.fulfilled.set(queryResponse, fulfillTimestamp); } - queries.value.set(queryResponse, findMatches(resourceType, filter, limit)); + queries.value.set( + queryResponse, + findMatches(resourceType, filter, sort, limit) + ); } -function fetch(resourceType, filter, limit = 0) { +function fetch(resourceType, filter, options = {}) { const filterStr = funcCache.get(expression.stringify, filter); - const key = `${limit}:${filterStr}`; + const sort = options.sort || {}; + const limit = options.limit || 0; + if (resourceType === "devices") + sort["DeviceID.ID"] = sort["DeviceID.ID"] || 1; + else sort["_id"] = sort["_id"] || 1; + + let key = `${filterStr}:${limit}:${JSON.stringify(sort)}`; let queryResponse = resources[resourceType].fetch.get(key); if (queryResponse) return queryResponse; @@ -128,9 +210,7 @@ function fetch(resourceType, filter, limit = 0) { resources[resourceType].fetch.set(key, queryResponse); queries.filter.set(queryResponse, filter); queries.limit.set(queryResponse, limit); - queries.mapper.set(queryResponse, list => - list.map(x => resources[resourceType].objects.get(x)) - ); + queries.sort.set(queryResponse, sort); inferQuery(resourceType, queryResponse); return queryResponse; } @@ -204,6 +284,7 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { toFetchAll[resourceType] = toFetchAll[resourceType] || []; toFetchAll[resourceType].push(queryResponse); const limit = queries.limit.get(queryResponse); + const sort = queries.sort.get(queryResponse); if (limit) allPromises.push( new Promise((resolve, reject) => { @@ -219,18 +300,26 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { filter: funcCache.get(expression.stringify, filter), limit: 1, skip: limit - 1, - projection: "_id" + sort: JSON.stringify(sort), + projection: Object.keys(sort).join(",") }) }) .then(res => { - if (res.length) - if (resourceType === "devices") - queries.last.set( - queryResponse, - res[0]["DeviceID.ID"].value[0] - ); - else queries.last.set(queryResponse, res[0]["_id"]); - else queries.last.delete(queryResponse); + if (res.length) { + // Generate bookmark object + let 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); + } resolve(); }) .catch(reject); @@ -249,8 +338,10 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { toFetch = toFetch.filter(queryResponse => { let filter = queries.filter.get(queryResponse); filter = unpackExpression(filter); - let last = queries.last.get(queryResponse); - if (last) filter = limitFilter(resourceType, filter, last); + 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); if ( resources[resourceType].combinedFilter && @@ -258,7 +349,7 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { ) { queries.value.set( queryResponse, - findMatches(resourceType, filter) + findMatches(resourceType, filter, sort, limit) ); queries.fulfilled.set(queryResponse, fulfillTimestamp); queries.fulfilling.delete(queryResponse); @@ -325,12 +416,14 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { for (let queryResponse of toFetch) { let filter = queries.filter.get(queryResponse); filter = unpackExpression(filter); - let last = queries.last.get(queryResponse); - if (last) filter = limitFilter(resourceType, filter, last); + 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) + findMatches(resourceType, filter, sort, limit) ); queries.fulfilled.set(queryResponse, fulfillTimestamp); queries.fulfilling.delete(queryResponse); diff --git a/common/expression-cnf.js b/common/expression-cnf.js index d2ea2ce..27c28f9 100644 --- a/common/expression-cnf.js +++ b/common/expression-cnf.js @@ -48,7 +48,7 @@ function booleanCnf(exp) { let m = mutuallyExclusive.get(lhs); if (!m) mutuallyExclusive.set(lhs, (m = new Set())); m.add(f); - if (rhs || key !== "null") m.add(getVariable(lhs, "null")); + if (rhs || op !== "null") m.add(getVariable(lhs, "null")); } return f; } @@ -66,7 +66,7 @@ function booleanCnf(exp) { for (let i = 1; i < range.length; ++i) if ((from == null || range[i] > from) && (to == null || range[i] <= to)) { vars.push(`${lhs}:${range[i - 1]}`); - vars.push(`${lhs}:${range[i]}`); + if (range[i] !== to) vars.push(`${lhs}:${range[i]}`); vars.push(`${lhs}:${range[i - 1]}--${range[i]}`); } diff --git a/server/api.js b/server/api.js index ff472db..40479e7 100644 --- a/server/api.js +++ b/server/api.js @@ -58,6 +58,8 @@ for (let [resource, flags] of Object.entries(resources)) { filter = expression.parse(ctx.request.query.filter); if (ctx.request.query.limit) options.limit = +ctx.request.query.limit; if (ctx.request.query.skip) options.skip = +ctx.request.query.skip; + if (ctx.request.query.sort) + options.sort = JSON.parse(ctx.request.query.sort); if (ctx.request.query.projection) options.projection = ctx.request.query.projection .split(",") diff --git a/server/db.js b/server/db.js index f0fa4b8..6afed3c 100644 --- a/server/db.js +++ b/server/db.js @@ -56,7 +56,19 @@ function query(resource, filter, options, callback) { ); if (options.skip) cursor = cursor.skip(options.skip); if (options.limit) cursor = cursor.limit(options.limit); - cursor = cursor.sort({ _id: 1 }); + + if (options.sort) { + let s = Object.entries(options.sort) + .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1])) + .reduce( + (obj, [k, v]) => + Object.assign(obj, { [k]: Math.min(Math.max(v, -1), 1) }), + {} + ); + + if (resource === "devices") s = mongodbFunctions.processDeviceSort(s); + cursor = cursor.sort(s); + } if (!callback) cursor.toArray((err, docs) => { From 9fb2d843245c4665ae78808a8799f1eebe241712 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Mon, 23 Jul 2018 01:36:31 -0700 Subject: [PATCH 035/106] Use chunked encoding for search response --- server/api.js | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/server/api.js b/server/api.js index 40479e7..1774e1e 100644 --- a/server/api.js +++ b/server/api.js @@ -1,6 +1,8 @@ "use strict"; +const zlib = require("zlib"); const Router = require("koa-router"); +const PassThrough = require("stream").PassThrough; const db = require("./db"); const apiFunctions = require("./api-functions"); @@ -74,9 +76,30 @@ for (let [resource, flags] of Object.entries(resources)) { ["<", ["PARAM", "expiry"], Date.now() + 60000] ]); - let res = await db.query(resource, filter, options); + let stream; + switch (ctx.acceptsEncodings("gzip", "deflate", "identity")) { + case "gzip": + stream = zlib.createGzip(); + ctx.set("Content-Encoding", "gzip"); + break; + case "deflate": + stream = zlib.createDeflate(); + ctx.set("Content-Encoding", "deflate"); + break; + default: + stream = new PassThrough(); + break; + } - ctx.body = res; + ctx.body = stream; + ctx.type = "application/json"; + + let c = 0; + stream.write("[\n"); + await db.query(resource, filter, options, obj => { + stream.write((c++ ? "," : "") + JSON.stringify(obj) + "\n"); + }); + stream.end("]"); }); router.head(`/${resource}/:id`, async (ctx, next) => { From afd1a6169606b0666dd041b2e709505af929fa51 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Wed, 1 Aug 2018 12:22:24 -0700 Subject: [PATCH 036/106] Implement LIKE operator --- common/expression-cnf.js | 135 ++++++++++++++++++++++++++++------- common/expression-parser.js | 138 +++++++++++++++++++++++++++--------- common/expression.js | 56 ++++++++++++++- server/mongodb-functions.js | 26 +++++++ 4 files changed, 294 insertions(+), 61 deletions(-) diff --git a/common/expression-cnf.js b/common/expression-cnf.js index 27c28f9..4ed1a34 100644 --- a/common/expression-cnf.js +++ b/common/expression-cnf.js @@ -1,28 +1,17 @@ "use strict"; -function collectRanges(exp, ranges = new Map()) { - if (["AND", "OR", "NOT"].includes(exp[0])) - for (let i = 1; i < exp.length; ++i) collectRanges(exp[i], ranges); - else if ([">", ">=", "<", "<=", "=", "<>"].includes(exp[0])) - if (!Array.isArray(exp[2])) { - const key = `${JSON.stringify(exp[1])}:${typeof exp[2]}`; - let s = ranges.get(key); - if (!s) ranges.set(key, (s = new Set())); - s.add(exp[2]); - } else if (!Array.isArray(exp[1])) { - const key = `${JSON.stringify(exp[2])}:${typeof exp[1]}`; - let s = ranges.get(key); - if (!s) ranges.set(key, (s = new Set())); - s.add(exp[2]); - } - - return ranges; -} +const expressionParser = require("./expression-parser"); function or(cnf1, cnf2) { if (cnf1.length === 0) return cnf2; else if (cnf2.length === 0) return cnf1; + if (cnf1.length > cnf2.length) { + let c = cnf1; + cnf1 = cnf2; + cnf2 = c; + } + const res = []; for (let i = 0; i < cnf1.length; ++i) for (let j = i; j < cnf2.length; ++j) res.push(cnf1[i].concat(cnf2[j])); @@ -31,7 +20,39 @@ function or(cnf1, cnf2) { } function booleanCnf(exp) { - const ranges = collectRanges(exp); + const ranges = new Map(); + const likePatterns = new Map(); + expressionParser.map(exp, e => { + if (!Array.isArray(e)) return e; + if (e[0] === "LIKE" || e[0] === "NOT LIKE") { + if ( + typeof e[2] === "string" && + (e.length >= 4 || (e[3] != null && !Array.isArray(e[3]))) + ) { + const key = JSON.stringify(e[1]); + let s = likePatterns.get(key); + if (!s) likePatterns.set(key, (s = new Map())); + s.set( + `${e[3] || ""}:${e[2]}`, + expressionParser.parseLikePattern(e[2], e[3]) + ); + } + } else if ([">", ">=", "<", "<=", "=", "<>"].includes(e[0])) { + if (!Array.isArray(e[2])) { + const key = `${JSON.stringify(e[1])}:${typeof e[2]}`; + let s = ranges.get(key); + if (!s) ranges.set(key, (s = new Set())); + s.add(e[2]); + } else if (!Array.isArray(e[1])) { + const key = `${JSON.stringify(e[2])}:${typeof e[1]}`; + let s = ranges.get(key); + if (!s) ranges.set(key, (s = new Set())); + s.add(e[2]); + } + } + return e; + }); + for (let [k, v] of ranges) if (k.endsWith(":number")) ranges.set(k, Array.from(v).sort((a, b) => a - b)); @@ -59,16 +80,24 @@ function booleanCnf(exp) { const vars = []; if (from == null) vars.push(`${lhs}:--${range[0]}`); - if (to == null) vars.push(`${lhs}:${range[range.length - 1]}--`); - if (fromInclusive) vars.push(`${lhs}:${from}`); - if (toInclusive) vars.push(`${lhs}:${to}`); - for (let i = 1; i < range.length; ++i) - if ((from == null || range[i] > from) && (to == null || range[i] <= to)) { - vars.push(`${lhs}:${range[i - 1]}`); - if (range[i] !== to) vars.push(`${lhs}:${range[i]}`); + for (let i = 0; i < range.length; ++i) { + if ( + i > 0 && + (from == null || range[i] > from) && + (to == null || range[i] <= to) + ) vars.push(`${lhs}:${range[i - 1]}--${range[i]}`); - } + + if ( + (range[i] > from && range[i] < to) || + (range[i] === from && fromInclusive) || + (range[i] === to && toInclusive) + ) + vars.push(`${lhs}:${range[i]}`); + } + + if (to == null) vars.push(`${lhs}:${range[range.length - 1]}--`); return vars.map(key => { let f = expressions.get(key); @@ -82,6 +111,21 @@ function booleanCnf(exp) { }); } + function getLikeVariables(lhs, pat, esc) { + const vars = []; + let pats = likePatterns.get(lhs); + pat = pats.get(`${esc || ""}:${pat}`); + for (let p of pats.values()) + if (pat === p || likePatternIncludes(pat, p)) + vars.push(`${lhs}:like:${JSON.stringify(p)}`); + + return vars.map(key => { + let f = expressions.get(key); + if (!f) expressions.set(key, (f = expressions.size + 1)); + return f; + }); + } + const comparisonOperators = { "=": v => [v, true, v, true], ">": v => [v, false, null, null], @@ -151,6 +195,20 @@ function booleanCnf(exp) { if (negate) return [[0 - v]]; else return [[v]]; } + } else if ( + (op === "LIKE" || op === "NOT LIKE") && + !Array.isArray(clause[2]) && + clause[2] != null && + (clause.length >= 4 || clause[3] != null) + ) { + if (op === "NOT LIKE") negate = !negate; + const vars = getLikeVariables( + JSON.stringify(clause[1]), + clause[2], + clause[3] + ); + if (negate) return vars.map(x => [0 - x]); + else return [vars]; } else { const v = getVariable(JSON.stringify(clause), ""); if (negate) return [[0 - v]]; @@ -169,4 +227,27 @@ function booleanCnf(exp) { return { vars: expressions.size, clauses: cnf }; } +function likePatternIncludes(pat1, pat2, idx1 = 0, idx2 = 0) { + while (idx1 < pat1.length && idx2 < pat2.length) { + if (pat1[idx1] === "\\%") { + for (let i = idx2; i <= pat2.length; ++i) + if (likePatternIncludes(pat1, pat2, idx1 + 1, i)) return true; + return false; + } else if (pat2[idx2] === "\\%") { + if (pat1[idx1] !== "\\%") return false; + } else if (pat1[idx1] === "\\_") { + // Ignore + } else if (pat2[idx2] === "\\_") { + return false; + } else if (pat2[idx2] !== pat1[idx1]) { + return false; + } + ++idx1; + ++idx2; + } + + if (idx1 === pat1.length && idx2 === pat2.length) return true; + return false; +} + exports.booleanCnf = booleanCnf; diff --git a/common/expression-parser.js b/common/expression-parser.js index db235cb..5d4c541 100644 --- a/common/expression-parser.js +++ b/common/expression-parser.js @@ -65,6 +65,21 @@ const lang = parsimmon.createLanguage({ ) .skip(parsimmon.optWhitespace); }, + LikeOperator: function() { + return parsimmon + .alt( + parsimmon + .regexp(/like/i) + .result("LIKE") + .desc("LIKE"), + parsimmon + .regexp(/not\s+like/i) + .result("NOT LIKE") + .desc("NOT LIKE") + ) + .notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/)) + .skip(parsimmon.optWhitespace); + }, IsNullOperator: function() { return parsimmon .alt( @@ -213,12 +228,31 @@ const lang = parsimmon.createLanguage({ }, Comparison: function(r) { return parsimmon.alt( - parsimmon.seqMap(r.Parameter, r.IsNullOperator, (p, o) => [o, p]), + parsimmon.seqMap(r.ValueExpression, r.IsNullOperator, (p, o) => [o, p]), parsimmon.seqMap( r.ValueExpression, r.ComparisonOperator, r.ValueExpression, (p, o, v) => [o, p, v] + ), + parsimmon.seqMap( + r.ValueExpression, + r.LikeOperator, + r.ValueExpression.skip( + parsimmon + .regexp(/escape/i) + .result("ESCAPE") + .skip(parsimmon.whitespace) + .desc("ESCAPE") + ), + r.ValueExpression, + (a, b, c, d) => [b, a, c, d] + ), + parsimmon.seqMap( + r.ValueExpression, + r.LikeOperator, + r.ValueExpression, + (a, b, c) => [b, a, c] ) ); }, @@ -242,7 +276,7 @@ function parse(str) { return lang.Expression.tryParse(str); } -function stringify(exp) { +function stringify(exp, level = 0) { if (!Array.isArray(exp)) return JSON.stringify(exp); const opLevels = { @@ -250,11 +284,15 @@ function stringify(exp) { AND: 11, NOT: 12, "=": 20, - "<>": 21, - ">": 22, - ">=": 23, - "<": 24, - "<=": 25, + "<>": 20, + ">": 20, + ">=": 20, + "<": 20, + "<=": 20, + LIKE: 20, + "NOT LIKE": 20, + "IS NULL": 20, + "IS NOT NULL": 20, "||": 30, "+": 31, "-": 31, @@ -264,45 +302,79 @@ function stringify(exp) { const op = exp[0].toUpperCase(); + function wrap(e) { + if (opLevels[op] <= level) return `(${e})`; + else return e; + } + if (op === "FUNC") { - return `${exp[1]}(${exp - .slice(2) - .map(e => stringify(e)) - .join(", ")})`; + return wrap( + `${exp[1]}(${exp + .slice(2) + .map(e => stringify(e)) + .join(", ")})` + ); } else if (op === "PARAM") { - if (typeof exp[1] === "string") return exp[1]; + if (typeof exp[1] === "string") return wrap(exp[1]); else if (Array.isArray(exp[1]) && exp[1][0] === "||") - return exp[1] - .slice(1) - .map(p => { - if (typeof p === "string") return p; - else return `{${stringify(p)}}`; - }) - .join(""); - else return `{${stringify(exp[1])}}`; + return wrap( + exp[1] + .slice(1) + .map(p => { + if (typeof p === "string") return p; + else return `{${stringify(p)}}`; + }) + .join("") + ); + else return wrap(`{${stringify(exp[1])}}`); } else if (op === "IS NULL" || op === "IS NOT NULL") { - return `${stringify(exp[1])} ${op}`; + return wrap(`${stringify(exp[1], opLevels[op])} ${op}`); + } else if (op === "LIKE" || op === "NOT LIKE") { + if (exp[3]) + return wrap( + `${stringify(exp[1], opLevels[op])} ${op} ${stringify( + exp[2], + opLevels[op] + )} ESCAPE ${stringify(exp[3], opLevels[op])}` + ); + else + return wrap( + `${stringify(exp[1], opLevels[op])} ${op} ${stringify( + exp[2], + opLevels[op] + )}` + ); } else if (op in opLevels) { const parts = exp.slice(1).map((e, i) => { - if ( - Array.isArray(e) && - (opLevels[e[0]] < opLevels[exp[0]] || - (opLevels[e[0]] < opLevels[exp[0]] && i > 0) || - (opLevels[exp[0]] >= 20 && - opLevels[e[0]] >= 20 && - opLevels[e[0]] < 30)) - ) - return `(${stringify(e)})`; - else return stringify(e); + return stringify(e, opLevels[exp[0]] + Math.min(i - 1, 0)); }); - if (op === "NOT") return `${op} ${parts[0]}`; - else return parts.join(` ${op} `); + if (op === "NOT") return wrap(`${op} ${parts[0]}`); + else return wrap(parts.join(` ${op} `)); } else { throw new Error(`Unrecognized operator ${exp[0]}`); } } +function parseLikePattern(pat, esc) { + let chars = pat.split(""); + + for (let i = 0; i < chars.length; ++i) { + let c = chars[i]; + if (c === esc) { + chars[i] = chars[i + 1] || ""; + chars[i + 1] = ""; + } else if (c === "_") { + chars[i] = "\\_"; + } else if (c === "%") { + chars[i] = "\\%"; + while (chars[i + 1] === "%") chars[++i] = ""; + } + } + return chars.filter(c => c); +} + exports.parse = parse; exports.stringify = stringify; exports.map = map; +exports.parseLikePattern = parseLikePattern; diff --git a/common/expression.js b/common/expression.js index 1e8d84f..290ee7a 100644 --- a/common/expression.js +++ b/common/expression.js @@ -6,6 +6,8 @@ const satSolver = require("./sat-solver"); const isArray = Array.isArray; +const regExpCache = new WeakMap(); + function* permute(arr) { if (arr.length <= 1) { for (let i = 0; i < arr[0]; ++i) yield [i]; @@ -35,6 +37,14 @@ function reduce(exp, callback) { } function evaluate(exp, obj, now) { + function getRegExp(pat, esc) { + const k = `${esc || ""}:${pat}`; + let c = regExpCache.get(exp); + if (!c) regExpCache.set(exp, (c = {})); + if (!c[k]) c[k] = likePatternToRegExp(pat, esc); + return c[k]; + } + return expressionParser.map(exp, e => { if (!isArray(e)) return e; @@ -45,7 +55,7 @@ function evaluate(exp, obj, now) { if (e[2] == null) return null; if (!isArray(e[2])) return `${e[2]}`.toUpperCase(); } else if (e[1] === "LOWER") { - if (e[12] == null) return null; + if (e[2] == null) return null; if (!isArray(e[2])) return `${e[2]}`.toLowerCase(); } } else if (e[0] === "PARAM") { @@ -77,6 +87,19 @@ function evaluate(exp, obj, now) { if (isArray(e[1])) return e; else if (e[1] != null) return true; else return null; + } else if (e[0] === "LIKE") { + if (isArray(e[1]) || isArray(e[2]) || isArray(e[3])) return e; + else if (e[1] == null || e[2] == null || (e.length >= 4 && e[3] == null)) + return null; + + const r = getRegExp(e[2], e[3]); + return r.test(e[1]); + } else if (e[0] === "NOT LIKE") { + if (isArray(e[1]) || isArray(e[2]) || isArray(e[3])) return e; + else if (e[1] == null || e[2] == null || (e.length >= 4 && e[3] == null)) + return null; + const r = getRegExp(e[2], e[3]); + return !r.test(e[1]); } else if (e[0] === "=") { if (isArray(e[1]) || isArray(e[2])) return e; if (e[1] == null || e[2] == null) return null; @@ -183,6 +206,36 @@ function subset(exp1, exp2) { return !satSolver.naiveDpll(clauses, vars); } +function likePatternToRegExp(pat, esc = "", flags = "") { + const convChars = { + "-": "\\-", + "/": "\\/", + "\\": "\\/", + "^": "\\^", + $: "\\$", + "*": "\\*", + "+": "\\+", + "?": "\\?", + ".": "\\.", + "(": "\\(", + ")": "\\)", + "|": "\\|", + "[": "\\[", + "]": "\\]", + "{": "\\{", + "}": "\\}", + "\\%": ".*", + "\\_": "." + }; + let chars = expressionParser.parseLikePattern(pat, esc); + if (!chars.length) return new RegExp("", flags); + chars = chars.map(c => convChars[c] || c); + chars[0] = chars[0] === ".*" ? "" : "^" + chars[0]; + const l = chars.length - 1; + chars[l] = [".*", ""].includes(chars[l]) ? "" : chars[l] + "$"; + return new RegExp(chars.join(""), flags); +} + exports.evaluate = evaluate; exports.and = and; exports.or = or; @@ -190,3 +243,4 @@ exports.not = not; exports.subset = subset; exports.parse = expressionParser.parse; exports.stringify = expressionParser.stringify; +exports.likePatternToRegExp = likePatternToRegExp; diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index a4751b1..f0eb680 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -1,6 +1,7 @@ "use strict"; const mongodb = require("mongodb"); const expressionParser = require("../common/expression-parser"); +const expression = require("../common/expression"); function processDeviceFilter(filter) { return expressionParser.map(filter, exp => { @@ -130,6 +131,31 @@ function filterToMongoQuery(filter, negate = false, res = {}) { } else if (op === "IS NOT NULL") { const param = filter[1][1]; res[param] = { $exists: !negate }; + } else if (op === "LIKE" || op === "NOT LIKE") { + if (op === "NOT LIKE") negate = !negate; + let param; + let flags; + if (filter[1][0] === "FUNC" && filter[1][1] === "UPPER") { + if (filter[2] !== filter[2].toUpperCase()) + throw new Error( + "Cannot compare UPPER() against non upper case pattern" + ); + param = filter[1][2][1]; + flags = "i"; + } else if (filter[1][0] === "FUNC" && filter[1][1] === "LOWER") { + if (filter[2] !== filter[2].toLowerCase()) + throw new Error( + "Cannot compare LOWER() against non lower case pattern" + ); + param = filter[1][2][1]; + flags = "i"; + } else { + param = filter[1][1]; + flags = ""; + } + const r = expression.likePatternToRegExp(filter[2], filter[3], flags); + if (negate) res[param] = { $not: r }; + else res[param] = r; } else { throw new Error(`Unrecognized operator ${op}`); } From 24b047bd5efee68c6d0eed4d48152a139268fb0b Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Wed, 1 Aug 2018 14:39:53 -0700 Subject: [PATCH 037/106] Update dependencies --- .gitignore | 2 +- client/store.js | 105 +- {public => images}/favicon.png | Bin {public => images}/logo.svg | 0 package-lock.json | 7472 ++++++++++++++++++++++++++++++++ package.json | 39 +- postcss.config.js | 4 +- server/config.js | 3 +- server/db.js | 12 +- server/server.js | 4 +- webpack.config.js | 53 +- 11 files changed, 7606 insertions(+), 88 deletions(-) rename {public => images}/favicon.png (100%) rename {public => images}/logo.svg (100%) create mode 100644 package-lock.json diff --git a/.gitignore b/.gitignore index 5f77bef..a6b211b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ *~ node_modules -public \ No newline at end of file +dist \ No newline at end of file diff --git a/client/store.js b/client/store.js index 6663f1e..83b7e84 100644 --- a/client/store.js +++ b/client/store.js @@ -245,17 +245,16 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { updated = true; let filter = queries.filter.get(queryResponse); filter = unpackExpression(filter); - m - .request({ - method: "HEAD", - url: - `/api/${resourceType}/?` + - m.buildQueryString({ - filter: funcCache.get(expression.stringify, filter) - }), - extract: xhr => +xhr.getResponseHeader("x-total-count"), - background: true - }) + m.request({ + method: "HEAD", + url: + `/api/${resourceType}/?` + + m.buildQueryString({ + filter: funcCache.get(expression.stringify, filter) + }), + extract: xhr => +xhr.getResponseHeader("x-total-count"), + background: true + }) .then(c => { queries.value.set(queryResponse, c); queries.fulfilled.set(queryResponse, fulfillTimestamp); @@ -291,19 +290,18 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { updated = true; let filter = queries.filter.get(queryResponse); filter = unpackExpression(filter); - m - .request({ - method: "GET", - url: - `/api/${resourceType}/?` + - m.buildQueryString({ - filter: funcCache.get(expression.stringify, filter), - limit: 1, - skip: limit - 1, - sort: JSON.stringify(sort), - projection: Object.keys(sort).join(",") - }) - }) + m.request({ + method: "GET", + url: + `/api/${resourceType}/?` + + m.buildQueryString({ + filter: funcCache.get(expression.stringify, filter), + limit: 1, + skip: limit - 1, + sort: JSON.stringify(sort), + projection: Object.keys(sort).join(",") + }) + }) .then(res => { if (res.length) { // Generate bookmark object @@ -379,18 +377,17 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { allPromises2.push( new Promise((resolve2, reject2) => { - m - .request({ - method: "GET", - url: - `/api/${resourceType}/?` + - m.buildQueryString({ - filter: funcCache.get( - expression.stringify, - combinedFilterDiff - ) - }) - }) + m.request({ + method: "GET", + url: + `/api/${resourceType}/?` + + m.buildQueryString({ + filter: funcCache.get( + expression.stringify, + combinedFilterDiff + ) + }) + }) .then(res => { for (let r of res) { const id = @@ -453,26 +450,24 @@ function postTasks(deviceId, tasks) { } 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" - ); - let st = JSON.parse(xhr.response); - for (let [i, t] of st.entries()) { - tasks[i]._id = t._id; - tasks[i].status = t.status; - tasks[i].fault = t.fault; - } - resolve(connectionRequestStatus); + 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" + ); + let st = JSON.parse(xhr.response); + for (let [i, t] of st.entries()) { + tasks[i]._id = t._id; + tasks[i].status = t.status; + tasks[i].fault = t.fault; } - }) - .catch(reject); + resolve(connectionRequestStatus); + } + }).catch(reject); }); } diff --git a/public/favicon.png b/images/favicon.png similarity index 100% rename from public/favicon.png rename to images/favicon.png diff --git a/public/logo.svg b/images/logo.svg similarity index 100% rename from public/logo.svg rename to images/logo.svg diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cc55b65 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,7472 @@ +{ + "name": "genieacs-ui", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@mrmlnc/readdir-enhanced": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", + "integrity": "sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==", + "dev": true, + "requires": { + "call-me-maybe": "^1.0.1", + "glob-to-regexp": "^0.3.0" + } + }, + "@nodelib/fs.stat": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.0.tgz", + "integrity": "sha512-LAQ1d4OPfSJ/BMbI2DuizmYrrkD9JMaTdi2hQTlI53lQ4kRQPyZQRS4CYQ7O66bnBBnP/oYdRxbk++X0xuFU6A==", + "dev": true + }, + "@webassemblyjs/ast": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.5.13.tgz", + "integrity": "sha512-49nwvW/Hx9i+OYHg+mRhKZfAlqThr11Dqz8TsrvqGKMhdI2ijy3KBJOun2Z4770TPjrIJhR6KxChQIDaz8clDA==", + "dev": true, + "requires": { + "@webassemblyjs/helper-module-context": "1.5.13", + "@webassemblyjs/helper-wasm-bytecode": "1.5.13", + "@webassemblyjs/wast-parser": "1.5.13", + "debug": "^3.1.0", + "mamacro": "^0.0.3" + } + }, + "@webassemblyjs/floating-point-hex-parser": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.5.13.tgz", + "integrity": "sha512-vrvvB18Kh4uyghSKb0NTv+2WZx871WL2NzwMj61jcq2bXkyhRC+8Q0oD7JGVf0+5i/fKQYQSBCNMMsDMRVAMqA==", + "dev": true + }, + "@webassemblyjs/helper-api-error": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.5.13.tgz", + "integrity": "sha512-dBh2CWYqjaDlvMmRP/kudxpdh30uXjIbpkLj9HQe+qtYlwvYjPRjdQXrq1cTAAOUSMTtzqbXIxEdEZmyKfcwsg==", + "dev": true + }, + "@webassemblyjs/helper-buffer": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.5.13.tgz", + "integrity": "sha512-v7igWf1mHcpJNbn4m7e77XOAWXCDT76Xe7Is1VQFXc4K5jRcFrl9D0NrqM4XifQ0bXiuTSkTKMYqDxu5MhNljA==", + "dev": true, + "requires": { + "debug": "^3.1.0" + } + }, + "@webassemblyjs/helper-code-frame": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.5.13.tgz", + "integrity": "sha512-yN6ScQQDFCiAXnVctdVO/J5NQRbwyTbQzsGzEgXsAnrxhjp0xihh+nNHQTMrq5UhOqTb5LykpJAvEv9AT0jnAQ==", + "dev": true, + "requires": { + "@webassemblyjs/wast-printer": "1.5.13" + } + }, + "@webassemblyjs/helper-fsm": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-fsm/-/helper-fsm-1.5.13.tgz", + "integrity": "sha512-hSIKzbXjVMRvy3Jzhgu+vDd/aswJ+UMEnLRCkZDdknZO3Z9e6rp1DAs0tdLItjCFqkz9+0BeOPK/mk3eYvVzZg==", + "dev": true + }, + "@webassemblyjs/helper-module-context": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-module-context/-/helper-module-context-1.5.13.tgz", + "integrity": "sha512-zxJXULGPLB7r+k+wIlvGlXpT4CYppRz8fLUM/xobGHc9Z3T6qlmJD9ySJ2jknuktuuiR9AjnNpKYDECyaiX+QQ==", + "dev": true, + "requires": { + "debug": "^3.1.0", + "mamacro": "^0.0.3" + } + }, + "@webassemblyjs/helper-wasm-bytecode": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.5.13.tgz", + "integrity": "sha512-0n3SoNGLvbJIZPhtMFq0XmmnA/YmQBXaZKQZcW8maGKwLpVcgjNrxpFZHEOLKjXJYVN5Il8vSfG7nRX50Zn+aw==", + "dev": true + }, + "@webassemblyjs/helper-wasm-section": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.5.13.tgz", + "integrity": "sha512-IJ/goicOZ5TT1axZFSnlAtz4m8KEjYr12BNOANAwGFPKXM4byEDaMNXYowHMG0yKV9a397eU/NlibFaLwr1fbw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.5.13", + "@webassemblyjs/helper-buffer": "1.5.13", + "@webassemblyjs/helper-wasm-bytecode": "1.5.13", + "@webassemblyjs/wasm-gen": "1.5.13", + "debug": "^3.1.0" + } + }, + "@webassemblyjs/ieee754": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.5.13.tgz", + "integrity": "sha512-TseswvXEPpG5TCBKoLx9tT7+/GMACjC1ruo09j46ULRZWYm8XHpDWaosOjTnI7kr4SRJFzA6MWoUkAB+YCGKKg==", + "dev": true, + "requires": { + "ieee754": "^1.1.11" + } + }, + "@webassemblyjs/leb128": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.5.13.tgz", + "integrity": "sha512-0NRMxrL+GG3eISGZBmLBLAVjphbN8Si15s7jzThaw1UE9e5BY1oH49/+MA1xBzxpf1OW5sf9OrPDOclk9wj2yg==", + "dev": true, + "requires": { + "long": "4.0.0" + }, + "dependencies": { + "long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "dev": true + } + } + }, + "@webassemblyjs/utf8": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.5.13.tgz", + "integrity": "sha512-Ve1ilU2N48Ew0lVGB8FqY7V7hXjaC4+PeZM+vDYxEd+R2iQ0q+Wb3Rw8v0Ri0+rxhoz6gVGsnQNb4FjRiEH/Ng==", + "dev": true + }, + "@webassemblyjs/wasm-edit": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.5.13.tgz", + "integrity": "sha512-X7ZNW4+Hga4f2NmqENnHke2V/mGYK/xnybJSIXImt1ulxbCOEs/A+ZK/Km2jgihjyVxp/0z0hwIcxC6PrkWtgw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.5.13", + "@webassemblyjs/helper-buffer": "1.5.13", + "@webassemblyjs/helper-wasm-bytecode": "1.5.13", + "@webassemblyjs/helper-wasm-section": "1.5.13", + "@webassemblyjs/wasm-gen": "1.5.13", + "@webassemblyjs/wasm-opt": "1.5.13", + "@webassemblyjs/wasm-parser": "1.5.13", + "@webassemblyjs/wast-printer": "1.5.13", + "debug": "^3.1.0" + } + }, + "@webassemblyjs/wasm-gen": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.5.13.tgz", + "integrity": "sha512-yfv94Se8R73zmr8GAYzezFHc3lDwE/lBXQddSiIZEKZFuqy7yWtm3KMwA1uGbv5G1WphimJxboXHR80IgX1hQA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.5.13", + "@webassemblyjs/helper-wasm-bytecode": "1.5.13", + "@webassemblyjs/ieee754": "1.5.13", + "@webassemblyjs/leb128": "1.5.13", + "@webassemblyjs/utf8": "1.5.13" + } + }, + "@webassemblyjs/wasm-opt": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.5.13.tgz", + "integrity": "sha512-IkXSkgzVhQ0QYAdIayuCWMmXSYx0dHGU8Ah/AxJf1gBvstMWVnzJnBwLsXLyD87VSBIcsqkmZ28dVb0mOC3oBg==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.5.13", + "@webassemblyjs/helper-buffer": "1.5.13", + "@webassemblyjs/wasm-gen": "1.5.13", + "@webassemblyjs/wasm-parser": "1.5.13", + "debug": "^3.1.0" + } + }, + "@webassemblyjs/wasm-parser": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.5.13.tgz", + "integrity": "sha512-XnYoIcu2iqq8/LrtmdnN3T+bRjqYFjRHqWbqK3osD/0r/Fcv4d9ecRzjVtC29ENEuNTK4mQ9yyxCBCbK8S/cpg==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.5.13", + "@webassemblyjs/helper-api-error": "1.5.13", + "@webassemblyjs/helper-wasm-bytecode": "1.5.13", + "@webassemblyjs/ieee754": "1.5.13", + "@webassemblyjs/leb128": "1.5.13", + "@webassemblyjs/utf8": "1.5.13" + } + }, + "@webassemblyjs/wast-parser": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-parser/-/wast-parser-1.5.13.tgz", + "integrity": "sha512-Lbz65T0LQ1LgzKiUytl34CwuhMNhaCLgrh0JW4rJBN6INnBB8NMwUfQM+FxTnLY9qJ+lHJL/gCM5xYhB9oWi4A==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.5.13", + "@webassemblyjs/floating-point-hex-parser": "1.5.13", + "@webassemblyjs/helper-api-error": "1.5.13", + "@webassemblyjs/helper-code-frame": "1.5.13", + "@webassemblyjs/helper-fsm": "1.5.13", + "long": "^3.2.0", + "mamacro": "^0.0.3" + } + }, + "@webassemblyjs/wast-printer": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.5.13.tgz", + "integrity": "sha512-QcwogrdqcBh8Z+eUF8SG+ag5iwQSXxQJELBEHmLkk790wgQgnIMmntT2sMAMw53GiFNckArf5X0bsCA44j3lWQ==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.5.13", + "@webassemblyjs/wast-parser": "1.5.13", + "long": "^3.2.0" + } + }, + "accepts": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.5.tgz", + "integrity": "sha1-63d99gEXI6OxTopywIBcjoZ0a9I=", + "requires": { + "mime-types": "~2.1.18", + "negotiator": "0.6.1" + } + }, + "acorn": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.1.tgz", + "integrity": "sha512-d+nbxBUGKg7Arpsvbnlq61mc12ek3EY8EQldM3GPAhWJ1UVxC6TDGbIvUMNU6obBX3i1+ptCIzV4vq0gFPEGVQ==", + "dev": true + }, + "acorn-dynamic-import": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/acorn-dynamic-import/-/acorn-dynamic-import-3.0.0.tgz", + "integrity": "sha512-zVWV8Z8lislJoOKKqdNMOB+s6+XV5WERty8MnKBeFgwA+19XJjJHs2RP5dzM57FftIs+jQnRToLiWazKr6sSWg==", + "dev": true, + "requires": { + "acorn": "^5.0.0" + } + }, + "acorn-jsx": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-4.1.1.tgz", + "integrity": "sha512-JY+iV6r+cO21KtntVvFkD+iqjtdpRUpGqKWgfkCdZq1R+kbreEl8EcdcJR4SmiIgsIQT33s6QzheQ9a275Q8xw==", + "dev": true, + "requires": { + "acorn": "^5.0.3" + } + }, + "ajv": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.5.2.tgz", + "integrity": "sha512-hOs7GfvI6tUI1LfZddH82ky6mOMyTuY0mk7kE2pWpmhhUSkumzaTO5vbVwij39MdwPQWCV4Zv57Eo06NtL/GVA==", + "dev": true, + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.1" + } + }, + "ajv-keywords": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.2.0.tgz", + "integrity": "sha1-6GuBnGAs+IIa1jdBNpjx3sAhhHo=", + "dev": true + }, + "alphanum-sort": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz", + "integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=", + "dev": true + }, + "ansi-escapes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.1.0.tgz", + "integrity": "sha512-UgAb8H9D41AQnu/PbWlCofQVcnV4Gs2bBJi9eZPxfU/hgglFh3SMDMENRIqdr7H6XFnXdoknctFByVsCOotTVw==", + "dev": true + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=" + }, + "anymatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", + "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", + "dev": true, + "requires": { + "micromatch": "^3.1.4", + "normalize-path": "^2.1.1" + } + }, + "aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "dev": true + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "dev": true + }, + "arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", + "dev": true + }, + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", + "dev": true, + "requires": { + "array-uniq": "^1.0.1" + } + }, + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", + "dev": true + }, + "asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "dev": true, + "requires": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "assert": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/assert/-/assert-1.4.1.tgz", + "integrity": "sha1-mZEtWRg2tab1s0XA8H7vwI/GXZE=", + "dev": true, + "requires": { + "util": "0.10.3" + }, + "dependencies": { + "inherits": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=", + "dev": true + }, + "util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", + "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", + "dev": true, + "requires": { + "inherits": "2.0.1" + } + } + } + }, + "assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", + "dev": true + }, + "async-each": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.1.tgz", + "integrity": "sha1-GdOGodntxufByF04iu28xW0zYC0=", + "dev": true + }, + "atob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.1.tgz", + "integrity": "sha1-ri1acpR38onWDdf5amMUoi3Wwio=", + "dev": true + }, + "autoprefixer": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-7.2.6.tgz", + "integrity": "sha512-Iq8TRIB+/9eQ8rbGhcP7ct5cYb/3qjNYAR2SnzLCEcwF6rvVOax8+9+fccgXk4bEhQGjOZd5TLhsksmAdsbGqQ==", + "dev": true, + "requires": { + "browserslist": "^2.11.3", + "caniuse-lite": "^1.0.30000805", + "normalize-range": "^0.1.2", + "num2fraction": "^1.2.2", + "postcss": "^6.0.17", + "postcss-value-parser": "^3.2.3" + }, + "dependencies": { + "browserslist": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-2.11.3.tgz", + "integrity": "sha512-yWu5cXT7Av6mVwzWc8lMsJMHWn4xyjSuGYi4IozbVTLUOEYPSagUB8kiMDUHA1fS3zjr8nkxkn9jdvug4BBRmA==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30000792", + "electron-to-chromium": "^1.3.30" + } + } + } + }, + "babel-code-frame": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", + "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", + "dev": true, + "requires": { + "chalk": "^1.1.3", + "esutils": "^2.0.2", + "js-tokens": "^3.0.2" + }, + "dependencies": { + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "dev": true, + "requires": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + } + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "base": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "dev": true, + "requires": { + "cache-base": "^1.0.1", + "class-utils": "^0.3.5", + "component-emitter": "^1.2.1", + "define-property": "^1.0.0", + "isobject": "^3.0.1", + "mixin-deep": "^1.2.0", + "pascalcase": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "base64-js": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", + "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==", + "dev": true + }, + "big.js": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-3.2.0.tgz", + "integrity": "sha512-+hN/Zh2D08Mx65pZ/4g5bsmNiZUuChDiQfTUQ7qJr4/kuopCr88xZsAXv6mBoZEsUI4OuGHlX59qE94K2mMW8Q==", + "dev": true + }, + "binary-extensions": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.11.0.tgz", + "integrity": "sha1-RqoXUftqL5PuXmibsQh9SxTGwgU=", + "dev": true + }, + "bluebird": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz", + "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==", + "dev": true + }, + "bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", + "dev": true + }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=", + "dev": true + }, + "browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "dev": true, + "requires": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", + "dev": true, + "requires": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", + "dev": true, + "requires": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "browserify-rsa": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", + "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "randombytes": "^2.0.1" + } + }, + "browserify-sign": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.0.4.tgz", + "integrity": "sha1-qk62jl17ZYuqa/alfmMMvXqT0pg=", + "dev": true, + "requires": { + "bn.js": "^4.1.1", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.2", + "elliptic": "^6.0.0", + "inherits": "^2.0.1", + "parse-asn1": "^5.0.0" + } + }, + "browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "dev": true, + "requires": { + "pako": "~1.0.5" + } + }, + "browserslist": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.0.1.tgz", + "integrity": "sha512-QqiiIWchEIkney3wY53/huI7ZErouNAdvOkjorUALAwRcu3tEwOV3Sh6He0DnP38mz1JjBpCBb50jQBmaYuHPw==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30000865", + "electron-to-chromium": "^1.3.52", + "node-releases": "^1.0.0-alpha.10" + } + }, + "bson": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.0.9.tgz", + "integrity": "sha512-IQX9/h7WdMBIW/q/++tGd+emQr0XMdeZ6icnT/74Xk9fnabWn+gZgpE+9V+gujL3hhJOoNrnDVY7tWdzc7NUTg==" + }, + "buffer": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", + "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", + "dev": true, + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + } + } + }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", + "dev": true + }, + "builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=", + "dev": true + }, + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" + }, + "cacache": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-10.0.4.tgz", + "integrity": "sha512-Dph0MzuH+rTQzGPNT9fAnrPmMmjKfST6trxJeK7NQuHRaVw24VzPRWTmg9MpcwOVQZO0E1FBICUlFeNaKPIfHA==", + "dev": true, + "requires": { + "bluebird": "^3.5.1", + "chownr": "^1.0.1", + "glob": "^7.1.2", + "graceful-fs": "^4.1.11", + "lru-cache": "^4.1.1", + "mississippi": "^2.0.0", + "mkdirp": "^0.5.1", + "move-concurrently": "^1.0.1", + "promise-inflight": "^1.0.1", + "rimraf": "^2.6.2", + "ssri": "^5.2.4", + "unique-filename": "^1.1.0", + "y18n": "^4.0.0" + } + }, + "cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "dev": true, + "requires": { + "collection-visit": "^1.0.0", + "component-emitter": "^1.2.1", + "get-value": "^2.0.6", + "has-value": "^1.0.0", + "isobject": "^3.0.1", + "set-value": "^2.0.0", + "to-object-path": "^0.3.0", + "union-value": "^1.0.0", + "unset-value": "^1.0.0" + } + }, + "cache-content-type": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", + "integrity": "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==", + "requires": { + "mime-types": "^2.1.18", + "ylru": "^1.2.0" + } + }, + "call-me-maybe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz", + "integrity": "sha1-JtII6onje1y95gJQoV8DHBak1ms=", + "dev": true + }, + "caller-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-0.1.0.tgz", + "integrity": "sha1-lAhe9jWB7NPaqSREqP6U6CV3dR8=", + "dev": true, + "requires": { + "callsites": "^0.2.0" + } + }, + "callsites": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-0.2.0.tgz", + "integrity": "sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo=", + "dev": true + }, + "camelcase": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", + "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=", + "dev": true + }, + "caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "caniuse-lite": { + "version": "1.0.30000865", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000865.tgz", + "integrity": "sha512-vs79o1mOSKRGv/1pSkp4EXgl4ZviWeYReXw60XfacPU64uQWZwJT6vZNmxRF9O+6zu71sJwMxLK5JXxbzuVrLw==", + "dev": true + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "chardet": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz", + "integrity": "sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I=", + "dev": true + }, + "chokidar": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.0.4.tgz", + "integrity": "sha512-z9n7yt9rOvIJrMhvDtDictKrkFHeihkNl6uWMmZlmL6tJtX9Cs+87oK+teBx+JIgzvbX3yZHT3eF8vpbDxHJXQ==", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.0", + "braces": "^2.3.0", + "fsevents": "^1.2.2", + "glob-parent": "^3.1.0", + "inherits": "^2.0.1", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "lodash.debounce": "^4.0.8", + "normalize-path": "^2.1.1", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.0.0", + "upath": "^1.0.5" + } + }, + "chownr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.0.1.tgz", + "integrity": "sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE=", + "dev": true + }, + "chrome-trace-event": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.0.tgz", + "integrity": "sha512-xDbVgyfDTT2piup/h8dK/y4QZfJRSa73bw1WZ8b4XM1o7fsFubUVGYcE+1ANtOzJJELGpYoG2961z0Z6OAld9A==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "cipher-base": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", + "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "circular-json": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz", + "integrity": "sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A==", + "dev": true + }, + "class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "clean-webpack-plugin": { + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/clean-webpack-plugin/-/clean-webpack-plugin-0.1.19.tgz", + "integrity": "sha512-M1Li5yLHECcN2MahoreuODul5LkjohJGFxLPTjl3j1ttKrF5rgjZET1SJduuqxLAuT1gAPOdkhg03qcaaU1KeA==", + "dev": true, + "requires": { + "rimraf": "^2.6.1" + } + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dev": true, + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "cli-width": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz", + "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", + "dev": true + }, + "cliui": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-4.1.0.tgz", + "integrity": "sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ==", + "dev": true, + "requires": { + "string-width": "^2.1.1", + "strip-ansi": "^4.0.0", + "wrap-ansi": "^2.0.0" + } + }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "dev": true + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" + }, + "co-body": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/co-body/-/co-body-6.0.0.tgz", + "integrity": "sha512-9ZIcixguuuKIptnY8yemEOuhb71L/lLf+Rl5JfJEUiDNJk0e02MBt7BPxR2GEh5mw8dPthQYR4jPI/BnS1MQgw==", + "requires": { + "inflation": "^2.0.0", + "qs": "^6.5.2", + "raw-body": "^2.3.3", + "type-is": "^1.6.16" + } + }, + "coa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/coa/-/coa-2.0.1.tgz", + "integrity": "sha512-5wfTTO8E2/ja4jFSxePXlG5nRu5bBtL/r1HCIpJW/lzT6yDtKl0u0Z4o/Vpz32IpKmBn7HerheEZQgA9N2DarQ==", + "dev": true, + "requires": { + "q": "^1.1.2" + } + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "dev": true + }, + "collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", + "dev": true, + "requires": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + } + }, + "color": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/color/-/color-3.0.0.tgz", + "integrity": "sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w==", + "dev": true, + "requires": { + "color-convert": "^1.9.1", + "color-string": "^1.5.2" + } + }, + "color-convert": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.2.tgz", + "integrity": "sha512-3NUJZdhMhcdPn8vJ9v2UQJoH0qqoGUkYTgFEPZaPjEtwmmKUfNV46zZmgB2M5M4DCEQHMaCfWHCxiBflLm04Tg==", + "dev": true, + "requires": { + "color-name": "1.1.1" + } + }, + "color-name": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.1.tgz", + "integrity": "sha1-SxQVMEz1ACjqgWQ2Q72C6gWANok=", + "dev": true + }, + "color-string": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.3.tgz", + "integrity": "sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==", + "dev": true, + "requires": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "colors": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz", + "integrity": "sha1-FopHAXVran9RoSzgyXv6KMCE7WM=", + "dev": true + }, + "commander": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.13.0.tgz", + "integrity": "sha512-MVuS359B+YzaWqjCL/c+22gfryv+mCBPHAv3zyVI2GN8EY6IRP8VwtasXn8jyyhvvq84R4ImN1OKRtcbIasjYA==", + "dev": true + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", + "dev": true + }, + "component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=", + "dev": true + }, + "compressible": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.14.tgz", + "integrity": "sha1-MmxfUH+7BV9UEWeCuWmoG2einac=", + "requires": { + "mime-db": ">= 1.34.0 < 2" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "console-browserify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", + "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=", + "dev": true, + "requires": { + "date-now": "^0.1.4" + } + }, + "constants-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", + "integrity": "sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=", + "dev": true + }, + "content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=" + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "cookies": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.7.1.tgz", + "integrity": "sha1-fIphX1SBxhq58WyDNzG8uPZjuZs=", + "requires": { + "depd": "~1.1.1", + "keygrip": "~1.0.2" + } + }, + "copy-concurrently": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz", + "integrity": "sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==", + "dev": true, + "requires": { + "aproba": "^1.1.1", + "fs-write-stream-atomic": "^1.0.8", + "iferr": "^0.1.5", + "mkdirp": "^0.5.1", + "rimraf": "^2.5.4", + "run-queue": "^1.0.0" + } + }, + "copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", + "dev": true + }, + "copy-to": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/copy-to/-/copy-to-2.0.1.tgz", + "integrity": "sha1-JoD7uAaKSNCGVrYJgJK9r8kG9KU=" + }, + "copy-webpack-plugin": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-4.5.2.tgz", + "integrity": "sha512-zmC33E8FFSq3AbflTvqvPvBo621H36Afsxlui91d+QyZxPIuXghfnTsa1CuqiAaCPgJoSUWfTFbKJnadZpKEbQ==", + "dev": true, + "requires": { + "cacache": "^10.0.4", + "find-cache-dir": "^1.0.0", + "globby": "^7.1.1", + "is-glob": "^4.0.0", + "loader-utils": "^1.1.0", + "minimatch": "^3.0.4", + "p-limit": "^1.0.0", + "serialize-javascript": "^1.4.0" + } + }, + "core-js": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.7.tgz", + "integrity": "sha512-RszJCAxg/PP6uzXVXL6BsxSXx/B05oJAQ2vkJRjyjrEcNVycaqOmNb5OTxZPE3xa5gwZduqza6L9JOCenh/Ecw==", + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "cosmiconfig": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.0.5.tgz", + "integrity": "sha512-94j37OtvxS5w7qr7Ta6dt67tWdnOxigBVN4VnSxNXFez9o18PGQ0D33SchKP17r9LAcWVTYV72G6vDayAUBFIg==", + "dev": true, + "requires": { + "is-directory": "^0.3.1", + "js-yaml": "^3.9.0", + "parse-json": "^4.0.0" + } + }, + "create-ecdh": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz", + "integrity": "sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "elliptic": "^6.0.0" + } + }, + "create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "crypto-browserify": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", + "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==", + "dev": true, + "requires": { + "browserify-cipher": "^1.0.0", + "browserify-sign": "^4.0.0", + "create-ecdh": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.0", + "diffie-hellman": "^5.0.0", + "inherits": "^2.0.1", + "pbkdf2": "^3.0.3", + "public-encrypt": "^4.0.0", + "randombytes": "^2.0.0", + "randomfill": "^1.0.3" + } + }, + "css-color-function": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/css-color-function/-/css-color-function-1.3.3.tgz", + "integrity": "sha1-jtJMLAIFBzM5+voAS8jBQfzLKC4=", + "dev": true, + "requires": { + "balanced-match": "0.1.0", + "color": "^0.11.0", + "debug": "^3.1.0", + "rgb": "~0.1.0" + }, + "dependencies": { + "balanced-match": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.1.0.tgz", + "integrity": "sha1-tQS9BYabOSWd0MXvw12EMXbczEo=", + "dev": true + }, + "color": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/color/-/color-0.11.4.tgz", + "integrity": "sha1-bXtcdPtl6EHNSHkq0e1eB7kE12Q=", + "dev": true, + "requires": { + "clone": "^1.0.2", + "color-convert": "^1.3.0", + "color-string": "^0.3.0" + } + }, + "color-string": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-0.3.0.tgz", + "integrity": "sha1-J9RvtnAlxcL6JZk7+/V55HhBuZE=", + "dev": true, + "requires": { + "color-name": "^1.0.0" + } + } + } + }, + "css-color-names": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz", + "integrity": "sha1-gIrcLnnPhHOAabZGyyDsJ762KeA=", + "dev": true + }, + "css-declaration-sorter": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-3.0.1.tgz", + "integrity": "sha512-jH4024SHZ3e0M7ann9VxpFpH3moplRXNz9ZBqvFMZqi09Yo5ARbs2wdPH8GqN9iRTlQynrbGbraNbBxBLei85Q==", + "dev": true, + "requires": { + "postcss": "^6.0.0", + "timsort": "^0.3.0" + } + }, + "css-select": { + "version": "1.3.0-rc0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.3.0-rc0.tgz", + "integrity": "sha1-b5MZaqrnN2ZuoQNqjLFKj8t6kjE=", + "dev": true, + "requires": { + "boolbase": "^1.0.0", + "css-what": "2.1", + "domutils": "1.5.1", + "nth-check": "^1.0.1" + } + }, + "css-select-base-adapter": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.0.tgz", + "integrity": "sha1-AQKz0UYw34bD65+p9UVicBBs+ZA=", + "dev": true + }, + "css-tree": { + "version": "1.0.0-alpha25", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha25.tgz", + "integrity": "sha512-XC6xLW/JqIGirnZuUWHXCHRaAjje2b3OIB0Vj5RIJo6mIi/AdJo30quQl5LxUl0gkXDIrTrFGbMlcZjyFplz1A==", + "dev": true, + "requires": { + "mdn-data": "^1.0.0", + "source-map": "^0.5.3" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "css-unit-converter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.1.tgz", + "integrity": "sha1-2bkoGtz9jO2TW9urqDeGiX9k6ZY=", + "dev": true + }, + "css-url-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/css-url-regex/-/css-url-regex-1.1.0.tgz", + "integrity": "sha1-g4NCMMyfdMRX3lnuvRVD/uuDt+w=", + "dev": true + }, + "css-what": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.0.tgz", + "integrity": "sha1-lGfQMsOM+u+58teVASUwYvh/ob0=", + "dev": true + }, + "cssnano": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-4.0.5.tgz", + "integrity": "sha512-P2O0sz/YAAzqZVsSWOrbliPCr0c6abwVNQmFZ48AgejN/GbzwEf6IVFGQAj0UKHC+crv60wUAPQocAnDmeWlkg==", + "dev": true, + "requires": { + "cosmiconfig": "^5.0.0", + "cssnano-preset-default": "^4.0.0", + "is-resolvable": "^1.0.0", + "postcss": "^6.0.0" + } + }, + "cssnano-preset-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-4.0.0.tgz", + "integrity": "sha1-wzQoe099SfstFwqS+SFGVXiOO2s=", + "dev": true, + "requires": { + "css-declaration-sorter": "^3.0.0", + "cssnano-util-raw-cache": "^4.0.0", + "postcss": "^6.0.0", + "postcss-calc": "^6.0.0", + "postcss-colormin": "^4.0.0", + "postcss-convert-values": "^4.0.0", + "postcss-discard-comments": "^4.0.0", + "postcss-discard-duplicates": "^4.0.0", + "postcss-discard-empty": "^4.0.0", + "postcss-discard-overridden": "^4.0.0", + "postcss-merge-longhand": "^4.0.0", + "postcss-merge-rules": "^4.0.0", + "postcss-minify-font-values": "^4.0.0", + "postcss-minify-gradients": "^4.0.0", + "postcss-minify-params": "^4.0.0", + "postcss-minify-selectors": "^4.0.0", + "postcss-normalize-charset": "^4.0.0", + "postcss-normalize-display-values": "^4.0.0", + "postcss-normalize-positions": "^4.0.0", + "postcss-normalize-repeat-style": "^4.0.0", + "postcss-normalize-string": "^4.0.0", + "postcss-normalize-timing-functions": "^4.0.0", + "postcss-normalize-unicode": "^4.0.0", + "postcss-normalize-url": "^4.0.0", + "postcss-normalize-whitespace": "^4.0.0", + "postcss-ordered-values": "^4.0.0", + "postcss-reduce-initial": "^4.0.0", + "postcss-reduce-transforms": "^4.0.0", + "postcss-svgo": "^4.0.0", + "postcss-unique-selectors": "^4.0.0" + } + }, + "cssnano-util-get-arguments": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cssnano-util-get-arguments/-/cssnano-util-get-arguments-4.0.0.tgz", + "integrity": "sha1-7ToIKZ8h11dBsg87gfGU7UnMFQ8=", + "dev": true + }, + "cssnano-util-get-match": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cssnano-util-get-match/-/cssnano-util-get-match-4.0.0.tgz", + "integrity": "sha1-wOTKB/U4a7F+xeUiULT1lhNlFW0=", + "dev": true + }, + "cssnano-util-raw-cache": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cssnano-util-raw-cache/-/cssnano-util-raw-cache-4.0.0.tgz", + "integrity": "sha1-vgooVuJfGF9feivMBiTii38Xmp8=", + "dev": true, + "requires": { + "postcss": "^6.0.0" + } + }, + "cssnano-util-same-parent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cssnano-util-same-parent/-/cssnano-util-same-parent-4.0.0.tgz", + "integrity": "sha1-0qPeEDmqmLxOwlAB+gUDMMKhbaw=", + "dev": true + }, + "csso": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/csso/-/csso-3.5.1.tgz", + "integrity": "sha512-vrqULLffYU1Q2tLdJvaCYbONStnfkfimRxXNaGjxMldI0C7JPBC4rB1RyjhfdZ4m1frm8pM9uRPKH3d2knZ8gg==", + "dev": true, + "requires": { + "css-tree": "1.0.0-alpha.29" + }, + "dependencies": { + "css-tree": { + "version": "1.0.0-alpha.29", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.29.tgz", + "integrity": "sha512-sRNb1XydwkW9IOci6iB2xmy8IGCj6r/fr+JWitvJ2JxQRPzN3T4AGGVWCMlVmVwM1gtgALJRmGIlWv5ppnGGkg==", + "dev": true, + "requires": { + "mdn-data": "~1.1.0", + "source-map": "^0.5.3" + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "cyclist": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-0.2.2.tgz", + "integrity": "sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA=", + "dev": true + }, + "date-now": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", + "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=", + "dev": true + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "decamelize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-2.0.0.tgz", + "integrity": "sha512-Ikpp5scV3MSYxY39ymh45ZLEecsTdv/Xj2CaQfI8RLMuwi7XvjX9H/fhraiSuU+C5w5NTDu4ZU72xNiZnurBPg==", + "dev": true, + "requires": { + "xregexp": "4.0.0" + } + }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", + "dev": true + }, + "deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=" + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, + "define-properties": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.2.tgz", + "integrity": "sha1-g6c/L+pWmJj7c3GTyPhzyvbUXJQ=", + "dev": true, + "requires": { + "foreach": "^2.0.5", + "object-keys": "^1.0.8" + } + }, + "define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "requires": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "dependencies": { + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "del": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/del/-/del-2.2.2.tgz", + "integrity": "sha1-wSyYHQZ4RshLyvhiz/kw2Qf/0ag=", + "dev": true, + "requires": { + "globby": "^5.0.0", + "is-path-cwd": "^1.0.0", + "is-path-in-cwd": "^1.0.0", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "rimraf": "^2.2.8" + }, + "dependencies": { + "globby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-5.0.0.tgz", + "integrity": "sha1-69hGZ8oNuzMLmbz8aOrCvFQ3Dg0=", + "dev": true, + "requires": { + "array-union": "^1.0.1", + "arrify": "^1.0.0", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "dependency-graph": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.7.1.tgz", + "integrity": "sha512-2s2uojwu7aq0K94DwrnJwo/mTkGiPqy2cU7z5BVXmhb564WgITZR3ruZMUIJ8Ymb5ruew244odZCR23/lZoxXg==", + "dev": true + }, + "des.js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.0.tgz", + "integrity": "sha1-wHTS4qpqipoH29YfmhXCzYPsjsw=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "dir-glob": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.0.0.tgz", + "integrity": "sha512-37qirFDz8cA5fimp9feo43fSuRo2gHwaIn6dXL8Ber1dGwUosDrGZeCCXq57WnIqE4aQ+u3eQZzsk1yOzhdwag==", + "dev": true, + "requires": { + "arrify": "^1.0.1", + "path-type": "^3.0.0" + } + }, + "doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "dom-serializer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz", + "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=", + "dev": true, + "requires": { + "domelementtype": "~1.1.1", + "entities": "~1.1.1" + }, + "dependencies": { + "domelementtype": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", + "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=", + "dev": true + } + } + }, + "domain-browser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", + "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==", + "dev": true + }, + "domelementtype": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz", + "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI=", + "dev": true + }, + "domutils": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", + "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", + "dev": true, + "requires": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "dot-prop": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-4.2.0.tgz", + "integrity": "sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ==", + "dev": true, + "requires": { + "is-obj": "^1.0.0" + } + }, + "duplexify": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.6.0.tgz", + "integrity": "sha512-fO3Di4tBKJpYTFHAxTU00BcfWMY9w24r/x21a6rZRbsD/ToUgGxsMbiGRmB7uVAXeGKXD9MwiLZa5E97EVgIRQ==", + "dev": true, + "requires": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "ecdsa-sig-formatter": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.10.tgz", + "integrity": "sha1-HFlQAPBKiJffuFAAiSoPTDOvhsM=", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "electron-to-chromium": { + "version": "1.3.55", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.55.tgz", + "integrity": "sha1-8VDhCyC3fZ1Br8yjEu/gw7Gn/c4=", + "dev": true + }, + "elliptic": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.0.tgz", + "integrity": "sha1-ysmvh2LIWDYYcAPI3+GT5eLq5d8=", + "dev": true, + "requires": { + "bn.js": "^4.4.0", + "brorand": "^1.0.1", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.0" + } + }, + "emojis-list": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz", + "integrity": "sha1-TapNnbAPmBmIDHn6RXrlsJof04k=", + "dev": true + }, + "end-of-stream": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", + "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, + "enhanced-resolve": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.1.0.tgz", + "integrity": "sha512-F/7vkyTtyc/llOIn8oWclcB25KdRaiPBpZYDgJHgh/UHtpgT2p2eldQgtQnLtUvfMKPKxbRaQM/hHkvLHt1Vng==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "memory-fs": "^0.4.0", + "tapable": "^1.0.0" + } + }, + "entities": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz", + "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA=", + "dev": true + }, + "errno": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.7.tgz", + "integrity": "sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg==", + "dev": true, + "requires": { + "prr": "~1.0.1" + } + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "error-inject": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/error-inject/-/error-inject-1.0.0.tgz", + "integrity": "sha1-4rPZG1Su1nLzCdlQ0VSFD6EdTzc=" + }, + "es-abstract": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.12.0.tgz", + "integrity": "sha512-C8Fx/0jFmV5IPoMOFPA9P9G5NtqW+4cOPit3MIuvR2t7Ag2K15EJTpxnHAYTzL+aYQJIESYeXZmDBfOBE1HcpA==", + "dev": true, + "requires": { + "es-to-primitive": "^1.1.1", + "function-bind": "^1.1.1", + "has": "^1.0.1", + "is-callable": "^1.1.3", + "is-regex": "^1.0.4" + } + }, + "es-to-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.1.1.tgz", + "integrity": "sha1-RTVSSKiJeQNLZ5Lhm7gfK3l13Q0=", + "dev": true, + "requires": { + "is-callable": "^1.1.1", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.1" + } + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "eslint": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-5.2.0.tgz", + "integrity": "sha512-zlggW1qp7/TBjwLfouRoY7eWXrXwJZFqCdIxxh0/LVB/QuuKuIMkzyUZEcDo6LBadsry5JcEMxIqd3H/66CXVg==", + "dev": true, + "requires": { + "ajv": "^6.5.0", + "babel-code-frame": "^6.26.0", + "chalk": "^2.1.0", + "cross-spawn": "^6.0.5", + "debug": "^3.1.0", + "doctrine": "^2.1.0", + "eslint-scope": "^4.0.0", + "eslint-utils": "^1.3.1", + "eslint-visitor-keys": "^1.0.0", + "espree": "^4.0.0", + "esquery": "^1.0.1", + "esutils": "^2.0.2", + "file-entry-cache": "^2.0.0", + "functional-red-black-tree": "^1.0.1", + "glob": "^7.1.2", + "globals": "^11.7.0", + "ignore": "^4.0.2", + "imurmurhash": "^0.1.4", + "inquirer": "^5.2.0", + "is-resolvable": "^1.1.0", + "js-yaml": "^3.11.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.3.0", + "lodash": "^4.17.5", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.1", + "natural-compare": "^1.4.0", + "optionator": "^0.8.2", + "path-is-inside": "^1.0.2", + "pluralize": "^7.0.0", + "progress": "^2.0.0", + "regexpp": "^1.1.0", + "require-uncached": "^1.0.3", + "semver": "^5.5.0", + "string.prototype.matchall": "^2.0.0", + "strip-ansi": "^4.0.0", + "strip-json-comments": "^2.0.1", + "table": "^4.0.3", + "text-table": "^0.2.0" + }, + "dependencies": { + "ignore": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.3.tgz", + "integrity": "sha512-Z/vAH2GGIEATQnBVXMclE2IGV6i0GyVngKThcGZ5kHgHMxLo9Ow2+XHRq1aEKEej5vOF1TPJNbvX6J/anT0M7A==", + "dev": true + } + } + }, + "eslint-scope": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.0.tgz", + "integrity": "sha512-1G6UTDi7Jc1ELFwnR58HV4fK9OQK4S6N985f166xqXxpjU6plxFISJa2Ba9KCQuFa8RCnj/lSFJbHo7UFDBnUA==", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, + "eslint-utils": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.3.1.tgz", + "integrity": "sha512-Z7YjnIldX+2XMcjr7ZkgEsOj/bREONV60qYeB/bjMAqqqZ4zxKyWX+BOUkdmRmA9riiIPVvo5x86m5elviOk0Q==", + "dev": true + }, + "eslint-visitor-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", + "integrity": "sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ==", + "dev": true + }, + "espree": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-4.0.0.tgz", + "integrity": "sha512-kapdTCt1bjmspxStVKX6huolXVV5ZfyZguY1lcfhVVZstce3bqxH9mcLzNn3/mlgW6wQ732+0fuG9v7h0ZQoKg==", + "dev": true, + "requires": { + "acorn": "^5.6.0", + "acorn-jsx": "^4.1.1" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "esquery": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.0.1.tgz", + "integrity": "sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==", + "dev": true, + "requires": { + "estraverse": "^4.0.0" + } + }, + "esrecurse": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", + "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", + "dev": true, + "requires": { + "estraverse": "^4.1.0" + } + }, + "estraverse": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz", + "integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=", + "dev": true + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", + "dev": true + }, + "events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=", + "dev": true + }, + "evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "dev": true, + "requires": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, + "execa": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", + "integrity": "sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=", + "dev": true, + "requires": { + "cross-spawn": "^5.0.1", + "get-stream": "^3.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + } + } + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "requires": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "external-editor": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-2.2.0.tgz", + "integrity": "sha512-bSn6gvGxKt+b7+6TKEv1ZycHleA7aHhRHyAqJyp5pbUFuYYNIzpZnQDk7AsYckyWdEnTeAnay0aCy2aV6iTk9A==", + "dev": true, + "requires": { + "chardet": "^0.4.0", + "iconv-lite": "^0.4.17", + "tmp": "^0.0.33" + } + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "requires": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true + }, + "fast-glob": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-2.2.2.tgz", + "integrity": "sha512-TR6zxCKftDQnUAPvkrCWdBgDq/gbqx8A3ApnBrR5rMvpp6+KMJI0Igw7fkWPgeVK0uhRXTXdvO3O+YP0CaUX2g==", + "dev": true, + "requires": { + "@mrmlnc/readdir-enhanced": "^2.2.1", + "@nodelib/fs.stat": "^1.0.1", + "glob-parent": "^3.1.0", + "is-glob": "^4.0.0", + "merge2": "^1.2.1", + "micromatch": "^3.1.10" + } + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "file-entry-cache": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-2.0.0.tgz", + "integrity": "sha1-w5KZDD5oR4PYOLjISkXYoEhFg2E=", + "dev": true, + "requires": { + "flat-cache": "^1.2.1", + "object-assign": "^4.0.1" + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "find-cache-dir": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-1.0.0.tgz", + "integrity": "sha1-kojj6ePMN0hxfTnq3hfPcfww7m8=", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^1.0.0", + "pkg-dir": "^2.0.0" + } + }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "dev": true, + "requires": { + "locate-path": "^2.0.0" + } + }, + "flat-cache": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-1.3.0.tgz", + "integrity": "sha1-0wMLMrOBVPTjt+nHCfSQ9++XxIE=", + "dev": true, + "requires": { + "circular-json": "^0.3.1", + "del": "^2.0.2", + "graceful-fs": "^4.1.2", + "write": "^0.2.1" + } + }, + "flatten": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/flatten/-/flatten-1.0.2.tgz", + "integrity": "sha1-2uRqnXj74lKSJYzB54CkHZXAN4I=", + "dev": true + }, + "flush-write-stream": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.0.3.tgz", + "integrity": "sha512-calZMC10u0FMUqoiunI2AiGIIUtUIvifNwkHhNupZH4cbNnW1Itkoh/Nf5HFYmDrwWPjrUxpkZT0KhuCq0jmGw==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.4" + } + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", + "dev": true + }, + "foreach": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", + "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=", + "dev": true + }, + "fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", + "dev": true, + "requires": { + "map-cache": "^0.2.2" + } + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "from2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + } + }, + "fs-extra": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.0.tgz", + "integrity": "sha512-EglNDLRpmaTWiD/qraZn6HREAEAHJcJOmxNEYwq6xeMKnVMAy3GUcFB+wXt2C6k4CNvB/mP1y/U3dzvKKj5OtQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "fs-write-stream-atomic": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz", + "integrity": "sha1-tH31NJPvkR33VzHnCp3tAYnbQMk=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "iferr": "^0.1.5", + "imurmurhash": "^0.1.4", + "readable-stream": "1 || 2" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "fsevents": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.4.tgz", + "integrity": "sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg==", + "dev": true, + "optional": true, + "requires": { + "nan": "^2.9.2", + "node-pre-gyp": "^0.10.0" + }, + "dependencies": { + "abbrev": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, + "dev": true + }, + "aproba": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "chownr": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "debug": { + "version": "2.6.9", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-extend": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "optional": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "detect-libc": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "fs-minipass": { + "version": "1.2.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "glob": { + "version": "7.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "iconv-lite": { + "version": "0.4.21", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safer-buffer": "^2.1.0" + } + }, + "ignore-walk": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true, + "dev": true + }, + "ini": { + "version": "1.3.5", + "bundled": true, + "dev": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "bundled": true, + "dev": true + }, + "minipass": { + "version": "2.2.4", + "bundled": true, + "dev": true, + "requires": { + "safe-buffer": "^5.1.1", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "needle": { + "version": "2.2.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "debug": "^2.1.2", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + } + }, + "node-pre-gyp": { + "version": "0.10.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.0", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.1.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4" + } + }, + "nopt": { + "version": "4.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "npm-bundled": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "npm-packlist": { + "version": "1.1.10", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1" + } + }, + "npmlog": { + "version": "4.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "osenv": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "process-nextick-args": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "rc": { + "version": "1.2.7", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "deep-extend": "^0.5.1", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rimraf": { + "version": "2.6.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "glob": "^7.0.5" + } + }, + "safe-buffer": { + "version": "5.1.1", + "bundled": true, + "dev": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "sax": { + "version": "1.2.4", + "bundled": true, + "dev": true, + "optional": true + }, + "semver": { + "version": "5.5.0", + "bundled": true, + "dev": true, + "optional": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "tar": { + "version": "4.4.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "chownr": "^1.0.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.2.4", + "minizlib": "^1.1.0", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.1", + "yallist": "^3.0.2" + } + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "wide-align": { + "version": "1.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "string-width": "^1.0.2" + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "yallist": { + "version": "3.0.2", + "bundled": true, + "dev": true + } + } + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true + }, + "get-caller-file": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", + "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", + "dev": true + }, + "get-stdin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz", + "integrity": "sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==", + "dev": true + }, + "get-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", + "dev": true + }, + "get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", + "dev": true + }, + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", + "dev": true, + "requires": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + }, + "dependencies": { + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "^2.1.0" + } + } + } + }, + "glob-to-regexp": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz", + "integrity": "sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs=", + "dev": true + }, + "global-modules-path": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/global-modules-path/-/global-modules-path-2.3.0.tgz", + "integrity": "sha512-HchvMJNYh9dGSCy8pOQ2O8u/hoXaL+0XhnrwH0RyLiSXMMTl9W3N6KUU73+JFOg5PGjtzl6VZzUQsnrpm7Szag==", + "dev": true + }, + "globals": { + "version": "11.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.7.0.tgz", + "integrity": "sha512-K8BNSPySfeShBQXsahYB/AbbWruVOTyVpgoIDnl8odPpeSfP2J5QO2oLFFdl2j7GfDCtZj2bMKar2T49itTPCg==", + "dev": true + }, + "globby": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-7.1.1.tgz", + "integrity": "sha1-+yzP+UAfhgCUXfral0QMypcrhoA=", + "dev": true, + "requires": { + "array-union": "^1.0.1", + "dir-glob": "^2.0.0", + "glob": "^7.1.2", + "ignore": "^3.3.5", + "pify": "^3.0.0", + "slash": "^1.0.0" + } + }, + "graceful-fs": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", + "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=", + "dev": true + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "has-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz", + "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=", + "dev": true + }, + "has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", + "dev": true, + "requires": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + } + }, + "has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "dependencies": { + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "hash-base": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz", + "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "hash.js": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.5.tgz", + "integrity": "sha512-eWI5HG9Np+eHV1KQhisXWwM+4EPPYe5dFX1UZZH7k/E3JzDEazVH+VGlZi6R94ZqImq+A3D1mCEtrFIfg/E7sA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "hex-color-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz", + "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==", + "dev": true + }, + "hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", + "dev": true, + "requires": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "hsl-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hsl-regex/-/hsl-regex-1.0.0.tgz", + "integrity": "sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4=", + "dev": true + }, + "hsla-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hsla-regex/-/hsla-regex-1.0.0.tgz", + "integrity": "sha1-wc56MWjIxmFAM6S194d/OyJfnDg=", + "dev": true + }, + "html-comment-regex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.1.tgz", + "integrity": "sha1-ZouTd26q5V696POtRkswekljYl4=", + "dev": true + }, + "http-assert": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.3.0.tgz", + "integrity": "sha1-oxpc+IyHPsu1eWkH1NbxMujAHko=", + "requires": { + "deep-equal": "~1.0.1", + "http-errors": "~1.6.1" + }, + "dependencies": { + "http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + } + } + } + }, + "http-errors": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.0.tgz", + "integrity": "sha512-hz3BtSHB7Z6dNWzYc+gUbWqG4dIpJedwwOhe1cvGUq5tGmcTTIRkPiAbyh/JlZx+ksSJyGJlgcHo5jGahiXnKw==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, + "https-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", + "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", + "dev": true + }, + "iconv-lite": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz", + "integrity": "sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ieee754": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.12.tgz", + "integrity": "sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA==", + "dev": true + }, + "iferr": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/iferr/-/iferr-0.1.5.tgz", + "integrity": "sha1-xg7taebY/bazEEofy8ocGS3FtQE=", + "dev": true + }, + "ignore": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz", + "integrity": "sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==", + "dev": true + }, + "import-cwd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz", + "integrity": "sha1-qmzzbnInYShcs3HsZRn1PiQ1sKk=", + "dev": true, + "requires": { + "import-from": "^2.1.0" + } + }, + "import-from": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-from/-/import-from-2.1.0.tgz", + "integrity": "sha1-M1238qev/VOqpHHUuAId7ja387E=", + "dev": true, + "requires": { + "resolve-from": "^3.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", + "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=", + "dev": true + } + } + }, + "import-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-1.0.0.tgz", + "integrity": "sha512-vAaZHieK9qjGo58agRBg+bhHX3hoTZU/Oa3GESWLz7t1U62fk63aHuDJJEteXoDeTCcPmUT+z38gkHPZkkmpmQ==", + "dev": true, + "requires": { + "pkg-dir": "^2.0.0", + "resolve-cwd": "^2.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "indexes-of": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz", + "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=", + "dev": true + }, + "indexof": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", + "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=", + "dev": true + }, + "inflation": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/inflation/-/inflation-2.0.0.tgz", + "integrity": "sha1-i0F+R8KPklpFEz2RTKH9OJEH8w8=" + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "inquirer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-5.2.0.tgz", + "integrity": "sha512-E9BmnJbAKLPGonz0HeWHtbKf+EeSP93paWO3ZYoUpq/aowXvYGjjCSuashhXPpzbArIjBbji39THkxTz9ZeEUQ==", + "dev": true, + "requires": { + "ansi-escapes": "^3.0.0", + "chalk": "^2.0.0", + "cli-cursor": "^2.1.0", + "cli-width": "^2.0.0", + "external-editor": "^2.1.0", + "figures": "^2.0.0", + "lodash": "^4.3.0", + "mute-stream": "0.0.7", + "run-async": "^2.2.0", + "rxjs": "^5.5.2", + "string-width": "^2.1.0", + "strip-ansi": "^4.0.0", + "through": "^2.3.6" + } + }, + "interpret": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz", + "integrity": "sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ=", + "dev": true + }, + "invert-kv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", + "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=", + "dev": true + }, + "is-absolute-url": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-2.1.0.tgz", + "integrity": "sha1-UFMN+4T8yap9vnhS6Do3uTufKqY=", + "dev": true + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-binary-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", + "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=", + "dev": true, + "requires": { + "binary-extensions": "^1.0.0" + } + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, + "is-callable": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz", + "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==", + "dev": true + }, + "is-color-stop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-color-stop/-/is-color-stop-1.1.0.tgz", + "integrity": "sha1-z/9HGu5N1cnhWFmPvhKWe1za00U=", + "dev": true, + "requires": { + "css-color-names": "^0.0.4", + "hex-color-regex": "^1.1.0", + "hsl-regex": "^1.0.0", + "hsla-regex": "^1.0.0", + "rgb-regex": "^1.0.1", + "rgba-regex": "^1.0.0" + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-date-object": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz", + "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=", + "dev": true + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "is-directory": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz", + "integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=", + "dev": true + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "is-generator-function": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.7.tgz", + "integrity": "sha512-YZc5EwyO4f2kWCax7oegfuSr9mFz1ZvieNYBEjmukLxgXfBUbxAWGVF7GZf0zidYtoBl3WvC07YK0wT76a+Rtw==" + }, + "is-glob": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.0.tgz", + "integrity": "sha1-lSHHaEXMJhCoUgPd8ICpWML/q8A=", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", + "dev": true + }, + "is-path-cwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz", + "integrity": "sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=", + "dev": true + }, + "is-path-in-cwd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz", + "integrity": "sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==", + "dev": true, + "requires": { + "is-path-inside": "^1.0.0" + } + }, + "is-path-inside": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz", + "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=", + "dev": true, + "requires": { + "path-is-inside": "^1.0.1" + } + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", + "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=", + "dev": true + }, + "is-regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", + "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=", + "dev": true, + "requires": { + "has": "^1.0.1" + } + }, + "is-resolvable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.1.0.tgz", + "integrity": "sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==", + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + }, + "is-svg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-3.0.0.tgz", + "integrity": "sha512-gi4iHK53LR2ujhLVVj+37Ykh9GLqYHX6JOVXbLAucaG/Cqw9xwdFOjDM2qeifLs1sF1npXXFvDu0r5HNgCMrzQ==", + "dev": true, + "requires": { + "html-comment-regex": "^1.1.0" + } + }, + "is-symbol": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.1.tgz", + "integrity": "sha1-PMWfAAJRlLarLjjbrmaJJWtmBXI=", + "dev": true + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isnumeric": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/isnumeric/-/isnumeric-0.2.0.tgz", + "integrity": "sha1-ojR7o2DeGeM9D/1ZD933dVy/LmQ=", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "js-tokens": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", + "dev": true + }, + "js-yaml": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.12.0.tgz", + "integrity": "sha512-PIt2cnwmPfL4hKNwqeiuz4bKfnzHTBv6HyVgjahA6mPLwPDzjDWrplJBMjHUFxku/N3FlmrbyPclad+I+4mJ3A==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "json5": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", + "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=", + "dev": true + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "jsonwebtoken": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.3.0.tgz", + "integrity": "sha512-oge/hvlmeJCH+iIz1DwcO7vKPkNGJHhgkspk8OH3VKlw+mbi42WtD4ig1+VXRln765vxptAv+xT26Fd3cteqag==", + "requires": { + "jws": "^3.1.5", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1" + } + }, + "jwa": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.1.6.tgz", + "integrity": "sha512-tBO/cf++BUsJkYql/kBbJroKOgHWEigTKBAjjBEmrMGYd1QMBC74Hr4Wo2zCZw6ZrVhlJPvoMrkcOnlWR/DJfw==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.10", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.1.5.tgz", + "integrity": "sha512-GsCSexFADNQUr8T5HPJvayTjvPIfoyJPtLQBwn5a4WZQchcrPMPMAWcC1AzJVRDKyD6ZPROPAxgv6rfHViO4uQ==", + "requires": { + "jwa": "^1.1.5", + "safe-buffer": "^5.0.1" + } + }, + "keygrip": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.0.2.tgz", + "integrity": "sha1-rTKXxVcGneqLz+ek+kkbdcXd65E=" + }, + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + }, + "koa": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.5.2.tgz", + "integrity": "sha512-MoVGWre9g3p35pCqXNhOT/a4trwK5CGvalIoPi7qOA2RCZaep3GCsa/G/tD9QMjQI7bmVWn3XF3SOau8RkPh6w==", + "requires": { + "accepts": "^1.3.5", + "cache-content-type": "^1.0.0", + "content-disposition": "~0.5.2", + "content-type": "^1.0.4", + "cookies": "~0.7.1", + "debug": "^3.1.0", + "delegates": "^1.0.0", + "depd": "^1.1.2", + "destroy": "^1.0.4", + "error-inject": "^1.0.0", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.3.0", + "http-errors": "^1.6.3", + "is-generator-function": "^1.0.7", + "koa-compose": "^4.1.0", + "koa-convert": "^1.2.0", + "koa-is-json": "^1.0.0", + "on-finished": "^2.3.0", + "only": "~0.0.2", + "parseurl": "^1.3.2", + "statuses": "^1.5.0", + "type-is": "^1.6.16", + "vary": "^1.1.2" + } + }, + "koa-bodyparser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/koa-bodyparser/-/koa-bodyparser-4.2.1.tgz", + "integrity": "sha512-UIjPAlMZfNYDDe+4zBaOAUKYqkwAGcIU6r2ARf1UOXPAlfennQys5IiShaVeNf7KkVBlf88f2LeLvBFvKylttw==", + "requires": { + "co-body": "^6.0.0", + "copy-to": "^2.0.1" + } + }, + "koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==" + }, + "koa-compress": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/koa-compress/-/koa-compress-3.0.0.tgz", + "integrity": "sha512-xol+LkNB1mozKJkB5Kj6nYXbJXhkLkZlXl9BsGBPjujVfZ8MsIXwU4GHRTT7TlSfUcl2DU3JtC+j6wOWcovfuQ==", + "requires": { + "bytes": "^3.0.0", + "compressible": "^2.0.0", + "koa-is-json": "^1.0.0", + "statuses": "^1.0.0" + } + }, + "koa-convert": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-1.2.0.tgz", + "integrity": "sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA=", + "requires": { + "co": "^4.6.0", + "koa-compose": "^3.0.0" + }, + "dependencies": { + "koa-compose": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-3.2.1.tgz", + "integrity": "sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec=", + "requires": { + "any-promise": "^1.1.0" + } + } + } + }, + "koa-is-json": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/koa-is-json/-/koa-is-json-1.0.0.tgz", + "integrity": "sha1-JzwH7c3Ljfaiwat9We52SRRR7BQ=" + }, + "koa-jwt": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/koa-jwt/-/koa-jwt-3.3.2.tgz", + "integrity": "sha512-Io4DeONbK5RTOhtV50+Oqk9cg5TDdPH0mS0CCnwLrFKA43deJubEiP3FjKPFo1wH5KcVQC2TY3m8zOpZmJ9KAw==", + "requires": { + "jsonwebtoken": "8.0.0", + "koa-unless": "1.0.0" + }, + "dependencies": { + "jsonwebtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.0.0.tgz", + "integrity": "sha1-eyQapnxhs9Cz5kElSppvW2b1UOg=", + "requires": { + "jws": "^3.1.4", + "lodash.includes": "^4.3.0", + "lodash.isarray": "^4.0.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.0.0", + "xtend": "^4.0.1" + } + } + } + }, + "koa-router": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/koa-router/-/koa-router-7.4.0.tgz", + "integrity": "sha512-IWhaDXeAnfDBEpWS6hkGdZ1ablgr6Q6pGdXCyK38RbzuH4LkUOpPqPw+3f8l8aTDrQmBQ7xJc0bs2yV4dzcO+g==", + "requires": { + "debug": "^3.1.0", + "http-errors": "^1.3.1", + "koa-compose": "^3.0.0", + "methods": "^1.0.1", + "path-to-regexp": "^1.1.1", + "urijs": "^1.19.0" + }, + "dependencies": { + "koa-compose": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-3.2.1.tgz", + "integrity": "sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec=", + "requires": { + "any-promise": "^1.1.0" + } + } + } + }, + "koa-send": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/koa-send/-/koa-send-5.0.0.tgz", + "integrity": "sha512-90ZotV7t0p3uN9sRwW2D484rAaKIsD8tAVtypw/aBU+ryfV+fR2xrcAwhI8Wl6WRkojLUs/cB9SBSCuIb+IanQ==", + "requires": { + "debug": "^3.1.0", + "http-errors": "^1.6.3", + "mz": "^2.7.0", + "resolve-path": "^1.4.0" + } + }, + "koa-static": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/koa-static/-/koa-static-5.0.0.tgz", + "integrity": "sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==", + "requires": { + "debug": "^3.1.0", + "koa-send": "^5.0.0" + } + }, + "koa-unless": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/koa-unless/-/koa-unless-1.0.0.tgz", + "integrity": "sha1-WqVzhLyIJWivyQrASFKj1YZYrus=" + }, + "lcid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", + "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=", + "dev": true, + "requires": { + "invert-kv": "^1.0.0" + } + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "loader-runner": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.3.0.tgz", + "integrity": "sha1-9IKuqC1UPgeSFwDVpG7yb9rGuKI=", + "dev": true + }, + "loader-utils": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.1.0.tgz", + "integrity": "sha1-yYrvSIvM7aL/teLeZG1qdUQp9c0=", + "dev": true, + "requires": { + "big.js": "^3.1.3", + "emojis-list": "^2.0.0", + "json5": "^0.5.0" + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "dev": true, + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + } + }, + "lodash": { + "version": "4.17.10", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", + "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==", + "dev": true + }, + "lodash._reinterpolate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", + "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=", + "dev": true + }, + "lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", + "dev": true + }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "lodash.isarray": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-4.0.0.tgz", + "integrity": "sha1-KspJayjEym1yZxUxNZDALm6jRAM=" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, + "lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", + "dev": true + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, + "lodash.template": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.4.0.tgz", + "integrity": "sha1-5zoDhcg1VZF0bgILmWecaQ5o+6A=", + "dev": true, + "requires": { + "lodash._reinterpolate": "~3.0.0", + "lodash.templatesettings": "^4.0.0" + } + }, + "lodash.templatesettings": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-4.1.0.tgz", + "integrity": "sha1-K01OlbpEDZFf8IvImeRVNmZxMxY=", + "dev": true, + "requires": { + "lodash._reinterpolate": "~3.0.0" + } + }, + "lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=", + "dev": true + }, + "log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "dev": true, + "requires": { + "chalk": "^2.0.1" + } + }, + "long": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", + "integrity": "sha1-2CG3E4yhy1gcFymQ7xTbIAtcR0s=", + "dev": true + }, + "lru-cache": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.3.tgz", + "integrity": "sha512-fFEhvcgzuIoJVUF8fYr5KR0YqxD238zgObTps31YdADwPPAp82a4M8TrckkWyx7ekNlf9aBcVn81cFwwXngrJA==", + "dev": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "mamacro": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/mamacro/-/mamacro-0.0.3.tgz", + "integrity": "sha512-qMEwh+UujcQ+kbz3T6V+wAmO2U8veoq2w+3wY8MquqwVA3jChfwY+Tk52GZKDfACEPjuZ7r2oJLejwpt8jtwTA==", + "dev": true + }, + "map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=", + "dev": true + }, + "map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", + "dev": true, + "requires": { + "object-visit": "^1.0.0" + } + }, + "math-expression-evaluator": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz", + "integrity": "sha1-3oGf282E3M2PrlnGrreWFbnSZqw=", + "dev": true + }, + "md5.js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.4.tgz", + "integrity": "sha1-6b296UogpawYsENA/Fdk1bCdkB0=", + "dev": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "mdn-data": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-1.1.4.tgz", + "integrity": "sha512-FSYbp3lyKjyj3E7fMl6rYvUdX0FBXaluGqlFoYESWQlyUTq8R+wp0rkFxoYFqZlHCvsUXGjyJmLQSnXToYhOSA==", + "dev": true + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "mem": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-1.1.0.tgz", + "integrity": "sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "memory-fs": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz", + "integrity": "sha1-OpoguEYlI+RHz7x+i7gO1me/xVI=", + "dev": true, + "requires": { + "errno": "^0.1.3", + "readable-stream": "^2.0.1" + } + }, + "merge2": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.2.2.tgz", + "integrity": "sha512-bgM8twH86rWni21thii6WCMQMRMmwqqdW3sGWi9IipnVAszdLXRjwDwAnyrVXo6DuP3AjRMMttZKUB48QWIFGg==", + "dev": true + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "dev": true, + "requires": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + } + }, + "mime-db": { + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.35.0.tgz", + "integrity": "sha512-JWT/IcCTsB0Io3AhWUMjRqucrHSPsSf2xKLaRldJVULioggvkJvggZ3VXNNSRkCddE6D+BUI4HEIZIA2OjwIvg==" + }, + "mime-types": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.19.tgz", + "integrity": "sha512-P1tKYHVSZ6uFo26mtnve4HQFE3koh1UWVkp8YUC+ESBHe945xWSoXuHHiGarDqcEZ+whpCDnlNw5LON0kLo+sw==", + "requires": { + "mime-db": "~1.35.0" + } + }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true + }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "mississippi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-2.0.0.tgz", + "integrity": "sha512-zHo8v+otD1J10j/tC+VNoGK9keCuByhKovAvdn74dmxJl9+mWHnx6EMsDN4lgRoMI/eYo2nchAxniIbUPb5onw==", + "dev": true, + "requires": { + "concat-stream": "^1.5.0", + "duplexify": "^3.4.2", + "end-of-stream": "^1.1.0", + "flush-write-stream": "^1.0.0", + "from2": "^2.1.0", + "parallel-transform": "^1.1.0", + "pump": "^2.0.1", + "pumpify": "^1.3.3", + "stream-each": "^1.1.0", + "through2": "^2.0.0" + } + }, + "mithril": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/mithril/-/mithril-1.1.6.tgz", + "integrity": "sha512-fWcUrQTCqu8M916rj1MFGlHaPh65rznPu6U/N2U9g81H89klDCIptSK5bnkNkC+jyi3sJIXjyGhSQjUnR8jzZA==", + "dev": true + }, + "mixin-deep": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.1.tgz", + "integrity": "sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ==", + "dev": true, + "requires": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "mongodb": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.1.1.tgz", + "integrity": "sha512-GU9oWK4pi8PC7NyGiwjFMwZyMqwGWoMEMvM0LZh7UKW/FFAqgmZKjjriD+5MEOCDUJE2dtHX93/K5UtDxO0otg==", + "requires": { + "mongodb-core": "3.1.0" + } + }, + "mongodb-core": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-3.1.0.tgz", + "integrity": "sha512-qRjG62Fu//CZhkgn0jA/k8jh5MhACIq8cOJUryH6sck87pgt+C222MSD02tsCq5zNo/B6ZFHtNodZ2qpf8E86g==", + "requires": { + "bson": "~1.0.4", + "require_optional": "^1.0.1", + "saslprep": "^1.0.0" + } + }, + "move-concurrently": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", + "integrity": "sha1-viwAX9oy4LKa8fBdfEszIUxwH5I=", + "dev": true, + "requires": { + "aproba": "^1.1.1", + "copy-concurrently": "^1.0.0", + "fs-write-stream-atomic": "^1.0.8", + "mkdirp": "^0.5.1", + "rimraf": "^2.5.4", + "run-queue": "^1.0.3" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + }, + "mute-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", + "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=", + "dev": true + }, + "mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "requires": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "nan": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz", + "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==", + "dev": true, + "optional": true + }, + "nanomatch": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", + "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + } + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "negotiator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz", + "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=" + }, + "neo-async": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.5.1.tgz", + "integrity": "sha512-3KL3fvuRkZ7s4IFOMfztb7zJp3QaVWnBeGoJlgB38XnCRPj/0tLzzLG5IB8NYOHbJ8g8UGrgZv44GLDk6CxTxA==", + "dev": true + }, + "nice-try": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.4.tgz", + "integrity": "sha512-2NpiFHqC87y/zFke0fC0spBXL3bBsoh/p5H1EFhshxjCR5+0g2d6BiXbUFz9v1sAcxsk2htp2eQnNIci2dIYcA==", + "dev": true + }, + "node-libs-browser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.1.0.tgz", + "integrity": "sha512-5AzFzdoIMb89hBGMZglEegffzgRg+ZFoUmisQ8HI4j1KDdpx13J0taNp2y9xPbur6W61gepGDDotGBVQ7mfUCg==", + "dev": true, + "requires": { + "assert": "^1.1.1", + "browserify-zlib": "^0.2.0", + "buffer": "^4.3.0", + "console-browserify": "^1.1.0", + "constants-browserify": "^1.0.0", + "crypto-browserify": "^3.11.0", + "domain-browser": "^1.1.1", + "events": "^1.0.0", + "https-browserify": "^1.0.0", + "os-browserify": "^0.3.0", + "path-browserify": "0.0.0", + "process": "^0.11.10", + "punycode": "^1.2.4", + "querystring-es3": "^0.2.0", + "readable-stream": "^2.3.3", + "stream-browserify": "^2.0.1", + "stream-http": "^2.7.2", + "string_decoder": "^1.0.0", + "timers-browserify": "^2.0.4", + "tty-browserify": "0.0.0", + "url": "^0.11.0", + "util": "^0.10.3", + "vm-browserify": "0.0.4" + }, + "dependencies": { + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + } + } + }, + "node-releases": { + "version": "1.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.0.0-alpha.10.tgz", + "integrity": "sha512-BSQrRgOfN6L/MoKIa7pRUc7dHvflCXMcqyTBvphixcSsgJTuUd24vAFONuNfVsuwTyz28S1HEc9XN6ZKylk4Hg==", + "dev": true, + "requires": { + "semver": "^5.3.0" + } + }, + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "dev": true, + "requires": { + "remove-trailing-separator": "^1.0.1" + } + }, + "normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=", + "dev": true + }, + "normalize-url": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-3.2.0.tgz", + "integrity": "sha512-WvF3Myk0NhXkG8S9bygFM4IC1KOvnVJGq0QoGeoqOYOBeinBZp5ybW3QuYbTc89lkWBMM9ZBO4QGRoc0353kKA==", + "dev": true + }, + "npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "dev": true, + "requires": { + "path-key": "^2.0.0" + } + }, + "nth-check": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.1.tgz", + "integrity": "sha1-mSms32KPwsQQmN6rgqxYDPFJquQ=", + "dev": true, + "requires": { + "boolbase": "~1.0.0" + } + }, + "num2fraction": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz", + "integrity": "sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4=", + "dev": true + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", + "dev": true, + "requires": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "object-keys": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.0.12.tgz", + "integrity": "sha512-FTMyFUm2wBcGHnH2eXmz7tC6IwlqQZ6mVZ+6dm6vZ4IQIHjs6FdNsQBuKGPuUUUY6NfJw2PshC08Tn6LzLDOag==", + "dev": true + }, + "object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", + "dev": true, + "requires": { + "isobject": "^3.0.0" + } + }, + "object.getownpropertydescriptors": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz", + "integrity": "sha1-h1jIRvW0B62rDyNuCYbxSwUcqhY=", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.5.1" + } + }, + "object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "object.values": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.0.4.tgz", + "integrity": "sha1-5STaCbT2b/Bd9FdUbscqyZ8TBpo=", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.6.1", + "function-bind": "^1.1.0", + "has": "^1.0.1" + } + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "onecolor": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/onecolor/-/onecolor-3.0.5.tgz", + "integrity": "sha1-Nu/zIgE3nv3xGA+0ReUajiQl+fY=", + "dev": true + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "only": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", + "integrity": "sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q=" + }, + "optionator": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.4", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "wordwrap": "~1.0.0" + } + }, + "os-browserify": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", + "integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=", + "dev": true + }, + "os-locale": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-2.1.0.tgz", + "integrity": "sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA==", + "dev": true, + "requires": { + "execa": "^0.7.0", + "lcid": "^1.0.0", + "mem": "^1.1.0" + } + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, + "p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "requires": { + "p-try": "^1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "dev": true, + "requires": { + "p-limit": "^1.1.0" + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", + "dev": true + }, + "pako": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.6.tgz", + "integrity": "sha512-lQe48YPsMJAig+yngZ87Lus+NF+3mtu7DVOBu6b/gHO1YpKwIj5AWjZ/TOS7i46HD/UixzWb1zeWDZfGZ3iYcg==", + "dev": true + }, + "parallel-transform": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.1.0.tgz", + "integrity": "sha1-1BDwZbBdojCB/NEPKIVMKb2jOwY=", + "dev": true, + "requires": { + "cyclist": "~0.2.2", + "inherits": "^2.0.3", + "readable-stream": "^2.1.5" + } + }, + "parse-asn1": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.1.tgz", + "integrity": "sha512-KPx7flKXg775zZpnp9SxJlz00gTd4BmJ2yJufSc44gMCRrRQ7NSzAcSJQfifuOLgW6bEi+ftrALtsgALeB2Adw==", + "dev": true, + "requires": { + "asn1.js": "^4.0.0", + "browserify-aes": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.0", + "pbkdf2": "^3.0.3" + } + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "parseurl": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.2.tgz", + "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M=" + }, + "parsimmon": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/parsimmon/-/parsimmon-1.12.0.tgz", + "integrity": "sha512-uC/BjuSfb4jfaWajKCp1mVncXXq+V1twbcYChbTxN3GM7fn+8XoHwUdvUz+PTaFtDSCRQxU8+Rnh+iMhAkVwdw==" + }, + "pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", + "dev": true + }, + "path-browserify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.0.tgz", + "integrity": "sha1-oLhwcpquIUAFt9UDLsLLuw+0RRo=", + "dev": true + }, + "path-dirname": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", + "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", + "dev": true + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", + "dev": true + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + }, + "path-parse": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz", + "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=", + "dev": true + }, + "path-to-regexp": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz", + "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=", + "requires": { + "isarray": "0.0.1" + } + }, + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pbkdf2": { + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.0.16.tgz", + "integrity": "sha512-y4CXP3thSxqf7c0qmOF+9UeOTrifiVTIM+u7NWlq+PRsHbr7r7dpCmvzrZxa96JJUNi0Y5w9VqG5ZNeCVMoDcA==", + "dev": true, + "requires": { + "create-hash": "^1.1.2", + "create-hmac": "^1.1.4", + "ripemd160": "^2.0.1", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "^2.0.0" + } + }, + "pixrem": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pixrem/-/pixrem-4.0.1.tgz", + "integrity": "sha1-LaSh3m7EQjxfw3lOkwuB1EkOxoY=", + "dev": true, + "requires": { + "browserslist": "^2.0.0", + "postcss": "^6.0.0", + "reduce-css-calc": "^1.2.7" + }, + "dependencies": { + "balanced-match": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz", + "integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg=", + "dev": true + }, + "browserslist": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-2.11.3.tgz", + "integrity": "sha512-yWu5cXT7Av6mVwzWc8lMsJMHWn4xyjSuGYi4IozbVTLUOEYPSagUB8kiMDUHA1fS3zjr8nkxkn9jdvug4BBRmA==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30000792", + "electron-to-chromium": "^1.3.30" + } + }, + "reduce-css-calc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz", + "integrity": "sha1-dHyRTgSWFKTJz7umKYca0dKSdxY=", + "dev": true, + "requires": { + "balanced-match": "^0.4.2", + "math-expression-evaluator": "^1.2.14", + "reduce-function-call": "^1.0.1" + } + } + } + }, + "pkg-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz", + "integrity": "sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=", + "dev": true, + "requires": { + "find-up": "^2.1.0" + } + }, + "pleeease-filters": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pleeease-filters/-/pleeease-filters-4.0.0.tgz", + "integrity": "sha1-ZjKy+wVkjSdY2GU4T7zteeHMrsc=", + "dev": true, + "requires": { + "onecolor": "^3.0.4", + "postcss": "^6.0.1" + } + }, + "pluralize": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-7.0.0.tgz", + "integrity": "sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow==", + "dev": true + }, + "posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", + "dev": true + }, + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "postcss-apply": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/postcss-apply/-/postcss-apply-0.8.0.tgz", + "integrity": "sha1-FOVEu7XLbxweBIhXll15rgZrE0M=", + "dev": true, + "requires": { + "babel-runtime": "^6.23.0", + "balanced-match": "^0.4.2", + "postcss": "^6.0.0" + }, + "dependencies": { + "balanced-match": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz", + "integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg=", + "dev": true + } + } + }, + "postcss-attribute-case-insensitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-2.0.0.tgz", + "integrity": "sha1-lNxCLI+QmX8WvTOjZUu77AhJY7Q=", + "dev": true, + "requires": { + "postcss": "^6.0.0", + "postcss-selector-parser": "^2.2.3" + } + }, + "postcss-calc": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-6.0.1.tgz", + "integrity": "sha1-PSQXG79udinUIqQ26/5t2VEfQzA=", + "dev": true, + "requires": { + "css-unit-converter": "^1.1.1", + "postcss": "^6.0.0", + "postcss-selector-parser": "^2.2.2", + "reduce-css-calc": "^2.0.0" + } + }, + "postcss-cli": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/postcss-cli/-/postcss-cli-6.0.0.tgz", + "integrity": "sha512-7DuxMn1Wj6dJKbjKpZXOdAc5nl5NfPXiJbg0m/+tdObPvgk1xv4+lZgNKD3jL/kCrDRPf1jgFlmq1cHh8lBR2w==", + "dev": true, + "requires": { + "chalk": "^2.1.0", + "chokidar": "^2.0.0", + "dependency-graph": "^0.7.0", + "fs-extra": "^7.0.0", + "get-stdin": "^6.0.0", + "globby": "^8.0.0", + "postcss": "^7.0.0", + "postcss-load-config": "^2.0.0", + "postcss-reporter": "^5.0.0", + "pretty-hrtime": "^1.0.3", + "read-cache": "^1.0.0", + "yargs": "^12.0.1" + }, + "dependencies": { + "globby": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-8.0.1.tgz", + "integrity": "sha512-oMrYrJERnKBLXNLVTqhm3vPEdJ/b2ZE28xN4YARiix1NOIOBPEpOUnm844K1iu/BkphCaf2WNFwMszv8Soi1pw==", + "dev": true, + "requires": { + "array-union": "^1.0.1", + "dir-glob": "^2.0.0", + "fast-glob": "^2.0.2", + "glob": "^7.1.2", + "ignore": "^3.3.5", + "pify": "^3.0.0", + "slash": "^1.0.0" + } + }, + "postcss": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.2.tgz", + "integrity": "sha512-fmaUY5370keLUTx+CnwRxtGiuFTcNBLQBqr1oE3WZ/euIYmGAo0OAgOhVJ3ByDnVmOR3PK+0V9VebzfjRIUcqw==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + } + } + }, + "postcss-color-function": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-color-function/-/postcss-color-function-4.0.1.tgz", + "integrity": "sha1-QCs/LOvD9pR+YY+2vjZU++zvZEQ=", + "dev": true, + "requires": { + "css-color-function": "~1.3.3", + "postcss": "^6.0.1", + "postcss-message-helpers": "^2.0.0", + "postcss-value-parser": "^3.3.0" + } + }, + "postcss-color-gray": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-color-gray/-/postcss-color-gray-4.1.0.tgz", + "integrity": "sha512-L4iLKQLdqChz6ZOgGb6dRxkBNw78JFYcJmBz1orHpZoeLtuhDDGegRtX9gSyfoCIM7rWZ3VNOyiqqvk83BEN+w==", + "dev": true, + "requires": { + "color": "^2.0.1", + "postcss": "^6.0.14", + "postcss-message-helpers": "^2.0.0", + "reduce-function-call": "^1.0.2" + }, + "dependencies": { + "color": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color/-/color-2.0.1.tgz", + "integrity": "sha512-ubUCVVKfT7r2w2D3qtHakj8mbmKms+tThR8gI8zEYCbUBl8/voqFGt3kgBqGwXAopgXybnkuOq+qMYCRrp4cXw==", + "dev": true, + "requires": { + "color-convert": "^1.9.1", + "color-string": "^1.5.2" + } + } + } + }, + "postcss-color-hex-alpha": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-3.0.0.tgz", + "integrity": "sha1-HlPmyKyyN5Vej9CLfs2xuLgwn5U=", + "dev": true, + "requires": { + "color": "^1.0.3", + "postcss": "^6.0.1", + "postcss-message-helpers": "^2.0.0" + }, + "dependencies": { + "color": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-1.0.3.tgz", + "integrity": "sha1-5I6DLYXxTvaU+0aIEcLVz+cptV0=", + "dev": true, + "requires": { + "color-convert": "^1.8.2", + "color-string": "^1.4.0" + } + } + } + }, + "postcss-color-hsl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postcss-color-hsl/-/postcss-color-hsl-2.0.0.tgz", + "integrity": "sha1-EnA2ZvoxBDDj8wpFTawThjF9WEQ=", + "dev": true, + "requires": { + "postcss": "^6.0.1", + "postcss-value-parser": "^3.3.0", + "units-css": "^0.4.0" + } + }, + "postcss-color-hwb": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-color-hwb/-/postcss-color-hwb-3.0.0.tgz", + "integrity": "sha1-NAKxnvTYSXVAwftQcr6YY8qVVx4=", + "dev": true, + "requires": { + "color": "^1.0.3", + "postcss": "^6.0.1", + "postcss-message-helpers": "^2.0.0", + "reduce-function-call": "^1.0.2" + }, + "dependencies": { + "color": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-1.0.3.tgz", + "integrity": "sha1-5I6DLYXxTvaU+0aIEcLVz+cptV0=", + "dev": true, + "requires": { + "color-convert": "^1.8.2", + "color-string": "^1.4.0" + } + } + } + }, + "postcss-color-rebeccapurple": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-3.1.0.tgz", + "integrity": "sha512-212hJUk9uSsbwO5ECqVjmh/iLsmiVL1xy9ce9TVf+X3cK/ZlUIlaMdoxje/YpsL9cmUH3I7io+/G2LyWx5rg1g==", + "dev": true, + "requires": { + "postcss": "^6.0.22", + "postcss-values-parser": "^1.5.0" + } + }, + "postcss-color-rgb": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postcss-color-rgb/-/postcss-color-rgb-2.0.0.tgz", + "integrity": "sha1-FFOcinExSUtILg3RzCZf9lFLUmM=", + "dev": true, + "requires": { + "postcss": "^6.0.1", + "postcss-value-parser": "^3.3.0" + } + }, + "postcss-color-rgba-fallback": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-color-rgba-fallback/-/postcss-color-rgba-fallback-3.0.0.tgz", + "integrity": "sha1-N9XJNToHoJJwkSqCYGu0Kg1wLAQ=", + "dev": true, + "requires": { + "postcss": "^6.0.6", + "postcss-value-parser": "^3.3.0", + "rgb-hex": "^2.1.0" + } + }, + "postcss-colormin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-4.0.1.tgz", + "integrity": "sha1-bxwYoBVbxpYT8v8ThD4uSuj/C74=", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "color": "^3.0.0", + "has": "^1.0.0", + "postcss": "^6.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-convert-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-4.0.0.tgz", + "integrity": "sha1-d9d9mu0dxOaVbmUcw0nVMwWHb2I=", + "dev": true, + "requires": { + "postcss": "^6.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-cssnext": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-cssnext/-/postcss-cssnext-3.1.0.tgz", + "integrity": "sha512-awPDhI4OKetcHCr560iVCoDuP6e/vn0r6EAqdWPpAavJMvkBSZ6kDpSN4b3mB3Ti57hQMunHHM8Wvx9PeuYXtA==", + "dev": true, + "requires": { + "autoprefixer": "^7.1.1", + "caniuse-api": "^2.0.0", + "chalk": "^2.0.1", + "pixrem": "^4.0.0", + "pleeease-filters": "^4.0.0", + "postcss": "^6.0.5", + "postcss-apply": "^0.8.0", + "postcss-attribute-case-insensitive": "^2.0.0", + "postcss-calc": "^6.0.0", + "postcss-color-function": "^4.0.0", + "postcss-color-gray": "^4.0.0", + "postcss-color-hex-alpha": "^3.0.0", + "postcss-color-hsl": "^2.0.0", + "postcss-color-hwb": "^3.0.0", + "postcss-color-rebeccapurple": "^3.0.0", + "postcss-color-rgb": "^2.0.0", + "postcss-color-rgba-fallback": "^3.0.0", + "postcss-custom-media": "^6.0.0", + "postcss-custom-properties": "^6.1.0", + "postcss-custom-selectors": "^4.0.1", + "postcss-font-family-system-ui": "^3.0.0", + "postcss-font-variant": "^3.0.0", + "postcss-image-set-polyfill": "^0.3.5", + "postcss-initial": "^2.0.0", + "postcss-media-minmax": "^3.0.0", + "postcss-nesting": "^4.0.1", + "postcss-pseudo-class-any-link": "^4.0.0", + "postcss-pseudoelements": "^5.0.0", + "postcss-replace-overflow-wrap": "^2.0.0", + "postcss-selector-matches": "^3.0.1", + "postcss-selector-not": "^3.0.1" + }, + "dependencies": { + "browserslist": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-2.11.3.tgz", + "integrity": "sha512-yWu5cXT7Av6mVwzWc8lMsJMHWn4xyjSuGYi4IozbVTLUOEYPSagUB8kiMDUHA1fS3zjr8nkxkn9jdvug4BBRmA==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30000792", + "electron-to-chromium": "^1.3.30" + } + }, + "caniuse-api": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-2.0.0.tgz", + "integrity": "sha1-sd21pZZrFvSNxJmERNS7xsfZ2DQ=", + "dev": true, + "requires": { + "browserslist": "^2.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + } + } + }, + "postcss-custom-media": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-6.0.0.tgz", + "integrity": "sha1-vlMnhBEOyylQRPtTlaGABushpzc=", + "dev": true, + "requires": { + "postcss": "^6.0.1" + } + }, + "postcss-custom-properties": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-6.3.1.tgz", + "integrity": "sha512-zoiwn4sCiUFbr4KcgcNZLFkR6gVQom647L+z1p/KBVHZ1OYwT87apnS42atJtx6XlX2yI7N5fjXbFixShQO2QQ==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "postcss": "^6.0.18" + } + }, + "postcss-custom-selectors": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-4.0.1.tgz", + "integrity": "sha1-eBOC+UxS5yfvXKR3bqKt9JphE4I=", + "dev": true, + "requires": { + "postcss": "^6.0.1", + "postcss-selector-matches": "^3.0.0" + } + }, + "postcss-discard-comments": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-4.0.0.tgz", + "integrity": "sha1-loSimedrPpMmPvj9KtvxocCP2I0=", + "dev": true, + "requires": { + "postcss": "^6.0.0" + } + }, + "postcss-discard-duplicates": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-4.0.0.tgz", + "integrity": "sha1-QvPCZ/hfqQngQsNXZ+z9Zcsr1yw=", + "dev": true, + "requires": { + "postcss": "^6.0.0" + } + }, + "postcss-discard-empty": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-4.0.0.tgz", + "integrity": "sha1-VeGKWcdBKOOMfSgEvPpAVmEfuX8=", + "dev": true, + "requires": { + "postcss": "^6.0.0" + } + }, + "postcss-discard-overridden": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-4.0.0.tgz", + "integrity": "sha1-Sgv4WXh4TPH4HtLBwf2dlkodofo=", + "dev": true, + "requires": { + "postcss": "^6.0.0" + } + }, + "postcss-font-family-system-ui": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-font-family-system-ui/-/postcss-font-family-system-ui-3.0.0.tgz", + "integrity": "sha512-58G/hTxMSSKlIRpcPUjlyo6hV2MEzvcVO2m4L/T7Bb2fJTG4DYYfQjQeRvuimKQh1V1sOzCIz99g+H2aFNtlQw==", + "dev": true, + "requires": { + "postcss": "^6.0" + } + }, + "postcss-font-variant": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-3.0.0.tgz", + "integrity": "sha1-CMzIj2BQuoLtjvLMdsDGprQfGD4=", + "dev": true, + "requires": { + "postcss": "^6.0.1" + } + }, + "postcss-image-set-polyfill": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/postcss-image-set-polyfill/-/postcss-image-set-polyfill-0.3.5.tgz", + "integrity": "sha1-Dxk0E3AM8fgr05Bm7wFtZaShgYE=", + "dev": true, + "requires": { + "postcss": "^6.0.1", + "postcss-media-query-parser": "^0.2.3" + } + }, + "postcss-import": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-11.1.0.tgz", + "integrity": "sha512-5l327iI75POonjxkXgdRCUS+AlzAdBx4pOvMEhTKTCjb1p8IEeVR9yx3cPbmN7LIWJLbfnIXxAhoB4jpD0c/Cw==", + "dev": true, + "requires": { + "postcss": "^6.0.1", + "postcss-value-parser": "^3.2.3", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + } + }, + "postcss-initial": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postcss-initial/-/postcss-initial-2.0.0.tgz", + "integrity": "sha1-cnFfczbgu3k1HZnuZcSiU6hEG6Q=", + "dev": true, + "requires": { + "lodash.template": "^4.2.4", + "postcss": "^6.0.1" + } + }, + "postcss-load-config": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-2.0.0.tgz", + "integrity": "sha512-V5JBLzw406BB8UIfsAWSK2KSwIJ5yoEIVFb4gVkXci0QdKgA24jLmHZ/ghe/GgX0lJ0/D1uUK1ejhzEY94MChQ==", + "dev": true, + "requires": { + "cosmiconfig": "^4.0.0", + "import-cwd": "^2.0.0" + }, + "dependencies": { + "cosmiconfig": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-4.0.0.tgz", + "integrity": "sha512-6e5vDdrXZD+t5v0L8CrurPeybg4Fmf+FCSYxXKYVAqLUtyCSbuyqE059d0kDthTNRzKVjL7QMgNpEUlsoYH3iQ==", + "dev": true, + "requires": { + "is-directory": "^0.3.1", + "js-yaml": "^3.9.0", + "parse-json": "^4.0.0", + "require-from-string": "^2.0.1" + } + } + } + }, + "postcss-media-minmax": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-media-minmax/-/postcss-media-minmax-3.0.0.tgz", + "integrity": "sha1-Z1JWA3pD70C8Twdgv9BtTcadSNI=", + "dev": true, + "requires": { + "postcss": "^6.0.1" + } + }, + "postcss-media-query-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha1-J7Ocb02U+Bsac7j3Y1HGCeXO8kQ=", + "dev": true + }, + "postcss-merge-longhand": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-4.0.4.tgz", + "integrity": "sha512-wLi2u22mSdBDBjLF8pyaPCNppOmqb+B4O0Dlt/4nUwn79EltDUJmCeCDYqo7SB2z9puOHTftnxviY4J9xS+ygQ==", + "dev": true, + "requires": { + "css-color-names": "0.0.4", + "postcss": "^6.0.0", + "postcss-value-parser": "^3.0.0", + "stylehacks": "^4.0.0" + } + }, + "postcss-merge-rules": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-4.0.1.tgz", + "integrity": "sha1-Qw/Vmz8u0uivzQsxJ47aOYVKuxA=", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "caniuse-api": "^3.0.0", + "cssnano-util-same-parent": "^4.0.0", + "postcss": "^6.0.0", + "postcss-selector-parser": "^3.0.0", + "vendors": "^1.0.0" + }, + "dependencies": { + "postcss-selector-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.1.tgz", + "integrity": "sha1-T4dfSvsMllc9XPTXQBGu4lCn6GU=", + "dev": true, + "requires": { + "dot-prop": "^4.1.1", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + } + } + }, + "postcss-message-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postcss-message-helpers/-/postcss-message-helpers-2.0.0.tgz", + "integrity": "sha1-pPL0+rbk/gAvCu0ABHjN9S+bpg4=", + "dev": true + }, + "postcss-minify-font-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-4.0.0.tgz", + "integrity": "sha1-TMM9KD1qgXWQNudX75gdksvYW+0=", + "dev": true, + "requires": { + "postcss": "^6.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-minify-gradients": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-4.0.0.tgz", + "integrity": "sha1-P8ORZDnSepu4Bm23za2AFlDrCQ4=", + "dev": true, + "requires": { + "cssnano-util-get-arguments": "^4.0.0", + "is-color-stop": "^1.0.0", + "postcss": "^6.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-minify-params": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-4.0.0.tgz", + "integrity": "sha1-BekWbuSMBa9lGYnOhNOcG015BnQ=", + "dev": true, + "requires": { + "alphanum-sort": "^1.0.0", + "cssnano-util-get-arguments": "^4.0.0", + "postcss": "^6.0.0", + "postcss-value-parser": "^3.0.0", + "uniqs": "^2.0.0" + } + }, + "postcss-minify-selectors": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-4.0.0.tgz", + "integrity": "sha1-sen2xGNBbT/Nyybnt4XZX2FXiq0=", + "dev": true, + "requires": { + "alphanum-sort": "^1.0.0", + "has": "^1.0.0", + "postcss": "^6.0.0", + "postcss-selector-parser": "^3.0.0" + }, + "dependencies": { + "postcss-selector-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.1.tgz", + "integrity": "sha1-T4dfSvsMllc9XPTXQBGu4lCn6GU=", + "dev": true, + "requires": { + "dot-prop": "^4.1.1", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + } + } + }, + "postcss-nesting": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-4.2.1.tgz", + "integrity": "sha512-IkyWXICwagCnlaviRexi7qOdwPw3+xVVjgFfGsxmztvRVaNxAlrypOIKqDE5mxY+BVxnId1rnUKBRQoNE2VDaA==", + "dev": true, + "requires": { + "postcss": "^6.0.11" + } + }, + "postcss-normalize-charset": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-4.0.0.tgz", + "integrity": "sha1-JFJyknAtXoEp6vo9HeSe1RpqtzA=", + "dev": true, + "requires": { + "postcss": "^6.0.0" + } + }, + "postcss-normalize-display-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-4.0.0.tgz", + "integrity": "sha1-lQ4Me+NEV3ChYP/9a2ZEw8DNj4k=", + "dev": true, + "requires": { + "cssnano-util-get-match": "^4.0.0", + "postcss": "^6.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-positions": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-4.0.0.tgz", + "integrity": "sha1-7pNDq5gbgixjq3JhXszNCFZERaM=", + "dev": true, + "requires": { + "cssnano-util-get-arguments": "^4.0.0", + "has": "^1.0.0", + "postcss": "^6.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-repeat-style": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-4.0.0.tgz", + "integrity": "sha1-txHFks8W+vn/V15C+hALZ5kIPv8=", + "dev": true, + "requires": { + "cssnano-util-get-arguments": "^4.0.0", + "cssnano-util-get-match": "^4.0.0", + "postcss": "^6.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-4.0.0.tgz", + "integrity": "sha1-cYy20wpvrGrGqDDjLAbAfbxm/l0=", + "dev": true, + "requires": { + "has": "^1.0.0", + "postcss": "^6.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-timing-functions": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-4.0.0.tgz", + "integrity": "sha1-A1HymIaqmB1D2RssK9GuptCvbSM=", + "dev": true, + "requires": { + "cssnano-util-get-match": "^4.0.0", + "postcss": "^6.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-unicode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-4.0.0.tgz", + "integrity": "sha1-Ws1dR7rqXRdnSyzMSuUWb6iM35c=", + "dev": true, + "requires": { + "postcss": "^6.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-url": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-4.0.0.tgz", + "integrity": "sha1-t6nIrSbPJmlMFG6y1ovQz0mVbw0=", + "dev": true, + "requires": { + "is-absolute-url": "^2.0.0", + "normalize-url": "^3.0.0", + "postcss": "^6.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-whitespace": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-4.0.0.tgz", + "integrity": "sha1-HafnaxCuY8EYJ/oE/Du0oe/pnMA=", + "dev": true, + "requires": { + "postcss": "^6.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-ordered-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-4.0.0.tgz", + "integrity": "sha1-WLQMdPcuAi6zQVLBLksPk1RIL8I=", + "dev": true, + "requires": { + "cssnano-util-get-arguments": "^4.0.0", + "postcss": "^6.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-pseudo-class-any-link": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-4.0.0.tgz", + "integrity": "sha1-kVKgYT00UHIFE+iJKFS65C0O5o4=", + "dev": true, + "requires": { + "postcss": "^6.0.1", + "postcss-selector-parser": "^2.2.3" + } + }, + "postcss-pseudoelements": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-pseudoelements/-/postcss-pseudoelements-5.0.0.tgz", + "integrity": "sha1-7vGU6NUkZFylIKlJ6V5RjoEkAss=", + "dev": true, + "requires": { + "postcss": "^6.0.0" + } + }, + "postcss-reduce-initial": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-4.0.1.tgz", + "integrity": "sha1-8tWPUM6isMXcEnjW6l7Q/1gpwpM=", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "caniuse-api": "^3.0.0", + "has": "^1.0.0", + "postcss": "^6.0.0" + } + }, + "postcss-reduce-transforms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-4.0.0.tgz", + "integrity": "sha1-9kX8dEDDUnT0DegQThStcWPt8Yg=", + "dev": true, + "requires": { + "cssnano-util-get-match": "^4.0.0", + "has": "^1.0.0", + "postcss": "^6.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-replace-overflow-wrap": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-2.0.0.tgz", + "integrity": "sha1-eU22+qVPjbEAhUOSqTr0V2i04ls=", + "dev": true, + "requires": { + "postcss": "^6.0.1" + } + }, + "postcss-reporter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-reporter/-/postcss-reporter-5.0.0.tgz", + "integrity": "sha512-rBkDbaHAu5uywbCR2XE8a25tats3xSOsGNx6mppK6Q9kSFGKc/FyAzfci+fWM2l+K402p1D0pNcfDGxeje5IKg==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "lodash": "^4.17.4", + "log-symbols": "^2.0.0", + "postcss": "^6.0.8" + } + }, + "postcss-selector-matches": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/postcss-selector-matches/-/postcss-selector-matches-3.0.1.tgz", + "integrity": "sha1-5WNAEeE5UIgYYbvdWMLQER/8lqs=", + "dev": true, + "requires": { + "balanced-match": "^0.4.2", + "postcss": "^6.0.1" + }, + "dependencies": { + "balanced-match": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz", + "integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg=", + "dev": true + } + } + }, + "postcss-selector-not": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-3.0.1.tgz", + "integrity": "sha1-Lk2y8JZTNsAefOx9tsYN/3ZzNdk=", + "dev": true, + "requires": { + "balanced-match": "^0.4.2", + "postcss": "^6.0.1" + }, + "dependencies": { + "balanced-match": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz", + "integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg=", + "dev": true + } + } + }, + "postcss-selector-parser": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-2.2.3.tgz", + "integrity": "sha1-+UN3iGBsPJrO4W/+jYsWKX8nu5A=", + "dev": true, + "requires": { + "flatten": "^1.0.2", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + }, + "postcss-svgo": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-4.0.0.tgz", + "integrity": "sha1-wLutAlIPxjbJ14sOhAPi5RXDIoU=", + "dev": true, + "requires": { + "is-svg": "^3.0.0", + "postcss": "^6.0.0", + "postcss-value-parser": "^3.0.0", + "svgo": "^1.0.0" + } + }, + "postcss-unique-selectors": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-4.0.0.tgz", + "integrity": "sha1-BMHpdkx1h0JhMDQCxB8Ol2n8VQE=", + "dev": true, + "requires": { + "alphanum-sort": "^1.0.0", + "postcss": "^6.0.0", + "uniqs": "^2.0.0" + } + }, + "postcss-value-parser": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.0.tgz", + "integrity": "sha1-h/OPnxj3dKSrTIojL1xc6IcqnRU=", + "dev": true + }, + "postcss-values-parser": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/postcss-values-parser/-/postcss-values-parser-1.5.0.tgz", + "integrity": "sha512-3M3p+2gMp0AH3da530TlX8kiO1nxdTnc3C6vr8dMxRLIlh8UYkz0/wcwptSXjhtx2Fr0TySI7a+BHDQ8NL7LaQ==", + "dev": true, + "requires": { + "flatten": "^1.0.2", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "prettier": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.14.0.tgz", + "integrity": "sha512-KtQ2EGaUwf2EyDfp1fxyEb0PqGKakVm0WyXwDt6u+cAoxbO2Z2CwKvOe3+b4+F2IlO9lYHi1kqFuRM70ddBnow==", + "dev": true + }, + "pretty-hrtime": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", + "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=", + "dev": true + }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", + "dev": true + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", + "dev": true + }, + "progress": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.0.tgz", + "integrity": "sha1-ihvjZr+Pwj2yvSPxDG/pILQ4nR8=", + "dev": true + }, + "promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=", + "dev": true + }, + "prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=", + "dev": true + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", + "dev": true + }, + "public-encrypt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.2.tgz", + "integrity": "sha512-4kJ5Esocg8X3h8YgJsKAuoesBgB7mqH3eowiDzMUPKiRDDE7E/BqqZD1hnTByIaAFiwAw246YEltSq7tdrOH0Q==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1" + } + }, + "pump": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", + "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "pumpify": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.5.1.tgz", + "integrity": "sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==", + "dev": true, + "requires": { + "duplexify": "^3.6.0", + "inherits": "^2.0.3", + "pump": "^2.0.0" + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, + "q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=", + "dev": true + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=", + "dev": true + }, + "querystring-es3": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", + "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=", + "dev": true + }, + "randombytes": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.0.6.tgz", + "integrity": "sha512-CIQ5OFxf4Jou6uOKe9t1AOgqpeU5fd70A8NPdHSGeYXqXsPe6peOwI0cUl88RWZ6sP1vPMV3avd/R6cZ5/sP1A==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "dev": true, + "requires": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "raw-body": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.3.tgz", + "integrity": "sha512-9esiElv1BrZoI3rCDuOuKCBRbuApGGaDPQfjSflGxdy4oyzqghxu6klEkkVIvBje+FF0BX9coEv8KqW6X/7njw==", + "requires": { + "bytes": "3.0.0", + "http-errors": "1.6.3", + "iconv-lite": "0.4.23", + "unpipe": "1.0.0" + }, + "dependencies": { + "http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + } + } + } + }, + "read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha1-5mTvMRYRZsl1HNvo28+GtftY93Q=", + "dev": true, + "requires": { + "pify": "^2.3.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + } + } + }, + "readdirp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.1.0.tgz", + "integrity": "sha1-TtCtBg3zBzMAxIRANz9y0cxkLXg=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "minimatch": "^3.0.2", + "readable-stream": "^2.0.2", + "set-immediate-shim": "^1.0.1" + } + }, + "reduce-css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-2.1.4.tgz", + "integrity": "sha512-i/vWQbyd3aJRmip9OVSN9V6nIjLf/gg/ctxb0CpvHWtcRysFl/ngDBQD+rqavxdw/doScA3GMBXhzkHQ4GCzFQ==", + "dev": true, + "requires": { + "css-unit-converter": "^1.1.1", + "postcss-value-parser": "^3.3.0" + } + }, + "reduce-function-call": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.2.tgz", + "integrity": "sha1-WiAL+S4ON3UXUv5FsKszD9S2vpk=", + "dev": true, + "requires": { + "balanced-match": "^0.4.2" + }, + "dependencies": { + "balanced-match": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz", + "integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg=", + "dev": true + } + } + }, + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", + "dev": true + }, + "regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + } + }, + "regexp.prototype.flags": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.2.0.tgz", + "integrity": "sha512-ztaw4M1VqgMwl9HlPpOuiYgItcHlunW0He2fE6eNfT6E/CF2FtYi9ofOYe4mKntstYk0Fyh/rDRBdS3AnxjlrA==", + "dev": true, + "requires": { + "define-properties": "^1.1.2" + } + }, + "regexpp": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-1.1.0.tgz", + "integrity": "sha512-LOPw8FpgdQF9etWMaAfG/WRthIdXJGYp4mJ2Jgn/2lpkbod9jPn0t9UqN7AxBOKNfzRbYyVfgc7Vk4t/MpnXgw==", + "dev": true + }, + "remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", + "dev": true + }, + "repeat-element": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.2.tgz", + "integrity": "sha1-7wiaF40Ug7quTZPrmLT55OEdmQo=", + "dev": true + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "dev": true + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "dev": true + }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true + }, + "require-main-filename": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", + "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=", + "dev": true + }, + "require-uncached": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz", + "integrity": "sha1-Tg1W1slmL9MeQwEcS5WqSZVUIdM=", + "dev": true, + "requires": { + "caller-path": "^0.1.0", + "resolve-from": "^1.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-1.0.1.tgz", + "integrity": "sha1-Jsv+k10a7uq7Kbw/5a6wHpPUQiY=", + "dev": true + } + } + }, + "require_optional": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz", + "integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==", + "requires": { + "resolve-from": "^2.0.0", + "semver": "^5.1.0" + } + }, + "resolve": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.8.1.tgz", + "integrity": "sha512-AicPrAC7Qu1JxPCZ9ZgCZlY35QgFnNqc+0LtbRNxnVw4TXvjQ72wnuL9JQcEBgXkI9JM8MsT9kaQoHcpCRJOYA==", + "dev": true, + "requires": { + "path-parse": "^1.0.5" + } + }, + "resolve-cwd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-2.0.0.tgz", + "integrity": "sha1-AKn3OHVW4nA46uIyyqNypqWbZlo=", + "dev": true, + "requires": { + "resolve-from": "^3.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", + "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=", + "dev": true + } + } + }, + "resolve-from": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz", + "integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c=" + }, + "resolve-path": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/resolve-path/-/resolve-path-1.4.0.tgz", + "integrity": "sha1-xL2p9e+y/OZSR4c6s2u02DT+Fvc=", + "requires": { + "http-errors": "~1.6.2", + "path-is-absolute": "1.0.1" + }, + "dependencies": { + "http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + } + } + } + }, + "resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", + "dev": true + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + }, + "ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true + }, + "rgb": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/rgb/-/rgb-0.1.0.tgz", + "integrity": "sha1-vieykej+/+rBvZlylyG/pA/AN7U=", + "dev": true + }, + "rgb-hex": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/rgb-hex/-/rgb-hex-2.1.0.tgz", + "integrity": "sha1-x3PF/iJoolV42SU5qCp6XOU77aY=", + "dev": true + }, + "rgb-regex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgb-regex/-/rgb-regex-1.0.1.tgz", + "integrity": "sha1-wODWiC3w4jviVKR16O3UGRX+rrE=", + "dev": true + }, + "rgba-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rgba-regex/-/rgba-regex-1.0.0.tgz", + "integrity": "sha1-QzdOLiyglosO8VI0YLfXMP8i7rM=", + "dev": true + }, + "rimraf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", + "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", + "dev": true, + "requires": { + "glob": "^7.0.5" + } + }, + "ripemd160": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", + "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "dev": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "run-async": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz", + "integrity": "sha1-A3GrSuC91yDUFm19/aZP96RFpsA=", + "dev": true, + "requires": { + "is-promise": "^2.1.0" + } + }, + "run-queue": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/run-queue/-/run-queue-1.0.3.tgz", + "integrity": "sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec=", + "dev": true, + "requires": { + "aproba": "^1.1.1" + } + }, + "rxjs": { + "version": "5.5.11", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-5.5.11.tgz", + "integrity": "sha512-3bjO7UwWfA2CV7lmwYMBzj4fQ6Cq+ftHc2MvUe+WMS7wcdJ1LosDWmdjPQanYp2dBRj572p7PeU81JUxHKOcBA==", + "dev": true, + "requires": { + "symbol-observable": "1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", + "dev": true, + "requires": { + "ret": "~0.1.10" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "saslprep": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.1.tgz", + "integrity": "sha512-ntN6SbE3hRqd45PKKadRPgA+xHPWg5lPSj2JWJdJvjTwXDDfkPVtXWvP8jJojvnm+rAsZ2b299C5NwZqq818EA==", + "optional": true + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true + }, + "schema-utils": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.5.tgz", + "integrity": "sha512-yYrjb9TX2k/J1Y5UNy3KYdZq10xhYcF8nMpAW6o3hy6Q8WSIEf9lJHG/ePnOBfziPM3fvQwfOwa13U/Fh8qTfA==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-keywords": "^3.1.0" + } + }, + "semver": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz", + "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==" + }, + "serialize-javascript": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-1.5.0.tgz", + "integrity": "sha512-Ga8c8NjAAp46Br4+0oZ2WxJCwIzwP60Gq1YPgU+39PiTVxyed/iKE/zyZI6+UlVYH5Q4PaQdHhcegIFPZTUfoQ==", + "dev": true + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "dev": true + }, + "set-immediate-shim": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz", + "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=", + "dev": true + }, + "set-value": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.0.tgz", + "integrity": "sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg==", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=", + "dev": true + }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, + "sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", + "dev": true + }, + "simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", + "dev": true, + "requires": { + "is-arrayish": "^0.3.1" + }, + "dependencies": { + "is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "dev": true + } + } + }, + "slash": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", + "integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=", + "dev": true + }, + "slice-ansi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-1.0.0.tgz", + "integrity": "sha512-POqxBK6Lb3q6s047D/XsDVNPnF9Dl8JSaqe9h9lURl0OdNqy/ujDrOiIHtsqXMGbWWTIomRzAMaTyawAU//Reg==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0" + } + }, + "snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "dev": true, + "requires": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "dev": true, + "requires": { + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "dev": true, + "requires": { + "kind-of": "^3.2.0" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "source-list-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.0.tgz", + "integrity": "sha512-I2UmuJSRr/T8jisiROLU3A3ltr+swpniSmNPI4Ml3ZCX6tVnDsuZzK7F2hl5jTqbZBWCEKlj5HRQiPExXLgE8A==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-resolve": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.2.tgz", + "integrity": "sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA==", + "dev": true, + "requires": { + "atob": "^2.1.1", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "source-map-url": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", + "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", + "dev": true + }, + "split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.0" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "ssri": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-5.3.0.tgz", + "integrity": "sha512-XRSIPqLij52MtgoQavH/x/dU1qVKtWUAAZeOHsR9c2Ddi4XerFy3mc1alf+dLJKl9EUIm/Ht+EowFkTUOA6GAQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.1" + } + }, + "stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "dev": true + }, + "static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", + "dev": true, + "requires": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + }, + "stream-browserify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.1.tgz", + "integrity": "sha1-ZiZu5fm9uZQKTkUUyvtDu3Hlyds=", + "dev": true, + "requires": { + "inherits": "~2.0.1", + "readable-stream": "^2.0.2" + } + }, + "stream-each": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/stream-each/-/stream-each-1.2.3.tgz", + "integrity": "sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "stream-shift": "^1.0.0" + } + }, + "stream-http": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz", + "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==", + "dev": true, + "requires": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.3.6", + "to-arraybuffer": "^1.0.0", + "xtend": "^4.0.0" + } + }, + "stream-shift": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.0.tgz", + "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "string.prototype.matchall": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-2.0.0.tgz", + "integrity": "sha512-WoZ+B2ypng1dp4iFLF2kmZlwwlE19gmjgKuhL1FJfDgCREWb3ye3SDVHSzLH6bxfnvYmkCxbzkmWcQZHA4P//Q==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.10.0", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.0", + "regexp.prototype.flags": "^1.2.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + } + } + }, + "strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", + "dev": true + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true + }, + "stylehacks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-4.0.0.tgz", + "integrity": "sha1-ZLMjlRxKJOX8ey7AbBN78y0VXoo=", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "postcss": "^6.0.0", + "postcss-selector-parser": "^3.0.0" + }, + "dependencies": { + "postcss-selector-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.1.tgz", + "integrity": "sha1-T4dfSvsMllc9XPTXQBGu4lCn6GU=", + "dev": true, + "requires": { + "dot-prop": "^4.1.1", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + } + } + }, + "supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "svgo": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.0.5.tgz", + "integrity": "sha512-nYrifviB77aNKDNKKyuay3M9aYiK6Hv5gJVDdjj2ZXTQmI8WZc8+UPLR5IpVlktJfSu3co/4XcWgrgI6seGBPg==", + "dev": true, + "requires": { + "coa": "~2.0.1", + "colors": "~1.1.2", + "css-select": "~1.3.0-rc0", + "css-select-base-adapter": "~0.1.0", + "css-tree": "1.0.0-alpha25", + "css-url-regex": "^1.1.0", + "csso": "^3.5.0", + "js-yaml": "~3.10.0", + "mkdirp": "~0.5.1", + "object.values": "^1.0.4", + "sax": "~1.2.4", + "stable": "~0.1.6", + "unquote": "~1.1.1", + "util.promisify": "~1.0.0" + }, + "dependencies": { + "js-yaml": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.10.0.tgz", + "integrity": "sha512-O2v52ffjLa9VeM43J4XocZE//WT9N0IiwDa3KSHH7Tu8CtH+1qM8SIZvnsTh6v+4yFy5KUY3BHUVwjpfAWsjIA==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + } + } + }, + "symbol-observable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.0.1.tgz", + "integrity": "sha1-g0D8RwLDEi310iKI+IKD9RPT/dQ=", + "dev": true + }, + "table": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/table/-/table-4.0.3.tgz", + "integrity": "sha512-S7rnFITmBH1EnyKcvxBh1LjYeQMmnZtCXSEbHcH6S0NoKit24ZuFO/T1vDcLdYsLQkM188PVVhQmzKIuThNkKg==", + "dev": true, + "requires": { + "ajv": "^6.0.1", + "ajv-keywords": "^3.0.0", + "chalk": "^2.1.0", + "lodash": "^4.17.4", + "slice-ansi": "1.0.0", + "string-width": "^2.1.1" + } + }, + "tapable": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.0.0.tgz", + "integrity": "sha512-dQRhbNQkRnaqauC7WqSJ21EEksgT0fYZX2lqXzGkpo8JNig9zGZTYoMGvyI2nWmXlE2VSVXVDu7wLVGu/mQEsg==", + "dev": true + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "thenify": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.0.tgz", + "integrity": "sha1-5p44obq+lpsBCCB5eLn2K4hgSDk=", + "requires": { + "any-promise": "^1.0.0" + } + }, + "thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=", + "requires": { + "thenify": ">= 3.1.0 < 4" + } + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, + "through2": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", + "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=", + "dev": true, + "requires": { + "readable-stream": "^2.1.5", + "xtend": "~4.0.1" + } + }, + "timers-browserify": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.10.tgz", + "integrity": "sha512-YvC1SV1XdOUaL6gx5CoGroT3Gu49pK9+TZ38ErPldOWW4j49GI1HKs9DV+KGq/w6y+LZ72W1c8cKz2vzY+qpzg==", + "dev": true, + "requires": { + "setimmediate": "^1.0.4" + } + }, + "timsort": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", + "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=", + "dev": true + }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.2" + } + }, + "to-arraybuffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", + "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=", + "dev": true + }, + "to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "dev": true, + "requires": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + }, + "tslib": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz", + "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==", + "dev": true + }, + "tty-browserify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", + "integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=", + "dev": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + }, + "type-is": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.16.tgz", + "integrity": "sha512-HRkVv/5qY2G6I8iab9cI7v1bOIdhm94dVjQCPFElW9W+3GeDOSHmy2EBYe4VTApuzolPcmgFTN3ftVJRKR2J9Q==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.18" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "dev": true + }, + "uglify-es": { + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/uglify-es/-/uglify-es-3.3.9.tgz", + "integrity": "sha512-r+MU0rfv4L/0eeW3xZrd16t4NZfK8Ld4SWVglYBb7ez5uXFWHuVRs6xCTrf1yirs9a4j4Y27nn7SRfO6v67XsQ==", + "dev": true, + "requires": { + "commander": "~2.13.0", + "source-map": "~0.6.1" + } + }, + "uglifyjs-webpack-plugin": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-1.2.7.tgz", + "integrity": "sha512-1VicfKhCYHLS8m1DCApqBhoulnASsEoJ/BvpUpP4zoNAPpKzdH+ghk0olGJMmwX2/jprK2j3hAHdUbczBSy2FA==", + "dev": true, + "requires": { + "cacache": "^10.0.4", + "find-cache-dir": "^1.0.0", + "schema-utils": "^0.4.5", + "serialize-javascript": "^1.4.0", + "source-map": "^0.6.1", + "uglify-es": "^3.3.4", + "webpack-sources": "^1.1.0", + "worker-farm": "^1.5.2" + } + }, + "union-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", + "integrity": "sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ=", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^0.4.3" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "set-value": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-0.4.3.tgz", + "integrity": "sha1-fbCPnT0i3H945Trzw79GZuzfzPE=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.1", + "to-object-path": "^0.3.0" + } + } + } + }, + "uniq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", + "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=", + "dev": true + }, + "uniqs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/uniqs/-/uniqs-2.0.0.tgz", + "integrity": "sha1-/+3ks2slKQaW5uFl1KWe25mOawI=", + "dev": true + }, + "unique-filename": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.0.tgz", + "integrity": "sha1-0F8v5AMlYIcfMOk8vnNe6iAVFPM=", + "dev": true, + "requires": { + "unique-slug": "^2.0.0" + } + }, + "unique-slug": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.0.tgz", + "integrity": "sha1-22Z258fMBimHj/GWCXx4hVrp9Ks=", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4" + } + }, + "units-css": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/units-css/-/units-css-0.4.0.tgz", + "integrity": "sha1-1iKGU6UZg9fBb/KPi53Dsf/tOgc=", + "dev": true, + "requires": { + "isnumeric": "^0.2.0", + "viewport-dimensions": "^0.2.0" + } + }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "unquote": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", + "integrity": "sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ=", + "dev": true + }, + "unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", + "dev": true, + "requires": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "dependencies": { + "has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", + "dev": true, + "requires": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "dev": true, + "requires": { + "isarray": "1.0.0" + } + } + } + }, + "has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=", + "dev": true + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + } + } + }, + "upath": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.1.0.tgz", + "integrity": "sha512-bzpH/oBhoS/QI/YtbkqCg6VEiPYjSZtrHQM6/QnJS6OL9pKUFLqb3aFh4Scvwm45+7iAgiMkLhSbaZxUqmrprw==", + "dev": true + }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "urijs": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.1.tgz", + "integrity": "sha512-xVrGVi94ueCJNrBSTjWqjvtgvl3cyOTThp2zaMaFNGp3F542TR6sM3f2o8RqZl+AwteClSVmoCyt0ka4RjQOQg==" + }, + "urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", + "dev": true + }, + "url": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", + "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=", + "dev": true, + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + }, + "dependencies": { + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=", + "dev": true + } + } + }, + "use": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", + "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", + "dev": true + }, + "util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "requires": { + "inherits": "2.0.3" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "util.promisify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.0.tgz", + "integrity": "sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "object.getownpropertydescriptors": "^2.0.3" + } + }, + "v8-compile-cache": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.0.0.tgz", + "integrity": "sha512-qNdTUMaCjPs4eEnM3W9H94R3sU70YCuT+/ST7nUf+id1bVOrdjrpUaeZLqPBPRph3hsgn4a4BvwpxhHZx+oSDg==", + "dev": true + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "vendors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/vendors/-/vendors-1.0.2.tgz", + "integrity": "sha512-w/hry/368nO21AN9QljsaIhb9ZiZtZARoVH5f3CsFbawdLdayCgKRPup7CggujvySMxx0I91NOyxdVENohprLQ==", + "dev": true + }, + "viewport-dimensions": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/viewport-dimensions/-/viewport-dimensions-0.2.0.tgz", + "integrity": "sha1-3nQHR9tTh/0XJfUXXpG6x2r982w=", + "dev": true + }, + "vm-browserify": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz", + "integrity": "sha1-XX6kW7755Kb/ZflUOOCofDV9WnM=", + "dev": true, + "requires": { + "indexof": "0.0.1" + } + }, + "watchpack": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.6.0.tgz", + "integrity": "sha512-i6dHe3EyLjMmDlU1/bGQpEw25XSjkJULPuAVKCbNRefQVq48yXKUpwg538F7AZTf9kyr57zj++pQFltUa5H7yA==", + "dev": true, + "requires": { + "chokidar": "^2.0.2", + "graceful-fs": "^4.1.2", + "neo-async": "^2.5.0" + } + }, + "webpack": { + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.16.3.tgz", + "integrity": "sha512-3VcrVoFgzSz1IYgga71YpU3HO89Al5bSnDOj9RJQPsy+FNyI1sFsUyJITn3pktNuaRBlQT0usvKZE3GgkPGAIw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.5.13", + "@webassemblyjs/helper-module-context": "1.5.13", + "@webassemblyjs/wasm-edit": "1.5.13", + "@webassemblyjs/wasm-opt": "1.5.13", + "@webassemblyjs/wasm-parser": "1.5.13", + "acorn": "^5.6.2", + "acorn-dynamic-import": "^3.0.0", + "ajv": "^6.1.0", + "ajv-keywords": "^3.1.0", + "chrome-trace-event": "^1.0.0", + "enhanced-resolve": "^4.1.0", + "eslint-scope": "^4.0.0", + "json-parse-better-errors": "^1.0.2", + "loader-runner": "^2.3.0", + "loader-utils": "^1.1.0", + "memory-fs": "~0.4.1", + "micromatch": "^3.1.8", + "mkdirp": "~0.5.0", + "neo-async": "^2.5.0", + "node-libs-browser": "^2.0.0", + "schema-utils": "^0.4.4", + "tapable": "^1.0.0", + "uglifyjs-webpack-plugin": "^1.2.4", + "watchpack": "^1.5.0", + "webpack-sources": "^1.0.1" + } + }, + "webpack-cli": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-3.1.0.tgz", + "integrity": "sha512-p5NeKDtYwjZozUWq6kGNs9w+Gtw/CPvyuXjXn2HMdz8Tie+krjEg8oAtonvIyITZdvpF7XG9xDHwscLr2c+ugQ==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "enhanced-resolve": "^4.0.0", + "global-modules-path": "^2.1.0", + "import-local": "^1.0.0", + "inquirer": "^6.0.0", + "interpret": "^1.1.0", + "loader-utils": "^1.1.0", + "supports-color": "^5.4.0", + "v8-compile-cache": "^2.0.0", + "yargs": "^12.0.1" + }, + "dependencies": { + "chardet": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.5.0.tgz", + "integrity": "sha512-9ZTaoBaePSCFvNlNGrsyI8ZVACP2svUtq0DkM7t4K2ClAa96sqOIRjAzDTc8zXzFt1cZR46rRzLTiHFSJ+Qw0g==", + "dev": true + }, + "external-editor": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.0.0.tgz", + "integrity": "sha512-mpkfj0FEdxrIhOC04zk85X7StNtr0yXnG7zCb+8ikO8OJi2jsHh5YGoknNTyXgsbHOf1WOOcVU3kPFWT2WgCkQ==", + "dev": true, + "requires": { + "chardet": "^0.5.0", + "iconv-lite": "^0.4.22", + "tmp": "^0.0.33" + } + }, + "inquirer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-6.0.0.tgz", + "integrity": "sha512-tISQWRwtcAgrz+SHPhTH7d3e73k31gsOy6i1csonLc0u1dVK/wYvuOnFeiWqC5OXFIYbmrIFInef31wbT8MEJg==", + "dev": true, + "requires": { + "ansi-escapes": "^3.0.0", + "chalk": "^2.0.0", + "cli-cursor": "^2.1.0", + "cli-width": "^2.0.0", + "external-editor": "^3.0.0", + "figures": "^2.0.0", + "lodash": "^4.3.0", + "mute-stream": "0.0.7", + "run-async": "^2.2.0", + "rxjs": "^6.1.0", + "string-width": "^2.1.0", + "strip-ansi": "^4.0.0", + "through": "^2.3.6" + } + }, + "rxjs": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.2.2.tgz", + "integrity": "sha512-0MI8+mkKAXZUF9vMrEoPnaoHkfzBPP4IGwUYRJhIRJF6/w3uByO1e91bEHn8zd43RdkTMKiooYKmwz7RH6zfOQ==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + } + } + }, + "webpack-node-externals": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-1.7.2.tgz", + "integrity": "sha512-ajerHZ+BJKeCLviLUUmnyd5B4RavLF76uv3cs6KNuO8W+HuQaEs0y0L7o40NQxdPy5w0pcv8Ew7yPUAQG0UdCg==", + "dev": true + }, + "webpack-sources": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.1.0.tgz", + "integrity": "sha512-aqYp18kPphgoO5c/+NaUvEeACtZjMESmDChuD3NBciVpah3XpMEU9VAAtIaB1BsfJWWTSdv8Vv1m3T0aRk2dUw==", + "dev": true, + "requires": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, + "worker-farm": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/worker-farm/-/worker-farm-1.6.0.tgz", + "integrity": "sha512-6w+3tHbM87WnSWnENBUvA2pxJPLhQUg5LKwUQHq3r+XPhIM+Gh2R5ycbwPCyuGbNg+lPgdcnQUhuC02kJCvffQ==", + "dev": true, + "requires": { + "errno": "~0.1.7" + } + }, + "wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", + "dev": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "write": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/write/-/write-0.2.1.tgz", + "integrity": "sha1-X8A4KOJkzqP+kUVUdvejxWbLB1c=", + "dev": true, + "requires": { + "mkdirp": "^0.5.1" + } + }, + "xregexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.0.0.tgz", + "integrity": "sha512-PHyM+sQouu7xspQQwELlGwwd05mXUFqwFYfqPO0cC7x4fxyHnnuetmQr6CjJiafIDoH4MogHb9dOoJzR/Y4rFg==", + "dev": true + }, + "xtend": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", + "dev": true + }, + "yargs": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-12.0.1.tgz", + "integrity": "sha512-B0vRAp1hRX4jgIOWFtjfNjd9OA9RWYZ6tqGA9/I/IrTMsxmKvtWy+ersM+jzpQqbC3YfLzeABPdeTgcJ9eu1qQ==", + "dev": true, + "requires": { + "cliui": "^4.0.0", + "decamelize": "^2.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^1.0.1", + "os-locale": "^2.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1 || ^4.0.0", + "yargs-parser": "^10.1.0" + }, + "dependencies": { + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-limit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.0.0.tgz", + "integrity": "sha512-fl5s52lI5ahKCernzzIyAP0QAZbGIovtVHGwpcu1Jr/EpzLVDI2myISHwGqK7m8uQFugVWSrbxH7XnhGtvEc+A==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.0.0.tgz", + "integrity": "sha512-hMp0onDKIajHfIkdRk3P4CdCmErkYAxxDtP3Wx/4nZ3aGlau2VKh3mZpcuFkH27WQkL/3WBCPOktzA9ZOAnMQQ==", + "dev": true + } + } + }, + "yargs-parser": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-10.1.0.tgz", + "integrity": "sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ==", + "dev": true, + "requires": { + "camelcase": "^4.1.0" + } + }, + "ylru": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.2.1.tgz", + "integrity": "sha512-faQrqNMzcPCHGVC2aaOINk13K+aaBDUPjGWl0teOXywElLjyVAB6Oe2jj62jHYtwsU49jXhScYbvPENK+6zAvQ==" + } + } +} diff --git a/package.json b/package.json index 4789d22..31186a4 100644 --- a/package.json +++ b/package.json @@ -4,31 +4,34 @@ "description": "", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "lint": "eslint --fix common/*.js common/validators/*.js server/*.js client/*.js client/components/*.js;prettier --write common/*.js common/validators/*.js server/*.js client/*.js client/components/*.js client/css/*.css", - "build": "npm run lint;webpack;postcss -c postcss.config.js client/css/app.css -o public/app.css", - "start": "node server/server" + "lint": "eslint --fix common/*.js common/validators/*.js server/*.js client/*.js client/components/*.js;prettier --write *.js common/*.js common/validators/*.js server/*.js client/*.js client/components/*.js client/css/*.css", + "build": "npm run lint;webpack;postcss -c postcss.config.js client/css/app.css -o dist/public/app.css", + "start": "cd dist && node server" }, "author": "Zaid Abdulla ", "dependencies": { - "jsonwebtoken": "^8.2.1", - "koa": "^2.5.0", - "koa-body": "^2.5.0", - "koa-bodyparser": "^4.2.0", - "koa-compress": "^2.0.0", - "koa-jwt": "^3.3.1", + "jsonwebtoken": "^8.3.0", + "koa": "^2.5.2", + "koa-bodyparser": "^4.2.1", + "koa-compress": "^3.0.0", + "koa-jwt": "^3.3.2", "koa-router": "^7.4.0", - "koa-static": "^4.0.2", - "mithril": "^1.1.6", - "mongodb": "^3.0.10", - "parsimmon": "^1.7.2" + "koa-static": "^5.0.0", + "mongodb": "^3.1.1", + "parsimmon": "^1.12.0" }, "devDependencies": { - "cssnano": "^3.10.0", - "eslint": "^4.19.1", - "postcss-cli": "^4.1.1", + "clean-webpack-plugin": "^0.1.19", + "copy-webpack-plugin": "^4.5.2", + "cssnano": "^4.0.5", + "eslint": "^5.2.0", + "mithril": "^1.1.6", + "postcss-cli": "^6.0.0", "postcss-cssnext": "^3.1.0", "postcss-import": "^11.1.0", - "prettier": "^1.11.1", - "webpack": "^3.11.0" + "prettier": "^1.14.0", + "webpack": "^4.16.3", + "webpack-cli": "^3.1.0", + "webpack-node-externals": "^1.7.2" } } diff --git a/postcss.config.js b/postcss.config.js index 91a9f0d..6183fc4 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,7 +1,7 @@ module.exports = { plugins: [ require("postcss-import"), - require("postcss-cssnext")({warnForDuplicates: false}), + require("postcss-cssnext")({ warnForDuplicates: false }), require("cssnano") ] -} +}; diff --git a/server/config.js b/server/config.js index b7c5a92..4dee41d 100644 --- a/server/config.js +++ b/server/config.js @@ -1,6 +1,7 @@ "use strict"; -const configFile = require("../config.json"); +const fs = require("fs"); +const configFile = JSON.parse(fs.readFileSync("./config.json")); let config; diff --git a/server/db.js b/server/db.js index 6afed3c..f1eb19a 100644 --- a/server/db.js +++ b/server/db.js @@ -21,10 +21,14 @@ function getClient(db) { return new Promise((resolve, reject) => { const CONNECTION_URL = config.server.mongodbConnectionUrl; - mongodb.MongoClient.connect(CONNECTION_URL, (err, client) => { - if (err) return reject(err); - resolve((_client[db] = client.db(db))); - }); + mongodb.MongoClient.connect( + CONNECTION_URL, + { useNewUrlParser: true }, + (err, client) => { + if (err) return reject(err); + resolve((_client[db] = client.db(db))); + } + ); }); } diff --git a/server/server.js b/server/server.js index 62b64ad..73a84ab 100644 --- a/server/server.js +++ b/server/server.js @@ -4,7 +4,7 @@ const Koa = require("koa"); const Router = require("koa-router"); const koaStatic = require("koa-static"); const koaCompress = require("koa-compress"); -const koaBody = require("koa-body"); +const koaBodyParser = require("koa-bodyparser"); const koaJwt = require("koa-jwt"); const jwt = require("jsonwebtoken"); @@ -81,7 +81,7 @@ router.post("/logout", async ctx => { ctx.body = ""; }); -koa.use(koaBody()); +koa.use(koaBodyParser()); router.use("/api", api.routes(), api.allowedMethods()); router.get("/", async ctx => { diff --git a/webpack.config.js b/webpack.config.js index 06a9de9..4de8a8b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,6 +1,49 @@ -module.exports = { - entry: "./client/app.js", - output: { - filename: "public/app.js" +const nodeExternals = require("webpack-node-externals"); +const CopyWebpackPlugin = require("copy-webpack-plugin"); +const CleanWebpackPlugin = require("clean-webpack-plugin"); + +const mode = "production"; + +function transformPackageJson(content) { + const pkg = JSON.parse(content); + delete pkg["devDependencies"]; + pkg["scripts"] = { + start: "node server" + }; + return JSON.stringify(pkg, null, 2); +} + +module.exports = [ + { + mode: mode, + entry: "./server/server.js", + target: "node", + externals: [nodeExternals()], + output: { + filename: "server.js" + }, + plugins: [ + new CleanWebpackPlugin(["dist"]), + new CopyWebpackPlugin( + [ + { from: "images/logo.svg", to: "public/logo.svg" }, + { from: "images/favicon.png", to: "public/favicon.png" }, + { from: "config.json", to: "config.json" }, + { + from: "package.json", + to: "package.json", + transform: transformPackageJson + } + ], + {} + ) + ] + }, + { + mode: mode, + entry: "./client/app.js", + output: { + filename: "public/app.js" + } } -}; +]; From a49cbc5e408afe93d8b3b07e9e32612a6e74a897 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Mon, 13 Aug 2018 19:29:40 -0700 Subject: [PATCH 038/106] Implement CSV download --- client/components/container.js | 6 +- client/components/device-faults.js | 2 +- client/components/overview-dot.js | 6 +- client/components/parameter-list.js | 44 +++++---------- client/components/parameter-table.js | 33 ++++++----- client/components/parameter.js | 63 ++++++++++++++++----- client/components/summon-button.js | 24 ++++---- client/devices-page.js | 66 +++++++++++++--------- client/faults-page.js | 57 +++++++++++++------ client/filter-component.js | 50 ++++++++++------- client/overview-page.js | 6 +- client/store.js | 41 ++++---------- common/authorizer.js | 8 +-- common/expression.js | 3 +- common/func-cache.js | 29 ---------- common/memoize.js | 47 ++++++++++++++++ config.json | 4 +- server/api.js | 84 +++++++++++++++++++++++++++- server/db.js | 12 +++- server/mongodb-functions.js | 21 +++++++ 20 files changed, 391 insertions(+), 215 deletions(-) delete mode 100644 common/func-cache.js create mode 100644 common/memoize.js diff --git a/client/components/container.js b/client/components/container.js index 19603c3..9d1713a 100644 --- a/client/components/container.js +++ b/client/components/container.js @@ -4,12 +4,14 @@ import m from "mithril"; import * as components from "../components"; import * as store from "../store"; import * as expression from "../../common/expression"; -import * as funcCache from "../../common/func-cache"; +import memoize from "../../common/memoize"; + +const memoizedParse = memoize(expression.parse); const component = { view: vnode => { if (vnode.attrs.filter) { - const filter = funcCache.get(expression.parse, vnode.attrs.filter); + const filter = memoizedParse(vnode.attrs.filter); if (!store.evaluateExpression(filter, vnode.attrs.device)) return; } diff --git a/client/components/device-faults.js b/client/components/device-faults.js index 74145bb..2eff14a 100644 --- a/client/components/device-faults.js +++ b/client/components/device-faults.js @@ -26,7 +26,7 @@ const component = { m("td", f["code"]), m("td", f["message"]), m("td", f["retries"]), - m("td", f["timestamp"]), + m("td", new Date(f["timestamp"]).toLocaleString()), m( "td", m( diff --git a/client/components/overview-dot.js b/client/components/overview-dot.js index 918e927..6789df4 100644 --- a/client/components/overview-dot.js +++ b/client/components/overview-dot.js @@ -3,8 +3,10 @@ import m from "mithril"; import config from "../config"; import * as store from "../store"; -import * as funcCache from "../../common/func-cache"; import * as expression from "../../common/expression"; +import memoize from "../../common/memoize"; + +const memoizedParse = memoize(expression.parse); const CHARTS = config.ui.overview.charts; @@ -13,7 +15,7 @@ const component = { const device = vnode.attrs.device; const chart = CHARTS[vnode.attrs.chart]; for (let slice of Object.values(chart.slices)) { - const filter = funcCache.get(expression.parse, slice.filter); + const filter = memoizedParse(slice.filter); if (store.evaluateExpression(filter, device)) { const dot = m( "svg", diff --git a/client/components/parameter-list.js b/client/components/parameter-list.js index 77a9e31..194fdb0 100644 --- a/client/components/parameter-list.js +++ b/client/components/parameter-list.js @@ -2,47 +2,29 @@ import m from "mithril"; import * as components from "../components"; -import * as store from "../store"; -import * as funcCache from "../../common/func-cache"; -import * as expression from "../../common/expression"; +import memoize from "../../common/memoize"; -const parseParameter = funcCache.getter(p => { - p = expression.parse(p); - if (Array.isArray(p) && p[0] === "PARAM") p = p[1]; - return p; -}); +const getChildAttrs = memoize((attrs, device) => + Object.assign({}, attrs, { device: device }) +); const component = { - oninit: vnode => { - vnode.state.parameters = Object.values(vnode.attrs.parameters).map( - parameter => - Object.assign({}, parameter, { - parameter: parseParameter(parameter.parameter) - }) - ); - }, view: vnode => { const device = vnode.attrs.device; - const rows = []; - for (let parameter of vnode.state.parameters) { - let p = store.evaluateExpression(parameter.parameter, device); - - if (!(p in device)) continue; - rows.push( + const rows = Object.values(vnode.attrs.parameters).map(parameter => + m( + "tr", + m("th", parameter.label), m( - "tr", - m("th", parameter.label), + "td", m( - "td", - m( - components.get(parameter.type || "parameter"), - Object.assign({}, parameter, { device: device, parameter: p }) - ) + components.get(parameter.type || "parameter"), + getChildAttrs(parameter, device) ) ) - ); - } + ) + ); return m("table.parameter-list", rows); } diff --git a/client/components/parameter-table.js b/client/components/parameter-table.js index 84bb939..f406b8a 100644 --- a/client/components/parameter-table.js +++ b/client/components/parameter-table.js @@ -5,21 +5,18 @@ import * as components from "../components"; import * as taskQueue from "../task-queue"; import * as store from "../store"; import * as expression from "../../common/expression"; -import * as funcCache from "../../common/func-cache"; - -const parseParameter = funcCache.getter(p => { - p = expression.parse(p); - if (Array.isArray(p) && p[0] === "PARAM") p = p[1]; - return p; -}); +import * as expressionParser from "../../common/expression-parser"; const component = { oninit: vnode => { - vnode.state.object = parseParameter(vnode.attrs.parameter); + let obj = expression.parse(vnode.attrs.parameter); + if (!Array.isArray(obj) || !obj[0] === "PARAM") + throw new Error("Object must be a parameter path"); + vnode.state.object = obj[1]; vnode.state.parameters = Object.values(vnode.attrs.childParameters).map( parameter => { return Object.assign({}, parameter, { - parameter: parseParameter(parameter.parameter) + parameter: expression.parse(parameter.parameter) }); } ); @@ -46,22 +43,24 @@ const component = { const rows = []; for (let i of instances) { - const row = parameters.map(p => - m( + const row = parameters.map(p => { + const param = expressionParser.map(p.parameter, e => { + if (Array.isArray(e) && e[0] === "PARAM") + return ["PARAM", ["||", i, ".", e[1]]]; + return e; + }); + return m( "td", m( components.get(p.type || "parameter"), Object.assign({}, p, { device: device, - parameter: `${i}.${store.evaluateExpression( - p.parameter, - device - )}`, + parameter: expressionParser.stringify(param), label: "" }) ) - ) - ); + ); + }); if (device[i].writable === true) row.push( diff --git a/client/components/parameter.js b/client/components/parameter.js index 0b339ec..0871252 100644 --- a/client/components/parameter.js +++ b/client/components/parameter.js @@ -2,19 +2,53 @@ import m from "mithril"; import * as taskQueue from "../task-queue"; +import * as store from "../store"; +import * as expression from "../../common/expression"; +import memoize from "../../common/memoize"; + +const memoizedParse = memoize(expression.parse); + +const evaluateParam = memoize((exp, obj, now) => { + let timestamp = now; + let params = new Set(); + let value = expression.evaluate( + memoizedParse(exp), + obj, + now, + e => { + if (Array.isArray(e)) { + if (e[0] === "PARAM") { + params.add(e[1]); + if (!Array.isArray(e[1])) { + let p = obj[e[1]]; + if (p && p.valueTimestamp) + timestamp = Math.min(timestamp, p.valueTimestamp); + } + } + if (e[0] === "FUNC" && e[1] === "DATE_STRING" && !Array.isArray(e[2])) + return new Date(e[2]).toLocaleString(); + } + return e; + } + ); + + let parameter = params.size === 1 ? params.values().next().value : null; + if (Array.isArray(parameter)) parameter = null; + return { value, timestamp, parameter }; +}); const component = { view: vnode => { const device = vnode.attrs.device; - const parameter = vnode.attrs.parameter; - const param = device[parameter]; - if (!param || !param.value) return m("span.na", "N/A"); - let value = param.value[0]; - if (param.value[1] === "xsd:dateTime") - value = new Date(value).toLocaleString(); + + let { value, timestamp, parameter } = evaluateParam( + vnode.attrs.parameter, + vnode.attrs.device, + store.getTimestamp() + ); let edit; - if (param.writable) + if (device[parameter] && device[parameter].writable) edit = m( "button", { @@ -23,19 +57,20 @@ const component = { taskQueue.stageSpv({ name: "setParameterValues", device: device["DeviceID.ID"].value[0], - parameterValues: [[parameter, param.value[0], param.value[1]]] + parameterValues: [ + [ + parameter, + device[parameter].value[0], + device[parameter].value[1] + ] + ] }); } }, "✎" ); - return m( - "span.parameter-value", - { title: param.valueTimestamp }, - value, - edit - ); + return m("span.parameter-value", { title: timestamp }, value, edit); } }; diff --git a/client/components/summon-button.js b/client/components/summon-button.js index f379049..8afdc4b 100644 --- a/client/components/summon-button.js +++ b/client/components/summon-button.js @@ -4,21 +4,12 @@ import m from "mithril"; import * as taskQueue from "../task-queue"; import * as store from "../store"; import * as notifications from "../notifications"; -import * as funcCache from "../../common/func-cache"; import * as expression from "../../common/expression"; +import memoize from "../../common/memoize"; -const parseParameter = funcCache.getter(p => { - p = expression.parse(p); - if (Array.isArray(p) && p[0] === "PARAM") p = p[1]; - return p; -}); +const memoizedParse = memoize(expression.parse); const component = { - oninit: vnode => { - vnode.state.parameters = Object.values(vnode.attrs.parameters).map( - parameter => parseParameter(parameter) - ); - }, view: vnode => { const device = vnode.attrs.device; @@ -28,9 +19,14 @@ const component = { title: "Initiate session and refresh basic parameters", onclick: e => { e.target.disabled = true; - const params = vnode.state.parameters.map(p => - store.evaluateExpression(p, device) - ); + const params = Object.values(vnode.attrs.parameters) + .map(p => { + const exp = memoizedParse(p); + if (!Array.isArray(exp) || !exp[0] == "PARAM") return null; + return store.evaluateExpression(exp[1], device); + }) + .filter(exp => !!exp); + const task = { name: "getParameterValues", parameterNames: params, diff --git a/client/devices-page.js b/client/devices-page.js index 6ca53f4..685cf8a 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -9,20 +9,31 @@ import * as components from "./components"; import * as taskQueue from "./task-queue"; import * as notifications from "./notifications"; import * as expression from "../common/expression"; +import memoize from "../common/memoize"; + +const memoizedParse = memoize(expression.parse); + +const getDownloadUrl = memoize((filter, indexParameters) => { + const columns = {}; + for (const p of indexParameters) columns[p.label] = p.parameter; + return `/api/devices.csv?${m.buildQueryString({ + filter: filter, + columns: JSON.stringify(columns) + })}`; +}); + +const getChildAttrs = memoize((attrs, device) => + Object.assign({}, attrs, { device: device }) +); function init(args) { return new Promise((resolve, reject) => { if (!window.authorizer.hasAccess("devices", 2)) return reject(new Error("You are not authorized to view this page")); - let filter = null; - if (args.filter != null) filter = expression.parse(`${args.filter}`); - let indexParameters = Object.values(config.ui.index).map(p => { - let param = expression.parse(p.parameter); - if (Array.isArray(param) && param[0] === "PARAM") param = param[1]; - return Object.assign({}, p, { parameter: param }); - }); - resolve({ filter: filter, indexParameters: indexParameters }); + const filter = args.filter; + const indexParameters = Object.values(config.ui.index); + resolve({ filter, indexParameters }); }); } @@ -31,7 +42,8 @@ function renderTable( parameters, total, selected, - showMoreCallback + showMoreCallback, + downloadUrl ) { const devices = devicesResponse.value; const selectAll = m("input", { @@ -75,10 +87,7 @@ function renderTable( }, m("td", checkbox), parameters.map(p => { - const attrs = Object.assign({}, p, { - device: device, - parameter: store.evaluateExpression(p.parameter, device) - }); + const attrs = getChildAttrs(p, device); const comp = m(components.get(attrs.type || "parameter"), attrs); return m("td", comp); }), @@ -119,6 +128,9 @@ function renderTable( ) ); + if (downloadUrl) + footerElements.push(m("a.download-csv", { href: downloadUrl }, "Download")); + let tfoot = m( "tfoot", m("tr", m("td", { colspan: labels.length }, footerElements)) @@ -280,20 +292,16 @@ const component = { } function onFilterChanged(filter) { - let params = {}; - if (filter != null) params.filter = expression.stringify(filter); - m.route.set("/devices", params); + m.route.set("/devices", { filter }); } - let devs = store.fetch( - "devices", - vnode.attrs.filter == null ? true : vnode.attrs.filter, - { limit: vnode.state.showCount || 10 } - ); - let count = store.count( - "devices", - vnode.attrs.filter == null ? true : vnode.attrs.filter - ); + const filter = vnode.attrs.filter + ? memoizedParse(vnode.attrs.filter) + : true; + let devs = store.fetch("devices", filter, { + limit: vnode.state.showCount || 10 + }); + let count = store.count("devices", filter); let selected = new Set(); if (vnode.state.selected) @@ -302,6 +310,11 @@ const component = { selected.add(d["DeviceID.ID"].value[0]); vnode.state.selected = selected; + const downloadUrl = getDownloadUrl( + vnode.attrs.filter, + vnode.attrs.indexParameters + ); + return [ m("h1", "Listing devices"), m(filterComponent, { @@ -314,7 +327,8 @@ const component = { vnode.attrs.indexParameters, count.value, selected, - showMore + showMore, + downloadUrl ), renderActions(selected) ]; diff --git a/client/faults-page.js b/client/faults-page.js index a80294f..d2bd054 100644 --- a/client/faults-page.js +++ b/client/faults-page.js @@ -6,6 +6,22 @@ import filterComponent from "./filter-component"; import * as store from "./store"; import * as notifications from "./notifications"; import * as expression from "../common/expression"; +import memoize from "../common/memoize"; + +const memoizedParse = memoize(expression.parse); + +const getDownloadUrl = memoize(filter => { + return `/api/faults.csv?${m.buildQueryString({ + filter: filter, + columns: JSON.stringify({ + Device: "device", + Channel: "channel", + Code: "code", + Retries: "retries", + Timestamp: "DATE_STRING(timestamp)" + }) + })}`; +}); function init(args) { if (!window.authorizer.hasAccess("faults", 2)) @@ -13,12 +29,17 @@ function init(args) { new Error("You are not authorized to view this page") ); - let filter = null; - if (args.filter != null) filter = expression.parse(`${args.filter}`); - return Promise.resolve({ filter: filter }); + const filter = args.filter; + return Promise.resolve({ filter }); } -function renderTable(faultsResponse, total, selected, showMoreCallback) { +function renderTable( + faultsResponse, + total, + selected, + showMoreCallback, + downloadUrl +) { const faults = faultsResponse.value; const selectAll = m("input", { type: "checkbox", @@ -101,6 +122,9 @@ function renderTable(faultsResponse, total, selected, showMoreCallback) { ) ); + if (downloadUrl) + footerElements.push(m("a.download-csv", { href: downloadUrl }, "Download")); + let tfoot = m( "tfoot", m("tr", m("td", { colspan: labels.length }, footerElements)) @@ -152,20 +176,17 @@ const component = { } function onFilterChanged(filter) { - let params = {}; - if (filter != null) params.filter = expression.stringify(filter); - m.route.set("/faults", params); + m.route.set("/faults", { filter }); } - let faults = store.fetch( - "faults", - vnode.attrs.filter == null ? true : vnode.attrs.filter, - { limit: vnode.state.showCount || 10 } - ); - let count = store.count( - "faults", - vnode.attrs.filter == null ? true : vnode.attrs.filter - ); + const filter = vnode.attrs.filter + ? memoizedParse(vnode.attrs.filter) + : true; + + let faults = store.fetch("faults", filter, { + limit: vnode.state.showCount || 10 + }); + let count = store.count("faults", filter); let selected = new Set(); if (vnode.state.selected) @@ -173,6 +194,8 @@ const component = { if (vnode.state.selected.has(f["_id"])) selected.add(f["_id"]); vnode.state.selected = selected; + const downloadUrl = getDownloadUrl(vnode.attrs.filter); + return [ m("h1", "Listing faults"), m(filterComponent, { @@ -186,7 +209,7 @@ const component = { filter: vnode.attrs.filter, onChange: onFilterChanged }), - renderTable(faults, count.value, selected, showMore) + renderTable(faults, count.value, selected, showMore, downloadUrl) ]; } }; diff --git a/client/filter-component.js b/client/filter-component.js index 34c4df7..f2e3e56 100644 --- a/client/filter-component.js +++ b/client/filter-component.js @@ -1,8 +1,20 @@ "use strict"; import m from "mithril"; - import * as expression from "../common/expression"; +import memoize from "../common/memoize"; + +const splitFilter = memoize(filter => { + if (!filter) return [""]; + const list = []; + const f = expression.parse(filter); + if (Array.isArray(f) && f[0] === "AND") + for (let ff of f.slice(1)) list.push(expression.stringify(ff)); + else list.push(expression.stringify(f)); + + list.push(""); + return list; +}); const component = { view: vnode => { @@ -12,25 +24,15 @@ const component = { predefined.map(f => m("option", { value: `${f.parameter} = ` })) ); - if (vnode.attrs.filter !== vnode.state.filter) { - vnode.state.filterList = []; + if (!vnode.state.filterList || vnode.attrs.filter !== vnode.state.filter) { + vnode.state.filterInvalid = 0; vnode.state.filter = vnode.attrs.filter; - if (vnode.attrs.filter != null) - if (vnode.attrs.filter[0] === "AND") - for (let i = 1; i < vnode.attrs.filter.length; ++i) - vnode.state.filterList.push( - expression.stringify(vnode.attrs.filter[i]) - ); - else - vnode.state.filterList.push(expression.stringify(vnode.attrs.filter)); - vnode.state.filterList.push(""); + vnode.state.filterList = splitFilter(vnode.attrs.filter); } function onChange() { vnode.state.filterInvalid = 0; - vnode.state.filterList = vnode.state.filterList.filter( - f => f && f.trim() - ); + vnode.state.filterList = vnode.state.filterList.filter(f => f); let filter = vnode.state.filterList.map((f, idx) => { try { return expression.parse(f); @@ -38,11 +40,18 @@ const component = { vnode.state.filterInvalid |= 1 << idx; } }); - if (filter.length) filter = ["AND"].concat(filter); - else filter = null; - vnode.state.filterList.push(""); - if (!vnode.state.filterInvalid) vnode.attrs.onChange(filter); + + if (!vnode.state.filterInvalid) { + delete vnode.state.filter; + if (filter.length === 0) { + vnode.attrs.onChange(""); + } else { + if (filter.length > 1) filter = ["AND"].concat(filter); + else filter = filter[0]; + vnode.attrs.onChange(expression.stringify(filter)); + } + } } return m( @@ -55,7 +64,8 @@ const component = { class: `${(vnode.state.filterInvalid >> idx) & 1 ? "error" : ""}`, value: fltr, onchange: e => { - vnode.state.filterList[idx] = e.target.value; + vnode.state.filterList = vnode.state.filterList.slice(); + vnode.state.filterList[idx] = e.target.value.trim(); onChange(); } }); diff --git a/client/overview-page.js b/client/overview-page.js index 302cc50..b0c4b29 100644 --- a/client/overview-page.js +++ b/client/overview-page.js @@ -4,8 +4,10 @@ import m from "mithril"; import config from "./config"; import * as store from "./store"; import pieChartComponent from "./pie-chart-component"; -import * as funcCache from "../common/func-cache"; import * as expression from "../common/expression"; +import memoize from "../common/memoize"; + +const memoizedParse = memoize(expression.parse); const GROUPS = config.ui.overview.groups; const CHARTS = {}; @@ -19,7 +21,7 @@ function queryCharts(charts) { charts[chartName] = chart = Object.assign({}, chart); chart.slices = Object.assign({}, chart.slices); for (let [sliceName, slice] of Object.entries(chart.slices)) { - const filter = funcCache.get(expression.parse, slice.filter); + const filter = memoizedParse(slice.filter); chart.slices[sliceName] = slice = Object.assign({}, slice); slice.count = store.count("devices", filter); } diff --git a/client/store.js b/client/store.js index 83b7e84..ccc259b 100644 --- a/client/store.js +++ b/client/store.js @@ -4,13 +4,13 @@ import m from "mithril"; import config from "./config"; import * as expressionParser from "../common/expression-parser"; import * as expression from "../common/expression"; -import * as funcCache from "../common/func-cache"; +import memoize from "../common/memoize"; + +const memoizedStringify = memoize(expression.stringify); +const memoizedEvaluate = memoize(expression.evaluate); let fulfillTimestamp = 0; -let unpackExpressionCache = new WeakMap(); -let evaluateExpressionCache = new WeakMap(); - const queries = { filter: new WeakMap(), bookmark: new WeakMap(), @@ -45,16 +45,12 @@ class QueryResponse { function unpackExpression(exp) { if (!Array.isArray(exp)) return exp; - let e = unpackExpressionCache.get(exp); - if (e === undefined) { - e = expression.evaluate(e, null, fulfillTimestamp); - unpackExpressionCache.set(exp, e); - } + const e = memoizedEvaluate(exp, null, fulfillTimestamp); return e; } function count(resourceType, filter) { - const filterStr = funcCache.get(expression.stringify, filter); + const filterStr = memoizedStringify(filter); let queryResponse = resources[resourceType].count.get(filterStr); if (queryResponse) return queryResponse; @@ -195,7 +191,7 @@ function inferQuery(resourceType, queryResponse) { } function fetch(resourceType, filter, options = {}) { - const filterStr = funcCache.get(expression.stringify, filter); + const filterStr = memoizedStringify(filter); const sort = options.sort || {}; const limit = options.limit || 0; if (resourceType === "devices") @@ -219,9 +215,6 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { let updated = false; if (_fulfillTimestamp > fulfillTimestamp) { - unpackExpressionCache = new WeakMap(); - evaluateExpressionCache = new WeakMap(); - for (let resource of Object.values(resources)) resource.combinedFilter = null; fulfillTimestamp = _fulfillTimestamp; @@ -250,7 +243,7 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { url: `/api/${resourceType}/?` + m.buildQueryString({ - filter: funcCache.get(expression.stringify, filter) + filter: memoizedStringify(filter) }), extract: xhr => +xhr.getResponseHeader("x-total-count"), background: true @@ -295,7 +288,7 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { url: `/api/${resourceType}/?` + m.buildQueryString({ - filter: funcCache.get(expression.stringify, filter), + filter: memoizedStringify(filter), limit: 1, skip: limit - 1, sort: JSON.stringify(sort), @@ -382,10 +375,7 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { url: `/api/${resourceType}/?` + m.buildQueryString({ - filter: funcCache.get( - expression.stringify, - combinedFilterDiff - ) + filter: memoizedStringify(combinedFilterDiff) }) }) .then(res => { @@ -488,16 +478,7 @@ function deleteResource(resourceType, id) { function evaluateExpression(exp, obj) { if (!Array.isArray(exp)) return exp; - - let exps = evaluateExpressionCache.get(exp); - if (!exps) evaluateExpressionCache.set(exp, (exps = new WeakMap())); - - if (!exps.has(obj)) { - let v = expression.evaluate(exp, obj, fulfillTimestamp); - exps.set(obj, v); - } - - return exps.get(obj); + return memoizedEvaluate(exp, obj, fulfillTimestamp); } function logIn(username, password) { diff --git a/common/authorizer.js b/common/authorizer.js index 03e0122..0288142 100644 --- a/common/authorizer.js +++ b/common/authorizer.js @@ -10,7 +10,7 @@ const validators = { class Authorizer { constructor(permissionSets) { this.permissionSets = permissionSets; - this.validtorCache = new WeakMap(); + this.validatorCache = new WeakMap(); this.hasAccessCache = {}; } @@ -34,8 +34,8 @@ class Authorizer { } getValidator(resourceType, resource) { - if (this.validtorCache.has(resource)) - return this.validtorCache.get(resource); + if (this.validatorCache.has(resource)) + return this.validatorCache.get(resource); let funcs = {}; @@ -70,7 +70,7 @@ class Authorizer { return valid; }; - this.validtorCache.set(resource, validator); + this.validatorCache.set(resource, validator); return validator; } } diff --git a/common/expression.js b/common/expression.js index 290ee7a..77256dc 100644 --- a/common/expression.js +++ b/common/expression.js @@ -36,7 +36,7 @@ function reduce(exp, callback) { return exp; } -function evaluate(exp, obj, now) { +function evaluate(exp, obj, now, cb) { function getRegExp(pat, esc) { const k = `${esc || ""}:${pat}`; let c = regExpCache.get(exp); @@ -46,6 +46,7 @@ function evaluate(exp, obj, now) { } return expressionParser.map(exp, e => { + if (cb) e = cb(e); if (!isArray(e)) return e; if (e[0] === "FUNC") { diff --git a/common/func-cache.js b/common/func-cache.js deleted file mode 100644 index 4c9f081..0000000 --- a/common/func-cache.js +++ /dev/null @@ -1,29 +0,0 @@ -"use strict"; - -let cache1 = new WeakMap(); -let cache2 = new WeakMap(); - -function get(func, k) { - let c1 = cache1.get(func); - if (!c1) cache1.set(func, (c1 = new Map())); - if (c1.has(k)) return c1.get(k); - const c2 = cache2.get(func); - const v = c2 && c2.has(k) ? c2.get(k) : func(k); - c1.set(k, v); - return v; -} - -function getter(func) { - return k => get(func, k); -} - -function purge() { - cache2 = cache1; - cache1 = new WeakMap(); -} - -setInterval(purge, 120000); - -exports.get = get; -exports.getter = getter; -exports.purge = purge; diff --git a/common/memoize.js b/common/memoize.js new file mode 100644 index 0000000..9a13007 --- /dev/null +++ b/common/memoize.js @@ -0,0 +1,47 @@ +"use strict"; + +let cache1 = new Map(); +let cache2 = new Map(); +const keys = new WeakMap(); + +function getKey(obj) { + if (obj === null) return "null"; + else if (obj === undefined) return "undefined"; + + const t = typeof obj; + if (t === "number" || t === "boolean" || t === "string") return `${t}:${obj}`; + if (t !== "function" && t !== "object") + throw new Error(`Cannot memoize ${t} arguments`); + + let k = keys.get(obj); + if (!k) { + const rnd = Math.trunc(Math.random() * Number.MAX_SAFE_INTEGER); + k = `${t}:${rnd.toString(36)}`; + keys.set(obj, k); + } + return k; +} + +function memoize(func) { + return (...args) => { + const key = JSON.stringify(args.map(getKey)) + getKey(func); + + if (cache1.has(key)) return cache1.get(key); + + let r; + if (cache2.has(key)) r = cache2.get(key); + else r = func(...args); + cache1.set(key, r); + return r; + }; +} + +const interval = setInterval(() => { + cache2 = cache1; + cache1 = new Map(); +}, 120000); + +// Don't hold Node.js process +if (interval.unref) interval.unref(); + +module.exports = memoize; diff --git a/config.json b/config.json index e83fbce..32dfac5 100644 --- a/config.json +++ b/config.json @@ -95,7 +95,7 @@ }, { "label": "Last inform", - "parameter": "Events.Inform", + "parameter": "DATE_STRING(Events.Inform)", "type": "container", "element": "span.inform", "components": [ @@ -161,7 +161,7 @@ "type": "container", "label": "Last inform", "element": "span.inform", - "parameter": "Events.Inform", + "parameter": "DATE_STRING(Events.Inform)", "components": [ { "type": "parameter" diff --git a/server/api.js b/server/api.js index 1774e1e..9d4eb0b 100644 --- a/server/api.js +++ b/server/api.js @@ -3,7 +3,7 @@ const zlib = require("zlib"); const Router = require("koa-router"); const PassThrough = require("stream").PassThrough; - +const config = require("./config"); const db = require("./db"); const apiFunctions = require("./api-functions"); const expression = require("../common/expression"); @@ -102,6 +102,88 @@ for (let [resource, flags] of Object.entries(resources)) { stream.end("]"); }); + // CSV download + router.get(`/${resource}.csv`, async (ctx, next) => { + let options = {}; + let filter = true; + if (ctx.request.query.filter) + filter = expression.parse(ctx.request.query.filter); + if (ctx.request.query.limit) options.limit = +ctx.request.query.limit; + if (ctx.request.query.skip) options.skip = +ctx.request.query.skip; + if (ctx.request.query.sort) + options.sort = JSON.parse(ctx.request.query.sort); + + if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); + + const columns = JSON.parse(ctx.request.query.columns); + const now = Date.now(); + for (let [k, v] of Object.entries(columns)) + columns[k] = expression.evaluate( + expression.parse(v), + null, + now + ); + + // Exclude temporary tasks and faults + if (resource === "tasks" || resource === "faults") + filter = expression.and(filter, [ + "NOT", + ["<", ["PARAM", "expiry"], Date.now() + 60000] + ]); + + let stream; + switch (ctx.acceptsEncodings("gzip", "deflate", "identity")) { + case "gzip": + stream = zlib.createGzip(); + ctx.set("Content-Encoding", "gzip"); + break; + case "deflate": + stream = zlib.createDeflate(); + ctx.set("Content-Encoding", "deflate"); + break; + default: + stream = new PassThrough(); + break; + } + + ctx.body = stream; + ctx.type = "text/csv"; + + stream.write( + Object.keys(columns).map(k => `"${k.replace(/"/, '""')}"`) + "\n" + ); + await db.query(resource, filter, options, obj => { + let arr = Object.values(columns).map(exp => { + let v = expression.evaluate(exp, obj, null, e => { + if (Array.isArray(e)) + if (e[0] === "PARAM") { + if (resource === "devices") { + if (e[1] === "Tags") { + let tags = []; + for (let p in obj) + if (p.startsWith("Tags.")) tags.push(p.slice(5)); + + return tags.join(", "); + } + } + } else if (e[0] === "FUNC") { + if (e[1] === "DATE_STRING") + if (e[2] && !Array.isArray(e[2])) + return new Date(e[2]).toISOString(); + } + + return e; + }); + + if (Array.isArray(v) || v == null) return ""; + if (typeof v === "string") return `"${v.replace(/"/g, '""')}"`; + return v; + }); + stream.write(arr.join(",") + "\n"); + }); + stream.end(); + }); + router.head(`/${resource}/:id`, async (ctx, next) => { let filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); diff --git a/server/db.js b/server/db.js index f1eb19a..dafaf92 100644 --- a/server/db.js +++ b/server/db.js @@ -35,7 +35,7 @@ function getClient(db) { function query(resource, filter, options, callback) { options = options || {}; let q; - filter = expression.evaluate(filter, null, null, Date.now()); + filter = expression.evaluate(filter, null, Date.now()); if (Array.isArray(filter)) { if (resource === "devices") @@ -79,6 +79,10 @@ function query(resource, filter, options, callback) { if (err) return reject(err); if (resource === "devices") docs = docs.map(d => mongodbFunctions.flattenDevice(d)); + else if (resource === "faults") + docs = docs.map(d => mongodbFunctions.flattenFault(d)); + else if (resource === "tasks") + docs = docs.map(d => mongodbFunctions.flattenTask(d)); return resolve(docs); }); else @@ -86,6 +90,10 @@ function query(resource, filter, options, callback) { doc => { if (resource === "devices") doc = mongodbFunctions.flattenDevice(doc); + else if (resource === "faults") + doc = mongodbFunctions.flattenFault(doc); + else if (resource === "tasks") + doc = mongodbFunctions.flattenTask(doc); callback(doc); }, err => { @@ -99,7 +107,7 @@ function query(resource, filter, options, callback) { function count(resource, filter) { let q; - filter = expression.evaluate(filter, null, null, Date.now()); + filter = expression.evaluate(filter, null, Date.now()); if (Array.isArray(filter)) { if (resource === "devices") diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index f0eb680..8be074e 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -337,6 +337,25 @@ function flattenDevice(device) { return newDevice; } +function flattenFault(fault) { + const f = Object.assign({}, fault); + if (f.timestamp) f.timestamp = +f.timestamp; + if (f.expiry) f.expiry = +f.expiry; + if (f.detail) { + for (let [k, v] of Object.entries(f.detail)) fault[`detail.${k}`] = v; + delete f.detail; + } + return f; +} + +function flattenTask(task) { + let t = Object.assign({}, task); + t._id = "" + t._id; + if (t.timestamp) t.timestamp = +t.timestamp; + if (t.expiry) t.expiry = +t.expiry; + return t; +} + exports.processDeviceFilter = processDeviceFilter; exports.processTasksFilter = processTasksFilter; exports.processFaultsFilter = processFaultsFilter; @@ -344,3 +363,5 @@ exports.filterToMongoQuery = filterToMongoQuery; exports.processDeviceProjection = processDeviceProjection; exports.processDeviceSort = processDeviceSort; exports.flattenDevice = flattenDevice; +exports.flattenFault = flattenFault; +exports.flattenTask = flattenTask; From aa14189531d999a937b0a7361b9a1a5154a62a20 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Mon, 13 Aug 2018 21:37:53 -0700 Subject: [PATCH 039/106] Redirect to login when not authorized --- client/app.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/app.js b/client/app.js index fef3830..11069ab 100644 --- a/client/app.js +++ b/client/app.js @@ -11,6 +11,7 @@ import * as devicePage from "./device-page"; import * as errorPage from "./error-page"; import * as faultsPage from "./faults-page"; import Authorizer from "../common/authorizer"; +import * as notifications from "./notifications"; window.authorizer = new Authorizer(window.permissionSets); @@ -41,7 +42,7 @@ function pagify(pageName, page) { } }; - component.onmatch = args => { + component.onmatch = (args, requestedPath) => { pageVisitTimestamp = Date.now(); if (!page.init) { state = null; @@ -59,6 +60,10 @@ function pagify(pageName, page) { fulfill(); }) .catch(err => { + if (!window.username && err.message.indexOf("authorized") >= 0) { + notifications.push("error", err.message); + m.route.set("/login", { continue: requestedPath }); + } state = { error: err.message }; resolve(); }); From 562cbec57328b11ff22c38e06e3ce3da32d13063 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Tue, 14 Aug 2018 01:49:30 -0700 Subject: [PATCH 040/106] Use pageSize config --- client/devices-page.js | 6 ++++-- client/faults-page.js | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/client/devices-page.js b/client/devices-page.js index 685cf8a..0ede1c8 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -11,6 +11,8 @@ import * as notifications from "./notifications"; import * as expression from "../common/expression"; import memoize from "../common/memoize"; +const PAGE_SIZE = config.ui.pageSize || 10; + const memoizedParse = memoize(expression.parse); const getDownloadUrl = memoize((filter, indexParameters) => { @@ -287,7 +289,7 @@ const component = { document.title = "Devices - GenieACS"; function showMore() { - vnode.state.showCount = (vnode.state.showCount || 10) + 10; + vnode.state.showCount = (vnode.state.showCount || PAGE_SIZE) + PAGE_SIZE; m.redraw(); } @@ -299,7 +301,7 @@ const component = { ? memoizedParse(vnode.attrs.filter) : true; let devs = store.fetch("devices", filter, { - limit: vnode.state.showCount || 10 + limit: vnode.state.showCount || PAGE_SIZE }); let count = store.count("devices", filter); diff --git a/client/faults-page.js b/client/faults-page.js index d2bd054..bccbf54 100644 --- a/client/faults-page.js +++ b/client/faults-page.js @@ -1,13 +1,15 @@ "use strict"; import m from "mithril"; - +import config from "./config"; import filterComponent from "./filter-component"; import * as store from "./store"; import * as notifications from "./notifications"; import * as expression from "../common/expression"; import memoize from "../common/memoize"; +const PAGE_SIZE = config.ui.pageSize || 10; + const memoizedParse = memoize(expression.parse); const getDownloadUrl = memoize(filter => { @@ -171,7 +173,7 @@ const component = { document.title = "Faults - GenieACS"; function showMore() { - vnode.state.showCount = (vnode.state.showCount || 10) + 10; + vnode.state.showCount = (vnode.state.showCount || PAGE_SIZE) + PAGE_SIZE; m.redraw(); } @@ -184,7 +186,7 @@ const component = { : true; let faults = store.fetch("faults", filter, { - limit: vnode.state.showCount || 10 + limit: vnode.state.showCount || PAGE_SIZE }); let count = store.count("faults", filter); From 6266ab5422d2ca8ca944f5a62f34c455097c3d1e Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Tue, 14 Aug 2018 01:51:10 -0700 Subject: [PATCH 041/106] Hide null params in parameter-list --- client/components/parameter-list.js | 26 +++++++++++++++----------- client/components/parameter.js | 2 ++ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/client/components/parameter-list.js b/client/components/parameter-list.js index 194fdb0..2f6b540 100644 --- a/client/components/parameter-list.js +++ b/client/components/parameter-list.js @@ -12,19 +12,23 @@ const component = { view: vnode => { const device = vnode.attrs.device; - const rows = Object.values(vnode.attrs.parameters).map(parameter => - m( + const rows = Object.values(vnode.attrs.parameters).map(parameter => { + const p = m( + components.get(parameter.type || "parameter"), + getChildAttrs(parameter, device) + ); + + return m( "tr", + { + onupdate: vn => { + vn.dom.style.display = p.dom ? "" : "none"; + } + }, m("th", parameter.label), - m( - "td", - m( - components.get(parameter.type || "parameter"), - getChildAttrs(parameter, device) - ) - ) - ) - ); + m("td", p) + ); + }); return m("table.parameter-list", rows); } diff --git a/client/components/parameter.js b/client/components/parameter.js index 0871252..4683233 100644 --- a/client/components/parameter.js +++ b/client/components/parameter.js @@ -47,6 +47,8 @@ const component = { store.getTimestamp() ); + if (value == null) return null; + let edit; if (device[parameter] && device[parameter].writable) edit = m( From c2d6c85e5d9452b348fa14a711c9624bb740eb3e Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Tue, 14 Aug 2018 16:17:27 -0700 Subject: [PATCH 042/106] Fix chunked encoding issue --- server/api.js | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/server/api.js b/server/api.js index 9d4eb0b..6c2fca3 100644 --- a/server/api.js +++ b/server/api.js @@ -2,7 +2,6 @@ const zlib = require("zlib"); const Router = require("koa-router"); -const PassThrough = require("stream").PassThrough; const config = require("./config"); const db = require("./db"); const apiFunctions = require("./api-functions"); @@ -79,19 +78,21 @@ for (let [resource, flags] of Object.entries(resources)) { let stream; switch (ctx.acceptsEncodings("gzip", "deflate", "identity")) { case "gzip": - stream = zlib.createGzip(); + stream = zlib.createGzip({ flush: zlib.constants.Z_SYNC_FLUSH }); + stream.pipe(ctx.res); ctx.set("Content-Encoding", "gzip"); break; case "deflate": - stream = zlib.createDeflate(); + stream = zlib.createDeflate({ flush: zlib.constants.Z_SYNC_FLUSH }); + stream.pipe(ctx.res); ctx.set("Content-Encoding", "deflate"); break; default: - stream = new PassThrough(); + stream = ctx.res; break; } - ctx.body = stream; + ctx.body = ctx.res; ctx.type = "application/json"; let c = 0; @@ -134,20 +135,25 @@ for (let [resource, flags] of Object.entries(resources)) { let stream; switch (ctx.acceptsEncodings("gzip", "deflate", "identity")) { case "gzip": - stream = zlib.createGzip(); + stream = zlib.createGzip({ flush: zlib.constants.Z_SYNC_FLUSH }); + stream.pipe(ctx.res); ctx.set("Content-Encoding", "gzip"); break; case "deflate": - stream = zlib.createDeflate(); + stream = zlib.createDeflate({ flush: zlib.constants.Z_SYNC_FLUSH }); + stream.pipe(ctx.res); ctx.set("Content-Encoding", "deflate"); break; default: - stream = new PassThrough(); + stream = ctx.res; break; } - ctx.body = stream; + ctx.body = ctx.res; ctx.type = "text/csv"; + ctx.attachment( + `${resource}-${new Date(now).toISOString().replace(/[:.]/g, "")}.csv` + ); stream.write( Object.keys(columns).map(k => `"${k.replace(/"/, '""')}"`) + "\n" From 5a8d93423906adace9592674f4c1a3b33509325b Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Wed, 15 Aug 2018 22:10:28 +0300 Subject: [PATCH 043/106] Add presets page --- client/app.js | 4 +- client/menu.js | 9 ++ client/presets-page.js | 232 ++++++++++++++++++++++++++++++++++++ client/store.js | 2 +- config.json | 9 ++ server/db.js | 4 + server/mongodb-functions.js | 93 +++++++++++++++ server/server.js | 9 +- 8 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 client/presets-page.js diff --git a/client/app.js b/client/app.js index 11069ab..68d93a7 100644 --- a/client/app.js +++ b/client/app.js @@ -10,6 +10,7 @@ import * as devicesPage from "./devices-page"; import * as devicePage from "./device-page"; import * as errorPage from "./error-page"; import * as faultsPage from "./faults-page"; +import * as presetsPage from "./presets-page"; import Authorizer from "../common/authorizer"; import * as notifications from "./notifications"; @@ -78,5 +79,6 @@ m.route(document.body, "/overview", { "/overview": pagify("overview", overviewPage), "/devices": pagify("devices", devicesPage), "/devices/:id": pagify("devices", devicePage), - "/faults": pagify("faults", faultsPage) + "/faults": pagify("faults", faultsPage), + "/presets": pagify("presets", presetsPage) }); diff --git a/client/menu.js b/client/menu.js index 05b3b94..2f23854 100644 --- a/client/menu.js +++ b/client/menu.js @@ -34,6 +34,15 @@ const menu = { ) ); + if (window.authorizer.hasAccess("presets", 2)) + tabs.push( + m( + "li", + { class: active["presets"] }, + m("a", { href: "#!/presets" }, "Presets") + ) + ); + return m("nav", m("ul", tabs)); } }; diff --git a/client/presets-page.js b/client/presets-page.js new file mode 100644 index 0000000..7e3bd7e --- /dev/null +++ b/client/presets-page.js @@ -0,0 +1,232 @@ +"use strict"; + +import m from "mithril"; + +import config from "./config"; +import filterComponent from "./filter-component"; +import * as store from "./store"; +import * as expression from "../common/expression"; +import * as notifications from "./notifications"; +import memoize from "../common/memoize"; + +const PAGE_SIZE = config.ui.pageSize || 10; + +const memoizedParse = memoize(expression.parse); + +const getDownloadUrl = memoize(filter => { + return `/api/presets.csv?${m.buildQueryString({ + filter: filter, + columns: JSON.stringify({ + Name: "_id", + Channel: "channel", + Weight: "weight", + Schedule: "schedule", + Events: "events", + Precondition: "precondition", + Provision: "provision", + Arguments: "provision_args" + }) + })}`; +}); + +function init(args) { + if (!window.authorizer.hasAccess("presets", 2)) + return Promise.reject( + new Error("You are not authorized to view this page") + ); + + const filter = args.filter; + return Promise.resolve({ filter }); +} + +function renderTable( + presetsResponse, + total, + selected, + showMoreCallback, + downloadUrl +) { + const presets = presetsResponse.value; + const selectAll = m("input", { + type: "checkbox", + checked: presets.length && selected.size === presets.length, + onchange: e => { + for (let preset of presets) + if (e.target.checked) selected.add(preset["_id"]); + else selected.delete(preset["_id"]); + }, + disabled: !total + }); + + const labels = [ + selectAll, + "Name", + "Channel", + "Weight", + "Schedule", + "Events", + "Precondition", + "Provision", + "Arguments", + "" + ].map(l => m("th", l)); + + let rows = []; + for (let preset of presets) { + let checkbox = m("input", { + type: "checkbox", + checked: selected.has(preset["_id"]), + onchange: e => { + if (e.target.checked) selected.add(preset["_id"]); + else selected.delete(preset["_id"]); + }, + onclick: e => { + e.stopPropagation(); + e.redraw = false; + } + }); + + let devicesUrl = "/#!/devices"; + if (preset["precondition"].length) + devicesUrl += `?${m.buildQueryString({ + filter: preset["precondition"] + })}`; + + rows.push( + m( + "tr", + { + onclick: e => { + if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { + e.redraw = false; + return; + } + + if (!selected.delete(preset["_id"])) selected.add(preset["_id"]); + } + }, + m("td", checkbox), + m("td", preset["_id"]), + m("td", preset["channel"]), + m("td", preset["weight"]), + m("td", preset["schedule"]), + m("td", preset["events"]), + m("td", { title: preset["precondition"] }, preset["precondition"]), + m("td", preset["provision"]), + m("td", preset["provision_args"]), + m("td", m("a", { href: devicesUrl }, "Devices")) + ) + ); + } + + if (!rows.length) + rows.push(m("tr.empty", m("td", { colspan: labels.length }, "No presets"))); + + let footerElements = []; + if (total != null) footerElements.push(`${presets.length}/${total}`); + else footerElements.push(`${presets.length}`); + + footerElements.push( + m( + "button", + { + title: "Show more presets", + onclick: showMoreCallback, + disabled: presets.length >= total || !presetsResponse.fulfilled + }, + "More" + ) + ); + + if (downloadUrl) + footerElements.push(m("a.download-csv", { href: downloadUrl }, "Download")); + + let tfoot = m( + "tfoot", + m("tr", m("td", { colspan: labels.length }, footerElements)) + ); + + const buttons = [ + m( + "button.primary", + { + title: "Delete selected presets", + disabled: !selected.size, + onclick: e => { + e.redraw = false; + e.target.disabled = true; + Promise.all( + Array.from(selected).map(id => store.deleteResource("presets", id)) + ) + .then(res => { + notifications.push("success", `${res.length} presets deleted`); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } + }, + "Delete" + ) + ]; + + return [ + m( + "table.table.highlight", + m("thead", m("tr", labels)), + m("tbody", rows), + tfoot + ), + buttons + ]; +} + +const component = { + view: vnode => { + document.title = "Presets - GenieACS"; + + function showMore() { + vnode.state.showCount = (vnode.state.showCount || PAGE_SIZE) + PAGE_SIZE; + m.redraw(); + } + + function onFilterChanged(filter) { + m.route.set("/presets", { filter }); + } + + const filter = vnode.attrs.filter + ? memoizedParse(vnode.attrs.filter) + : true; + + let presets = store.fetch("presets", filter, { + limit: vnode.state.showCount || PAGE_SIZE + }); + let count = store.count("presets", filter); + + let selected = new Set(); + if (vnode.state.selected) + for (let preset of presets.value) + if (vnode.state.selected.has(preset["_id"])) + selected.add(preset["_id"]); + vnode.state.selected = selected; + + const downloadUrl = getDownloadUrl(vnode.attrs.filter); + + return [ + m("h1", "Listing presets"), + m(filterComponent, { + predefined: [ + { parameter: "_id" }, + { parameter: "channel" }, + { parameter: "weight" } + ], + filter: vnode.attrs.filter, + onChange: onFilterChanged + }), + renderTable(presets, count.value, selected, showMore, downloadUrl) + ]; + } +}; + +export { init, component }; diff --git a/client/store.js b/client/store.js index ccc259b..5227ba4 100644 --- a/client/store.js +++ b/client/store.js @@ -23,7 +23,7 @@ const queries = { }; const resources = {}; -for (let r of ["devices", "faults", "files"]) +for (let r of ["devices", "faults", "files", "presets"]) resources[r] = { objects: new Map(), count: new Map(), diff --git a/config.json b/config.json index 32dfac5..126c213 100644 --- a/config.json +++ b/config.json @@ -34,6 +34,12 @@ "pass": true } }, + "presets": { + "access": 3, + "validate": { + "pass": true + } + }, "files": { "access": 3 } @@ -47,6 +53,9 @@ "faults": { "access": 2 }, + "presets": { + "access": 2 + }, "files": { "access": 2 } diff --git a/server/db.js b/server/db.js index dafaf92..d8be805 100644 --- a/server/db.js +++ b/server/db.js @@ -83,6 +83,8 @@ function query(resource, filter, options, callback) { docs = docs.map(d => mongodbFunctions.flattenFault(d)); else if (resource === "tasks") docs = docs.map(d => mongodbFunctions.flattenTask(d)); + else if (resource === "presets") + docs = docs.map(d => mongodbFunctions.flattenPreset(d)); return resolve(docs); }); else @@ -94,6 +96,8 @@ function query(resource, filter, options, callback) { doc = mongodbFunctions.flattenFault(doc); else if (resource === "tasks") doc = mongodbFunctions.flattenTask(doc); + else if (resource === "presets") + doc = mongodbFunctions.flattenPreset(doc); callback(doc); }, err => { diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index 8be074e..a319dc1 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -356,6 +356,98 @@ function flattenTask(task) { return t; } +function mongoQueryToFilter(query) { + let expressions = []; + function recursive(_query) { + for (let [k, v] of Object.entries(_query)) + if (k[0] == "$") { + if (k == "$and") for (let vv of Object.values(v)) recursive(vv); + else throw new Error(`Operator ${k} not supported`); + } else if (k == "_tags") { + if (typeof v === "object") { + if (Array.isArray(v)) throw new Error(`Invalid type`); + + for (let [kk, vv] of Object.entries(v)) + if (kk == "$ne") + expressions.push(["IS NULL", ["PARAM", `Tags.${vv}`]]); + else throw new Error(`Operator ${kk} not supported`); + } else { + expressions.push(["IS NOT NULL", ["PARAM", `Tags.${v}`]]); + } + } else if (typeof v === "object") { + if (Array.isArray(v)) throw new Error(`Invalid type`); + + for (let [kk, vv] of Object.entries(v)) { + let op; + switch (kk) { + case "$eq": + op = "="; + break; + case "$ne": + op = "<>"; + break; + case "$lt": + op = "<"; + break; + case "$lte": + op = "<="; + break; + case "$gt": + op = ">"; + break; + case "$gte": + op = ">="; + break; + default: + throw new Error(`Operator ${kk} not supported`); + } + expressions.push([op, ["PARAM", k], vv]); + } + } else { + expressions.push(["=", ["PARAM", k], v]); + } + } + recursive(query); + + if (expressions.length > 1) return ["AND"].concat(expressions); + + if (expressions.length == 1) return expressions[0]; + + return expressions; +} + +function flattenPreset(preset) { + let p = Object.assign({}, preset); + if (p.precondition) { + p.precondition = mongoQueryToFilter(JSON.parse(p.precondition)); + p.precondition = p.precondition.length + ? expression.stringify(p.precondition) + : ""; + } + + if (p.events) { + let e = []; + for (let [k, v] of Object.entries(p.events)) e.push(v ? k : `-${k}`); + p.events = e.join(", "); + } + + let provision = p.configurations[0]; + if ( + p.configurations.length != 1 || + provision.type != "provision" || + !provision.name || + !provision.name.length + ) + throw new Error("Invalid preset provision"); + p.provision = provision.name; + p.provision_args = + provision.args && provision.args.length + ? `"${provision.args.join('" "')}"` + : ""; + delete p.configurations; + return p; +} + exports.processDeviceFilter = processDeviceFilter; exports.processTasksFilter = processTasksFilter; exports.processFaultsFilter = processFaultsFilter; @@ -365,3 +457,4 @@ exports.processDeviceSort = processDeviceSort; exports.flattenDevice = flattenDevice; exports.flattenFault = flattenFault; exports.flattenTask = flattenTask; +exports.flattenPreset = flattenPreset; diff --git a/server/server.js b/server/server.js index 73a84ab..dfda269 100644 --- a/server/server.js +++ b/server/server.js @@ -26,7 +26,14 @@ function getPermissionSets(roles) { p = Object.assign({}, p); for (let [k, v] of Object.entries(p)) { p[k] = v = Object.assign({}, v); - if (["devices", "faults", "files"].includes(k)) + if ( + [ + "devices", + "faults", + "presets", + "files" + ].includes(k) + ) if (v.filter == null || v.filter === "") v.filter = true; else { v.filter = expression.parse(v.filter); From a4a1e722aa3d96780b11c16c57fc32c5877c52b2 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Thu, 16 Aug 2018 01:14:00 +0300 Subject: [PATCH 044/106] Fix href issue in Safari --- client/pie-chart-component.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/pie-chart-component.js b/client/pie-chart-component.js index 745e116..add0044 100644 --- a/client/pie-chart-component.js +++ b/client/pie-chart-component.js @@ -67,7 +67,7 @@ const drawChart = function(chartData) { m( "a", { - href: `/#!/devices/?${m.buildQueryString({ + "xlink:href": `/#!/devices/?${m.buildQueryString({ filter: slice.filter })}` }, From 14e0e95da578def890bf02837f9aea4752ef0f7a Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Mon, 20 Aug 2018 22:03:11 +0300 Subject: [PATCH 045/106] Preset's query as devices url Make the preset's query to hold it's devices url. --- client/presets-page.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/client/presets-page.js b/client/presets-page.js index 7e3bd7e..797c03f 100644 --- a/client/presets-page.js +++ b/client/presets-page.js @@ -67,8 +67,7 @@ function renderTable( "Events", "Precondition", "Provision", - "Arguments", - "" + "Arguments" ].map(l => m("th", l)); let rows = []; @@ -111,10 +110,13 @@ function renderTable( m("td", preset["weight"]), m("td", preset["schedule"]), m("td", preset["events"]), - m("td", { title: preset["precondition"] }, preset["precondition"]), + m( + "td", + { title: preset["precondition"] }, + m("a", { href: devicesUrl }, preset["precondition"]) + ), m("td", preset["provision"]), - m("td", preset["provision_args"]), - m("td", m("a", { href: devicesUrl }, "Devices")) + m("td", preset["provision_args"]) ) ); } From 600f8a8eed2230e0d50863a6a829df73728675e5 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Thu, 16 Aug 2018 01:14:00 +0300 Subject: [PATCH 046/106] Fix attribute name Fixing attribute name in presets to align with the followed convention. --- client/presets-page.js | 4 ++-- server/mongodb-functions.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/presets-page.js b/client/presets-page.js index 797c03f..f6c019a 100644 --- a/client/presets-page.js +++ b/client/presets-page.js @@ -24,7 +24,7 @@ const getDownloadUrl = memoize(filter => { Events: "events", Precondition: "precondition", Provision: "provision", - Arguments: "provision_args" + Arguments: "provisionArgs" }) })}`; }); @@ -116,7 +116,7 @@ function renderTable( m("a", { href: devicesUrl }, preset["precondition"]) ), m("td", preset["provision"]), - m("td", preset["provision_args"]) + m("td", preset["provisionArgs"]) ) ); } diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index a319dc1..a470130 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -440,7 +440,7 @@ function flattenPreset(preset) { ) throw new Error("Invalid preset provision"); p.provision = provision.name; - p.provision_args = + p.provisionArgs = provision.args && provision.args.length ? `"${provision.args.join('" "')}"` : ""; From 087658cddf5bc4ce9b474844d957319fcead2029 Mon Sep 17 00:00:00 2001 From: shweelan Date: Sun, 2 Sep 2018 02:07:19 +0300 Subject: [PATCH 047/106] Fix overlay not closing on popstate (#10) Fix overlay not closing when back/forward browser buttons used. --- client/overlay.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/overlay.js b/client/overlay.js index 9cc6c1e..ae857a1 100644 --- a/client/overlay.js +++ b/client/overlay.js @@ -58,4 +58,8 @@ document.addEventListener("keydown", e => { if (e.keyCode === 27 && close(overlayCallback)) m.redraw(); }); +window.addEventListener("popstate", () => { + if (close(overlayCallback)) m.redraw(); +}); + export { open, close, render }; From 8ef67efd835a9389e8b238582a999284f44f4c28 Mon Sep 17 00:00:00 2001 From: shweelan Date: Fri, 7 Sep 2018 01:06:30 +0300 Subject: [PATCH 048/106] Fix download error showing in console (#12) Fix error showing in safari/chrome console when download link is clicked --- client/devices-page.js | 4 +++- client/faults-page.js | 4 +++- client/presets-page.js | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/client/devices-page.js b/client/devices-page.js index 0ede1c8..b53843e 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -131,7 +131,9 @@ function renderTable( ); if (downloadUrl) - footerElements.push(m("a.download-csv", { href: downloadUrl }, "Download")); + footerElements.push( + m("a.download-csv", { href: downloadUrl, download: "" }, "Download") + ); let tfoot = m( "tfoot", diff --git a/client/faults-page.js b/client/faults-page.js index bccbf54..a70298a 100644 --- a/client/faults-page.js +++ b/client/faults-page.js @@ -125,7 +125,9 @@ function renderTable( ); if (downloadUrl) - footerElements.push(m("a.download-csv", { href: downloadUrl }, "Download")); + footerElements.push( + m("a.download-csv", { href: downloadUrl, download: "" }, "Download") + ); let tfoot = m( "tfoot", diff --git a/client/presets-page.js b/client/presets-page.js index f6c019a..1acb843 100644 --- a/client/presets-page.js +++ b/client/presets-page.js @@ -141,7 +141,9 @@ function renderTable( ); if (downloadUrl) - footerElements.push(m("a.download-csv", { href: downloadUrl }, "Download")); + footerElements.push( + m("a.download-csv", { href: downloadUrl, download: "" }, "Download") + ); let tfoot = m( "tfoot", From afa2e6d9282620eab2407f5d524d60af15672f87 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Fri, 31 Aug 2018 18:31:09 +0300 Subject: [PATCH 049/106] Add logging functionality Add logging on every API call, and on server starting and listenning. --- config.json | 4 + server/api.js | 140 +++++++++++++++++++++++--- server/logger.js | 248 +++++++++++++++++++++++++++++++++++++++++++++++ server/server.js | 33 ++++++- 4 files changed, 411 insertions(+), 14 deletions(-) create mode 100644 server/logger.js diff --git a/config.json b/config.json index 126c213..1217810 100644 --- a/config.json +++ b/config.json @@ -282,5 +282,9 @@ "type": "device-actions" } ] + }, + "logger": { + "logFormat": "simple", + "accessLogFormat": "simple" } } diff --git a/server/api.js b/server/api.js index 6c2fca3..0843fbd 100644 --- a/server/api.js +++ b/server/api.js @@ -6,9 +6,15 @@ const config = require("./config"); const db = require("./db"); const apiFunctions = require("./api-functions"); const expression = require("../common/expression"); +const logger = require("./logger"); const router = new Router(); +function logUnauthorizedWarning(log) { + log.message += " not authorized"; + logger.accessWarn(log); +} + const RESOURCE_DELETE = 1 << 0; const RESOURCE_IDS = { @@ -37,7 +43,16 @@ for (let [resource, flags] of Object.entries(resources)) { if (ctx.request.query.filter) filter = expression.parse(ctx.request.query.filter); - if (!ctx.state.authorizer.hasAccess(resource, 1)) return next(); + const log = { + message: `Count ${resource}`, + context: ctx, + filter: ctx.request.query.filter + }; + + if (!ctx.state.authorizer.hasAccess(resource, 1)) { + logUnauthorizedWarning(log); + return next(); + } // Exclude temporary tasks and faults if (resource === "tasks" || resource === "faults") @@ -50,6 +65,9 @@ for (let [resource, flags] of Object.entries(resources)) { ctx.set("X-Total-Count", count); ctx.body = ""; + + log.count = count; + logger.accessInfo(log); }); router.get(`/${resource}`, async (ctx, next) => { @@ -66,7 +84,20 @@ for (let [resource, flags] of Object.entries(resources)) { .split(",") .reduce((obj, k) => Object.assign(obj, { [k]: 1 }), {}); - if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); + const log = { + message: `Query ${resource}`, + context: ctx, + filter: ctx.request.query.filter, + limit: options.limit, + skip: options.skip, + sort: options.sort, + projection: options.projection + }; + + if (!ctx.state.authorizer.hasAccess(resource, 2)) { + logUnauthorizedWarning(log); + return next(); + } // Exclude temporary tasks and faults if (resource === "tasks" || resource === "faults") @@ -101,6 +132,8 @@ for (let [resource, flags] of Object.entries(resources)) { stream.write((c++ ? "," : "") + JSON.stringify(obj) + "\n"); }); stream.end("]"); + + logger.accessInfo(log); }); // CSV download @@ -114,7 +147,20 @@ for (let [resource, flags] of Object.entries(resources)) { if (ctx.request.query.sort) options.sort = JSON.parse(ctx.request.query.sort); - if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); + const log = { + message: `Query ${resource} (CSV)`, + context: ctx, + filter: ctx.request.query.filter, + limit: options.limit, + skip: options.skip, + sort: options.sort, + projection: options.projection + }; + + if (!ctx.state.authorizer.hasAccess(resource, 2)) { + logUnauthorizedWarning(log); + return next(); + } const columns = JSON.parse(ctx.request.query.columns); const now = Date.now(); @@ -188,72 +234,144 @@ for (let [resource, flags] of Object.entries(resources)) { stream.write(arr.join(",") + "\n"); }); stream.end(); + + logger.accessInfo(log); }); router.head(`/${resource}/:id`, async (ctx, next) => { + const log = { + message: `Count ${resource}`, + context: ctx, + filter: `${RESOURCE_IDS[resource]} = "${ctx.params.id}"` + }; + let filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; - if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); + if (!ctx.state.authorizer.hasAccess(resource, 2)) { + logUnauthorizedWarning(log); + return next(); + } let res = await db.query(resource, filter); if (!res.length) return next(); + + logger.accessInfo(log); ctx.body = ""; }); router.get(`/${resource}/:id`, async (ctx, next) => { + const log = { + message: `Query ${resource}`, + context: ctx, + filter: `${RESOURCE_IDS[resource]} = "${ctx.params.id}"` + }; + let filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; - if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); + if (!ctx.state.authorizer.hasAccess(resource, 2)) { + logUnauthorizedWarning(log); + return next(); + } let res = await db.query(resource, filter); if (!res.length) return next(); + + logger.accessInfo(log); ctx.body = res[0]; }); // TODO add PUT, PATCH routes if (flags & RESOURCE_DELETE) router.delete(`/${resource}/:id`, async (ctx, next) => { + const log = { + message: `Delete ${resource}`, + context: ctx, + id: ctx.params.id + }; + const authorizer = ctx.state.authorizer; let filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; - if (!authorizer.hasAccess(resource, 2)) return next(); + if (!authorizer.hasAccess(resource, 2)) { + logUnauthorizedWarning(log); + return next(); + } let res = await db.query(resource, filter); if (!res.length) return next(); const validate = authorizer.getValidator(resource, res[0]); - if (!validate("delete")) return (ctx.status = 403); + if (!validate("delete")) { + logUnauthorizedWarning(log); + return (ctx.status = 403); + } await apiFunctions.deleteResource(resource, ctx.params.id); + + logger.accessInfo(log); + ctx.body = ""; }); } router.post("/devices/:id/tasks", async (ctx, next) => { + const log = { + message: "Commit tasks", + context: ctx, + deviceId: ctx.params.id + }; + const authorizer = ctx.state.authorizer; let filter = ["=", ["PARAM", "DeviceID.ID"], ctx.params.id]; - if (!authorizer.hasAccess("devices", 2)) return next(); + if (!authorizer.hasAccess("devices", 2)) { + logUnauthorizedWarning(log); + return next(); + } let devices = await db.query("devices", filter); if (!devices.length) return next(); const validate = authorizer.getValidator("devices", devices[0]); for (let t of ctx.request.body) - if (!validate("task", t)) return (ctx.status = 403); + if (!validate("task", t)) { + logUnauthorizedWarning(log); + return (ctx.status = 403); + } let res = await apiFunctions.postTasks(ctx.params.id, ctx.request.body); + + log.tasks = res.tasks.map(t => t._id).join(","); + + logger.accessInfo(log); + ctx.set("Connection-Request", res.connectionRequest); ctx.body = res.tasks; }); router.post("/devices/:id/tags", async (ctx, next) => { + const log = { + message: "Update tags", + context: ctx, + deviceId: ctx.params.id, + tags: ctx.request.body + }; + const authorizer = ctx.state.authorizer; let filter = ["=", ["PARAM", "DeviceID.ID"], ctx.params.id]; - if (!authorizer.hasAccess("devices", 2)) return next(); + if (!authorizer.hasAccess("devices", 2)) { + logUnauthorizedWarning(log); + return next(); + } let res = await db.query("devices", filter); if (!res.length) return next(); const validate = authorizer.getValidator("devices", res[0]); - if (!validate("tags", ctx.request.body)) return (ctx.status = 403); + if (!validate("tags", ctx.request.body)) { + logUnauthorizedWarning(log); + return (ctx.status = 403); + } await apiFunctions.updateTags(ctx.params.id, ctx.request.body); + + logger.accessInfo(log); + ctx.body = ""; }); diff --git a/server/logger.js b/server/logger.js new file mode 100644 index 0000000..7e388e8 --- /dev/null +++ b/server/logger.js @@ -0,0 +1,248 @@ +"use strict"; + +const fs = require("fs"); +const os = require("os"); + +const config = require("../config"); + +const REOPEN_EVERY = 60000; + +const LOG_FORMAT = config.logger.logFormat; +const ACCESS_LOG_FORMAT = config.logger.accessLogFormat || LOG_FORMAT; + +const defaultMeta = {}; + +let LOG_SYSTEMD = false; +let ACCESS_LOG_SYSTEMD = false; + +let LOG_FILE, ACCESS_LOG_FILE; + +let logStream = fs.createWriteStream(null, { fd: process.stderr.fd }); +let logStat = fs.fstatSync(logStream.fd); +let accessLogStream = fs.createWriteStream(null, { fd: process.stdout.fd }); +let accessLogStat = fs.fstatSync(accessLogStream.fd); + +// Reopen if original files have been moved (e.g. logrotate) +function reopen() { + let counter = 1; + + if (LOG_FILE) { + ++counter; + fs.stat(LOG_FILE, (err, stat) => { + if (err && !err.message.startsWith("ENOENT:")) throw err; + + if (!(stat && stat.dev === logStat.dev && stat.ino === logStat.ino)) { + logStream.end(); + logStream = fs.createWriteStream(null, { + fd: fs.openSync(LOG_FILE, "a") + }); + logStat = fs.fstatSync(logStream.fd); + } + + if (--counter === 0) + setTimeout(reopen, REOPEN_EVERY - (Date.now() % REOPEN_EVERY)).unref(); + }); + } + + if (ACCESS_LOG_FILE) { + ++counter; + fs.stat(ACCESS_LOG_FILE, (err, stat) => { + if (err && !err.message.startsWith("ENOENT:")) throw err; + + if ( + !( + stat && + stat.dev === accessLogStat.dev && + stat.ino === accessLogStat.ino + ) + ) { + accessLogStream.end(); + accessLogStream = fs.createWriteStream(null, { + fd: fs.openSync(ACCESS_LOG_FILE, "a") + }); + accessLogStat = fs.fstatSync(accessLogStream.fd); + } + + if (--counter === 0) + setTimeout(reopen, REOPEN_EVERY - (Date.now() % REOPEN_EVERY)).unref(); + }); + } + + if (--counter === 0) + setTimeout(reopen, REOPEN_EVERY - (Date.now() % REOPEN_EVERY)).unref(); +} + +function init(service, version) { + defaultMeta.hostname = os.hostname(); + defaultMeta.pid = process.pid; + defaultMeta.name = `genieacs-ui-${service}`; + defaultMeta.version = version; + + LOG_FILE = config.logger[`${service}LogFile`]; + ACCESS_LOG_FILE = config.logger[`${service}AccessLogFile`]; + + if (LOG_FILE) { + logStream = fs.createWriteStream(null, { fd: fs.openSync(LOG_FILE, "a") }); + logStat = fs.fstatSync(logStream.fd); + } + + if (ACCESS_LOG_FILE) { + accessLogStream = fs.createWriteStream(null, { + fd: fs.openSync(ACCESS_LOG_FILE, "a") + }); + accessLogStat = fs.fstatSync(accessLogStream.fd); + } + + // Determine if logs are going to journald + const JOURNAL_STREAM = process.env["JOURNAL_STREAM"]; + + if (JOURNAL_STREAM) { + let [dev, inode] = JOURNAL_STREAM.split(":"); + dev = parseInt(dev); + inode = parseInt(inode); + + LOG_SYSTEMD = logStat.dev === dev && logStat.ino === inode; + ACCESS_LOG_SYSTEMD = + accessLogStat.dev === dev && accessLogStat.ino === inode; + } + + if (LOG_FILE || ACCESS_LOG_FILE) + setTimeout(reopen, REOPEN_EVERY - (Date.now() % REOPEN_EVERY)).unref(); +} + +function close() { + accessLogStream.end(); + logStream.end(); +} + +function flatten(details) { + if (details.exception) { + details.exceptionName = details.exception.name; + details.exceptionMessage = details.exception.message; + details.exceptionStack = details.exception.stack; + delete details.exception; + } + + if (details.context) { + details.remoteAddress = details.context.request.ip; + if (details.context.state.user) + details.user = details.context.state.user.username; + delete details.context; + } + + for (const [k, v] of Object.entries(details)) + if (v == null) delete details[k]; + + return details; +} + +function formatJson(details, systemd) { + if (systemd) { + let severity = ""; + if (details.severity === "info") severity = "<6>"; + else if (details.severity === "warn") severity = "<4>"; + else if (details.severity === "error") severity = "<3>"; + + return `${severity}${JSON.stringify(flatten(details))}${os.EOL}`; + } + + return `${JSON.stringify(flatten(details))}${os.EOL}`; +} + +function formatSimple(details, systemd) { + flatten(details); + + let remote = ""; + if (details.remoteAddress) + if (details.user) remote = `${details.user}@${details.remoteAddress}: `; + else remote = `${details.remoteAddress}: `; + + const keys = Object.keys(details); + const skip = { + user: 1, + remoteAddress: 1, + severity: 1, + timestamp: 1, + message: 1 + }; + + let meta = ""; + + let kv = []; + for (let k of keys) + if (!skip[k]) kv.push(`${k}=${JSON.stringify(details[k])}`); + + if (kv.length) meta = `; ${kv.join(" ")}`; + + if (systemd) { + let severity = ""; + if (details.severity === "info") severity = "<6>"; + else if (details.severity === "warn") severity = "<4>"; + else if (details.severity === "error") severity = "<3>"; + + return `${severity}${remote}${details.message}${meta}${os.EOL}`; + } + + return `${details.timestamp} [${details.severity.toUpperCase()}] ${remote}${ + details.message + }${meta}${os.EOL}`; +} + +function log(details) { + details.timestamp = new Date().toISOString(); + if (LOG_FORMAT === "json") { + details = Object.assign({}, defaultMeta, details); + logStream.write(formatJson(details, LOG_SYSTEMD)); + } else { + logStream.write(formatSimple(details, LOG_SYSTEMD)); + } +} + +function info(details) { + details.severity = "info"; + log(details); +} + +function warn(details) { + details.severity = "warn"; + log(details); +} + +function error(details) { + details.severity = "error"; + log(details); +} + +function accessLog(details) { + details.timestamp = new Date().toISOString(); + if (ACCESS_LOG_FORMAT === "json") { + Object.assign(details, defaultMeta); + accessLogStream.write(formatJson(details, ACCESS_LOG_SYSTEMD)); + } else { + accessLogStream.write(formatSimple(details, ACCESS_LOG_SYSTEMD)); + } +} + +function accessInfo(details) { + details.severity = "info"; + accessLog(details); +} + +function accessWarn(details) { + details.severity = "warn"; + accessLog(details); +} + +function accessError(details) { + details.severity = "error"; + accessLog(details); +} + +exports.init = init; +exports.close = close; +exports.info = info; +exports.warn = warn; +exports.error = error; +exports.accessInfo = accessInfo; +exports.accessWarn = accessWarn; +exports.accessError = accessError; diff --git a/server/server.js b/server/server.js index dfda269..d0a8894 100644 --- a/server/server.js +++ b/server/server.js @@ -12,6 +12,7 @@ const config = require("./config"); const api = require("./api"); const Authorizer = require("../common/authorizer"); const expression = require("../common/expression"); +const logger = require("./logger"); const koa = new Koa(); const router = new Router(); @@ -19,6 +20,14 @@ const router = new Router(); const JWT_SECRET = config.server.jwtSecret; const JWT_COOKIE = "genieacs-ui-jwt"; +const VERSION = require("../package.json")["version"]; + +logger.info({ + message: "GenieACS UI starting", + pid: process.pid, + version: VERSION +}); + function getPermissionSets(roles) { const allPermissions = config.permissions; const permissionSets = roles.map(role => { @@ -69,9 +78,17 @@ router.post("/login", async ctx => { const user = config.auth.simple.users[username]; + let log = { + message: "Log in", + context: ctx, + username: username + }; + if (!user || user.password !== password) { ctx.status = 400; ctx.body = "Incorrect username or password"; + log.message += " - invalid credentials"; + logger.accessWarn(log); return; } @@ -81,11 +98,18 @@ router.post("/login", async ctx => { ); ctx.cookies.set(JWT_COOKIE, token); ctx.body = JSON.stringify(token); + + logger.accessInfo(log); }); router.post("/logout", async ctx => { ctx.cookies.set(JWT_COOKIE); // Delete cookie ctx.body = ""; + + logger.accessInfo({ + message: "Log out", + context: ctx + }); }); koa.use(koaBodyParser()); @@ -125,7 +149,10 @@ koa.use( koa.use(router.routes()); koa.use(koaStatic("./public")); -koa.listen(config.server.port, () => { - // eslint-disable-next-line no-console - console.log(`Server listening on port ${config.server.port}`); +koa.listen(config.server.port, "0.0.0.0", () => { + logger.info({ + message: "Worker litening", + port: config.server.port, + pid: process.pid + }); }); From 8b5cd384ab35c864fbceb02a3faffdaa48f995d4 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Fri, 7 Sep 2018 21:14:37 +0300 Subject: [PATCH 050/106] Fix a bug in db connections Fix a bug caused by concurrent calls to obtain db clients. --- server/db.js | 47 +++++++++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/server/db.js b/server/db.js index d8be805..e37bf1c 100644 --- a/server/db.js +++ b/server/db.js @@ -5,7 +5,7 @@ const config = require("./config"); const mongodbFunctions = require("./mongodb-functions"); const expression = require("../common/expression"); -const _client = {}; +let _clientPromise = null; const RESOURCE_DB = { devices: "genieacs", @@ -16,20 +16,21 @@ const RESOURCE_DB = { files: "genieacs" }; -function getClient(db) { - if (_client[db]) return Promise.resolve(_client[db]); +function getClient() { + if (!_clientPromise) + _clientPromise = new Promise((resolve, reject) => { + const CONNECTION_URL = config.server.mongodbConnectionUrl; + mongodb.MongoClient.connect( + CONNECTION_URL, + { useNewUrlParser: true }, + (err, client) => { + if (err) return reject(err); + resolve(client); + } + ); + }); - return new Promise((resolve, reject) => { - const CONNECTION_URL = config.server.mongodbConnectionUrl; - mongodb.MongoClient.connect( - CONNECTION_URL, - { useNewUrlParser: true }, - (err, client) => { - if (err) return reject(err); - resolve((_client[db] = client.db(db))); - } - ); - }); + return _clientPromise; } function query(resource, filter, options, callback) { @@ -50,8 +51,10 @@ function query(resource, filter, options, callback) { } return new Promise((resolve, reject) => { - getClient(RESOURCE_DB[resource]).then(client => { - const collection = client.collection(resource); + getClient().then(client => { + const collection = client + .db(RESOURCE_DB[resource]) + .collection(resource); let cursor = collection.find( q, resource === "Devices" @@ -126,8 +129,8 @@ function count(resource, filter) { } return new Promise((resolve, reject) => { - getClient(RESOURCE_DB[resource]).then(client => { - const collection = client.collection(resource); + getClient().then(client => { + const collection = client.db(RESOURCE_DB[resource]).collection(resource); collection.find(q).count((err, c) => { if (err) reject(err); else resolve(c); @@ -136,5 +139,13 @@ function count(resource, filter) { }); } +function disconnect() { + if (_clientPromise) + _clientPromise.then(client => { + client.close(); + }); +} + exports.query = query; exports.count = count; +exports.disconnect = disconnect; From 220d1a17480eb85ea3f4cbbbe2ed0db645c9c40c Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Sun, 9 Sep 2018 21:16:05 +0300 Subject: [PATCH 051/106] Implement cluster and exceptions handler --- config.json | 4 ++ package.json | 2 +- server/cluster.js | 110 ++++++++++++++++++++++++++++++++++++++++++++++ server/logger.js | 8 ++-- server/server.js | 59 ++++++++++++++++++++----- webpack.config.js | 10 ++++- 6 files changed, 175 insertions(+), 18 deletions(-) create mode 100644 server/cluster.js diff --git a/config.json b/config.json index 1217810..39ffa28 100644 --- a/config.json +++ b/config.json @@ -1,5 +1,7 @@ { "server": { + "workers": 4, + "interface": "0.0.0.0", "port": 3000, "nbi": "http://127.0.0.1:7557/", "mongodbConnectionUrl": "mongodb://127.0.0.1:27017", @@ -284,6 +286,8 @@ ] }, "logger": { + "logFile": "", + "accessLogFile": "", "logFormat": "simple", "accessLogFormat": "simple" } diff --git a/package.json b/package.json index 31186a4..fca4ee2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "test": "echo \"Error: no test specified\" && exit 1", "lint": "eslint --fix common/*.js common/validators/*.js server/*.js client/*.js client/components/*.js;prettier --write *.js common/*.js common/validators/*.js server/*.js client/*.js client/components/*.js client/css/*.css", "build": "npm run lint;webpack;postcss -c postcss.config.js client/css/app.css -o dist/public/app.css", - "start": "cd dist && node server" + "start": "cd dist && node cluster" }, "author": "Zaid Abdulla ", "dependencies": { diff --git a/server/cluster.js b/server/cluster.js new file mode 100644 index 0000000..f77434e --- /dev/null +++ b/server/cluster.js @@ -0,0 +1,110 @@ +"use strict"; + +const cluster = require("cluster"); +const config = require("./config"); +const logger = require("./logger"); + +const VERSION = require("../package.json")["version"]; + +let respawnTimestamp = 0; +let tooManyCrashesTimestamp = 0; + +function restartWorker(worker, code, signal) { + const msg = { + message: "Worker died", + pid: worker.process.pid + }; + + if (code != null) msg.exitCode = code; + + if (signal != null) msg.signal = signal; + + logger.error(msg); + + const now = Date.now(); + respawnTimestamp = Math.max(now, respawnTimestamp + 2000); + if (respawnTimestamp === now) { + tooManyCrashesTimestamp = now; + cluster.fork(); + return; + } + + if (now - tooManyCrashesTimestamp > 60000) { + process.exitCode = 1; + cluster.removeListener("exit", restartWorker); + for (const pid in cluster.workers) cluster.workers[pid].kill(); + + logger.error({ + message: "Too many crashes, exiting", + pid: process.pid + }); + return; + } + + setTimeout(() => { + if (process.exitCode) return; + cluster.fork(); + }, respawnTimestamp - now); +} + +function start() { + logger.init(VERSION); + + logger.info({ + message: "GenieACS UI starting", + pid: process.pid, + version: VERSION + }); + + cluster.on("listening", (worker, address) => { + logger.info({ + message: "Worker listening", + pid: worker.process.pid, + address: address.address, + port: address.port + }); + }); + + cluster.on("exit", restartWorker); + + process.on("SIGINT", () => { + logger.info({ + message: "Received signal SIGINT, exiting", + pid: process.pid + }); + + cluster.removeListener("exit", restartWorker); + }); + + process.on("SIGTERM", () => { + logger.info({ + message: "Received signal SIGTERM, exiting", + pid: process.pid + }); + + cluster.removeListener("exit", restartWorker); + for (const pid in cluster.workers) cluster.workers[pid].kill(); + }); + + cluster.setupMaster({ exec: "./server" }); + + let workerCount = config.server.workers; + + if (!workerCount) workerCount = Math.max(2, require("os").cpus().length); + + for (let i = 0; i < workerCount; ++i) cluster.fork(); +} + +process.on("uncaughtException", err => { + logger.error({ + message: "Uncaught exception in master process, exiting", + exception: err, + pid: process.pid + }); + process.exitCode = 1; + cluster.removeListener("exit", restartWorker); + for (const pid in cluster.workers) cluster.workers[pid].kill(); +}); + +// Starting GenieACS UI +start(); diff --git a/server/logger.js b/server/logger.js index 7e388e8..aab3345 100644 --- a/server/logger.js +++ b/server/logger.js @@ -72,14 +72,14 @@ function reopen() { setTimeout(reopen, REOPEN_EVERY - (Date.now() % REOPEN_EVERY)).unref(); } -function init(service, version) { +function init(version) { defaultMeta.hostname = os.hostname(); defaultMeta.pid = process.pid; - defaultMeta.name = `genieacs-ui-${service}`; + defaultMeta.name = "genieacs-ui"; defaultMeta.version = version; - LOG_FILE = config.logger[`${service}LogFile`]; - ACCESS_LOG_FILE = config.logger[`${service}AccessLogFile`]; + LOG_FILE = config.logger.logFile; + ACCESS_LOG_FILE = config.logger.accessLogFile; if (LOG_FILE) { logStream = fs.createWriteStream(null, { fd: fs.openSync(LOG_FILE, "a") }); diff --git a/server/server.js b/server/server.js index d0a8894..e759443 100644 --- a/server/server.js +++ b/server/server.js @@ -1,5 +1,7 @@ "use strict"; +const http = require("http"); +const cluster = require("cluster"); const Koa = require("koa"); const Router = require("koa-router"); const koaStatic = require("koa-static"); @@ -13,6 +15,10 @@ const api = require("./api"); const Authorizer = require("../common/authorizer"); const expression = require("../common/expression"); const logger = require("./logger"); +const db = require("./db"); + +const VERSION = require("../package.json")["version"]; +logger.init("server", VERSION); const koa = new Koa(); const router = new Router(); @@ -20,14 +26,6 @@ const router = new Router(); const JWT_SECRET = config.server.jwtSecret; const JWT_COOKIE = "genieacs-ui-jwt"; -const VERSION = require("../package.json")["version"]; - -logger.info({ - message: "GenieACS UI starting", - pid: process.pid, - version: VERSION -}); - function getPermissionSets(roles) { const allPermissions = config.permissions; const permissionSets = roles.map(role => { @@ -149,10 +147,47 @@ koa.use( koa.use(router.routes()); koa.use(koaStatic("./public")); -koa.listen(config.server.port, "0.0.0.0", () => { - logger.info({ - message: "Worker litening", - port: config.server.port, +let server = http + .createServer(koa.callback()) + .listen(config.server.port, config.server.interface); + +function closeServer(timeout) { + return new Promise(resolve => { + setTimeout(() => { + if (!resolve) return; + // Ignore HTTP requests from alive connections + server.removeListener("request", koa.callback()); + server.setTimeout(1); + + setTimeout(resolve, 1000); + }, timeout).unref(); + + // prevent new sockets to connect and close server eventually. + server.close(resolve); + }); +} + +function exit() { + setTimeout(() => { + process.exit(1); + }, 30000).unref(); + + closeServer(20000).then(() => { + db.disconnect(); + if (cluster.worker) cluster.worker.disconnect(); + logger.close(); + }); +} + +process.on("uncaughtException", err => { + logger.error({ + message: "Uncaught exception", + exception: err, pid: process.pid }); + process.exitCode = 1; + exit(); }); + +process.on("SIGINT", exit); +process.on("SIGTERM", exit); diff --git a/webpack.config.js b/webpack.config.js index 4de8a8b..d2aee54 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -8,7 +8,7 @@ function transformPackageJson(content) { const pkg = JSON.parse(content); delete pkg["devDependencies"]; pkg["scripts"] = { - start: "node server" + start: "node cluster" }; return JSON.stringify(pkg, null, 2); } @@ -39,6 +39,14 @@ module.exports = [ ) ] }, + { + mode: mode, + entry: "./server/cluster.js", + target: "node", + output: { + filename: "cluster.js" + } + }, { mode: mode, entry: "./client/app.js", From ad3dfeae7f6597d924f242ea664a72bfab650d92 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Fri, 7 Sep 2018 21:43:15 +0300 Subject: [PATCH 052/106] Implement presets new/edit functionality --- client/components/device-actions.js | 2 +- client/components/put-form.js | 125 +++++++++++++++++++ client/css/app.css | 11 +- client/devices-page.js | 2 +- client/presets-page.js | 184 ++++++++++++++++++++++------ client/store.js | 27 ++++ common/expression.js | 2 +- server/api-functions.js | 25 ++++ server/api.js | 45 ++++++- server/mongodb-functions.js | 50 +++++++- 10 files changed, 427 insertions(+), 46 deletions(-) create mode 100644 client/components/put-form.js diff --git a/client/components/device-actions.js b/client/components/device-actions.js index 523c123..e2df56b 100644 --- a/client/components/device-actions.js +++ b/client/components/device-actions.js @@ -83,7 +83,7 @@ const component = { ) ); - return m(".device-actions", buttons); + return m(".actions-bar", buttons); } }; diff --git a/client/components/put-form.js b/client/components/put-form.js new file mode 100644 index 0000000..bbc8eba --- /dev/null +++ b/client/components/put-form.js @@ -0,0 +1,125 @@ +"use strict"; + +import m from "mithril"; + +const singular = { + presets: "preset" +}; + +function createField(current, attr, focus) { + if (attr.type === "combo") { + let options = [m("option", { value: "" }, "--Please choose--")]; + for (let op of attr.options) options.push(m("option", { value: op }, op)); + + let selected = ""; + if (attr.options.includes(current.object[attr.id])) + selected = current.object[attr.id]; + return m( + "select", + { + name: attr.id, + value: selected, + oncreate: focus + ? _vnode => { + _vnode.dom.focus(); + } + : null, + onchange: e => { + current.object[attr.id] = e.target.value; + e.redraw = false; + } + }, + options + ); + } + + return m("input", { + type: "text", + name: attr.id, + disabled: attr.id === "_id" && !current.isNew, + value: current.object[attr.id], + oncreate: focus + ? _vnode => { + _vnode.dom.focus(); + } + : null, + oninput: e => { + current.object[attr.id] = e.target.value; + e.redraw = false; + } + }); +} + +const component = { + view: vnode => { + let actionHandler = vnode.attrs.actionHandler; + let attributes = vnode.attrs.attributes; + let resource = vnode.attrs.resource; + let base = vnode.attrs.base || {}; + if (!vnode.state.current) + vnode.state.current = { + isNew: !base["_id"], + object: Object.assign({}, base) + }; + + let current = vnode.state.current; + + let form = []; + let focused = false; + for (let attr of attributes) { + let focus = false; + if (!focused && (current.isNew || attr.id !== "_id")) + focus = focused = true; + + form.push( + m( + "p", + m("label", { for: attr.id }, attr.label || attr.id), + m("br"), + createField(current, attr, focus) + ) + ); + } + + let buttons = [m("button.primary", { type: "submit" }, "Save")]; + + if (!current.isNew) + buttons.push( + m( + "button.primary", + { + type: "button", + title: `Delete ${singular[resource]}`, + onclick: e => { + e.redraw = false; + e.target.disabled = true; + actionHandler("delete", current.object); + } + }, + "Delete" + ) + ); + + form.push(m(".actions-bar", buttons)); + + let children = [ + m("h1", `${current.isNew ? "New" : "Editing"} ${singular[resource]}`), + m( + "form", + { + onsubmit: e => { + e.target.disabled = true; + e.preventDefault(); + e.redraw = false; + actionHandler("save", current.object); + } + }, + form + ) + ]; + + return m("div.put-form", children); + } +}; + +export default component; diff --git a/client/css/app.css b/client/css/app.css index 5d477ca..f8f0788 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -355,6 +355,7 @@ button { } } +select, input { display: block; border: 1px solid var(--color1); @@ -519,7 +520,7 @@ span.na { clear: both; } -.device-actions { +.actions-bar { margin-top: 10px; & > button { margin-right: 10px; @@ -556,8 +557,8 @@ span.tag { justify-content: center; & > .overlay { - max-width: 90%; - max-height: 90%; + max-width: 90vw; + max-height: 90vh; background-color: white; border: 1px solid var(--color1); border-radius: 10px; @@ -565,3 +566,7 @@ span.tag { box-shadow: 0 0 15px #aaa; } } + +.overlay > .put-form { + min-width: 700px; +} diff --git a/client/devices-page.js b/client/devices-page.js index b53843e..fa1ae09 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -283,7 +283,7 @@ function renderActions(selected) { ) ); - return m(".device-actions", buttons); + return m(".actions-bar", buttons); } const component = { diff --git a/client/presets-page.js b/client/presets-page.js index 1acb843..ab4159b 100644 --- a/client/presets-page.js +++ b/client/presets-page.js @@ -4,28 +4,92 @@ import m from "mithril"; import config from "./config"; import filterComponent from "./filter-component"; +import * as overlay from "./overlay"; import * as store from "./store"; import * as expression from "../common/expression"; import * as notifications from "./notifications"; +import putForm from "./components/put-form"; import memoize from "../common/memoize"; const PAGE_SIZE = config.ui.pageSize || 10; const memoizedParse = memoize(expression.parse); +const attributes = [ + { id: "_id", label: "Name" }, + { id: "channel", label: "Channel" }, + { id: "weight", label: "Weight" }, + { id: "schedule", label: "Schedule" }, + { id: "events", label: "Events" }, + { id: "precondition", label: "Precondition" }, + { id: "provision", label: "Provision" }, + { id: "provisionArgs", label: "Arguments" } +]; + +function putActoinHandler(action, object, isNew) { + if (action === "save") { + let id = object["_id"]; + delete object["_id"]; + + if (!id) return notifications.push("error", "ID can not be empty"); + + store + .resourceExists("presets", id) + .then(exists => { + if (exists && isNew) { + notifications.push("error", "Preset already exists"); + store.fulfill(0, Date.now()); + return; + } + + if (!exists && !isNew) { + notifications.push("error", "Preset already deleted"); + store.fulfill(0, Date.now()); + return; + } + + store + .putResource("presets", id, object) + .then(() => { + notifications.push( + "success", + `Preset ${exists ? "updated" : "created"}` + ); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } else if (action === "delete") { + store + .deleteResource("presets", object["_id"]) + .then(() => { + notifications.push("success", "Preset deleted"); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } else { + throw new Error("Undefined action"); + } +} + +let formData = { + resource: "presets", + attributes: attributes +}; + const getDownloadUrl = memoize(filter => { + let cols = {}; + for (let attr of attributes) cols[attr.label] = attr.id; return `/api/presets.csv?${m.buildQueryString({ filter: filter, - columns: JSON.stringify({ - Name: "_id", - Channel: "channel", - Weight: "weight", - Schedule: "schedule", - Events: "events", - Precondition: "precondition", - Provision: "provision", - Arguments: "provisionArgs" - }) + columns: JSON.stringify(cols) })}`; }); @@ -58,17 +122,9 @@ function renderTable( disabled: !total }); - const labels = [ - selectAll, - "Name", - "Channel", - "Weight", - "Schedule", - "Events", - "Precondition", - "Provision", - "Arguments" - ].map(l => m("th", l)); + const labels = [selectAll] + .concat(attributes.map(elem => elem.label)) + .map(l => m("th", l)); let rows = []; for (let preset of presets) { @@ -91,6 +147,48 @@ function renderTable( filter: preset["precondition"] })}`; + let tds = [m("td", checkbox)]; + for (let attr of attributes) + if (attr.id === "precondition") + tds.push( + m( + "td", + { title: preset[attr.id] }, + m("a", { href: devicesUrl }, preset[attr.id]) + ) + ); + else tds.push(m("td", preset[attr.id])); + + tds.push( + m( + "td.table-row-links", + m( + "a", + { + onclick: () => { + let cb = () => { + return m( + putForm, + Object.assign( + { + base: preset, + actionHandler: (action, object) => { + overlay.close(cb); + putActoinHandler(action, object, false); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "Show" + ) + ) + ); + rows.push( m( "tr", @@ -104,19 +202,7 @@ function renderTable( if (!selected.delete(preset["_id"])) selected.add(preset["_id"]); } }, - m("td", checkbox), - m("td", preset["_id"]), - m("td", preset["channel"]), - m("td", preset["weight"]), - m("td", preset["schedule"]), - m("td", preset["events"]), - m( - "td", - { title: preset["precondition"] }, - m("a", { href: devicesUrl }, preset["precondition"]) - ), - m("td", preset["provision"]), - m("td", preset["provisionArgs"]) + tds ) ); } @@ -175,6 +261,34 @@ function renderTable( ) ]; + if (window.authorizer.hasAccess("presets", 3)) + buttons.push( + m( + "button.primary", + { + title: "Create new preset", + onclick: () => { + let cb = () => { + return m( + putForm, + Object.assign( + { + actionHandler: (action, object) => { + putActoinHandler(action, object, true); + overlay.close(cb); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "New" + ) + ); + return [ m( "table.table.highlight", @@ -182,7 +296,7 @@ function renderTable( m("tbody", rows), tfoot ), - buttons + m("div.actions-bar", buttons) ]; } diff --git a/client/store.js b/client/store.js index 5227ba4..f32764b 100644 --- a/client/store.js +++ b/client/store.js @@ -476,6 +476,31 @@ function deleteResource(resourceType, id) { }); } +function putResource(resourceType, id, object) { + for (let k in object) if (object[k] === undefined) object[k] = null; + + return m.request({ + method: "PUT", + url: `/api/${resourceType}/${encodeURIComponent(id)}`, + data: object + }); +} + +function resourceExists(resource, id) { + const param = resource === "devices" ? "DeviceID.ID" : "_id"; + let filter = ["=", ["PARAM", param], id]; + return m.request({ + method: "HEAD", + url: + `/api/${resource}/?` + + m.buildQueryString({ + filter: memoizedStringify(filter) + }), + extract: xhr => +xhr.getResponseHeader("x-total-count"), + background: true + }); +} + function evaluateExpression(exp, obj) { if (!Array.isArray(exp)) return exp; return memoizedEvaluate(exp, obj, fulfillTimestamp); @@ -512,6 +537,8 @@ export { postTasks, updateTags, deleteResource, + putResource, + resourceExists, evaluateExpression, logIn, logOut, diff --git a/common/expression.js b/common/expression.js index 77256dc..cf61052 100644 --- a/common/expression.js +++ b/common/expression.js @@ -63,8 +63,8 @@ function evaluate(exp, obj, now, cb) { if (e[1] == null) return null; if (obj && !isArray(e[1])) { let v = obj[e[1]]; - if (typeof v === "object") v = v.value ? v.value[0] : null; if (v == null) return null; + if (typeof v === "object") v = v.value ? v.value[0] : null; return v; } } else if (e[0] === "AND") { diff --git a/server/api-functions.js b/server/api-functions.js index 52794ce..f70b57c 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -7,6 +7,7 @@ const url = require("url"); const config = require("./config"); const db = require("./db"); const expression = require("../common/expression"); +const mongodbFunctions = require("./mongodb-functions"); function filterToMongoQuery(filter, negate = false, res = {}) { const op = filter[0]; @@ -283,8 +284,32 @@ function ping(host) { }); } +function putResource(resource, id, data) { + return new Promise((resolve, reject) => { + let options = url.parse( + `${config.server.nbi}${resource}/${encodeURIComponent(id)}` + ); + + options.method = "PUT"; + + let _http = options.protocol === "https:" ? https : http; + + if (resource === "presets") data = mongodbFunctions.preProcessPreset(data); + + let body = JSON.stringify(data); + _http + .request(options, res => { + res.resume(); + if (res.statusCode === 200) resolve(true); + else reject(new Error(`Unexpected status code ${res.statusCode}`)); + }) + .end(body); + }); +} + exports.postTasks = postTasks; exports.deleteResource = deleteResource; +exports.putResource = putResource; exports.updateTags = updateTags; exports.filterToMongoQuery = filterToMongoQuery; exports.ping = ping; diff --git a/server/api.js b/server/api.js index 0843fbd..3d4b7bb 100644 --- a/server/api.js +++ b/server/api.js @@ -16,6 +16,7 @@ function logUnauthorizedWarning(log) { } const RESOURCE_DELETE = 1 << 0; +const RESOURCE_PUT = 1 << 1; const RESOURCE_IDS = { devices: "DeviceID.ID", @@ -29,7 +30,7 @@ const RESOURCE_IDS = { const resources = { devices: 0 | RESOURCE_DELETE, - presets: 0 | RESOURCE_DELETE, + presets: 0 | RESOURCE_DELETE | RESOURCE_PUT, provisions: 0 | RESOURCE_DELETE, files: 0 | RESOURCE_DELETE, virtual_parameters: 0 | RESOURCE_DELETE, @@ -308,6 +309,48 @@ for (let [resource, flags] of Object.entries(resources)) { logger.accessInfo(log); + ctx.body = ""; + }); + + if (flags & RESOURCE_PUT) + router.put(`/${resource}/:id`, async (ctx, next) => { + const id = ctx.params.id; + + const log = { + message: `Put ${resource}`, + context: ctx, + id: id + }; + + const authorizer = ctx.state.authorizer; + if (!authorizer.hasAccess(resource, 3)) { + logUnauthorizedWarning(log); + return next(); + } + + let obj = ctx.request.body; + + const validate = authorizer.getValidator(resource, obj); + if (!validate("put")) { + logUnauthorizedWarning(log); + return (ctx.status = 403); + } + + await apiFunctions.putResource(resource, id, obj); + + logger.accessInfo(log); + + db.putAudit({ + username: ctx.state.user.username, + action: "put", + objectType: resource, + objectId: id + }).catch(err => { + setTimeout(() => { + throw err; + }, 0); + }); + ctx.body = ""; }); } diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index a470130..ae4c585 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -374,6 +374,11 @@ function mongoQueryToFilter(query) { } else { expressions.push(["IS NOT NULL", ["PARAM", `Tags.${v}`]]); } + } else if (k.startsWith("Tags.")) { + expressions.push([ + `IS${v["$exists"] ? " " : " NOT "}NULL`, + ["PARAM", k] + ]); } else if (typeof v === "object") { if (Array.isArray(v)) throw new Error(`Invalid type`); @@ -440,14 +445,50 @@ function flattenPreset(preset) { ) throw new Error("Invalid preset provision"); p.provision = provision.name; - p.provisionArgs = - provision.args && provision.args.length - ? `"${provision.args.join('" "')}"` - : ""; + p.provisionArgs = provision.args + ? JSON.stringify(provision.args).slice(1, -1) + : ""; delete p.configurations; return p; } +function preProcessPreset(data) { + let preset = Object.assign({}, data); + preset.precondition = preset.precondition + ? filterToMongoQuery(expression.parse(preset.precondition)) + : {}; + preset.precondition = JSON.stringify(preset.precondition); + + let events = {}; + if (preset.events) + for (let e of preset.events.split(",")) { + let v = true; + e = e.trim(); + if (e.startsWith("-")) { + v = false; + e = e.slice(1).trim(); + } + if (e) events[e] = v; + } + + preset.events = events; + + if (!preset.provision) throw new Error("Invalid preset provision"); + + let configuration = { + type: "provision", + name: preset.provision + }; + + if (preset.provisionArgs) + configuration.args = JSON.parse(`[${preset.provisionArgs}]`); + + delete preset.provision; + delete preset.provisionArgs; + preset.configurations = [configuration]; + return preset; +} + exports.processDeviceFilter = processDeviceFilter; exports.processTasksFilter = processTasksFilter; exports.processFaultsFilter = processFaultsFilter; @@ -458,3 +499,4 @@ exports.flattenDevice = flattenDevice; exports.flattenFault = flattenFault; exports.flattenTask = flattenTask; exports.flattenPreset = flattenPreset; +exports.preProcessPreset = preProcessPreset; From f48b596271fea1700d009f9b58cefa1b7a9b7e39 Mon Sep 17 00:00:00 2001 From: shweelan Date: Tue, 11 Sep 2018 02:50:12 +0300 Subject: [PATCH 053/106] Add API error handling mechanism (#14) --- server/server.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/server.js b/server/server.js index e759443..c175c9b 100644 --- a/server/server.js +++ b/server/server.js @@ -52,6 +52,10 @@ function getPermissionSets(roles) { return permissionSets; } +koa.on("error", async err => { + throw err; +}); + koa.use( koaJwt({ secret: JWT_SECRET, @@ -179,6 +183,10 @@ function exit() { }); } +process.on("unhandledRejection", err => { + throw err; +}); + process.on("uncaughtException", err => { logger.error({ message: "Uncaught exception", From 6ae61b276388ece75fb4663da4574d548143d55b Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Tue, 11 Sep 2018 02:21:55 -0700 Subject: [PATCH 054/106] Auth refactoring --- server/server.js | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/server/server.js b/server/server.js index c175c9b..020312c 100644 --- a/server/server.js +++ b/server/server.js @@ -52,6 +52,15 @@ function getPermissionSets(roles) { return permissionSets; } +function authSimple(username, password) { + const user = config.auth.simple.users[username]; + if (user && user.password === password) { + const roles = user.roles.split(",").map(s => s.trim()); + return Promise.resolve(roles); + } + return Promise.resolve(null); +} + koa.on("error", async err => { throw err; }); @@ -78,30 +87,33 @@ router.post("/login", async ctx => { const username = ctx.request.body.username; const password = ctx.request.body.password; - const user = config.auth.simple.users[username]; - let log = { message: "Log in", context: ctx, username: username }; - if (!user || user.password !== password) { - ctx.status = 400; - ctx.body = "Incorrect username or password"; - log.message += " - invalid credentials"; - logger.accessWarn(log); - return; + function success(roles, method) { + log.method = method; + const token = jwt.sign({ username, roles }, JWT_SECRET); + ctx.cookies.set(JWT_COOKIE, token); + ctx.body = JSON.stringify(token); + logger.accessInfo(log); } - let token = jwt.sign( - { username: username, roles: user.roles.split(",") }, - JWT_SECRET - ); - ctx.cookies.set(JWT_COOKIE, token); - ctx.body = JSON.stringify(token); + function failure() { + ctx.status = 400; + ctx.body = "Incorrect username or password"; + log.message += " failed"; + logger.accessWarn(log); + } - logger.accessInfo(log); + let roles; + roles = await authSimple(username, password); + + if (roles) return success(roles, "simple"); + + failure(); }); router.post("/logout", async ctx => { From 41938b466729aa8a7e523a517110598b955d4629 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Thu, 13 Sep 2018 10:20:17 -0700 Subject: [PATCH 055/106] Task drawer sexification --- client/css/app.css | 50 +++++++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/client/css/app.css b/client/css/app.css index f8f0788..5a3d993 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -13,6 +13,10 @@ --fade: 0.2s; --disabled: #666; + + --status-red: hsl(0, 80%, 80%); + --status-green: hsl(125, 80%, 80%); + --status-gray: hsl(58, 3%, 80%); } body, @@ -47,12 +51,12 @@ i { font-style: italic; } -@keyframes blink { +@keyframes pending-blink { from { - opacity: 1; + background-color: color(var(--status-green) a(90%)); } to { - opacity: 0.3; + background-color: transparent; } } @@ -64,12 +68,13 @@ i { & > .drawer-wrapper { position: fixed; + z-index: 10; width: 720px; margin-left: -360px; top: 0; left: 50%; & > .drawer { - background-color: rgba(255, 255, 255, 0.8); + background-color: rgba(255, 255, 255, 0.9); border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; border-bottom: 1px solid var(--color1); @@ -88,20 +93,22 @@ i { display: flex; & > span { margin-right: 1em; + padding-left: 0.2em; + padding-right: 0.2em; + border-radius: 0.4em; } - & > .pending { - color: #770; + & > .pending.active { &.active { - animation: 0.5s linear 0s infinite alternate blink; + animation: 0.5s linear 0s infinite alternate pending-blink; } } - & > .stale { - color: #777; + & > .stale.active { + background-color: color(var(--status-gray) a(90%)); } - & > .fault { - color: #700; + & > .fault.active { + background-color: color(var(--status-red) a(90%)); } - & > .active { + & > .queued.active { font-weight: bold; } @@ -134,13 +141,16 @@ i { & > .queue { & > .pending { - color: #770; + animation: 0.5s linear 0s infinite alternate pending-blink; + border-radius: 0.4em; } & > .stale { - color: #777; + background-color: color(var(--status-gray) a(90%)); + border-radius: 0.4em; } & > .fault { - color: #700; + background-color: color(var(--status-red) a(90%)); + border-radius: 0.4em; } & .parameter { font-style: italic; @@ -172,19 +182,19 @@ i { padding: 0.3em; left: 0; right: 0; - background-color: rgba(255, 255, 255, 0.8); + background-color: rgba(255, 255, 255, 0.9); border: 1px solid var(--color1); border-radius: 6px; box-shadow: 0 0 10px #ccc; transition: opacity var(--fade), top var(--fade); &.error { - background-color: rgba(255, 200, 200, 0.8); - border: 1px solid rgb(255, 200, 200); + background-color: color(var(--status-red) a(90%)); + border: 1px solid var(--status-red); } &.success { - background-color: rgba(200, 255, 200, 0.8); - border: 1px solid rgb(200, 255, 200); + background-color: color(var(--status-green) a(90%)); + border: 1px solid var(--status-green); } } } From d5d63d219dbdd9f44bb2eef8e29c1c2c03e0861d Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Wed, 12 Sep 2018 02:28:02 +0300 Subject: [PATCH 056/106] Add provisions page --- client/app.js | 4 +- client/components/put-form.js | 22 ++- client/css/app.css | 4 + client/menu.js | 9 + client/provisions-page.js | 334 ++++++++++++++++++++++++++++++++++ client/store.js | 8 +- config.json | 9 + package.json | 1 + server/api-functions.js | 29 ++- server/api.js | 11 +- server/mongodb-functions.js | 5 + server/server.js | 1 + 12 files changed, 428 insertions(+), 9 deletions(-) create mode 100644 client/provisions-page.js diff --git a/client/app.js b/client/app.js index 68d93a7..c88bad5 100644 --- a/client/app.js +++ b/client/app.js @@ -11,6 +11,7 @@ import * as devicePage from "./device-page"; import * as errorPage from "./error-page"; import * as faultsPage from "./faults-page"; import * as presetsPage from "./presets-page"; +import * as provisionsPage from "./provisions-page"; import Authorizer from "../common/authorizer"; import * as notifications from "./notifications"; @@ -80,5 +81,6 @@ m.route(document.body, "/overview", { "/devices": pagify("devices", devicesPage), "/devices/:id": pagify("devices", devicePage), "/faults": pagify("faults", faultsPage), - "/presets": pagify("presets", presetsPage) + "/presets": pagify("presets", presetsPage), + "/provisions": pagify("provisions", provisionsPage) }); diff --git a/client/components/put-form.js b/client/components/put-form.js index bbc8eba..37de10e 100644 --- a/client/components/put-form.js +++ b/client/components/put-form.js @@ -1,9 +1,12 @@ "use strict"; import m from "mithril"; +import { fromTextArea as codeMirrorFromTextArea } from "codemirror"; +import "codemirror/mode/javascript/javascript"; const singular = { - presets: "preset" + presets: "preset", + provisions: "provision" }; function createField(current, attr, focus) { @@ -31,6 +34,23 @@ function createField(current, attr, focus) { }, options ); + } else if (attr.type === "code") { + return m("textarea", { + name: attr.id, + value: current.object[attr.id], + oncreate: _vnode => { + let editor = codeMirrorFromTextArea(_vnode.dom, { + mode: "javascript", + lineNumbers: true + }); + + editor.on("change", e => { + current.object[attr.id] = e.getValue(); + }); + + if (focus) editor.focus(); + } + }); } return m("input", { diff --git a/client/css/app.css b/client/css/app.css index 5a3d993..9a8d0d5 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -1,4 +1,5 @@ @import "normalize.css"; +@import "codemirror/lib/codemirror.css"; :root { --base-font-size: 13px; @@ -365,6 +366,8 @@ button { } } +.CodeMirror, +textarea, select, input { display: block; @@ -574,6 +577,7 @@ span.tag { border-radius: 10px; padding: 10px; box-shadow: 0 0 15px #aaa; + overflow: auto; } } diff --git a/client/menu.js b/client/menu.js index 2f23854..fa15deb 100644 --- a/client/menu.js +++ b/client/menu.js @@ -43,6 +43,15 @@ const menu = { ) ); + if (window.authorizer.hasAccess("provisions", 2)) + tabs.push( + m( + "li", + { class: active["provisions"] }, + m("a", { href: "#!/provisions" }, "Provisions") + ) + ); + return m("nav", m("ul", tabs)); } }; diff --git a/client/provisions-page.js b/client/provisions-page.js new file mode 100644 index 0000000..ee2586b --- /dev/null +++ b/client/provisions-page.js @@ -0,0 +1,334 @@ +"use strict"; + +import m from "mithril"; + +import config from "./config"; +import filterComponent from "./filter-component"; +import * as store from "./store"; +import * as expression from "../common/expression"; +import * as notifications from "./notifications"; +import memoize from "../common/memoize"; +import putForm from "./components/put-form"; +import * as overlay from "./overlay"; + +const PAGE_SIZE = config.ui.pageSize || 10; + +const memoizedParse = memoize(expression.parse); + +const attributes = [ + { id: "_id", label: "Name" }, + { id: "script", label: "Script", type: "code" } +]; + +function putActoinHandler(action, object, isNew) { + if (action === "save") { + let id = object["_id"]; + delete object["_id"]; + + if (!id) return notifications.push("error", "ID can not be empty"); + + store + .resourceExists("provisions", id) + .then(exists => { + if (exists && isNew) { + notifications.push("error", "Provision already exists"); + store.fulfill(0, Date.now()); + return; + } + + if (!exists && !isNew) { + notifications.push("error", "Provision already deleted"); + store.fulfill(0, Date.now()); + return; + } + + store + .putResource("provisions", id, object) + .then(() => { + notifications.push( + "success", + `Provision ${exists ? "updated" : "created"}` + ); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } else if (action === "delete") { + store + .deleteResource("provisions", object["_id"]) + .then(() => { + notifications.push("success", "Provision deleted"); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } else { + throw new Error("Undefined action"); + } +} + +let formData = { + resource: "provisions", + attributes: attributes +}; + +const getDownloadUrl = memoize(filter => { + let cols = {}; + for (let attr of attributes) cols[attr.label] = attr.id; + return `/api/provisions.csv?${m.buildQueryString({ + filter: filter, + columns: JSON.stringify(cols) + })}`; +}); + +function init(args) { + if (!window.authorizer.hasAccess("provisions", 2)) + return Promise.reject( + new Error("You are not authorized to view this page") + ); + + const filter = args.filter; + return Promise.resolve({ filter }); +} + +function renderTable( + provisionsResponse, + total, + selected, + showMoreCallback, + downloadUrl +) { + const provisions = provisionsResponse.value; + const selectAll = m("input", { + type: "checkbox", + checked: provisions.length && selected.size === provisions.length, + onchange: e => { + for (let provision of provisions) + if (e.target.checked) selected.add(provision["_id"]); + else selected.delete(provision["_id"]); + }, + disabled: !total + }); + + const labels = [selectAll] + .concat(attributes.map(elem => elem.label)) + .map(l => m("th", l)); + + let rows = []; + for (let provision of provisions) { + let checkbox = m("input", { + type: "checkbox", + checked: selected.has(provision["_id"]), + onchange: e => { + if (e.target.checked) selected.add(provision["_id"]); + else selected.delete(provision["_id"]); + }, + onclick: e => { + e.stopPropagation(); + e.redraw = false; + } + }); + + let tds = [m("td", checkbox)]; + for (let attr of attributes) + if (attr.id == "script") + tds.push(m("td", { title: provision[attr.id] }, provision[attr.id])); + else tds.push(m("td", provision[attr.id])); + + tds.push( + m( + "td.table-row-links", + m( + "a", + { + onclick: () => { + let cb = () => { + return m( + putForm, + Object.assign( + { + base: provision, + actionHandler: (action, object) => { + overlay.close(cb); + putActoinHandler(action, object, false); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "Show" + ) + ) + ); + + rows.push( + m( + "tr", + { + onclick: e => { + if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { + e.redraw = false; + return; + } + + if (!selected.delete(provision["_id"])) + selected.add(provision["_id"]); + } + }, + tds + ) + ); + } + + if (!rows.length) + rows.push( + m("tr.empty", m("td", { colspan: labels.length }, "No provisions")) + ); + + let footerElements = []; + if (total != null) footerElements.push(`${provisions.length}/${total}`); + else footerElements.push(`${provisions.length}`); + + footerElements.push( + m( + "button", + { + title: "Show more provisions", + onclick: showMoreCallback, + disabled: provisions.length >= total || !provisionsResponse.fulfilled + }, + "More" + ) + ); + + if (downloadUrl) + footerElements.push( + m("a.download-csv", { href: downloadUrl, download: "" }, "Download") + ); + + let tfoot = m( + "tfoot", + m("tr", m("td", { colspan: labels.length }, footerElements)) + ); + + const buttons = [ + m( + "button.primary", + { + title: "Delete selected provisions", + disabled: !selected.size, + onclick: e => { + e.redraw = false; + e.target.disabled = true; + Promise.all( + Array.from(selected).map(id => + store.deleteResource("provisions", id) + ) + ) + .then(res => { + notifications.push("success", `${res.length} provisions deleted`); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } + }, + "Delete" + ) + ]; + + if (window.authorizer.hasAccess("provisions", 3)) + buttons.push( + m( + "button.primary", + { + title: "Create new provision", + onclick: () => { + let cb = () => { + return m( + putForm, + Object.assign( + { + actionHandler: (action, object) => { + putActoinHandler(action, object, true); + overlay.close(cb); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "New" + ) + ); + + return [ + m( + "table.table.highlight", + m("thead", m("tr", labels)), + m("tbody", rows), + tfoot + ), + m("div.actions-bar", buttons) + ]; +} + +const component = { + view: vnode => { + document.title = "Provisions - GenieACS"; + + function showMore() { + vnode.state.showCount = (vnode.state.showCount || PAGE_SIZE) + PAGE_SIZE; + m.redraw(); + } + + function onFilterChanged(filter) { + m.route.set("/provisions", { filter }); + } + + const filter = vnode.attrs.filter + ? memoizedParse(vnode.attrs.filter) + : true; + + let provisions = store.fetch("provisions", filter, { + limit: vnode.state.showCount || PAGE_SIZE + }); + + let count = store.count("provisions", filter); + + let selected = new Set(); + if (vnode.state.selected) + for (let provision of provisions.value) + if (vnode.state.selected.has(provision["_id"])) + selected.add(provision["_id"]); + vnode.state.selected = selected; + + const downloadUrl = getDownloadUrl(vnode.attrs.filter); + + return [ + m("h1", "Listing provisions"), + m(filterComponent, { + predefined: [{ parameter: "_id" }], + filter: vnode.attrs.filter, + onChange: onFilterChanged + }), + renderTable(provisions, count.value, selected, showMore, downloadUrl) + ]; + } +}; + +export { init, component }; diff --git a/client/store.js b/client/store.js index f32764b..0a51b94 100644 --- a/client/store.js +++ b/client/store.js @@ -23,7 +23,13 @@ const queries = { }; const resources = {}; -for (let r of ["devices", "faults", "files", "presets"]) +for (let r of [ + "devices", + "faults", + "files", + "presets", + "provisions" +]) resources[r] = { objects: new Map(), count: new Map(), diff --git a/config.json b/config.json index 39ffa28..169ea95 100644 --- a/config.json +++ b/config.json @@ -42,6 +42,12 @@ "pass": true } }, + "provisions" : { + "access": 3, + "validate": { + "pass": true + } + }, "files": { "access": 3 } @@ -58,6 +64,9 @@ "presets": { "access": 2 }, + "provisions": { + "access": 2 + }, "files": { "access": 2 } diff --git a/package.json b/package.json index fca4ee2..a078821 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "author": "Zaid Abdulla ", "dependencies": { + "codemirror": "^5.40.0", "jsonwebtoken": "^8.3.0", "koa": "^2.5.2", "koa-bodyparser": "^4.2.1", diff --git a/server/api-functions.js b/server/api-functions.js index f70b57c..737fc2b 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -296,12 +296,33 @@ function putResource(resource, id, data) { if (resource === "presets") data = mongodbFunctions.preProcessPreset(data); - let body = JSON.stringify(data); + if (resource === "provisions") + data = mongodbFunctions.preProcessProvision(data); + + let body = typeof data !== "string" ? JSON.stringify(data) : data; _http .request(options, res => { - res.resume(); - if (res.statusCode === 200) resolve(true); - else reject(new Error(`Unexpected status code ${res.statusCode}`)); + const chunks = []; + let bytes = 0; + res.on("data", chunk => { + chunks.push(chunk); + bytes += chunk.length; + }); + + res.on("end", () => { + if (res.statusCode === 200) return resolve(); + else if (res.statusCode !== 400) + return reject( + new Error(`Unexpected status code ${res.statusCode}`) + ); + let buf = new Buffer(bytes); + let o = 0; + for (let c of chunks) { + c.copy(buf, o, 0, c.length); + o += c.length; + } + resolve(buf.toString()); + }); }) .end(body); }); diff --git a/server/api.js b/server/api.js index 3d4b7bb..970d608 100644 --- a/server/api.js +++ b/server/api.js @@ -31,7 +31,7 @@ const RESOURCE_IDS = { const resources = { devices: 0 | RESOURCE_DELETE, presets: 0 | RESOURCE_DELETE | RESOURCE_PUT, - provisions: 0 | RESOURCE_DELETE, + provisions: 0 | RESOURCE_DELETE | RESOURCE_PUT, files: 0 | RESOURCE_DELETE, virtual_parameters: 0 | RESOURCE_DELETE, faults: 0 | RESOURCE_DELETE, @@ -336,7 +336,14 @@ for (let [resource, flags] of Object.entries(resources)) { return (ctx.status = 403); } - await apiFunctions.putResource(resource, id, obj); + let err = await apiFunctions.putResource(resource, id, obj); + + if (err) { + log.message += " failed"; + logger.accessWarn(log); + ctx.body = err; + return (ctx.status = 400); + } logger.accessInfo(log); diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index ae4c585..428d683 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -489,6 +489,10 @@ function preProcessPreset(data) { return preset; } +function preProcessProvision(data) { + return data.script; +} + exports.processDeviceFilter = processDeviceFilter; exports.processTasksFilter = processTasksFilter; exports.processFaultsFilter = processFaultsFilter; @@ -500,3 +504,4 @@ exports.flattenFault = flattenFault; exports.flattenTask = flattenTask; exports.flattenPreset = flattenPreset; exports.preProcessPreset = preProcessPreset; +exports.preProcessProvision = preProcessProvision; diff --git a/server/server.js b/server/server.js index 020312c..adc5239 100644 --- a/server/server.js +++ b/server/server.js @@ -38,6 +38,7 @@ function getPermissionSets(roles) { "devices", "faults", "presets", + "provisions", "files" ].includes(k) ) From 0871032a9d79ecad92bcfec3c555a2a74f91cc5d Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Wed, 12 Sep 2018 02:28:39 +0300 Subject: [PATCH 057/106] Make presets provision input as combo --- client/presets-page.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/client/presets-page.js b/client/presets-page.js index ab4159b..1cb2644 100644 --- a/client/presets-page.js +++ b/client/presets-page.js @@ -22,7 +22,7 @@ const attributes = [ { id: "schedule", label: "Schedule" }, { id: "events", label: "Events" }, { id: "precondition", label: "Precondition" }, - { id: "provision", label: "Provision" }, + { id: "provision", label: "Provision", type: "combo" }, { id: "provisionArgs", label: "Arguments" } ]; @@ -322,6 +322,16 @@ const component = { }); let count = store.count("presets", filter); + const provisions = store.fetch("provisions", true); + if (provisions.fulfilled) { + let provisionAttr = attributes.find(attr => { + return attr.id === "provision"; + }); + provisionAttr.options = provisions.value.map(v => { + return v["_id"]; + }); + } + let selected = new Set(); if (vnode.state.selected) for (let preset of presets.value) From 6ab9ba800cfd4841f328d13d7ffc71c7f9c81c8b Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Wed, 19 Sep 2018 20:34:14 -0700 Subject: [PATCH 058/106] Add horizontal scrollbar to index table --- client/css/app.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/css/app.css b/client/css/app.css index 9a8d0d5..d0cbff3 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -261,6 +261,8 @@ i { table.table { border-spacing: 0; text-align: left; + display: block; + overflow-x: auto; & table.table { font-size: 90%; From 991ca16e03e560bafba3f0bc2cd90a61c9ecdab5 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Wed, 12 Sep 2018 02:28:39 +0300 Subject: [PATCH 059/106] Add sort to index pages --- client/css/app.css | 1 + client/devices-page.js | 64 +++++++++++++++++++++--- client/faults-page.js | 101 +++++++++++++++++++++++++++----------- client/presets-page.js | 72 ++++++++++++++++++++++----- client/provisions-page.js | 58 +++++++++++++++++++--- client/store.js | 4 +- common/expression.js | 10 ++++ config.json | 3 +- 8 files changed, 257 insertions(+), 56 deletions(-) diff --git a/client/css/app.css b/client/css/app.css index d0cbff3..0486562 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -269,6 +269,7 @@ table.table { } & th { + white-space: nowrap; color: var(--color4); font-weight: bold; border-bottom: 2px solid var(--color4); diff --git a/client/devices-page.js b/client/devices-page.js index fa1ae09..c8cc7fd 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -14,6 +14,17 @@ import memoize from "../common/memoize"; const PAGE_SIZE = config.ui.pageSize || 10; const memoizedParse = memoize(expression.parse); +const memoizedJsonParse = memoize(JSON.parse); +const memoizedGetSortable = memoize(str => { + let expressionParams = expression.extractParams( + expression.parse(str) + ); + if (expressionParams.length === 1) { + let param = expression.evaluate(expressionParams[0]); + if (typeof param === "string") return param; + } + return null; +}); const getDownloadUrl = memoize((filter, indexParameters) => { const columns = {}; @@ -34,8 +45,9 @@ function init(args) { return reject(new Error("You are not authorized to view this page")); const filter = args.filter; + const sort = args.sort; const indexParameters = Object.values(config.ui.index); - resolve({ filter, indexParameters }); + resolve({ filter, indexParameters, sort }); }); } @@ -45,7 +57,9 @@ function renderTable( total, selected, showMoreCallback, - downloadUrl + downloadUrl, + sort, + onSortChange ) { const devices = devicesResponse.value; const selectAll = m("input", { @@ -60,7 +74,32 @@ function renderTable( }); let labels = [m("th", selectAll)]; - for (let param of parameters) labels.push(m("th", param.label)); + for (let param of parameters) { + let label = param.label; + let _param; + if (!param.unsortable && (_param = memoizedGetSortable(param.parameter))) { + let direction = 1; + + let symbol = "\u21f3"; + if (sort[_param] > 0) symbol = "\u2b07"; + else if (sort[_param] < 0) symbol = "\u2b06"; + + let sortable = m( + "button", + { + onclick: () => { + if (sort[_param] > 0) direction *= -1; + return onSortChange(JSON.stringify({ [_param]: direction })); + } + }, + symbol + ); + + labels.push(m("th", [label, sortable])); + } else { + labels.push(m("th", label)); + } + } let rows = []; for (let device of devices) { @@ -296,14 +335,25 @@ const component = { } function onFilterChanged(filter) { - m.route.set("/devices", { filter }); + let ops = { filter }; + if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; + m.route.set("/devices", ops); } + function onSortChange(sort) { + let ops = { sort }; + if (vnode.attrs.filter) ops.filter = vnode.attrs.filter; + m.route.set("/devices", ops); + } + + const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; const filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + let devs = store.fetch("devices", filter, { - limit: vnode.state.showCount || PAGE_SIZE + limit: vnode.state.showCount || PAGE_SIZE, + sort: sort }); let count = store.count("devices", filter); @@ -332,7 +382,9 @@ const component = { count.value, selected, showMore, - downloadUrl + downloadUrl, + sort, + onSortChange ), renderActions(selected) ]; diff --git a/client/faults-page.js b/client/faults-page.js index a70298a..9d090a6 100644 --- a/client/faults-page.js +++ b/client/faults-page.js @@ -11,17 +11,25 @@ import memoize from "../common/memoize"; const PAGE_SIZE = config.ui.pageSize || 10; const memoizedParse = memoize(expression.parse); +const memoizedJsonParse = memoize(JSON.parse); + +const attributes = [ + { id: "device", label: "Device" }, + { id: "channel", label: "Channel" }, + { id: "code", label: "Code" }, + { id: "retries", label: "Retries" }, + { id: "timestamp", label: "Timestamp" } +]; const getDownloadUrl = memoize(filter => { + let cols = {}; + for (let attr of attributes) + cols[attr.label] = + attr.id === "timestamp" ? `DATE_STRING(${attr.id})` : attr.id; + return `/api/faults.csv?${m.buildQueryString({ filter: filter, - columns: JSON.stringify({ - Device: "device", - Channel: "channel", - Code: "code", - Retries: "retries", - Timestamp: "DATE_STRING(timestamp)" - }) + columns: JSON.stringify(cols) })}`; }); @@ -31,8 +39,9 @@ function init(args) { new Error("You are not authorized to view this page") ); + const sort = args.sort; const filter = args.filter; - return Promise.resolve({ filter }); + return Promise.resolve({ filter, sort }); } function renderTable( @@ -40,7 +49,9 @@ function renderTable( total, selected, showMoreCallback, - downloadUrl + downloadUrl, + sort, + onSortChange ) { const faults = faultsResponse.value; const selectAll = m("input", { @@ -54,15 +65,29 @@ function renderTable( disabled: !total }); - const labels = [ - selectAll, - "Device", - "Channel", - "Code", - "Message", - "Retries", - "Timestamp" - ].map(l => m("th", l)); + const labels = [m("th", selectAll)]; + for (let attr of attributes) { + let label = attr.label; + + let direction = 1; + + let symbol = "\u21f3"; + if (sort[attr.id] > 0) symbol = "\u2b07"; + else if (sort[attr.id] < 0) symbol = "\u2b06"; + + let sortable = m( + "button", + { + onclick: () => { + if (sort[attr.id] > 0) direction *= -1; + return onSortChange(JSON.stringify({ [attr.id]: direction })); + } + }, + symbol + ); + + labels.push(m("th", [label, sortable])); + } let rows = []; for (let f of faults) { @@ -81,6 +106,14 @@ function renderTable( const deviceHref = `#!/devices/${encodeURIComponent(f["device"])}`; + let tds = [m("td", checkbox)]; + for (let attr of attributes) + if (attr.id === "device") + tds.push(m("a", { href: deviceHref }, f[attr.id])); + else if (attr.id === "timestamp") + tds.push(m("td", new Date(f[attr.id]).toLocaleString())); + else tds.push(m("td", f[attr.id])); + rows.push( m( "tr", @@ -94,13 +127,7 @@ function renderTable( if (!selected.delete(f["_id"])) selected.add(f["_id"]); } }, - m("td", checkbox), - m("td", m("a", { href: deviceHref }, f["device"])), - m("td", f["channel"]), - m("td", f["code"]), - m("td", f["message"]), - m("td", f["retries"]), - m("td", new Date(f["timestamp"]).toLocaleString()) + tds ) ); } @@ -180,15 +207,25 @@ const component = { } function onFilterChanged(filter) { - m.route.set("/faults", { filter }); + let ops = { filter }; + if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; + m.route.set("/faults", ops); } + function onSortChange(sort) { + let ops = { sort }; + if (vnode.attrs.filter) ops.filter = vnode.attrs.filter; + m.route.set("/faults", ops); + } + + const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; const filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; let faults = store.fetch("faults", filter, { - limit: vnode.state.showCount || PAGE_SIZE + limit: vnode.state.showCount || PAGE_SIZE, + sort: sort }); let count = store.count("faults", filter); @@ -213,7 +250,15 @@ const component = { filter: vnode.attrs.filter, onChange: onFilterChanged }), - renderTable(faults, count.value, selected, showMore, downloadUrl) + renderTable( + faults, + count.value, + selected, + showMore, + downloadUrl, + sort, + onSortChange + ) ]; } }; diff --git a/client/presets-page.js b/client/presets-page.js index 1cb2644..badffb1 100644 --- a/client/presets-page.js +++ b/client/presets-page.js @@ -14,16 +14,17 @@ import memoize from "../common/memoize"; const PAGE_SIZE = config.ui.pageSize || 10; const memoizedParse = memoize(expression.parse); +const memoizedJsonParse = memoize(JSON.parse); const attributes = [ { id: "_id", label: "Name" }, { id: "channel", label: "Channel" }, { id: "weight", label: "Weight" }, { id: "schedule", label: "Schedule" }, - { id: "events", label: "Events" }, - { id: "precondition", label: "Precondition" }, - { id: "provision", label: "Provision", type: "combo" }, - { id: "provisionArgs", label: "Arguments" } + { id: "events", label: "Events", unsortable: true }, + { id: "precondition", label: "Precondition", unsortable: true }, + { id: "provision", label: "Provision", type: "combo", unsortable: true }, + { id: "provisionArgs", label: "Arguments", unsortable: true } ]; function putActoinHandler(action, object, isNew) { @@ -99,8 +100,9 @@ function init(args) { new Error("You are not authorized to view this page") ); + const sort = args.sort; const filter = args.filter; - return Promise.resolve({ filter }); + return Promise.resolve({ filter, sort }); } function renderTable( @@ -108,7 +110,9 @@ function renderTable( total, selected, showMoreCallback, - downloadUrl + downloadUrl, + sort, + onSortChange ) { const presets = presetsResponse.value; const selectAll = m("input", { @@ -122,9 +126,35 @@ function renderTable( disabled: !total }); - const labels = [selectAll] - .concat(attributes.map(elem => elem.label)) - .map(l => m("th", l)); + const labels = [m("th", selectAll)]; + + for (let attr of attributes) { + let label = attr.label; + + if (attr.unsortable) { + labels.push(m("th", label)); + continue; + } + + let direction = 1; + + let symbol = "\u21f3"; + if (sort[attr.id] > 0) symbol = "\u2b07"; + else if (sort[attr.id] < 0) symbol = "\u2b06"; + + let sortable = m( + "button", + { + onclick: () => { + if (sort[attr.id] > 0) direction *= -1; + return onSortChange(JSON.stringify({ [attr.id]: direction })); + } + }, + symbol + ); + + labels.push(m("th", [label, sortable])); + } let rows = []; for (let preset of presets) { @@ -310,15 +340,25 @@ const component = { } function onFilterChanged(filter) { - m.route.set("/presets", { filter }); + let ops = { filter }; + if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; + m.route.set("/presets", ops); } + function onSortChange(sort) { + let ops = { sort }; + if (vnode.attrs.filter) ops.filter = vnode.attrs.filter; + m.route.set("/presets", ops); + } + + const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; const filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; let presets = store.fetch("presets", filter, { - limit: vnode.state.showCount || PAGE_SIZE + limit: vnode.state.showCount || PAGE_SIZE, + sort: sort }); let count = store.count("presets", filter); @@ -352,7 +392,15 @@ const component = { filter: vnode.attrs.filter, onChange: onFilterChanged }), - renderTable(presets, count.value, selected, showMore, downloadUrl) + renderTable( + presets, + count.value, + selected, + showMore, + downloadUrl, + sort, + onSortChange + ) ]; } }; diff --git a/client/provisions-page.js b/client/provisions-page.js index ee2586b..b11e89f 100644 --- a/client/provisions-page.js +++ b/client/provisions-page.js @@ -14,6 +14,7 @@ import * as overlay from "./overlay"; const PAGE_SIZE = config.ui.pageSize || 10; const memoizedParse = memoize(expression.parse); +const memoizedJsonParse = memoize(JSON.parse); const attributes = [ { id: "_id", label: "Name" }, @@ -93,8 +94,9 @@ function init(args) { new Error("You are not authorized to view this page") ); + const sort = args.sort; const filter = args.filter; - return Promise.resolve({ filter }); + return Promise.resolve({ filter, sort }); } function renderTable( @@ -102,7 +104,9 @@ function renderTable( total, selected, showMoreCallback, - downloadUrl + downloadUrl, + sort, + onSortChange ) { const provisions = provisionsResponse.value; const selectAll = m("input", { @@ -116,9 +120,29 @@ function renderTable( disabled: !total }); - const labels = [selectAll] - .concat(attributes.map(elem => elem.label)) - .map(l => m("th", l)); + const labels = [m("th", selectAll)]; + for (let attr of attributes) { + let label = attr.label; + + let direction = 1; + + let symbol = "\u21f3"; + if (sort[attr.id] > 0) symbol = "\u2b07"; + else if (sort[attr.id] < 0) symbol = "\u2b06"; + + let sortable = m( + "button", + { + onclick: () => { + if (sort[attr.id] > 0) direction *= -1; + return onSortChange(JSON.stringify({ [attr.id]: direction })); + } + }, + symbol + ); + + labels.push(m("th", [label, sortable])); + } let rows = []; for (let provision of provisions) { @@ -297,15 +321,25 @@ const component = { } function onFilterChanged(filter) { - m.route.set("/provisions", { filter }); + let ops = { filter }; + if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; + m.route.set("/provisions", ops); } + function onSortChange(sort) { + let ops = { sort }; + if (vnode.attrs.filter) ops.filter = vnode.attrs.filter; + m.route.set("/provisions", ops); + } + + const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; const filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; let provisions = store.fetch("provisions", filter, { - limit: vnode.state.showCount || PAGE_SIZE + limit: vnode.state.showCount || PAGE_SIZE, + sort: sort }); let count = store.count("provisions", filter); @@ -326,7 +360,15 @@ const component = { filter: vnode.attrs.filter, onChange: onFilterChanged }), - renderTable(provisions, count.value, selected, showMore, downloadUrl) + renderTable( + provisions, + count.value, + selected, + showMore, + downloadUrl, + sort, + onSortChange + ) ]; } }; diff --git a/client/store.js b/client/store.js index 0a51b94..f6c7ba5 100644 --- a/client/store.js +++ b/client/store.js @@ -198,7 +198,9 @@ function inferQuery(resourceType, queryResponse) { function fetch(resourceType, filter, options = {}) { const filterStr = memoizedStringify(filter); - const sort = options.sort || {}; + const sort = Object.assign({}, options.sort); + for (const [k, v] of Object.entries(sort)) sort[k] += Math.sign(v); + const limit = options.limit || 0; if (resourceType === "devices") sort["DeviceID.ID"] = sort["DeviceID.ID"] || 1; diff --git a/common/expression.js b/common/expression.js index cf61052..376fce3 100644 --- a/common/expression.js +++ b/common/expression.js @@ -237,6 +237,15 @@ function likePatternToRegExp(pat, esc = "", flags = "") { return new RegExp(chars.join(""), flags); } +function extractParams(exp) { + let params = []; + expressionParser.map(exp, e => { + if (isArray(e) && e[0] === "PARAM") params.push(e[1]); + return e; + }); + return params; +} + exports.evaluate = evaluate; exports.and = and; exports.or = or; @@ -245,3 +254,4 @@ exports.subset = subset; exports.parse = expressionParser.parse; exports.stringify = expressionParser.stringify; exports.likePatternToRegExp = likePatternToRegExp; +exports.extractParams = extractParams; diff --git a/config.json b/config.json index 169ea95..1130e67 100644 --- a/config.json +++ b/config.json @@ -132,7 +132,8 @@ "label": "Tags", "parameter": "Tags", "type": "tags", - "writable": false + "writable": false, + "unsortable": true } ], "overview": { From 518254aa55e22ff763560f47123f28fc6bf2032d Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Thu, 20 Sep 2018 12:31:48 -0700 Subject: [PATCH 060/106] Fix bug in Mongo query conversion --- server/mongodb-functions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index 428d683..27e3aef 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -376,7 +376,7 @@ function mongoQueryToFilter(query) { } } else if (k.startsWith("Tags.")) { expressions.push([ - `IS${v["$exists"] ? " " : " NOT "}NULL`, + v["$exists"] ? "IS NOT NULL" : "IS NULL", ["PARAM", k] ]); } else if (typeof v === "object") { From 5b84b1daade57ce670b4f38f2cd394783788efbe Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Mon, 24 Sep 2018 13:24:01 -0700 Subject: [PATCH 061/106] Implement autocomplete and smart queries --- client/autocomplete-compnent.js | 113 ++++++++++++++++++++++++++++++++ client/css/app.css | 16 +++++ client/devices-page.js | 17 +++-- client/faults-page.js | 23 ++++--- client/filter-component.js | 44 +++++++++---- client/overlay.js | 2 +- client/presets-page.js | 21 +++--- client/provisions-page.js | 17 +++-- client/smart-query.js | 102 ++++++++++++++++++++++++++++ server/mongodb-functions.js | 23 ++++--- 10 files changed, 331 insertions(+), 47 deletions(-) create mode 100644 client/autocomplete-compnent.js create mode 100644 client/smart-query.js diff --git a/client/autocomplete-compnent.js b/client/autocomplete-compnent.js new file mode 100644 index 0000000..d4cb437 --- /dev/null +++ b/client/autocomplete-compnent.js @@ -0,0 +1,113 @@ +"use strict"; + +class Autocomplete { + constructor(className, callback) { + this.callback = callback; + this.element = null; + this.hideTimeout = null; + this.visible = false; + this.default = null; + + this.container = document.createElement("div"); + this.container.style.position = "absolute"; + this.container.style.display = "block"; + this.container.style.opacity = 0; + this.container.className = className; + } + + attach(el) { + el.setAttribute("autocomplete", "off"); + + el.addEventListener("focus", () => { + this.element = el; + let domRect = el.getBoundingClientRect(); + this.container.style.left = `${domRect.left}px`; + this.container.style.width = `${domRect.width}px`; + this.container.style.top = `${domRect.bottom}px`; + this.update(); + }); + + el.addEventListener("blur", () => { + if (this.element !== el) return; + if (!this.visible) return; + this.default = null; + this.hide(); + }); + + el.addEventListener("keydown", e => { + if (this.element !== el) return; + if (e.key === "Escape") this.hide(); + else if (e.key === "Enter") + if (this.default != null) { + el.value = this.default; + e.preventDefault(); + this.update(); + } + }); + + el.addEventListener("input", () => { + if (this.element !== el) return; + this.update(); + }); + } + + hide() { + this.container.style.opacity = 0; + this.visible = false; + this.default = null; + clearTimeout(this.hideTimeout); + this.hideTimeout = setTimeout(() => { + this.hideTimeout = null; + while (this.container.firstChild) + this.container.removeChild(this.container.firstChild); + document.body.removeChild(this.container); + }, 500); + } + + update() { + const el = this.element; + + this.callback(el.value, suggestions => { + if (this.element !== el) return; + this.default = null; + + if (!suggestions.length) { + if (this.visible) this.hide(); + + return; + } + + while (this.container.firstChild) + this.container.removeChild(this.container.firstChild); + + if (!this.visible) { + if (!this.hideTimeout) { + document.body.appendChild(this.container); + window.getComputedStyle(this.container).opacity; + } else { + clearTimeout(this.hideTimeout); + this.hideTimeout = null; + } + this.container.style.opacity = 1; + this.visible = true; + } + + this.default = suggestions[0]; + + for (let suggestion of suggestions) { + let e = document.createElement("div"); + e.className = "suggestion"; + let t = document.createTextNode(suggestion); + e.appendChild(t); + e.addEventListener("mousedown", ev => { + ev.preventDefault(); + el.value = suggestion; + if (this.element === el) this.update(el); + }); + this.container.appendChild(e); + } + }); + } +} + +export default Autocomplete; diff --git a/client/css/app.css b/client/css/app.css index 0486562..982dca5 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -587,3 +587,19 @@ span.tag { .overlay > .put-form { min-width: 700px; } + +.autocomplete { + box-shadow: 0 0 10px #ccc; + background-color: rgba(255, 255, 255, 0.9); + border: 1px solid var(--color1); + border-radius: 4px; + max-height: 300px; + overflow-y: auto; + transition: opacity var(--fade); + & > .suggestion { + padding: 5px; + &:hover { + background-color: var(--color3); + } + } +} diff --git a/client/devices-page.js b/client/devices-page.js index c8cc7fd..5b8da57 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -10,6 +10,8 @@ import * as taskQueue from "./task-queue"; import * as notifications from "./notifications"; import * as expression from "../common/expression"; import memoize from "../common/memoize"; +import * as smartQuery from "./smart-query"; +import * as expressionParser from "../common/expression-parser"; const PAGE_SIZE = config.ui.pageSize || 10; @@ -39,6 +41,14 @@ const getChildAttrs = memoize((attrs, device) => Object.assign({}, attrs, { device: device }) ); +const unpackSmartQuery = memoize(query => { + return expressionParser.map(query, e => { + if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q") + return smartQuery.unpack("devices", e[2], e[3]); + return e; + }); +}); + function init(args) { return new Promise((resolve, reject) => { if (!window.authorizer.hasAccess("devices", 2)) @@ -347,9 +357,8 @@ const component = { } const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; - const filter = vnode.attrs.filter - ? memoizedParse(vnode.attrs.filter) - : true; + let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + filter = unpackSmartQuery(filter); let devs = store.fetch("devices", filter, { limit: vnode.state.showCount || PAGE_SIZE, @@ -372,7 +381,7 @@ const component = { return [ m("h1", "Listing devices"), m(filterComponent, { - predefined: Object.values(config.ui.filters), + resource: "devices", filter: vnode.attrs.filter, onChange: onFilterChanged }), diff --git a/client/faults-page.js b/client/faults-page.js index 9d090a6..07ac944 100644 --- a/client/faults-page.js +++ b/client/faults-page.js @@ -7,6 +7,8 @@ import * as store from "./store"; import * as notifications from "./notifications"; import * as expression from "../common/expression"; import memoize from "../common/memoize"; +import * as smartQuery from "./smart-query"; +import * as expressionParser from "../common/expression-parser"; const PAGE_SIZE = config.ui.pageSize || 10; @@ -33,6 +35,14 @@ const getDownloadUrl = memoize(filter => { })}`; }); +const unpackSmartQuery = memoize(query => { + return expressionParser.map(query, e => { + if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q") + return smartQuery.unpack("faults", e[2], e[3]); + return e; + }); +}); + function init(args) { if (!window.authorizer.hasAccess("faults", 2)) return Promise.reject( @@ -219,9 +229,8 @@ const component = { } const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; - const filter = vnode.attrs.filter - ? memoizedParse(vnode.attrs.filter) - : true; + let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + filter = unpackSmartQuery(filter); let faults = store.fetch("faults", filter, { limit: vnode.state.showCount || PAGE_SIZE, @@ -240,13 +249,7 @@ const component = { return [ m("h1", "Listing faults"), m(filterComponent, { - predefined: [ - { parameter: "device" }, - { parameter: "channel" }, - { parameter: "code" }, - { parameter: "retries" }, - { parameter: "timestamp" } - ], + resource: "faults", filter: vnode.attrs.filter, onChange: onFilterChanged }), diff --git a/client/filter-component.js b/client/filter-component.js index f2e3e56..91b2249 100644 --- a/client/filter-component.js +++ b/client/filter-component.js @@ -3,14 +3,40 @@ import m from "mithril"; import * as expression from "../common/expression"; import memoize from "../common/memoize"; +import Autocomplete from "./autocomplete-compnent"; +import * as smartQuery from "./smart-query"; + +const getAutocomplete = memoize(resource => { + const labels = smartQuery.getLabels(resource); + const autocomplete = new Autocomplete("autocomplete", (txt, cb) => { + txt = txt.toLowerCase(); + cb(labels.filter(s => s.toLowerCase().includes(txt)).map(s => `${s}: `)); + }); + return autocomplete; +}); + +function parseFilter(f) { + if (/^[\s0-9a-zA-Z]+:/.test(f)) { + let k = f.split(":", 1)[0]; + let v = f.slice(k.length + 1).trim(); + return ["FUNC", "Q", k.trim(), v]; + } + return expression.parse(f); +} + +function stringifyFilter(f) { + if (Array.isArray(f) && f[0] === "FUNC" && f[1] === "Q") + return `${f[2]}: ${f[3]}`; + return expression.stringify(f); +} const splitFilter = memoize(filter => { if (!filter) return [""]; const list = []; const f = expression.parse(filter); if (Array.isArray(f) && f[0] === "AND") - for (let ff of f.slice(1)) list.push(expression.stringify(ff)); - else list.push(expression.stringify(f)); + for (let ff of f.slice(1)) list.push(stringifyFilter(ff)); + else list.push(stringifyFilter(f)); list.push(""); return list; @@ -18,12 +44,6 @@ const splitFilter = memoize(filter => { const component = { view: vnode => { - const predefined = vnode.attrs.predefined || []; - let a = m( - "datalist#filters", - predefined.map(f => m("option", { value: `${f.parameter} = ` })) - ); - if (!vnode.state.filterList || vnode.attrs.filter !== vnode.state.filter) { vnode.state.filterInvalid = 0; vnode.state.filter = vnode.attrs.filter; @@ -35,7 +55,7 @@ const component = { vnode.state.filterList = vnode.state.filterList.filter(f => f); let filter = vnode.state.filterList.map((f, idx) => { try { - return expression.parse(f); + return parseFilter(f); } catch (err) { vnode.state.filterInvalid |= 1 << idx; } @@ -56,17 +76,19 @@ const component = { return m( "div.filter", - [m("b", "Filter"), a].concat( + [m("b", "Filter")].concat( vnode.state.filterList.map((fltr, idx) => { return m("input", { type: "text", - list: "filters", class: `${(vnode.state.filterInvalid >> idx) & 1 ? "error" : ""}`, value: fltr, onchange: e => { vnode.state.filterList = vnode.state.filterList.slice(); vnode.state.filterList[idx] = e.target.value.trim(); onChange(); + }, + oncreate: vn => { + getAutocomplete(vnode.attrs.resource).attach(vn.dom); } }); }) diff --git a/client/overlay.js b/client/overlay.js index ae857a1..bca3be3 100644 --- a/client/overlay.js +++ b/client/overlay.js @@ -55,7 +55,7 @@ function render() { } document.addEventListener("keydown", e => { - if (e.keyCode === 27 && close(overlayCallback)) m.redraw(); + if (overlayCallback && e.keyCode === 27 && close(overlayCallback)) m.redraw(); }); window.addEventListener("popstate", () => { diff --git a/client/presets-page.js b/client/presets-page.js index badffb1..da826d5 100644 --- a/client/presets-page.js +++ b/client/presets-page.js @@ -10,6 +10,8 @@ import * as expression from "../common/expression"; import * as notifications from "./notifications"; import putForm from "./components/put-form"; import memoize from "../common/memoize"; +import * as smartQuery from "./smart-query"; +import * as expressionParser from "../common/expression-parser"; const PAGE_SIZE = config.ui.pageSize || 10; @@ -27,6 +29,14 @@ const attributes = [ { id: "provisionArgs", label: "Arguments", unsortable: true } ]; +const unpackSmartQuery = memoize(query => { + return expressionParser.map(query, e => { + if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q") + return smartQuery.unpack("presets", e[2], e[3]); + return e; + }); +}); + function putActoinHandler(action, object, isNew) { if (action === "save") { let id = object["_id"]; @@ -352,9 +362,8 @@ const component = { } const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; - const filter = vnode.attrs.filter - ? memoizedParse(vnode.attrs.filter) - : true; + let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + filter = unpackSmartQuery(filter); let presets = store.fetch("presets", filter, { limit: vnode.state.showCount || PAGE_SIZE, @@ -384,11 +393,7 @@ const component = { return [ m("h1", "Listing presets"), m(filterComponent, { - predefined: [ - { parameter: "_id" }, - { parameter: "channel" }, - { parameter: "weight" } - ], + resource: "presets", filter: vnode.attrs.filter, onChange: onFilterChanged }), diff --git a/client/provisions-page.js b/client/provisions-page.js index b11e89f..2e4c20f 100644 --- a/client/provisions-page.js +++ b/client/provisions-page.js @@ -10,6 +10,8 @@ import * as notifications from "./notifications"; import memoize from "../common/memoize"; import putForm from "./components/put-form"; import * as overlay from "./overlay"; +import * as smartQuery from "./smart-query"; +import * as expressionParser from "../common/expression-parser"; const PAGE_SIZE = config.ui.pageSize || 10; @@ -21,6 +23,14 @@ const attributes = [ { id: "script", label: "Script", type: "code" } ]; +const unpackSmartQuery = memoize(query => { + return expressionParser.map(query, e => { + if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q") + return smartQuery.unpack("provisions", e[2], e[3]); + return e; + }); +}); + function putActoinHandler(action, object, isNew) { if (action === "save") { let id = object["_id"]; @@ -333,9 +343,8 @@ const component = { } const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; - const filter = vnode.attrs.filter - ? memoizedParse(vnode.attrs.filter) - : true; + let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + filter = unpackSmartQuery(filter); let provisions = store.fetch("provisions", filter, { limit: vnode.state.showCount || PAGE_SIZE, @@ -356,7 +365,7 @@ const component = { return [ m("h1", "Listing provisions"), m(filterComponent, { - predefined: [{ parameter: "_id" }], + resource: "provisions", filter: vnode.attrs.filter, onChange: onFilterChanged }), diff --git a/client/smart-query.js b/client/smart-query.js new file mode 100644 index 0000000..771e32d --- /dev/null +++ b/client/smart-query.js @@ -0,0 +1,102 @@ +"use strict"; + +import config from "./config"; +import * as expression from "../common/expression"; + +let resources = { + devices: {}, + faults: { + Device: { parameter: ["PARAM", "device"], type: "string" }, + Channel: { parameter: ["PARAM", "channel"], type: "string" }, + Code: { parameter: ["PARAM", "code"], type: "string" }, + Retries: { parameter: ["PARAM", "retries"], type: "number" }, + Timestamp: { parameter: ["PARAM", "timestamp"], type: "timestamp" } + }, + presets: { + ID: { parameter: ["PARAM", "_id"], type: "string" }, + Channel: { parameter: ["PARAM", "channel"], type: "string" }, + Weight: { parameter: ["PARAM", "weight"], type: "number" } + }, + provisions: { + ID: { parameter: ["PARAM", "_id"], type: "string" } + } +}; + +for (let v of Object.values(config.ui.filters)) + resources.devices[v.label] = { + parameter: expression.parse(v.parameter), + type: (v.type || "").split(",").map(s => s.trim()) + }; + +function getLabels(resource) { + if (!resources[resource]) return []; + return Object.keys(resources[resource]); +} + +function queryNumber(param, value) { + let op = "="; + for (let o of ["<>", "=", "<=", "<", ">=", ">"]) + if (value.startsWith(o)) { + op = o; + value = value.slice(o.length).trim(); + break; + } + + let v = parseInt(value); + if (v !== +value) return null; + + return [op, param, v]; +} + +function queryString(param, value) { + return ["LIKE", ["FUNC", "LOWER", param], value.toLowerCase()]; +} + +function queryMac(param, value) { + value = value.replace(/[^a-f0-9]/gi, "").toLowerCase(); + if (!value) return null; + if (value.length === 12) + return [ + "LIKE", + ["FUNC", "LOWER", param], + value.replace(/(..)(?!$)/g, "$1:") + ]; + + return [ + "OR", + [ + "LIKE", + ["FUNC", "LOWER", param], + `%${value.replace(/(..)(?!$)/g, "$1:")}%` + ], + ["LIKE", ["FUNC", "LOWER", param], `%${value.replace(/(.)(.)/g, "$1:$2")}%`] + ]; +} + +function unpack(resource, label, value) { + if (!resources[resource]) return null; + const type = resources[resource][label].type; + value = value.trim(); + let res = ["OR"]; + + if (type.length === 0 || type.includes("number")) { + let q = queryNumber(resources[resource][label].parameter, value); + if (q) res.push(q); + } + + if (type.length === 0 || type.includes("string")) { + let q = queryString(resources[resource][label].parameter, value); + if (q) res.push(q); + } + + if (type.includes("mac")) { + let q = queryMac(resources[resource][label].parameter, value); + if (q) res.push(q); + } + + if (res.length <= 1) return null; + else if (res.length === 2) return res[1]; + else return res; +} + +export { getLabels, unpack }; diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index 27e3aef..630f0d7 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -29,15 +29,20 @@ function processDeviceFilter(filter) { else if (exp[0] === "IS NOT NULL") return ["=", ["PARAM", "_tags"], t]; else if (exp[0] === "=" && exp[2] === true) return ["=", ["PARAM", "_tags"], t]; - } else if (["=", "<>", ">", ">=", "<", "<="].includes(exp[0])) { - let e = exp.slice(); - if ( - Array.isArray(exp[1]) && - exp[1][0] === "PARAM" && - typeof exp[1][1] === "string" && - !exp[1][1].startsWith("_") - ) - e[1] = ["PARAM", `${exp[1][1]}._value`]; + } else if ( + ["=", "<>", ">", ">=", "<", "<=", "LIKE", "NOT LIKE"].includes(exp[0]) + ) { + let e = expressionParser.map(exp, ee => { + if ( + Array.isArray(ee) && + ee[0] === "PARAM" && + typeof ee[1] === "string" && + !ee[1].startsWith("_") + ) + return ["PARAM", `${ee[1]}._value`]; + return ee; + }); + if (typeof e[2] === "number") { let alt = e.slice(); alt[2] = new Date(e[2]); From f9c26dac1ac5b8416bb025686433d00c9d9d4790 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Fri, 21 Sep 2018 00:53:10 +0300 Subject: [PATCH 062/106] Add virtual parameters page --- client/app.js | 4 +- client/components/put-form.js | 3 +- client/menu.js | 9 + client/smart-query.js | 3 + client/store.js | 3 +- client/virtual-parameters-page.js | 401 ++++++++++++++++++++++++++++++ config.json | 9 + server/api-functions.js | 11 +- server/api.js | 15 +- server/db.js | 9 +- server/mongodb-functions.js | 5 + server/server.js | 1 + 12 files changed, 453 insertions(+), 20 deletions(-) create mode 100644 client/virtual-parameters-page.js diff --git a/client/app.js b/client/app.js index c88bad5..629dccc 100644 --- a/client/app.js +++ b/client/app.js @@ -12,6 +12,7 @@ import * as errorPage from "./error-page"; import * as faultsPage from "./faults-page"; import * as presetsPage from "./presets-page"; import * as provisionsPage from "./provisions-page"; +import * as virtualParametersPage from "./virtual-parameters-page"; import Authorizer from "../common/authorizer"; import * as notifications from "./notifications"; @@ -82,5 +83,6 @@ m.route(document.body, "/overview", { "/devices/:id": pagify("devices", devicePage), "/faults": pagify("faults", faultsPage), "/presets": pagify("presets", presetsPage), - "/provisions": pagify("provisions", provisionsPage) + "/provisions": pagify("provisions", provisionsPage), + "/virtualParameters": pagify("virtualParameters", virtualParametersPage) }); diff --git a/client/components/put-form.js b/client/components/put-form.js index 37de10e..5d70b9f 100644 --- a/client/components/put-form.js +++ b/client/components/put-form.js @@ -6,7 +6,8 @@ import "codemirror/mode/javascript/javascript"; const singular = { presets: "preset", - provisions: "provision" + provisions: "provision", + virtualParameters: "virtual parameter" }; function createField(current, attr, focus) { diff --git a/client/menu.js b/client/menu.js index fa15deb..c77b414 100644 --- a/client/menu.js +++ b/client/menu.js @@ -52,6 +52,15 @@ const menu = { ) ); + if (window.authorizer.hasAccess("virtualParameters", 2)) + tabs.push( + m( + "li", + { class: active["virtualParameters"] }, + m("a", { href: "#!/virtualParameters" }, "Virtual Parameters") + ) + ); + return m("nav", m("ul", tabs)); } }; diff --git a/client/smart-query.js b/client/smart-query.js index 771e32d..ebabea6 100644 --- a/client/smart-query.js +++ b/client/smart-query.js @@ -19,6 +19,9 @@ let resources = { }, provisions: { ID: { parameter: ["PARAM", "_id"], type: "string" } + }, + virtualParameters: { + ID: { parameter: ["PARAM", "_id"], type: "string" } } }; diff --git a/client/store.js b/client/store.js index f6c7ba5..052a0db 100644 --- a/client/store.js +++ b/client/store.js @@ -28,7 +28,8 @@ for (let r of [ "faults", "files", "presets", - "provisions" + "provisions", + "virtualParameters" ]) resources[r] = { objects: new Map(), diff --git a/client/virtual-parameters-page.js b/client/virtual-parameters-page.js new file mode 100644 index 0000000..f6e23a6 --- /dev/null +++ b/client/virtual-parameters-page.js @@ -0,0 +1,401 @@ +"use strict"; + +import m from "mithril"; + +import config from "./config"; +import filterComponent from "./filter-component"; +import * as store from "./store"; +import * as expression from "../common/expression"; +import * as notifications from "./notifications"; +import memoize from "../common/memoize"; +import putForm from "./components/put-form"; +import * as overlay from "./overlay"; +import * as smartQuery from "./smart-query"; +import * as expressionParser from "../common/expression-parser"; + +const PAGE_SIZE = config.ui.pageSize || 10; + +const memoizedParse = memoize(expression.parse); +const memoizedJsonParse = memoize(JSON.parse); + +const attributes = [ + { id: "_id", label: "Name" }, + { id: "script", label: "Script", type: "code" } +]; + +const unpackSmartQuery = memoize(query => { + return expressionParser.map(query, e => { + if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q") + return smartQuery.unpack("virtualParameters", e[2], e[3]); + return e; + }); +}); + +function putActionHandler(action, object, isNew) { + if (action === "save") { + let id = object["_id"]; + delete object["_id"]; + + if (!id) return notifications.push("error", "ID can not be empty"); + + store + .resourceExists("virtualParameters", id) + .then(exists => { + if (exists && isNew) { + notifications.push("error", "Virtual parameter already exists"); + store.fulfill(0, Date.now()); + return; + } + + if (!exists && !isNew) { + notifications.push("error", "Virtual parameter already deleted"); + store.fulfill(0, Date.now()); + return; + } + + store + .putResource("virtualParameters", id, object) + .then(() => { + notifications.push( + "success", + `Virtual parameter ${exists ? "updated" : "created"}` + ); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } else if (action === "delete") { + store + .deleteResource("virtualParameters", object["_id"]) + .then(() => { + notifications.push("success", "Virtual parameter deleted"); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } else { + throw new Error("Undefined action"); + } +} + +let formData = { + resource: "virtualParameters", + attributes: attributes +}; + +const getDownloadUrl = memoize(filter => { + let cols = {}; + for (let attr of attributes) cols[attr.label] = attr.id; + return `/api/virtualParameters.csv?${m.buildQueryString({ + filter: filter, + columns: JSON.stringify(cols) + })}`; +}); + +function init(args) { + if (!window.authorizer.hasAccess("virtualParameters", 2)) + return Promise.reject( + new Error("You are not authorized to view this page") + ); + + const sort = args.sort; + const filter = args.filter; + return Promise.resolve({ filter, sort }); +} + +function renderTable( + virtualParametersResponse, + total, + selected, + showMoreCallback, + downloadUrl, + sort, + onSortChange +) { + const virtualParameters = virtualParametersResponse.value; + const selectAll = m("input", { + type: "checkbox", + checked: + virtualParameters.length && selected.size === virtualParameters.length, + onchange: e => { + for (let virtualParameter of virtualParameters) + if (e.target.checked) selected.add(virtualParameter["_id"]); + else selected.delete(virtualParameter["_id"]); + }, + disabled: !total + }); + + const labels = [m("th", selectAll)]; + for (let attr of attributes) { + let label = attr.label; + + let direction = 1; + + let symbol = "\u21f3"; + if (sort[attr.id] > 0) symbol = "\u2b07"; + else if (sort[attr.id] < 0) symbol = "\u2b06"; + + let sortable = m( + "button", + { + onclick: () => { + if (sort[attr.id] > 0) direction *= -1; + return onSortChange(JSON.stringify({ [attr.id]: direction })); + } + }, + symbol + ); + + labels.push(m("th", [label, sortable])); + } + + let rows = []; + for (let virtualParameter of virtualParameters) { + let checkbox = m("input", { + type: "checkbox", + checked: selected.has(virtualParameter["_id"]), + onchange: e => { + if (e.target.checked) selected.add(virtualParameter["_id"]); + else selected.delete(virtualParameter["_id"]); + }, + onclick: e => { + e.stopPropagation(); + e.redraw = false; + } + }); + + let tds = [m("td", checkbox)]; + for (let attr of attributes) + if (attr.id == "script") + tds.push( + m( + "td", + { title: virtualParameter[attr.id] }, + virtualParameter[attr.id] + ) + ); + else tds.push(m("td", virtualParameter[attr.id])); + + tds.push( + m( + "td.table-row-links", + m( + "a", + { + onclick: () => { + let cb = () => { + return m( + putForm, + Object.assign( + { + base: virtualParameter, + actionHandler: (action, object) => { + overlay.close(cb); + putActionHandler(action, object, false); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "Show" + ) + ) + ); + + rows.push( + m( + "tr", + { + onclick: e => { + if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { + e.redraw = false; + return; + } + + if (!selected.delete(virtualParameter["_id"])) + selected.add(virtualParameter["_id"]); + } + }, + tds + ) + ); + } + + if (!rows.length) + rows.push( + m( + "tr.empty", + m("td", { colspan: labels.length }, "No virtual parameters") + ) + ); + + let footerElements = []; + if (total != null) + footerElements.push(`${virtualParameters.length}/${total}`); + else footerElements.push(`${virtualParameters.length}`); + + footerElements.push( + m( + "button", + { + title: "Show more virtual parameters", + onclick: showMoreCallback, + disabled: + virtualParameters.length >= total || + !virtualParametersResponse.fulfilled + }, + "More" + ) + ); + + if (downloadUrl) + footerElements.push( + m("a.download-csv", { href: downloadUrl, download: "" }, "Download") + ); + + let tfoot = m( + "tfoot", + m("tr", m("td", { colspan: labels.length }, footerElements)) + ); + + const buttons = [ + m( + "button.primary", + { + title: "Delete selected virtual parameters", + disabled: !selected.size, + onclick: e => { + e.redraw = false; + e.target.disabled = true; + Promise.all( + Array.from(selected).map(id => + store.deleteResource("virtualParameters", id) + ) + ) + .then(res => { + notifications.push( + "success", + `${res.length} virtual parameters deleted` + ); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } + }, + "Delete" + ) + ]; + + if (window.authorizer.hasAccess("virtualParameters", 3)) + buttons.push( + m( + "button.primary", + { + title: "Create new virtual parameter", + onclick: () => { + let cb = () => { + return m( + putForm, + Object.assign( + { + actionHandler: (action, object) => { + putActionHandler(action, object, true); + overlay.close(cb); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "New" + ) + ); + + return [ + m( + "table.table.highlight", + m("thead", m("tr", labels)), + m("tbody", rows), + tfoot + ), + m("div.actions-bar", buttons) + ]; +} + +const component = { + view: vnode => { + document.title = "Virtual Parameters - GenieACS"; + + function showMore() { + vnode.state.showCount = (vnode.state.showCount || PAGE_SIZE) + PAGE_SIZE; + m.redraw(); + } + + function onFilterChanged(filter) { + let ops = { filter }; + if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; + m.route.set("/virtualParameters", ops); + } + + function onSortChange(sort) { + let ops = { sort }; + if (vnode.attrs.filter) ops.filter = vnode.attrs.filter; + m.route.set("/virtualParameters", ops); + } + + const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; + let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + filter = unpackSmartQuery(filter); + + let virtualParameters = store.fetch("virtualParameters", filter, { + limit: vnode.state.showCount || PAGE_SIZE, + sort: sort + }); + + let count = store.count("virtualParameters", filter); + + let selected = new Set(); + if (vnode.state.selected) + for (let virtualParameter of virtualParameters.value) + if (vnode.state.selected.has(virtualParameter["_id"])) + selected.add(virtualParameter["_id"]); + vnode.state.selected = selected; + + const downloadUrl = getDownloadUrl(vnode.attrs.filter); + + return [ + m("h1", "Listing virtual parameters"), + m(filterComponent, { + resource: "virtualParameters", + filter: vnode.attrs.filter, + onChange: onFilterChanged + }), + renderTable( + virtualParameters, + count.value, + selected, + showMore, + downloadUrl, + sort, + onSortChange + ) + ]; + } +}; + +export { init, component }; diff --git a/config.json b/config.json index 1130e67..079c7be 100644 --- a/config.json +++ b/config.json @@ -48,6 +48,12 @@ "pass": true } }, + "virtualParameters" : { + "access": 3, + "validate": { + "pass": true + } + }, "files": { "access": 3 } @@ -67,6 +73,9 @@ "provisions": { "access": 2 }, + "virtualParameters": { + "access": 2 + }, "files": { "access": 2 } diff --git a/server/api-functions.js b/server/api-functions.js index 737fc2b..b8c859d 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -69,8 +69,10 @@ function filterToMongoQuery(filter, negate = false, res = {}) { function deleteResource(resource, id) { return new Promise((resolve, reject) => { + let resourceUrl = + resource === "virtualParameters" ? "virtual_parameters" : resource; let options = url.parse( - `${config.server.nbi}${resource}/${encodeURIComponent(id)}` + `${config.server.nbi}${resourceUrl}/${encodeURIComponent(id)}` ); options.method = "DELETE"; @@ -286,8 +288,10 @@ function ping(host) { function putResource(resource, id, data) { return new Promise((resolve, reject) => { + let resourceUrl = + resource === "virtualParameters" ? "virtual_parameters" : resource; let options = url.parse( - `${config.server.nbi}${resource}/${encodeURIComponent(id)}` + `${config.server.nbi}${resourceUrl}/${encodeURIComponent(id)}` ); options.method = "PUT"; @@ -299,6 +303,9 @@ function putResource(resource, id, data) { if (resource === "provisions") data = mongodbFunctions.preProcessProvision(data); + if (resource === "virtualParameters") + data = mongodbFunctions.preProcessVirtualParameters(data); + let body = typeof data !== "string" ? JSON.stringify(data) : data; _http .request(options, res => { diff --git a/server/api.js b/server/api.js index 970d608..0d8c205 100644 --- a/server/api.js +++ b/server/api.js @@ -23,7 +23,7 @@ const RESOURCE_IDS = { presets: "_id", provisions: "_id", files: "_id", - virtual_parameters: "_id", + virtualParameters: "_id", faults: "_id", tasks: "_id" }; @@ -33,7 +33,7 @@ const resources = { presets: 0 | RESOURCE_DELETE | RESOURCE_PUT, provisions: 0 | RESOURCE_DELETE | RESOURCE_PUT, files: 0 | RESOURCE_DELETE, - virtual_parameters: 0 | RESOURCE_DELETE, + virtualParameters: 0 | RESOURCE_DELETE | RESOURCE_PUT, faults: 0 | RESOURCE_DELETE, tasks: 0 }; @@ -347,17 +347,6 @@ for (let [resource, flags] of Object.entries(resources)) { logger.accessInfo(log); - db.putAudit({ - username: ctx.state.user.username, - action: "put", - objectType: resource, - objectId: id - }).catch(err => { - setTimeout(() => { - throw err; - }, 0); - }); - ctx.body = ""; }); } diff --git a/server/db.js b/server/db.js index e37bf1c..d3b5b90 100644 --- a/server/db.js +++ b/server/db.js @@ -13,9 +13,12 @@ const RESOURCE_DB = { tasks: "genieacs", presets: "genieacs", provisions: "genieacs", + virtualParameters: "genieacs", files: "genieacs" }; +const RESOURCE_COLLECTION = {}; + function getClient() { if (!_clientPromise) _clientPromise = new Promise((resolve, reject) => { @@ -54,7 +57,7 @@ function query(resource, filter, options, callback) { getClient().then(client => { const collection = client .db(RESOURCE_DB[resource]) - .collection(resource); + .collection(RESOURCE_COLLECTION[resource] || resource); let cursor = collection.find( q, resource === "Devices" @@ -130,7 +133,9 @@ function count(resource, filter) { return new Promise((resolve, reject) => { getClient().then(client => { - const collection = client.db(RESOURCE_DB[resource]).collection(resource); + const collection = client + .db(RESOURCE_DB[resource]) + .collection(RESOURCE_COLLECTION[resource] || resource); collection.find(q).count((err, c) => { if (err) reject(err); else resolve(c); diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index 630f0d7..c6643a1 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -498,6 +498,10 @@ function preProcessProvision(data) { return data.script; } +function preProcessVirtualParameters(data) { + return data.script; +} + exports.processDeviceFilter = processDeviceFilter; exports.processTasksFilter = processTasksFilter; exports.processFaultsFilter = processFaultsFilter; @@ -510,3 +514,4 @@ exports.flattenTask = flattenTask; exports.flattenPreset = flattenPreset; exports.preProcessPreset = preProcessPreset; exports.preProcessProvision = preProcessProvision; +exports.preProcessVirtualParameters = preProcessVirtualParameters; diff --git a/server/server.js b/server/server.js index adc5239..2e71034 100644 --- a/server/server.js +++ b/server/server.js @@ -39,6 +39,7 @@ function getPermissionSets(roles) { "faults", "presets", "provisions", + "virtualParameters", "files" ].includes(k) ) From f1c3e21ba345ecc8a3868287ded9744620446310 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Tue, 25 Sep 2018 17:12:29 +0300 Subject: [PATCH 063/106] Fix a typo --- client/presets-page.js | 6 +++--- client/provisions-page.js | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/presets-page.js b/client/presets-page.js index da826d5..fc703dd 100644 --- a/client/presets-page.js +++ b/client/presets-page.js @@ -37,7 +37,7 @@ const unpackSmartQuery = memoize(query => { }); }); -function putActoinHandler(action, object, isNew) { +function putActionHandler(action, object, isNew) { if (action === "save") { let id = object["_id"]; delete object["_id"]; @@ -214,7 +214,7 @@ function renderTable( base: preset, actionHandler: (action, object) => { overlay.close(cb); - putActoinHandler(action, object, false); + putActionHandler(action, object, false); } }, formData @@ -314,7 +314,7 @@ function renderTable( Object.assign( { actionHandler: (action, object) => { - putActoinHandler(action, object, true); + putActionHandler(action, object, true); overlay.close(cb); } }, diff --git a/client/provisions-page.js b/client/provisions-page.js index 2e4c20f..57ecc1a 100644 --- a/client/provisions-page.js +++ b/client/provisions-page.js @@ -31,7 +31,7 @@ const unpackSmartQuery = memoize(query => { }); }); -function putActoinHandler(action, object, isNew) { +function putActionHandler(action, object, isNew) { if (action === "save") { let id = object["_id"]; delete object["_id"]; @@ -190,7 +190,7 @@ function renderTable( base: provision, actionHandler: (action, object) => { overlay.close(cb); - putActoinHandler(action, object, false); + putActionHandler(action, object, false); } }, formData @@ -295,7 +295,7 @@ function renderTable( Object.assign( { actionHandler: (action, object) => { - putActoinHandler(action, object, true); + putActionHandler(action, object, true); overlay.close(cb); } }, From 41229ffb85345acb9044843f48068008dbe2f4da Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Tue, 25 Sep 2018 17:12:49 +0300 Subject: [PATCH 064/106] Add files page --- client/app.js | 4 +- client/components/put-form.js | 26 ++- client/files-page.js | 370 ++++++++++++++++++++++++++++++++++ client/menu.js | 9 + client/smart-query.js | 10 + client/store.js | 3 +- config.json | 5 +- server/api-functions.js | 19 ++ server/api.js | 55 +++++ server/db.js | 8 +- server/mongodb-functions.js | 13 ++ server/server.js | 7 + 12 files changed, 521 insertions(+), 8 deletions(-) create mode 100644 client/files-page.js diff --git a/client/app.js b/client/app.js index 629dccc..fe5e468 100644 --- a/client/app.js +++ b/client/app.js @@ -13,6 +13,7 @@ import * as faultsPage from "./faults-page"; import * as presetsPage from "./presets-page"; import * as provisionsPage from "./provisions-page"; import * as virtualParametersPage from "./virtual-parameters-page"; +import * as filesPage from "./files-page"; import Authorizer from "../common/authorizer"; import * as notifications from "./notifications"; @@ -84,5 +85,6 @@ m.route(document.body, "/overview", { "/faults": pagify("faults", faultsPage), "/presets": pagify("presets", presetsPage), "/provisions": pagify("provisions", provisionsPage), - "/virtualParameters": pagify("virtualParameters", virtualParametersPage) + "/virtualParameters": pagify("virtualParameters", virtualParametersPage), + "/files": pagify("files", filesPage) }); diff --git a/client/components/put-form.js b/client/components/put-form.js index 5d70b9f..0a904c1 100644 --- a/client/components/put-form.js +++ b/client/components/put-form.js @@ -7,7 +7,8 @@ import "codemirror/mode/javascript/javascript"; const singular = { presets: "preset", provisions: "provision", - virtualParameters: "virtual parameter" + virtualParameters: "virtual parameter", + files: "file" }; function createField(current, attr, focus) { @@ -52,6 +53,20 @@ function createField(current, attr, focus) { if (focus) editor.focus(); } }); + } else if (attr.type === "file") { + return m("input", { + type: "file", + name: attr.id, + oncreate: focus + ? _vnode => { + _vnode.dom.focus(); + } + : null, + onchange: e => { + current.object[attr.id] = e.target.files; + e.redraw = false; + } + }); } return m("input", { @@ -102,7 +117,8 @@ const component = { ); } - let buttons = [m("button.primary", { type: "submit" }, "Save")]; + const submit = m("button.primary", { type: "submit" }, "Save"); + let buttons = [submit]; if (!current.isNew) buttons.push( @@ -129,9 +145,11 @@ const component = { "form", { onsubmit: e => { - e.target.disabled = true; - e.preventDefault(); e.redraw = false; + e.target.onsubmit = null; + e.preventDefault(); + for (let elem of e.target.elements) elem.disabled = true; + submit.dom.textContent = "Loading ..."; actionHandler("save", current.object); } }, diff --git a/client/files-page.js b/client/files-page.js new file mode 100644 index 0000000..02f0424 --- /dev/null +++ b/client/files-page.js @@ -0,0 +1,370 @@ +"use strict"; + +import m from "mithril"; + +import config from "./config"; +import filterComponent from "./filter-component"; +import * as store from "./store"; +import * as expression from "../common/expression"; +import * as notifications from "./notifications"; +import memoize from "../common/memoize"; +import putForm from "./components/put-form"; +import * as overlay from "./overlay"; +import * as smartQuery from "./smart-query"; +import * as expressionParser from "../common/expression-parser"; + +const PAGE_SIZE = config.ui.pageSize || 10; + +const memoizedParse = memoize(expression.parse); +const memoizedJsonParse = memoize(JSON.parse); + +const attributes = [ + { id: "_id", label: "Name" }, + { + id: "metadata.fileType", + label: "Type", + type: "combo", + options: [ + "1 Firmware Upgrade Image", + "2 Web Content", + "3 Vendor Configuration File", + "4 Tone File", + "5 Ringer File" + ] + }, + { id: "metadata.oui", label: "OUI" }, + { id: "metadata.productClass", label: "Product Class" }, + { id: "metadata.version", label: "Version" } +]; + +let formData = { + resource: "files", + attributes: attributes + .slice(1) // remove _id from new object form + .concat([{ id: "file", label: "File", type: "file" }]) +}; + +const unpackSmartQuery = memoize(query => { + return expressionParser.map(query, e => { + if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q") + return smartQuery.unpack("files", e[2], e[3]); + return e; + }); +}); + +function putActionHandler(action, object) { + return new Promise((resolve, reject) => { + if (action === "save") { + let file = object["file"] ? object["file"][0] : null; + delete object["file"]; + + if (!file) return reject(new Error("File not selected")); + + let id = file.name; + + store + .resourceExists("files", id) + .then(exists => { + if (exists) { + store.fulfill(0, Date.now()); + return reject(new Error("File already exists")); + } + let headers = Object.assign( + { + "Content-Type": "application/octet-stream", + Accept: "application/octet-stream" + }, + object + ); + + m.request({ + method: "PUT", + headers: headers, + url: `/api/files/${encodeURIComponent(id)}`, + serialize: body => { + return body; // Identity function to prevent JSON.parse on blob data + }, + data: file + }) + .then(() => { + notifications.push( + "success", + `File ${exists ? "updated" : "created"}` + ); + store.fulfill(0, Date.now()); + resolve(); + }) + .catch(reject); + }) + .catch(reject); + } else { + reject(new Error("Undefined action")); + } + }); +} + +const getDownloadUrl = memoize(filter => { + let cols = {}; + for (let attr of attributes) cols[attr.label] = attr.id; + return `/api/files.csv?${m.buildQueryString({ + filter: filter, + columns: JSON.stringify(cols) + })}`; +}); + +function init(args) { + if (!window.authorizer.hasAccess("files", 2)) + return Promise.reject( + new Error("You are not authorized to view this page") + ); + + const sort = args.sort; + const filter = args.filter; + return Promise.resolve({ filter, sort }); +} + +function renderTable( + filesResponse, + total, + selected, + showMoreCallback, + downloadUrl, + sort, + onSortChange +) { + const files = filesResponse.value; + const selectAll = m("input", { + type: "checkbox", + checked: files.length && selected.size === files.length, + onchange: e => { + for (let file of files) + if (e.target.checked) selected.add(file["_id"]); + else selected.delete(file["_id"]); + }, + disabled: !total + }); + + const labels = [m("th", selectAll)]; + for (let attr of attributes) { + let label = attr.label; + + let direction = 1; + + let symbol = "\u21f3"; + if (sort[attr.id] > 0) symbol = "\u2b07"; + else if (sort[attr.id] < 0) symbol = "\u2b06"; + + let sortable = m( + "button", + { + onclick: () => { + if (sort[attr.id] > 0) direction *= -1; + return onSortChange(JSON.stringify({ [attr.id]: direction })); + } + }, + symbol + ); + + labels.push(m("th", [label, sortable])); + } + + let rows = []; + for (let file of files) { + let checkbox = m("input", { + type: "checkbox", + checked: selected.has(file["_id"]), + onchange: e => { + if (e.target.checked) selected.add(file["_id"]); + else selected.delete(file["_id"]); + }, + onclick: e => { + e.stopPropagation(); + e.redraw = false; + } + }); + + let tds = [m("td", checkbox)]; + for (let attr of attributes) + if (attr.id == "script") + tds.push(m("td", { title: file[attr.id] }, file[attr.id])); + else tds.push(m("td", file[attr.id])); + + rows.push( + m( + "tr", + { + onclick: e => { + if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { + e.redraw = false; + return; + } + + if (!selected.delete(file["_id"])) selected.add(file["_id"]); + } + }, + tds + ) + ); + } + + if (!rows.length) + rows.push(m("tr.empty", m("td", { colspan: labels.length }, "No files"))); + + let footerElements = []; + if (total != null) footerElements.push(`${files.length}/${total}`); + else footerElements.push(`${files.length}`); + + footerElements.push( + m( + "button", + { + title: "Show more files", + onclick: showMoreCallback, + disabled: files.length >= total || !filesResponse.fulfilled + }, + "More" + ) + ); + + if (downloadUrl) + footerElements.push( + m("a.download-csv", { href: downloadUrl, download: "" }, "Download") + ); + + let tfoot = m( + "tfoot", + m("tr", m("td", { colspan: labels.length }, footerElements)) + ); + + const buttons = [ + m( + "button.primary", + { + title: "Delete selected files", + disabled: !selected.size, + onclick: e => { + e.redraw = false; + e.target.disabled = true; + Promise.all( + Array.from(selected).map(id => store.deleteResource("files", id)) + ) + .then(res => { + notifications.push("success", `${res.length} files deleted`); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } + }, + "Delete" + ) + ]; + + if (window.authorizer.hasAccess("files", 3)) + buttons.push( + m( + "button.primary", + { + title: "Create new file", + onclick: () => { + let cb = () => { + return m( + putForm, + Object.assign( + { + actionHandler: (action, object) => { + putActionHandler(action, object) + .then(() => { + overlay.close(cb); + }) + .catch(err => { + notifications.push("error", err.message); + overlay.close(cb); + m.redraw(); + }); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "New" + ) + ); + + return [ + m( + "table.table.highlight", + m("thead", m("tr", labels)), + m("tbody", rows), + tfoot + ), + m("div.actions-bar", buttons) + ]; +} + +const component = { + view: vnode => { + document.title = "Files - GenieACS"; + + function showMore() { + vnode.state.showCount = (vnode.state.showCount || PAGE_SIZE) + PAGE_SIZE; + m.redraw(); + } + + function onFilterChanged(filter) { + let ops = { filter }; + if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; + m.route.set("/files", ops); + } + + function onSortChange(sort) { + let ops = { sort }; + if (vnode.attrs.filter) ops.filter = vnode.attrs.filter; + m.route.set("/files", ops); + } + + const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; + let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + filter = unpackSmartQuery(filter); + + let files = store.fetch("files", filter, { + limit: vnode.state.showCount || PAGE_SIZE, + sort: sort + }); + + let count = store.count("files", filter); + + let selected = new Set(); + if (vnode.state.selected) + for (let file of files.value) + if (vnode.state.selected.has(file["_id"])) selected.add(file["_id"]); + vnode.state.selected = selected; + + const downloadUrl = getDownloadUrl(vnode.attrs.filter); + + return [ + m("h1", "Listing files"), + m(filterComponent, { + resource: "files", + filter: vnode.attrs.filter, + onChange: onFilterChanged + }), + renderTable( + files, + count.value, + selected, + showMore, + downloadUrl, + sort, + onSortChange + ) + ]; + } +}; + +export { init, component }; diff --git a/client/menu.js b/client/menu.js index c77b414..cf7ef8a 100644 --- a/client/menu.js +++ b/client/menu.js @@ -61,6 +61,15 @@ const menu = { ) ); + if (window.authorizer.hasAccess("files", 2)) + tabs.push( + m( + "li", + { class: active["files"] }, + m("a", { href: "#!/files" }, "Files") + ) + ); + return m("nav", m("ul", tabs)); } }; diff --git a/client/smart-query.js b/client/smart-query.js index ebabea6..256e6ed 100644 --- a/client/smart-query.js +++ b/client/smart-query.js @@ -22,6 +22,16 @@ let resources = { }, virtualParameters: { ID: { parameter: ["PARAM", "_id"], type: "string" } + }, + files: { + ID: { parameter: ["PARAM", "_id"], type: "string" }, + Type: { parameter: ["PARAM", "metadata.fileType"], type: "string" }, + OUI: { parameter: ["PARAM", "metadata.oui"], type: "string" }, + "Product class": { + parameter: ["PARAM", "metadata.productClass"], + type: "string" + }, + Version: { parameter: ["PARAM", "metadata.version"], type: "string" } } }; diff --git a/client/store.js b/client/store.js index 052a0db..dab64a2 100644 --- a/client/store.js +++ b/client/store.js @@ -29,7 +29,8 @@ for (let r of [ "files", "presets", "provisions", - "virtualParameters" + "virtualParameters", + "files" ]) resources[r] = { objects: new Map(), diff --git a/config.json b/config.json index 079c7be..7d09554 100644 --- a/config.json +++ b/config.json @@ -55,7 +55,10 @@ } }, "files": { - "access": 3 + "access": 3, + "validate": { + "pass": true + } } } ], diff --git a/server/api-functions.js b/server/api-functions.js index b8c859d..3f46c23 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -335,9 +335,28 @@ function putResource(resource, id, data) { }); } +function putFile(id, metadata, fileStream) { + return new Promise((resolve, reject) => { + let options = url.parse( + `${config.server.nbi}files/${encodeURIComponent(id)}` + ); + + options.method = "PUT"; + options.headers = Object.assign({}, metadata); + let _http = options.protocol === "https:" ? https : http; + let req = _http.request(options, res => { + res.resume(); + if (res.statusCode === 201) return resolve(); + else return reject(new Error(`Unexpected status code ${res.statusCode}`)); + }); + fileStream.pipe(req); + }); +} + exports.postTasks = postTasks; exports.deleteResource = deleteResource; exports.putResource = putResource; exports.updateTags = updateTags; exports.filterToMongoQuery = filterToMongoQuery; exports.ping = ping; +exports.putFile = putFile; diff --git a/server/api.js b/server/api.js index 0d8c205..71c36b7 100644 --- a/server/api.js +++ b/server/api.js @@ -351,6 +351,61 @@ for (let [resource, flags] of Object.entries(resources)) { }); } +router.put("/files/:id", async (ctx, next) => { + const resource = "files"; + const id = ctx.params.id; + + const log = { + message: `Upload ${resource}`, + context: ctx, + id: id + }; + + const authorizer = ctx.state.authorizer; + if (!authorizer.hasAccess(resource, 3)) { + logUnauthorizedWarning(log); + return next(); + } + + let metadata = { + fileType: ctx.request.headers["metadata.filetype"] || "", + oui: ctx.request.headers["metadata.oui"] || "", + productClass: ctx.request.headers["metadata.productclass"] || "", + version: ctx.request.headers["metadata.version"] || "" + }; + + const validate = authorizer.getValidator(resource, metadata); + if (!validate("put")) { + logUnauthorizedWarning(log); + return (ctx.status = 403); + } + + let err = await apiFunctions.putFile(id, metadata, ctx.req); + + if (err) { + log.message += " failed"; + logger.accessWarn(log); + ctx.body = err; + return (ctx.status = 400); + } + + log.metadata = metadata; + logger.accessInfo(log); + + db.putAudit({ + username: ctx.state.user.username, + action: "put", + objectType: resource, + objectId: id + }).catch(err => { + setTimeout(() => { + throw err; + }, 0); + }); + + ctx.body = ""; +}); + router.post("/devices/:id/tasks", async (ctx, next) => { const log = { message: "Commit tasks", diff --git a/server/db.js b/server/db.js index d3b5b90..0d1dfaa 100644 --- a/server/db.js +++ b/server/db.js @@ -17,7 +17,9 @@ const RESOURCE_DB = { files: "genieacs" }; -const RESOURCE_COLLECTION = {}; +const RESOURCE_COLLECTION = { + files: "fs.files" +}; function getClient() { if (!_clientPromise) @@ -91,6 +93,8 @@ function query(resource, filter, options, callback) { docs = docs.map(d => mongodbFunctions.flattenTask(d)); else if (resource === "presets") docs = docs.map(d => mongodbFunctions.flattenPreset(d)); + else if (resource === "files") + docs = docs.map(d => mongodbFunctions.flattenFile(d)); return resolve(docs); }); else @@ -104,6 +108,8 @@ function query(resource, filter, options, callback) { doc = mongodbFunctions.flattenTask(doc); else if (resource === "presets") doc = mongodbFunctions.flattenPreset(doc); + else if (resource === "files") + doc = mongodbFunctions.flattenFile(doc); callback(doc); }, err => { diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index c6643a1..9cf0251 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -457,6 +457,18 @@ function flattenPreset(preset) { return p; } +function flattenFile(file) { + let f = {}; + f["_id"] = file["_id"]; + if (file.metadata) { + f["metadata.fileType"] = file.metadata.fileType || ""; + f["metadata.oui"] = file.metadata.oui || ""; + f["metadata.productClass"] = file.metadata.productClass || ""; + f["metadata.version"] = file.metadata.version || ""; + } + return f; +} + function preProcessPreset(data) { let preset = Object.assign({}, data); preset.precondition = preset.precondition @@ -512,6 +524,7 @@ exports.flattenDevice = flattenDevice; exports.flattenFault = flattenFault; exports.flattenTask = flattenTask; exports.flattenPreset = flattenPreset; +exports.flattenFile = flattenFile; exports.preProcessPreset = preProcessPreset; exports.preProcessProvision = preProcessProvision; exports.preProcessVirtualParameters = preProcessVirtualParameters; diff --git a/server/server.js b/server/server.js index 2e71034..012230c 100644 --- a/server/server.js +++ b/server/server.js @@ -128,6 +128,13 @@ router.post("/logout", async ctx => { }); }); +koa.use(async (ctx, next) => { + if (ctx.request.type === "application/octet-stream") + ctx.disableBodyParser = true; + + return next(); +}); + koa.use(koaBodyParser()); router.use("/api", api.routes(), api.allowedMethods()); From 9ccc7b9d2413dcd43ba150c0b7c974f52e9b7db0 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Wed, 26 Sep 2018 19:13:57 -0700 Subject: [PATCH 065/106] Change sort symbols --- client/devices-page.js | 6 +++--- client/faults-page.js | 6 +++--- client/files-page.js | 6 +++--- client/presets-page.js | 6 +++--- client/provisions-page.js | 6 +++--- client/virtual-parameters-page.js | 6 +++--- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/client/devices-page.js b/client/devices-page.js index 5b8da57..42d00d2 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -90,9 +90,9 @@ function renderTable( if (!param.unsortable && (_param = memoizedGetSortable(param.parameter))) { let direction = 1; - let symbol = "\u21f3"; - if (sort[_param] > 0) symbol = "\u2b07"; - else if (sort[_param] < 0) symbol = "\u2b06"; + let symbol = "\u2981"; + if (sort[_param] > 0) symbol = "\u2bc6"; + else if (sort[_param] < 0) symbol = "\u2bc5"; let sortable = m( "button", diff --git a/client/faults-page.js b/client/faults-page.js index 07ac944..99b7388 100644 --- a/client/faults-page.js +++ b/client/faults-page.js @@ -81,9 +81,9 @@ function renderTable( let direction = 1; - let symbol = "\u21f3"; - if (sort[attr.id] > 0) symbol = "\u2b07"; - else if (sort[attr.id] < 0) symbol = "\u2b06"; + let symbol = "\u2981"; + if (sort[attr.id] > 0) symbol = "\u2bc6"; + else if (sort[attr.id] < 0) symbol = "\u2bc5"; let sortable = m( "button", diff --git a/client/files-page.js b/client/files-page.js index 02f0424..1dec116 100644 --- a/client/files-page.js +++ b/client/files-page.js @@ -150,9 +150,9 @@ function renderTable( let direction = 1; - let symbol = "\u21f3"; - if (sort[attr.id] > 0) symbol = "\u2b07"; - else if (sort[attr.id] < 0) symbol = "\u2b06"; + let symbol = "\u2981"; + if (sort[attr.id] > 0) symbol = "\u2bc6"; + else if (sort[attr.id] < 0) symbol = "\u2bc5"; let sortable = m( "button", diff --git a/client/presets-page.js b/client/presets-page.js index fc703dd..f51a842 100644 --- a/client/presets-page.js +++ b/client/presets-page.js @@ -148,9 +148,9 @@ function renderTable( let direction = 1; - let symbol = "\u21f3"; - if (sort[attr.id] > 0) symbol = "\u2b07"; - else if (sort[attr.id] < 0) symbol = "\u2b06"; + let symbol = "\u2981"; + if (sort[attr.id] > 0) symbol = "\u2bc6"; + else if (sort[attr.id] < 0) symbol = "\u2bc5"; let sortable = m( "button", diff --git a/client/provisions-page.js b/client/provisions-page.js index 57ecc1a..eae6b8a 100644 --- a/client/provisions-page.js +++ b/client/provisions-page.js @@ -136,9 +136,9 @@ function renderTable( let direction = 1; - let symbol = "\u21f3"; - if (sort[attr.id] > 0) symbol = "\u2b07"; - else if (sort[attr.id] < 0) symbol = "\u2b06"; + let symbol = "\u2981"; + if (sort[attr.id] > 0) symbol = "\u2bc6"; + else if (sort[attr.id] < 0) symbol = "\u2bc5"; let sortable = m( "button", diff --git a/client/virtual-parameters-page.js b/client/virtual-parameters-page.js index f6e23a6..30fcf4b 100644 --- a/client/virtual-parameters-page.js +++ b/client/virtual-parameters-page.js @@ -137,9 +137,9 @@ function renderTable( let direction = 1; - let symbol = "\u21f3"; - if (sort[attr.id] > 0) symbol = "\u2b07"; - else if (sort[attr.id] < 0) symbol = "\u2b06"; + let symbol = "\u2981"; + if (sort[attr.id] > 0) symbol = "\u2bc6"; + else if (sort[attr.id] < 0) symbol = "\u2bc5"; let sortable = m( "button", From cb183d1c5647589ea998fb6aed950de63b7ee65a Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Thu, 27 Sep 2018 15:19:00 -0700 Subject: [PATCH 066/106] Fix exception when attempting to push file --- client/drawer-component.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/drawer-component.js b/client/drawer-component.js index 6bb44bc..65b563a 100644 --- a/client/drawer-component.js +++ b/client/drawer-component.js @@ -73,9 +73,9 @@ function renderStagingDownload(task) { files.value .filter( f => - (!f.metadata.oui || f.metadata.oui === oui) && - (!f.metadata.productClass || - f.metadata.productClass === productClass) + (!f["metadata.oui"] || f["metadata.oui"] === oui) && + (!f["metadata.productClass"] || + f["metadata.productClass"] === productClass) ) .map(f => f._id) ) @@ -90,7 +90,7 @@ function renderStagingDownload(task) { task.fileName = f; task.fileType = ""; for (let file of files.value) - if (file._id === f) task.fileType = file.metadata.fileType; + if (file._id === f) task.fileType = file["metadata.fileType"]; } }, f From 4ad416eccd0e490571a7e80db2fabd0fc319a622 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Thu, 27 Sep 2018 15:20:49 -0700 Subject: [PATCH 067/106] Small perf improvement in flattenDevice --- server/mongodb-functions.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index 9cf0251..53c4c3c 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -207,7 +207,7 @@ function processDeviceSort(sort) { function flattenDevice(device) { function recursive(input, root, output, timestamp) { for (let [name, tree] of Object.entries(input)) { - if (root.length === 0) + if (!root) if (name === "_lastInform") { output["Events.Inform"] = { value: [Date.parse(tree), "xsd:dateTime"], @@ -309,7 +309,7 @@ function flattenDevice(device) { let childrenTimestamp = timestamp; - if (root.length === 0) childrenTimestamp = +(input["_timestamp"] || 1); + if (!root) childrenTimestamp = +(input["_timestamp"] || 1); else if (+input["_timestamp"] > timestamp) childrenTimestamp = +input["_timestamp"]; @@ -329,8 +329,8 @@ function flattenDevice(device) { attrs["writableTimestamp"] = childrenTimestamp; } - let r = root.concat(name); - output[r.join(".")] = attrs; + let r = root ? `${root}.${name}` : name; + output[r] = attrs; if (attrs["object"]) recursive(tree, r, output, childrenTimestamp); } @@ -338,7 +338,7 @@ function flattenDevice(device) { const newDevice = {}; const timestamp = new Date(device["_lastInform"] || 1).getTime(); - recursive(device, [], newDevice, timestamp); + recursive(device, "", newDevice, timestamp); return newDevice; } From f79b9dc0f735ca35f2bbfad2ba981de9046e680b Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Thu, 27 Sep 2018 23:22:23 +0300 Subject: [PATCH 068/106] Implement pretty parameter timestamp --- client/components/parameter.js | 15 ++++++++++++++- client/timeago.js | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 client/timeago.js diff --git a/client/components/parameter.js b/client/components/parameter.js index 4683233..1cae675 100644 --- a/client/components/parameter.js +++ b/client/components/parameter.js @@ -5,6 +5,7 @@ import * as taskQueue from "../task-queue"; import * as store from "../store"; import * as expression from "../../common/expression"; import memoize from "../../common/memoize"; +import timeAgo from "../timeago"; const memoizedParse = memoize(expression.parse); @@ -72,7 +73,19 @@ const component = { "✎" ); - return m("span.parameter-value", { title: timestamp }, value, edit); + return m( + "span.parameter-value", + { + onmouseover: e => { + e.redraw = false; + const now = Date.now(); + const localeString = new Date(timestamp).toLocaleString(); + e.target.title = `${localeString} (${timeAgo(now - timestamp)})`; + } + }, + value, + edit + ); } }; diff --git a/client/timeago.js b/client/timeago.js new file mode 100644 index 0000000..b4f1d2c --- /dev/null +++ b/client/timeago.js @@ -0,0 +1,33 @@ +"use strict"; + +const UNITS = { + year: 12 * 30 * 24 * 60 * 60 * 1000, + month: 30 * 24 * 60 * 60 * 1000, + day: 24 * 60 * 60 * 1000, + hour: 60 * 60 * 1000, + minute: 60 * 1000, + second: 1000 +}; + +function timeAgo(dtime) { + let res = ""; + let level = 2; + + for (const [u, t] of Object.entries(UNITS)) + if (dtime >= t) { + let n; + if (level > 1) { + n = Math.floor(dtime / t); + dtime -= n * t; + } else { + n = Math.round(dtime / t); + } + if (n > 1) res += `${n} ${u}s `; + else res += `${n} ${u} `; + if (!--level) break; + } + + return res + "ago"; +} + +export default timeAgo; From de759b8d932cfe6bb7c242f4b560019da79e00ad Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Fri, 28 Sep 2018 23:21:11 +0300 Subject: [PATCH 069/106] Implement code splitting for codemirror --- client/.eslintrc.json | 2 ++ client/components/put-form.js | 5 ++--- client/dynamic-loader.js | 22 ++++++++++++++++++++++ client/provisions-page.js | 10 +++++++++- client/virtual-parameters-page.js | 10 +++++++++- package.json | 1 + webpack.config.js | 4 +++- 7 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 client/dynamic-loader.js diff --git a/client/.eslintrc.json b/client/.eslintrc.json index 6881637..60a5c13 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -2,7 +2,9 @@ "env": { "browser": true }, + "parser": "babel-eslint", "parserOptions": { + "allowImportExportEverywhere": true, "sourceType": "module", "ecmaVersion": 8 } diff --git a/client/components/put-form.js b/client/components/put-form.js index 0a904c1..e61127e 100644 --- a/client/components/put-form.js +++ b/client/components/put-form.js @@ -1,8 +1,7 @@ "use strict"; import m from "mithril"; -import { fromTextArea as codeMirrorFromTextArea } from "codemirror"; -import "codemirror/mode/javascript/javascript"; +import { codeMirror } from "../dynamic-loader"; const singular = { presets: "preset", @@ -41,7 +40,7 @@ function createField(current, attr, focus) { name: attr.id, value: current.object[attr.id], oncreate: _vnode => { - let editor = codeMirrorFromTextArea(_vnode.dom, { + let editor = codeMirror.fromTextArea(_vnode.dom, { mode: "javascript", lineNumbers: true }); diff --git a/client/dynamic-loader.js b/client/dynamic-loader.js new file mode 100644 index 0000000..b556484 --- /dev/null +++ b/client/dynamic-loader.js @@ -0,0 +1,22 @@ +"use strict"; + +let codeMirror; + +function loadCodeMirror() { + if (codeMirror) return Promise.resolve(); + + return new Promise((resolve, reject) => { + const promises = [ + import(/* webpackChunkName: "codemirror" */ "codemirror"), + import(/* webpackChunkName: "codemirror" */ "codemirror/mode/javascript/javascript") + ]; + Promise.all(promises) + .then(modules => { + codeMirror = modules[0]; + resolve(); + }) + .catch(reject); + }); +} + +export { loadCodeMirror, codeMirror }; diff --git a/client/provisions-page.js b/client/provisions-page.js index eae6b8a..52d1c66 100644 --- a/client/provisions-page.js +++ b/client/provisions-page.js @@ -12,6 +12,7 @@ import putForm from "./components/put-form"; import * as overlay from "./overlay"; import * as smartQuery from "./smart-query"; import * as expressionParser from "../common/expression-parser"; +import { loadCodeMirror } from "./dynamic-loader"; const PAGE_SIZE = config.ui.pageSize || 10; @@ -106,7 +107,14 @@ function init(args) { const sort = args.sort; const filter = args.filter; - return Promise.resolve({ filter, sort }); + + return new Promise((resolve, reject) => { + loadCodeMirror() + .then(() => { + resolve({ filter, sort }); + }) + .catch(reject); + }); } function renderTable( diff --git a/client/virtual-parameters-page.js b/client/virtual-parameters-page.js index 30fcf4b..2f62d07 100644 --- a/client/virtual-parameters-page.js +++ b/client/virtual-parameters-page.js @@ -12,6 +12,7 @@ import putForm from "./components/put-form"; import * as overlay from "./overlay"; import * as smartQuery from "./smart-query"; import * as expressionParser from "../common/expression-parser"; +import { loadCodeMirror } from "./dynamic-loader"; const PAGE_SIZE = config.ui.pageSize || 10; @@ -106,7 +107,14 @@ function init(args) { const sort = args.sort; const filter = args.filter; - return Promise.resolve({ filter, sort }); + + return new Promise((resolve, reject) => { + loadCodeMirror() + .then(() => { + resolve({ filter, sort }); + }) + .catch(reject); + }); } function renderTable( diff --git a/package.json b/package.json index a078821..53c58c2 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "parsimmon": "^1.12.0" }, "devDependencies": { + "babel-eslint": "^8.2.6", "clean-webpack-plugin": "^0.1.19", "copy-webpack-plugin": "^4.5.2", "cssnano": "^4.0.5", diff --git a/webpack.config.js b/webpack.config.js index d2aee54..17a396a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,6 +1,7 @@ const nodeExternals = require("webpack-node-externals"); const CopyWebpackPlugin = require("copy-webpack-plugin"); const CleanWebpackPlugin = require("clean-webpack-plugin"); +const path = require("path"); const mode = "production"; @@ -51,7 +52,8 @@ module.exports = [ mode: mode, entry: "./client/app.js", output: { - filename: "public/app.js" + path: path.resolve(__dirname, "dist/public"), + filename: "app.js" } } ]; From 75a5a42296ae1b392cec386ab7845731e174be1a Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sat, 29 Sep 2018 12:46:11 -0700 Subject: [PATCH 070/106] Optimize CSV download --- server/api.js | 24 ++++++++---------------- server/db.js | 19 ++++++++++--------- server/mongodb-functions.js | 3 ++- 3 files changed, 20 insertions(+), 26 deletions(-) diff --git a/server/api.js b/server/api.js index 71c36b7..dd1cd3c 100644 --- a/server/api.js +++ b/server/api.js @@ -139,7 +139,7 @@ for (let [resource, flags] of Object.entries(resources)) { // CSV download router.get(`/${resource}.csv`, async (ctx, next) => { - let options = {}; + let options = { projection: {} }; let filter = true; if (ctx.request.query.filter) filter = expression.parse(ctx.request.query.filter); @@ -154,8 +154,7 @@ for (let [resource, flags] of Object.entries(resources)) { filter: ctx.request.query.filter, limit: options.limit, skip: options.skip, - sort: options.sort, - projection: options.projection + sort: options.sort }; if (!ctx.state.authorizer.hasAccess(resource, 2)) { @@ -165,12 +164,16 @@ for (let [resource, flags] of Object.entries(resources)) { const columns = JSON.parse(ctx.request.query.columns); const now = Date.now(); - for (let [k, v] of Object.entries(columns)) - columns[k] = expression.evaluate( + + for (let [k, v] of Object.entries(columns)) { + const e = expression.evaluate( expression.parse(v), null, now ); + columns[k] = e; + for (let p of expression.extractParams(e)) options.projection[p] = 1; + } // Exclude temporary tasks and faults if (resource === "tasks" || resource === "faults") @@ -392,17 +395,6 @@ router.put("/files/:id", async (ctx, next) => { log.metadata = metadata; logger.accessInfo(log); - db.putAudit({ - username: ctx.state.user.username, - action: "put", - objectType: resource, - objectId: id - }).catch(err => { - setTimeout(() => { - throw err; - }, 0); - }); - ctx.body = ""; }); diff --git a/server/db.js b/server/db.js index 0d1dfaa..153160b 100644 --- a/server/db.js +++ b/server/db.js @@ -60,14 +60,15 @@ function query(resource, filter, options, callback) { const collection = client .db(RESOURCE_DB[resource]) .collection(RESOURCE_COLLECTION[resource] || resource); - let cursor = collection.find( - q, - resource === "Devices" - ? mongodbFunctions.processDeviceProjection(options.projection) - : options.projection - ); - if (options.skip) cursor = cursor.skip(options.skip); - if (options.limit) cursor = cursor.limit(options.limit); + const cursor = collection.find(q); + if (options.projection) + cursor.project( + resource === "devices" + ? mongodbFunctions.processDeviceProjection(options.projection) + : options.projection + ); + if (options.skip) cursor.skip(options.skip); + if (options.limit) cursor.limit(options.limit); if (options.sort) { let s = Object.entries(options.sort) @@ -79,7 +80,7 @@ function query(resource, filter, options, callback) { ); if (resource === "devices") s = mongodbFunctions.processDeviceSort(s); - cursor = cursor.sort(s); + cursor.sort(s); } if (!callback) diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index 53c4c3c..80a626d 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -332,7 +332,8 @@ function flattenDevice(device) { let r = root ? `${root}.${name}` : name; output[r] = attrs; - if (attrs["object"]) recursive(tree, r, output, childrenTimestamp); + if (attrs["object"] || tree["object"] == null) + recursive(tree, r, output, childrenTimestamp); } } From c702003c3258f8ff373e1c334121680310c72332 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sat, 29 Sep 2018 17:37:03 -0700 Subject: [PATCH 071/106] Add linting rules --- .eslintrc.json | 5 +- client/app.js | 4 +- client/autocomplete-compnent.js | 14 +-- client/components/all-parameters.js | 19 ++-- client/components/container.js | 4 +- client/components/device-faults.js | 9 +- client/components/overview-dot.js | 3 +- client/components/parameter-table.js | 18 ++-- client/components/parameter.js | 36 ++++---- client/components/ping.js | 8 +- client/components/put-form.js | 32 +++---- client/components/summon-button.js | 21 ++--- client/components/tags.js | 2 +- client/device-page.js | 8 +- client/devices-page.js | 63 +++++++------ client/drawer-component.js | 76 +++++++++------- client/faults-page.js | 50 ++++++----- client/files-page.js | 70 ++++++++------- client/filter-component.js | 7 +- client/menu.js | 23 +++-- client/overlay.js | 3 +- client/overview-page.js | 18 ++-- client/pie-chart-component.js | 22 ++--- client/presets-page.js | 74 +++++++++------- client/provisions-page.js | 66 ++++++++------ client/smart-query.js | 21 +++-- client/store.js | 75 +++++++++------- client/task-queue.js | 11 +-- client/timeago.js | 3 +- client/user-menu.js | 5 +- client/virtual-parameters-page.js | 72 ++++++++------- common/authorizer.js | 29 +++--- common/expression-cnf.js | 16 ++-- common/expression-parser.js | 28 +++--- common/expression.js | 25 +++--- common/sat-solver.js | 7 +- server/api-functions.js | 92 ++++++++++--------- server/api.js | 128 ++++++++++++++------------- server/config.js | 4 +- server/db.js | 16 ++-- server/logger.js | 7 +- server/mongodb-functions.js | 65 ++++++++------ server/server.js | 22 ++--- 43 files changed, 705 insertions(+), 576 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 9d8ed29..87bad29 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -10,6 +10,9 @@ "no-shadow": ["error", { "allow": ["err"] }], "handle-callback-err": "error", "prefer-arrow-callback": "error", - "curly": ["error", "multi", "consistent"] + "curly": ["error", "multi-or-nest", "consistent"], + "consistent-return": ["error", {"treatUndefinedAsUnspecified": true}], + "no-buffer-constructor": "error", + "prefer-const": ["error", {"destructuring": "all"}] } } diff --git a/client/app.js b/client/app.js index fe5e468..f6ca4b3 100644 --- a/client/app.js +++ b/client/app.js @@ -51,14 +51,14 @@ function pagify(pageName, page) { if (!page.init) { state = null; fulfill(); - return; + return null; } return new Promise(resolve => { page .init(args) .then(st => { - if (!st) return m.route.set("/"); + if (!st) return void m.route.set("/"); state = st; resolve(); fulfill(); diff --git a/client/autocomplete-compnent.js b/client/autocomplete-compnent.js index d4cb437..c2b2e4b 100644 --- a/client/autocomplete-compnent.js +++ b/client/autocomplete-compnent.js @@ -20,7 +20,7 @@ class Autocomplete { el.addEventListener("focus", () => { this.element = el; - let domRect = el.getBoundingClientRect(); + const domRect = el.getBoundingClientRect(); this.container.style.left = `${domRect.left}px`; this.container.style.width = `${domRect.width}px`; this.container.style.top = `${domRect.bottom}px`; @@ -36,13 +36,15 @@ class Autocomplete { el.addEventListener("keydown", e => { if (this.element !== el) return; - if (e.key === "Escape") this.hide(); - else if (e.key === "Enter") + if (e.key === "Escape") { + this.hide(); + } else if (e.key === "Enter") { if (this.default != null) { el.value = this.default; e.preventDefault(); this.update(); } + } }); el.addEventListener("input", () => { @@ -94,10 +96,10 @@ class Autocomplete { this.default = suggestions[0]; - for (let suggestion of suggestions) { - let e = document.createElement("div"); + for (const suggestion of suggestions) { + const e = document.createElement("div"); e.className = "suggestion"; - let t = document.createTextNode(suggestion); + const t = document.createTextNode(suggestion); e.appendChild(t); e.addEventListener("mousedown", ev => { ev.preventDefault(); diff --git a/client/components/all-parameters.js b/client/components/all-parameters.js index 4c5e6e9..8ed6ce7 100644 --- a/client/components/all-parameters.js +++ b/client/components/all-parameters.js @@ -12,7 +12,7 @@ const component = { view: vnode => { const device = vnode.attrs.device; - let search = m("input", { + const search = m("input", { type: "text", placeholder: "Search parameters", oninput: e => { @@ -20,10 +20,10 @@ const component = { } }); - let instanceRegex = /\.[0-9]+$/; + const instanceRegex = /\.[0-9]+$/; let re; if (vnode.state.searchString) { - let keywords = vnode.state.searchString.split(" ").filter(s => s); + const keywords = vnode.state.searchString.split(" ").filter(s => s); if (keywords.length) re = new RegExp(keywords.map(s => escapeRegExp(s)).join(".*"), "i"); } @@ -31,22 +31,22 @@ const component = { const rows = Object.keys(device) .sort() .map(k => { - let p = device[k]; - let val = []; - let attrs = {}; + const p = device[k]; + const val = []; + const attrs = {}; if (re) { - let str = p.value && p.value[0] ? `${k} ${p.value[0]}` : k; + const str = p.value && p.value[0] ? `${k} ${p.value[0]}` : k; if (!re.test(str)) attrs.style = "display: none;"; } - if (p.object === false) + if (p.object === false) { val.push( m( components.get("parameter"), Object.assign({ device: device, parameter: k }) ) ); - else if (p.object && p.writable) + } else if (p.object && p.writable) { if (instanceRegex.test(k)) { val.push( m( @@ -82,6 +82,7 @@ const component = { ) ); } + } val.push( m( diff --git a/client/components/container.js b/client/components/container.js index 9d1713a..600ac18 100644 --- a/client/components/container.js +++ b/client/components/container.js @@ -12,10 +12,10 @@ const component = { view: vnode => { if (vnode.attrs.filter) { const filter = memoizedParse(vnode.attrs.filter); - if (!store.evaluateExpression(filter, vnode.attrs.device)) return; + if (!store.evaluateExpression(filter, vnode.attrs.device)) return null; } - let children = Object.values(vnode.attrs.components).map(c => { + const children = Object.values(vnode.attrs.components).map(c => { if (typeof c !== "object") return `${c}`; const attrs = Object.assign({}, vnode.attrs, c); return m(components.get(attrs.type), attrs); diff --git a/client/components/device-faults.js b/client/components/device-faults.js index 2eff14a..cd8c232 100644 --- a/client/components/device-faults.js +++ b/client/components/device-faults.js @@ -20,7 +20,7 @@ const component = { const thead = m("thead", m("tr", headers)); const rows = []; - for (let f of faults.value) + for (const f of faults.value) { rows.push([ m("td", f["channel"]), m("td", f["code"]), @@ -51,14 +51,17 @@ const component = { ) ) ]); + } let tbody; - if (rows.length) tbody = m("tbody", rows.map(r => m("tr", r))); - else + if (rows.length) { + tbody = m("tbody", rows.map(r => m("tr", r))); + } else { tbody = m( "tbody", m("tr.empty", m("td", { colspan: headers.length }, "No faults")) ); + } return m("table.table", thead, tbody); } diff --git a/client/components/overview-dot.js b/client/components/overview-dot.js index 6789df4..71fc0d8 100644 --- a/client/components/overview-dot.js +++ b/client/components/overview-dot.js @@ -14,7 +14,7 @@ const component = { view: vnode => { const device = vnode.attrs.device; const chart = CHARTS[vnode.attrs.chart]; - for (let slice of Object.values(chart.slices)) { + for (const slice of Object.values(chart.slices)) { const filter = memoizedParse(slice.filter); if (store.evaluateExpression(filter, device)) { const dot = m( @@ -35,6 +35,7 @@ const component = { return m("span.overview-dot", dot, `${slice.label}`); } } + return null; } }; diff --git a/client/components/parameter-table.js b/client/components/parameter-table.js index f406b8a..947ebbb 100644 --- a/client/components/parameter-table.js +++ b/client/components/parameter-table.js @@ -9,7 +9,7 @@ import * as expressionParser from "../../common/expression-parser"; const component = { oninit: vnode => { - let obj = expression.parse(vnode.attrs.parameter); + const obj = expression.parse(vnode.attrs.parameter); if (!Array.isArray(obj) || !obj[0] === "PARAM") throw new Error("Object must be a parameter path"); vnode.state.object = obj[1]; @@ -30,19 +30,20 @@ const component = { const instances = new Set(); const prefix = `${object}.`; - for (let p in device) + for (const p in device) { if (p.startsWith(prefix)) { - let i = p.indexOf(".", prefix.length); + const i = p.indexOf(".", prefix.length); if (i === -1) instances.add(p); else instances.add(p.slice(0, i)); } + } const headers = Object.values(parameters).map(p => m("th", p.label)); const thead = m("thead", m("tr", headers)); const rows = []; - for (let i of instances) { + for (const i of instances) { const row = parameters.map(p => { const param = expressionParser.map(p.parameter, e => { if (Array.isArray(e) && e[0] === "PARAM") @@ -62,7 +63,7 @@ const component = { ); }); - if (device[i].writable === true) + if (device[i].writable === true) { row.push( m( "td", @@ -82,15 +83,17 @@ const component = { ) ) ); + } rows.push(m("tr", row)); } - if (!rows.length) + if (!rows.length) { rows.push( m("tr.empty", m("td", { colspan: headers.length }, "No instances")) ); + } - if (device[object].writable === true) + if (device[object].writable === true) { rows.push( m( "tr", @@ -114,6 +117,7 @@ const component = { ) ) ); + } let label; if (vnode.attrs.label) label = m("h2", vnode.attrs.label); diff --git a/client/components/parameter.js b/client/components/parameter.js index 1cae675..03d515d 100644 --- a/client/components/parameter.js +++ b/client/components/parameter.js @@ -11,27 +11,22 @@ const memoizedParse = memoize(expression.parse); const evaluateParam = memoize((exp, obj, now) => { let timestamp = now; - let params = new Set(); - let value = expression.evaluate( - memoizedParse(exp), - obj, - now, - e => { - if (Array.isArray(e)) { - if (e[0] === "PARAM") { - params.add(e[1]); - if (!Array.isArray(e[1])) { - let p = obj[e[1]]; - if (p && p.valueTimestamp) - timestamp = Math.min(timestamp, p.valueTimestamp); - } + const params = new Set(); + const value = expression.evaluate(memoizedParse(exp), obj, now, e => { + if (Array.isArray(e)) { + if (e[0] === "PARAM") { + params.add(e[1]); + if (!Array.isArray(e[1])) { + const p = obj[e[1]]; + if (p && p.valueTimestamp) + timestamp = Math.min(timestamp, p.valueTimestamp); } - if (e[0] === "FUNC" && e[1] === "DATE_STRING" && !Array.isArray(e[2])) - return new Date(e[2]).toLocaleString(); } - return e; + if (e[0] === "FUNC" && e[1] === "DATE_STRING" && !Array.isArray(e[2])) + return new Date(e[2]).toLocaleString(); } - ); + return e; + }); let parameter = params.size === 1 ? params.values().next().value : null; if (Array.isArray(parameter)) parameter = null; @@ -42,7 +37,7 @@ const component = { view: vnode => { const device = vnode.attrs.device; - let { value, timestamp, parameter } = evaluateParam( + const { value, timestamp, parameter } = evaluateParam( vnode.attrs.parameter, vnode.attrs.device, store.getTimestamp() @@ -51,7 +46,7 @@ const component = { if (value == null) return null; let edit; - if (device[parameter] && device[parameter].writable) + if (device[parameter] && device[parameter].writable) { edit = m( "button", { @@ -72,6 +67,7 @@ const component = { }, "✎" ); + } return m( "span.parameter-value", diff --git a/client/components/ping.js b/client/components/ping.js index 89facae..93146c5 100644 --- a/client/components/ping.js +++ b/client/components/ping.js @@ -13,7 +13,7 @@ const component = { vnode.state.timeout = null; }, view: vnode => { - let refresh = () => { + const refresh = () => { const device = vnode.attrs.device; let param = device["InternetGatewayDevice.ManagementServer.ConnectionRequestURL"]; @@ -34,20 +34,22 @@ const component = { }); }; - let t = vnode.state.timestamp + REFRESH_INTERVAL - Date.now(); + const t = vnode.state.timestamp + REFRESH_INTERVAL - Date.now(); clearTimeout(vnode.state.timeout); if (t <= 0) refresh(); else vnode.state.timeout = setTimeout(refresh, t); if (vnode.state.host) { let status = ""; - if (vnode.state.ping) + if (vnode.state.ping) { if (vnode.state.ping.avg != null) status = `${Math.trunc(vnode.state.ping.avg)} ms`; else status = "Unreachable"; + } return m("div", `Pinging ${vnode.state.host}: ${status}`); } + return null; } }; diff --git a/client/components/put-form.js b/client/components/put-form.js index e61127e..fb6a0de 100644 --- a/client/components/put-form.js +++ b/client/components/put-form.js @@ -12,8 +12,8 @@ const singular = { function createField(current, attr, focus) { if (attr.type === "combo") { - let options = [m("option", { value: "" }, "--Please choose--")]; - for (let op of attr.options) options.push(m("option", { value: op }, op)); + const options = [m("option", { value: "" }, "--Please choose--")]; + for (const op of attr.options) options.push(m("option", { value: op }, op)); let selected = ""; if (attr.options.includes(current.object[attr.id])) @@ -40,7 +40,7 @@ function createField(current, attr, focus) { name: attr.id, value: current.object[attr.id], oncreate: _vnode => { - let editor = codeMirror.fromTextArea(_vnode.dom, { + const editor = codeMirror.fromTextArea(_vnode.dom, { mode: "javascript", lineNumbers: true }); @@ -87,21 +87,22 @@ function createField(current, attr, focus) { const component = { view: vnode => { - let actionHandler = vnode.attrs.actionHandler; - let attributes = vnode.attrs.attributes; - let resource = vnode.attrs.resource; - let base = vnode.attrs.base || {}; - if (!vnode.state.current) + const actionHandler = vnode.attrs.actionHandler; + const attributes = vnode.attrs.attributes; + const resource = vnode.attrs.resource; + const base = vnode.attrs.base || {}; + if (!vnode.state.current) { vnode.state.current = { isNew: !base["_id"], object: Object.assign({}, base) }; + } - let current = vnode.state.current; + const current = vnode.state.current; - let form = []; + const form = []; let focused = false; - for (let attr of attributes) { + for (const attr of attributes) { let focus = false; if (!focused && (current.isNew || attr.id !== "_id")) focus = focused = true; @@ -117,9 +118,9 @@ const component = { } const submit = m("button.primary", { type: "submit" }, "Save"); - let buttons = [submit]; + const buttons = [submit]; - if (!current.isNew) + if (!current.isNew) { buttons.push( m( "button.primary", @@ -135,10 +136,11 @@ const component = { "Delete" ) ); + } form.push(m(".actions-bar", buttons)); - let children = [ + const children = [ m("h1", `${current.isNew ? "New" : "Editing"} ${singular[resource]}`), m( "form", @@ -147,7 +149,7 @@ const component = { e.redraw = false; e.target.onsubmit = null; e.preventDefault(); - for (let elem of e.target.elements) elem.disabled = true; + for (const elem of e.target.elements) elem.disabled = true; submit.dom.textContent = "Loading ..."; actionHandler("save", current.object); } diff --git a/client/components/summon-button.js b/client/components/summon-button.js index 8afdc4b..f70854d 100644 --- a/client/components/summon-button.js +++ b/client/components/summon-button.js @@ -37,28 +37,29 @@ const component = { .commit( [task], (deviceId, err, connectionRequestStatus, tasks2) => { - if (err) - return notifications.push( - "error", - `${deviceId}: ${err.message}` - ); + if (err) { + notifications.push("error", `${deviceId}: ${err.message}`); + return; + } - for (let t of tasks2) + for (const t of tasks2) if (t.status === "stale") taskQueue.deleteTask(t); - if (connectionRequestStatus !== "OK") + if (connectionRequestStatus !== "OK") { notifications.push( "error", `${deviceId}: ${connectionRequestStatus}` ); - else if (tasks2[0].status === "stale") + } else if (tasks2[0].status === "stale") { notifications.push( "error", `${deviceId}: No contact from device` ); - else if (tasks2[0].status === "fault") + } else if (tasks2[0].status === "fault") { notifications.push("error", `${deviceId}: Refresh faulted`); - else notifications.push("success", `${deviceId}: Summoned`); + } else { + notifications.push("success", `${deviceId}: Summoned`); + } } ) .then(() => { diff --git a/client/components/tags.js b/client/components/tags.js index 76bf2cf..7a12881 100644 --- a/client/components/tags.js +++ b/client/components/tags.js @@ -11,7 +11,7 @@ const component = { if (vnode.attrs.writable != null) writable = vnode.attrs.writable; const tags = []; - for (let p of Object.keys(device)) + for (const p of Object.keys(device)) if (p.startsWith("Tags.")) tags.push(p.slice(5)); tags.sort(); diff --git a/client/device-page.js b/client/device-page.js index 907c6cd..809c2c4 100644 --- a/client/device-page.js +++ b/client/device-page.js @@ -7,10 +7,11 @@ import * as store from "./store"; import * as components from "./components"; function init(args) { - if (!window.authorizer.hasAccess("devices", 2)) + if (!window.authorizer.hasAccess("devices", 2)) { return Promise.reject( new Error("You are not authorized to view this page") ); + } return Promise.resolve({ deviceId: args.id, @@ -22,15 +23,16 @@ const component = { view: vnode => { document.title = `${vnode.attrs.deviceId} - Devices - GenieACS`; - let dev = store.fetch("devices", vnode.attrs.deviceFilter).value; + const dev = store.fetch("devices", vnode.attrs.deviceFilter).value; if (!dev.length) return "Loading"; const conf = config.ui.device; const cmps = []; - for (let c of Object.values(conf)) + for (const c of Object.values(conf)) { cmps.push( m(components.get(c["type"]), Object.assign({ device: dev[0] }, c)) ); + } return [ m("h1", vnode.attrs.deviceId), diff --git a/client/devices-page.js b/client/devices-page.js index 42d00d2..2eab86d 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -18,11 +18,9 @@ const PAGE_SIZE = config.ui.pageSize || 10; const memoizedParse = memoize(expression.parse); const memoizedJsonParse = memoize(JSON.parse); const memoizedGetSortable = memoize(str => { - let expressionParams = expression.extractParams( - expression.parse(str) - ); + const expressionParams = expression.extractParams(expression.parse(str)); if (expressionParams.length === 1) { - let param = expression.evaluate(expressionParams[0]); + const param = expression.evaluate(expressionParams[0]); if (typeof param === "string") return param; } return null; @@ -52,7 +50,7 @@ const unpackSmartQuery = memoize(query => { function init(args) { return new Promise((resolve, reject) => { if (!window.authorizer.hasAccess("devices", 2)) - return reject(new Error("You are not authorized to view this page")); + return void reject(new Error("You are not authorized to view this page")); const filter = args.filter; const sort = args.sort; @@ -76,16 +74,17 @@ function renderTable( type: "checkbox", checked: devices.length && selected.size === devices.length, onchange: e => { - for (let d of devices) + for (const d of devices) { if (e.target.checked) selected.add(d["DeviceID.ID"].value[0]); else selected.delete(d["DeviceID.ID"].value[0]); + } }, disabled: !total }); - let labels = [m("th", selectAll)]; - for (let param of parameters) { - let label = param.label; + const labels = [m("th", selectAll)]; + for (const param of parameters) { + const label = param.label; let _param; if (!param.unsortable && (_param = memoizedGetSortable(param.parameter))) { let direction = 1; @@ -94,7 +93,7 @@ function renderTable( if (sort[_param] > 0) symbol = "\u2bc6"; else if (sort[_param] < 0) symbol = "\u2bc5"; - let sortable = m( + const sortable = m( "button", { onclick: () => { @@ -111,9 +110,9 @@ function renderTable( } } - let rows = []; - for (let device of devices) { - let checkbox = m("input", { + const rows = []; + for (const device of devices) { + const checkbox = m("input", { type: "checkbox", checked: selected.has(device["DeviceID.ID"].value[0]), onchange: e => { @@ -158,12 +157,13 @@ function renderTable( ); } - if (!rows.length) + if (!rows.length) { rows.push( m("tr.empty", m("td", { colspan: parameters.length + 1 }, "No devices")) ); + } - let footerElements = []; + const footerElements = []; if (total != null) footerElements.push(`${devices.length}/${total}`); else footerElements.push(`${devices.length}`); @@ -179,12 +179,13 @@ function renderTable( ) ); - if (downloadUrl) + if (downloadUrl) { footerElements.push( m("a.download-csv", { href: downloadUrl, download: "" }, "Download") ); + } - let tfoot = m( + const tfoot = m( "tfoot", m("tr", m("td", { colspan: labels.length }, footerElements)) ); @@ -206,11 +207,12 @@ function renderActions(selected) { title: "Reboot selected devices", disabled: !selected.size, onclick: () => { - for (let d of selected) + for (const d of selected) { taskQueue.queueTask({ name: "reboot", device: d }); + } } }, "Reboot" @@ -224,11 +226,12 @@ function renderActions(selected) { title: "Factory reset selected devices", disabled: !selected.size, onclick: () => { - for (let d of selected) + for (const d of selected) { taskQueue.queueTask({ name: "factoryReset", device: d }); + } } }, "Reset" @@ -246,7 +249,7 @@ function renderActions(selected) { if (!confirm(`Deleting ${ids.length} devices. Are you sure?`)) return; let counter = 1; - for (let id of ids) { + for (const id of ids) { ++counter; store .deleteResource("devices", id) @@ -278,7 +281,7 @@ function renderActions(selected) { if (!tag) return; let counter = 1; - for (let id of ids) { + for (const id of ids) { ++counter; store .updateTags(id, { [tag]: true }) @@ -312,7 +315,7 @@ function renderActions(selected) { if (!tag) return; let counter = 1; - for (let id of ids) { + for (const id of ids) { ++counter; store .updateTags(id, { [tag]: false }) @@ -345,13 +348,13 @@ const component = { } function onFilterChanged(filter) { - let ops = { filter }; + const ops = { filter }; if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; m.route.set("/devices", ops); } function onSortChange(sort) { - let ops = { sort }; + const ops = { sort }; if (vnode.attrs.filter) ops.filter = vnode.attrs.filter; m.route.set("/devices", ops); } @@ -360,17 +363,19 @@ const component = { let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; filter = unpackSmartQuery(filter); - let devs = store.fetch("devices", filter, { + const devs = store.fetch("devices", filter, { limit: vnode.state.showCount || PAGE_SIZE, sort: sort }); - let count = store.count("devices", filter); + const count = store.count("devices", filter); - let selected = new Set(); - if (vnode.state.selected) - for (let d of devs.value) + const selected = new Set(); + if (vnode.state.selected) { + for (const d of devs.value) { if (vnode.state.selected.has(d["DeviceID.ID"].value[0])) selected.add(d["DeviceID.ID"].value[0]); + } + } vnode.state.selected = selected; const downloadUrl = getDownloadUrl( diff --git a/client/drawer-component.js b/client/drawer-component.js index 65b563a..45813d3 100644 --- a/client/drawer-component.js +++ b/client/drawer-component.js @@ -89,7 +89,7 @@ function renderStagingDownload(task) { onclick: () => { task.fileName = f; task.fileType = ""; - for (let file of files.value) + for (const file of files.value) if (file._id === f) task.fileType = file["metadata.fileType"]; } }, @@ -110,8 +110,8 @@ function renderStagingDownload(task) { } function renderStaging(staging) { - let elements = []; - for (let s of staging) { + const elements = []; + for (const s of staging) { const queueFunc = () => { staging.delete(s); taskQueue.queueTask(s); @@ -143,19 +143,19 @@ function renderStaging(staging) { } function renderQueue(queue) { - let details = []; - let devices = {}; - for (let t of queue) { + const details = []; + const devices = {}; + for (const t of queue) { devices[t.device] = devices[t.device] || []; devices[t.device].push(t); } - for (let [k, v] of Object.entries(devices)) { + for (const [k, v] of Object.entries(devices)) { details.push(m("strong", k)); - for (let t of v) { - let actions = []; + for (const t of v) { + const actions = []; - if (t.status === "fault" || t.status === "stale") + if (t.status === "fault" || t.status === "stale") { actions.push( m( "button", @@ -168,6 +168,7 @@ function renderQueue(queue) { "↺" ) ); + } actions.push( m( @@ -182,7 +183,7 @@ function renderQueue(queue) { ) ); - if (t.name === "setParameterValues") + if (t.name === "setParameterValues") { details.push( m( `div.${t.status}`, @@ -197,7 +198,7 @@ function renderQueue(queue) { m(".actions", actions) ) ); - else if (t.name === "refreshObject") + } else if (t.name === "refreshObject") { details.push( m( `div.${t.status}`, @@ -205,13 +206,13 @@ function renderQueue(queue) { m(".actions", actions) ) ); - else if (t.name === "reboot") + } else if (t.name === "reboot") { details.push(m(`div.${t.status}`, "Reboot", m(".actions", actions))); - else if (t.name === "factoryReset") + } else if (t.name === "factoryReset") { details.push( m(`div.${t.status}`, "Factory reset", m(".actions", actions)) ); - else if (t.name === "addObject") + } else if (t.name === "addObject") { details.push( m( `div.${t.status}`, @@ -219,7 +220,7 @@ function renderQueue(queue) { m(".actions", actions) ) ); - else if (t.name === "deleteObject") + } else if (t.name === "deleteObject") { details.push( m( `div.${t.status}`, @@ -227,7 +228,7 @@ function renderQueue(queue) { m(".actions", actions) ) ); - else if (t.name === "getParameterValues") + } else if (t.name === "getParameterValues") { details.push( m( `div.${t.status}`, @@ -235,7 +236,7 @@ function renderQueue(queue) { m(".actions", actions) ) ); - else if (t.name === "download") + } else if (t.name === "download") { details.push( m( `div.${t.status}`, @@ -243,7 +244,9 @@ function renderQueue(queue) { m(".actions", actions) ) ); - else details.push(m(`div.${t.status}`, t.name, m(".actions", actions))); + } else { + details.push(m(`div.${t.status}`, t.name, m(".actions", actions))); + } } } @@ -253,7 +256,7 @@ function renderQueue(queue) { function renderNotifications(notifs) { const notificationElements = []; - for (let n of notifs) + for (const n of notifs) { notificationElements.push( m( "div.notification", @@ -276,6 +279,7 @@ function renderNotifications(notifs) { n.message ) ); + } return notificationElements; } @@ -286,13 +290,13 @@ const component = { const notifs = notifications.getNotifications(); let drawerElement, statusElement; - let notificationElements = renderNotifications(notifs); - let stagingElements = renderStaging(staging); - let queueElements = renderQueue(queue); + const notificationElements = renderNotifications(notifs); + const stagingElements = renderStaging(staging); + const queueElements = renderQueue(queue); function repositionNotifications() { let top = 10; - for (let c of notificationElements) { + for (const c of notificationElements) { c.dom.style.top = top; top += c.dom.offsetHeight + 10; } @@ -300,20 +304,21 @@ const component = { function resizeDrawer() { let height = statusElement.dom.offsetTop + statusElement.dom.offsetHeight; - if (stagingElements.length) - for (let s of stagingElements) + if (stagingElements.length) { + for (const s of stagingElements) height = Math.max(height, s.dom.offsetTop + s.dom.offsetHeight); - else if (vnode.state.mouseIn) - for (let c of drawerElement.children) + } else if (vnode.state.mouseIn) { + for (const c of drawerElement.children) height = Math.max(height, c.dom.offsetTop + c.dom.offsetHeight); + } drawerElement.dom.style.height = height; } if (stagingElements.length + queueElements.length) { const statusCount = { queued: 0, pending: 0, fault: 0, stale: 0 }; - for (let t of queue) statusCount[t.status] += 1; + for (const t of queue) statusCount[t.status] += 1; - let actions = m( + const actions = m( ".actions", m( "button.primary", @@ -321,18 +326,20 @@ const component = { title: "Commit queued tasks", disabled: !statusCount.queued, onclick: () => { - let tasks = Array.from(taskQueue.getQueue()).filter( + const tasks = Array.from(taskQueue.getQueue()).filter( t => t.status === "queued" ); taskQueue .commit( tasks, (deviceId, err, connectionRequestStatus, tasks2) => { - if (err) - return notifications.push( + if (err) { + notifications.push( "error", `${deviceId}: ${err.message}` ); + return; + } if (connectionRequestStatus !== "OK") { notifications.push( @@ -342,7 +349,7 @@ const component = { return; } - for (let t of tasks2) + for (const t of tasks2) { if (t.status === "stale") { notifications.push( "error", @@ -356,6 +363,7 @@ const component = { ); return; } + } notifications.push( "success", diff --git a/client/faults-page.js b/client/faults-page.js index 99b7388..47b48d7 100644 --- a/client/faults-page.js +++ b/client/faults-page.js @@ -24,10 +24,11 @@ const attributes = [ ]; const getDownloadUrl = memoize(filter => { - let cols = {}; - for (let attr of attributes) + const cols = {}; + for (const attr of attributes) { cols[attr.label] = attr.id === "timestamp" ? `DATE_STRING(${attr.id})` : attr.id; + } return `/api/faults.csv?${m.buildQueryString({ filter: filter, @@ -44,10 +45,11 @@ const unpackSmartQuery = memoize(query => { }); function init(args) { - if (!window.authorizer.hasAccess("faults", 2)) + if (!window.authorizer.hasAccess("faults", 2)) { return Promise.reject( new Error("You are not authorized to view this page") ); + } const sort = args.sort; const filter = args.filter; @@ -68,16 +70,17 @@ function renderTable( type: "checkbox", checked: faults.length && selected.size === faults.length, onchange: e => { - for (let f of faults) + for (const f of faults) { if (e.target.checked) selected.add(f["_id"]); else selected.delete(f["_id"]); + } }, disabled: !total }); const labels = [m("th", selectAll)]; - for (let attr of attributes) { - let label = attr.label; + for (const attr of attributes) { + const label = attr.label; let direction = 1; @@ -85,7 +88,7 @@ function renderTable( if (sort[attr.id] > 0) symbol = "\u2bc6"; else if (sort[attr.id] < 0) symbol = "\u2bc5"; - let sortable = m( + const sortable = m( "button", { onclick: () => { @@ -99,9 +102,9 @@ function renderTable( labels.push(m("th", [label, sortable])); } - let rows = []; - for (let f of faults) { - let checkbox = m("input", { + const rows = []; + for (const f of faults) { + const checkbox = m("input", { type: "checkbox", checked: selected.has(f["_id"]), onchange: e => { @@ -116,13 +119,14 @@ function renderTable( const deviceHref = `#!/devices/${encodeURIComponent(f["device"])}`; - let tds = [m("td", checkbox)]; - for (let attr of attributes) + const tds = [m("td", checkbox)]; + for (const attr of attributes) { if (attr.id === "device") tds.push(m("a", { href: deviceHref }, f[attr.id])); else if (attr.id === "timestamp") tds.push(m("td", new Date(f[attr.id]).toLocaleString())); else tds.push(m("td", f[attr.id])); + } rows.push( m( @@ -145,7 +149,7 @@ function renderTable( if (!rows.length) rows.push(m("tr.empty", m("td", { colspan: 7 }, "No faults"))); - let footerElements = []; + const footerElements = []; if (total != null) footerElements.push(`${faults.length}/${total}`); else footerElements.push(`${faults.length}`); @@ -161,12 +165,13 @@ function renderTable( ) ); - if (downloadUrl) + if (downloadUrl) { footerElements.push( m("a.download-csv", { href: downloadUrl, download: "" }, "Download") ); + } - let tfoot = m( + const tfoot = m( "tfoot", m("tr", m("td", { colspan: labels.length }, footerElements)) ); @@ -217,13 +222,13 @@ const component = { } function onFilterChanged(filter) { - let ops = { filter }; + const ops = { filter }; if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; m.route.set("/faults", ops); } function onSortChange(sort) { - let ops = { sort }; + const ops = { sort }; if (vnode.attrs.filter) ops.filter = vnode.attrs.filter; m.route.set("/faults", ops); } @@ -232,16 +237,17 @@ const component = { let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; filter = unpackSmartQuery(filter); - let faults = store.fetch("faults", filter, { + const faults = store.fetch("faults", filter, { limit: vnode.state.showCount || PAGE_SIZE, sort: sort }); - let count = store.count("faults", filter); + const count = store.count("faults", filter); - let selected = new Set(); - if (vnode.state.selected) - for (let f of faults.value) + const selected = new Set(); + if (vnode.state.selected) { + for (const f of faults.value) if (vnode.state.selected.has(f["_id"])) selected.add(f["_id"]); + } vnode.state.selected = selected; const downloadUrl = getDownloadUrl(vnode.attrs.filter); diff --git a/client/files-page.js b/client/files-page.js index 1dec116..6173f11 100644 --- a/client/files-page.js +++ b/client/files-page.js @@ -37,7 +37,7 @@ const attributes = [ { id: "metadata.version", label: "Version" } ]; -let formData = { +const formData = { resource: "files", attributes: attributes .slice(1) // remove _id from new object form @@ -55,21 +55,21 @@ const unpackSmartQuery = memoize(query => { function putActionHandler(action, object) { return new Promise((resolve, reject) => { if (action === "save") { - let file = object["file"] ? object["file"][0] : null; + const file = object["file"] ? object["file"][0] : null; delete object["file"]; - if (!file) return reject(new Error("File not selected")); + if (!file) return void reject(new Error("File not selected")); - let id = file.name; + const id = file.name; store .resourceExists("files", id) .then(exists => { if (exists) { store.fulfill(0, Date.now()); - return reject(new Error("File already exists")); + return void reject(new Error("File already exists")); } - let headers = Object.assign( + const headers = Object.assign( { "Content-Type": "application/octet-stream", Accept: "application/octet-stream" @@ -81,9 +81,7 @@ function putActionHandler(action, object) { method: "PUT", headers: headers, url: `/api/files/${encodeURIComponent(id)}`, - serialize: body => { - return body; // Identity function to prevent JSON.parse on blob data - }, + serialize: body => body, // Identity function to prevent JSON.parse on blob data data: file }) .then(() => { @@ -104,8 +102,8 @@ function putActionHandler(action, object) { } const getDownloadUrl = memoize(filter => { - let cols = {}; - for (let attr of attributes) cols[attr.label] = attr.id; + const cols = {}; + for (const attr of attributes) cols[attr.label] = attr.id; return `/api/files.csv?${m.buildQueryString({ filter: filter, columns: JSON.stringify(cols) @@ -113,10 +111,11 @@ const getDownloadUrl = memoize(filter => { }); function init(args) { - if (!window.authorizer.hasAccess("files", 2)) + if (!window.authorizer.hasAccess("files", 2)) { return Promise.reject( new Error("You are not authorized to view this page") ); + } const sort = args.sort; const filter = args.filter; @@ -137,16 +136,17 @@ function renderTable( type: "checkbox", checked: files.length && selected.size === files.length, onchange: e => { - for (let file of files) + for (const file of files) { if (e.target.checked) selected.add(file["_id"]); else selected.delete(file["_id"]); + } }, disabled: !total }); const labels = [m("th", selectAll)]; - for (let attr of attributes) { - let label = attr.label; + for (const attr of attributes) { + const label = attr.label; let direction = 1; @@ -154,7 +154,7 @@ function renderTable( if (sort[attr.id] > 0) symbol = "\u2bc6"; else if (sort[attr.id] < 0) symbol = "\u2bc5"; - let sortable = m( + const sortable = m( "button", { onclick: () => { @@ -168,9 +168,9 @@ function renderTable( labels.push(m("th", [label, sortable])); } - let rows = []; - for (let file of files) { - let checkbox = m("input", { + const rows = []; + for (const file of files) { + const checkbox = m("input", { type: "checkbox", checked: selected.has(file["_id"]), onchange: e => { @@ -183,11 +183,12 @@ function renderTable( } }); - let tds = [m("td", checkbox)]; - for (let attr of attributes) + const tds = [m("td", checkbox)]; + for (const attr of attributes) { if (attr.id == "script") tds.push(m("td", { title: file[attr.id] }, file[attr.id])); else tds.push(m("td", file[attr.id])); + } rows.push( m( @@ -210,7 +211,7 @@ function renderTable( if (!rows.length) rows.push(m("tr.empty", m("td", { colspan: labels.length }, "No files"))); - let footerElements = []; + const footerElements = []; if (total != null) footerElements.push(`${files.length}/${total}`); else footerElements.push(`${files.length}`); @@ -226,12 +227,13 @@ function renderTable( ) ); - if (downloadUrl) + if (downloadUrl) { footerElements.push( m("a.download-csv", { href: downloadUrl, download: "" }, "Download") ); + } - let tfoot = m( + const tfoot = m( "tfoot", m("tr", m("td", { colspan: labels.length }, footerElements)) ); @@ -261,14 +263,14 @@ function renderTable( ) ]; - if (window.authorizer.hasAccess("files", 3)) + if (window.authorizer.hasAccess("files", 3)) { buttons.push( m( "button.primary", { title: "Create new file", onclick: () => { - let cb = () => { + const cb = () => { return m( putForm, Object.assign( @@ -295,6 +297,7 @@ function renderTable( "New" ) ); + } return [ m( @@ -317,13 +320,13 @@ const component = { } function onFilterChanged(filter) { - let ops = { filter }; + const ops = { filter }; if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; m.route.set("/files", ops); } function onSortChange(sort) { - let ops = { sort }; + const ops = { sort }; if (vnode.attrs.filter) ops.filter = vnode.attrs.filter; m.route.set("/files", ops); } @@ -332,17 +335,18 @@ const component = { let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; filter = unpackSmartQuery(filter); - let files = store.fetch("files", filter, { + const files = store.fetch("files", filter, { limit: vnode.state.showCount || PAGE_SIZE, sort: sort }); - let count = store.count("files", filter); + const count = store.count("files", filter); - let selected = new Set(); - if (vnode.state.selected) - for (let file of files.value) + const selected = new Set(); + if (vnode.state.selected) { + for (const file of files.value) if (vnode.state.selected.has(file["_id"])) selected.add(file["_id"]); + } vnode.state.selected = selected; const downloadUrl = getDownloadUrl(vnode.attrs.filter); diff --git a/client/filter-component.js b/client/filter-component.js index 91b2249..32b102d 100644 --- a/client/filter-component.js +++ b/client/filter-component.js @@ -17,8 +17,8 @@ const getAutocomplete = memoize(resource => { function parseFilter(f) { if (/^[\s0-9a-zA-Z]+:/.test(f)) { - let k = f.split(":", 1)[0]; - let v = f.slice(k.length + 1).trim(); + const k = f.split(":", 1)[0]; + const v = f.slice(k.length + 1).trim(); return ["FUNC", "Q", k.trim(), v]; } return expression.parse(f); @@ -35,7 +35,7 @@ const splitFilter = memoize(filter => { const list = []; const f = expression.parse(filter); if (Array.isArray(f) && f[0] === "AND") - for (let ff of f.slice(1)) list.push(stringifyFilter(ff)); + for (const ff of f.slice(1)) list.push(stringifyFilter(ff)); else list.push(stringifyFilter(f)); list.push(""); @@ -59,6 +59,7 @@ const component = { } catch (err) { vnode.state.filterInvalid |= 1 << idx; } + return null; }); vnode.state.filterList.push(""); diff --git a/client/menu.js b/client/menu.js index cf7ef8a..605d470 100644 --- a/client/menu.js +++ b/client/menu.js @@ -6,8 +6,8 @@ const menu = { view: vnode => { const active = { [vnode.attrs.page]: "active" }; - let tabs = []; - if (window.authorizer.hasAccess("devices", 1)) + const tabs = []; + if (window.authorizer.hasAccess("devices", 1)) { tabs.push( m( "li", @@ -15,8 +15,9 @@ const menu = { m("a", { href: "#!/overview" }, "Overview") ) ); + } - if (window.authorizer.hasAccess("devices", 2)) + if (window.authorizer.hasAccess("devices", 2)) { tabs.push( m( "li", @@ -24,8 +25,9 @@ const menu = { m("a", { href: "#!/devices" }, "Devices") ) ); + } - if (window.authorizer.hasAccess("faults", 2)) + if (window.authorizer.hasAccess("faults", 2)) { tabs.push( m( "li", @@ -33,8 +35,9 @@ const menu = { m("a", { href: "#!/faults" }, "Faults") ) ); + } - if (window.authorizer.hasAccess("presets", 2)) + if (window.authorizer.hasAccess("presets", 2)) { tabs.push( m( "li", @@ -42,8 +45,9 @@ const menu = { m("a", { href: "#!/presets" }, "Presets") ) ); + } - if (window.authorizer.hasAccess("provisions", 2)) + if (window.authorizer.hasAccess("provisions", 2)) { tabs.push( m( "li", @@ -51,8 +55,9 @@ const menu = { m("a", { href: "#!/provisions" }, "Provisions") ) ); + } - if (window.authorizer.hasAccess("virtualParameters", 2)) + if (window.authorizer.hasAccess("virtualParameters", 2)) { tabs.push( m( "li", @@ -60,8 +65,9 @@ const menu = { m("a", { href: "#!/virtualParameters" }, "Virtual Parameters") ) ); + } - if (window.authorizer.hasAccess("files", 2)) + if (window.authorizer.hasAccess("files", 2)) { tabs.push( m( "li", @@ -69,6 +75,7 @@ const menu = { m("a", { href: "#!/files" }, "Files") ) ); + } return m("nav", m("ul", tabs)); } diff --git a/client/overlay.js b/client/overlay.js index bca3be3..59f0331 100644 --- a/client/overlay.js +++ b/client/overlay.js @@ -18,7 +18,7 @@ function close(callback) { } function render() { - if (overlayCallback) + if (overlayCallback) { return m( ".overlay-wrapper", { @@ -50,6 +50,7 @@ function render() { overlayCallback() ) ); + } return null; } diff --git a/client/overview-page.js b/client/overview-page.js index b0c4b29..cd81f5a 100644 --- a/client/overview-page.js +++ b/client/overview-page.js @@ -11,9 +11,10 @@ const memoizedParse = memoize(expression.parse); const GROUPS = config.ui.overview.groups; const CHARTS = {}; -for (let group of Object.values(GROUPS)) - for (let chartName of Object.values(group["charts"])) +for (const group of Object.values(GROUPS)) { + for (const chartName of Object.values(group["charts"])) CHARTS[chartName] = config.ui.overview.charts[chartName]; +} function queryCharts(charts) { charts = Object.assign({}, charts); @@ -30,10 +31,11 @@ function queryCharts(charts) { } const init = function() { - if (!window.authorizer.hasAccess("devices", 1)) + if (!window.authorizer.hasAccess("devices", 1)) { return Promise.reject( new Error("You are not authorized to view this page") ); + } return Promise.resolve({ charts: queryCharts(CHARTS) }); }; @@ -41,14 +43,14 @@ const init = function() { const component = { view: vnode => { document.title = "Overview - GenieACS"; - let children = []; - for (let group of Object.values(GROUPS)) { + const children = []; + for (const group of Object.values(GROUPS)) { if (group.label) children.push(m("h1", group.label)); - let groupChildren = []; - for (let chartName of Object.values(group.charts)) { + const groupChildren = []; + for (const chartName of Object.values(group.charts)) { const chart = vnode.attrs.charts[chartName]; - let chartChildren = []; + const chartChildren = []; if (chart.label) chartChildren.push(m("h2", chart.label)); chartChildren.push(m(pieChartComponent, { chart: chart })); diff --git a/client/pie-chart-component.js b/client/pie-chart-component.js index add0044..71a79a4 100644 --- a/client/pie-chart-component.js +++ b/client/pie-chart-component.js @@ -3,21 +3,21 @@ import m from "mithril"; const drawChart = function(chartData) { - let slices = chartData.slices; - let total = Array.from(Object.values(chartData.slices)).reduce( + const slices = chartData.slices; + const total = Array.from(Object.values(chartData.slices)).reduce( (a, s) => a + (s.count.value || 0), 0 ); - let legend = []; - let paths = []; - let links = []; + const legend = []; + const paths = []; + const links = []; let currentProgressPercentage = 0; let startX = Math.cos(2 * Math.PI * currentProgressPercentage) * 100; let startY = Math.sin(2 * Math.PI * currentProgressPercentage) * 100; let endX, endY; - for (let slice of Object.values(slices)) { - let percent = total > 0 ? (slice.count.value || 0) / total : 0; + for (const slice of Object.values(slices)) { + const percent = total > 0 ? (slice.count.value || 0) / total : 0; legend.push( m(".legend-line", [ m("span.color", { @@ -41,9 +41,9 @@ const drawChart = function(chartData) { currentProgressPercentage += percent; endX = Math.cos(2 * Math.PI * currentProgressPercentage) * 100; endY = Math.sin(2 * Math.PI * currentProgressPercentage) * 100; - let isBigArc = percent > 0.5 ? 1 : 0; + const isBigArc = percent > 0.5 ? 1 : 0; - let sketch = + const sketch = `M ${startX} ${startY} ` + // Move to the starting point `A 100 100 0 ${isBigArc} 1 ${endX} ${endY} ` + // Draw an Arc from starting point to ending point `L 0 0 z`; // complete the shape by drawing a line to the center of circle @@ -58,9 +58,9 @@ const drawChart = function(chartData) { }) ); - let percentageX = + const percentageX = Math.cos(2 * Math.PI * (currentProgressPercentage - percent / 2)) * 50; - let percentageY = + const percentageY = Math.sin(2 * Math.PI * (currentProgressPercentage - percent / 2)) * 50; links.push( diff --git a/client/presets-page.js b/client/presets-page.js index f51a842..3c1dff0 100644 --- a/client/presets-page.js +++ b/client/presets-page.js @@ -39,10 +39,10 @@ const unpackSmartQuery = memoize(query => { function putActionHandler(action, object, isNew) { if (action === "save") { - let id = object["_id"]; + const id = object["_id"]; delete object["_id"]; - if (!id) return notifications.push("error", "ID can not be empty"); + if (!id) return void notifications.push("error", "ID can not be empty"); store .resourceExists("presets", id) @@ -90,14 +90,14 @@ function putActionHandler(action, object, isNew) { } } -let formData = { +const formData = { resource: "presets", attributes: attributes }; const getDownloadUrl = memoize(filter => { - let cols = {}; - for (let attr of attributes) cols[attr.label] = attr.id; + const cols = {}; + for (const attr of attributes) cols[attr.label] = attr.id; return `/api/presets.csv?${m.buildQueryString({ filter: filter, columns: JSON.stringify(cols) @@ -105,10 +105,11 @@ const getDownloadUrl = memoize(filter => { }); function init(args) { - if (!window.authorizer.hasAccess("presets", 2)) + if (!window.authorizer.hasAccess("presets", 2)) { return Promise.reject( new Error("You are not authorized to view this page") ); + } const sort = args.sort; const filter = args.filter; @@ -129,17 +130,18 @@ function renderTable( type: "checkbox", checked: presets.length && selected.size === presets.length, onchange: e => { - for (let preset of presets) + for (const preset of presets) { if (e.target.checked) selected.add(preset["_id"]); else selected.delete(preset["_id"]); + } }, disabled: !total }); const labels = [m("th", selectAll)]; - for (let attr of attributes) { - let label = attr.label; + for (const attr of attributes) { + const label = attr.label; if (attr.unsortable) { labels.push(m("th", label)); @@ -152,7 +154,7 @@ function renderTable( if (sort[attr.id] > 0) symbol = "\u2bc6"; else if (sort[attr.id] < 0) symbol = "\u2bc5"; - let sortable = m( + const sortable = m( "button", { onclick: () => { @@ -166,9 +168,9 @@ function renderTable( labels.push(m("th", [label, sortable])); } - let rows = []; - for (let preset of presets) { - let checkbox = m("input", { + const rows = []; + for (const preset of presets) { + const checkbox = m("input", { type: "checkbox", checked: selected.has(preset["_id"]), onchange: e => { @@ -182,14 +184,15 @@ function renderTable( }); let devicesUrl = "/#!/devices"; - if (preset["precondition"].length) + if (preset["precondition"].length) { devicesUrl += `?${m.buildQueryString({ filter: preset["precondition"] })}`; + } - let tds = [m("td", checkbox)]; - for (let attr of attributes) - if (attr.id === "precondition") + const tds = [m("td", checkbox)]; + for (const attr of attributes) { + if (attr.id === "precondition") { tds.push( m( "td", @@ -197,7 +200,10 @@ function renderTable( m("a", { href: devicesUrl }, preset[attr.id]) ) ); - else tds.push(m("td", preset[attr.id])); + } else { + tds.push(m("td", preset[attr.id])); + } + } tds.push( m( @@ -206,7 +212,7 @@ function renderTable( "a", { onclick: () => { - let cb = () => { + const cb = () => { return m( putForm, Object.assign( @@ -250,7 +256,7 @@ function renderTable( if (!rows.length) rows.push(m("tr.empty", m("td", { colspan: labels.length }, "No presets"))); - let footerElements = []; + const footerElements = []; if (total != null) footerElements.push(`${presets.length}/${total}`); else footerElements.push(`${presets.length}`); @@ -266,12 +272,13 @@ function renderTable( ) ); - if (downloadUrl) + if (downloadUrl) { footerElements.push( m("a.download-csv", { href: downloadUrl, download: "" }, "Download") ); + } - let tfoot = m( + const tfoot = m( "tfoot", m("tr", m("td", { colspan: labels.length }, footerElements)) ); @@ -301,14 +308,14 @@ function renderTable( ) ]; - if (window.authorizer.hasAccess("presets", 3)) + if (window.authorizer.hasAccess("presets", 3)) { buttons.push( m( "button.primary", { title: "Create new preset", onclick: () => { - let cb = () => { + const cb = () => { return m( putForm, Object.assign( @@ -328,6 +335,7 @@ function renderTable( "New" ) ); + } return [ m( @@ -350,13 +358,13 @@ const component = { } function onFilterChanged(filter) { - let ops = { filter }; + const ops = { filter }; if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; m.route.set("/presets", ops); } function onSortChange(sort) { - let ops = { sort }; + const ops = { sort }; if (vnode.attrs.filter) ops.filter = vnode.attrs.filter; m.route.set("/presets", ops); } @@ -365,15 +373,15 @@ const component = { let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; filter = unpackSmartQuery(filter); - let presets = store.fetch("presets", filter, { + const presets = store.fetch("presets", filter, { limit: vnode.state.showCount || PAGE_SIZE, sort: sort }); - let count = store.count("presets", filter); + const count = store.count("presets", filter); const provisions = store.fetch("provisions", true); if (provisions.fulfilled) { - let provisionAttr = attributes.find(attr => { + const provisionAttr = attributes.find(attr => { return attr.id === "provision"; }); provisionAttr.options = provisions.value.map(v => { @@ -381,11 +389,13 @@ const component = { }); } - let selected = new Set(); - if (vnode.state.selected) - for (let preset of presets.value) + const selected = new Set(); + if (vnode.state.selected) { + for (const preset of presets.value) { if (vnode.state.selected.has(preset["_id"])) selected.add(preset["_id"]); + } + } vnode.state.selected = selected; const downloadUrl = getDownloadUrl(vnode.attrs.filter); diff --git a/client/provisions-page.js b/client/provisions-page.js index 52d1c66..0f61c5c 100644 --- a/client/provisions-page.js +++ b/client/provisions-page.js @@ -34,10 +34,10 @@ const unpackSmartQuery = memoize(query => { function putActionHandler(action, object, isNew) { if (action === "save") { - let id = object["_id"]; + const id = object["_id"]; delete object["_id"]; - if (!id) return notifications.push("error", "ID can not be empty"); + if (!id) return void notifications.push("error", "ID can not be empty"); store .resourceExists("provisions", id) @@ -85,14 +85,14 @@ function putActionHandler(action, object, isNew) { } } -let formData = { +const formData = { resource: "provisions", attributes: attributes }; const getDownloadUrl = memoize(filter => { - let cols = {}; - for (let attr of attributes) cols[attr.label] = attr.id; + const cols = {}; + for (const attr of attributes) cols[attr.label] = attr.id; return `/api/provisions.csv?${m.buildQueryString({ filter: filter, columns: JSON.stringify(cols) @@ -100,10 +100,11 @@ const getDownloadUrl = memoize(filter => { }); function init(args) { - if (!window.authorizer.hasAccess("provisions", 2)) + if (!window.authorizer.hasAccess("provisions", 2)) { return Promise.reject( new Error("You are not authorized to view this page") ); + } const sort = args.sort; const filter = args.filter; @@ -131,16 +132,17 @@ function renderTable( type: "checkbox", checked: provisions.length && selected.size === provisions.length, onchange: e => { - for (let provision of provisions) + for (const provision of provisions) { if (e.target.checked) selected.add(provision["_id"]); else selected.delete(provision["_id"]); + } }, disabled: !total }); const labels = [m("th", selectAll)]; - for (let attr of attributes) { - let label = attr.label; + for (const attr of attributes) { + const label = attr.label; let direction = 1; @@ -148,7 +150,7 @@ function renderTable( if (sort[attr.id] > 0) symbol = "\u2bc6"; else if (sort[attr.id] < 0) symbol = "\u2bc5"; - let sortable = m( + const sortable = m( "button", { onclick: () => { @@ -162,9 +164,9 @@ function renderTable( labels.push(m("th", [label, sortable])); } - let rows = []; - for (let provision of provisions) { - let checkbox = m("input", { + const rows = []; + for (const provision of provisions) { + const checkbox = m("input", { type: "checkbox", checked: selected.has(provision["_id"]), onchange: e => { @@ -177,11 +179,12 @@ function renderTable( } }); - let tds = [m("td", checkbox)]; - for (let attr of attributes) + const tds = [m("td", checkbox)]; + for (const attr of attributes) { if (attr.id == "script") tds.push(m("td", { title: provision[attr.id] }, provision[attr.id])); else tds.push(m("td", provision[attr.id])); + } tds.push( m( @@ -190,7 +193,7 @@ function renderTable( "a", { onclick: () => { - let cb = () => { + const cb = () => { return m( putForm, Object.assign( @@ -232,12 +235,13 @@ function renderTable( ); } - if (!rows.length) + if (!rows.length) { rows.push( m("tr.empty", m("td", { colspan: labels.length }, "No provisions")) ); + } - let footerElements = []; + const footerElements = []; if (total != null) footerElements.push(`${provisions.length}/${total}`); else footerElements.push(`${provisions.length}`); @@ -253,12 +257,13 @@ function renderTable( ) ); - if (downloadUrl) + if (downloadUrl) { footerElements.push( m("a.download-csv", { href: downloadUrl, download: "" }, "Download") ); + } - let tfoot = m( + const tfoot = m( "tfoot", m("tr", m("td", { colspan: labels.length }, footerElements)) ); @@ -290,14 +295,14 @@ function renderTable( ) ]; - if (window.authorizer.hasAccess("provisions", 3)) + if (window.authorizer.hasAccess("provisions", 3)) { buttons.push( m( "button.primary", { title: "Create new provision", onclick: () => { - let cb = () => { + const cb = () => { return m( putForm, Object.assign( @@ -317,6 +322,7 @@ function renderTable( "New" ) ); + } return [ m( @@ -339,13 +345,13 @@ const component = { } function onFilterChanged(filter) { - let ops = { filter }; + const ops = { filter }; if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; m.route.set("/provisions", ops); } function onSortChange(sort) { - let ops = { sort }; + const ops = { sort }; if (vnode.attrs.filter) ops.filter = vnode.attrs.filter; m.route.set("/provisions", ops); } @@ -354,18 +360,20 @@ const component = { let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; filter = unpackSmartQuery(filter); - let provisions = store.fetch("provisions", filter, { + const provisions = store.fetch("provisions", filter, { limit: vnode.state.showCount || PAGE_SIZE, sort: sort }); - let count = store.count("provisions", filter); + const count = store.count("provisions", filter); - let selected = new Set(); - if (vnode.state.selected) - for (let provision of provisions.value) + const selected = new Set(); + if (vnode.state.selected) { + for (const provision of provisions.value) { if (vnode.state.selected.has(provision["_id"])) selected.add(provision["_id"]); + } + } vnode.state.selected = selected; const downloadUrl = getDownloadUrl(vnode.attrs.filter); diff --git a/client/smart-query.js b/client/smart-query.js index 256e6ed..50155f1 100644 --- a/client/smart-query.js +++ b/client/smart-query.js @@ -3,7 +3,7 @@ import config from "./config"; import * as expression from "../common/expression"; -let resources = { +const resources = { devices: {}, faults: { Device: { parameter: ["PARAM", "device"], type: "string" }, @@ -35,11 +35,12 @@ let resources = { } }; -for (let v of Object.values(config.ui.filters)) +for (const v of Object.values(config.ui.filters)) { resources.devices[v.label] = { parameter: expression.parse(v.parameter), type: (v.type || "").split(",").map(s => s.trim()) }; +} function getLabels(resource) { if (!resources[resource]) return []; @@ -48,14 +49,15 @@ function getLabels(resource) { function queryNumber(param, value) { let op = "="; - for (let o of ["<>", "=", "<=", "<", ">=", ">"]) + for (const o of ["<>", "=", "<=", "<", ">=", ">"]) { if (value.startsWith(o)) { op = o; value = value.slice(o.length).trim(); break; } + } - let v = parseInt(value); + const v = parseInt(value); if (v !== +value) return null; return [op, param, v]; @@ -68,12 +70,13 @@ function queryString(param, value) { function queryMac(param, value) { value = value.replace(/[^a-f0-9]/gi, "").toLowerCase(); if (!value) return null; - if (value.length === 12) + if (value.length === 12) { return [ "LIKE", ["FUNC", "LOWER", param], value.replace(/(..)(?!$)/g, "$1:") ]; + } return [ "OR", @@ -90,20 +93,20 @@ function unpack(resource, label, value) { if (!resources[resource]) return null; const type = resources[resource][label].type; value = value.trim(); - let res = ["OR"]; + const res = ["OR"]; if (type.length === 0 || type.includes("number")) { - let q = queryNumber(resources[resource][label].parameter, value); + const q = queryNumber(resources[resource][label].parameter, value); if (q) res.push(q); } if (type.length === 0 || type.includes("string")) { - let q = queryString(resources[resource][label].parameter, value); + const q = queryString(resources[resource][label].parameter, value); if (q) res.push(q); } if (type.includes("mac")) { - let q = queryMac(resources[resource][label].parameter, value); + const q = queryMac(resources[resource][label].parameter, value); if (q) res.push(q); } diff --git a/client/store.js b/client/store.js index dab64a2..f2d5bf5 100644 --- a/client/store.js +++ b/client/store.js @@ -1,7 +1,6 @@ "use strict"; import m from "mithril"; -import config from "./config"; import * as expressionParser from "../common/expression-parser"; import * as expression from "../common/expression"; import memoize from "../common/memoize"; @@ -23,7 +22,7 @@ const queries = { }; const resources = {}; -for (let r of [ +for (const r of [ "devices", "faults", "files", @@ -31,13 +30,14 @@ for (let r of [ "provisions", "virtualParameters", "files" -]) +]) { resources[r] = { objects: new Map(), count: new Map(), fetch: new Map(), combinedFilter: null }; +} class QueryResponse { get fulfilled() { @@ -79,11 +79,12 @@ function limitFilter(filter, sort, bookmark) { arr.reduce((cur, kv) => { const [param, asc] = kv; if (asc <= 0) { - if (bookmark[param] == null) + if (bookmark[param] == null) { return expression.or( ["IS NOT NULL", ["PARAM", param]], expression.and(["IS NULL", ["PARAM", param]], cur) ); + } let f = null; @@ -124,13 +125,15 @@ function compareFunction(sort) { for (const [param, asc] of sortEntries) { let v1 = a[param]; let v2 = b[param]; - if (v1 != null && typeof v1 === "object") + if (v1 != null && typeof v1 === "object") { if (v1.value) v1 = v1.value[0]; else v1 = null; + } - if (v2 != null && typeof v2 === "object") + if (v2 != null && typeof v2 === "object") { if (v2.value) v2 = v2.value[0]; else v2 = null; + } if (v1 > v2) { return asc; @@ -153,23 +156,24 @@ function compareFunction(sort) { function findMatches(resourceType, filter, sort, limit) { // Handle "tag =" and "tag <>" special cases - if (resourceType === "devices") + if (resourceType === "devices") { filter = expressionParser.map(filter, e => { if ( Array.isArray(e) && Array.isArray(e[1]) && e[1][0] === "PARAM" && e[1][1] === "tag" - ) + ) { if (e[0] === "=") return ["IS NOT NULL", ["PARAM", `Tags.${e[2]}`]]; else if (e[0] === "<>") return ["IS NULL", ["PARAM", `Tags.${e[2]}`]]; + } return e; }); + } let value = []; - for (let obj of resources[resourceType].objects.values()) - if (expression.evaluate(filter, obj, fulfillTimestamp)) - value.push(obj); + for (const obj of resources[resourceType].objects.values()) + if (expression.evaluate(filter, obj, fulfillTimestamp)) value.push(obj); value = value.sort(compareFunction(sort)); if (limit) value = value.slice(0, limit); @@ -181,7 +185,7 @@ function inferQuery(resourceType, queryResponse) { const limit = queries.limit.get(queryResponse); let filter = queries.filter.get(queryResponse); filter = unpackExpression(filter); - let bookmark = queries.bookmark.get(queryResponse); + const bookmark = queries.bookmark.get(queryResponse); const sort = queries.sort.get(queryResponse); if (bookmark || !limit) { if (bookmark) filter = limitFilter(filter, sort, bookmark); @@ -208,7 +212,7 @@ function fetch(resourceType, filter, options = {}) { sort["DeviceID.ID"] = sort["DeviceID.ID"] || 1; else sort["_id"] = sort["_id"] || 1; - let key = `${filterStr}:${limit}:${JSON.stringify(sort)}`; + const key = `${filterStr}:${limit}:${JSON.stringify(sort)}`; let queryResponse = resources[resourceType].fetch.get(key); if (queryResponse) return queryResponse; @@ -225,15 +229,15 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { let updated = false; if (_fulfillTimestamp > fulfillTimestamp) { - for (let resource of Object.values(resources)) + for (const resource of Object.values(resources)) resource.combinedFilter = null; fulfillTimestamp = _fulfillTimestamp; } const allPromises = []; - for (let [resourceType, resource] of Object.entries(resources)) - for (let [queryResponseKey, queryResponse] of resource.count) { + for (const [resourceType, resource] of Object.entries(resources)) { + for (const [queryResponseKey, queryResponse] of resource.count) { if (!(queries.accessed.get(queryResponse) >= accessTimestamp)) { resource.count.delete(queryResponseKey); continue; @@ -269,11 +273,12 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { ); } } + } - let toFetchAll = {}; + const toFetchAll = {}; - for (let [resourceType, resource] of Object.entries(resources)) - for (let [queryResponseKey, queryResponse] of resource.fetch) { + for (const [resourceType, resource] of Object.entries(resources)) { + for (const [queryResponseKey, queryResponse] of resource.fetch) { if (!(queries.accessed.get(queryResponse) >= accessTimestamp)) { resource.fetch.delete(queryResponseKey); continue; @@ -287,7 +292,7 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { toFetchAll[resourceType].push(queryResponse); const limit = queries.limit.get(queryResponse); const sort = queries.sort.get(queryResponse); - if (limit) + if (limit) { allPromises.push( new Promise((resolve, reject) => { updated = true; @@ -308,12 +313,15 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { .then(res => { if (res.length) { // Generate bookmark object - let bm = Object.keys(sort).reduce((b, k) => { - if (res[0][k] != null) + 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]; + } else { + b[k] = res[0][k]; + } + } return b; }, {}); @@ -326,8 +334,10 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { .catch(reject); }) ); + } } } + } return new Promise((resolve, reject) => { Promise.all(allPromises) @@ -368,11 +378,12 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { if (!resources[resourceType].combinedFilter) deleted = new Set(resources[resourceType].objects.keys()); let combinedFilterDiff = combinedFilter; - if (resources[resourceType].combinedFilter) + if (resources[resourceType].combinedFilter) { combinedFilterDiff = expression.and( combinedFilterDiff, expression.not(resources[resourceType].combinedFilter) ); + } resources[resourceType].combinedFilter = expression.or( combinedFilter, resources[resourceType].combinedFilter @@ -389,7 +400,7 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { }) }) .then(res => { - for (let r of res) { + for (const r of res) { const id = resourceType === "devices" ? r["DeviceID.ID"].value[0] @@ -398,7 +409,7 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { deleted.delete(id); } - for (let d of deleted) { + for (const d of deleted) { const obj = resources[resourceType].objects.get(d); if ( expression.evaluate( @@ -410,7 +421,7 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { resources[resourceType].objects.delete(d); } - for (let queryResponse of toFetch) { + for (const queryResponse of toFetch) { let filter = queries.filter.get(queryResponse); filter = unpackExpression(filter); const limit = queries.limit.get(queryResponse); @@ -444,7 +455,7 @@ function getTimestamp() { } function postTasks(deviceId, tasks) { - for (let t of tasks) { + for (const t of tasks) { t.status = "pending"; t.device = deviceId; } @@ -459,8 +470,8 @@ function postTasks(deviceId, tasks) { const connectionRequestStatus = xhr.getResponseHeader( "Connection-Request" ); - let st = JSON.parse(xhr.response); - for (let [i, t] of st.entries()) { + 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; @@ -487,7 +498,7 @@ function deleteResource(resourceType, id) { } function putResource(resourceType, id, object) { - for (let k in object) if (object[k] === undefined) object[k] = null; + for (const k in object) if (object[k] === undefined) object[k] = null; return m.request({ method: "PUT", @@ -498,7 +509,7 @@ function putResource(resourceType, id, object) { function resourceExists(resource, id) { const param = resource === "devices" ? "DeviceID.ID" : "_id"; - let filter = ["=", ["PARAM", param], id]; + const filter = ["=", ["PARAM", param], id]; return m.request({ method: "HEAD", url: diff --git a/client/task-queue.js b/client/task-queue.js index df0b9db..b24244d 100644 --- a/client/task-queue.js +++ b/client/task-queue.js @@ -41,8 +41,8 @@ function stageDownload(task) { } function commit(tasks, callback) { - let devices = {}; - for (let t of tasks) { + const devices = {}; + for (const t of tasks) { devices[t.device] = devices[t.device] || []; devices[t.device].push(t); queueTask(t); @@ -50,19 +50,20 @@ function commit(tasks, callback) { return new Promise(resolve => { let counter = 1; - for (let [deviceId, tasks2] of Object.entries(devices)) { + for (const [deviceId, tasks2] of Object.entries(devices)) { ++counter; store .postTasks(deviceId, tasks) .then(connectionRequestStatus => { - for (let t of tasks2) + for (const t of tasks2) { if (t.status === "pending") t.status = "stale"; else if (t.status === "done") queue.delete(t); + } callback(deviceId, null, connectionRequestStatus, tasks2); if (--counter === 0) resolve(); }) .catch(err => { - for (let t of tasks2) t.status = "stale"; + for (const t of tasks2) t.status = "stale"; callback(deviceId, err, null, tasks2); if (--counter === 0) resolve(); }); diff --git a/client/timeago.js b/client/timeago.js index b4f1d2c..c0143ab 100644 --- a/client/timeago.js +++ b/client/timeago.js @@ -13,7 +13,7 @@ function timeAgo(dtime) { let res = ""; let level = 2; - for (const [u, t] of Object.entries(UNITS)) + for (const [u, t] of Object.entries(UNITS)) { if (dtime >= t) { let n; if (level > 1) { @@ -26,6 +26,7 @@ function timeAgo(dtime) { else res += `${n} ${u} `; if (!--level) break; } + } return res + "ago"; } diff --git a/client/user-menu.js b/client/user-menu.js index 7eeff90..3a01528 100644 --- a/client/user-menu.js +++ b/client/user-menu.js @@ -7,7 +7,7 @@ import * as notifications from "./notifications"; const userMenu = { view: () => { - if (window.username) + if (window.username) { return m( "div.user-menu", window.username, @@ -32,7 +32,7 @@ const userMenu = { "Log out" ) ); - else + } else { return m( "div.user-menu", m( @@ -43,6 +43,7 @@ const userMenu = { "Log in" ) ); + } } }; diff --git a/client/virtual-parameters-page.js b/client/virtual-parameters-page.js index 2f62d07..1f32be5 100644 --- a/client/virtual-parameters-page.js +++ b/client/virtual-parameters-page.js @@ -34,10 +34,10 @@ const unpackSmartQuery = memoize(query => { function putActionHandler(action, object, isNew) { if (action === "save") { - let id = object["_id"]; + const id = object["_id"]; delete object["_id"]; - if (!id) return notifications.push("error", "ID can not be empty"); + if (!id) return void notifications.push("error", "ID can not be empty"); store .resourceExists("virtualParameters", id) @@ -85,14 +85,14 @@ function putActionHandler(action, object, isNew) { } } -let formData = { +const formData = { resource: "virtualParameters", attributes: attributes }; const getDownloadUrl = memoize(filter => { - let cols = {}; - for (let attr of attributes) cols[attr.label] = attr.id; + const cols = {}; + for (const attr of attributes) cols[attr.label] = attr.id; return `/api/virtualParameters.csv?${m.buildQueryString({ filter: filter, columns: JSON.stringify(cols) @@ -100,10 +100,11 @@ const getDownloadUrl = memoize(filter => { }); function init(args) { - if (!window.authorizer.hasAccess("virtualParameters", 2)) + if (!window.authorizer.hasAccess("virtualParameters", 2)) { return Promise.reject( new Error("You are not authorized to view this page") ); + } const sort = args.sort; const filter = args.filter; @@ -132,16 +133,17 @@ function renderTable( checked: virtualParameters.length && selected.size === virtualParameters.length, onchange: e => { - for (let virtualParameter of virtualParameters) + for (const virtualParameter of virtualParameters) { if (e.target.checked) selected.add(virtualParameter["_id"]); else selected.delete(virtualParameter["_id"]); + } }, disabled: !total }); const labels = [m("th", selectAll)]; - for (let attr of attributes) { - let label = attr.label; + for (const attr of attributes) { + const label = attr.label; let direction = 1; @@ -149,7 +151,7 @@ function renderTable( if (sort[attr.id] > 0) symbol = "\u2bc6"; else if (sort[attr.id] < 0) symbol = "\u2bc5"; - let sortable = m( + const sortable = m( "button", { onclick: () => { @@ -163,9 +165,9 @@ function renderTable( labels.push(m("th", [label, sortable])); } - let rows = []; - for (let virtualParameter of virtualParameters) { - let checkbox = m("input", { + const rows = []; + for (const virtualParameter of virtualParameters) { + const checkbox = m("input", { type: "checkbox", checked: selected.has(virtualParameter["_id"]), onchange: e => { @@ -178,9 +180,9 @@ function renderTable( } }); - let tds = [m("td", checkbox)]; - for (let attr of attributes) - if (attr.id == "script") + const tds = [m("td", checkbox)]; + for (const attr of attributes) { + if (attr.id == "script") { tds.push( m( "td", @@ -188,7 +190,10 @@ function renderTable( virtualParameter[attr.id] ) ); - else tds.push(m("td", virtualParameter[attr.id])); + } else { + tds.push(m("td", virtualParameter[attr.id])); + } + } tds.push( m( @@ -197,7 +202,7 @@ function renderTable( "a", { onclick: () => { - let cb = () => { + const cb = () => { return m( putForm, Object.assign( @@ -239,15 +244,16 @@ function renderTable( ); } - if (!rows.length) + if (!rows.length) { rows.push( m( "tr.empty", m("td", { colspan: labels.length }, "No virtual parameters") ) ); + } - let footerElements = []; + const footerElements = []; if (total != null) footerElements.push(`${virtualParameters.length}/${total}`); else footerElements.push(`${virtualParameters.length}`); @@ -266,12 +272,13 @@ function renderTable( ) ); - if (downloadUrl) + if (downloadUrl) { footerElements.push( m("a.download-csv", { href: downloadUrl, download: "" }, "Download") ); + } - let tfoot = m( + const tfoot = m( "tfoot", m("tr", m("td", { colspan: labels.length }, footerElements)) ); @@ -306,14 +313,14 @@ function renderTable( ) ]; - if (window.authorizer.hasAccess("virtualParameters", 3)) + if (window.authorizer.hasAccess("virtualParameters", 3)) { buttons.push( m( "button.primary", { title: "Create new virtual parameter", onclick: () => { - let cb = () => { + const cb = () => { return m( putForm, Object.assign( @@ -333,6 +340,7 @@ function renderTable( "New" ) ); + } return [ m( @@ -355,13 +363,13 @@ const component = { } function onFilterChanged(filter) { - let ops = { filter }; + const ops = { filter }; if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; m.route.set("/virtualParameters", ops); } function onSortChange(sort) { - let ops = { sort }; + const ops = { sort }; if (vnode.attrs.filter) ops.filter = vnode.attrs.filter; m.route.set("/virtualParameters", ops); } @@ -370,18 +378,20 @@ const component = { let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; filter = unpackSmartQuery(filter); - let virtualParameters = store.fetch("virtualParameters", filter, { + const virtualParameters = store.fetch("virtualParameters", filter, { limit: vnode.state.showCount || PAGE_SIZE, sort: sort }); - let count = store.count("virtualParameters", filter); + const count = store.count("virtualParameters", filter); - let selected = new Set(); - if (vnode.state.selected) - for (let virtualParameter of virtualParameters.value) + const selected = new Set(); + if (vnode.state.selected) { + for (const virtualParameter of virtualParameters.value) { if (vnode.state.selected.has(virtualParameter["_id"])) selected.add(virtualParameter["_id"]); + } + } vnode.state.selected = selected; const downloadUrl = getDownloadUrl(vnode.attrs.filter); diff --git a/common/authorizer.js b/common/authorizer.js index 0288142..82bb3b1 100644 --- a/common/authorizer.js +++ b/common/authorizer.js @@ -1,7 +1,5 @@ "use strict"; -const expression = require("./expression"); - const validators = { pass: require("./validators/pass"), test: require("./validators/test") @@ -16,17 +14,18 @@ class Authorizer { hasAccess(resourceType, access) { const cacheKey = `${resourceType}-${access}`; - if (cacheKey in this.hasAccessCache) - return this.hasAccessCache[cacheKey]; + if (cacheKey in this.hasAccessCache) return this.hasAccessCache[cacheKey]; let has = false; - for (let permissionSet of this.permissionSets) { - for (let perm of permissionSet) - if (perm[resourceType]) + for (const permissionSet of this.permissionSets) { + for (const perm of permissionSet) { + if (perm[resourceType]) { if (perm[resourceType].access >= access) { has = true; break; } + } + } } this.hasAccessCache[cacheKey] = has; @@ -39,23 +38,26 @@ class Authorizer { let funcs = {}; - for (let permissionSet of this.permissionSets) - for (let perm of permissionSet) + for (const permissionSet of this.permissionSets) { + for (const perm of permissionSet) { if (perm[resourceType]) { if (perm[resourceType].access >= 3) { - if (perm[resourceType].validate) - for (let [k, v] of Object.entries(perm[resourceType].validate)) + if (perm[resourceType].validate) { + for (const [k, v] of Object.entries(perm[resourceType].validate)) funcs[k] = v; + } } else { funcs = {}; } } + } + } const validator = (mutationType, mutation, any) => { let valid = false; - for (let [k, v] of Object.entries(funcs)) + for (const [k, v] of Object.entries(funcs)) { if (v) { - let res = validators[k]( + const res = validators[k]( resourceType, resource, mutationType, @@ -66,6 +68,7 @@ class Authorizer { if (res > 0) valid = true; else if (res < 0) return false; } + } return valid; }; diff --git a/common/expression-cnf.js b/common/expression-cnf.js index 4ed1a34..51960a4 100644 --- a/common/expression-cnf.js +++ b/common/expression-cnf.js @@ -7,7 +7,7 @@ function or(cnf1, cnf2) { else if (cnf2.length === 0) return cnf1; if (cnf1.length > cnf2.length) { - let c = cnf1; + const c = cnf1; cnf1 = cnf2; cnf2 = c; } @@ -53,10 +53,11 @@ function booleanCnf(exp) { return e; }); - for (let [k, v] of ranges) + for (const [k, v] of ranges) { if (k.endsWith(":number")) ranges.set(k, Array.from(v).sort((a, b) => a - b)); else ranges.set(k, Array.from(v).sort()); + } const expressions = new Map(); const mutuallyExclusive = new Map(); @@ -113,11 +114,12 @@ function booleanCnf(exp) { function getLikeVariables(lhs, pat, esc) { const vars = []; - let pats = likePatterns.get(lhs); + const pats = likePatterns.get(lhs); pat = pats.get(`${esc || ""}:${pat}`); - for (let p of pats.values()) + for (const p of pats.values()) { if (pat === p || likePatternIncludes(pat, p)) vars.push(`${lhs}:like:${JSON.stringify(p)}`); + } return vars.map(key => { let f = expressions.get(key); @@ -185,7 +187,7 @@ function booleanCnf(exp) { let rhs = JSON.stringify(clause[2]); // For consistency if (rhs > lhs) { - let t = rhs; + const t = rhs; rhs = lhs; lhs = t; op = { ">": "<", ">=": "<=", "<": ">", "<=": ">=" }[op] || op; @@ -218,8 +220,8 @@ function booleanCnf(exp) { const cnf = recursive(exp, false); - for (let m of mutuallyExclusive.values()) { - let ar = Array.from(m); + for (const m of mutuallyExclusive.values()) { + const ar = Array.from(m); for (let i = 0; i < ar.length; ++i) for (let j = i + 1; j < ar.length; ++j) cnf.push([0 - ar[i], 0 - ar[j]]); } diff --git a/common/expression-parser.js b/common/expression-parser.js index 5d4c541..f581202 100644 --- a/common/expression-parser.js +++ b/common/expression-parser.js @@ -4,7 +4,7 @@ const parsimmon = require("parsimmon"); // Turn escaped characters into real ones (e.g. "\\n" becomes "\n"). function interpretEscapes(str) { - let escapes = { + const escapes = { b: "\b", f: "\f", n: "\n", @@ -12,8 +12,8 @@ function interpretEscapes(str) { t: "\t" }; return str.replace(/\\(u[0-9a-fA-F]{4}|[^u])/, (_, escape) => { - let type = escape.charAt(0); - let hex = escape.slice(1); + const type = escape.charAt(0); + const hex = escape.slice(1); if (type === "u") return String.fromCharCode(parseInt(hex, 16)); if (escapes.hasOwnProperty(type)) return escapes[type]; @@ -27,7 +27,7 @@ function map(exp, callback) { let clone; for (let i = 1; i < exp.length; ++i) { - let sub = map(exp[i], callback); + const sub = map(exp[i], callback); if (sub !== exp[i]) { clone = clone || exp.slice(); clone[i] = sub; @@ -43,7 +43,7 @@ function binaryLeft(operatorsParser, nextParser) { parsimmon.seq(operatorsParser, nextParser).many(), (first, rest) => rest.reduce((acc, ch) => { - let [op, another] = ch; + const [op, another] = ch; if (Array.isArray(acc) && op === acc[0]) return acc.concat([another]); if (Array.isArray(another) && op === another[0]) return [op, acc].concat(another.slice(1)); @@ -315,8 +315,9 @@ function stringify(exp, level = 0) { .join(", ")})` ); } else if (op === "PARAM") { - if (typeof exp[1] === "string") return wrap(exp[1]); - else if (Array.isArray(exp[1]) && exp[1][0] === "||") + if (typeof exp[1] === "string") { + return wrap(exp[1]); + } else if (Array.isArray(exp[1]) && exp[1][0] === "||") { return wrap( exp[1] .slice(1) @@ -326,24 +327,27 @@ function stringify(exp, level = 0) { }) .join("") ); - else return wrap(`{${stringify(exp[1])}}`); + } else { + return wrap(`{${stringify(exp[1])}}`); + } } else if (op === "IS NULL" || op === "IS NOT NULL") { return wrap(`${stringify(exp[1], opLevels[op])} ${op}`); } else if (op === "LIKE" || op === "NOT LIKE") { - if (exp[3]) + if (exp[3]) { return wrap( `${stringify(exp[1], opLevels[op])} ${op} ${stringify( exp[2], opLevels[op] )} ESCAPE ${stringify(exp[3], opLevels[op])}` ); - else + } else { return wrap( `${stringify(exp[1], opLevels[op])} ${op} ${stringify( exp[2], opLevels[op] )}` ); + } } else if (op in opLevels) { const parts = exp.slice(1).map((e, i) => { return stringify(e, opLevels[exp[0]] + Math.min(i - 1, 0)); @@ -357,10 +361,10 @@ function stringify(exp, level = 0) { } function parseLikePattern(pat, esc) { - let chars = pat.split(""); + const chars = pat.split(""); for (let i = 0; i < chars.length; ++i) { - let c = chars[i]; + const c = chars[i]; if (c === esc) { chars[i] = chars[i + 1] || ""; chars[i + 1] = ""; diff --git a/common/expression.js b/common/expression.js index 376fce3..49dcc62 100644 --- a/common/expression.js +++ b/common/expression.js @@ -8,24 +8,14 @@ const isArray = Array.isArray; const regExpCache = new WeakMap(); -function* permute(arr) { - if (arr.length <= 1) { - for (let i = 0; i < arr[0]; ++i) yield [i]; - return; - } - - let slc = arr.slice(1); - for (let i = 0; i < arr[0]; ++i) - for (let innerArr of permute(slc)) yield [i].concat(innerArr); -} - +const REDUCE_SKIP = {}; function reduce(exp, callback) { let loop = true; while (loop) { loop = false; for (let i = 2; i < exp.length; ++i) { - let res = callback(exp[i - 1], exp[i]); - if (res !== undefined) { + const res = callback(exp[i - 1], exp[i]); + if (res !== REDUCE_SKIP) { loop = true; exp = exp.slice(); exp.splice(i - 1, 2, res); @@ -71,11 +61,13 @@ function evaluate(exp, obj, now, cb) { return reduce(e, (a, b) => { if (!isArray(a)) return a ? b : a; if (!isArray(b)) return b ? a : b; + return REDUCE_SKIP; }); } else if (e[0] === "OR") { return reduce(e, (a, b) => { if (!isArray(a)) return a ? a : b; if (!isArray(b)) return b ? b : a; + return REDUCE_SKIP; }); } else if (e[0] === "NOT") { if (!isArray(e[1])) return !e[1]; @@ -131,6 +123,7 @@ function evaluate(exp, obj, now, cb) { if (a == null || b == null) return null; return a * b; } + return REDUCE_SKIP; }); } else if (e[0] === "/") { return reduce(e, (a, b) => { @@ -138,6 +131,7 @@ function evaluate(exp, obj, now, cb) { if (a == null || b == null) return null; return a / b; } + return REDUCE_SKIP; }); } else if (e[0] === "+") { return reduce(e, (a, b) => { @@ -145,6 +139,7 @@ function evaluate(exp, obj, now, cb) { if (a == null || b == null) return null; return a + b; } + return REDUCE_SKIP; }); } else if (e[0] === "-") { return reduce(e, (a, b) => { @@ -152,6 +147,7 @@ function evaluate(exp, obj, now, cb) { if (a == null || b == null) return null; return a - b; } + return REDUCE_SKIP; }); } else if (e[0] === "||") { return reduce(e, (a, b) => { @@ -159,6 +155,7 @@ function evaluate(exp, obj, now, cb) { if (a == null || b == null) return null; return `${a}${b}`; } + return REDUCE_SKIP; }); } return e; @@ -238,7 +235,7 @@ function likePatternToRegExp(pat, esc = "", flags = "") { } function extractParams(exp) { - let params = []; + const params = []; expressionParser.map(exp, e => { if (isArray(e) && e[0] === "PARAM") params.push(e[1]); return e; diff --git a/common/sat-solver.js b/common/sat-solver.js index 815c400..4800345 100644 --- a/common/sat-solver.js +++ b/common/sat-solver.js @@ -33,11 +33,12 @@ function satisfiableAt(clauses, varVals, varnr, val) { // find next unassigned var to split let nextVar = 0; - for (let i = 1; i < varVals.length; i++) + for (let i = 1; i < varVals.length; i++) { if (varVals[i] === 0) { nextVar = i; break; } + } if (nextVar === 0) throw new Error("Error in satisfiableAt"); @@ -105,7 +106,7 @@ function unitPropagate(clauses, varVals) { let unassignedLit = 0; // 0 means none found for this clause for (let j = 0; j < clause.length; j++) { - let lit = clause[j]; + const lit = clause[j]; let nr, polarity; if (lit < 0) { nr = 0 - lit; @@ -149,7 +150,7 @@ function unitPropagate(clauses, varVals) { // some units were derived: iterate the main loop for (let j = 0; j < derivedQueue.length; j++) { - let lit = derivedQueue[j]; + const lit = derivedQueue[j]; let nr, polarity; if (lit < 0) { nr = 0 - lit; diff --git a/server/api-functions.js b/server/api-functions.js index 3f46c23..b0acd9d 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -6,7 +6,6 @@ const url = require("url"); const config = require("./config"); const db = require("./db"); -const expression = require("../common/expression"); const mongodbFunctions = require("./mongodb-functions"); function filterToMongoQuery(filter, negate = false, res = {}) { @@ -25,7 +24,7 @@ function filterToMongoQuery(filter, negate = false, res = {}) { filterToMongoQuery(filter[1], !negate, res); } else if (op === "=") { const param = filter[1][1]; - let p = (res[param] = res[param] || {}); + const p = (res[param] = res[param] || {}); if (negate) p["$ne"] = filter[2]; else p["$eq"] = filter[2]; } else if (op === "<>") { @@ -69,15 +68,15 @@ function filterToMongoQuery(filter, negate = false, res = {}) { function deleteResource(resource, id) { return new Promise((resolve, reject) => { - let resourceUrl = + const resourceUrl = resource === "virtualParameters" ? "virtual_parameters" : resource; - let options = url.parse( + const options = url.parse( `${config.server.nbi}${resourceUrl}/${encodeURIComponent(id)}` ); options.method = "DELETE"; - let _http = options.protocol === "https:" ? https : http; + const _http = options.protocol === "https:" ? https : http; _http .request(options, res => { @@ -91,13 +90,13 @@ function deleteResource(resource, id) { } function postTask(task, connectionRequest, callback) { - let options = url.parse( + const options = url.parse( `${config.server.nbi}devices/${encodeURIComponent(task.device)}/tasks${ connectionRequest ? "?connection_request" : "" }` ); options.method = "POST"; - let _http = options.protocol === "https:" ? https : http; + const _http = options.protocol === "https:" ? https : http; _http .request(options, res => { @@ -114,14 +113,14 @@ function postTask(task, connectionRequest, callback) { }); res.on("end", () => { - let buf = new Buffer(bytes); + const buf = Buffer.allocUnsafe(bytes); let o = 0; - for (let c of chunks) { + for (const c of chunks) { c.copy(buf, o, 0, c.length); o += c.length; } try { - let t = JSON.parse(buf); + const t = JSON.parse(buf); let connectionRequestStatus; if (connectionRequest) { connectionRequestStatus = "OK"; @@ -144,16 +143,16 @@ function postTask(task, connectionRequest, callback) { function postTasks(deviceId, tasks) { return new Promise((resolve, reject) => { let connectionRequestStatus; - let promises = []; - for (let [idx, task] of tasks.entries()) { + const promises = []; + for (const [idx, task] of tasks.entries()) { task.device = deviceId; promises.push( new Promise((res, rej) => { - let conReq = idx === tasks.length - 1; + const conReq = idx === tasks.length - 1; delete task._id; task.expiry = 5; postTask(task, conReq, (err, t, crs) => { - if (err) return rej(err); + if (err) return void rej(err); if (conReq) connectionRequestStatus = crs; res({ _id: t._id, status: "pending" }); }); @@ -163,14 +162,15 @@ function postTasks(deviceId, tasks) { Promise.all(promises) .then(statuses => { - if (connectionRequestStatus !== "OK") - return resolve({ + if (connectionRequestStatus !== "OK") { + return void resolve({ connectionRequest: connectionRequestStatus, tasks: statuses }); + } - let promises2 = []; - for (let s of statuses) { + const promises2 = []; + for (const s of statuses) { promises2.push(db.query("tasks", ["=", ["PARAM", "_id"], s._id])); promises2.push( db.query("faults", [ @@ -183,7 +183,7 @@ function postTasks(deviceId, tasks) { Promise.all(promises2) .then(res => { - for (let [i, r] of statuses.entries()) { + for (const [i, r] of statuses.entries()) { if (res[i * 2].length === 0) { r.status = "done"; } else if (res[i * 2 + 1].length === 1) { @@ -216,7 +216,7 @@ function updateTags(deviceId, tags) { ); if (onOff) options.method = "POST"; else options.method = "DELETE"; - let _http = options.protocol === "https:" ? https : http; + const _http = options.protocol === "https:" ? https : http; _http .request(options, res => { @@ -237,17 +237,19 @@ function ping(host) { `${config.server.nbi}ping/${encodeURIComponent(host)}` ); - let _http = options.protocol === "https:" ? https : http; + const _http = options.protocol === "https:" ? https : http; _http .request(options, res => { if (res.statusCode === 404) { res.resume(); - return resolve({}); + return void resolve({}); } if (res.statusCode !== 200) { res.resume(); - return reject(new Error(`Unexpected status code ${res.statusCode}`)); + return void reject( + new Error(`Unexpected status code ${res.statusCode}`) + ); } const chunks = []; @@ -258,18 +260,19 @@ function ping(host) { }); res.on("end", () => { - let buf = new Buffer(bytes); + const buf = Buffer.allocUnsafe(bytes); let o = 0; - for (let c of chunks) { + for (const c of chunks) { c.copy(buf, o, 0, c.length); o += c.length; } - let m = buf + const m = buf .toString() .match( /(\d) packets transmitted, (\d) received, ([\d.%]+) packet loss[^]*([\d.]+)\/([\d.]+)\/([\d.]+)\/([\d.]+)/ ); - if (!m) return reject(new Error("Could not parse ping response")); + if (!m) + return void reject(new Error("Could not parse ping response")); resolve({ packetsTransmitted: +m[1], @@ -288,15 +291,15 @@ function ping(host) { function putResource(resource, id, data) { return new Promise((resolve, reject) => { - let resourceUrl = + const resourceUrl = resource === "virtualParameters" ? "virtual_parameters" : resource; - let options = url.parse( + const options = url.parse( `${config.server.nbi}${resourceUrl}/${encodeURIComponent(id)}` ); options.method = "PUT"; - let _http = options.protocol === "https:" ? https : http; + const _http = options.protocol === "https:" ? https : http; if (resource === "presets") data = mongodbFunctions.preProcessPreset(data); @@ -306,7 +309,7 @@ function putResource(resource, id, data) { if (resource === "virtualParameters") data = mongodbFunctions.preProcessVirtualParameters(data); - let body = typeof data !== "string" ? JSON.stringify(data) : data; + const body = typeof data !== "string" ? JSON.stringify(data) : data; _http .request(options, res => { const chunks = []; @@ -317,14 +320,16 @@ function putResource(resource, id, data) { }); res.on("end", () => { - if (res.statusCode === 200) return resolve(); - else if (res.statusCode !== 400) - return reject( + if (res.statusCode === 200) { + return void resolve(); + } else if (res.statusCode !== 400) { + return void reject( new Error(`Unexpected status code ${res.statusCode}`) ); - let buf = new Buffer(bytes); + } + const buf = Buffer.allocUnsafe(bytes); let o = 0; - for (let c of chunks) { + for (const c of chunks) { c.copy(buf, o, 0, c.length); o += c.length; } @@ -337,17 +342,22 @@ function putResource(resource, id, data) { function putFile(id, metadata, fileStream) { return new Promise((resolve, reject) => { - let options = url.parse( + const options = url.parse( `${config.server.nbi}files/${encodeURIComponent(id)}` ); options.method = "PUT"; options.headers = Object.assign({}, metadata); - let _http = options.protocol === "https:" ? https : http; - let req = _http.request(options, res => { + const _http = options.protocol === "https:" ? https : http; + const req = _http.request(options, res => { res.resume(); - if (res.statusCode === 201) return resolve(); - else return reject(new Error(`Unexpected status code ${res.statusCode}`)); + if (res.statusCode === 201) { + return void resolve(); + } else { + return void reject( + new Error(`Unexpected status code ${res.statusCode}`) + ); + } }); fileStream.pipe(req); }); diff --git a/server/api.js b/server/api.js index dd1cd3c..f76ba65 100644 --- a/server/api.js +++ b/server/api.js @@ -2,7 +2,6 @@ const zlib = require("zlib"); const Router = require("koa-router"); -const config = require("./config"); const db = require("./db"); const apiFunctions = require("./api-functions"); const expression = require("../common/expression"); @@ -38,7 +37,7 @@ const resources = { tasks: 0 }; -for (let [resource, flags] of Object.entries(resources)) { +for (const [resource, flags] of Object.entries(resources)) { router.head(`/${resource}`, async (ctx, next) => { let filter = true; if (ctx.request.query.filter) @@ -52,17 +51,18 @@ for (let [resource, flags] of Object.entries(resources)) { if (!ctx.state.authorizer.hasAccess(resource, 1)) { logUnauthorizedWarning(log); - return next(); + return void next(); } // Exclude temporary tasks and faults - if (resource === "tasks" || resource === "faults") + if (resource === "tasks" || resource === "faults") { filter = expression.and(filter, [ "NOT", ["<", ["PARAM", "expiry"], Date.now() + 60000] ]); + } - let count = await db.count(resource, filter); + const count = await db.count(resource, filter); ctx.set("X-Total-Count", count); ctx.body = ""; @@ -72,7 +72,7 @@ for (let [resource, flags] of Object.entries(resources)) { }); router.get(`/${resource}`, async (ctx, next) => { - let options = {}; + const options = {}; let filter = true; if (ctx.request.query.filter) filter = expression.parse(ctx.request.query.filter); @@ -80,10 +80,11 @@ for (let [resource, flags] of Object.entries(resources)) { if (ctx.request.query.skip) options.skip = +ctx.request.query.skip; if (ctx.request.query.sort) options.sort = JSON.parse(ctx.request.query.sort); - if (ctx.request.query.projection) + if (ctx.request.query.projection) { options.projection = ctx.request.query.projection .split(",") .reduce((obj, k) => Object.assign(obj, { [k]: 1 }), {}); + } const log = { message: `Query ${resource}`, @@ -97,15 +98,16 @@ for (let [resource, flags] of Object.entries(resources)) { if (!ctx.state.authorizer.hasAccess(resource, 2)) { logUnauthorizedWarning(log); - return next(); + return void next(); } // Exclude temporary tasks and faults - if (resource === "tasks" || resource === "faults") + if (resource === "tasks" || resource === "faults") { filter = expression.and(filter, [ "NOT", ["<", ["PARAM", "expiry"], Date.now() + 60000] ]); + } let stream; switch (ctx.acceptsEncodings("gzip", "deflate", "identity")) { @@ -139,7 +141,7 @@ for (let [resource, flags] of Object.entries(resources)) { // CSV download router.get(`/${resource}.csv`, async (ctx, next) => { - let options = { projection: {} }; + const options = { projection: {} }; let filter = true; if (ctx.request.query.filter) filter = expression.parse(ctx.request.query.filter); @@ -159,28 +161,25 @@ for (let [resource, flags] of Object.entries(resources)) { if (!ctx.state.authorizer.hasAccess(resource, 2)) { logUnauthorizedWarning(log); - return next(); + return void next(); } const columns = JSON.parse(ctx.request.query.columns); const now = Date.now(); - for (let [k, v] of Object.entries(columns)) { - const e = expression.evaluate( - expression.parse(v), - null, - now - ); + for (const [k, v] of Object.entries(columns)) { + const e = expression.evaluate(expression.parse(v), null, now); columns[k] = e; - for (let p of expression.extractParams(e)) options.projection[p] = 1; + for (const p of expression.extractParams(e)) options.projection[p] = 1; } // Exclude temporary tasks and faults - if (resource === "tasks" || resource === "faults") + if (resource === "tasks" || resource === "faults") { filter = expression.and(filter, [ "NOT", ["<", ["PARAM", "expiry"], Date.now() + 60000] ]); + } let stream; switch (ctx.acceptsEncodings("gzip", "deflate", "identity")) { @@ -209,24 +208,26 @@ for (let [resource, flags] of Object.entries(resources)) { Object.keys(columns).map(k => `"${k.replace(/"/, '""')}"`) + "\n" ); await db.query(resource, filter, options, obj => { - let arr = Object.values(columns).map(exp => { - let v = expression.evaluate(exp, obj, null, e => { - if (Array.isArray(e)) + const arr = Object.values(columns).map(exp => { + const v = expression.evaluate(exp, obj, null, e => { + if (Array.isArray(e)) { if (e[0] === "PARAM") { if (resource === "devices") { if (e[1] === "Tags") { - let tags = []; - for (let p in obj) + const tags = []; + for (const p in obj) if (p.startsWith("Tags.")) tags.push(p.slice(5)); return tags.join(", "); } } } else if (e[0] === "FUNC") { - if (e[1] === "DATE_STRING") + if (e[1] === "DATE_STRING") { if (e[2] && !Array.isArray(e[2])) return new Date(e[2]).toISOString(); + } } + } return e; }); @@ -249,15 +250,15 @@ for (let [resource, flags] of Object.entries(resources)) { filter: `${RESOURCE_IDS[resource]} = "${ctx.params.id}"` }; - let filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; + const filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; if (!ctx.state.authorizer.hasAccess(resource, 2)) { logUnauthorizedWarning(log); - return next(); + return void next(); } - let res = await db.query(resource, filter); + const res = await db.query(resource, filter); - if (!res.length) return next(); + if (!res.length) return void next(); logger.accessInfo(log); ctx.body = ""; @@ -270,22 +271,22 @@ for (let [resource, flags] of Object.entries(resources)) { filter: `${RESOURCE_IDS[resource]} = "${ctx.params.id}"` }; - let filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; + const filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; if (!ctx.state.authorizer.hasAccess(resource, 2)) { logUnauthorizedWarning(log); - return next(); + return void next(); } - let res = await db.query(resource, filter); + const res = await db.query(resource, filter); - if (!res.length) return next(); + if (!res.length) return void next(); logger.accessInfo(log); ctx.body = res[0]; }); // TODO add PUT, PATCH routes - if (flags & RESOURCE_DELETE) + if (flags & RESOURCE_DELETE) { router.delete(`/${resource}/:id`, async (ctx, next) => { const log = { message: `Delete ${resource}`, @@ -294,18 +295,18 @@ for (let [resource, flags] of Object.entries(resources)) { }; const authorizer = ctx.state.authorizer; - let filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; + const filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; if (!authorizer.hasAccess(resource, 2)) { logUnauthorizedWarning(log); - return next(); + return void next(); } - let res = await db.query(resource, filter); - if (!res.length) return next(); + const res = await db.query(resource, filter); + if (!res.length) return void next(); const validate = authorizer.getValidator(resource, res[0]); if (!validate("delete")) { logUnauthorizedWarning(log); - return (ctx.status = 403); + return void (ctx.status = 403); } await apiFunctions.deleteResource(resource, ctx.params.id); @@ -314,8 +315,9 @@ for (let [resource, flags] of Object.entries(resources)) { ctx.body = ""; }); + } - if (flags & RESOURCE_PUT) + if (flags & RESOURCE_PUT) { router.put(`/${resource}/:id`, async (ctx, next) => { const id = ctx.params.id; @@ -328,30 +330,31 @@ for (let [resource, flags] of Object.entries(resources)) { const authorizer = ctx.state.authorizer; if (!authorizer.hasAccess(resource, 3)) { logUnauthorizedWarning(log); - return next(); + return void next(); } - let obj = ctx.request.body; + const obj = ctx.request.body; const validate = authorizer.getValidator(resource, obj); if (!validate("put")) { logUnauthorizedWarning(log); - return (ctx.status = 403); + return void (ctx.status = 403); } - let err = await apiFunctions.putResource(resource, id, obj); + const err = await apiFunctions.putResource(resource, id, obj); if (err) { log.message += " failed"; logger.accessWarn(log); ctx.body = err; - return (ctx.status = 400); + return void (ctx.status = 400); } logger.accessInfo(log); ctx.body = ""; }); + } } router.put("/files/:id", async (ctx, next) => { @@ -367,10 +370,10 @@ router.put("/files/:id", async (ctx, next) => { const authorizer = ctx.state.authorizer; if (!authorizer.hasAccess(resource, 3)) { logUnauthorizedWarning(log); - return next(); + return void next(); } - let metadata = { + const metadata = { fileType: ctx.request.headers["metadata.filetype"] || "", oui: ctx.request.headers["metadata.oui"] || "", productClass: ctx.request.headers["metadata.productclass"] || "", @@ -380,16 +383,16 @@ router.put("/files/:id", async (ctx, next) => { const validate = authorizer.getValidator(resource, metadata); if (!validate("put")) { logUnauthorizedWarning(log); - return (ctx.status = 403); + return void (ctx.status = 403); } - let err = await apiFunctions.putFile(id, metadata, ctx.req); + const err = await apiFunctions.putFile(id, metadata, ctx.req); if (err) { log.message += " failed"; logger.accessWarn(log); ctx.body = err; - return (ctx.status = 400); + return void (ctx.status = 400); } log.metadata = metadata; @@ -406,22 +409,23 @@ router.post("/devices/:id/tasks", async (ctx, next) => { }; const authorizer = ctx.state.authorizer; - let filter = ["=", ["PARAM", "DeviceID.ID"], ctx.params.id]; + const filter = ["=", ["PARAM", "DeviceID.ID"], ctx.params.id]; if (!authorizer.hasAccess("devices", 2)) { logUnauthorizedWarning(log); - return next(); + return void next(); } - let devices = await db.query("devices", filter); - if (!devices.length) return next(); + const devices = await db.query("devices", filter); + if (!devices.length) return void next(); const validate = authorizer.getValidator("devices", devices[0]); - for (let t of ctx.request.body) + for (const t of ctx.request.body) { if (!validate("task", t)) { logUnauthorizedWarning(log); - return (ctx.status = 403); + return void (ctx.status = 403); } + } - let res = await apiFunctions.postTasks(ctx.params.id, ctx.request.body); + const res = await apiFunctions.postTasks(ctx.params.id, ctx.request.body); log.tasks = res.tasks.map(t => t._id).join(","); @@ -440,18 +444,18 @@ router.post("/devices/:id/tags", async (ctx, next) => { }; const authorizer = ctx.state.authorizer; - let filter = ["=", ["PARAM", "DeviceID.ID"], ctx.params.id]; + const filter = ["=", ["PARAM", "DeviceID.ID"], ctx.params.id]; if (!authorizer.hasAccess("devices", 2)) { logUnauthorizedWarning(log); - return next(); + return void next(); } - let res = await db.query("devices", filter); - if (!res.length) return next(); + const res = await db.query("devices", filter); + if (!res.length) return void next(); const validate = authorizer.getValidator("devices", res[0]); if (!validate("tags", ctx.request.body)) { logUnauthorizedWarning(log); - return (ctx.status = 403); + return void (ctx.status = 403); } await apiFunctions.updateTags(ctx.params.id, ctx.request.body); diff --git a/server/config.js b/server/config.js index 4dee41d..3b7ee4c 100644 --- a/server/config.js +++ b/server/config.js @@ -3,7 +3,7 @@ const fs = require("fs"); const configFile = JSON.parse(fs.readFileSync("./config.json")); -let config; +const config = loadConfig(configFile); function getClientConfig() { return Object.assign({}, config, { @@ -33,6 +33,4 @@ function loadConfig(conf, root = {}) { return root; } -config = loadConfig(configFile); - module.exports = Object.assign({}, config, { getClientConfig }); diff --git a/server/db.js b/server/db.js index 153160b..c1180f1 100644 --- a/server/db.js +++ b/server/db.js @@ -22,18 +22,19 @@ const RESOURCE_COLLECTION = { }; function getClient() { - if (!_clientPromise) + if (!_clientPromise) { _clientPromise = new Promise((resolve, reject) => { const CONNECTION_URL = config.server.mongodbConnectionUrl; mongodb.MongoClient.connect( CONNECTION_URL, { useNewUrlParser: true }, (err, client) => { - if (err) return reject(err); + if (err) return void reject(err); resolve(client); } ); }); + } return _clientPromise; } @@ -61,12 +62,13 @@ function query(resource, filter, options, callback) { .db(RESOURCE_DB[resource]) .collection(RESOURCE_COLLECTION[resource] || resource); const cursor = collection.find(q); - if (options.projection) + if (options.projection) { cursor.project( resource === "devices" ? mongodbFunctions.processDeviceProjection(options.projection) : options.projection ); + } if (options.skip) cursor.skip(options.skip); if (options.limit) cursor.limit(options.limit); @@ -83,7 +85,7 @@ function query(resource, filter, options, callback) { cursor.sort(s); } - if (!callback) + if (!callback) { cursor.toArray((err, docs) => { if (err) return reject(err); if (resource === "devices") @@ -98,7 +100,7 @@ function query(resource, filter, options, callback) { docs = docs.map(d => mongodbFunctions.flattenFile(d)); return resolve(docs); }); - else + } else { cursor.forEach( doc => { if (resource === "devices") @@ -118,6 +120,7 @@ function query(resource, filter, options, callback) { else resolve(); } ); + } }); }); } @@ -152,10 +155,11 @@ function count(resource, filter) { } function disconnect() { - if (_clientPromise) + if (_clientPromise) { _clientPromise.then(client => { client.close(); }); + } } exports.query = query; diff --git a/server/logger.js b/server/logger.js index aab3345..80e314b 100644 --- a/server/logger.js +++ b/server/logger.js @@ -153,9 +153,10 @@ function formatSimple(details, systemd) { flatten(details); let remote = ""; - if (details.remoteAddress) + if (details.remoteAddress) { if (details.user) remote = `${details.user}@${details.remoteAddress}: `; else remote = `${details.remoteAddress}: `; + } const keys = Object.keys(details); const skip = { @@ -168,8 +169,8 @@ function formatSimple(details, systemd) { let meta = ""; - let kv = []; - for (let k of keys) + const kv = []; + for (const k of keys) if (!skip[k]) kv.push(`${k}=${JSON.stringify(details[k])}`); if (kv.length) meta = `; ${kv.join(" ")}`; diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index 80a626d..e002720 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -44,7 +44,7 @@ function processDeviceFilter(filter) { }); if (typeof e[2] === "number") { - let alt = e.slice(); + const alt = e.slice(); alt[2] = new Date(e[2]); e = ["OR", e, alt]; } @@ -58,7 +58,7 @@ function processTasksFilter(filter) { return expressionParser.map(filter, exp => { if (!Array.isArray(exp)) return exp; if (["=", "<>", ">", ">=", "<", "<="].includes(exp[0])) { - let e = exp.slice(); + const e = exp.slice(); if (e[1][0] === "PARAM" && e[1][1] === "_id") e[2] = new mongodb.ObjectID(e[2]); else if (e[1][0] === "PARAM" && e[1][1] === "timestamp") @@ -75,7 +75,7 @@ function processFaultsFilter(filter) { return expressionParser.map(filter, exp => { if (!Array.isArray(exp)) return exp; if (["=", "<>", ">", ">=", "<", "<="].includes(exp[0])) { - let e = exp.slice(); + const e = exp.slice(); if (e[1][0] === "PARAM" && e[1][1] === "timestamp") e[2] = new Date(e[2]); else if (e[1][0] === "PARAM" && e[1][1] === "expiry") e[2] = new Date(e[2]); @@ -101,7 +101,7 @@ function filterToMongoQuery(filter, negate = false, res = {}) { filterToMongoQuery(filter[1], !negate, res); } else if (op === "=") { const param = filter[1][1]; - let p = (res[param] = res[param] || {}); + const p = (res[param] = res[param] || {}); if (negate) p["$ne"] = filter[2]; else p["$eq"] = filter[2]; } else if (op === "<>") { @@ -141,17 +141,19 @@ function filterToMongoQuery(filter, negate = false, res = {}) { let param; let flags; if (filter[1][0] === "FUNC" && filter[1][1] === "UPPER") { - if (filter[2] !== filter[2].toUpperCase()) + if (filter[2] !== filter[2].toUpperCase()) { throw new Error( "Cannot compare UPPER() against non upper case pattern" ); + } param = filter[1][2][1]; flags = "i"; } else if (filter[1][0] === "FUNC" && filter[1][1] === "LOWER") { - if (filter[2] !== filter[2].toLowerCase()) + if (filter[2] !== filter[2].toLowerCase()) { throw new Error( "Cannot compare LOWER() against non lower case pattern" ); + } param = filter[1][2][1]; flags = "i"; } else { @@ -171,7 +173,7 @@ function filterToMongoQuery(filter, negate = false, res = {}) { function processDeviceProjection(projection) { if (!projection) return projection; const p = {}; - for (const [k, v] of Object.entries(projection)) + for (const [k, v] of Object.entries(projection)) { if (k === "DeviceID.ID") { p["_id"] = 1; } else if (k.startsWith("DeviceID")) { @@ -189,6 +191,7 @@ function processDeviceProjection(projection) { } else { p[k] = v; } + } return p; } @@ -196,18 +199,19 @@ function processDeviceProjection(projection) { function processDeviceSort(sort) { if (!sort) return sort; const s = {}; - for (const [k, v] of Object.entries(sort)) + for (const [k, v] of Object.entries(sort)) { if (k === "DeviceID.ID") s["_id"] = v; else if (k === "Events.Inform") s["_lastInform"] = v; else s[k] = v; + } return s; } function flattenDevice(device) { function recursive(input, root, output, timestamp) { - for (let [name, tree] of Object.entries(input)) { - if (!root) + for (const [name, tree] of Object.entries(input)) { + if (!root) { if (name === "_lastInform") { output["Events.Inform"] = { value: [Date.parse(tree), "xsd:dateTime"], @@ -294,7 +298,7 @@ function flattenDevice(device) { objectTimestamp: timestamp }; - for (let t of tree) + for (const t of tree) { output[`Tags.${t}`] = { value: [true, "xsd:boolean"], valueTimestamp: timestamp, @@ -303,7 +307,9 @@ function flattenDevice(device) { object: false, objectTimestamp: timestamp }; + } } + } if (name.startsWith("_")) continue; @@ -329,7 +335,7 @@ function flattenDevice(device) { attrs["writableTimestamp"] = childrenTimestamp; } - let r = root ? `${root}.${name}` : name; + const r = root ? `${root}.${name}` : name; output[r] = attrs; if (attrs["object"] || tree["object"] == null) @@ -348,14 +354,14 @@ function flattenFault(fault) { if (f.timestamp) f.timestamp = +f.timestamp; if (f.expiry) f.expiry = +f.expiry; if (f.detail) { - for (let [k, v] of Object.entries(f.detail)) fault[`detail.${k}`] = v; + for (const [k, v] of Object.entries(f.detail)) fault[`detail.${k}`] = v; delete f.detail; } return f; } function flattenTask(task) { - let t = Object.assign({}, task); + const t = Object.assign({}, task); t._id = "" + t._id; if (t.timestamp) t.timestamp = +t.timestamp; if (t.expiry) t.expiry = +t.expiry; @@ -363,20 +369,21 @@ function flattenTask(task) { } function mongoQueryToFilter(query) { - let expressions = []; + const expressions = []; function recursive(_query) { - for (let [k, v] of Object.entries(_query)) + for (const [k, v] of Object.entries(_query)) { if (k[0] == "$") { - if (k == "$and") for (let vv of Object.values(v)) recursive(vv); + if (k == "$and") for (const vv of Object.values(v)) recursive(vv); else throw new Error(`Operator ${k} not supported`); } else if (k == "_tags") { if (typeof v === "object") { if (Array.isArray(v)) throw new Error(`Invalid type`); - for (let [kk, vv] of Object.entries(v)) + for (const [kk, vv] of Object.entries(v)) { if (kk == "$ne") expressions.push(["IS NULL", ["PARAM", `Tags.${vv}`]]); else throw new Error(`Operator ${kk} not supported`); + } } else { expressions.push(["IS NOT NULL", ["PARAM", `Tags.${v}`]]); } @@ -388,7 +395,7 @@ function mongoQueryToFilter(query) { } else if (typeof v === "object") { if (Array.isArray(v)) throw new Error(`Invalid type`); - for (let [kk, vv] of Object.entries(v)) { + for (const [kk, vv] of Object.entries(v)) { let op; switch (kk) { case "$eq": @@ -417,6 +424,7 @@ function mongoQueryToFilter(query) { } else { expressions.push(["=", ["PARAM", k], v]); } + } } recursive(query); @@ -428,7 +436,7 @@ function mongoQueryToFilter(query) { } function flattenPreset(preset) { - let p = Object.assign({}, preset); + const p = Object.assign({}, preset); if (p.precondition) { p.precondition = mongoQueryToFilter(JSON.parse(p.precondition)); p.precondition = p.precondition.length @@ -437,12 +445,12 @@ function flattenPreset(preset) { } if (p.events) { - let e = []; - for (let [k, v] of Object.entries(p.events)) e.push(v ? k : `-${k}`); + const e = []; + for (const [k, v] of Object.entries(p.events)) e.push(v ? k : `-${k}`); p.events = e.join(", "); } - let provision = p.configurations[0]; + const provision = p.configurations[0]; if ( p.configurations.length != 1 || provision.type != "provision" || @@ -459,7 +467,7 @@ function flattenPreset(preset) { } function flattenFile(file) { - let f = {}; + const f = {}; f["_id"] = file["_id"]; if (file.metadata) { f["metadata.fileType"] = file.metadata.fileType || ""; @@ -471,14 +479,14 @@ function flattenFile(file) { } function preProcessPreset(data) { - let preset = Object.assign({}, data); + const preset = Object.assign({}, data); preset.precondition = preset.precondition ? filterToMongoQuery(expression.parse(preset.precondition)) : {}; preset.precondition = JSON.stringify(preset.precondition); - let events = {}; - if (preset.events) + const events = {}; + if (preset.events) { for (let e of preset.events.split(",")) { let v = true; e = e.trim(); @@ -488,12 +496,13 @@ function preProcessPreset(data) { } if (e) events[e] = v; } + } preset.events = events; if (!preset.provision) throw new Error("Invalid preset provision"); - let configuration = { + const configuration = { type: "provision", name: preset.provision }; diff --git a/server/server.js b/server/server.js index 012230c..6e5b808 100644 --- a/server/server.js +++ b/server/server.js @@ -42,11 +42,10 @@ function getPermissionSets(roles) { "virtualParameters", "files" ].includes(k) - ) + ) { if (v.filter == null || v.filter === "") v.filter = true; - else { - v.filter = expression.parse(v.filter); - } + else v.filter = expression.parse(v.filter); + } } return p; }); @@ -76,11 +75,13 @@ koa.use( ); koa.use(async (ctx, next) => { - if (ctx.state.user && ctx.state.user.roles) + if (ctx.state.user && ctx.state.user.roles) { ctx.state.authorizer = new Authorizer( getPermissionSets(ctx.state.user.roles) ); - else ctx.state.authorizer = new Authorizer([]); + } else { + ctx.state.authorizer = new Authorizer([]); + } return next(); }); @@ -89,7 +90,7 @@ router.post("/login", async ctx => { const username = ctx.request.body.username; const password = ctx.request.body.password; - let log = { + const log = { message: "Log in", context: ctx, username: username @@ -110,10 +111,9 @@ router.post("/login", async ctx => { logger.accessWarn(log); } - let roles; - roles = await authSimple(username, password); + const roles = await authSimple(username, password); - if (roles) return success(roles, "simple"); + if (roles) return void success(roles, "simple"); failure(); }); @@ -172,7 +172,7 @@ koa.use( koa.use(router.routes()); koa.use(koaStatic("./public")); -let server = http +const server = http .createServer(koa.callback()) .listen(config.server.port, config.server.interface); From 28a6ec950e7e41b3de34eab4ce5404b17942db47 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Tue, 2 Oct 2018 12:52:00 +0300 Subject: [PATCH 072/106] Show only first line of script in index page --- client/provisions-page.js | 12 +++++++++--- client/virtual-parameters-page.js | 8 +++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/client/provisions-page.js b/client/provisions-page.js index 0f61c5c..0b1f8a5 100644 --- a/client/provisions-page.js +++ b/client/provisions-page.js @@ -181,9 +181,15 @@ function renderTable( const tds = [m("td", checkbox)]; for (const attr of attributes) { - if (attr.id == "script") - tds.push(m("td", { title: provision[attr.id] }, provision[attr.id])); - else tds.push(m("td", provision[attr.id])); + if (attr.id == "script") { + const firstLines = provision[attr.id].split("\n", 11); + if (firstLines.length > 10) firstLines[10] = ["\ufe19"]; + tds.push( + m("td", { title: firstLines.join("\n") }, firstLines[0] || "") + ); + } else { + tds.push(m("td", provision[attr.id])); + } } tds.push( diff --git a/client/virtual-parameters-page.js b/client/virtual-parameters-page.js index 1f32be5..0188093 100644 --- a/client/virtual-parameters-page.js +++ b/client/virtual-parameters-page.js @@ -183,12 +183,10 @@ function renderTable( const tds = [m("td", checkbox)]; for (const attr of attributes) { if (attr.id == "script") { + const firstLines = virtualParameter[attr.id].split("\n", 11); + if (firstLines.length > 10) firstLines[10] = ["\ufe19"]; tds.push( - m( - "td", - { title: virtualParameter[attr.id] }, - virtualParameter[attr.id] - ) + m("td", { title: firstLines.join("\n") }, firstLines[0] || "") ); } else { tds.push(m("td", virtualParameter[attr.id])); From bb416d25b915c147473d31cad4f926cf3ae94d8a Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Mon, 1 Oct 2018 21:25:43 +0300 Subject: [PATCH 073/106] Add admin page --- client/admin-menu.js | 54 +++++++++++++++++++++++++++++++ client/app.js | 29 ++++++++++++++--- client/css/app.css | 33 +++++++++++++++++++ client/files-page.js | 4 +-- client/layout.js | 17 ++++++++-- client/menu.js | 50 +++++++--------------------- client/presets-page.js | 4 +-- client/provisions-page.js | 4 +-- client/virtual-parameters-page.js | 4 +-- 9 files changed, 147 insertions(+), 52 deletions(-) create mode 100644 client/admin-menu.js diff --git a/client/admin-menu.js b/client/admin-menu.js new file mode 100644 index 0000000..f11a546 --- /dev/null +++ b/client/admin-menu.js @@ -0,0 +1,54 @@ +"use strict"; + +import m from "mithril"; + +const menu = { + view: vnode => { + const active = { [vnode.attrs.page]: "active" }; + const tabs = []; + + if (window.authorizer.hasAccess("presets", 1)) { + tabs.push( + m( + "li", + { class: active["presets"] }, + m("a", { href: "#!/admin/presets" }, "Presets") + ) + ); + } + + if (window.authorizer.hasAccess("provisions", 1)) { + tabs.push( + m( + "li", + { class: active["provisions"] }, + m("a", { href: "#!/admin/provisions" }, "Provisions") + ) + ); + } + + if (window.authorizer.hasAccess("virtualParameters", 1)) { + tabs.push( + m( + "li", + { class: active["virtualParameters"] }, + m("a", { href: "#!/admin/virtualParameters" }, "Virtual Parameters") + ) + ); + } + + if (window.authorizer.hasAccess("files", 1)) { + tabs.push( + m( + "li", + { class: active["files"] }, + m("a", { href: "#!/admin/files" }, "Files") + ) + ); + } + + return m("nav#side-menu", m("ul", tabs)); + } +}; + +export default menu; diff --git a/client/app.js b/client/app.js index f6ca4b3..84ed8c4 100644 --- a/client/app.js +++ b/client/app.js @@ -19,6 +19,8 @@ import * as notifications from "./notifications"; window.authorizer = new Authorizer(window.permissionSets); +const adminPages = ["presets", "provisions", "virtualParameters", "files"]; + let state; let lastRenderTimestamp = 0; @@ -77,14 +79,33 @@ function pagify(pageName, page) { return component; } +function redirectAdminPage() { + const component = { + onmatch: () => { + for (const page of adminPages) { + if (window.authorizer.hasAccess(page, 2)) { + m.route.set(`/admin/${page}`); + return null; + } + } + return null; + } + }; + return component; +} + m.route(document.body, "/overview", { "/login": pagify("login", loginPage), "/overview": pagify("overview", overviewPage), "/devices": pagify("devices", devicesPage), "/devices/:id": pagify("devices", devicePage), "/faults": pagify("faults", faultsPage), - "/presets": pagify("presets", presetsPage), - "/provisions": pagify("provisions", provisionsPage), - "/virtualParameters": pagify("virtualParameters", virtualParametersPage), - "/files": pagify("files", filesPage) + "/admin": redirectAdminPage(), + "/admin/presets": pagify("presets", presetsPage), + "/admin/provisions": pagify("provisions", provisionsPage), + "/admin/virtualParameters": pagify( + "virtualParameters", + virtualParametersPage + ), + "/admin/files": pagify("files", filesPage) }); diff --git a/client/css/app.css b/client/css/app.css index 982dca5..6756bad 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -253,7 +253,40 @@ i { } } +#content-wrapper { + display: flex; +} + +#side-menu { + width: 200px; + & > ul { + padding: 0; + list-style: none; + + & > li { + & > a { + display: block; + text-decoration: none; + margin: 5px 0; + padding: 8px; + border-bottom-right-radius: 6px; + border-top-right-radius: 6px; + background-color: var(--color3); + &:hover { + background-color: var(--color4); + color: var(--color3); + } + } + + &.active { + font-weight: bold; + } + } + } +} + #content { + flex-grow: 1; margin: 10px; min-height: 250px; } diff --git a/client/files-page.js b/client/files-page.js index 6173f11..b8ba4ce 100644 --- a/client/files-page.js +++ b/client/files-page.js @@ -322,13 +322,13 @@ const component = { function onFilterChanged(filter) { const ops = { filter }; if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; - m.route.set("/files", ops); + m.route.set(m.route.get(), ops); } function onSortChange(sort) { const ops = { sort }; if (vnode.attrs.filter) ops.filter = vnode.attrs.filter; - m.route.set("/files", ops); + m.route.set(m.route.get(), ops); } const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; diff --git a/client/layout.js b/client/layout.js index b2d3ecf..feb59ed 100644 --- a/client/layout.js +++ b/client/layout.js @@ -5,18 +5,31 @@ import m from "mithril"; import menu from "./menu"; import drawerComponent from "./drawer-component"; import userMenu from "./user-menu"; +import adminMenu from "./admin-menu"; import * as overlay from "./overlay"; +const adminPages = ["presets", "provisions", "virtualParameters", "files"]; + const layout = { view: vnode => { + let sideMenu, group; + if (adminPages.includes(vnode.attrs.page)) { + group = "admin"; + sideMenu = m(adminMenu, { page: vnode.attrs.page }); + } + return [ m("#header", [ m("img.logo", { src: "logo.svg" }), m(userMenu), - m(menu, { page: vnode.attrs.page }), + m(menu, { page: group || vnode.attrs.page }), m(drawerComponent) ]), - m("#content", { class: `page-${vnode.attrs.page}` }, [vnode.children]), + m( + "#content-wrapper", + sideMenu, + m("#content", { class: `page-${vnode.attrs.page}` }, [vnode.children]) + ), overlay.render() ]; } diff --git a/client/menu.js b/client/menu.js index 605d470..2a2e196 100644 --- a/client/menu.js +++ b/client/menu.js @@ -37,44 +37,18 @@ const menu = { ); } - if (window.authorizer.hasAccess("presets", 2)) { - tabs.push( - m( - "li", - { class: active["presets"] }, - m("a", { href: "#!/presets" }, "Presets") - ) - ); - } - - if (window.authorizer.hasAccess("provisions", 2)) { - tabs.push( - m( - "li", - { class: active["provisions"] }, - m("a", { href: "#!/provisions" }, "Provisions") - ) - ); - } - - if (window.authorizer.hasAccess("virtualParameters", 2)) { - tabs.push( - m( - "li", - { class: active["virtualParameters"] }, - m("a", { href: "#!/virtualParameters" }, "Virtual Parameters") - ) - ); - } - - if (window.authorizer.hasAccess("files", 2)) { - tabs.push( - m( - "li", - { class: active["files"] }, - m("a", { href: "#!/files" }, "Files") - ) - ); + const adminPages = ["presets", "provisions", "virtualParameters", "files"]; + for (const page of adminPages) { + if (window.authorizer.hasAccess(page, 2)) { + tabs.push( + m( + "li", + { class: active["admin"] }, + m("a", { href: "#!/admin" }, "Admin") + ) + ); + break; + } } return m("nav", m("ul", tabs)); diff --git a/client/presets-page.js b/client/presets-page.js index 3c1dff0..d0031f5 100644 --- a/client/presets-page.js +++ b/client/presets-page.js @@ -360,13 +360,13 @@ const component = { function onFilterChanged(filter) { const ops = { filter }; if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; - m.route.set("/presets", ops); + m.route.set(m.route.get(), ops); } function onSortChange(sort) { const ops = { sort }; if (vnode.attrs.filter) ops.filter = vnode.attrs.filter; - m.route.set("/presets", ops); + m.route.set(m.route.get(), ops); } const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; diff --git a/client/provisions-page.js b/client/provisions-page.js index 0b1f8a5..e18198c 100644 --- a/client/provisions-page.js +++ b/client/provisions-page.js @@ -353,13 +353,13 @@ const component = { function onFilterChanged(filter) { const ops = { filter }; if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; - m.route.set("/provisions", ops); + m.route.set(m.route.get(), ops); } function onSortChange(sort) { const ops = { sort }; if (vnode.attrs.filter) ops.filter = vnode.attrs.filter; - m.route.set("/provisions", ops); + m.route.set(m.route.get(), ops); } const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; diff --git a/client/virtual-parameters-page.js b/client/virtual-parameters-page.js index 0188093..c048136 100644 --- a/client/virtual-parameters-page.js +++ b/client/virtual-parameters-page.js @@ -363,13 +363,13 @@ const component = { function onFilterChanged(filter) { const ops = { filter }; if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; - m.route.set("/virtualParameters", ops); + m.route.set(m.route.get(), ops); } function onSortChange(sort) { const ops = { sort }; if (vnode.attrs.filter) ops.filter = vnode.attrs.filter; - m.route.set("/virtualParameters", ops); + m.route.set(m.route.get(), ops); } const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; From bae54300a712467a95e907c5b244a4b2a28caa05 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Tue, 2 Oct 2018 23:48:46 -0700 Subject: [PATCH 074/106] Fix bug in param timestamp tooltip --- client/components/parameter.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/client/components/parameter.js b/client/components/parameter.js index 03d515d..f4ee24b 100644 --- a/client/components/parameter.js +++ b/client/components/parameter.js @@ -69,19 +69,24 @@ const component = { ); } - return m( + const el = m( "span.parameter-value", { onmouseover: e => { e.redraw = false; - const now = Date.now(); - const localeString = new Date(timestamp).toLocaleString(); - e.target.title = `${localeString} (${timeAgo(now - timestamp)})`; + // Don't update any child element + if (e.target === el.dom) { + const now = Date.now(); + const localeString = new Date(timestamp).toLocaleString(); + e.target.title = `${localeString} (${timeAgo(now - timestamp)})`; + } } }, value, edit ); + + return el; } }; From 44dadcb8a70d5aef164dde6fbb6f3c330d7b540e Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Wed, 3 Oct 2018 02:20:25 +0300 Subject: [PATCH 075/106] Support booleans and numbers in edit parameter --- client/drawer-component.js | 67 +++++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/client/drawer-component.js b/client/drawer-component.js index 45813d3..ada2109 100644 --- a/client/drawer-component.js +++ b/client/drawer-component.js @@ -15,25 +15,54 @@ function mval(val) { } function renderStagingSpv(task, queueFunc, cancelFunc) { - const input = m("input", { - value: task.parameterValues[0][1], - oninput: e => { - e.redraw = false; - task.parameterValues[0][1] = input.dom.value; - }, - onkeydown: e => { - if (e.keyCode === 13) queueFunc(); - else if (e.keyCode === 27) cancelFunc(); - else e.redraw = false; - }, - oncreate: vnode => { - vnode.dom.focus(); - vnode.dom.select(); - // Need to prevent scrolling on focus because - // we're animating height and using overflow: hidden - vnode.dom.parentNode.parentNode.scrollTop = 0; - } - }); + function keydown(e) { + if (e.keyCode === 13) queueFunc(); + else if (e.keyCode === 27) cancelFunc(); + else e.redraw = false; + } + + let input; + if (task.parameterValues[0][2] === "xsd:boolean") { + input = m( + "select", + { + value: task.parameterValues[0][1].toString(), + onchange: e => { + e.redraw = false; + task.parameterValues[0][1] = input.dom.value; + }, + onkeydown: keydown, + oncreate: vnode => { + vnode.dom.focus(); + } + }, + [ + m("option", { value: "true" }, "true"), + m("option", { value: "false" }, "false") + ] + ); + } else { + input = m("input", { + type: ["xsd:dateTime", "xsd:int", "xsd:unsignedInt"].includes( + task.parameterValues[0][2] + ) + ? "number" + : "text", + value: task.parameterValues[0][1], + oninput: e => { + e.redraw = false; + task.parameterValues[0][1] = input.dom.value; + }, + onkeydown: keydown, + oncreate: vnode => { + vnode.dom.focus(); + vnode.dom.select(); + // Need to prevent scrolling on focus because + // we're animating height and using overflow: hidden + vnode.dom.parentNode.parentNode.scrollTop = 0; + } + }); + } return [m("span", "Editing ", mparam(task.parameterValues[0][0])), input]; } From ed3b7b57c329b7ec96bcdefe54dd622c0baa9dbe Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Wed, 3 Oct 2018 18:24:31 -0700 Subject: [PATCH 076/106] Move put form out of components directory --- client/files-page.js | 4 ++-- client/presets-page.js | 6 +++--- client/provisions-page.js | 6 +++--- client/{components/put-form.js => put-form-component.js} | 2 +- client/virtual-parameters-page.js | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) rename client/{components/put-form.js => put-form-component.js} (98%) diff --git a/client/files-page.js b/client/files-page.js index b8ba4ce..87d6089 100644 --- a/client/files-page.js +++ b/client/files-page.js @@ -8,7 +8,7 @@ import * as store from "./store"; import * as expression from "../common/expression"; import * as notifications from "./notifications"; import memoize from "../common/memoize"; -import putForm from "./components/put-form"; +import putFormComponent from "./put-form-component"; import * as overlay from "./overlay"; import * as smartQuery from "./smart-query"; import * as expressionParser from "../common/expression-parser"; @@ -272,7 +272,7 @@ function renderTable( onclick: () => { const cb = () => { return m( - putForm, + putFormComponent, Object.assign( { actionHandler: (action, object) => { diff --git a/client/presets-page.js b/client/presets-page.js index d0031f5..7d39742 100644 --- a/client/presets-page.js +++ b/client/presets-page.js @@ -8,7 +8,7 @@ import * as overlay from "./overlay"; import * as store from "./store"; import * as expression from "../common/expression"; import * as notifications from "./notifications"; -import putForm from "./components/put-form"; +import putFormComponent from "./put-form-component"; import memoize from "../common/memoize"; import * as smartQuery from "./smart-query"; import * as expressionParser from "../common/expression-parser"; @@ -214,7 +214,7 @@ function renderTable( onclick: () => { const cb = () => { return m( - putForm, + putFormComponent, Object.assign( { base: preset, @@ -317,7 +317,7 @@ function renderTable( onclick: () => { const cb = () => { return m( - putForm, + putFormComponent, Object.assign( { actionHandler: (action, object) => { diff --git a/client/provisions-page.js b/client/provisions-page.js index e18198c..2702276 100644 --- a/client/provisions-page.js +++ b/client/provisions-page.js @@ -8,7 +8,7 @@ import * as store from "./store"; import * as expression from "../common/expression"; import * as notifications from "./notifications"; import memoize from "../common/memoize"; -import putForm from "./components/put-form"; +import putFormComponent from "./put-form-component"; import * as overlay from "./overlay"; import * as smartQuery from "./smart-query"; import * as expressionParser from "../common/expression-parser"; @@ -201,7 +201,7 @@ function renderTable( onclick: () => { const cb = () => { return m( - putForm, + putFormComponent, Object.assign( { base: provision, @@ -310,7 +310,7 @@ function renderTable( onclick: () => { const cb = () => { return m( - putForm, + putFormComponent, Object.assign( { actionHandler: (action, object) => { diff --git a/client/components/put-form.js b/client/put-form-component.js similarity index 98% rename from client/components/put-form.js rename to client/put-form-component.js index fb6a0de..9a98b29 100644 --- a/client/components/put-form.js +++ b/client/put-form-component.js @@ -1,7 +1,7 @@ "use strict"; import m from "mithril"; -import { codeMirror } from "../dynamic-loader"; +import { codeMirror } from "./dynamic-loader"; const singular = { presets: "preset", diff --git a/client/virtual-parameters-page.js b/client/virtual-parameters-page.js index c048136..b7ccdd6 100644 --- a/client/virtual-parameters-page.js +++ b/client/virtual-parameters-page.js @@ -8,7 +8,7 @@ import * as store from "./store"; import * as expression from "../common/expression"; import * as notifications from "./notifications"; import memoize from "../common/memoize"; -import putForm from "./components/put-form"; +import putFormComponent from "./put-form-component"; import * as overlay from "./overlay"; import * as smartQuery from "./smart-query"; import * as expressionParser from "../common/expression-parser"; @@ -202,7 +202,7 @@ function renderTable( onclick: () => { const cb = () => { return m( - putForm, + putFormComponent, Object.assign( { base: virtualParameter, @@ -320,7 +320,7 @@ function renderTable( onclick: () => { const cb = () => { return m( - putForm, + putFormComponent, Object.assign( { actionHandler: (action, object) => { From dc822cab9b81ad84be3dfd25d80c455b44832d9c Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Wed, 3 Oct 2018 18:47:08 -0700 Subject: [PATCH 077/106] Make serial in device list into a link --- client/components.js | 2 ++ client/components/device-link.js | 29 +++++++++++++++++++++++++++++ config.json | 8 +++++++- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 client/components/device-link.js diff --git a/client/components.js b/client/components.js index 458ed66..492fa92 100644 --- a/client/components.js +++ b/client/components.js @@ -11,6 +11,7 @@ import allParameters from "./components/all-parameters"; import deviceActions from "./components/device-actions"; import tags from "./components/tags"; import ping from "./components/ping"; +import deviceLink from "./components/device-link"; function get(name) { if (name === "parameter") return parameter; @@ -24,6 +25,7 @@ function get(name) { else if (name === "device-actions") return deviceActions; else if (name === "tags") return tags; else if (name === "ping") return ping; + else if (name === "device-link") return deviceLink; else throw new Error(`No such component '${name}'`); } diff --git a/client/components/device-link.js b/client/components/device-link.js new file mode 100644 index 0000000..6cf9f76 --- /dev/null +++ b/client/components/device-link.js @@ -0,0 +1,29 @@ +"use strict"; + +import m from "mithril"; +import * as components from "../components"; + +const component = { + view: vnode => { + let deviceId; + if (vnode.attrs.device) + deviceId = vnode.attrs.device["DeviceID.ID"].value[0]; + + const children = Object.values(vnode.attrs.components).map(c => { + if (typeof c !== "object") return `${c}`; + const attrs = Object.assign({}, vnode.attrs, c); + return m(components.get(attrs.type), attrs); + }); + if (deviceId) { + return m( + "a", + { href: `#!/devices/${encodeURIComponent(deviceId)}` }, + children + ); + } else { + return children; + } + } +}; + +export default component; diff --git a/config.json b/config.json index 7d09554..4a7bc42 100644 --- a/config.json +++ b/config.json @@ -107,7 +107,13 @@ "index": [ { "label": "Serial number", - "parameter": "DeviceID.SerialNumber" + "parameter": "DeviceID.SerialNumber", + "type": "device-link", + "components": [ + { + "type": "parameter" + } + ] }, { "label": "Product class", From f475565365a32de23ba354b134d0f7c2eaeeefe7 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Wed, 3 Oct 2018 19:09:22 -0700 Subject: [PATCH 078/106] Save preset weight as int --- server/mongodb-functions.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index e002720..0ebcc3b 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -485,6 +485,8 @@ function preProcessPreset(data) { : {}; preset.precondition = JSON.stringify(preset.precondition); + preset.weight = parseInt(preset.weight) || 0; + const events = {}; if (preset.events) { for (let e of preset.events.split(",")) { From 44471d5f5ea4482c4c29e0f1ade2099c2f87b9e3 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Wed, 3 Oct 2018 23:22:01 -0700 Subject: [PATCH 079/106] Fix table width overflow --- client/css/app.css | 1 + 1 file changed, 1 insertion(+) diff --git a/client/css/app.css b/client/css/app.css index 6756bad..ea71300 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -287,6 +287,7 @@ i { #content { flex-grow: 1; + overflow: auto; margin: 10px; min-height: 250px; } From 4e378d4dff07232620869bae56e4e01f17994615 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Fri, 5 Oct 2018 22:37:16 -0700 Subject: [PATCH 080/106] Show fault message and detail in faults page --- client/css/app.css | 19 ++++++++++++++++ client/dynamic-loader.js | 16 +++++++++++++- client/faults-page.js | 34 ++++++++++++++++++++++++----- client/long-text-component.js | 41 +++++++++++++++++++++++++++++++++++ package.json | 3 ++- server/mongodb-functions.js | 4 ---- 6 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 client/long-text-component.js diff --git a/client/css/app.css b/client/css/app.css index ea71300..16a00fc 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -637,3 +637,22 @@ span.tag { } } } + +span.long-text { + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 400px; + cursor: pointer; + &:hover { + text-decoration: underline; + text-decoration-style: dotted; + } +} + +textarea.long-text { + font-family: monospace; + min-width: 10vw; + min-height: 10vh; +} diff --git a/client/dynamic-loader.js b/client/dynamic-loader.js index b556484..baf7fed 100644 --- a/client/dynamic-loader.js +++ b/client/dynamic-loader.js @@ -1,6 +1,7 @@ "use strict"; let codeMirror; +let yaml; function loadCodeMirror() { if (codeMirror) return Promise.resolve(); @@ -19,4 +20,17 @@ function loadCodeMirror() { }); } -export { loadCodeMirror, codeMirror }; +function loadYaml() { + if (yaml) return Promise.resolve(); + + return new Promise((resolve, reject) => { + import(/* webpackChunkName: "yaml" */ "yaml") + .then(module => { + yaml = module; + resolve(); + }) + .catch(reject); + }); +} + +export { loadCodeMirror, codeMirror, loadYaml, yaml }; diff --git a/client/faults-page.js b/client/faults-page.js index 47b48d7..38efc39 100644 --- a/client/faults-page.js +++ b/client/faults-page.js @@ -9,6 +9,8 @@ import * as expression from "../common/expression"; import memoize from "../common/memoize"; import * as smartQuery from "./smart-query"; import * as expressionParser from "../common/expression-parser"; +import { loadYaml, yaml } from "./dynamic-loader"; +import longTextComponent from "./long-text-component"; const PAGE_SIZE = config.ui.pageSize || 10; @@ -19,6 +21,8 @@ const attributes = [ { id: "device", label: "Device" }, { id: "channel", label: "Channel" }, { id: "code", label: "Code" }, + { id: "message", label: "Message" }, + { id: "detail", label: "Detail", unsortable: true }, { id: "retries", label: "Retries" }, { id: "timestamp", label: "Timestamp" } ]; @@ -53,7 +57,13 @@ function init(args) { const sort = args.sort; const filter = args.filter; - return Promise.resolve({ filter, sort }); + return new Promise((resolve, reject) => { + loadYaml() + .then(() => { + resolve({ filter, sort }); + }) + .catch(reject); + }); } function renderTable( @@ -82,7 +92,13 @@ function renderTable( for (const attr of attributes) { const label = attr.label; - let direction = 1; + if (attr.unsortable) { + labels.push(m("th", label)); + continue; + } + + // Offset direction by 1 since sort["_id"] = -1 + let direction = 2; let symbol = "\u2981"; if (sort[attr.id] > 0) symbol = "\u2bc6"; @@ -121,11 +137,17 @@ function renderTable( const tds = [m("td", checkbox)]; for (const attr of attributes) { - if (attr.id === "device") - tds.push(m("a", { href: deviceHref }, f[attr.id])); - else if (attr.id === "timestamp") + if (attr.id === "device") { + tds.push(m("td", m("a", { href: deviceHref }, f[attr.id]))); + } else if (attr.id === "timestamp") { tds.push(m("td", new Date(f[attr.id]).toLocaleString())); - else tds.push(m("td", f[attr.id])); + } else if (attr.id === "detail") { + tds.push( + m("td", m(longTextComponent, { text: yaml.stringify(f[attr.id]) })) + ); + } else { + tds.push(m("td", f[attr.id])); + } } rows.push( diff --git a/client/long-text-component.js b/client/long-text-component.js new file mode 100644 index 0000000..08f4cff --- /dev/null +++ b/client/long-text-component.js @@ -0,0 +1,41 @@ +"use strict"; + +import m from "mithril"; +import * as overlay from "./overlay"; + +const component = { + view: vnode => { + const text = vnode.attrs.text; + const element = vnode.attrs.element || "span"; + + return m( + element, + { + class: "long-text", + title: text, + onclick: () => { + overlay.open(() => { + return m( + "textarea.long-text", + { + cols: 80, + rows: 24, + readonly: "", + oncreate: vnode2 => { + setTimeout(() => { + vnode2.dom.focus(); + vnode2.dom.select(); + }, 50); + } + }, + text + ); + }); + } + }, + text + ); + } +}; + +export default component; diff --git a/package.json b/package.json index 53c58c2..26377a1 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "koa-router": "^7.4.0", "koa-static": "^5.0.0", "mongodb": "^3.1.1", - "parsimmon": "^1.12.0" + "parsimmon": "^1.12.0", + "yaml": "^1.0.0" }, "devDependencies": { "babel-eslint": "^8.2.6", diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index 0ebcc3b..c4f65a2 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -353,10 +353,6 @@ function flattenFault(fault) { const f = Object.assign({}, fault); if (f.timestamp) f.timestamp = +f.timestamp; if (f.expiry) f.expiry = +f.expiry; - if (f.detail) { - for (const [k, v] of Object.entries(f.detail)) fault[`detail.${k}`] = v; - delete f.detail; - } return f; } From 18c31ae9348d271c5ef6a5e276022bb2ca4ae294 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sat, 6 Oct 2018 00:22:11 -0700 Subject: [PATCH 081/106] Fix date parsing in flattenDevice --- server/mongodb-functions.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index c4f65a2..6b29577 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -208,13 +208,18 @@ function processDeviceSort(sort) { return s; } +function parseDate(d) { + const n = +d; + return isNaN(n) ? "" + d : n; +} + function flattenDevice(device) { function recursive(input, root, output, timestamp) { for (const [name, tree] of Object.entries(input)) { if (!root) { if (name === "_lastInform") { output["Events.Inform"] = { - value: [Date.parse(tree), "xsd:dateTime"], + value: [parseDate(tree), "xsd:dateTime"], valueTimestamp: timestamp, writable: false, writableTimestamp: timestamp, @@ -223,7 +228,7 @@ function flattenDevice(device) { }; } else if (name === "_registered") { output["Events.Registered"] = { - value: [Date.parse(tree), "xsd:dateTime"], + value: [parseDate(tree), "xsd:dateTime"], valueTimestamp: timestamp, writable: false, writableTimestamp: timestamp, @@ -232,7 +237,7 @@ function flattenDevice(device) { }; } else if (name === "_lastBoot") { output["Events.1_BOOT"] = { - value: [Date.parse(tree), "xsd:dateTime"], + value: [parseDate(tree), "xsd:dateTime"], valueTimestamp: timestamp, writable: false, writableTimestamp: timestamp, @@ -241,7 +246,7 @@ function flattenDevice(device) { }; } else if (name === "_lastBootstrap") { output["Events.0_BOOTSTRAP"] = { - value: [Date.parse(tree), "xsd:dateTime"], + value: [parseDate(tree), "xsd:dateTime"], valueTimestamp: timestamp, writable: false, writableTimestamp: timestamp, From b1c684fc9f5b189e1df73e38eb53827725201e4a Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sat, 6 Oct 2018 00:47:02 -0700 Subject: [PATCH 082/106] Fix converting empty like pattern to regex --- common/expression.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/expression.js b/common/expression.js index 49dcc62..c1b43bd 100644 --- a/common/expression.js +++ b/common/expression.js @@ -226,7 +226,7 @@ function likePatternToRegExp(pat, esc = "", flags = "") { "\\_": "." }; let chars = expressionParser.parseLikePattern(pat, esc); - if (!chars.length) return new RegExp("", flags); + if (!chars.length) return new RegExp("^$", flags); chars = chars.map(c => convChars[c] || c); chars[0] = chars[0] === ".*" ? "" : "^" + chars[0]; const l = chars.length - 1; From aa55dcef8ffd632cc5941d93f0bc8ffd73bdc7b3 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 7 Oct 2018 22:33:25 -0700 Subject: [PATCH 083/106] Fix sort bug in API --- server/mongodb-functions.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index 6b29577..d2a3404 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -201,8 +201,12 @@ function processDeviceSort(sort) { const s = {}; for (const [k, v] of Object.entries(sort)) { if (k === "DeviceID.ID") s["_id"] = v; + else if (k.startsWith("DeviceID.")) s[`_deviceId._${k.slice(9)}`] = v; else if (k === "Events.Inform") s["_lastInform"] = v; - else s[k] = v; + else if (k === "Events.Registered") s["_registered"] = v; + else if (k === "Events.1_BOOT") s["_lastBoot"] = v; + else if (k === "Events.0_BOOTSTRAP") s["_lastBootstrap"] = v; + else s[`${k}._value`] = v; } return s; From b60c5a8c3488d04693e535df9e2bf8d6123ef9e0 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Wed, 10 Oct 2018 19:58:23 +0300 Subject: [PATCH 084/106] Refresh page on delete error --- client/components/device-faults.js | 1 + client/faults-page.js | 1 + client/files-page.js | 1 + client/presets-page.js | 2 ++ client/provisions-page.js | 2 ++ client/virtual-parameters-page.js | 2 ++ 6 files changed, 9 insertions(+) diff --git a/client/components/device-faults.js b/client/components/device-faults.js index cd8c232..02576a8 100644 --- a/client/components/device-faults.js +++ b/client/components/device-faults.js @@ -44,6 +44,7 @@ const component = { }) .catch(err => { notifications.push("error", err.message); + store.fulfill(Date.now(), Date.now()); }); } }, diff --git a/client/faults-page.js b/client/faults-page.js index 38efc39..de71340 100644 --- a/client/faults-page.js +++ b/client/faults-page.js @@ -216,6 +216,7 @@ function renderTable( }) .catch(err => { notifications.push("error", err.message); + store.fulfill(0, Date.now()); }); } }, diff --git a/client/files-page.js b/client/files-page.js index 87d6089..edd3563 100644 --- a/client/files-page.js +++ b/client/files-page.js @@ -256,6 +256,7 @@ function renderTable( }) .catch(err => { notifications.push("error", err.message); + store.fulfill(0, Date.now()); }); } }, diff --git a/client/presets-page.js b/client/presets-page.js index 7d39742..5681cb6 100644 --- a/client/presets-page.js +++ b/client/presets-page.js @@ -84,6 +84,7 @@ function putActionHandler(action, object, isNew) { }) .catch(err => { notifications.push("error", err.message); + store.fulfill(0, Date.now()); }); } else { throw new Error("Undefined action"); @@ -301,6 +302,7 @@ function renderTable( }) .catch(err => { notifications.push("error", err.message); + store.fulfill(0, Date.now()); }); } }, diff --git a/client/provisions-page.js b/client/provisions-page.js index 2702276..6d845c0 100644 --- a/client/provisions-page.js +++ b/client/provisions-page.js @@ -79,6 +79,7 @@ function putActionHandler(action, object, isNew) { }) .catch(err => { notifications.push("error", err.message); + store.fulfill(0, Date.now()); }); } else { throw new Error("Undefined action"); @@ -294,6 +295,7 @@ function renderTable( }) .catch(err => { notifications.push("error", err.message); + store.fulfill(0, Date.now()); }); } }, diff --git a/client/virtual-parameters-page.js b/client/virtual-parameters-page.js index b7ccdd6..e043af8 100644 --- a/client/virtual-parameters-page.js +++ b/client/virtual-parameters-page.js @@ -79,6 +79,7 @@ function putActionHandler(action, object, isNew) { }) .catch(err => { notifications.push("error", err.message); + store.fulfill(0, Date.now()); }); } else { throw new Error("Undefined action"); @@ -304,6 +305,7 @@ function renderTable( }) .catch(err => { notifications.push("error", err.message); + store.fulfill(0, Date.now()); }); } }, From 109ea05fc24059d15baeaa92382d5fd59dc9b16e Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Wed, 10 Oct 2018 18:58:03 +0300 Subject: [PATCH 085/106] Support ctrl/cmd-enter to submit forms with code --- client/put-form-component.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/put-form-component.js b/client/put-form-component.js index 9a98b29..c8b226d 100644 --- a/client/put-form-component.js +++ b/client/put-form-component.js @@ -42,7 +42,15 @@ function createField(current, attr, focus) { oncreate: _vnode => { const editor = codeMirror.fromTextArea(_vnode.dom, { mode: "javascript", - lineNumbers: true + lineNumbers: true, + extraKeys: { + "Ctrl-Enter": () => { + _vnode.dom.form.querySelector("button[type=submit]").click(); + }, + "Cmd-Enter": () => { + _vnode.dom.form.querySelector("button[type=submit]").click(); + } + } }); editor.on("change", e => { From 3e455d6cbce4af40b914ef2a4cff56b231588635 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Thu, 11 Oct 2018 18:21:28 -0700 Subject: [PATCH 086/106] Use value attr for textarea Because setting content as child text node seems to confuse Mithril. I suspect that's because it uses 'value' property to get/set textarea content. --- client/long-text-component.js | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/client/long-text-component.js b/client/long-text-component.js index 08f4cff..d884d91 100644 --- a/client/long-text-component.js +++ b/client/long-text-component.js @@ -15,21 +15,16 @@ const component = { title: text, onclick: () => { overlay.open(() => { - return m( - "textarea.long-text", - { - cols: 80, - rows: 24, - readonly: "", - oncreate: vnode2 => { - setTimeout(() => { - vnode2.dom.focus(); - vnode2.dom.select(); - }, 50); - } - }, - text - ); + return m("textarea.long-text", { + value: text, + cols: 80, + rows: 24, + readonly: "", + oncreate: vnode2 => { + vnode2.dom.focus(); + vnode2.dom.select(); + } + }); }); } }, From 11902fefd15e42cb4864a03daeec6c78b700bdd0 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Fri, 12 Oct 2018 00:20:27 +0300 Subject: [PATCH 087/106] Prevent all parameters overflow --- client/components/all-parameters.js | 8 ++++- client/components/parameter.js | 12 ++++--- client/css/app.css | 9 ++++++ client/long-text-component.js | 50 ++++++++++++++++++++--------- 4 files changed, 58 insertions(+), 21 deletions(-) diff --git a/client/components/all-parameters.js b/client/components/all-parameters.js index 8ed6ce7..3403299 100644 --- a/client/components/all-parameters.js +++ b/client/components/all-parameters.js @@ -3,6 +3,7 @@ import m from "mithril"; import * as components from "../components"; import * as taskQueue from "../task-queue"; +import longTextComponent from "../long-text-component"; function escapeRegExp(str) { return str.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&"); @@ -101,7 +102,12 @@ const component = { ) ); - return m("tr", attrs, m("td.left", k), m("td.right", val)); + return m( + "tr", + attrs, + m("td.left", m(longTextComponent, { text: k })), + m("td.right", val) + ); }); return m( diff --git a/client/components/parameter.js b/client/components/parameter.js index f4ee24b..d03c5d8 100644 --- a/client/components/parameter.js +++ b/client/components/parameter.js @@ -6,6 +6,7 @@ import * as store from "../store"; import * as expression from "../../common/expression"; import memoize from "../../common/memoize"; import timeAgo from "../timeago"; +import longTextComponent from "../long-text-component"; const memoizedParse = memoize(expression.parse); @@ -69,9 +70,12 @@ const component = { ); } - const el = m( - "span.parameter-value", + const el = m(longTextComponent, { text: value }); + + return m( + "span", { + class: "parameter-value", onmouseover: e => { e.redraw = false; // Don't update any child element @@ -82,11 +86,9 @@ const component = { } } }, - value, + el, edit ); - - return el; } }; diff --git a/client/css/app.css b/client/css/app.css index 16a00fc..e6f448d 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -538,10 +538,13 @@ span.na { & > table { width: 100%; + table-layout: fixed; border-spacing: 0; & > tbody { & > tr { & > td { + overflow: hidden; + white-space: nowrap; border-bottom: 1px dotted var(--color1); } transition: background-color var(--fade); @@ -550,6 +553,9 @@ span.na { } & > td.left { + & > * { + max-width: 100%; + } } & > td.right { text-align: right; @@ -644,6 +650,9 @@ span.long-text { text-overflow: ellipsis; white-space: nowrap; max-width: 400px; +} + +span.long-text-overflowed { cursor: pointer; &:hover { text-decoration: underline; diff --git a/client/long-text-component.js b/client/long-text-component.js index d884d91..07afb32 100644 --- a/client/long-text-component.js +++ b/client/long-text-component.js @@ -8,25 +8,45 @@ const component = { const text = vnode.attrs.text; const element = vnode.attrs.element || "span"; + function overflowed(_vnode) { + if (_vnode.dom.classList.contains("long-text-overflowed")) return; + _vnode.dom.classList.add("long-text-overflowed"); + _vnode.dom.onclick = e => { + overlay.open(() => { + return m("textarea.long-text", { + value: text, + cols: 80, + rows: 24, + readonly: "", + oncreate: vnode2 => { + vnode2.dom.focus(); + vnode2.dom.select(); + } + }); + }); + // prevent index page selection + e.stopPropagation(); + m.redraw(); + }; + } + return m( element, { + oncreate: vnode2 => { + if (vnode2.dom.clientWidth !== vnode2.dom.scrollWidth) + overflowed(vnode2); + }, + onupdate: vnode2 => { + if (vnode2.dom.clientWidth === vnode2.dom.scrollWidth) { + vnode2.dom.classList.remove("long-text-overflowed"); + vnode2.dom.onclick = null; + } else { + overflowed(vnode2); + } + }, class: "long-text", - title: text, - onclick: () => { - overlay.open(() => { - return m("textarea.long-text", { - value: text, - cols: 80, - rows: 24, - readonly: "", - oncreate: vnode2 => { - vnode2.dom.focus(); - vnode2.dom.select(); - } - }); - }); - } + title: text }, text ); From cae54613a23872297c0700138911adc53a000dfb Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Fri, 12 Oct 2018 13:44:29 +0300 Subject: [PATCH 088/106] Fix write access level in API --- server/api.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/api.js b/server/api.js index f76ba65..b2ae83a 100644 --- a/server/api.js +++ b/server/api.js @@ -296,7 +296,7 @@ for (const [resource, flags] of Object.entries(resources)) { const authorizer = ctx.state.authorizer; const filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; - if (!authorizer.hasAccess(resource, 2)) { + if (!authorizer.hasAccess(resource, 3)) { logUnauthorizedWarning(log); return void next(); } @@ -410,7 +410,7 @@ router.post("/devices/:id/tasks", async (ctx, next) => { const authorizer = ctx.state.authorizer; const filter = ["=", ["PARAM", "DeviceID.ID"], ctx.params.id]; - if (!authorizer.hasAccess("devices", 2)) { + if (!authorizer.hasAccess("devices", 3)) { logUnauthorizedWarning(log); return void next(); } @@ -445,7 +445,7 @@ router.post("/devices/:id/tags", async (ctx, next) => { const authorizer = ctx.state.authorizer; const filter = ["=", ["PARAM", "DeviceID.ID"], ctx.params.id]; - if (!authorizer.hasAccess("devices", 2)) { + if (!authorizer.hasAccess("devices", 3)) { logUnauthorizedWarning(log); return void next(); } From 14c629689fec43f53f3c0e47d7012ad7538c1ce3 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Fri, 12 Oct 2018 17:28:51 +0300 Subject: [PATCH 089/106] Keep put-form open on error --- client/files-page.js | 33 +++++--- client/presets-page.js | 136 +++++++++++++++++++----------- client/provisions-page.js | 135 +++++++++++++++++------------ client/put-form-component.js | 17 ++-- client/virtual-parameters-page.js | 135 +++++++++++++++++------------ 5 files changed, 282 insertions(+), 174 deletions(-) diff --git a/client/files-page.js b/client/files-page.js index edd3563..1958846 100644 --- a/client/files-page.js +++ b/client/files-page.js @@ -52,13 +52,14 @@ const unpackSmartQuery = memoize(query => { }); }); -function putActionHandler(action, object) { +function putActionHandler(action, _object) { return new Promise((resolve, reject) => { + const object = Object.assign({}, _object); if (action === "save") { const file = object["file"] ? object["file"][0] : null; delete object["file"]; - if (!file) return void reject(new Error("File not selected")); + if (!file) return void resolve({ file: "File not selected" }); const id = file.name; @@ -67,7 +68,7 @@ function putActionHandler(action, object) { .then(exists => { if (exists) { store.fulfill(0, Date.now()); - return void reject(new Error("File already exists")); + return void resolve({ file: "File already exists" }); } const headers = Object.assign( { @@ -277,15 +278,23 @@ function renderTable( Object.assign( { actionHandler: (action, object) => { - putActionHandler(action, object) - .then(() => { - overlay.close(cb); - }) - .catch(err => { - notifications.push("error", err.message); - overlay.close(cb); - m.redraw(); - }); + return new Promise(resolve => { + putActionHandler(action, object) + .then(errors => { + errors = errors ? Object.values(errors) : []; + if (errors.length) { + for (const err of errors) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); } }, formData diff --git a/client/presets-page.js b/client/presets-page.js index 5681cb6..2b6eed3 100644 --- a/client/presets-page.js +++ b/client/presets-page.js @@ -37,58 +37,62 @@ const unpackSmartQuery = memoize(query => { }); }); -function putActionHandler(action, object, isNew) { - if (action === "save") { - const id = object["_id"]; - delete object["_id"]; +function putActionHandler(action, _object, isNew) { + return new Promise((resolve, reject) => { + const object = Object.assign({}, _object); + if (action === "save") { + const id = object["_id"]; + delete object["_id"]; - if (!id) return void notifications.push("error", "ID can not be empty"); + const errors = {}; - store - .resourceExists("presets", id) - .then(exists => { - if (exists && isNew) { - notifications.push("error", "Preset already exists"); - store.fulfill(0, Date.now()); - return; - } + if (!id) errors["_id"] = "ID can not be empty"; + if (!object.provision) errors.provision = "Provision not selected"; - if (!exists && !isNew) { - notifications.push("error", "Preset already deleted"); - store.fulfill(0, Date.now()); - return; - } + if (Object.keys(errors).length) return void resolve(errors); - store - .putResource("presets", id, object) - .then(() => { - notifications.push( - "success", - `Preset ${exists ? "updated" : "created"}` - ); + store + .resourceExists("presets", id) + .then(exists => { + if (exists && isNew) { store.fulfill(0, Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - }); - }) - .catch(err => { - notifications.push("error", err.message); - }); - } else if (action === "delete") { - store - .deleteResource("presets", object["_id"]) - .then(() => { - notifications.push("success", "Preset deleted"); - store.fulfill(0, Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - store.fulfill(0, Date.now()); - }); - } else { - throw new Error("Undefined action"); - } + return void resolve({ _id: "Preset already exists" }); + } + + if (!exists && !isNew) { + store.fulfill(0, Date.now()); + return void resolve({ _id: "Preset does not exist" }); + } + + store + .putResource("presets", id, object) + .then(() => { + notifications.push( + "success", + `Preset ${exists ? "updated" : "created"}` + ); + store.fulfill(0, Date.now()); + resolve(); + }) + .catch(reject); + }) + .catch(reject); + } else if (action === "delete") { + store + .deleteResource("presets", object["_id"]) + .then(() => { + notifications.push("success", "Preset deleted"); + store.fulfill(0, Date.now()); + resolve(); + }) + .catch(err => { + reject(err); + store.fulfill(0, Date.now()); + }); + } else { + reject(new Error("Undefined action")); + } + }); } const formData = { @@ -220,8 +224,23 @@ function renderTable( { base: preset, actionHandler: (action, object) => { - overlay.close(cb); - putActionHandler(action, object, false); + return new Promise(resolve => { + putActionHandler(action, object, false) + .then(errors => { + errors = errors ? Object.values(errors) : []; + if (errors.length) { + for (const err of errors) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); } }, formData @@ -323,8 +342,23 @@ function renderTable( Object.assign( { actionHandler: (action, object) => { - putActionHandler(action, object, true); - overlay.close(cb); + return new Promise(resolve => { + putActionHandler(action, object, true) + .then(errors => { + errors = errors ? Object.values(errors) : []; + if (errors.length) { + for (const err of errors) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); } }, formData diff --git a/client/provisions-page.js b/client/provisions-page.js index 6d845c0..4da0b6a 100644 --- a/client/provisions-page.js +++ b/client/provisions-page.js @@ -32,58 +32,57 @@ const unpackSmartQuery = memoize(query => { }); }); -function putActionHandler(action, object, isNew) { - if (action === "save") { - const id = object["_id"]; - delete object["_id"]; +function putActionHandler(action, _object, isNew) { + return new Promise((resolve, reject) => { + const object = Object.assign({}, _object); + if (action === "save") { + const id = object["_id"]; + delete object["_id"]; - if (!id) return void notifications.push("error", "ID can not be empty"); + if (!id) return void resolve({ _id: "ID can not be empty" }); - store - .resourceExists("provisions", id) - .then(exists => { - if (exists && isNew) { - notifications.push("error", "Provision already exists"); - store.fulfill(0, Date.now()); - return; - } - - if (!exists && !isNew) { - notifications.push("error", "Provision already deleted"); - store.fulfill(0, Date.now()); - return; - } - - store - .putResource("provisions", id, object) - .then(() => { - notifications.push( - "success", - `Provision ${exists ? "updated" : "created"}` - ); + store + .resourceExists("provisions", id) + .then(exists => { + if (exists && isNew) { store.fulfill(0, Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - }); - }) - .catch(err => { - notifications.push("error", err.message); - }); - } else if (action === "delete") { - store - .deleteResource("provisions", object["_id"]) - .then(() => { - notifications.push("success", "Provision deleted"); - store.fulfill(0, Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - store.fulfill(0, Date.now()); - }); - } else { - throw new Error("Undefined action"); - } + return void resolve({ _id: "Provision already exists" }); + } + + if (!exists && !isNew) { + store.fulfill(0, Date.now()); + return void resolve({ _id: "Provision does not exist" }); + } + + store + .putResource("provisions", id, object) + .then(() => { + notifications.push( + "success", + `Provision ${exists ? "updated" : "created"}` + ); + store.fulfill(0, Date.now()); + resolve(); + }) + .catch(reject); + }) + .catch(reject); + } else if (action === "delete") { + store + .deleteResource("provisions", object["_id"]) + .then(() => { + notifications.push("success", "Provision deleted"); + store.fulfill(0, Date.now()); + resolve(); + }) + .catch(err => { + store.fulfill(0, Date.now()); + reject(err); + }); + } else { + reject(new Error("Undefined action")); + } + }); } const formData = { @@ -207,8 +206,23 @@ function renderTable( { base: provision, actionHandler: (action, object) => { - overlay.close(cb); - putActionHandler(action, object, false); + return new Promise(resolve => { + putActionHandler(action, object, false) + .then(errors => { + errors = errors ? Object.values(errors) : []; + if (errors.length) { + for (const err of errors) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); } }, formData @@ -316,8 +330,23 @@ function renderTable( Object.assign( { actionHandler: (action, object) => { - putActionHandler(action, object, true); - overlay.close(cb); + return new Promise(resolve => { + putActionHandler(action, object, true) + .then(errors => { + errors = errors ? Object.values(errors) : []; + if (errors.length) { + for (const err of errors) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); } }, formData diff --git a/client/put-form-component.js b/client/put-form-component.js index c8b226d..6cb59c8 100644 --- a/client/put-form-component.js +++ b/client/put-form-component.js @@ -138,7 +138,9 @@ const component = { onclick: e => { e.redraw = false; e.target.disabled = true; - actionHandler("delete", current.object); + actionHandler("delete", current.object).then(() => { + e.target.disabled = false; + }); } }, "Delete" @@ -155,11 +157,16 @@ const component = { { onsubmit: e => { e.redraw = false; - e.target.onsubmit = null; + // const onsubmit = e.target.onsubmit; e.preventDefault(); - for (const elem of e.target.elements) elem.disabled = true; - submit.dom.textContent = "Loading ..."; - actionHandler("save", current.object); + // e.target.onsubmit = null; + submit.dom.disabled = true; + // submit.dom.textContent = "Loading ..."; + actionHandler("save", current.object).then(() => { + // submit.dom.textContent = "Save"; + // e.target.onsubmit = onsubmit; + submit.dom.disabled = false; + }); } }, form diff --git a/client/virtual-parameters-page.js b/client/virtual-parameters-page.js index e043af8..1c7e5bf 100644 --- a/client/virtual-parameters-page.js +++ b/client/virtual-parameters-page.js @@ -32,58 +32,57 @@ const unpackSmartQuery = memoize(query => { }); }); -function putActionHandler(action, object, isNew) { - if (action === "save") { - const id = object["_id"]; - delete object["_id"]; +function putActionHandler(action, _object, isNew) { + return new Promise((resolve, reject) => { + const object = Object.assign({}, _object); + if (action === "save") { + const id = object["_id"]; + delete object["_id"]; - if (!id) return void notifications.push("error", "ID can not be empty"); + if (!id) return void resolve({ _id: "ID can not be empty" }); - store - .resourceExists("virtualParameters", id) - .then(exists => { - if (exists && isNew) { - notifications.push("error", "Virtual parameter already exists"); - store.fulfill(0, Date.now()); - return; - } - - if (!exists && !isNew) { - notifications.push("error", "Virtual parameter already deleted"); - store.fulfill(0, Date.now()); - return; - } - - store - .putResource("virtualParameters", id, object) - .then(() => { - notifications.push( - "success", - `Virtual parameter ${exists ? "updated" : "created"}` - ); + store + .resourceExists("virtualParameters", id) + .then(exists => { + if (exists && isNew) { store.fulfill(0, Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - }); - }) - .catch(err => { - notifications.push("error", err.message); - }); - } else if (action === "delete") { - store - .deleteResource("virtualParameters", object["_id"]) - .then(() => { - notifications.push("success", "Virtual parameter deleted"); - store.fulfill(0, Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - store.fulfill(0, Date.now()); - }); - } else { - throw new Error("Undefined action"); - } + return void resolve({ _id: "Virtual parameter already exists" }); + } + + if (!exists && !isNew) { + store.fulfill(0, Date.now()); + return void resolve({ _id: "Virtual parameter does not exist" }); + } + + store + .putResource("virtualParameters", id, object) + .then(() => { + notifications.push( + "success", + `Virtual parameter ${exists ? "updated" : "created"}` + ); + store.fulfill(0, Date.now()); + resolve(); + }) + .catch(reject); + }) + .catch(reject); + } else if (action === "delete") { + store + .deleteResource("virtualParameters", object["_id"]) + .then(() => { + notifications.push("success", "Virtual parameter deleted"); + store.fulfill(0, Date.now()); + resolve(); + }) + .catch(err => { + store.fulfill(0, Date.now()); + reject(err); + }); + } else { + reject(new Error("Undefined action")); + } + }); } const formData = { @@ -208,8 +207,23 @@ function renderTable( { base: virtualParameter, actionHandler: (action, object) => { - overlay.close(cb); - putActionHandler(action, object, false); + return new Promise(resolve => { + putActionHandler(action, object, false) + .then(errors => { + errors = errors ? Object.values(errors) : []; + if (errors.length) { + for (const err of errors) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); } }, formData @@ -326,8 +340,23 @@ function renderTable( Object.assign( { actionHandler: (action, object) => { - putActionHandler(action, object, true); - overlay.close(cb); + return new Promise(resolve => { + putActionHandler(action, object, true) + .then(errors => { + errors = errors ? Object.values(errors) : []; + if (errors.length) { + for (const err of errors) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); } }, formData From b9fafd538272f756e6fb8344d178fae1106ca90a Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Sat, 13 Oct 2018 18:46:06 +0300 Subject: [PATCH 090/106] Show blank when parameter value is empty string --- client/css/app.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/css/app.css b/client/css/app.css index e6f448d..8cc904d 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -665,3 +665,10 @@ textarea.long-text { min-width: 10vw; min-height: 10vh; } + +span.parameter-value { + & > span:empty::before { + content: "blank"; + color: #aaa; + } +} From 00dd3457842fd3f798c282eaa410001c0607d47c Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Sat, 13 Oct 2018 18:33:20 +0300 Subject: [PATCH 091/106] Add default provisions to presets provision combo --- client/presets-page.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/client/presets-page.js b/client/presets-page.js index 2b6eed3..a297881 100644 --- a/client/presets-page.js +++ b/client/presets-page.js @@ -420,9 +420,20 @@ const component = { const provisionAttr = attributes.find(attr => { return attr.id === "provision"; }); - provisionAttr.options = provisions.value.map(v => { - return v["_id"]; - }); + + const provisionIds = new Set([ + "refresh", + "value", + "tag", + "reboot", + "reset", + "download", + "instances" + ]); + + for (const p of provisions.value) provisionIds.add(p["_id"]); + + provisionAttr.options = Array.from(provisionIds); } const selected = new Set(); From 4dec8921dbd642f7826e5514c1cb5227e6b1a3ca Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Fri, 12 Oct 2018 19:35:47 +0300 Subject: [PATCH 092/106] Send connection: close header on shutdown --- server/server.js | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/server/server.js b/server/server.js index 6e5b808..a3202e7 100644 --- a/server/server.js +++ b/server/server.js @@ -169,26 +169,24 @@ koa.use( flush: require("zlib").Z_SYNC_FLUSH }) ); -koa.use(router.routes()); -koa.use(koaStatic("./public")); const server = http .createServer(koa.callback()) .listen(config.server.port, config.server.interface); +koa.use(async (ctx, next) => { + await next(); + if (!server.listening) ctx.set("Connection", "close"); +}); + +koa.use(router.routes()); +koa.use(koaStatic("./public")); + function closeServer(timeout) { return new Promise(resolve => { - setTimeout(() => { - if (!resolve) return; - // Ignore HTTP requests from alive connections - server.removeListener("request", koa.callback()); - server.setTimeout(1); - - setTimeout(resolve, 1000); - }, timeout).unref(); - // prevent new sockets to connect and close server eventually. server.close(resolve); + setTimeout(resolve, timeout).unref(); }); } From 6c57be8e81b63cf8fbb96276f426a66c7083a46b Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Sat, 13 Oct 2018 15:36:44 +0300 Subject: [PATCH 093/106] Move autocomplete selection using up/down --- client/autocomplete-compnent.js | 29 ++++++++++++++++++++++++----- client/css/app.css | 2 ++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/client/autocomplete-compnent.js b/client/autocomplete-compnent.js index c2b2e4b..12ac5f0 100644 --- a/client/autocomplete-compnent.js +++ b/client/autocomplete-compnent.js @@ -7,6 +7,7 @@ class Autocomplete { this.hideTimeout = null; this.visible = false; this.default = null; + this.selection = null; this.container = document.createElement("div"); this.container.style.position = "absolute"; @@ -30,25 +31,34 @@ class Autocomplete { el.addEventListener("blur", () => { if (this.element !== el) return; if (!this.visible) return; - this.default = null; this.hide(); }); el.addEventListener("keydown", e => { if (this.element !== el) return; if (e.key === "Escape") { - this.hide(); + if (this.visible) this.hide(); } else if (e.key === "Enter") { if (this.default != null) { el.value = this.default; e.preventDefault(); this.update(); } + } else if (e.key === "ArrowDown") { + e.preventDefault(); + if (this.selection == null) this.selection = 0; + else ++this.selection; + this.update(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + --this.selection; + this.update(); } }); el.addEventListener("input", () => { if (this.element !== el) return; + this.selection = null; this.update(); }); } @@ -57,6 +67,7 @@ class Autocomplete { this.container.style.opacity = 0; this.visible = false; this.default = null; + this.selection = null; clearTimeout(this.hideTimeout); this.hideTimeout = setTimeout(() => { this.hideTimeout = null; @@ -94,11 +105,19 @@ class Autocomplete { this.visible = true; } - this.default = suggestions[0]; + if (this.selection != null) { + this.selection = + ((this.selection % suggestions.length) + suggestions.length) % + suggestions.length; + this.default = suggestions[this.selection]; + } else { + this.default = suggestions[0]; + } - for (const suggestion of suggestions) { + for (const [idx, suggestion] of suggestions.entries()) { const e = document.createElement("div"); - e.className = "suggestion"; + e.classList.add("suggestion"); + if (idx === this.selection) e.classList.add("selected"); const t = document.createTextNode(suggestion); e.appendChild(t); e.addEventListener("mousedown", ev => { diff --git a/client/css/app.css b/client/css/app.css index 8cc904d..1215e27 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -638,6 +638,8 @@ span.tag { transition: opacity var(--fade); & > .suggestion { padding: 5px; + + &.selected, &:hover { background-color: var(--color3); } From 16c8871102b5f8812b0de4d574d51df50393f735 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 14 Oct 2018 22:01:26 -0700 Subject: [PATCH 094/106] Ensure selected filter suggestion remains in view --- client/autocomplete-compnent.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/client/autocomplete-compnent.js b/client/autocomplete-compnent.js index 12ac5f0..2f21c91 100644 --- a/client/autocomplete-compnent.js +++ b/client/autocomplete-compnent.js @@ -114,10 +114,15 @@ class Autocomplete { this.default = suggestions[0]; } + let selectedElement; for (const [idx, suggestion] of suggestions.entries()) { const e = document.createElement("div"); e.classList.add("suggestion"); - if (idx === this.selection) e.classList.add("selected"); + if (idx === this.selection) { + e.classList.add("selected"); + selectedElement = e; + } + const t = document.createTextNode(suggestion); e.appendChild(t); e.addEventListener("mousedown", ev => { @@ -127,6 +132,21 @@ class Autocomplete { }); this.container.appendChild(e); } + + // Ensure selected element is in view + if (selectedElement) { + this.container.scrollTop = Math.min( + this.container.scrollTop, + selectedElement.offsetTop + ); + + this.container.scrollTop = Math.max( + this.container.scrollTop, + selectedElement.offsetTop + + selectedElement.scrollHeight - + this.container.clientHeight + ); + } }); } } From 46c17818161f559f83274ae521e113a0ee2b1d7e Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Mon, 15 Oct 2018 13:07:55 -0700 Subject: [PATCH 095/106] Better chunked-encoding implementation --- server/api.js | 52 +++++++++------------------------------------------ 1 file changed, 9 insertions(+), 43 deletions(-) diff --git a/server/api.js b/server/api.js index b2ae83a..156506a 100644 --- a/server/api.js +++ b/server/api.js @@ -1,6 +1,6 @@ "use strict"; -const zlib = require("zlib"); +const stream = require("stream"); const Router = require("koa-router"); const db = require("./db"); const apiFunctions = require("./api-functions"); @@ -109,32 +109,15 @@ for (const [resource, flags] of Object.entries(resources)) { ]); } - let stream; - switch (ctx.acceptsEncodings("gzip", "deflate", "identity")) { - case "gzip": - stream = zlib.createGzip({ flush: zlib.constants.Z_SYNC_FLUSH }); - stream.pipe(ctx.res); - ctx.set("Content-Encoding", "gzip"); - break; - case "deflate": - stream = zlib.createDeflate({ flush: zlib.constants.Z_SYNC_FLUSH }); - stream.pipe(ctx.res); - ctx.set("Content-Encoding", "deflate"); - break; - default: - stream = ctx.res; - break; - } - - ctx.body = ctx.res; + ctx.body = new stream.PassThrough(); ctx.type = "application/json"; let c = 0; - stream.write("[\n"); + ctx.body.write("[\n"); await db.query(resource, filter, options, obj => { - stream.write((c++ ? "," : "") + JSON.stringify(obj) + "\n"); + ctx.body.write((c++ ? "," : "") + JSON.stringify(obj) + "\n"); }); - stream.end("]"); + ctx.body.end("]"); logger.accessInfo(log); }); @@ -181,30 +164,13 @@ for (const [resource, flags] of Object.entries(resources)) { ]); } - let stream; - switch (ctx.acceptsEncodings("gzip", "deflate", "identity")) { - case "gzip": - stream = zlib.createGzip({ flush: zlib.constants.Z_SYNC_FLUSH }); - stream.pipe(ctx.res); - ctx.set("Content-Encoding", "gzip"); - break; - case "deflate": - stream = zlib.createDeflate({ flush: zlib.constants.Z_SYNC_FLUSH }); - stream.pipe(ctx.res); - ctx.set("Content-Encoding", "deflate"); - break; - default: - stream = ctx.res; - break; - } - - ctx.body = ctx.res; + ctx.body = new stream.PassThrough(); ctx.type = "text/csv"; ctx.attachment( `${resource}-${new Date(now).toISOString().replace(/[:.]/g, "")}.csv` ); - stream.write( + ctx.body.write( Object.keys(columns).map(k => `"${k.replace(/"/, '""')}"`) + "\n" ); await db.query(resource, filter, options, obj => { @@ -236,9 +202,9 @@ for (const [resource, flags] of Object.entries(resources)) { if (typeof v === "string") return `"${v.replace(/"/g, '""')}"`; return v; }); - stream.write(arr.join(",") + "\n"); + ctx.body.write(arr.join(",") + "\n"); }); - stream.end(); + ctx.body.end(); logger.accessInfo(log); }); From 71bb2d4f28891cea4f255c0a313676dbf31062c2 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Wed, 17 Oct 2018 19:19:27 +0300 Subject: [PATCH 096/106] Add support for timestamp in smart query --- client/smart-query.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/client/smart-query.js b/client/smart-query.js index 50155f1..9bb432c 100644 --- a/client/smart-query.js +++ b/client/smart-query.js @@ -63,6 +63,22 @@ function queryNumber(param, value) { return [op, param, v]; } +function queryTimestamp(param, value) { + let op = "="; + for (const o of ["<>", "=", "<=", "<", ">=", ">"]) { + if (value.startsWith(o)) { + op = o; + value = value.slice(o.length).trim(); + break; + } + } + + let v = parseInt(value); + if (v !== +value) v = Date.parse(value); + if (isNaN(v)) return null; + return [op, param, v]; +} + function queryString(param, value) { return ["LIKE", ["FUNC", "LOWER", param], value.toLowerCase()]; } @@ -105,6 +121,11 @@ function unpack(resource, label, value) { if (q) res.push(q); } + if (type.length === 0 || type.includes("timestamp")) { + const q = queryTimestamp(resources[resource][label].parameter, value); + if (q) res.push(q); + } + if (type.includes("mac")) { const q = queryMac(resources[resource][label].parameter, value); if (q) res.push(q); From e1c340f4bcffe8b6b398c6c57f3e31b7efd7826e Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Fri, 19 Oct 2018 19:29:31 -0700 Subject: [PATCH 097/106] Fix style issues for long-text elements --- client/css/app.css | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/client/css/app.css b/client/css/app.css index 1215e27..a877a64 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -41,6 +41,10 @@ a { color: white; background-color: var(--color4); } + /* text-decoration does not propagate to inline-block children */ + & span { + text-decoration: inherit; + } } strong, @@ -648,10 +652,15 @@ span.tag { span.long-text { display: inline-block; - overflow: hidden; + overflow-x: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 400px; + /* + "overflow: hidden" changes the baseline as explained here: + https://stackoverflow.com/questions/9273016/why-is-this-inline-block-element-pushed-downward + */ + vertical-align: top; } span.long-text-overflowed { From 043b54fdd67f7c2168331ef67e797993e056c839 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Fri, 19 Oct 2018 19:39:57 -0700 Subject: [PATCH 098/106] Fix page scroll issue in autocomplete-component --- client/autocomplete-compnent.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/autocomplete-compnent.js b/client/autocomplete-compnent.js index 2f21c91..19a3c8e 100644 --- a/client/autocomplete-compnent.js +++ b/client/autocomplete-compnent.js @@ -22,9 +22,9 @@ class Autocomplete { el.addEventListener("focus", () => { this.element = el; const domRect = el.getBoundingClientRect(); - this.container.style.left = `${domRect.left}px`; + this.container.style.left = `${domRect.left + window.pageXOffset}px`; this.container.style.width = `${domRect.width}px`; - this.container.style.top = `${domRect.bottom}px`; + this.container.style.top = `${domRect.bottom + window.pageYOffset}px`; this.update(); }); From a2b402c6902b69ea732077009e1c871def28d0c4 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Fri, 19 Oct 2018 19:52:17 -0700 Subject: [PATCH 099/106] Add delay in all-parameters search --- client/components/all-parameters.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/components/all-parameters.js b/client/components/all-parameters.js index 3403299..f06fb91 100644 --- a/client/components/all-parameters.js +++ b/client/components/all-parameters.js @@ -18,6 +18,9 @@ const component = { placeholder: "Search parameters", oninput: e => { vnode.state.searchString = e.target.value; + e.redraw = false; + clearTimeout(vnode.state.timeout); + vnode.state.timeout = setTimeout(m.redraw, 500); } }); From b6f2a33b3ff7346777d3c657c984d7cdb21c3dc3 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Fri, 26 Oct 2018 14:23:00 -0700 Subject: [PATCH 100/106] Allow hypen in param name --- common/expression-parser.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/expression-parser.js b/common/expression-parser.js index f581202..f9b5490 100644 --- a/common/expression-parser.js +++ b/common/expression-parser.js @@ -122,7 +122,7 @@ const lang = parsimmon.createLanguage({ Parameter: function(r) { return parsimmon .alt( - parsimmon.regexp(/[a-zA-Z0-9_.*]+/), + parsimmon.regexp(/[a-zA-Z0-9_.*-]+/), r.Expression.wrap( parsimmon.string("{").skip(parsimmon.optWhitespace), parsimmon.string("}") From cdaad02fffc326bd0709c9f3049135ec5208e9df Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Fri, 26 Oct 2018 14:28:11 -0700 Subject: [PATCH 101/106] Convert param value to text when redering False boolean values would otherwise not be rendered correctly. --- client/components/parameter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/parameter.js b/client/components/parameter.js index d03c5d8..1775cbd 100644 --- a/client/components/parameter.js +++ b/client/components/parameter.js @@ -70,7 +70,7 @@ const component = { ); } - const el = m(longTextComponent, { text: value }); + const el = m(longTextComponent, { text: `${value}` }); return m( "span", From 9b42ea6cd514a50a825933d7e3bfbd9b22956c3e Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Mon, 29 Oct 2018 14:54:45 +0300 Subject: [PATCH 102/106] Return 0 instead of [] in db.count --- server/db.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/db.js b/server/db.js index c1180f1..bed89e6 100644 --- a/server/db.js +++ b/server/db.js @@ -138,7 +138,7 @@ function count(resource, filter) { filter = mongodbFunctions.processFaultsFilter(filter); q = mongodbFunctions.filterToMongoQuery(filter); } else if (filter != null && !filter) { - return Promise.resolve([]); + return Promise.resolve(0); } return new Promise((resolve, reject) => { From 23666feeb57d7add436f0610e55aa8f2fcab1231 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Mon, 29 Oct 2018 14:54:26 +0300 Subject: [PATCH 103/106] Return unique parameters list from extractParams --- common/expression.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/expression.js b/common/expression.js index c1b43bd..2fc5988 100644 --- a/common/expression.js +++ b/common/expression.js @@ -235,12 +235,12 @@ function likePatternToRegExp(pat, esc = "", flags = "") { } function extractParams(exp) { - const params = []; + const params = new Set(); expressionParser.map(exp, e => { - if (isArray(e) && e[0] === "PARAM") params.push(e[1]); + if (isArray(e) && e[0] === "PARAM") params.add(e[1]); return e; }); - return params; + return Array.from(params); } exports.evaluate = evaluate; From 6ee024cdfdd88d3564ee541db68ccc7e37fd4d9e Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Mon, 29 Oct 2018 15:26:10 +0300 Subject: [PATCH 104/106] Implement DB cache --- server/db.js | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/server/db.js b/server/db.js index bed89e6..e3f66b9 100644 --- a/server/db.js +++ b/server/db.js @@ -5,6 +5,8 @@ const config = require("./config"); const mongodbFunctions = require("./mongodb-functions"); const expression = require("../common/expression"); +const CACHE_TTL = 300000; + let _clientPromise = null; const RESOURCE_DB = { @@ -14,13 +16,21 @@ const RESOURCE_DB = { presets: "genieacs", provisions: "genieacs", virtualParameters: "genieacs", - files: "genieacs" + files: "genieacs", + cache: "genieacs-ui" }; const RESOURCE_COLLECTION = { files: "fs.files" }; +function ensureIndexes(client) { + client + .db(RESOURCE_DB["cache"]) + .collection("cache") + .createIndex({ expire: 1 }, { expireAfterSeconds: 0 }); +} + function getClient() { if (!_clientPromise) { _clientPromise = new Promise((resolve, reject) => { @@ -30,6 +40,7 @@ function getClient() { { useNewUrlParser: true }, (err, client) => { if (err) return void reject(err); + ensureIndexes(client); resolve(client); } ); @@ -39,6 +50,39 @@ function getClient() { return _clientPromise; } +function cache(key, valueGetter, ttl) { + return new Promise((resolve, reject) => { + getClient() + .then(client => { + const collection = client.db(RESOURCE_DB["cache"]).collection("cache"); + collection.findOne({ _id: key }, (err, doc) => { + if (err) return void reject(err); + if (doc != null) return void resolve(JSON.parse(doc.value)); + valueGetter() + .then(res => { + const expire = Date.now() + (ttl || CACHE_TTL); + const cacheDoc = { + _id: key, + value: JSON.stringify(res), + expire: new Date(expire) + }; + collection.updateOne( + { _id: key }, + { $set: cacheDoc }, + { upsert: true }, + err => { + if (err) reject(err); + else resolve(res); + } + ); + }) + .catch(reject); + }); + }) + .catch(reject); + }); +} + function query(resource, filter, options, callback) { options = options || {}; let q; @@ -164,4 +208,5 @@ function disconnect() { exports.query = query; exports.count = count; +exports.cache = cache; exports.disconnect = disconnect; From 4016f3acfd3f2a967a33e0802423fbceb0bfc797 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Wed, 5 Dec 2018 18:16:14 +0300 Subject: [PATCH 105/106] Support filters for object parameters --- client/components/parameter-table.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/client/components/parameter-table.js b/client/components/parameter-table.js index 947ebbb..ff39bbb 100644 --- a/client/components/parameter-table.js +++ b/client/components/parameter-table.js @@ -6,6 +6,9 @@ import * as taskQueue from "../task-queue"; import * as store from "../store"; import * as expression from "../../common/expression"; import * as expressionParser from "../../common/expression-parser"; +import memoize from "../../common/memoize"; + +const memoizeParse = memoize(expression.parse); const component = { oninit: vnode => { @@ -44,6 +47,17 @@ const component = { const rows = []; for (const i of instances) { + let filter = + vnode.attrs.filter != null ? memoizeParse(vnode.attrs.filter) : true; + + filter = expressionParser.map(filter, e => { + if (Array.isArray(e) && e[0] === "PARAM") + return ["PARAM", ["||", i, ".", e[1]]]; + return e; + }); + + if (!store.evaluateExpression(filter, device)) continue; + const row = parameters.map(p => { const param = expressionParser.map(p.parameter, e => { if (Array.isArray(e) && e[0] === "PARAM") From d27247e265d8704b61324f0aaaaecb8b61b632d2 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Tue, 4 Dec 2018 23:04:03 +0300 Subject: [PATCH 106/106] Implement CSV download for all parameters --- client/components/all-parameters.js | 9 ++++++ client/css/app.css | 2 +- server/api.js | 47 +++++++++++++++++++++++++++++ server/mongodb-functions.js | 5 ++- 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/client/components/all-parameters.js b/client/components/all-parameters.js index f06fb91..082306b 100644 --- a/client/components/all-parameters.js +++ b/client/components/all-parameters.js @@ -115,6 +115,15 @@ const component = { return m( ".all-parameters", + m( + "a.download-csv", + { + href: `/api/devices/${device["DeviceID.ID"].value[0]}.csv`, + download: "", + style: "float: right;" + }, + "Download" + ), search, m(".parameter-list", m("table", m("tbody", rows))) ); diff --git a/client/css/app.css b/client/css/app.css index a877a64..48e6c0f 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -528,7 +528,6 @@ span.na { } .all-parameters { - font-family: monospace; & > input { font-family: monospace; margin-bottom: 2px; @@ -537,6 +536,7 @@ span.na { } & > .parameter-list { + font-family: monospace; overflow-y: scroll; height: 200px; diff --git a/server/api.js b/server/api.js index 156506a..ead1dc9 100644 --- a/server/api.js +++ b/server/api.js @@ -37,6 +37,53 @@ const resources = { tasks: 0 }; +router.get(`/devices/:id.csv`, async ctx => { + const log = { + message: "Query device (CSV)", + context: ctx, + id: ctx.params.id + }; + + const filter = ["=", ["PARAM", RESOURCE_IDS.devices], ctx.params.id]; + + if (!ctx.state.authorizer.hasAccess("devices", 2)) { + logUnauthorizedWarning(log); + return void (ctx.status = 404); + } + + const res = await db.query("devices", filter); + if (!res[0]) return void (ctx.status = 404); + + ctx.type = "text/csv"; + ctx.attachment( + `device-${ctx.params.id}-${new Date() + .toISOString() + .replace(/[:.]/g, "")}.csv` + ); + + ctx.body = new stream.PassThrough(); + ctx.body.write( + "Parameter,Object,Object timestamp,Writable,Writable timestamp,Value,Value type,Value timestamp\n" + ); + + for (const k of Object.keys(res[0]).sort()) { + const p = res[0][k]; + const row = [ + k, + p.object, + p.objectTimestamp, + p.writable, + p.writableTimestamp, + p.value != null ? `"${p.value[0].toString().replace(/"/g, '""')}"` : "", + p.value != null ? p.value[1] : "", + p.valueTimestamp + ]; + ctx.body.write(row.map(r => (r != null ? r : "")).join(",") + "\n"); + } + ctx.body.end(); + logger.accessInfo(log); +}); + for (const [resource, flags] of Object.entries(resources)) { router.head(`/${resource}`, async (ctx, next) => { let filter = true; diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index d2a3404..10944c8 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -330,7 +330,10 @@ function flattenDevice(device) { const attrs = {}; if (tree["_value"] != null) { - attrs["value"] = [tree["_value"], tree["_type"]]; + attrs["value"] = [ + tree["_value"] instanceof Date ? +tree["_value"] : tree["_value"], + tree["_type"] + ]; attrs["valueTimestamp"] = +(tree["_timestamp"] || childrenTimestamp); attrs["object"] = false; attrs["objectTimestamp"] = childrenTimestamp;