diff --git a/build/build.ts b/build/build.ts index 23ccf12..c172311 100644 --- a/build/build.ts +++ b/build/build.ts @@ -52,7 +52,8 @@ const externals = [ "mithril", "parsimmon", "codemirror", - "codemirror/mode/javascript/javascript" + "codemirror/mode/javascript/javascript", + "codemirror/mode/yaml/yaml" ]; function rmDirSync(dirPath): void { diff --git a/lib/local-cache.ts b/lib/local-cache.ts index 7c1c701..cf04d49 100644 --- a/lib/local-cache.ts +++ b/lib/local-cache.ts @@ -363,26 +363,42 @@ function refresh(callback): void { conf.sort((a, b) => (a.id > b.id ? 1 : a.id < b.id ? -1 : 0)); + const ui = { + filters: {}, + device: {}, + index: {}, + overview: { charts: {}, groups: {} } + }; const _config = {}; for (const c of conf) { + // Evaluate expressions to simplify them + const val = expression.evaluate(c.value); + _config[c.id] = val; if (c.id.startsWith("ui.")) { const keys = c.id.split("."); - let ref = _config; + // remove the first key(ui) + keys.shift(); + let ref = ui; while (keys.length > 1) { const k = keys.shift(); if (typeof ref[k] !== "object") ref[k] = {}; ref = ref[k]; } - // Evaluate expressions to simplify them - ref[keys[0]] = expression.evaluate(c.value); - } else { - // Evaluate expressions to simplify them - _config[c.id] = expression.evaluate(c.value); + ref[keys[0]] = val; } } - resolve(_config); + if (!Object.keys(ui["index"]).length) { + ui["index"] = { + "0": { + label: "ID", + parameter: ["PARAM", "DeviceID.ID"] + } + }; + } + + resolve([_config, ui]); }); }) ); @@ -396,7 +412,8 @@ function refresh(callback): void { files: res[3], permissions: res[4], users: res[5], - config: res[6] + config: res[6][0], + ui: res[6][1] }; if (currentSnapshot) { @@ -536,5 +553,5 @@ export function getUiConfig(snapshotKey): {} { const snapshot = snapshots.get(snapshotKey); if (!snapshot) throw new Error("Cache snapshot does not exist"); - return snapshot.config.ui; + return snapshot.ui; } diff --git a/ui/code-editor-component.ts b/ui/code-editor-component.ts new file mode 100644 index 0000000..0eeadee --- /dev/null +++ b/ui/code-editor-component.ts @@ -0,0 +1,42 @@ +import { ClosureComponent, Component } from "mithril"; +import { m } from "./components"; +import { codeMirror } from "./dynamic-loader"; + +const component: ClosureComponent = (): Component => { + return { + view: vnode => { + return m("textarea", { + name: vnode.attrs["id"], + value: vnode.attrs["value"], + oncreate: _vnode => { + const editor = codeMirror.fromTextArea(_vnode.dom, { + mode: vnode.attrs["mode"], + lineNumbers: true, + readOnly: vnode.attrs["readOnly"], + extraKeys: { + "Ctrl-Enter": () => { + if (vnode.attrs["onSubmit"]) + vnode.attrs["onSubmit"](_vnode.dom); + }, + "Cmd-Enter": () => { + if (vnode.attrs["onSubmit"]) + vnode.attrs["onSubmit"](_vnode.dom); + } + } + }); + + if (vnode.attrs["onChange"]) { + editor.on("change", e => { + vnode.attrs["onChange"](e.getValue()); + }); + } + + if (vnode.attrs["focus"]) editor.focus(); + if (vnode.attrs["onReady"]) vnode.attrs["onReady"](editor); + } + }); + } + }; +}; + +export default component; diff --git a/ui/components/overview-dot.ts b/ui/components/overview-dot.ts index 4353d31..0dda69a 100644 --- a/ui/components/overview-dot.ts +++ b/ui/components/overview-dot.ts @@ -10,6 +10,7 @@ const component: ClosureComponent = (): Component => { view: vnode => { const device = vnode.attrs["device"]; const chart = CHARTS[vnode.attrs["chart"]]; + if (!chart) return null; for (const slice of Object.values(chart.slices)) { const filter = slice["filter"]; if (store.evaluateExpression(filter, device)) { diff --git a/ui/config-functions.ts b/ui/config-functions.ts new file mode 100644 index 0000000..154fe5e --- /dev/null +++ b/ui/config-functions.ts @@ -0,0 +1,92 @@ +interface Config { + _id: string; + value: string; +} + +interface Diff { + add: Config[]; + remove: string[]; +} + +export function flattenConfig(config): any { + const flatten = {}; + const recuresive = (obj: any, root: string): void => { + for (const [k, v] of Object.entries(obj)) { + const key = root ? `${root}.${k}` : k; + if (v === undefined) continue; + if (v === null || typeof v !== "object") flatten[key] = v; + else recuresive(v, key); + } + }; + + if (config !== null && typeof config === "object") recuresive(config, ""); + return flatten; +} + +export function structureConfig(config: Config[]): any { + config.sort((a, b) => (a._id > b._id ? 1 : a._id < b._id ? -1 : 0)); + const _config = {}; + for (const c of config) { + const keys = c._id.split("."); + let ref = _config; + while (keys.length > 1) { + const k = keys.shift(); + if (typeof ref[k] !== "object") ref[k] = {}; + ref = ref[k]; + } + ref[keys[0]] = c.value; + } + + const toArray = function(object): any { + const MAX_BITS = 30; + const MAX_ARRAY_SIZE = MAX_BITS * 10; + + if (object == null || typeof object !== "object") return object; + + if (Object.keys(object).length <= MAX_ARRAY_SIZE) { + let indexes = []; + for (const key of Object.keys(object)) { + const idx = Math.floor(+key); + if (idx >= 0 && idx < MAX_ARRAY_SIZE && String(idx) === key) { + const pos = Math.floor(idx / MAX_BITS); + if (!indexes[pos]) indexes[pos] = 0; + indexes[pos] |= 1 << idx % MAX_BITS; + } else { + indexes = []; + break; + } + } + + let index = 0; + while (indexes.length && (index = indexes.shift()) === 1073741823); + + if (index && (~index & (index + 1)) === index + 1) { + // its an array + const array = []; + for (let i = 0; i < Object.keys(object).length; i++) + array[i] = object[i]; + + object = array; + } + } + + for (const [k, v] of Object.entries(object)) object[k] = toArray(v); + return object; + }; + + return toArray(_config); +} + +export function diffConfig(current, target): Diff { + const diff = { + add: [], + remove: [] + }; + + for (const [k, v] of Object.entries(target)) + if (!current[k] || current[k] !== v) diff.add.push({ _id: k, value: v }); + + for (const k of Object.keys(current)) if (!target[k]) diff.remove.push(k); + + return diff; +} diff --git a/ui/config-page.ts b/ui/config-page.ts index 1c2e466..a88ed18 100644 --- a/ui/config-page.ts +++ b/ui/config-page.ts @@ -2,9 +2,10 @@ import { m } from "./components"; import * as store from "./store"; import * as notifications from "./notifications"; import putFormComponent from "./put-form-component"; +import uiConfigComponent from "./ui-config-component"; import * as overlay from "./overlay"; import * as expressionParser from "../lib/common/expression-parser"; -import { loadCodeMirror } from "./dynamic-loader"; +import { loadCodeMirror, loadYaml } from "./dynamic-loader"; import { ClosureComponent, Component, Children } from "mithril"; const attributes = [ @@ -92,7 +93,7 @@ export function init(): Promise<{}> { ); } return new Promise((resolve, reject) => { - loadCodeMirror() + Promise.all([loadCodeMirror(), loadYaml()]) .then(() => { resolve({}); }) @@ -209,7 +210,11 @@ export const component: ClosureComponent = (): Component => { } }); + const confs = store.fetch("config", true); + let newConfig; + const subs = []; + if (window.authorizer.hasAccess("config", 3)) { newConfig = m( "button.primary", @@ -250,11 +255,75 @@ export const component: ClosureComponent = (): Component => { overlay.open(cb); } }, - "New" + "New config" ); - } - const confs = store.fetch("config", true); + const subsData = [ + { name: "overview", prefix: "ui.overview.groups.", data: [] }, + { name: "charts", prefix: "ui.overview.charts.", data: [] }, + { name: "filters", prefix: "ui.filters.", data: [] }, + { name: "index page", prefix: "ui.index.", data: [] }, + { name: "device page", prefix: "ui.device.", data: [] } + ]; + + if (confs.fulfilled) { + for (const conf of confs.value) { + for (const sub of subsData) { + if (conf["_id"].startsWith(sub["prefix"])) { + sub["data"].push(conf); + break; + } + } + } + } + + for (const sub of subsData) { + const attrs = { prefix: sub.prefix, name: sub.name, data: sub.data }; + subs.push( + m( + "button", + { + onclick: () => { + const cb = (): Children => { + return m( + uiConfigComponent, + Object.assign( + { + onUpdate: errs => { + const errors = errs ? Object.values(errs) : []; + if (errors.length) { + for (const err of errors) + notifications.push("error", err); + } else { + notifications.push( + "success", + `${sub.name.replace( + /^[a-z]/, + sub.name[0].toUpperCase() + )} config updated` + ); + overlay.close(cb); + } + store.fulfill(0, Date.now()); + }, + onError: err => { + notifications.push("error", err.message); + store.fulfill(0, Date.now()); + overlay.close(cb); + } + }, + attrs + ) + ); + }; + overlay.open(cb); + } + }, + `Edit ${sub.name}` + ) + ); + } + } return [ m("h1", "Listing config"), @@ -266,7 +335,7 @@ export const component: ClosureComponent = (): Component => { { style: "height: 400px" }, renderTable(confs, vnode.state["searchString"]) ), - m(".actions-bar", newConfig) + m(".actions-bar", [newConfig].concat(subs)) ) ]; } diff --git a/ui/dynamic-loader.ts b/ui/dynamic-loader.ts index 5fb0db3..ef1af42 100644 --- a/ui/dynamic-loader.ts +++ b/ui/dynamic-loader.ts @@ -7,7 +7,8 @@ export function loadCodeMirror(): Promise { return new Promise((resolve, reject) => { const promises = [ import(/* webpackChunkName: "codemirror" */ "codemirror"), - import(/* webpackChunkName: "codemirror" */ "codemirror/mode/javascript/javascript") + import(/* webpackChunkName: "codemirror" */ "codemirror/mode/javascript/javascript"), + import(/* webpackChunkName: "codemirror" */ "codemirror/mode/yaml/yaml") ]; Promise.all(promises) .then(modules => { diff --git a/ui/put-form-component.ts b/ui/put-form-component.ts index 4b0b09a..09fce37 100644 --- a/ui/put-form-component.ts +++ b/ui/put-form-component.ts @@ -1,6 +1,6 @@ import { VnodeDOM, ClosureComponent, Component, Children } from "mithril"; import { m } from "./components"; -import { codeMirror } from "./dynamic-loader"; +import codeEditorComponent from "./code-editor-component"; const singular = { presets: "preset", @@ -59,34 +59,18 @@ function createField(current, attr, focus): Children { return m("table", options); } else if (attr.type === "code") { - return m("textarea", { - name: attr.id, + const attrs = { + id: attr.id, value: current.object[attr.id], - oncreate: _vnode => { - const editor = codeMirror.fromTextArea(_vnode.dom, { - mode: "javascript", - lineNumbers: true, - extraKeys: { - "Ctrl-Enter": () => { - ((_vnode.dom as HTMLTextAreaElement).form.querySelector( - "button[type=submit]" - ) as HTMLButtonElement).click(); - }, - "Cmd-Enter": () => { - ((_vnode.dom as HTMLTextAreaElement).form.querySelector( - "button[type=submit]" - ) as HTMLButtonElement).click(); - } - } - }); - - editor.on("change", e => { - current.object[attr.id] = e.getValue(); - }); - - if (focus) editor.focus(); + mode: "javascript", + onSubmit: dom => { + dom.form.querySelector("button[type=submit]").click(); + }, + onChange: value => { + current.object[attr.id] = value; } - }); + }; + return m(codeEditorComponent, attrs); } else if (attr.type === "file") { return m("input", { type: "file", diff --git a/ui/store.ts b/ui/store.ts index 3988670..c65ab78 100644 --- a/ui/store.ts +++ b/ui/store.ts @@ -520,6 +520,15 @@ export function putResource(resourceType, id, object): Promise { }); } +export function queryConfig(pattern = "%"): Promise { + const filter = stringify(["LIKE", ["PARAM", "_id"], pattern]); + return m.request({ + method: "GET", + url: `api/config/?${m.buildQueryString({ filter: filter })}`, + background: true + }); +} + export function resourceExists(resource, id): Promise { const param = resource === "devices" ? "DeviceID.ID" : "_id"; const filter = ["=", ["PARAM", param], id]; diff --git a/ui/ui-config-component.ts b/ui/ui-config-component.ts new file mode 100644 index 0000000..063af6d --- /dev/null +++ b/ui/ui-config-component.ts @@ -0,0 +1,126 @@ +import { ClosureComponent, Component } from "mithril"; +import { m } from "./components"; +import * as store from "./store"; +import { yaml } from "./dynamic-loader"; +import * as configFunctions from "./config-functions"; +import codeEditorComponent from "./code-editor-component"; +import { parse } from "../lib/common/expression-parser"; + +function putActionHandler(prefix: string[], dataYaml: string): Promise { + return new Promise((resolve, reject) => { + try { + let updated = yaml.parse(dataYaml, { schema: "failsafe" }); + if (updated) { + const config = {}; + let ref = config; + prefix.forEach((seg, index) => { + if (index < prefix.length - 1) { + ref[seg] = {}; + ref = ref[seg]; + } else { + ref[seg] = updated; + } + }); + updated = configFunctions.flattenConfig(config); + } else { + updated = {}; + } + + // Try parse to ensure valid expressions + for (const v of Object.values(updated)) parse(v); + + store + .queryConfig(`${prefix.join(".")}.%`) + .then(res => { + const current = {}; + for (const f of res) current[f._id] = f.value; + + const diff = configFunctions.diffConfig(current, updated); + if (!diff.add.length && !diff.remove.length) return void resolve(); + + const promises = []; + + for (const obj of diff.add) + promises.push(store.putResource("config", obj._id, obj)); + + for (const id of diff.remove) + promises.push(store.deleteResource("config", id)); + + Promise.all(promises) + .then(() => { + resolve(); + }) + .catch(reject); + }) + .catch(reject); + } catch (error) { + resolve({ config: error.message }); + } + }); +} + +const component: ClosureComponent = (): Component => { + return { + view: vnode => { + const prefix = vnode.attrs["prefix"].split("."); + const name = vnode.attrs["name"]; + const data = vnode.attrs["data"]; + + if (prefix[prefix.length - 1] === "") prefix.pop(); + + let config; + if (data.length) { + config = configFunctions.structureConfig(data); + for (const seg of prefix) config = config[seg]; + } + + const yamlString = + config && Object.values(config).length + ? yaml.stringify(config, { + schema: "failsafe", + tags: args => { + return args; + } + }) + : ""; + + const attrs = { + id: `${name}-ui-config`, + value: yamlString, + mode: "yaml", + focus: true, + onSubmit: dom => { + dom.form.querySelector("button[type=submit]").click(); + }, + onChange: value => { + vnode.state["updatedYaml"] = value; + } + }; + + const code = m(codeEditorComponent, attrs); + const submit = m("button.primary", { type: "submit" }, "Save"); + + return m("div.put-form", [ + m("h1", `Editing ${name}`), + m( + "form", + { + onsubmit: e => { + e.redraw = false; + e.preventDefault(); + if (vnode.state["updatedYaml"] == null) + vnode.state["updatedYaml"] = yamlString; + + putActionHandler(prefix, vnode.state["updatedYaml"]) + .then(vnode.attrs["onUpdate"]) + .catch(vnode.attrs["onError"]); + } + }, + [code, m(".actions-bar", [submit])] + ) + ]); + } + }; +}; + +export default component;