Merge remote-tracking branch 'ui/master'
This commit is contained in:
commit
4530967b6b
70 files changed
+10951
-8
No files matched your search
+2
-4
@@ -1,13 +1,11 @@
|
|||||||
{
|
{
|
||||||
"env": {
|
"env": {
|
||||||
"es6": true,
|
"es6": true
|
||||||
"node": true
|
|
||||||
},
|
},
|
||||||
"extends": "eslint:recommended",
|
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"sourceType": "script",
|
|
||||||
"ecmaVersion": 8
|
"ecmaVersion": 8
|
||||||
},
|
},
|
||||||
|
"extends": "eslint:recommended",
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-shadow": ["error", { "allow": ["err"] }],
|
"no-shadow": ["error", { "allow": ["err"] }],
|
||||||
"handle-callback-err": "error",
|
"handle-callback-err": "error",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
*~
|
*~
|
||||||
node_modules
|
node_modules
|
||||||
|
dist
|
||||||
debug
|
debug
|
||||||
config/auth.js
|
config/auth.js
|
||||||
config/config.json
|
config/config.json
|
||||||
|
config/config-ui.json
|
||||||
config/cwmp.key
|
config/cwmp.key
|
||||||
config/cwmp.crt
|
config/cwmp.crt
|
||||||
config/cwmp.ca-bundle
|
config/cwmp.ca-bundle
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"node": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
function pass() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = pass;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
+485
@@ -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;
|
||||||
@@ -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();
|
||||||
@@ -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 });
|
||||||
+212
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 = `
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>GenieACS</title>
|
||||||
|
<link rel="shortcut icon" type="image/png" href="favicon.png" />
|
||||||
|
<link rel="stylesheet" href="app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
window.clientConfig = ${JSON.stringify(config.getClientConfig())};
|
||||||
|
window.username = ${JSON.stringify(
|
||||||
|
ctx.state.user ? ctx.state.user.username : ""
|
||||||
|
)};
|
||||||
|
window.permissionSets = ${JSON.stringify(permissionSets)};
|
||||||
|
</script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
+27
-4
@@ -19,26 +19,49 @@
|
|||||||
"bin": "bin"
|
"bin": "bin"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"later": "^1.2.0",
|
||||||
"libxmljs": "^0.19.5",
|
"libxmljs": "^0.19.5",
|
||||||
"mongodb": "^3.1.10",
|
"mongodb": "^3.1.10",
|
||||||
"seedrandom": "^2.4.4"
|
"parsimmon": "^1.12.0",
|
||||||
|
"seedrandom": "^2.4.4",
|
||||||
|
"yaml": "^1.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"redis": "^2.6.5"
|
"redis": "^2.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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",
|
"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": {
|
"engines": {
|
||||||
"node": ">=8.14.0"
|
"node": ">=8.14.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"compile": "echo 'Compile step no longer required, skipping.'",
|
"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",
|
"install": "npm run configure",
|
||||||
"test": "node test",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: [
|
||||||
|
require("postcss-import"),
|
||||||
|
require("postcss-cssnext")({ warnForDuplicates: false }),
|
||||||
|
require("cssnano")
|
||||||
|
]
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="310" height="64" viewBox="0 0 82.020832 16.933334"><defs><linearGradient id="b"><stop offset="0" stop-color="#737373"/><stop offset="1" stop-color="#4b4b4b"/></linearGradient><linearGradient id="a"><stop offset="0" stop-color="#b72f5f"/><stop offset="1" stop-color="#872346"/></linearGradient><linearGradient xlink:href="#a" id="c" gradientUnits="userSpaceOnUse" gradientTransform="translate(173.233 -461.003) scale(.21343)" x1="16.045" y1="132.803" x2="16.045" y2="-81.164"/><linearGradient xlink:href="#a" id="d" gradientUnits="userSpaceOnUse" x1="46.459" y1="281.163" x2="46.459" y2="296.66" gradientTransform="translate(1.09 16.9) scale(.94143)"/><linearGradient xlink:href="#b" id="e" gradientUnits="userSpaceOnUse" gradientTransform="translate(11.105 -994.648) scale(3.55816)" x1="46.459" y1="281.163" x2="46.459" y2="296.66"/></defs><path d="M173.233-461.003l1.953-3.714c-.868-1.509-2.042-2.814-3.522-3.917-2.419-1.43-4.386-1.199-5.901.694 2.931 1.323 5.421 3.635 7.47 6.937m-7.577 11.151a6.095 6.095 0 0 0 .673-3.49 23.76 23.76 0 0 0-1.473-6.285 20.998 20.998 0 0 1-1.206-4.855 6.766 6.766 0 0 1 .48-3.426c.463-1.117 1.298-2.074 2.508-2.87-1.302-.392-2.44-.499-3.415-.32-1.707.326-2.82 1.354-3.34 3.083a10.152 10.152 0 0 0-.267 5.08 33.38 33.38 0 0 0 1.238 4.674 32.993 32.993 0 0 1 1.227 4.856c.107.626.118 1.277.032 1.953a4.72 4.72 0 0 1-.779 2.006 4.498 4.498 0 0 1-1.707 1.44c.697.129 1.387.235 2.07.32a4.124 4.124 0 0 0 2.07-.351c.84-.37 1.47-.975 1.89-1.815m22.463 5.123l.63-.62-5.25-4.77 1.579-1.579 4.77 5.25.619-.629-5.24-4.77 1.483-1.483 4.77 5.24.63-.62-5.25-4.77 1.58-1.58 5.015 5.016 1.067-1.045-4.503-4.504h.01l-5.87-5.859-3.563-1.462-5.102 3.831-.02.022-.022-.022.01-.02 3.394-4.526-1.131-.458-4.973 4.973 1.26 3.073 4.652-3.49.01-.01.033.02-.01.012-3.95 5.26.64 1.57 4.814 4.802h-.01l5.559 5.549 1.057-1.046-5.027-5.026 1.569-1.569 4.77 5.24m4.14-28.173c4.405 4.403 6.607 9.722 6.607 15.954 0 6.218-2.202 11.536-6.606 15.954-4.418 4.404-9.736 6.606-15.954 6.606-6.233 0-11.55-2.202-15.955-6.606-2.703-2.71-4.574-5.756-5.613-9.135l.448.245c2.241 1.181 4.226.577 5.955-1.814-1.565-.32-2.82-.658-3.767-1.014a5.467 5.467 0 0 1-1.43-.821c-.96-.762-1.565-1.73-1.814-2.903a11.315 11.315 0 0 1 .032-5.005c.81-4.34 2.874-8.16 6.19-11.461 4.403-4.411 9.721-6.617 15.954-6.617 6.225 0 11.543 2.206 15.954 6.617" fill="url(#c)" transform="matrix(.35176 0 0 -.35176 -53.595 -152.27)"/><path style="line-height:6.61458302px;-inkscape-font-specification:Norwegian" d="M47.104 281.834c-.456 0-.816.388-.816.849 0 .46.355.816.816.816a.82.82 0 0 0 .833-.816.85.85 0 0 0-.833-.849zm-21.502 1.404c-.205 0-.382.046-.5.13-.118.085-.187.201-.187.4v.981l-.151-.031a9.89 9.89 0 0 0-1.872-.173c-1.288 0-2.148.066-2.657.374a1.264 1.264 0 0 0-.548.676c-.119.313-.177.73-.177 1.28v6.123c0 .982.25 1.508.787 1.824.539.315 1.407.41 2.628.41 1.308 0 2.178-.13 2.701-.484.524-.354.745-.932.745-1.942 0-.821-.147-1.37-.543-1.722-.395-.352-1.075-.528-2.186-.528h-2.787v-1.234l.148.03c.478.092 1.094.11 1.89.11 1.292 0 2.148-.112 2.658-.453.51-.34.724-.915.724-1.958v-3.284c0-.198-.07-.314-.186-.399a.843.843 0 0 0-.487-.13zm6.112 1.29c-1.226 0-2.038.137-2.526.495-.488.357-.697.945-.697 1.947v2.84c0 .935.23 1.54.743 1.932.512.39 1.337.571 2.544.571 1.392 0 2.17-.058 2.567-.196.199-.069.298-.15.355-.246.058-.097.078-.227.078-.405 0-.208-.04-.378-.117-.485-.076-.108-.186-.171-.396-.171-.794 0-1.057.19-2.408.19-.593 0-1.06-.078-1.395-.255-.335-.177-.53-.473-.53-.84v-.89h4.301a.717.717 0 0 0 .539-.213c.13-.138.213-.342.213-.62v-1.212c0-1.037-.179-1.623-.654-1.97-.476-.348-1.3-.472-2.617-.472zm8.83 0c-1.303 0-2.117.086-2.59.49-.472.403-.665 1.168-.665 2.623v4.002c0 .215.074.338.197.426.123.088.31.134.524.134.223 0 .412-.046.537-.134.125-.088.198-.211.198-.426v-3.81c0-.424.006-.754.04-1.019.036-.265.102-.47.236-.623.135-.152.328-.236.574-.28.246-.044.552-.053.949-.053.4 0 .712.01.963.053.25.044.447.126.587.278.139.151.211.358.25.623.038.265.046.597.046 1.02v3.81c0 .217.07.34.19.427.122.088.308.134.53.134.215 0 .401-.046.525-.134.123-.088.196-.211.196-.426v-4.002c0-1.455-.201-2.219-.682-2.623-.48-.404-1.303-.49-2.605-.49zm12.996 0c-1.227 0-2.038.137-2.527.495-.488.357-.696.945-.696 1.947v2.84c0 .935.23 1.54.742 1.932.513.39 1.338.571 2.545.571 1.392 0 2.17-.058 2.567-.196.2-.069.298-.15.355-.246.058-.097.079-.227.079-.405 0-.208-.04-.378-.117-.485-.077-.108-.188-.171-.398-.171-.793 0-1.056.19-2.406.19-.594 0-1.062-.078-1.396-.255-.335-.177-.53-.473-.53-.84v-.89h4.301a.716.716 0 0 0 .539-.213c.13-.138.213-.342.213-.62v-1.212c0-1.037-.18-1.623-.655-1.97-.475-.348-1.3-.472-2.616-.472zm-6.436.13c-.212 0-.398.047-.522.14-.124.09-.198.22-.198.435v6.41c0 .215.073.338.196.426.123.088.31.134.524.134.223 0 .414-.046.54-.134.124-.088.198-.211.198-.426v-6.41c0-.215-.075-.342-.201-.434-.126-.092-.316-.142-.537-.142zm-24.212 1.135c.662 0 1.4.065 1.918.146l.105.016v.92c0 .246-.01.453-.058.632a.757.757 0 0 1-Line truncated
|
||||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"parser": "babel-eslint",
|
||||||
|
"parserOptions": {
|
||||||
|
"allowImportExportEverywhere": true,
|
||||||
|
"sourceType": "module"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
export default window.clientConfig;
|
||||||
+685
@@ -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,<svg xmlns="http://www.w3.org/2000/svg" width="190" height="16"><path d="M6.59 15.42l-6-6a2 2 0 0 1 0-2.83l6-6A2 2 0 0 1 8 0h180a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H8a2 2 0 0 1-1.41-.58zM4.35 6.59A2 2 0 1 0 7.17 9.4 2 2 0 0 0 4.35 6.6z" fill="#e8d5b2"></path></svg>');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+450
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
+58
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
+567
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
Reference in new issue
Block a user