diff --git a/client/app.js b/client/app.js index 68d93a7..c88bad5 100644 --- a/client/app.js +++ b/client/app.js @@ -11,6 +11,7 @@ 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 Authorizer from "../common/authorizer"; import * as notifications from "./notifications"; @@ -80,5 +81,6 @@ m.route(document.body, "/overview", { "/devices": pagify("devices", devicesPage), "/devices/:id": pagify("devices", devicePage), "/faults": pagify("faults", faultsPage), - "/presets": pagify("presets", presetsPage) + "/presets": pagify("presets", presetsPage), + "/provisions": pagify("provisions", provisionsPage) }); diff --git a/client/components/put-form.js b/client/components/put-form.js index bbc8eba..37de10e 100644 --- a/client/components/put-form.js +++ b/client/components/put-form.js @@ -1,9 +1,12 @@ "use strict"; import m from "mithril"; +import { fromTextArea as codeMirrorFromTextArea } from "codemirror"; +import "codemirror/mode/javascript/javascript"; const singular = { - presets: "preset" + presets: "preset", + provisions: "provision" }; function createField(current, attr, focus) { @@ -31,6 +34,23 @@ function createField(current, attr, focus) { }, options ); + } else if (attr.type === "code") { + return m("textarea", { + name: attr.id, + value: current.object[attr.id], + oncreate: _vnode => { + let editor = codeMirrorFromTextArea(_vnode.dom, { + mode: "javascript", + lineNumbers: true + }); + + editor.on("change", e => { + current.object[attr.id] = e.getValue(); + }); + + if (focus) editor.focus(); + } + }); } return m("input", { diff --git a/client/css/app.css b/client/css/app.css index 5a3d993..9a8d0d5 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -1,4 +1,5 @@ @import "normalize.css"; +@import "codemirror/lib/codemirror.css"; :root { --base-font-size: 13px; @@ -365,6 +366,8 @@ button { } } +.CodeMirror, +textarea, select, input { display: block; @@ -574,6 +577,7 @@ span.tag { border-radius: 10px; padding: 10px; box-shadow: 0 0 15px #aaa; + overflow: auto; } } diff --git a/client/menu.js b/client/menu.js index 2f23854..fa15deb 100644 --- a/client/menu.js +++ b/client/menu.js @@ -43,6 +43,15 @@ const menu = { ) ); + if (window.authorizer.hasAccess("provisions", 2)) + tabs.push( + m( + "li", + { class: active["provisions"] }, + m("a", { href: "#!/provisions" }, "Provisions") + ) + ); + return m("nav", m("ul", tabs)); } }; diff --git a/client/provisions-page.js b/client/provisions-page.js new file mode 100644 index 0000000..ee2586b --- /dev/null +++ b/client/provisions-page.js @@ -0,0 +1,334 @@ +"use strict"; + +import m from "mithril"; + +import config from "./config"; +import filterComponent from "./filter-component"; +import * as store from "./store"; +import * as expression from "../common/expression"; +import * as notifications from "./notifications"; +import memoize from "../common/memoize"; +import putForm from "./components/put-form"; +import * as overlay from "./overlay"; + +const PAGE_SIZE = config.ui.pageSize || 10; + +const memoizedParse = memoize(expression.parse); + +const attributes = [ + { id: "_id", label: "Name" }, + { id: "script", label: "Script", type: "code" } +]; + +function putActoinHandler(action, object, isNew) { + if (action === "save") { + let id = object["_id"]; + delete object["_id"]; + + if (!id) return notifications.push("error", "ID can not be empty"); + + store + .resourceExists("provisions", id) + .then(exists => { + if (exists && isNew) { + notifications.push("error", "Provision already exists"); + store.fulfill(0, Date.now()); + return; + } + + if (!exists && !isNew) { + notifications.push("error", "Provision already deleted"); + store.fulfill(0, Date.now()); + return; + } + + store + .putResource("provisions", id, object) + .then(() => { + notifications.push( + "success", + `Provision ${exists ? "updated" : "created"}` + ); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } else if (action === "delete") { + store + .deleteResource("provisions", object["_id"]) + .then(() => { + notifications.push("success", "Provision deleted"); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } else { + throw new Error("Undefined action"); + } +} + +let formData = { + resource: "provisions", + attributes: attributes +}; + +const getDownloadUrl = memoize(filter => { + let cols = {}; + for (let 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 filter = args.filter; + return Promise.resolve({ filter }); +} + +function renderTable( + provisionsResponse, + total, + selected, + showMoreCallback, + downloadUrl +) { + const provisions = provisionsResponse.value; + const selectAll = m("input", { + type: "checkbox", + checked: provisions.length && selected.size === provisions.length, + onchange: e => { + for (let provision of provisions) + if (e.target.checked) selected.add(provision["_id"]); + else selected.delete(provision["_id"]); + }, + disabled: !total + }); + + const labels = [selectAll] + .concat(attributes.map(elem => elem.label)) + .map(l => m("th", l)); + + let rows = []; + for (let provision of provisions) { + let 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; + } + }); + + let tds = [m("td", checkbox)]; + for (let attr of attributes) + if (attr.id == "script") + tds.push(m("td", { title: provision[attr.id] }, provision[attr.id])); + else tds.push(m("td", provision[attr.id])); + + tds.push( + m( + "td.table-row-links", + m( + "a", + { + onclick: () => { + let cb = () => { + return m( + putForm, + Object.assign( + { + base: provision, + actionHandler: (action, object) => { + overlay.close(cb); + putActoinHandler(action, object, false); + } + }, + 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")) + ); + + let 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") + ); + + let 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); + }); + } + }, + "Delete" + ) + ]; + + if (window.authorizer.hasAccess("provisions", 3)) + buttons.push( + m( + "button.primary", + { + title: "Create new provision", + onclick: () => { + let cb = () => { + return m( + putForm, + Object.assign( + { + actionHandler: (action, object) => { + putActoinHandler(action, object, true); + overlay.close(cb); + } + }, + 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) { + m.route.set("/provisions", { filter }); + } + + const filter = vnode.attrs.filter + ? memoizedParse(vnode.attrs.filter) + : true; + + let provisions = store.fetch("provisions", filter, { + limit: vnode.state.showCount || PAGE_SIZE + }); + + let count = store.count("provisions", filter); + + let selected = new Set(); + if (vnode.state.selected) + for (let 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, { + predefined: [{ parameter: "_id" }], + filter: vnode.attrs.filter, + onChange: onFilterChanged + }), + renderTable(provisions, count.value, selected, showMore, downloadUrl) + ]; + } +}; + +export { init, component }; diff --git a/client/store.js b/client/store.js index f32764b..0a51b94 100644 --- a/client/store.js +++ b/client/store.js @@ -23,7 +23,13 @@ const queries = { }; const resources = {}; -for (let r of ["devices", "faults", "files", "presets"]) +for (let r of [ + "devices", + "faults", + "files", + "presets", + "provisions" +]) resources[r] = { objects: new Map(), count: new Map(), diff --git a/config.json b/config.json index 39ffa28..169ea95 100644 --- a/config.json +++ b/config.json @@ -42,6 +42,12 @@ "pass": true } }, + "provisions" : { + "access": 3, + "validate": { + "pass": true + } + }, "files": { "access": 3 } @@ -58,6 +64,9 @@ "presets": { "access": 2 }, + "provisions": { + "access": 2 + }, "files": { "access": 2 } diff --git a/package.json b/package.json index fca4ee2..a078821 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "author": "Zaid Abdulla ", "dependencies": { + "codemirror": "^5.40.0", "jsonwebtoken": "^8.3.0", "koa": "^2.5.2", "koa-bodyparser": "^4.2.1", diff --git a/server/api-functions.js b/server/api-functions.js index f70b57c..737fc2b 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -296,12 +296,33 @@ function putResource(resource, id, data) { if (resource === "presets") data = mongodbFunctions.preProcessPreset(data); - let body = JSON.stringify(data); + if (resource === "provisions") + data = mongodbFunctions.preProcessProvision(data); + + let body = typeof data !== "string" ? JSON.stringify(data) : data; _http .request(options, res => { - res.resume(); - if (res.statusCode === 200) resolve(true); - else 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", () => { + if (res.statusCode === 200) return resolve(); + else if (res.statusCode !== 400) + return reject( + new Error(`Unexpected status code ${res.statusCode}`) + ); + let buf = new Buffer(bytes); + let o = 0; + for (let c of chunks) { + c.copy(buf, o, 0, c.length); + o += c.length; + } + resolve(buf.toString()); + }); }) .end(body); }); diff --git a/server/api.js b/server/api.js index 3d4b7bb..970d608 100644 --- a/server/api.js +++ b/server/api.js @@ -31,7 +31,7 @@ const RESOURCE_IDS = { const resources = { devices: 0 | RESOURCE_DELETE, presets: 0 | RESOURCE_DELETE | RESOURCE_PUT, - provisions: 0 | RESOURCE_DELETE, + provisions: 0 | RESOURCE_DELETE | RESOURCE_PUT, files: 0 | RESOURCE_DELETE, virtual_parameters: 0 | RESOURCE_DELETE, faults: 0 | RESOURCE_DELETE, @@ -336,7 +336,14 @@ for (let [resource, flags] of Object.entries(resources)) { return (ctx.status = 403); } - await apiFunctions.putResource(resource, id, obj); + let err = await apiFunctions.putResource(resource, id, obj); + + if (err) { + log.message += " failed"; + logger.accessWarn(log); + ctx.body = err; + return (ctx.status = 400); + } logger.accessInfo(log); diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index ae4c585..428d683 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -489,6 +489,10 @@ function preProcessPreset(data) { return preset; } +function preProcessProvision(data) { + return data.script; +} + exports.processDeviceFilter = processDeviceFilter; exports.processTasksFilter = processTasksFilter; exports.processFaultsFilter = processFaultsFilter; @@ -500,3 +504,4 @@ exports.flattenFault = flattenFault; exports.flattenTask = flattenTask; exports.flattenPreset = flattenPreset; exports.preProcessPreset = preProcessPreset; +exports.preProcessProvision = preProcessProvision; diff --git a/server/server.js b/server/server.js index 020312c..adc5239 100644 --- a/server/server.js +++ b/server/server.js @@ -38,6 +38,7 @@ function getPermissionSets(roles) { "devices", "faults", "presets", + "provisions", "files" ].includes(k) )