From 1c9061ab5a00497b26e03ba4de7f582b198cabec Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Fri, 15 Dec 2017 22:46:27 -0800 Subject: [PATCH] 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 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 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;