From 498947bc4a742cf47aa5bd012f582008a1d4c0e9 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Thu, 17 Jan 2019 11:35:27 +0300 Subject: [PATCH] Add config page --- lib/ui/api-functions.js | 25 ++++ lib/ui/api.js | 2 + lib/ui/db.js | 32 +++++ tools/configure-ui | 3 + ui/admin-menu.js | 10 ++ ui/app.js | 12 +- ui/config-page.js | 275 +++++++++++++++++++++++++++++++++++++++ ui/css/app.css | 2 +- ui/layout.js | 8 +- ui/put-form-component.js | 7 +- ui/store.js | 3 +- 11 files changed, 372 insertions(+), 7 deletions(-) create mode 100644 ui/config-page.js diff --git a/lib/ui/api-functions.js b/lib/ui/api-functions.js index a26da13..e6947d5 100644 --- a/lib/ui/api-functions.js +++ b/lib/ui/api-functions.js @@ -5,6 +5,7 @@ import https from "https"; import url from "url"; import * as config from "../config"; import * as db from "./db"; +import * as cache from "../cache"; import * as mongodbFunctions from "./mongodb-functions"; export function filterToMongoQuery(filter, negate = false, res = {}) { @@ -67,6 +68,18 @@ export function filterToMongoQuery(filter, negate = false, res = {}) { export function deleteResource(resource, id) { return new Promise((resolve, reject) => { + if (resource === "config") { + db.deleteConfig(id) + .then(() => { + cache.del("presets_hash", err => { + if (err) return void reject(err); + resolve(); + }); + }) + .catch(reject); + return; + } + const resourceUrl = resource === "virtualParameters" ? "virtual_parameters" : resource; const options = url.parse( @@ -296,6 +309,18 @@ export function ping(host) { export function putResource(resource, id, data) { return new Promise((resolve, reject) => { + if (resource === "config") { + db.putConfig(id, data) + .then(() => { + cache.del("presets_hash", err => { + if (err) return void reject(err); + resolve(); + }); + }) + .catch(reject); + return; + } + const resourceUrl = resource === "virtualParameters" ? "virtual_parameters" : resource; const options = url.parse( diff --git a/lib/ui/api.js b/lib/ui/api.js index f209ac5..725ec86 100644 --- a/lib/ui/api.js +++ b/lib/ui/api.js @@ -25,6 +25,7 @@ const RESOURCE_IDS = { provisions: "_id", files: "_id", virtualParameters: "_id", + config: "_id", faults: "_id", tasks: "_id" }; @@ -35,6 +36,7 @@ const resources = { provisions: 0 | RESOURCE_DELETE | RESOURCE_PUT, files: 0 | RESOURCE_DELETE, virtualParameters: 0 | RESOURCE_DELETE | RESOURCE_PUT, + config: 0 | RESOURCE_DELETE | RESOURCE_PUT, faults: 0 | RESOURCE_DELETE, tasks: 0 }; diff --git a/lib/ui/db.js b/lib/ui/db.js index 075e1d9..39b2ce0 100644 --- a/lib/ui/db.js +++ b/lib/ui/db.js @@ -187,6 +187,38 @@ export function count(resource, filter) { }); } +export function deleteConfig(id) { + return new Promise((resolve, reject) => { + getClient() + .then(client => { + const collection = client + .db() + .collection(RESOURCE_COLLECTION["config"] || "config"); + collection.deleteOne({ _id: id }, err => { + if (err) return void reject(err); + resolve(); + }); + }) + .catch(reject); + }); +} + +export function putConfig(id, object) { + return new Promise((resolve, reject) => { + getClient() + .then(client => { + const collection = client + .db() + .collection(RESOURCE_COLLECTION["config"] || "config"); + collection.replaceOne({ _id: id }, object, { upsert: true }, err => { + if (err) return void reject(err); + resolve(); + }); + }) + .catch(reject); + }); +} + export function disconnect() { if (_clientPromise) { _clientPromise.then(client => { diff --git a/tools/configure-ui b/tools/configure-ui index a404385..01931e6 100755 --- a/tools/configure-ui +++ b/tools/configure-ui @@ -19,12 +19,15 @@ const configData = { "permissions.admin.0.presets.validate.pass": "true", "permissions.admin.0.provisions.access": "3", "permissions.admin.0.provisions.validate.pass": "true", + "permissions.admin.0.config.access": "3", + "permissions.admin.0.config.validate.pass": "true", "permissions.admin.0.virtualParameters.access": "3", "permissions.admin.0.virtualParameters.validate.pass": "true", "permissions.view.0.devices.access": "2", "permissions.view.0.faults.access": "2", "permissions.view.0.files.access": "2", "permissions.view.0.presets.access": "2", + "permissions.view.0.config.access": "2", "permissions.view.0.provisions.access": "2", "permissions.view.0.virtualParameters.access": "2", "ui.device.0.type": "'tags'", diff --git a/ui/admin-menu.js b/ui/admin-menu.js index f11a546..e94a913 100644 --- a/ui/admin-menu.js +++ b/ui/admin-menu.js @@ -47,6 +47,16 @@ const menu = { ); } + if (window.authorizer.hasAccess("config", 1)) { + tabs.push( + m( + "li", + { class: active["config"] }, + m("a", { href: "#!/admin/config" }, "Config") + ) + ); + } + return m("nav#side-menu", m("ul", tabs)); } }; diff --git a/ui/app.js b/ui/app.js index e66d6a6..c78b689 100644 --- a/ui/app.js +++ b/ui/app.js @@ -14,12 +14,19 @@ 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 * as configPage from "./config-page"; import Authorizer from "../lib//common/authorizer"; import * as notifications from "./notifications"; window.authorizer = new Authorizer(window.permissionSets); -const adminPages = ["presets", "provisions", "virtualParameters", "files"]; +const adminPages = [ + "presets", + "provisions", + "virtualParameters", + "files", + "config" +]; let state; @@ -107,5 +114,6 @@ m.route(document.body, "/overview", { "virtualParameters", virtualParametersPage ), - "/admin/files": pagify("files", filesPage) + "/admin/files": pagify("files", filesPage), + "/admin/config": pagify("config", configPage) }); diff --git a/ui/config-page.js b/ui/config-page.js new file mode 100644 index 0000000..58fec21 --- /dev/null +++ b/ui/config-page.js @@ -0,0 +1,275 @@ +"use strict"; + +import m from "mithril"; + +import * as store from "./store"; +import * as notifications from "./notifications"; +import putFormComponent from "./put-form-component"; +import * as overlay from "./overlay"; +import longTextComponent from "./long-text-component"; +import * as expressionParser from "../lib/common/expression-parser"; +import { loadCodeMirror } from "./dynamic-loader"; + +const attributes = [ + { id: "_id", label: "Key" }, + { id: "value", label: "Value" } +]; + +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" }); + try { + expressionParser.parse(object.value); + } catch (err) { + return void resolve({ + value: "Config value must be valid expression" + }); + } + + store + .resourceExists("config", id) + .then(exists => { + if (exists && isNew) { + store.fulfill(0, Date.now()); + return void resolve({ _id: "Config already exists" }); + } + + if (!exists && !isNew) { + store.fulfill(0, Date.now()); + return void resolve({ _id: "Config does not exist" }); + } + + store + .putResource("config", id, object) + .then(() => { + notifications.push( + "success", + `Config ${exists ? "updated" : "created"}` + ); + store.fulfill(0, Date.now()); + resolve(); + }) + .catch(reject); + }) + .catch(reject); + } else if (action === "delete") { + store + .deleteResource("config", object["_id"]) + .then(() => { + notifications.push("success", "Config 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: "config", + attributes: attributes +}; + +function escapeRegExp(str) { + return str.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&"); +} + +function init() { + if (!window.authorizer.hasAccess("config", 2)) { + return Promise.reject( + new Error("You are not authorized to view this page") + ); + } + return new Promise((resolve, reject) => { + loadCodeMirror() + .then(() => { + resolve({}); + }) + .catch(reject); + }); +} + +function renderTable(confsResponse, searchString) { + const confs = confsResponse.value.sort((a, b) => { + return a._id < b._id ? -1 : 1; + }); + + let regex; + if (searchString) { + const keywords = searchString.split(" ").filter(s => s); + if (keywords.length) + regex = new RegExp(keywords.map(s => escapeRegExp(s)).join(".*"), "i"); + } + + const rows = []; + for (const conf of confs) { + const attrs = {}; + if (regex && !regex.test(conf._id) && !regex.test(conf.value)) + attrs.style = "display: none;"; + + const edit = m( + "button", + { + title: "Edit config value", + onclick: () => { + const cb = () => { + return m( + putFormComponent, + Object.assign( + { + base: conf, + 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); + } + }, + "✎" + ); + + const del = m( + "button", + { + title: "Delete config", + onclick: () => { + if (!confirm(`Deleting ${conf._id} config. Are you sure?`)) return; + + putActionHandler("delete", conf).catch(err => { + throw err; + }); + } + }, + "✕" + ); + + rows.push( + m( + "tr", + attrs, + m("td.left", m(longTextComponent, { text: conf._id })), + m( + "td.right", + m("span", [ + m(longTextComponent, { text: `${conf.value}` }), + edit, + del + ]) + ) + ) + ); + } + + if (!rows.length) + rows.push(m("tr.empty", m("td", { colspan: 2 }, "No config"))); + + return m("table", m("tbody", rows)); +} + +const component = { + view: vnode => { + document.title = "Config - GenieACS"; + + const search = m("input", { + type: "text", + placeholder: "Search config", + oninput: e => { + vnode.state.searchString = e.target.value; + e.redraw = false; + clearTimeout(vnode.state.timeout); + vnode.state.timeout = setTimeout(m.redraw, 250); + } + }); + + let newConfig; + if (window.authorizer.hasAccess("config", 3)) { + newConfig = m( + "button.primary", + { + title: "Create new config", + 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" + ); + } + + const confs = store.fetch("config", true); + + return [ + m("h1", "Listing config"), + m( + ".all-parameters", + search, + m( + ".parameter-list", + { style: "height: 400px" }, + renderTable(confs, vnode.state.searchString) + ), + m(".actions-bar", newConfig) + ) + ]; + } +}; + +export { init, component }; diff --git a/ui/css/app.css b/ui/css/app.css index 48e6c0f..f5976c6 100644 --- a/ui/css/app.css +++ b/ui/css/app.css @@ -263,6 +263,7 @@ i { #side-menu { width: 200px; + min-width: 200px; & > ul { padding: 0; list-style: none; @@ -291,7 +292,6 @@ i { #content { flex-grow: 1; - overflow: auto; margin: 10px; min-height: 250px; } diff --git a/ui/layout.js b/ui/layout.js index feb59ed..84d6966 100644 --- a/ui/layout.js +++ b/ui/layout.js @@ -8,7 +8,13 @@ import userMenu from "./user-menu"; import adminMenu from "./admin-menu"; import * as overlay from "./overlay"; -const adminPages = ["presets", "provisions", "virtualParameters", "files"]; +const adminPages = [ + "presets", + "provisions", + "virtualParameters", + "files", + "config" +]; const layout = { view: vnode => { diff --git a/ui/put-form-component.js b/ui/put-form-component.js index 6cb59c8..2b66c2f 100644 --- a/ui/put-form-component.js +++ b/ui/put-form-component.js @@ -134,7 +134,7 @@ const component = { "button.primary", { type: "button", - title: `Delete ${singular[resource]}`, + title: `Delete ${singular[resource] || resource}`, onclick: e => { e.redraw = false; e.target.disabled = true; @@ -151,7 +151,10 @@ const component = { form.push(m(".actions-bar", buttons)); const children = [ - m("h1", `${current.isNew ? "New" : "Editing"} ${singular[resource]}`), + m( + "h1", + `${current.isNew ? "New" : "Editing"} ${singular[resource] || resource}` + ), m( "form", { diff --git a/ui/store.js b/ui/store.js index 618d4a3..8e722f4 100644 --- a/ui/store.js +++ b/ui/store.js @@ -29,7 +29,8 @@ for (const r of [ "presets", "provisions", "virtualParameters", - "files" + "files", + "config" ]) { resources[r] = { objects: new Map(),