diff --git a/.eslintrc.json b/.eslintrc.json index baba800..becdf45 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,13 +1,11 @@ { "env": { - "es6": true, - "node": true + "es6": true }, - "extends": "eslint:recommended", "parserOptions": { - "sourceType": "script", "ecmaVersion": 8 }, + "extends": "eslint:recommended", "rules": { "no-shadow": ["error", { "allow": ["err"] }], "handle-callback-err": "error", diff --git a/.gitignore b/.gitignore index ca2c62c..f16cfb6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ *~ node_modules +dist debug config/auth.js config/config.json +config/config-ui.json config/cwmp.key config/cwmp.crt config/cwmp.ca-bundle diff --git a/config/config-ui-sample.json b/config/config-ui-sample.json new file mode 100644 index 0000000..4a7bc42 --- /dev/null +++ b/config/config-ui-sample.json @@ -0,0 +1,322 @@ +{ + "server": { + "workers": 4, + "interface": "0.0.0.0", + "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 + } + }, + "presets": { + "access": 3, + "validate": { + "pass": true + } + }, + "provisions" : { + "access": 3, + "validate": { + "pass": true + } + }, + "virtualParameters" : { + "access": 3, + "validate": { + "pass": true + } + }, + "files": { + "access": 3, + "validate": { + "pass": true + } + } + } + ], + "view": [ + { + "devices": { + "access": 2 + }, + "faults": { + "access": 2 + }, + "presets": { + "access": 2 + }, + "provisions": { + "access": 2 + }, + "virtualParameters": { + "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", + "type": "device-link", + "components": [ + { + "type": "parameter" + } + ] + }, + { + "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": "DATE_STRING(Events.Inform)", + "type": "container", + "element": "span.inform", + "components": [ + { + "type": "parameter" + }, + { + "type": "overview-dot", + "chart": "online" + } + ] + }, + { + "label": "Tags", + "parameter": "Tags", + "type": "tags", + "writable": false, + "unsortable": true + } + ], + "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": "DATE_STRING(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": "container", + "element": "div", + "components": [ + { + "type": "container", + "element": "h3", + "components": ["Faults"] + }, + {"type": "device-faults"} + ] + }, + { + "type": "container", + "element": "div.container-full-width", + "components": [ + { + "type": "container", + "element": "h3", + "components": ["All parameters"] + }, + {"type": "all-parameters"} + ] + }, + { + "type": "device-actions" + } + ] + }, + "logger": { + "logFile": "", + "accessLogFile": "", + "logFormat": "simple", + "accessLogFormat": "simple" + } +} diff --git a/lib/.eslintrc.json b/lib/.eslintrc.json new file mode 100644 index 0000000..10d2238 --- /dev/null +++ b/lib/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "env": { + "node": true + } +} diff --git a/lib/common/authorizer.js b/lib/common/authorizer.js new file mode 100644 index 0000000..82bb3b1 --- /dev/null +++ b/lib/common/authorizer.js @@ -0,0 +1,81 @@ +"use strict"; + +const validators = { + pass: require("./validators/pass"), + test: require("./validators/test") +}; + +class Authorizer { + constructor(permissionSets) { + this.permissionSets = permissionSets; + this.validatorCache = 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.validatorCache.has(resource)) + return this.validatorCache.get(resource); + + let funcs = {}; + + for (const permissionSet of this.permissionSets) { + for (const perm of permissionSet) { + if (perm[resourceType]) { + if (perm[resourceType].access >= 3) { + 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 (const [k, v] of Object.entries(funcs)) { + if (v) { + const res = validators[k]( + resourceType, + resource, + mutationType, + mutation, + any + ); + + if (res > 0) valid = true; + else if (res < 0) return false; + } + } + + return valid; + }; + + this.validatorCache.set(resource, validator); + return validator; + } +} + +module.exports = Authorizer; diff --git a/lib/common/expression-cnf.js b/lib/common/expression-cnf.js new file mode 100644 index 0000000..51960a4 --- /dev/null +++ b/lib/common/expression-cnf.js @@ -0,0 +1,255 @@ +"use strict"; + +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) { + const 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])); + + return res; +} + +function booleanCnf(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 (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(); + + 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 || op !== "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]}`); + + 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); + 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; + }); + } + + function getLikeVariables(lhs, pat, esc) { + const vars = []; + const pats = likePatterns.get(lhs); + pat = pats.get(`${esc || ""}:${pat}`); + 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); + if (!f) expressions.set(key, (f = expressions.size + 1)); + 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) { + const t = rhs; + rhs = lhs; + lhs = t; + op = { ">": "<", ">=": "<=", "<": ">", "<=": ">=" }[op] || op; + } + + const v = getVariable(lhs, op, rhs); + 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]]; + else return [[v]]; + } + } + + const cnf = recursive(exp, false); + + 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]]); + } + + 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/lib/common/expression-parser.js b/lib/common/expression-parser.js new file mode 100644 index 0000000..f9b5490 --- /dev/null +++ b/lib/common/expression-parser.js @@ -0,0 +1,384 @@ +"use strict"; + +const parsimmon = require("parsimmon"); + +// Turn escaped characters into real ones (e.g. "\\n" becomes "\n"). +function interpretEscapes(str) { + const escapes = { + b: "\b", + f: "\f", + n: "\n", + r: "\r", + t: "\t" + }; + return str.replace(/\\(u[0-9a-fA-F]{4}|[^u])/, (_, escape) => { + 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]; + + return type; + }); +} + +function map(exp, callback) { + if (!Array.isArray(exp)) return callback(exp); + + let clone; + for (let i = 1; i < exp.length; ++i) { + const 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, + parsimmon.seq(operatorsParser, nextParser).many(), + (first, rest) => + rest.reduce((acc, 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)); + 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); + }, + 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( + 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") + ) + .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"); + }, + AndOperator: function() { + return parsimmon + .regexp(/and/i) + .result("AND") + .notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/)) + .skip(parsimmon.optWhitespace) + .desc("AND"); + }, + OrOperator: function() { + return parsimmon + .regexp(/or/i) + .result("OR") + .notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/)) + .skip(parsimmon.optWhitespace) + .desc("OR"); + }, + Parameter: function(r) { + return parsimmon + .alt( + parsimmon.regexp(/[a-zA-Z0-9_.*-]+/), + r.Expression.wrap( + parsimmon.string("{").skip(parsimmon.optWhitespace), + parsimmon.string("}") + ) + ) + .atLeast(1) + .map(x => ["PARAM", 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"); + }, + 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-9_]+)/, 1) + .skip(parsimmon.optWhitespace) + .desc("function"), + r.ValueExpression.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.NullValue, + r.BooleanValue, + r.NumberValue, + r.StringValueSql, + r.StringValueJs, + r.FuncValue + ); + }, + ValueExpression: function(r) { + return binaryLeft( + parsimmon.string("||").skip(parsimmon.optWhitespace), + 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.Parameter, + r.Expression.wrap( + parsimmon.string("(").skip(parsimmon.optWhitespace), + parsimmon.string(")").skip(parsimmon.optWhitespace) + ) + ) + ) + ) + ); + }, + Comparison: function(r) { + return parsimmon.alt( + 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] + ) + ); + }, + 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.ValueExpression)) + ) + ).trim(parsimmon.optWhitespace); + } +}); + +function parse(str) { + if (!str) return null; + return lang.Expression.tryParse(str); +} + +function stringify(exp, level = 0) { + if (!Array.isArray(exp)) return JSON.stringify(exp); + + const opLevels = { + OR: 10, + AND: 11, + NOT: 12, + "=": 20, + "<>": 20, + ">": 20, + ">=": 20, + "<": 20, + "<=": 20, + LIKE: 20, + "NOT LIKE": 20, + "IS NULL": 20, + "IS NOT NULL": 20, + "||": 30, + "+": 31, + "-": 31, + "*": 32, + "/": 32 + }; + + const op = exp[0].toUpperCase(); + + function wrap(e) { + if (opLevels[op] <= level) return `(${e})`; + else return e; + } + + if (op === "FUNC") { + return wrap( + `${exp[1]}(${exp + .slice(2) + .map(e => stringify(e)) + .join(", ")})` + ); + } else if (op === "PARAM") { + if (typeof exp[1] === "string") { + return wrap(exp[1]); + } else if (Array.isArray(exp[1]) && exp[1][0] === "||") { + 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 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) => { + return stringify(e, opLevels[exp[0]] + Math.min(i - 1, 0)); + }); + + 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) { + const chars = pat.split(""); + + for (let i = 0; i < chars.length; ++i) { + const 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/lib/common/expression.js b/lib/common/expression.js new file mode 100644 index 0000000..2fc5988 --- /dev/null +++ b/lib/common/expression.js @@ -0,0 +1,254 @@ +"use strict"; + +const expressionParser = require("./expression-parser"); +const expressionCnf = require("./expression-cnf"); +const satSolver = require("./sat-solver"); + +const isArray = Array.isArray; + +const regExpCache = new WeakMap(); + +const REDUCE_SKIP = {}; +function reduce(exp, callback) { + let loop = true; + while (loop) { + loop = false; + for (let i = 2; i < exp.length; ++i) { + const res = callback(exp[i - 1], exp[i]); + if (res !== REDUCE_SKIP) { + 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, cb) { + 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 (cb) e = cb(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[2] == 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])) { + let v = obj[e[1]]; + if (v == null) return null; + if (typeof v === "object") v = v.value ? v.value[0] : null; + return v; + } + } else if (e[0] === "AND") { + 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]; + else if (e[1][0] === "NOT") return e[1][1]; + } else if (e[0] === "IS NULL") { + if (isArray(e[1])) return e; + else if (e[1] == null) return true; + else return null; + } else if (e[0] === "IS NOT NULL") { + 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; + 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; + } + return REDUCE_SKIP; + }); + } 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 REDUCE_SKIP; + }); + } 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 REDUCE_SKIP; + }); + } 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 REDUCE_SKIP; + }); + } 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 REDUCE_SKIP; + }); + } + 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); +} + +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); +} + +function extractParams(exp) { + const params = new Set(); + expressionParser.map(exp, e => { + if (isArray(e) && e[0] === "PARAM") params.add(e[1]); + return e; + }); + return Array.from(params); +} + +exports.evaluate = evaluate; +exports.and = and; +exports.or = or; +exports.not = not; +exports.subset = subset; +exports.parse = expressionParser.parse; +exports.stringify = expressionParser.stringify; +exports.likePatternToRegExp = likePatternToRegExp; +exports.extractParams = extractParams; diff --git a/lib/common/memoize.js b/lib/common/memoize.js new file mode 100644 index 0000000..9a13007 --- /dev/null +++ b/lib/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/lib/common/sat-solver.js b/lib/common/sat-solver.js new file mode 100644 index 0000000..4800345 --- /dev/null +++ b/lib/common/sat-solver.js @@ -0,0 +1,176 @@ +/** + * 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++) { + const 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++) { + const 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/lib/common/validators/pass.js b/lib/common/validators/pass.js new file mode 100644 index 0000000..e01267a --- /dev/null +++ b/lib/common/validators/pass.js @@ -0,0 +1,5 @@ +function pass() { + return 1; +} + +module.exports = pass; diff --git a/lib/common/validators/test.js b/lib/common/validators/test.js new file mode 100644 index 0000000..6795cbc --- /dev/null +++ b/lib/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/lib/ui/api-functions.js b/lib/ui/api-functions.js new file mode 100644 index 0000000..b0acd9d --- /dev/null +++ b/lib/ui/api-functions.js @@ -0,0 +1,372 @@ +"use strict"; + +const http = require("http"); +const https = require("https"); +const url = require("url"); + +const config = require("./config"); +const db = require("./db"); +const mongodbFunctions = require("./mongodb-functions"); + +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]; + const 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 deleteResource(resource, id) { + return new Promise((resolve, reject) => { + const resourceUrl = + resource === "virtualParameters" ? "virtual_parameters" : resource; + const options = url.parse( + `${config.server.nbi}${resourceUrl}/${encodeURIComponent(id)}` + ); + + options.method = "DELETE"; + + const _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) { + const options = url.parse( + `${config.server.nbi}devices/${encodeURIComponent(task.device)}/tasks${ + connectionRequest ? "?connection_request" : "" + }` + ); + options.method = "POST"; + const _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", () => { + const buf = Buffer.allocUnsafe(bytes); + let o = 0; + for (const c of chunks) { + c.copy(buf, o, 0, c.length); + o += c.length; + } + try { + const t = JSON.parse(buf); + let connectionRequestStatus; + if (connectionRequest) { + connectionRequestStatus = "OK"; + if ( + res.statusCode === 202 && + res.statusMessage !== "Task queued but not processed" && + res.statusMessage !== "Task faulted" + ) + 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; + const promises = []; + for (const [idx, task] of tasks.entries()) { + task.device = deviceId; + promises.push( + new Promise((res, rej) => { + const conReq = idx === tasks.length - 1; + delete task._id; + task.expiry = 5; + postTask(task, conReq, (err, t, crs) => { + if (err) return void rej(err); + if (conReq) connectionRequestStatus = crs; + res({ _id: t._id, status: "pending" }); + }); + }) + ); + } + + Promise.all(promises) + .then(statuses => { + if (connectionRequestStatus !== "OK") { + return void resolve({ + connectionRequest: connectionRequestStatus, + tasks: statuses + }); + } + + const promises2 = []; + for (const s of statuses) { + promises2.push(db.query("tasks", ["=", ["PARAM", "_id"], s._id])); + promises2.push( + db.query("faults", [ + "=", + ["PARAM", "_id"], + `${deviceId}:task_${s._id}` + ]) + ); + } + + Promise.all(promises2) + .then(res => { + for (const [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", r._id); + } + + resolve({ + connectionRequest: connectionRequestStatus, + tasks: statuses + }); + }) + .catch(reject); + }) + .catch(reject); + }); +} + +function updateTags(deviceId, tags) { + return Promise.all( + Object.entries(tags).map( + ([tag, onOff]) => + new Promise((resolve, reject) => { + const options = url.parse( + `${config.server.nbi}devices/${encodeURIComponent( + deviceId + )}/tags/${encodeURIComponent(tag)}` + ); + if (onOff) options.method = "POST"; + else options.method = "DELETE"; + const _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(); + }) + ) + ); +} + +function ping(host) { + return new Promise((resolve, reject) => { + const options = url.parse( + `${config.server.nbi}ping/${encodeURIComponent(host)}` + ); + + const _http = options.protocol === "https:" ? https : http; + _http + .request(options, res => { + if (res.statusCode === 404) { + res.resume(); + return void resolve({}); + } + + if (res.statusCode !== 200) { + res.resume(); + return void 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", () => { + const buf = Buffer.allocUnsafe(bytes); + let o = 0; + for (const c of chunks) { + c.copy(buf, o, 0, c.length); + o += c.length; + } + const m = buf + .toString() + .match( + /(\d) packets transmitted, (\d) received, ([\d.%]+) packet loss[^]*([\d.]+)\/([\d.]+)\/([\d.]+)\/([\d.]+)/ + ); + if (!m) + return void 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(); + }); +} + +function putResource(resource, id, data) { + return new Promise((resolve, reject) => { + const resourceUrl = + resource === "virtualParameters" ? "virtual_parameters" : resource; + const options = url.parse( + `${config.server.nbi}${resourceUrl}/${encodeURIComponent(id)}` + ); + + options.method = "PUT"; + + const _http = options.protocol === "https:" ? https : http; + + if (resource === "presets") data = mongodbFunctions.preProcessPreset(data); + + if (resource === "provisions") + data = mongodbFunctions.preProcessProvision(data); + + if (resource === "virtualParameters") + data = mongodbFunctions.preProcessVirtualParameters(data); + + const body = typeof data !== "string" ? JSON.stringify(data) : data; + _http + .request(options, res => { + const chunks = []; + let bytes = 0; + res.on("data", chunk => { + chunks.push(chunk); + bytes += chunk.length; + }); + + res.on("end", () => { + if (res.statusCode === 200) { + return void resolve(); + } else if (res.statusCode !== 400) { + return void reject( + new Error(`Unexpected status code ${res.statusCode}`) + ); + } + const buf = Buffer.allocUnsafe(bytes); + let o = 0; + for (const c of chunks) { + c.copy(buf, o, 0, c.length); + o += c.length; + } + resolve(buf.toString()); + }); + }) + .end(body); + }); +} + +function putFile(id, metadata, fileStream) { + return new Promise((resolve, reject) => { + const options = url.parse( + `${config.server.nbi}files/${encodeURIComponent(id)}` + ); + + options.method = "PUT"; + options.headers = Object.assign({}, metadata); + const _http = options.protocol === "https:" ? https : http; + const req = _http.request(options, res => { + res.resume(); + if (res.statusCode === 201) { + return void resolve(); + } else { + return void 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/lib/ui/api.js b/lib/ui/api.js new file mode 100644 index 0000000..ead1dc9 --- /dev/null +++ b/lib/ui/api.js @@ -0,0 +1,485 @@ +"use strict"; + +const stream = require("stream"); +const Router = require("koa-router"); +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_PUT = 1 << 1; + +const RESOURCE_IDS = { + devices: "DeviceID.ID", + presets: "_id", + provisions: "_id", + files: "_id", + virtualParameters: "_id", + faults: "_id", + tasks: "_id" +}; + +const resources = { + devices: 0 | RESOURCE_DELETE, + presets: 0 | RESOURCE_DELETE | RESOURCE_PUT, + provisions: 0 | RESOURCE_DELETE | RESOURCE_PUT, + files: 0 | RESOURCE_DELETE, + virtualParameters: 0 | RESOURCE_DELETE | RESOURCE_PUT, + faults: 0 | RESOURCE_DELETE, + 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; + if (ctx.request.query.filter) + filter = expression.parse(ctx.request.query.filter); + + const log = { + message: `Count ${resource}`, + context: ctx, + filter: ctx.request.query.filter + }; + + if (!ctx.state.authorizer.hasAccess(resource, 1)) { + logUnauthorizedWarning(log); + return void next(); + } + + // Exclude temporary tasks and faults + if (resource === "tasks" || resource === "faults") { + filter = expression.and(filter, [ + "NOT", + ["<", ["PARAM", "expiry"], Date.now() + 60000] + ]); + } + + const count = await db.count(resource, filter); + + ctx.set("X-Total-Count", count); + ctx.body = ""; + + log.count = count; + logger.accessInfo(log); + }); + + router.get(`/${resource}`, async (ctx, next) => { + const 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.request.query.projection) { + options.projection = ctx.request.query.projection + .split(",") + .reduce((obj, k) => Object.assign(obj, { [k]: 1 }), {}); + } + + 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 void next(); + } + + // Exclude temporary tasks and faults + if (resource === "tasks" || resource === "faults") { + filter = expression.and(filter, [ + "NOT", + ["<", ["PARAM", "expiry"], Date.now() + 60000] + ]); + } + + ctx.body = new stream.PassThrough(); + ctx.type = "application/json"; + + let c = 0; + ctx.body.write("[\n"); + await db.query(resource, filter, options, obj => { + ctx.body.write((c++ ? "," : "") + JSON.stringify(obj) + "\n"); + }); + ctx.body.end("]"); + + logger.accessInfo(log); + }); + + // CSV download + router.get(`/${resource}.csv`, async (ctx, next) => { + const options = { projection: {} }; + 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); + + const log = { + message: `Query ${resource} (CSV)`, + context: ctx, + filter: ctx.request.query.filter, + limit: options.limit, + skip: options.skip, + sort: options.sort + }; + + if (!ctx.state.authorizer.hasAccess(resource, 2)) { + logUnauthorizedWarning(log); + return void next(); + } + + const columns = JSON.parse(ctx.request.query.columns); + const now = Date.now(); + + for (const [k, v] of Object.entries(columns)) { + const e = expression.evaluate(expression.parse(v), null, now); + columns[k] = e; + for (const p of expression.extractParams(e)) options.projection[p] = 1; + } + + // Exclude temporary tasks and faults + if (resource === "tasks" || resource === "faults") { + filter = expression.and(filter, [ + "NOT", + ["<", ["PARAM", "expiry"], Date.now() + 60000] + ]); + } + + ctx.body = new stream.PassThrough(); + ctx.type = "text/csv"; + ctx.attachment( + `${resource}-${new Date(now).toISOString().replace(/[:.]/g, "")}.csv` + ); + + ctx.body.write( + Object.keys(columns).map(k => `"${k.replace(/"/, '""')}"`) + "\n" + ); + await db.query(resource, filter, options, obj => { + 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") { + 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[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; + }); + ctx.body.write(arr.join(",") + "\n"); + }); + ctx.body.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}"` + }; + + const filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; + if (!ctx.state.authorizer.hasAccess(resource, 2)) { + logUnauthorizedWarning(log); + return void next(); + } + + const res = await db.query(resource, filter); + + if (!res.length) return void 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}"` + }; + + const filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; + if (!ctx.state.authorizer.hasAccess(resource, 2)) { + logUnauthorizedWarning(log); + return void next(); + } + + const res = await db.query(resource, filter); + + if (!res.length) return void 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; + const filter = ["=", ["PARAM", RESOURCE_IDS[resource]], ctx.params.id]; + if (!authorizer.hasAccess(resource, 3)) { + logUnauthorizedWarning(log); + return void 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 void (ctx.status = 403); + } + + await apiFunctions.deleteResource(resource, ctx.params.id); + + 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 void next(); + } + + const obj = ctx.request.body; + + const validate = authorizer.getValidator(resource, obj); + if (!validate("put")) { + logUnauthorizedWarning(log); + return void (ctx.status = 403); + } + + const err = await apiFunctions.putResource(resource, id, obj); + + if (err) { + log.message += " failed"; + logger.accessWarn(log); + ctx.body = err; + return void (ctx.status = 400); + } + + logger.accessInfo(log); + + ctx.body = ""; + }); + } +} + +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 void next(); + } + + const 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 void (ctx.status = 403); + } + + const err = await apiFunctions.putFile(id, metadata, ctx.req); + + if (err) { + log.message += " failed"; + logger.accessWarn(log); + ctx.body = err; + return void (ctx.status = 400); + } + + log.metadata = metadata; + 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; + const filter = ["=", ["PARAM", "DeviceID.ID"], ctx.params.id]; + if (!authorizer.hasAccess("devices", 3)) { + logUnauthorizedWarning(log); + return void next(); + } + const devices = await db.query("devices", filter); + if (!devices.length) return void next(); + + const validate = authorizer.getValidator("devices", devices[0]); + for (const t of ctx.request.body) { + if (!validate("task", t)) { + logUnauthorizedWarning(log); + return void (ctx.status = 403); + } + } + + const 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; + const filter = ["=", ["PARAM", "DeviceID.ID"], ctx.params.id]; + if (!authorizer.hasAccess("devices", 3)) { + logUnauthorizedWarning(log); + return void 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 void (ctx.status = 403); + } + + await apiFunctions.updateTags(ctx.params.id, ctx.request.body); + + logger.accessInfo(log); + + ctx.body = ""; +}); + +router.get("/ping/:host", async ctx => { + ctx.body = await apiFunctions.ping(ctx.params.host); +}); + +module.exports = router; diff --git a/lib/ui/cluster.js b/lib/ui/cluster.js new file mode 100644 index 0000000..5c96b51 --- /dev/null +++ b/lib/ui/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/lib/ui/config.js b/lib/ui/config.js new file mode 100644 index 0000000..1f9ceb4 --- /dev/null +++ b/lib/ui/config.js @@ -0,0 +1,36 @@ +"use strict"; + +const fs = require("fs"); +const configFile = JSON.parse(fs.readFileSync("./config-ui.json")); + +const config = loadConfig(configFile); + +function getClientConfig() { + return Object.assign({}, config, { + server: null, + auth: null, + permissions: null + }); +} + +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; +} + +module.exports = Object.assign({}, config, { getClientConfig }); diff --git a/lib/ui/db.js b/lib/ui/db.js new file mode 100644 index 0000000..e3f66b9 --- /dev/null +++ b/lib/ui/db.js @@ -0,0 +1,212 @@ +"use strict"; + +const mongodb = require("mongodb"); +const config = require("./config"); +const mongodbFunctions = require("./mongodb-functions"); +const expression = require("../common/expression"); + +const CACHE_TTL = 300000; + +let _clientPromise = null; + +const RESOURCE_DB = { + devices: "genieacs", + faults: "genieacs", + tasks: "genieacs", + presets: "genieacs", + provisions: "genieacs", + virtualParameters: "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) => { + const CONNECTION_URL = config.server.mongodbConnectionUrl; + mongodb.MongoClient.connect( + CONNECTION_URL, + { useNewUrlParser: true }, + (err, client) => { + if (err) return void reject(err); + ensureIndexes(client); + resolve(client); + } + ); + }); + } + + 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; + filter = expression.evaluate(filter, 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 => { + const collection = client + .db(RESOURCE_DB[resource]) + .collection(RESOURCE_COLLECTION[resource] || resource); + 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) + .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.sort(s); + } + + if (!callback) { + cursor.toArray((err, docs) => { + 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)); + 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 { + cursor.forEach( + doc => { + if (resource === "devices") + doc = mongodbFunctions.flattenDevice(doc); + else if (resource === "faults") + doc = mongodbFunctions.flattenFault(doc); + else if (resource === "tasks") + doc = mongodbFunctions.flattenTask(doc); + else if (resource === "presets") + doc = mongodbFunctions.flattenPreset(doc); + else if (resource === "files") + doc = mongodbFunctions.flattenFile(doc); + callback(doc); + }, + err => { + if (err) reject(err); + else resolve(); + } + ); + } + }); + }); +} + +function count(resource, filter) { + let q; + filter = expression.evaluate(filter, 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(0); + } + + return new Promise((resolve, reject) => { + getClient().then(client => { + 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); + }); + }); + }); +} + +function disconnect() { + if (_clientPromise) { + _clientPromise.then(client => { + client.close(); + }); + } +} + +exports.query = query; +exports.count = count; +exports.cache = cache; +exports.disconnect = disconnect; diff --git a/lib/ui/logger.js b/lib/ui/logger.js new file mode 100644 index 0000000..648f9fd --- /dev/null +++ b/lib/ui/logger.js @@ -0,0 +1,249 @@ +"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(version) { + defaultMeta.hostname = os.hostname(); + defaultMeta.pid = process.pid; + defaultMeta.name = "genieacs-ui"; + defaultMeta.version = version; + + LOG_FILE = config.logger.logFile; + ACCESS_LOG_FILE = config.logger.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 = ""; + + const kv = []; + for (const 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/lib/ui/mongodb-functions.js b/lib/ui/mongodb-functions.js new file mode 100644 index 0000000..741783e --- /dev/null +++ b/lib/ui/mongodb-functions.js @@ -0,0 +1,550 @@ +"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 => { + 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 ( + ["=", "<>", ">", ">=", "<", "<=", "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") { + const 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])) { + 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") + 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])) { + 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]); + 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]; + const 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 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}`); + } + + 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.startsWith("DeviceID.")) s[`_deviceId._${k.slice(9)}`] = v; + else if (k === "Events.Inform") s["_lastInform"] = 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; +} + +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: [parseDate(tree), "xsd:dateTime"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + } else if (name === "_registered") { + output["Events.Registered"] = { + value: [parseDate(tree), "xsd:dateTime"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + } else if (name === "_lastBoot") { + output["Events.1_BOOT"] = { + value: [parseDate(tree), "xsd:dateTime"], + valueTimestamp: timestamp, + writable: false, + writableTimestamp: timestamp, + object: false, + objectTimestamp: timestamp + }; + } else if (name === "_lastBootstrap") { + output["Events.0_BOOTSTRAP"] = { + value: [parseDate(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 (const 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) childrenTimestamp = +(input["_timestamp"] || 1); + else if (+input["_timestamp"] > timestamp) + childrenTimestamp = +input["_timestamp"]; + + const attrs = {}; + if (tree["_value"] != null) { + attrs["value"] = [ + tree["_value"] instanceof Date ? +tree["_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; + } + + const r = root ? `${root}.${name}` : name; + output[r] = attrs; + + if (attrs["object"] || tree["object"] == null) + recursive(tree, r, output, childrenTimestamp); + } + } + + const newDevice = {}; + const timestamp = new Date(device["_lastInform"] || 1).getTime(); + recursive(device, "", newDevice, timestamp); + return newDevice; +} + +function flattenFault(fault) { + const f = Object.assign({}, fault); + if (f.timestamp) f.timestamp = +f.timestamp; + if (f.expiry) f.expiry = +f.expiry; + return f; +} + +function flattenTask(task) { + const t = Object.assign({}, task); + t._id = "" + t._id; + if (t.timestamp) t.timestamp = +t.timestamp; + if (t.expiry) t.expiry = +t.expiry; + return t; +} + +function mongoQueryToFilter(query) { + const expressions = []; + function recursive(_query) { + for (const [k, v] of Object.entries(_query)) { + if (k[0] === "$") { + 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 (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}`]]); + } + } else if (k.startsWith("Tags.")) { + expressions.push([ + v["$exists"] ? "IS NOT NULL" : "IS NULL", + ["PARAM", k] + ]); + } else if (typeof v === "object") { + if (Array.isArray(v)) throw new Error(`Invalid type`); + + for (const [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) { + const 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) { + const e = []; + for (const [k, v] of Object.entries(p.events)) e.push(v ? k : `-${k}`); + p.events = e.join(", "); + } + + const 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.provisionArgs = provision.args + ? JSON.stringify(provision.args).slice(1, -1) + : ""; + delete p.configurations; + return p; +} + +function flattenFile(file) { + const 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) { + const preset = Object.assign({}, data); + preset.precondition = preset.precondition + ? filterToMongoQuery(expression.parse(preset.precondition)) + : {}; + preset.precondition = JSON.stringify(preset.precondition); + + preset.weight = parseInt(preset.weight) || 0; + + const 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"); + + const 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; +} + +function preProcessProvision(data) { + return data.script; +} + +function preProcessVirtualParameters(data) { + return data.script; +} + +exports.processDeviceFilter = processDeviceFilter; +exports.processTasksFilter = processTasksFilter; +exports.processFaultsFilter = processFaultsFilter; +exports.filterToMongoQuery = filterToMongoQuery; +exports.processDeviceProjection = processDeviceProjection; +exports.processDeviceSort = processDeviceSort; +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/lib/ui/server.js b/lib/ui/server.js new file mode 100644 index 0000000..753e01d --- /dev/null +++ b/lib/ui/server.js @@ -0,0 +1,220 @@ +"use strict"; + +const http = require("http"); +const cluster = require("cluster"); +const Koa = require("koa"); +const Router = require("koa-router"); +const koaStatic = require("koa-static"); +const koaCompress = require("koa-compress"); +const koaBodyParser = require("koa-bodyparser"); +const koaJwt = require("koa-jwt"); +const jwt = require("jsonwebtoken"); + +const config = require("./config"); +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(); + +const JWT_SECRET = config.server.jwtSecret; +const JWT_COOKIE = "genieacs-ui-jwt"; + +function getPermissionSets(roles) { + const allPermissions = config.permissions; + 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", + "presets", + "provisions", + "virtualParameters", + "files" + ].includes(k) + ) { + if (v.filter == null || v.filter === "") v.filter = true; + else v.filter = expression.parse(v.filter); + } + } + return p; + }); + }); + 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; +}); + +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 log = { + message: "Log in", + context: ctx, + username: username + }; + + 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); + } + + function failure() { + ctx.status = 400; + ctx.body = "Incorrect username or password"; + log.message += " failed"; + logger.accessWarn(log); + } + + const roles = await authSimple(username, password); + + if (roles) return void success(roles, "simple"); + + failure(); +}); + +router.post("/logout", async ctx => { + ctx.cookies.set(JWT_COOKIE); // Delete cookie + ctx.body = ""; + + logger.accessInfo({ + message: "Log out", + context: 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()); + +router.get("/", async ctx => { + let permissionSets = []; + if (ctx.state.user && ctx.state.user.roles) + permissionSets = getPermissionSets(ctx.state.user.roles); + + ctx.body = ` + + + GenieACS + + + + + + + + + `; +}); + +koa.use( + koaCompress({ + flush: require("zlib").Z_SYNC_FLUSH + }) +); + +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 => { + // prevent new sockets to connect and close server eventually. + server.close(resolve); + setTimeout(resolve, timeout).unref(); + }); +} + +function exit() { + setTimeout(() => { + process.exit(1); + }, 30000).unref(); + + closeServer(20000).then(() => { + db.disconnect(); + if (cluster.worker) cluster.worker.disconnect(); + logger.close(); + }); +} + +process.on("unhandledRejection", err => { + throw err; +}); + +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/package.json b/package.json index eac82cc..9ce0912 100644 --- a/package.json +++ b/package.json @@ -19,26 +19,49 @@ "bin": "bin" }, "dependencies": { + "codemirror": "^5.40.0", + "jsonwebtoken": "^8.3.0", + "koa-bodyparser": "^4.2.1", + "koa-compress": "^3.0.0", + "koa-jwt": "^3.3.2", + "koa-router": "^7.4.0", + "koa-static": "^5.0.0", + "koa": "^2.5.2", "later": "^1.2.0", "libxmljs": "^0.19.5", "mongodb": "^3.1.10", - "seedrandom": "^2.4.4" + "parsimmon": "^1.12.0", + "seedrandom": "^2.4.4", + "yaml": "^1.0.0" }, "optionalDependencies": { "redis": "^2.6.5" }, "devDependencies": { + "babel-eslint": "^8.2.6", + "clean-webpack-plugin": "^0.1.19", + "copy-webpack-plugin": "^4.5.2", + "cssnano": "^4.0.5", "eslint": "^5.10.0", - "prettier": "^1.15.3" + "mithril": "^1.1.6", + "postcss-cli": "^6.0.0", + "postcss-cssnext": "^3.1.0", + "postcss-import": "^11.1.0", + "prettier": "^1.15.3", + "webpack": "^4.16.3", + "webpack-cli": "^3.1.0", + "webpack-node-externals": "^1.7.2" }, "engines": { "node": ">=8.14.0" }, "scripts": { "compile": "echo 'Compile step no longer required, skipping.'", - "configure": "if [ ! -f ./config/config.json ]; then cp ./config/config-sample.json ./config/config.json; fi", + "configure": "if [ ! -f ./config/config.json ]; then cp ./config/config-sample.json ./config/config.json; cp ./config/config-ui-sample.json ./config/config-ui.json; fi", "install": "npm run configure", "test": "node test", - "lint": "eslint --fix lib/*.js; prettier --write lib/*.js" + "lint": "eslint --fix lib ui; prettier --write \"lib/**/*.js\" \"ui/**/*.js\"", + "build-ui": "npm run lint;webpack;postcss -c postcss.config.js ui/css/app.css -o dist/public/app.css", + "start-ui": "cd dist && node cluster" } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..6183fc4 --- /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 0000000..1f16570 Binary files /dev/null and b/public/favicon.png differ 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/ui/.eslintrc.json b/ui/.eslintrc.json new file mode 100644 index 0000000..5438791 --- /dev/null +++ b/ui/.eslintrc.json @@ -0,0 +1,10 @@ +{ + "env": { + "browser": true + }, + "parser": "babel-eslint", + "parserOptions": { + "allowImportExportEverywhere": true, + "sourceType": "module" + } +} diff --git a/ui/admin-menu.js b/ui/admin-menu.js new file mode 100644 index 0000000..f11a546 --- /dev/null +++ b/ui/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/ui/app.js b/ui/app.js new file mode 100644 index 0000000..e66d6a6 --- /dev/null +++ b/ui/app.js @@ -0,0 +1,111 @@ +"use strict"; + +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 * 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 "../lib//common/authorizer"; +import * as notifications from "./notifications"; + +window.authorizer = new Authorizer(window.permissionSets); + +const adminPages = ["presets", "provisions", "virtualParameters", "files"]; + +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: () => { + lastRenderTimestamp = Date.now(); + let p; + if (state && state.error) p = m(errorPage.component, state); + else p = m(page.component, state); + fulfill(); + return m(layout, { page: pageName }, p); + } + }; + + component.onmatch = (args, requestedPath) => { + pageVisitTimestamp = Date.now(); + if (!page.init) { + state = null; + fulfill(); + return null; + } + + return new Promise(resolve => { + page + .init(args) + .then(st => { + if (!st) return void m.route.set("/"); + state = st; + resolve(); + 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(); + }); + }); + }; + + 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), + "/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/ui/autocomplete-compnent.js b/ui/autocomplete-compnent.js new file mode 100644 index 0000000..19a3c8e --- /dev/null +++ b/ui/autocomplete-compnent.js @@ -0,0 +1,154 @@ +"use strict"; + +class Autocomplete { + constructor(className, callback) { + this.callback = callback; + this.element = null; + this.hideTimeout = null; + this.visible = false; + this.default = null; + this.selection = 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; + const domRect = el.getBoundingClientRect(); + this.container.style.left = `${domRect.left + window.pageXOffset}px`; + this.container.style.width = `${domRect.width}px`; + this.container.style.top = `${domRect.bottom + window.pageYOffset}px`; + this.update(); + }); + + el.addEventListener("blur", () => { + if (this.element !== el) return; + if (!this.visible) return; + this.hide(); + }); + + el.addEventListener("keydown", e => { + if (this.element !== el) return; + if (e.key === "Escape") { + 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(); + }); + } + + hide() { + this.container.style.opacity = 0; + this.visible = false; + this.default = null; + this.selection = 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; + } + + if (this.selection != null) { + this.selection = + ((this.selection % suggestions.length) + suggestions.length) % + suggestions.length; + this.default = suggestions[this.selection]; + } else { + 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"); + selectedElement = e; + } + + const 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); + } + + // 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 + ); + } + }); + } +} + +export default Autocomplete; diff --git a/ui/components.js b/ui/components.js new file mode 100644 index 0000000..492fa92 --- /dev/null +++ b/ui/components.js @@ -0,0 +1,32 @@ +"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"; +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"; +import tags from "./components/tags"; +import ping from "./components/ping"; +import deviceLink from "./components/device-link"; + +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 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 if (name === "ping") return ping; + else if (name === "device-link") return deviceLink; + else throw new Error(`No such component '${name}'`); +} + +export { get }; diff --git a/ui/components/all-parameters.js b/ui/components/all-parameters.js new file mode 100644 index 0000000..082306b --- /dev/null +++ b/ui/components/all-parameters.js @@ -0,0 +1,133 @@ +"use strict"; + +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, "\\$&"); +} + +const component = { + view: vnode => { + const device = vnode.attrs.device; + + const search = m("input", { + type: "text", + 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); + } + }); + + const instanceRegex = /\.[0-9]+$/; + let re; + if (vnode.state.searchString) { + const 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 => { + const p = device[k]; + const val = []; + const attrs = {}; + if (re) { + const 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", m(longTextComponent, { text: k })), + m("td.right", val) + ); + }); + + 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))) + ); + } +}; + +export default component; diff --git a/ui/components/container.js b/ui/components/container.js new file mode 100644 index 0000000..6c28ad2 --- /dev/null +++ b/ui/components/container.js @@ -0,0 +1,28 @@ +"use strict"; + +import m from "mithril"; +import * as components from "../components"; +import * as store from "../store"; +import * as expression from "../../lib/common/expression"; +import memoize from "../../lib/common/memoize"; + +const memoizedParse = memoize(expression.parse); + +const component = { + view: vnode => { + if (vnode.attrs.filter) { + const filter = memoizedParse(vnode.attrs.filter); + if (!store.evaluateExpression(filter, vnode.attrs.device)) return null; + } + + 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 (vnode.attrs.element) return m(vnode.attrs.element, children); + else return children; + } +}; + +export default component; diff --git a/ui/components/device-actions.js b/ui/components/device-actions.js new file mode 100644 index 0000000..e2df56b --- /dev/null +++ b/ui/components/device-actions.js @@ -0,0 +1,90 @@ +"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.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", + { + title: "Delete device", + onclick: () => { + if (!confirm("Deleting this device. Are you sure?")) return; + const deviceId = device["DeviceID.ID"].value[0]; + + store + .deleteResource("devices", deviceId) + .then(() => { + notifications.push("success", `${deviceId}: Device deleted`); + m.route.set("/devices"); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } + }, + "Delete" + ) + ); + + return m(".actions-bar", buttons); + } +}; + +export default component; diff --git a/ui/components/device-faults.js b/ui/components/device-faults.js new file mode 100644 index 0000000..02576a8 --- /dev/null +++ b/ui/components/device-faults.js @@ -0,0 +1,71 @@ +"use strict"; + +import m from "mithril"; +import * as store from "../store"; +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", [ + "AND", + [">", ["PARAM", "_id"], `${deviceId}:`], + ["<", ["PARAM", "_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 (const f of faults.value) { + rows.push([ + m("td", f["channel"]), + m("td", f["code"]), + m("td", f["message"]), + m("td", f["retries"]), + m("td", new Date(f["timestamp"]).toLocaleString()), + 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); + store.fulfill(Date.now(), Date.now()); + }); + } + }, + "✕" + ) + ) + ]); + } + + 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("table.table", thead, tbody); + } +}; + +export default component; diff --git a/ui/components/device-link.js b/ui/components/device-link.js new file mode 100644 index 0000000..6cf9f76 --- /dev/null +++ b/ui/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/ui/components/overview-dot.js b/ui/components/overview-dot.js new file mode 100644 index 0000000..f2f003a --- /dev/null +++ b/ui/components/overview-dot.js @@ -0,0 +1,42 @@ +"use strict"; + +import m from "mithril"; +import config from "../config"; +import * as store from "../store"; +import * as expression from "../../lib/common/expression"; +import memoize from "../../lib/common/memoize"; + +const memoizedParse = memoize(expression.parse); + +const CHARTS = config.ui.overview.charts; + +const component = { + view: vnode => { + const device = vnode.attrs.device; + const chart = CHARTS[vnode.attrs.chart]; + for (const slice of Object.values(chart.slices)) { + const filter = memoizedParse(slice.filter); + if (store.evaluateExpression(filter, 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}`); + } + } + return null; + } +}; + +export default component; diff --git a/ui/components/parameter-list.js b/ui/components/parameter-list.js new file mode 100644 index 0000000..9f02629 --- /dev/null +++ b/ui/components/parameter-list.js @@ -0,0 +1,37 @@ +"use strict"; + +import m from "mithril"; +import * as components from "../components"; +import memoize from "../../lib/common/memoize"; + +const getChildAttrs = memoize((attrs, device) => + Object.assign({}, attrs, { device: device }) +); + +const component = { + view: vnode => { + const device = vnode.attrs.device; + + 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", p) + ); + }); + + return m("table.parameter-list", rows); + } +}; + +export default component; diff --git a/ui/components/parameter-table.js b/ui/components/parameter-table.js new file mode 100644 index 0000000..f749a39 --- /dev/null +++ b/ui/components/parameter-table.js @@ -0,0 +1,143 @@ +"use strict"; + +import m from "mithril"; +import * as components from "../components"; +import * as taskQueue from "../task-queue"; +import * as store from "../store"; +import * as expression from "../../lib/common/expression"; +import * as expressionParser from "../../lib/common/expression-parser"; +import memoize from "../../lib/common/memoize"; + +const memoizeParse = memoize(expression.parse); + +const component = { + oninit: vnode => { + 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]; + vnode.state.parameters = Object.values(vnode.attrs.childParameters).map( + parameter => { + return Object.assign({}, parameter, { + parameter: expression.parse(parameter.parameter) + }); + } + ); + }, + view: vnode => { + const device = vnode.attrs.device; + const object = store.evaluateExpression(vnode.state.object, device); + const parameters = vnode.state.parameters; + + if (!device[object]) return null; + + const instances = new Set(); + const prefix = `${object}.`; + for (const p in device) { + if (p.startsWith(prefix)) { + 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 (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") + return ["PARAM", ["||", i, ".", e[1]]]; + return e; + }); + return m( + "td", + m( + components.get(p.type || "parameter"), + Object.assign({}, p, { + device: device, + parameter: expressionParser.stringify(param), + label: "" + }) + ) + ); + }); + + if (device[i].writable === true) { + row.push( + m( + "td", + m( + "button", + { + title: "Delete this instance", + onclick: () => { + taskQueue.queueTask({ + name: "deleteObject", + device: device["DeviceID.ID"].value[0], + objectName: i + }); + } + }, + "✕" + ) + ) + ); + } + 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( + "tr", + m("td", { colspan: headers.length }), + m( + "td", + m( + "button", + { + title: "Create a new instance", + onclick: () => { + taskQueue.queueTask({ + name: "addObject", + device: device["DeviceID.ID"].value[0], + objectName: object + }); + } + }, + "🞢" + ) + ) + ) + ); + } + + let label; + if (vnode.attrs.label) label = m("h2", vnode.attrs.label); + + return [label, m("table.table", thead, m("tbody", rows))]; + } +}; + +export default component; diff --git a/ui/components/parameter.js b/ui/components/parameter.js new file mode 100644 index 0000000..fdd79b1 --- /dev/null +++ b/ui/components/parameter.js @@ -0,0 +1,95 @@ +"use strict"; + +import m from "mithril"; +import * as taskQueue from "../task-queue"; +import * as store from "../store"; +import * as expression from "../../lib/common/expression"; +import memoize from "../../lib/common/memoize"; +import timeAgo from "../timeago"; +import longTextComponent from "../long-text-component"; + +const memoizedParse = memoize(expression.parse); + +const evaluateParam = memoize((exp, obj, now) => { + let timestamp = now; + 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; + }); + + 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 { value, timestamp, parameter } = evaluateParam( + vnode.attrs.parameter, + vnode.attrs.device, + store.getTimestamp() + ); + + if (value == null) return null; + + let edit; + if (device[parameter] && device[parameter].writable) { + edit = m( + "button", + { + title: "Edit parameter value", + onclick: () => { + taskQueue.stageSpv({ + name: "setParameterValues", + device: device["DeviceID.ID"].value[0], + parameterValues: [ + [ + parameter, + device[parameter].value[0], + device[parameter].value[1] + ] + ] + }); + } + }, + "✎" + ); + } + + const el = m(longTextComponent, { text: `${value}` }); + + return m( + "span", + { + class: "parameter-value", + onmouseover: e => { + e.redraw = false; + // 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)})`; + } + } + }, + el, + edit + ); + } +}; + +export default component; diff --git a/ui/components/ping.js b/ui/components/ping.js new file mode 100644 index 0000000..93146c5 --- /dev/null +++ b/ui/components/ping.js @@ -0,0 +1,56 @@ +"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 => { + const 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 + }); + }; + + 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.avg != null) + status = `${Math.trunc(vnode.state.ping.avg)} ms`; + else status = "Unreachable"; + } + + return m("div", `Pinging ${vnode.state.host}: ${status}`); + } + return null; + } +}; + +export default component; diff --git a/ui/components/summon-button.js b/ui/components/summon-button.js new file mode 100644 index 0000000..037740e --- /dev/null +++ b/ui/components/summon-button.js @@ -0,0 +1,76 @@ +"use strict"; + +import m from "mithril"; +import * as taskQueue from "../task-queue"; +import * as store from "../store"; +import * as notifications from "../notifications"; +import * as expression from "../../lib/common/expression"; +import memoize from "../../lib/common/memoize"; + +const memoizedParse = memoize(expression.parse); + +const component = { + 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) + .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, + device: device["DeviceID.ID"].value[0] + }; + + taskQueue + .commit( + [task], + (deviceId, err, connectionRequestStatus, tasks2) => { + if (err) { + notifications.push("error", `${deviceId}: ${err.message}`); + return; + } + + for (const 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()); + }); + } + }, + "Summon" + ); + } +}; + +export default component; diff --git a/ui/components/tags.js b/ui/components/tags.js new file mode 100644 index 0000000..7a12881 --- /dev/null +++ b/ui/components/tags.js @@ -0,0 +1,84 @@ +"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 (const 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; + const deviceId = device["DeviceID.ID"].value[0]; + store + .updateTags(deviceId, { [tag]: false }) + .then(() => { + e.target.disabled = false; + notifications.push("success", `${deviceId}: Tags updated`); + store.fulfill(0, Date.now()); + }) + .catch(err => { + e.target.disabled = false; + notifications.push("error", `${deviceId}: ${err.message}`); + }); + } + }, + "✕" + ) + ) + ), + m( + "span.tag.writable", + m( + "button", + { + 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; + return; + } + + store + .updateTags(deviceId, { [tag]: true }) + .then(() => { + e.target.disabled = false; + notifications.push("success", `${deviceId}: Tags updated`); + store.fulfill(0, Date.now()); + }) + .catch(err => { + e.target.disabled = false; + notifications.push("error", `${deviceId}: ${err.message}`); + }); + } + }, + "🞢" + ) + ) + ); + } +}; + +export default component; diff --git a/ui/config.js b/ui/config.js new file mode 100644 index 0000000..8a1dfcf --- /dev/null +++ b/ui/config.js @@ -0,0 +1,3 @@ +"use strict"; + +export default window.clientConfig; diff --git a/ui/css/app.css b/ui/css/app.css new file mode 100644 index 0000000..48e6c0f --- /dev/null +++ b/ui/css/app.css @@ -0,0 +1,685 @@ +@import "normalize.css"; +@import "codemirror/lib/codemirror.css"; + +:root { + --base-font-size: 13px; + --base-line-height: 16px; + + --color1: #ebe0bf; + --color2: #b9b098; + --color3: #f2ebd5; + --color4: #046380; + --color5: #002f2f; + + --fade: 0.2s; + + --disabled: #666; + + --status-red: hsl(0, 80%, 80%); + --status-green: hsl(125, 80%, 80%); + --status-gray: hsl(58, 3%, 80%); +} + +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); + } + /* text-decoration does not propagate to inline-block children */ + & span { + text-decoration: inherit; + } +} + +strong, +b { + font-weight: bold; +} + +i { + font-style: italic; +} + +@keyframes pending-blink { + from { + background-color: color(var(--status-green) a(90%)); + } + to { + background-color: transparent; + } +} + +#header { + background-color: #f2eddf; + border-bottom: 1px solid var(--color1); + overflow: visible; + position: relative; + + & > .drawer-wrapper { + position: fixed; + z-index: 10; + width: 720px; + margin-left: -360px; + top: 0; + left: 50%; + & > .drawer { + background-color: rgba(255, 255, 255, 0.9); + 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 { + display: flex; + & > span { + margin-right: 1em; + padding-left: 0.2em; + padding-right: 0.2em; + border-radius: 0.4em; + } + & > .pending.active { + &.active { + animation: 0.5s linear 0s infinite alternate pending-blink; + } + } + & > .stale.active { + background-color: color(var(--status-gray) a(90%)); + } + & > .fault.active { + background-color: color(var(--status-red) a(90%)); + } + & > .queued.active { + font-weight: bold; + } + + & > .actions { + flex-grow: 1; + text-align: right; + } + } + + & > .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; + } + } + + & > .queue { + & > .pending { + animation: 0.5s linear 0s infinite alternate pending-blink; + border-radius: 0.4em; + } + & > .stale { + background-color: color(var(--status-gray) a(90%)); + border-radius: 0.4em; + } + & > .fault { + background-color: color(var(--status-red) a(90%)); + border-radius: 0.4em; + } + & .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; + } + & > div { + display: flex; + & > .actions { + flex-grow: 1; + text-align: right; + } + } + } + } + & > .notifications-wrapper { + & > .notification { + padding: 0.3em; + left: 0; + right: 0; + 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: color(var(--status-red) a(90%)); + border: 1px solid var(--status-red); + } + &.success { + background-color: color(var(--status-green) a(90%)); + border: 1px solid var(--status-green); + } + } + } + } + & > 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; + 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 { + 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; + } + + & > .user-menu { + float: right; + margin: 5px; + } +} + +#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; + overflow: auto; + margin: 10px; + min-height: 250px; +} + +table.table { + border-spacing: 0; + text-align: left; + display: block; + overflow-x: auto; + + & table.table { + font-size: 90%; + } + + & th { + white-space: nowrap; + 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; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 400px; + } + + & 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 { + color: var(--color4); + font-size: 130%; + font-weight: bold; + margin-top: 1em; + margin-bottom: 0.5em; +} + +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; +} + +p.error { + color: #a00; +} + +/* Remove button padding in FF */ +input::-moz-focus-inner { + border: 0; + padding: 0; +} + +input[type="button"], +button { + background: transparent; + border: 0; + color: var(--color4); + border-radius: 4px; + transition: background-color var(--fade), color var(--fade); + &.primary { + border: 1px solid var(--color1); + background-color: var(--color3); + } + &.critical { + border: 1px solid var(--color1); + background-color: var(--color3); + color: #700; + } + &:hover:enabled { + background-color: var(--color4); + color: var(--color3); + } + &:disabled { + color: var(--disabled); + cursor: not-allowed; + } +} + +.CodeMirror, +textarea, +select, +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 { + font-size: 90%; + width: 30em; + margin: 2px; + padding: 2px; + } +} + +.page-overview { + & > h1 { + text-align: center; + } +} + +.overview-chart-group { + display: flex; + justify-content: center; + + & h2 { + text-align: center; + } +} + +table.parameter-list { + border-spacing: 0; + text-align: left; + & th { + padding: 0; + padding-right: 1em; + } +} + +.overview-dot { + & > svg { + margin-bottom: -0.125em; + margin-left: 0.2em; + margin-right: 0.2em; + & > circle { + stroke: var(--color5); + stroke-width: 0.08em; + } + } +} + +span.inform { + & > * { + margin-right: 0.5em; + } +} + +.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; + } + } + } +} + +span.na { + color: var(--disabled); +} + +.all-parameters { + & > input { + font-family: monospace; + margin-bottom: 2px; + padding: 4px; + width: 600px; + } + + & > .parameter-list { + font-family: monospace; + overflow-y: scroll; + height: 200px; + + & > 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); + &:hover { + background-color: var(--color3); + } + + & > td.left { + & > * { + max-width: 100%; + } + } + & > td.right { + text-align: right; + padding-right: 0.5em; + } + } + } + } + } +} + +.container-right { + float: right; + clear: right; +} + +.container-full-width { + clear: both; +} + +.actions-bar { + margin-top: 10px; + & > button { + 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; + } +} + +.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: 90vw; + max-height: 90vh; + background-color: white; + border: 1px solid var(--color1); + border-radius: 10px; + padding: 10px; + box-shadow: 0 0 15px #aaa; + overflow: auto; + } +} + +.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; + + &.selected, + &:hover { + background-color: var(--color3); + } + } +} + +span.long-text { + display: inline-block; + 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 { + cursor: pointer; + &:hover { + text-decoration: underline; + text-decoration-style: dotted; + } +} + +textarea.long-text { + font-family: monospace; + min-width: 10vw; + min-height: 10vh; +} + +span.parameter-value { + & > span:empty::before { + content: "blank"; + color: #aaa; + } +} diff --git a/ui/css/normalize.css b/ui/css/normalize.css new file mode 100644 index 0000000..04beed5 --- /dev/null +++ b/ui/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/ui/device-page.js b/ui/device-page.js new file mode 100644 index 0000000..809c2c4 --- /dev/null +++ b/ui/device-page.js @@ -0,0 +1,45 @@ +"use strict"; + +import m from "mithril"; + +import config from "./config"; +import * as store from "./store"; +import * as components from "./components"; + +function init(args) { + 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: ["=", ["PARAM", "DeviceID.ID"], args.id] + }); +} + +const component = { + view: vnode => { + document.title = `${vnode.attrs.deviceId} - Devices - GenieACS`; + + const dev = store.fetch("devices", vnode.attrs.deviceFilter).value; + if (!dev.length) return "Loading"; + const conf = config.ui.device; + const cmps = []; + + 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), + m("span", dev[0]["InternetGatewayDevice.DeviceInfo.SerialNumber"]), + cmps + ]; + } +}; + +export { init, component }; diff --git a/ui/devices-page.js b/ui/devices-page.js new file mode 100644 index 0000000..8cc1d76 --- /dev/null +++ b/ui/devices-page.js @@ -0,0 +1,408 @@ +"use strict"; + +import m from "mithril"; + +import config from "./config"; +import filterComponent from "./filter-component"; +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 "../lib/common/expression"; +import memoize from "../lib/common/memoize"; +import * as smartQuery from "./smart-query"; +import * as expressionParser from "../lib/common/expression-parser"; + +const PAGE_SIZE = config.ui.pageSize || 10; + +const memoizedParse = memoize(expression.parse); +const memoizedJsonParse = memoize(JSON.parse); +const memoizedGetSortable = memoize(str => { + const expressionParams = expression.extractParams(expression.parse(str)); + if (expressionParams.length === 1) { + const param = expression.evaluate(expressionParams[0]); + if (typeof param === "string") return param; + } + return null; +}); + +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 }) +); + +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)) + return void 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, sort }); + }); +} + +function renderTable( + devicesResponse, + parameters, + total, + selected, + showMoreCallback, + downloadUrl, + sort, + onSortChange +) { + const devices = devicesResponse.value; + const selectAll = m("input", { + type: "checkbox", + checked: devices.length && selected.size === devices.length, + onchange: e => { + 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 + }); + + 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; + + let symbol = "\u2981"; + if (sort[_param] > 0) symbol = "\u2bc6"; + else if (sort[_param] < 0) symbol = "\u2bc5"; + + const 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)); + } + } + + const rows = []; + for (const device of devices) { + const 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", + { + 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 = getChildAttrs(p, device); + 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")) + ); + } + + const footerElements = []; + if (total != null) footerElements.push(`${devices.length}/${total}`); + else footerElements.push(`${devices.length}`); + + footerElements.push( + m( + "button", + { + title: "Show more devices", + onclick: showMoreCallback, + disabled: devices.length >= total || !devicesResponse.fulfilled + }, + "More" + ) + ); + + if (downloadUrl) { + footerElements.push( + m("a.download-csv", { href: downloadUrl, download: "" }, "Download") + ); + } + + const tfoot = m( + "tfoot", + m("tr", m("td", { colspan: labels.length }, footerElements)) + ); + + return m("table.table.highlight", [ + m("thead", m("tr", labels)), + m("tbody", rows), + tfoot + ]); +} + +function renderActions(selected) { + const buttons = []; + + buttons.push( + m( + "button.primary", + { + title: "Reboot selected devices", + disabled: !selected.size, + onclick: () => { + for (const 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 (const 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; + + let counter = 1; + for (const 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" + ) + ); + + 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; + + let counter = 1; + for (const 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" + ) + ); + + 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; + + let counter = 1; + for (const 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" + ) + ); + + return m(".actions-bar", buttons); +} + +const component = { + view: vnode => { + document.title = "Devices - GenieACS"; + + function showMore() { + vnode.state.showCount = (vnode.state.showCount || PAGE_SIZE) + PAGE_SIZE; + m.redraw(); + } + + function onFilterChanged(filter) { + const ops = { filter }; + if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; + m.route.set("/devices", ops); + } + + function onSortChange(sort) { + const 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) : {}; + let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + filter = unpackSmartQuery(filter); + + const devs = store.fetch("devices", filter, { + limit: vnode.state.showCount || PAGE_SIZE, + sort: sort + }); + const count = store.count("devices", filter); + + 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( + vnode.attrs.filter, + vnode.attrs.indexParameters + ); + + return [ + m("h1", "Listing devices"), + m(filterComponent, { + resource: "devices", + filter: vnode.attrs.filter, + onChange: onFilterChanged + }), + renderTable( + devs, + vnode.attrs.indexParameters, + count.value, + selected, + showMore, + downloadUrl, + sort, + onSortChange + ), + renderActions(selected) + ]; + } +}; + +export { init, component }; diff --git a/ui/drawer-component.js b/ui/drawer-component.js new file mode 100644 index 0000000..ada2109 --- /dev/null +++ b/ui/drawer-component.js @@ -0,0 +1,498 @@ +"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 renderStagingSpv(task, queueFunc, cancelFunc) { + 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]; +} + +function renderStagingDownload(task) { + task.invalid = !task.fileName || !task.fileType; + const files = store.fetch("files", true); + 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 (const 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) { + const elements = []; + for (const s of staging) { + const queueFunc = () => { + staging.delete(s); + taskQueue.queueTask(s); + }; + const cancelFunc = () => { + staging.delete(s); + }; + + 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, disabled: s.invalid }, + "Queue" + ); + const cancel = m( + "button", + { title: "Cancel edit", onclick: cancelFunc }, + "Cancel" + ); + + elements.push(m(".staging", elms, m("div.actions", queue, cancel))); + } + return elements; +} + +function renderQueue(queue) { + const details = []; + const devices = {}; + for (const t of queue) { + devices[t.device] = devices[t.device] || []; + devices[t.device].push(t); + } + + for (const [k, v] of Object.entries(devices)) { + details.push(m("strong", k)); + for (const t of v) { + const actions = []; + + if (t.status === "fault" || t.status === "stale") { + actions.push( + m( + "button", + { + title: "Retry this task", + onclick: () => { + taskQueue.queueTask(t); + } + }, + "↺" + ) + ); + } + + actions.push( + m( + "button", + { + title: "Remove this task", + onclick: () => { + taskQueue.deleteTask(t); + } + }, + "✕" + ) + ); + + if (t.name === "setParameterValues") { + details.push( + m( + `div.${t.status}`, + 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}`, + m("span", "Refresh ", mparam(t.parameterName)), + m(".actions", actions) + ) + ); + } else if (t.name === "reboot") { + details.push(m(`div.${t.status}`, "Reboot", m(".actions", actions))); + } else if (t.name === "factoryReset") { + details.push( + m(`div.${t.status}`, "Factory reset", m(".actions", actions)) + ); + } else if (t.name === "addObject") { + details.push( + m( + `div.${t.status}`, + m("span", "Add ", mparam(t.objectName)), + m(".actions", actions) + ) + ); + } else if (t.name === "deleteObject") { + details.push( + m( + `div.${t.status}`, + m("span", "Delete ", mparam(t.objectName)), + m(".actions", actions) + ) + ); + } else if (t.name === "getParameterValues") { + details.push( + m( + `div.${t.status}`, + `Refresh ${t.parameterNames.length} parameters`, + 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))); + } + } + } + + return details; +} + +function renderNotifications(notifs) { + const notificationElements = []; + + for (const 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; + const notificationElements = renderNotifications(notifs); + const stagingElements = renderStaging(staging); + const queueElements = renderQueue(queue); + + function repositionNotifications() { + let top = 10; + for (const 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 (const s of stagingElements) + height = Math.max(height, s.dom.offsetTop + s.dom.offsetHeight); + } 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 (const t of queue) statusCount[t.status] += 1; + + const actions = m( + ".actions", + m( + "button.primary", + { + title: "Commit queued tasks", + disabled: !statusCount.queued, + onclick: () => { + const tasks = Array.from(taskQueue.getQueue()).filter( + t => t.status === "queued" + ); + taskQueue + .commit( + tasks, + (deviceId, err, connectionRequestStatus, tasks2) => { + if (err) { + notifications.push( + "error", + `${deviceId}: ${err.message}` + ); + return; + } + + if (connectionRequestStatus !== "OK") { + notifications.push( + "error", + `${deviceId}: ${connectionRequestStatus}` + ); + return; + } + + for (const 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()); + }); + } + }, + "Commit" + ), + m( + "button", + { + title: "Clear tasks", + onclick: taskQueue.clear, + disabled: !queueElements.length + }, + "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.fault", + { class: statusCount.fault ? "active" : "" }, + `Fault: ${statusCount.fault}` + ), + 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/ui/dynamic-loader.js b/ui/dynamic-loader.js new file mode 100644 index 0000000..baf7fed --- /dev/null +++ b/ui/dynamic-loader.js @@ -0,0 +1,36 @@ +"use strict"; + +let codeMirror; +let yaml; + +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); + }); +} + +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/ui/error-page.js b/ui/error-page.js new file mode 100644 index 0000000..afe4f49 --- /dev/null +++ b/ui/error-page.js @@ -0,0 +1,12 @@ +"use strict"; + +import m from "mithril"; + +const component = { + view: function(vnode) { + document.title = "Error! - GenieACS"; + return m("p.error", vnode.attrs.error); + } +}; + +export { component }; diff --git a/ui/faults-page.js b/ui/faults-page.js new file mode 100644 index 0000000..cfe6f78 --- /dev/null +++ b/ui/faults-page.js @@ -0,0 +1,298 @@ +"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 "../lib/common/expression"; +import memoize from "../lib/common/memoize"; +import * as smartQuery from "./smart-query"; +import * as expressionParser from "../lib/common/expression-parser"; +import { loadYaml, yaml } from "./dynamic-loader"; +import longTextComponent from "./long-text-component"; + +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: "message", label: "Message" }, + { id: "detail", label: "Detail", unsortable: true }, + { id: "retries", label: "Retries" }, + { id: "timestamp", label: "Timestamp" } +]; + +const getDownloadUrl = memoize(filter => { + 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, + columns: JSON.stringify(cols) + })}`; +}); + +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( + new Error("You are not authorized to view this page") + ); + } + + const sort = args.sort; + const filter = args.filter; + return new Promise((resolve, reject) => { + loadYaml() + .then(() => { + resolve({ filter, sort }); + }) + .catch(reject); + }); +} + +function renderTable( + faultsResponse, + total, + selected, + showMoreCallback, + downloadUrl, + sort, + onSortChange +) { + const faults = faultsResponse.value; + const selectAll = m("input", { + type: "checkbox", + checked: faults.length && selected.size === faults.length, + onchange: e => { + 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 (const attr of attributes) { + const label = attr.label; + + 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"; + else if (sort[attr.id] < 0) symbol = "\u2bc5"; + + const 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])); + } + + const rows = []; + for (const f of faults) { + const 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"])}`; + + const tds = [m("td", checkbox)]; + for (const attr of attributes) { + 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 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( + 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"]); + } + }, + tds + ) + ); + } + + if (!rows.length) + rows.push(m("tr.empty", m("td", { colspan: 7 }, "No faults"))); + + const 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" + ) + ); + + if (downloadUrl) { + footerElements.push( + m("a.download-csv", { href: downloadUrl, download: "" }, "Download") + ); + } + + const 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); + store.fulfill(0, Date.now()); + }); + } + }, + "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 || PAGE_SIZE) + PAGE_SIZE; + m.redraw(); + } + + function onFilterChanged(filter) { + const ops = { filter }; + if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; + m.route.set("/faults", ops); + } + + function onSortChange(sort) { + const 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) : {}; + let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + filter = unpackSmartQuery(filter); + + const faults = store.fetch("faults", filter, { + limit: vnode.state.showCount || PAGE_SIZE, + sort: sort + }); + const count = store.count("faults", filter); + + 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); + + return [ + m("h1", "Listing faults"), + m(filterComponent, { + resource: "faults", + filter: vnode.attrs.filter, + onChange: onFilterChanged + }), + renderTable( + faults, + count.value, + selected, + showMore, + downloadUrl, + sort, + onSortChange + ) + ]; + } +}; + +export { init, component }; diff --git a/ui/files-page.js b/ui/files-page.js new file mode 100644 index 0000000..561410a --- /dev/null +++ b/ui/files-page.js @@ -0,0 +1,384 @@ +"use strict"; + +import m from "mithril"; + +import config from "./config"; +import filterComponent from "./filter-component"; +import * as store from "./store"; +import * as expression from "../lib/common/expression"; +import * as notifications from "./notifications"; +import memoize from "../lib/common/memoize"; +import putFormComponent from "./put-form-component"; +import * as overlay from "./overlay"; +import * as smartQuery from "./smart-query"; +import * as expressionParser from "../lib/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" } +]; + +const 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) => { + const object = Object.assign({}, _object); + if (action === "save") { + const file = object["file"] ? object["file"][0] : null; + delete object["file"]; + + if (!file) return void resolve({ file: "File not selected" }); + + const id = file.name; + + store + .resourceExists("files", id) + .then(exists => { + if (exists) { + store.fulfill(0, Date.now()); + return void resolve({ file: "File already exists" }); + } + const 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 => 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 => { + const cols = {}; + for (const 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 (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 (const attr of attributes) { + const label = attr.label; + + let direction = 1; + + let symbol = "\u2981"; + if (sort[attr.id] > 0) symbol = "\u2bc6"; + else if (sort[attr.id] < 0) symbol = "\u2bc5"; + + const 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])); + } + + const rows = []; + for (const file of files) { + const 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; + } + }); + + 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( + "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"))); + + const 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") + ); + } + + const 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); + store.fulfill(0, Date.now()); + }); + } + }, + "Delete" + ) + ]; + + if (window.authorizer.hasAccess("files", 3)) { + buttons.push( + m( + "button.primary", + { + title: "Create new file", + onclick: () => { + const cb = () => { + return m( + putFormComponent, + Object.assign( + { + actionHandler: (action, object) => { + 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 + ) + ); + }; + 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) { + const ops = { filter }; + if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; + 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(m.route.get(), ops); + } + + const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; + let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + filter = unpackSmartQuery(filter); + + const files = store.fetch("files", filter, { + limit: vnode.state.showCount || PAGE_SIZE, + sort: sort + }); + + const count = store.count("files", filter); + + 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); + + 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/ui/filter-component.js b/ui/filter-component.js new file mode 100644 index 0000000..b805a8f --- /dev/null +++ b/ui/filter-component.js @@ -0,0 +1,101 @@ +"use strict"; + +import m from "mithril"; +import * as expression from "../lib/common/expression"; +import memoize from "../lib/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)) { + const k = f.split(":", 1)[0]; + const 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 (const ff of f.slice(1)) list.push(stringifyFilter(ff)); + else list.push(stringifyFilter(f)); + + list.push(""); + return list; +}); + +const component = { + view: vnode => { + if (!vnode.state.filterList || vnode.attrs.filter !== vnode.state.filter) { + vnode.state.filterInvalid = 0; + vnode.state.filter = vnode.attrs.filter; + vnode.state.filterList = splitFilter(vnode.attrs.filter); + } + + function onChange() { + vnode.state.filterInvalid = 0; + vnode.state.filterList = vnode.state.filterList.filter(f => f); + let filter = vnode.state.filterList.map((f, idx) => { + try { + return parseFilter(f); + } catch (err) { + vnode.state.filterInvalid |= 1 << idx; + } + return null; + }); + vnode.state.filterList.push(""); + + 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( + "div.filter", + [m("b", "Filter")].concat( + vnode.state.filterList.map((fltr, idx) => { + return m("input", { + type: "text", + 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); + } + }); + }) + ) + ); + } +}; + +export default component; diff --git a/ui/layout.js b/ui/layout.js new file mode 100644 index 0000000..feb59ed --- /dev/null +++ b/ui/layout.js @@ -0,0 +1,38 @@ +"use strict"; + +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: group || vnode.attrs.page }), + m(drawerComponent) + ]), + m( + "#content-wrapper", + sideMenu, + m("#content", { class: `page-${vnode.attrs.page}` }, [vnode.children]) + ), + overlay.render() + ]; + } +}; + +export default layout; diff --git a/ui/login-page.js b/ui/login-page.js new file mode 100644 index 0000000..ee12d77 --- /dev/null +++ b/ui/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/ui/long-text-component.js b/ui/long-text-component.js new file mode 100644 index 0000000..07afb32 --- /dev/null +++ b/ui/long-text-component.js @@ -0,0 +1,56 @@ +"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"; + + 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 + }, + text + ); + } +}; + +export default component; diff --git a/ui/menu.js b/ui/menu.js new file mode 100644 index 0000000..2a2e196 --- /dev/null +++ b/ui/menu.js @@ -0,0 +1,58 @@ +"use strict"; + +import m from "mithril"; + +const menu = { + view: vnode => { + const active = { [vnode.attrs.page]: "active" }; + + const 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") + ) + ); + } + + 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)); + } +}; + +export default menu; diff --git a/ui/notifications.js b/ui/notifications.js new file mode 100644 index 0000000..48c3827 --- /dev/null +++ b/ui/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/ui/overlay.js b/ui/overlay.js new file mode 100644 index 0000000..59f0331 --- /dev/null +++ b/ui/overlay.js @@ -0,0 +1,66 @@ +"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 (overlayCallback && e.keyCode === 27 && close(overlayCallback)) m.redraw(); +}); + +window.addEventListener("popstate", () => { + if (close(overlayCallback)) m.redraw(); +}); + +export { open, close, render }; diff --git a/ui/overview-page.js b/ui/overview-page.js new file mode 100644 index 0000000..f769cb1 --- /dev/null +++ b/ui/overview-page.js @@ -0,0 +1,68 @@ +"use strict"; + +import m from "mithril"; +import config from "./config"; +import * as store from "./store"; +import pieChartComponent from "./pie-chart-component"; +import * as expression from "../lib/common/expression"; +import memoize from "../lib/common/memoize"; + +const memoizedParse = memoize(expression.parse); + +const GROUPS = config.ui.overview.groups; +const 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); + 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 = memoizedParse(slice.filter); + chart.slices[sliceName] = slice = Object.assign({}, slice); + slice.count = store.count("devices", filter); + } + } + return charts; +} + +const init = function() { + 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 = { + view: vnode => { + document.title = "Overview - GenieACS"; + const children = []; + for (const group of Object.values(GROUPS)) { + if (group.label) children.push(m("h1", group.label)); + + const groupChildren = []; + for (const chartName of Object.values(group.charts)) { + const chart = vnode.attrs.charts[chartName]; + const 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; + } +}; + +export { init, component }; diff --git a/ui/pie-chart-component.js b/ui/pie-chart-component.js new file mode 100644 index 0000000..71a79a4 --- /dev/null +++ b/ui/pie-chart-component.js @@ -0,0 +1,120 @@ +"use strict"; + +import m from "mithril"; + +const drawChart = function(chartData) { + const slices = chartData.slices; + const total = Array.from(Object.values(chartData.slices)).reduce( + (a, s) => a + (s.count.value || 0), + 0 + ); + 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 (const slice of Object.values(slices)) { + const percent = total > 0 ? (slice.count.value || 0) / 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.value || 0 + ), + ` (${(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; + const isBigArc = percent > 0.5 ? 1 : 0; + + 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 + + startX = endX; + startY = endY; + + paths.push( + m("path", { + d: sketch, + fill: slice.color + }) + ); + + const percentageX = + Math.cos(2 * Math.PI * (currentProgressPercentage - percent / 2)) * 50; + const percentageY = + Math.sin(2 * Math.PI * (currentProgressPercentage - percent / 2)) * 50; + + links.push( + m( + "a", + { + "xlink: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; diff --git a/ui/presets-page.js b/ui/presets-page.js new file mode 100644 index 0000000..bc1480c --- /dev/null +++ b/ui/presets-page.js @@ -0,0 +1,470 @@ +"use strict"; + +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 "../lib/common/expression"; +import * as notifications from "./notifications"; +import putFormComponent from "./put-form-component"; +import memoize from "../lib/common/memoize"; +import * as smartQuery from "./smart-query"; +import * as expressionParser from "../lib/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: "channel", label: "Channel" }, + { id: "weight", label: "Weight" }, + { id: "schedule", label: "Schedule" }, + { 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 } +]; + +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 putActionHandler(action, _object, isNew) { + return new Promise((resolve, reject) => { + const object = Object.assign({}, _object); + if (action === "save") { + const id = object["_id"]; + delete object["_id"]; + + const errors = {}; + + if (!id) errors["_id"] = "ID can not be empty"; + if (!object.provision) errors.provision = "Provision not selected"; + + if (Object.keys(errors).length) return void resolve(errors); + + store + .resourceExists("presets", id) + .then(exists => { + if (exists && isNew) { + store.fulfill(0, Date.now()); + 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 = { + resource: "presets", + attributes: attributes +}; + +const getDownloadUrl = memoize(filter => { + const cols = {}; + for (const attr of attributes) cols[attr.label] = attr.id; + return `/api/presets.csv?${m.buildQueryString({ + filter: filter, + columns: JSON.stringify(cols) + })}`; +}); + +function init(args) { + 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; + return Promise.resolve({ filter, sort }); +} + +function renderTable( + presetsResponse, + total, + selected, + showMoreCallback, + downloadUrl, + sort, + onSortChange +) { + const presets = presetsResponse.value; + const selectAll = m("input", { + type: "checkbox", + checked: presets.length && selected.size === presets.length, + onchange: e => { + 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 (const attr of attributes) { + const label = attr.label; + + if (attr.unsortable) { + labels.push(m("th", label)); + continue; + } + + let direction = 1; + + let symbol = "\u2981"; + if (sort[attr.id] > 0) symbol = "\u2bc6"; + else if (sort[attr.id] < 0) symbol = "\u2bc5"; + + const 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])); + } + + const rows = []; + for (const preset of presets) { + const 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"] + })}`; + } + + const tds = [m("td", checkbox)]; + for (const 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: () => { + const cb = () => { + return m( + putFormComponent, + Object.assign( + { + base: preset, + actionHandler: (action, object) => { + 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 + ) + ); + }; + 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(preset["_id"])) selected.add(preset["_id"]); + } + }, + tds + ) + ); + } + + if (!rows.length) + rows.push(m("tr.empty", m("td", { colspan: labels.length }, "No presets"))); + + const 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: "" }, "Download") + ); + } + + const 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); + store.fulfill(0, Date.now()); + }); + } + }, + "Delete" + ) + ]; + + if (window.authorizer.hasAccess("presets", 3)) { + buttons.push( + m( + "button.primary", + { + title: "Create new preset", + onclick: () => { + const cb = () => { + return m( + putFormComponent, + Object.assign( + { + actionHandler: (action, object) => { + 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 + ) + ); + }; + 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 = "Presets - GenieACS"; + + function showMore() { + vnode.state.showCount = (vnode.state.showCount || PAGE_SIZE) + PAGE_SIZE; + m.redraw(); + } + + function onFilterChanged(filter) { + const ops = { filter }; + if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; + 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(m.route.get(), ops); + } + + const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; + let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + filter = unpackSmartQuery(filter); + + const presets = store.fetch("presets", filter, { + limit: vnode.state.showCount || PAGE_SIZE, + sort: sort + }); + const count = store.count("presets", filter); + + const provisions = store.fetch("provisions", true); + if (provisions.fulfilled) { + const provisionAttr = attributes.find(attr => { + return attr.id === "provision"; + }); + + 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(); + 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); + + return [ + m("h1", "Listing presets"), + m(filterComponent, { + resource: "presets", + filter: vnode.attrs.filter, + onChange: onFilterChanged + }), + renderTable( + presets, + count.value, + selected, + showMore, + downloadUrl, + sort, + onSortChange + ) + ]; + } +}; + +export { init, component }; diff --git a/ui/provisions-page.js b/ui/provisions-page.js new file mode 100644 index 0000000..f95375c --- /dev/null +++ b/ui/provisions-page.js @@ -0,0 +1,438 @@ +"use strict"; + +import m from "mithril"; + +import config from "./config"; +import filterComponent from "./filter-component"; +import * as store from "./store"; +import * as expression from "../lib/common/expression"; +import * as notifications from "./notifications"; +import memoize from "../lib/common/memoize"; +import putFormComponent from "./put-form-component"; +import * as overlay from "./overlay"; +import * as smartQuery from "./smart-query"; +import * as expressionParser from "../lib/common/expression-parser"; +import { loadCodeMirror } from "./dynamic-loader"; + +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("provisions", e[2], e[3]); + return e; + }); +}); + +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 resolve({ _id: "ID can not be empty" }); + + store + .resourceExists("provisions", id) + .then(exists => { + if (exists && isNew) { + store.fulfill(0, Date.now()); + 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 = { + resource: "provisions", + attributes: attributes +}; + +const getDownloadUrl = memoize(filter => { + const cols = {}; + for (const 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 sort = args.sort; + const filter = args.filter; + + return new Promise((resolve, reject) => { + loadCodeMirror() + .then(() => { + resolve({ filter, sort }); + }) + .catch(reject); + }); +} + +function renderTable( + provisionsResponse, + total, + selected, + showMoreCallback, + downloadUrl, + sort, + onSortChange +) { + const provisions = provisionsResponse.value; + const selectAll = m("input", { + type: "checkbox", + checked: provisions.length && selected.size === provisions.length, + onchange: e => { + 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 (const attr of attributes) { + const label = attr.label; + + let direction = 1; + + let symbol = "\u2981"; + if (sort[attr.id] > 0) symbol = "\u2bc6"; + else if (sort[attr.id] < 0) symbol = "\u2bc5"; + + const 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])); + } + + const rows = []; + for (const provision of provisions) { + const 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; + } + }); + + const tds = [m("td", checkbox)]; + for (const attr of attributes) { + 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( + m( + "td.table-row-links", + m( + "a", + { + onclick: () => { + const cb = () => { + return m( + putFormComponent, + Object.assign( + { + base: provision, + actionHandler: (action, object) => { + 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 + ) + ); + }; + 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")) + ); + } + + const 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") + ); + } + + const 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); + store.fulfill(0, Date.now()); + }); + } + }, + "Delete" + ) + ]; + + if (window.authorizer.hasAccess("provisions", 3)) { + buttons.push( + m( + "button.primary", + { + title: "Create new provision", + onclick: () => { + const cb = () => { + return m( + putFormComponent, + Object.assign( + { + actionHandler: (action, object) => { + 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 + ) + ); + }; + 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) { + const ops = { filter }; + if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; + 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(m.route.get(), ops); + } + + const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; + let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + filter = unpackSmartQuery(filter); + + const provisions = store.fetch("provisions", filter, { + limit: vnode.state.showCount || PAGE_SIZE, + sort: sort + }); + + const count = store.count("provisions", filter); + + 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); + + return [ + m("h1", "Listing provisions"), + m(filterComponent, { + resource: "provisions", + filter: vnode.attrs.filter, + onChange: onFilterChanged + }), + renderTable( + provisions, + count.value, + selected, + showMore, + downloadUrl, + sort, + onSortChange + ) + ]; + } +}; + +export { init, component }; diff --git a/ui/put-form-component.js b/ui/put-form-component.js new file mode 100644 index 0000000..6cb59c8 --- /dev/null +++ b/ui/put-form-component.js @@ -0,0 +1,180 @@ +"use strict"; + +import m from "mithril"; +import { codeMirror } from "./dynamic-loader"; + +const singular = { + presets: "preset", + provisions: "provision", + virtualParameters: "virtual parameter", + files: "file" +}; + +function createField(current, attr, focus) { + if (attr.type === "combo") { + 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])) + 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 + ); + } else if (attr.type === "code") { + return m("textarea", { + name: attr.id, + value: current.object[attr.id], + oncreate: _vnode => { + const editor = codeMirror.fromTextArea(_vnode.dom, { + mode: "javascript", + 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 => { + current.object[attr.id] = e.getValue(); + }); + + 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", { + 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 => { + 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) + }; + } + + const current = vnode.state.current; + + const form = []; + let focused = false; + for (const 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) + ) + ); + } + + const submit = m("button.primary", { type: "submit" }, "Save"); + const buttons = [submit]; + + 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).then(() => { + e.target.disabled = false; + }); + } + }, + "Delete" + ) + ); + } + + form.push(m(".actions-bar", buttons)); + + const children = [ + m("h1", `${current.isNew ? "New" : "Editing"} ${singular[resource]}`), + m( + "form", + { + onsubmit: e => { + e.redraw = false; + // const onsubmit = e.target.onsubmit; + e.preventDefault(); + // 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 + ) + ]; + + return m("div.put-form", children); + } +}; + +export default component; diff --git a/ui/smart-query.js b/ui/smart-query.js new file mode 100644 index 0000000..5234cdb --- /dev/null +++ b/ui/smart-query.js @@ -0,0 +1,139 @@ +"use strict"; + +import config from "./config"; +import * as expression from "../lib/common/expression"; + +const 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" } + }, + 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" } + } +}; + +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 []; + return Object.keys(resources[resource]); +} + +function queryNumber(param, value) { + let op = "="; + for (const o of ["<>", "=", "<=", "<", ">=", ">"]) { + if (value.startsWith(o)) { + op = o; + value = value.slice(o.length).trim(); + break; + } + } + + const v = parseInt(value); + if (v !== +value) return null; + + 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()]; +} + +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(); + const res = ["OR"]; + + if (type.length === 0 || type.includes("number")) { + const q = queryNumber(resources[resource][label].parameter, value); + if (q) res.push(q); + } + + if (type.length === 0 || type.includes("string")) { + const q = queryString(resources[resource][label].parameter, 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); + } + + if (res.length <= 1) return null; + else if (res.length === 2) return res[1]; + else return res; +} + +export { getLabels, unpack }; diff --git a/ui/store.js b/ui/store.js new file mode 100644 index 0000000..20df6b9 --- /dev/null +++ b/ui/store.js @@ -0,0 +1,567 @@ +"use strict"; + +import m from "mithril"; +import * as expressionParser from "../lib/common/expression-parser"; +import * as expression from "../lib/common/expression"; +import memoize from "../lib/common/memoize"; + +const memoizedStringify = memoize(expression.stringify); +const memoizedEvaluate = memoize(expression.evaluate); + +let fulfillTimestamp = 0; + +const queries = { + filter: new WeakMap(), + bookmark: new WeakMap(), + limit: new WeakMap(), + sort: new WeakMap(), + fulfilled: new WeakMap(), + fulfilling: new WeakSet(), + accessed: new WeakMap(), + value: new WeakMap() +}; + +const resources = {}; +for (const r of [ + "devices", + "faults", + "files", + "presets", + "provisions", + "virtualParameters", + "files" +]) { + 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()); + return queries.value.get(this); + } +} + +function unpackExpression(exp) { + if (!Array.isArray(exp)) return exp; + const e = memoizedEvaluate(exp, null, fulfillTimestamp); + return e; +} + +function count(resourceType, filter) { + const filterStr = memoizedStringify(filter); + let queryResponse = resources[resourceType].count.get(filterStr); + if (queryResponse) return queryResponse; + + queryResponse = new QueryResponse(); + + resources[resourceType].count.set(filterStr, queryResponse); + queries.filter.set(queryResponse, filter); + return queryResponse; +} + +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 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 => { + 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 (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); + + return value; +} + +function inferQuery(resourceType, queryResponse) { + const limit = queries.limit.get(queryResponse); + let filter = queries.filter.get(queryResponse); + filter = unpackExpression(filter); + const 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) + ) + queries.fulfilled.set(queryResponse, fulfillTimestamp); + } + + queries.value.set( + queryResponse, + findMatches(resourceType, filter, sort, limit) + ); +} + +function fetch(resourceType, filter, options = {}) { + const filterStr = memoizedStringify(filter); + 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; + else sort["_id"] = sort["_id"] || 1; + + const key = `${filterStr}:${limit}:${JSON.stringify(sort)}`; + 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.sort.set(queryResponse, sort); + inferQuery(resourceType, queryResponse); + return queryResponse; +} + +function fulfill(accessTimestamp, _fulfillTimestamp) { + let updated = false; + + if (_fulfillTimestamp > fulfillTimestamp) { + for (const resource of Object.values(resources)) + resource.combinedFilter = null; + fulfillTimestamp = _fulfillTimestamp; + } + + const allPromises = []; + + 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; + } + + if (queries.fulfilling.has(queryResponse)) continue; + + if (!(fulfillTimestamp <= queries.fulfilled.get(queryResponse))) { + queries.fulfilling.add(queryResponse); + allPromises.push( + new Promise((resolve, reject) => { + updated = true; + let filter = queries.filter.get(queryResponse); + filter = unpackExpression(filter); + m.request({ + method: "HEAD", + url: + `/api/${resourceType}/?` + + m.buildQueryString({ + filter: memoizedStringify(filter) + }), + 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)); + }) + ); + } + } + } + + const toFetchAll = {}; + + 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; + } + + if (queries.fulfilling.has(queryResponse)) continue; + + if (!(fulfillTimestamp <= queries.fulfilled.get(queryResponse))) { + queries.fulfilling.add(queryResponse); + 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) => { + updated = true; + let filter = queries.filter.get(queryResponse); + filter = unpackExpression(filter); + m.request({ + method: "GET", + url: + `/api/${resourceType}/?` + + m.buildQueryString({ + filter: memoizedStringify(filter), + limit: 1, + skip: limit - 1, + sort: JSON.stringify(sort), + projection: Object.keys(sort).join(",") + }) + }) + .then(res => { + if (res.length) { + // Generate bookmark object + const bm = Object.keys(sort).reduce((b, k) => { + if (res[0][k] != null) { + if (typeof res[0][k] === "object") { + if (res[0][k].value != null) + b[k] = res[0][k].value[0]; + } else { + b[k] = res[0][k]; + } + } + + return b; + }, {}); + queries.bookmark.set(queryResponse, bm); + } else { + queries.bookmark.delete(queryResponse); + } + 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 = unpackExpression(filter); + const limit = queries.limit.get(queryResponse); + const bookmark = queries.bookmark.get(queryResponse); + const sort = queries.sort.get(queryResponse); + if (bookmark) filter = limitFilter(filter, sort, bookmark); + + if ( + resources[resourceType].combinedFilter && + expression.subset(filter, resources[resourceType].combinedFilter) + ) { + queries.value.set( + queryResponse, + findMatches(resourceType, filter, sort, limit) + ); + queries.fulfilled.set(queryResponse, fulfillTimestamp); + queries.fulfilling.delete(queryResponse); + return false; + } + + combinedFilter = expression.or(combinedFilter, filter); + return true; + }); + + if (combinedFilter == null) continue; + + 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 = expression.and( + combinedFilterDiff, + expression.not(resources[resourceType].combinedFilter) + ); + } + resources[resourceType].combinedFilter = expression.or( + combinedFilter, + resources[resourceType].combinedFilter + ); + + allPromises2.push( + new Promise((resolve2, reject2) => { + m.request({ + method: "GET", + url: + `/api/${resourceType}/?` + + m.buildQueryString({ + filter: memoizedStringify(combinedFilterDiff) + }) + }) + .then(res => { + for (const r of res) { + const id = + resourceType === "devices" + ? r["DeviceID.ID"].value[0] + : r["_id"]; + resources[resourceType].objects.set(id, r); + deleted.delete(id); + } + + for (const d of deleted) { + const obj = resources[resourceType].objects.get(d); + if ( + expression.evaluate( + combinedFilterDiff, + obj, + fulfillTimestamp + ) + ) + resources[resourceType].objects.delete(d); + } + + for (const queryResponse of toFetch) { + let filter = queries.filter.get(queryResponse); + filter = unpackExpression(filter); + const limit = queries.limit.get(queryResponse); + const bookmark = queries.bookmark.get(queryResponse); + const sort = queries.sort.get(queryResponse); + if (bookmark) filter = limitFilter(filter, sort, bookmark); + + queries.value.set( + queryResponse, + findMatches(resourceType, filter, sort, limit) + ); + queries.fulfilled.set(queryResponse, fulfillTimestamp); + queries.fulfilling.delete(queryResponse); + } + resolve2(); + }) + .catch(reject2); + }) + ); + } + Promise.all(allPromises2) + .then(() => resolve(updated)) + .catch(reject); + }) + .catch(reject); + }); +} + +function getTimestamp() { + return fulfillTimestamp; +} + +function postTasks(deviceId, tasks) { + for (const t of tasks) { + t.status = "pending"; + t.device = deviceId; + } + + return new Promise((resolve, reject) => { + m.request({ + method: "POST", + url: `/api/devices/${encodeURIComponent(deviceId)}/tasks`, + data: tasks, + extract: xhr => { + if (xhr.status !== 200) throw new Error(xhr.response); + const connectionRequestStatus = xhr.getResponseHeader( + "Connection-Request" + ); + const st = JSON.parse(xhr.response); + for (const [i, t] of st.entries()) { + tasks[i]._id = t._id; + tasks[i].status = t.status; + tasks[i].fault = t.fault; + } + resolve(connectionRequestStatus); + } + }).catch(reject); + }); +} + +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", + url: `/api/${resourceType}/${encodeURIComponent(id)}` + }); +} + +function putResource(resourceType, id, object) { + for (const 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"; + const 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); +} + +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" + }); +} + +function ping(host) { + return m.request({ + url: `/api/ping/${encodeURIComponent(host)}` + }); +} + +export { + count, + fetch, + fulfill, + unpackExpression, + getTimestamp, + postTasks, + updateTags, + deleteResource, + putResource, + resourceExists, + evaluateExpression, + logIn, + logOut, + ping +}; diff --git a/ui/task-queue.js b/ui/task-queue.js new file mode 100644 index 0000000..b24244d --- /dev/null +++ b/ui/task-queue.js @@ -0,0 +1,86 @@ +"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 stageDownload(task) { + staging.add(task); +} + +function commit(tasks, callback) { + const devices = {}; + for (const t of tasks) { + devices[t.device] = devices[t.device] || []; + devices[t.device].push(t); + queueTask(t); + } + + return new Promise(resolve => { + let counter = 1; + for (const [deviceId, tasks2] of Object.entries(devices)) { + ++counter; + store + .postTasks(deviceId, tasks) + .then(connectionRequestStatus => { + 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 (const t of tasks2) t.status = "stale"; + callback(deviceId, err, null, tasks2); + if (--counter === 0) resolve(); + }); + } + + if (--counter === 0) resolve(); + }); +} + +export { + queueTask, + deleteTask, + clear, + getQueue, + getStaging, + clearStaging, + stageSpv, + stageDownload, + commit +}; diff --git a/ui/timeago.js b/ui/timeago.js new file mode 100644 index 0000000..c0143ab --- /dev/null +++ b/ui/timeago.js @@ -0,0 +1,34 @@ +"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; diff --git a/ui/user-menu.js b/ui/user-menu.js new file mode 100644 index 0000000..3a01528 --- /dev/null +++ b/ui/user-menu.js @@ -0,0 +1,50 @@ +"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/ui/virtual-parameters-page.js b/ui/virtual-parameters-page.js new file mode 100644 index 0000000..d3c16ba --- /dev/null +++ b/ui/virtual-parameters-page.js @@ -0,0 +1,448 @@ +"use strict"; + +import m from "mithril"; + +import config from "./config"; +import filterComponent from "./filter-component"; +import * as store from "./store"; +import * as expression from "../lib/common/expression"; +import * as notifications from "./notifications"; +import memoize from "../lib/common/memoize"; +import putFormComponent from "./put-form-component"; +import * as overlay from "./overlay"; +import * as smartQuery from "./smart-query"; +import * as expressionParser from "../lib/common/expression-parser"; +import { loadCodeMirror } from "./dynamic-loader"; + +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) { + return new Promise((resolve, reject) => { + const object = Object.assign({}, _object); + if (action === "save") { + const id = object["_id"]; + delete object["_id"]; + + if (!id) return void resolve({ _id: "ID can not be empty" }); + + store + .resourceExists("virtualParameters", id) + .then(exists => { + if (exists && isNew) { + store.fulfill(0, Date.now()); + 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 = { + resource: "virtualParameters", + attributes: attributes +}; + +const getDownloadUrl = memoize(filter => { + const cols = {}; + for (const 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 new Promise((resolve, reject) => { + loadCodeMirror() + .then(() => { + resolve({ filter, sort }); + }) + .catch(reject); + }); +} + +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 (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 (const attr of attributes) { + const label = attr.label; + + let direction = 1; + + let symbol = "\u2981"; + if (sort[attr.id] > 0) symbol = "\u2bc6"; + else if (sort[attr.id] < 0) symbol = "\u2bc5"; + + const 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])); + } + + const rows = []; + for (const virtualParameter of virtualParameters) { + const 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; + } + }); + + 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: firstLines.join("\n") }, firstLines[0] || "") + ); + } else { + tds.push(m("td", virtualParameter[attr.id])); + } + } + + tds.push( + m( + "td.table-row-links", + m( + "a", + { + onclick: () => { + const cb = () => { + return m( + putFormComponent, + Object.assign( + { + base: virtualParameter, + actionHandler: (action, object) => { + 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 + ) + ); + }; + 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") + ) + ); + } + + const 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") + ); + } + + const 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); + store.fulfill(0, Date.now()); + }); + } + }, + "Delete" + ) + ]; + + if (window.authorizer.hasAccess("virtualParameters", 3)) { + buttons.push( + m( + "button.primary", + { + title: "Create new virtual parameter", + onclick: () => { + const cb = () => { + return m( + putFormComponent, + Object.assign( + { + actionHandler: (action, object) => { + 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 + ) + ); + }; + 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) { + const ops = { filter }; + if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; + 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(m.route.get(), ops); + } + + const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; + let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + filter = unpackSmartQuery(filter); + + const virtualParameters = store.fetch("virtualParameters", filter, { + limit: vnode.state.showCount || PAGE_SIZE, + sort: sort + }); + + const count = store.count("virtualParameters", filter); + + 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); + + 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/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..a6ac7bf --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,59 @@ +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"; + +function transformPackageJson(content) { + const pkg = JSON.parse(content); + delete pkg["devDependencies"]; + pkg["scripts"] = { + "start-ui": "node cluster" + }; + return JSON.stringify(pkg, null, 2); +} + +module.exports = [ + { + mode: mode, + entry: "./lib/ui/server.js", + target: "node", + externals: [nodeExternals()], + output: { + filename: "server.js" + }, + plugins: [ + new CleanWebpackPlugin(["dist"]), + new CopyWebpackPlugin( + [ + { from: "public/logo.svg", to: "public/logo.svg" }, + { from: "public/favicon.png", to: "public/favicon.png" }, + { from: "config/config-ui.json", to: "config-ui.json" }, + { + from: "package.json", + to: "package.json", + transform: transformPackageJson + } + ], + {} + ) + ] + }, + { + mode: mode, + entry: "./lib/ui/cluster.js", + target: "node", + output: { + filename: "cluster.js" + } + }, + { + mode: mode, + entry: "./ui/app.js", + output: { + path: path.resolve(__dirname, "dist/public"), + filename: "app.js" + } + } +];