Merge remote-tracking branch 'ui/master'
This commit is contained in:
commit
4530967b6b
70 files changed
+10951
-8
No files matched your search
@@ -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 };
|
||||
Reference in new issue
Block a user