diff --git a/client/config.js b/client/config.js index 20f5748..566323d 100644 --- a/client/config.js +++ b/client/config.js @@ -2,6 +2,23 @@ export default { pageSize: 10, + filter: [ + { + label: "Serial number", + parameter: "DeviceID.SerialNumber", + type: "string" + }, + { + label: "Product class", + parameter: "DeviceID.ProductClass", + type: "string" + }, + { + label: "Tag", + parameter: "tag", + type: "string" + } + ], index: [ { label: "Serial number", diff --git a/client/css/app.css b/client/css/app.css index 5454683..75ae270 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -200,3 +200,13 @@ button { color: var(--color2); } } + +.filter { + margin-bottom: 0.5em; + & > input { + padding: 2px; + width: 30em; + font-size: 90%; + display: block; + } +} diff --git a/client/devices-page.js b/client/devices-page.js index ad6223d..ea6faba 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -3,13 +3,14 @@ import m from "mithril"; import config from "./config"; +import filterComponent from "./filter-component"; function load(filter, limit) { return new Promise((resolve, reject) => { m .request({ url: "/api/devices/", - data: { filter: JSON.stringify(filter), limit: limit } + data: { filter: filter, limit: limit } }) .then(res => { resolve(res); @@ -29,8 +30,9 @@ function count(filter, limit) { m .request({ method: "HEAD", - url: "/api/devices/", - data: { filter: JSON.stringify(filter), limit: limit }, + url: + "/api/devices/?" + + m.buildQueryString({ filter: filter, limit: limit }), extract: extract }) .then(res => { @@ -43,9 +45,45 @@ function count(filter, limit) { }); } +function preprocessFilter(filter) { + if (Array.isArray(filter)) { + filter = filter.map(f => { + f = f.trim(); + if (f.startsWith("(")) return f; + + let match = f.match(/([^=<>]+)([=<>]+)(.+)/); + + let param = match[1].trim(); + let op = match[2].trim(); + let value = match[3].trim(); + + try { + value = JSON.parse(value); + } catch (err) { + // eslint-disable-line no-empty + } + + for (let cf of config.filter) + if (param === cf.label) param = cf.parameter; + + if (param === "tag") { + let tag = value.replace(/[^a-zA-Z0-9]/g, "_"); + if (op === "=") return `Tags.${tag} is not null`; + else if (op === "<>") return `Tags.${tag} is null`; + } + + return `${param} ${op} ${JSON.stringify(value)}`; + }); + filter = "(" + filter.join(") AND (") + ")"; + } + return filter; +} + function init(args) { return new Promise((resolve, reject) => { - Promise.all([count(args.filter), load(args.filter, config.pageSize)]) + let filter = preprocessFilter(args.filter); + + Promise.all([count(filter), load(filter, config.pageSize)]) .then(res => { resolve({ filter: args.filter, total: res[0], devices: res[1] }); }) @@ -93,15 +131,18 @@ const component = { document.title = "Devices - GenieACS"; function showMore() { const lastDevice = vnode.attrs.devices[vnode.attrs.devices.length - 1]; - let f = Object.assign({}, vnode.attrs.filter, { - "DeviceID.ID>": lastDevice["DeviceID.ID"].value[0] - }); - load(f, config.pageSize).then(res => { + let f = (vnode.attrs.filter || []).concat( + `DeviceID.ID > "${lastDevice["DeviceID.ID"].value[0]}"` + ); + load(preprocessFilter(f), config.pageSize).then(res => { vnode.attrs.devices = vnode.attrs.devices.concat(res); + if (res.length < config.pageSize) + vnode.attrs.total = vnode.attrs.devices.length; }); } return [ m("h1", "Listing devices"), + m(filterComponent, { filterList: [].concat(vnode.attrs.filter) }), renderTable( vnode.attrs.devices, config.index, diff --git a/client/filter-component.js b/client/filter-component.js new file mode 100644 index 0000000..8f2f401 --- /dev/null +++ b/client/filter-component.js @@ -0,0 +1,41 @@ +"use strict"; + +import m from "mithril"; + +import config from "./config"; + +const component = { + view: vnode => { + let a = m( + "datalist#filters", + config.filter.map(f => m("option", { value: `${f.label} = ` })) + ); + + const filterList = vnode.attrs.filterList.filter(f => { + return !!f; + }); + filterList.push(""); + + return m( + "div.filter", + [m("b", "Filter"), a].concat( + filterList.map((fltr, idx) => { + return m("input", { + type: "string", + list: "filters", + value: fltr, + onchange: function(e) { + e.redraw = false; + filterList[idx] = this.value; + m.route.set("/devices", { + filter: filterList.filter(f => !!f) + }); + } + }); + }) + ) + ); + } +}; + +export default component; diff --git a/server/api-functions.js b/server/api-functions.js index 131dfe1..1a583ae 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -7,34 +7,68 @@ const querystring = require("querystring"); const config = require("./config"); const device = require("./device"); +const filterParser = require("./filter-parser"); const commaAscii = ",".charCodeAt(0); const newLineAscii = "\n".charCodeAt(0); -function filtersToQuery(filters) { - if (!Array.isArray(filters)) filters = [filters]; +function filterToQuery(filter, negate = false, res = {}) { + const op = filter[0]; - let queries = []; - for (let filter of filters) { - let q = {}; - for (let [k, v] of Object.entries(filter)) { - let [param, op] = k.split(/([^a-zA-Z0-9\-_.].*)/, 2); - if (!op || op === "=") q[param] = v; - else if (op === ">") q[param] = { $gt: v }; - else throw new Error(`Operator "${op}" not recognized`); + if ((!negate && op === "AND") || (negate && op === "OR")) { + for (let i = 1; i < filter.length; ++i) + filterToQuery(filter[i], negate, res); + } else if ((!negate && op === "OR") || (negate && op === "AND")) { + res["$or"] = res["$or"] || []; + + for (let i = 1; i < filter.length; ++i) + res["$or"].push(filterToQuery(filter[i], negate)); + } else if (op === "NOT") { + filterToQuery(filter[1], !negate, res); + } else if (op === "=") { + let p = (res[filter[1]] = res[filter[1]] || {}); + if (negate) p["$ne"] = filter[2]; + else p["$eq"] = filter[2]; + } else if (op === "<>") { + let p = (res[filter[1]] = res[filter[1]] || {}); + if (negate) { + p["$eq"] = filter[2]; + } else { + p["$ne"] = filter[2]; + p["$exists"] = true; } - queries.push(q); + } else if (op === ">") { + let p = (res[filter[1]] = res[filter[1]] || {}); + if (negate) p = p["$not"] = p["$not"] || {}; + p["$gt"] = filter[2]; + } else if (op === ">=") { + let p = (res[filter[1]] = res[filter[1]] || {}); + if (negate) p = p["$not"] = p["$not"] || {}; + p["$gte"] = filter[2]; + } else if (op === "<") { + let p = (res[filter[1]] = res[filter[1]] || {}); + if (negate) p = p["$not"] = p["$not"] || {}; + p["$lt"] = filter[2]; + } else if (op === "<=") { + let p = (res[filter[1]] = res[filter[1]] || {}); + if (negate) p = p["$not"] = p["$not"] || {}; + p["$lte"] = filter[2]; + } else if (op === "IS NULL") { + res[filter[1]] = { $exists: negate }; + } else if (op === "IS NOT NULL") { + res[filter[1]] = { $exists: !negate }; + } else { + throw new Error(`Unrecognized operator ${op}`); } - if (queries.length === 1) return queries[0]; - else return { $or: queries }; + return res; } -function count(resource, filters, limit) { +function count(resource, filter, limit) { return new Promise((resolve, reject) => { let qs = {}; - if (filters) { - let q = filtersToQuery(filters); + if (filter) { + let q = filterToQuery(filterParser.parse(filter)); if (resource === "devices") q = device.transposeQuery(q); qs.query = JSON.stringify(q); } @@ -62,14 +96,15 @@ function count(resource, filters, limit) { }); } -function query(resource, filters, limit, callback) { +function query(resource, filter, limit, callback) { return new Promise((resolve, reject) => { let ret; if (!callback) ret = []; let qs = {}; - if (filters) { - let q = filtersToQuery(filters); + if (filter) { + let q = filterToQuery(filterParser.parse(filter)); if (resource === "devices") q = device.transposeQuery(q); + qs.query = JSON.stringify(q); } diff --git a/server/api.js b/server/api.js index d7e3381..3bf2645 100644 --- a/server/api.js +++ b/server/api.js @@ -17,14 +17,7 @@ const resources = { for (let [resource, update] of Object.entries(resources)) { router.head(`/${resource}`, async ctx => { let filter, limit; - if (ctx.request.query.filter) - if (Array.isArray(ctx.request.query.filter)) - filter = ctx.request.query.query.map(q => JSON.parse(q)); - else { - filter = JSON.parse(ctx.request.query.filter); - if (!Array.isArray(filter)) filter = [filter]; - } - + if (ctx.request.query.filter) filter = ctx.request.query.filter; if (ctx.request.query.limit) limit = +ctx.request.query.limit; let count = await apiFunctions.count(resource, filter, limit); @@ -34,28 +27,21 @@ for (let [resource, update] of Object.entries(resources)) { router.get(`/${resource}`, async ctx => { let filter, limit; - if (ctx.request.query.filter) - if (Array.isArray(ctx.request.query.filter)) - filter = ctx.request.query.query.map(q => JSON.parse(q)); - else { - filter = JSON.parse(ctx.request.query.filter); - if (!Array.isArray(filter)) filter = [filter]; - } - + if (ctx.request.query.filter) filter = ctx.request.query.filter; if (ctx.request.query.limit) limit = +ctx.request.query.limit; ctx.body = await apiFunctions.query(resource, filter, limit); }); router.head(`/${resource}/:id`, async (ctx, next) => { - let filter = { _id: ctx.params.id }; + let filter = `DeviceID.ID = "${ctx.params.id}"`; let res = await apiFunctions.query(resource, filter); if (!res.length) return next(); ctx.body = ""; }); router.get(`/${resource}/:id`, async (ctx, next) => { - let filter = { _id: ctx.params.id }; + let filter = `DeviceID.ID = "${ctx.params.id}"`; let res = await apiFunctions.query(resource, filter); if (!res.length) return next(); ctx.body = res[0]; diff --git a/server/device.js b/server/device.js index aefea8f..fd57a79 100644 --- a/server/device.js +++ b/server/device.js @@ -142,8 +142,29 @@ function transposeQuery(query) { const newQuery = {}; for (let [key, val] of Object.entries(query)) - if (key.startsWith("$")) newQuery[key] = transposeQuery(val); - else if (key === "DeviceID.ID") newQuery["_id"] = val; + if (key.startsWith("$")) { + newQuery[key] = val.map(v => transposeQuery(v)); + } else if (key === "DeviceID.ID") { + newQuery["_id"] = val; + } else if (key === "DeviceID.SerialNumber") { + newQuery["_deviceId._SerialNumber"] = val; + } else if (key === "DeviceID.OUI") { + newQuery["_deviceId._OUI"] = val; + } else if (key === "DeviceID.Manufacturer") { + newQuery["_deviceId._Manufacturer"] = val; + } else if (key.startsWith("Tags.")) { + if (val["$exists"] === true) { + newQuery["_tags"] = newQuery["_tags"] || {}; + newQuery["_tags"]["$all"] = newQuery["_tags"]["$all"] || []; + newQuery["_tags"]["$all"].push(key.slice(5)); + } else if (val["$exists"] === false) { + newQuery["_tags"] = newQuery["_tags"] || {}; + newQuery["_tags"]["$nin"] = newQuery["_tags"]["$nin"] || []; + newQuery["_tags"]["$nin"].push(key.slice(5)); + } + } else { + newQuery[key] = val; + } return newQuery; }