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"
+ }
+ }
+];