refactor: replace array-based expressions with typed class hierarchy

Replace the old Lisp-like array expression representation
(["AND", ["PARAM", ...], ...]) with a class hierarchy rooted in
an abstract Expression class (Literal, Parameter, Binary, Unary,
FunctionCall, Conditional). This gives compile-time type safety
to expression construction, evaluation, and pattern matching.

Key changes:
- New lib/common/expression.ts defines the class hierarchy with
  map/evaluate/toString methods
- Rewrite parser as hand-rolled recursive descent, removing the
  parsimmon dependency
- Extend Path with a colon separator to unify parameter identity
  and attributes into a single address space (e.g.
  "Device.WiFi.SSID.1.Name:writable"). The colon field tracks
  how many trailing segments are attribute qualifiers. PathSet
  rewritten with separate param/attr segment indexes
- Flatten device attribute representation in the UI to
  colon-delimited keys mirroring the new path syntax
This commit is contained in:
Zaid Humaimidi committed 2026-03-12 14:58:20 -07:00
1 parent 75908e624f
commit 6f3ce6f7c8
71 files changed
+4045 -3449

No files matched your search

+21 -3
View File
@@ -20,19 +20,37 @@ import { contextifyComponent } from "./components.ts";
import { PermissionSet, UiConfig } from "../lib/types.ts";
import drawerComponent from "./drawer-component.ts";
import { render as renderOverlay } from "./overlay.ts";
import Expression from "../lib/common/expression.ts";
declare global {
interface Window {
authorizer: Authorizer;
permissionSets: PermissionSet[];
permissionSets: {
[resource: string]: { access: number; validate: string; filter: string };
}[][];
username: string;
clientConfig: { ui: UiConfig };
clientConfig: UiConfig;
configSnapshot: string;
genieacsVersion: string;
}
}
window.authorizer = new Authorizer(window.permissionSets);
const permissionSets: PermissionSet[] = window.permissionSets.map((p) =>
p.map((s) =>
Object.fromEntries(
Object.entries(s).map(([resource, { access, validate, filter }]) => [
resource,
{
access,
validate: Expression.parse(validate),
filter: Expression.parse(filter),
},
]),
),
),
);
window.authorizer = new Authorizer(permissionSets);
let state;
+57 -35
View File
@@ -1,26 +1,41 @@
import { ClosureComponent } from "mithril";
import { m } from "../components.ts";
import * as taskQueue from "../task-queue.ts";
import { parse } from "../../lib/common/expression/parser.ts";
import memoize from "../../lib/common/memoize.ts";
import { icon } from "../tailwind-utility-components.ts";
import { QueryResponse, evaluateExpression } from "../store.ts";
import debounce from "../../lib/common/debounce.ts";
import { Expression } from "../../lib/types.ts";
import Expression, { Value } from "../../lib/common/expression.ts";
import { FlatDevice } from "../../lib/ui/db.ts";
const memoizedParse = memoize(parse);
import Path from "../../lib/common/path.ts";
function escapeRegExp(str): string {
return str.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&");
}
const keysByDepth: WeakMap<Record<string, unknown>, string[][]> = new WeakMap();
interface Parameter {
path: Expression.Parameter;
value?: Value;
writable?: boolean;
object?: boolean;
}
function orderKeysByDepth(device: Record<string, unknown>): string[][] {
if (keysByDepth.has(device)) return keysByDepth.get(device);
const res: string[][] = [];
for (const key of Object.keys(device)) {
const prepareParams = memoize((device: FlatDevice): Parameter[][] => {
const map = new Map<string, Parameter>();
for (const [k, v] of Object.entries(device)) {
const [param, attr] = k.split(":");
let attrs = map.get(param);
if (!attrs)
map.set(
param,
(attrs = { path: new Expression.Parameter(Path.parse(param)) }),
);
attrs[attr || "value"] = v;
}
const res: Parameter[][] = [];
for (const [key, attrs] of map) {
let count = 0;
for (
let i = key.lastIndexOf(".", key.length - 2);
@@ -29,11 +44,10 @@ function orderKeysByDepth(device: Record<string, unknown>): string[][] {
)
++count;
while (res.length <= count) res.push([]);
res[count].push(key);
res[count].push(attrs);
}
keysByDepth.set(device, res);
return res;
}
});
interface Attrs {
device: FlatDevice;
@@ -51,9 +65,13 @@ const component: ClosureComponent<Attrs> = () => {
return {
view: (vnode) => {
const device = vnode.attrs.device;
const allParams = prepareParams(device);
const limit =
(evaluateExpression(vnode.attrs.limit, device) as number) || 100;
let limit = 100;
if (vnode.attrs.limit) {
const l = evaluateExpression(vnode.attrs.limit, device);
if (typeof l.value === "number") limit = l.value;
}
const search = m(
"input.appearance-none border-0 block w-full px-4 py-3 border-stone-300 placeholder-stone-500 text-stone-900 focus:ring-cyan-500 text-sm rounded-t-lg font-mono focus:ring-2",
@@ -75,35 +93,36 @@ const component: ClosureComponent<Attrs> = () => {
re = new RegExp(keywords.map((s) => escapeRegExp(s)).join(".*"), "i");
}
const filteredKeys: string[] = [];
const allKeys = orderKeysByDepth(device);
const filteredParams: Parameter[] = [];
let count = 0;
for (const keys of allKeys) {
for (const keys of allParams) {
let c = 0;
for (const k of keys) {
const p = device[k];
const str = p.value?.[0] ? `${k} ${p.value[0]}` : k;
const str = k.value ? `${k.path.toString()} ${k.value}` : k;
if (re && !re.test(str)) continue;
++c;
if (count < limit) filteredKeys.push(k);
if (count < limit) filteredParams.push(k);
}
count += c;
}
filteredKeys.sort();
filteredParams.sort((a, b) => {
if (a.path < b.path) return -1;
if (a.path > b.path) return 1;
return 0;
});
const rows = filteredKeys.map((k) => {
const p = device[k];
const rows = filteredParams.map((p) => {
const val = [];
if (p.object === false) {
if (p.value) {
val.push(
m(
"parameter",
Object.assign({ device: device, parameter: memoizedParse(k) }),
Object.assign({ device: device, parameter: p.path }),
),
);
} else if (p.object && p.writable) {
if (instanceRegex.test(k)) {
if (instanceRegex.test(p.path.toString())) {
val.push(
m(
"button",
@@ -112,8 +131,8 @@ const component: ClosureComponent<Attrs> = () => {
onclick: () => {
taskQueue.queueTask({
name: "deleteObject",
device: device["DeviceID.ID"].value[0] as string,
objectName: k,
device: device["DeviceID.ID"] as string,
objectName: p.path.toString(),
});
},
},
@@ -133,8 +152,8 @@ const component: ClosureComponent<Attrs> = () => {
onclick: () => {
taskQueue.queueTask({
name: "addObject",
device: device["DeviceID.ID"].value[0] as string,
objectName: k,
device: device["DeviceID.ID"] as string,
objectName: p.path.toString(),
});
},
},
@@ -156,8 +175,8 @@ const component: ClosureComponent<Attrs> = () => {
onclick: () => {
taskQueue.queueTask({
name: "getParameterValues",
device: device["DeviceID.ID"].value[0] as string,
parameterNames: [k],
device: device["DeviceID.ID"] as string,
parameterNames: [p.path.toString()],
});
},
},
@@ -170,7 +189,10 @@ const component: ClosureComponent<Attrs> = () => {
return m(
"tr",
m("td.pl-4 pr-2 py-2 truncate", m("long-text", { text: k })),
m(
"td.pl-4 pr-2 py-2 truncate",
m("long-text", { text: p.path.toString() }),
),
m("td.pr-4 py-2 text-right flex justify-end", val),
);
});
@@ -195,7 +217,7 @@ const component: ClosureComponent<Attrs> = () => {
m(
"span.text-xs",
"Displaying ",
m("span.font-medium", "" + filteredKeys.length),
m("span.font-medium", "" + filteredParams.length),
" out of ",
m("span.font-medium", "" + count),
" parameters",
@@ -204,7 +226,7 @@ const component: ClosureComponent<Attrs> = () => {
"a.text-cyan-700 hover:text-cyan-900 text-sm font-medium",
{
href: `api/devices/${encodeURIComponent(
device["DeviceID.ID"].value[0],
device["DeviceID.ID"],
)}.csv`,
download: "",
},
+16 -15
View File
@@ -1,7 +1,6 @@
import { Attributes, ClosureComponent } from "mithril";
import { map } from "../../lib/common/expression/parser.ts";
import memoize from "../../lib/common/memoize.ts";
import { Expression } from "../../lib/types.ts";
import Expression from "../../lib/common/expression.ts";
import { m } from "../components.ts";
import { evaluateExpression, getTimestamp } from "../store.ts";
import { FlatDevice } from "../../lib/ui/db.ts";
@@ -14,15 +13,16 @@ const evaluateAttributes = memoize(
): Attributes => {
const res: Attributes = {};
for (const [k, v] of Object.entries(attrs)) {
const vv = map(v, (e) => {
if (
Array.isArray(e) &&
e[0] === "FUNC" &&
e[1] === "ENCODEURICOMPONENT"
) {
const a = evaluateExpression(e[2], obj);
if (a == null) return null;
return encodeURIComponent(a as string);
const vv = v.evaluate((e) => {
if (e instanceof Expression.Literal) return e;
else if (e instanceof Expression.FunctionCall) {
if (e.name === "ENCODEURICOMPONENT") {
const a = evaluateExpression(e.args[0], obj);
if (a instanceof Expression.Literal) {
if (a.value == null) return new Expression.Literal(null);
return new Expression.Literal(encodeURIComponent(a.value));
}
}
}
return e;
});
@@ -47,9 +47,10 @@ const component: ClosureComponent<Attrs> = () => {
}
const children = Object.values(vnode.attrs.components).map((c) => {
if (Array.isArray(c)) c = evaluateExpression(c, device || {});
if (c instanceof Expression)
c = evaluateExpression(c, device || {}).value;
if (typeof c !== "object") return `${c}`;
const type = evaluateExpression(c["type"], device || {});
const type = evaluateExpression(c["type"], device || {}).value;
if (!type) return null;
return m(type as string, c);
});
@@ -59,8 +60,8 @@ const component: ClosureComponent<Attrs> = () => {
if (el == null) return children;
let attrs: Attributes;
if (Array.isArray(el)) {
el = evaluateExpression(el, device || {});
if (el instanceof Expression) {
el = evaluateExpression(el, device || {}).value;
} else if (typeof el === "object") {
if (el["attributes"] != null) {
attrs = evaluateAttributes(
+4 -4
View File
@@ -19,7 +19,7 @@ const component: ClosureComponent = (): Component => {
onclick: () => {
taskQueue.queueTask({
name: "reboot",
device: device["DeviceID.ID"].value[0],
device: device["DeviceID.ID"],
});
},
},
@@ -35,7 +35,7 @@ const component: ClosureComponent = (): Component => {
onclick: () => {
taskQueue.queueTask({
name: "factoryReset",
device: device["DeviceID.ID"].value[0],
device: device["DeviceID.ID"],
});
},
},
@@ -51,7 +51,7 @@ const component: ClosureComponent = (): Component => {
onclick: () => {
taskQueue.stageDownload({
name: "download",
devices: [device["DeviceID.ID"].value[0]],
devices: [device["DeviceID.ID"]],
});
},
},
@@ -66,7 +66,7 @@ const component: ClosureComponent = (): Component => {
title: "Delete device",
onclick: () => {
if (!confirm("Deleting this device. Are you sure?")) return;
const deviceId = device["DeviceID.ID"].value[0];
const deviceId = device["DeviceID.ID"];
store
.deleteResource("devices", deviceId)
+13 -6
View File
@@ -3,17 +3,24 @@ import { m } from "../components.ts";
import * as store from "../store.ts";
import * as notifications from "../notifications.ts";
import { stringify } from "../../lib/common/yaml.ts";
import Expression from "../../lib/common/expression.ts";
import Path from "../../lib/common/path.ts";
const component: ClosureComponent = (): Component => {
return {
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 deviceId = device["DeviceID.ID"];
const p = new Expression.Parameter(Path.parse("_id"));
const exp = Expression.and(
new Expression.Binary(">", p, new Expression.Literal(`${deviceId}:`)),
new Expression.Binary(
"<",
p,
new Expression.Literal(`${deviceId}:zzzz`),
),
);
const faults = store.fetch("faults", exp);
const headers = [
"Channel",
+5 -3
View File
@@ -1,18 +1,20 @@
import { ClosureComponent, Component } from "mithril";
import { m } from "../components.ts";
import { evaluateExpression } from "../store.ts";
import Expression from "../../lib/common/expression.ts";
const component: ClosureComponent = (): Component => {
return {
view: (vnode) => {
let deviceId;
const device = vnode.attrs["device"];
if (device) deviceId = device["DeviceID.ID"].value[0];
if (device) deviceId = device["DeviceID.ID"];
const children = Object.values(vnode.attrs["components"]).map((c) => {
if (Array.isArray(c)) c = evaluateExpression(c, device || {});
if (c instanceof Expression)
c = evaluateExpression(c, device ?? {}).value;
if (typeof c !== "object") return `${c}`;
const type = evaluateExpression(c["type"], device || {});
const type = evaluateExpression(c["type"], device ?? {}).value;
if (!type) return null;
const attrs = Object.assign({}, vnode.attrs, c);
return m(type as string, attrs);
+12 -9
View File
@@ -1,20 +1,23 @@
import { ClosureComponent, Component } from "mithril";
import { m } from "../components.ts";
import config from "../config.ts";
import { overview } from "../config.ts";
import { evaluateExpression } from "../store.ts";
const CHARTS = config.ui.overview.charts;
const CHARTS = overview.charts;
const component: ClosureComponent = (): Component => {
return {
view: (vnode) => {
const device = vnode.attrs["device"];
const chartName = evaluateExpression(vnode.attrs["chart"], device || {});
const chart = CHARTS[chartName as string] as Record<string, unknown>;
const chartName = evaluateExpression(
vnode.attrs["chart"],
device ?? {},
).value;
const chart = CHARTS[chartName as string];
if (!chart) return null;
for (const slice of Object.values(chart.slices)) {
const filter = slice["filter"];
if (evaluateExpression(filter, device || {})) {
for (const slice of chart.slices) {
const filter = slice.filter;
if (evaluateExpression(filter, device ?? {}).value) {
const dot = m(
"svg.inline",
{
@@ -28,10 +31,10 @@ const component: ClosureComponent = (): Component => {
cx: "0.5em",
cy: "0.5em",
r: "0.4em",
fill: evaluateExpression(slice["color"], null),
fill: slice.color,
}),
);
return m("span", dot, evaluateExpression(slice["label"], null));
return m("span", dot, slice.label);
}
}
return null;
+7 -3
View File
@@ -2,7 +2,7 @@ import { ClosureComponent, VnodeDOM } from "mithril";
import { m } from "../components.ts";
import { QueryResponse, evaluateExpression } from "../store.ts";
import { FlatDevice } from "../../lib/ui/db.ts";
import { Expression } from "../../lib/types.ts";
import Expression from "../../lib/common/expression.ts";
interface Attrs {
device: FlatDevice;
@@ -19,7 +19,11 @@ const component: ClosureComponent<Attrs> = () => {
const device = vnode.attrs.device;
const rows = Object.values(vnode.attrs.parameters).map((parameter) => {
const type = evaluateExpression(parameter.type, device);
let type = "parameter";
if (parameter.type) {
const t = evaluateExpression(parameter.type, device);
if (typeof t.value === "string") type = t.value;
}
const p = m.context(
{
device: device,
@@ -45,7 +49,7 @@ const component: ClosureComponent<Attrs> = () => {
},
m(
"dt.text-sm font-medium text-stone-500",
evaluateExpression(parameter["label"], device),
evaluateExpression(parameter["label"], device).value,
),
m("dd.text-sm text-stone-900 col-span-2", p),
);
+40 -33
View File
@@ -2,47 +2,45 @@ import { ClosureComponent } from "mithril";
import { m } from "../components.ts";
import * as taskQueue from "../task-queue.ts";
import { QueryResponse, evaluateExpression } from "../store.ts";
import * as expressionParser from "../../lib/common/expression/parser.ts";
import { icon } from "../tailwind-utility-components.ts";
import { FlatDevice } from "../../lib/ui/db.ts";
import { Expression } from "../../lib/types.ts";
import Expression from "../../lib/common/expression.ts";
import Path from "../../lib/common/path.ts";
interface Attrs {
device: FlatDevice;
parameter: Expression;
label: Expression;
childParameters: Record<string, { label: string; parameter: Expression }>;
childParameters: Record<string, { label: Expression; parameter: Expression }>;
filter?: Expression;
deviceQuery: QueryResponse;
}
const component: ClosureComponent<Attrs> = () => {
let object: Path;
let parameters: { label: Expression; parameter: Expression }[];
return {
oninit: (vnode) => {
const obj = vnode.attrs.parameter;
if (!Array.isArray(obj) || obj[0] !== "PARAM")
if (!(obj instanceof Expression.Parameter))
throw new Error("Object must be a parameter path");
vnode.state["object"] = obj[1];
vnode.state["parameters"] = Object.values(vnode.attrs.childParameters);
object = obj.path;
parameters = Object.values(vnode.attrs.childParameters);
},
view: (vnode) => {
const device = vnode.attrs.device;
const object = evaluateExpression(vnode.state["object"], device);
const parameters = vnode.state["parameters"];
if (typeof object !== "string" || !device[object]) return null;
const instances: Set<string> = new Set();
const prefix = `${object}.`;
const prefix = `${object.toString()}.`;
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));
}
if (!p.startsWith(prefix)) continue;
if (p.lastIndexOf(":") !== -1) continue;
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, i) => {
const headers = parameters.map((p, i) => {
const padding = i ? "px-3" : "pl-6 pr-3";
return m(
@@ -53,7 +51,7 @@ const component: ClosureComponent<Attrs> = () => {
"py-3.5 text-left text-sm font-semibold text-stone-500 " +
padding,
},
evaluateExpression(p["label"], device),
evaluateExpression(p.label, device).value,
);
});
@@ -63,24 +61,33 @@ const component: ClosureComponent<Attrs> = () => {
const rows = [];
for (const i of instances) {
let filter = "filter" in vnode.attrs ? vnode.attrs.filter : true;
let filter: Expression =
"filter" in vnode.attrs
? vnode.attrs.filter
: new Expression.Literal(true);
filter = expressionParser.map(filter, (e) => {
if (Array.isArray(e) && e[0] === "PARAM")
return ["PARAM", ["||", i, ".", e[1]]];
const root = Path.parse(i);
filter = filter.evaluate((e) => {
if (e instanceof Expression.Parameter)
return new Expression.Parameter(root.concat(e.path));
return e;
});
if (!evaluateExpression(filter, device)) continue;
if (!evaluateExpression(filter, device).value) continue;
const row = parameters.map((p, j) => {
const padding = j ? "px-3" : "pl-6 pr-3";
const param = expressionParser.map(p.parameter, (e) => {
if (Array.isArray(e) && e[0] === "PARAM")
return ["PARAM", ["||", i, ".", e[1]]];
const param = p.parameter.evaluate((e) => {
if (e instanceof Expression.Parameter)
return new Expression.Parameter(root.concat(e.path));
return e;
});
let type = "parameter";
if (p["type"] instanceof Expression)
type = evaluateExpression(p["type"], device).value + "";
return m(
"td",
{
@@ -91,7 +98,7 @@ const component: ClosureComponent<Attrs> = () => {
device: device,
parameter: param,
},
p.type || "parameter",
type,
Object.assign({}, p, {
device: device,
parameter: param,
@@ -101,7 +108,7 @@ const component: ClosureComponent<Attrs> = () => {
);
});
if (device[i].writable === true) {
if (device[i + ":writable"]) {
row.push(
m(
"td",
@@ -116,7 +123,7 @@ const component: ClosureComponent<Attrs> = () => {
onclick: () => {
taskQueue.queueTask({
name: "deleteObject",
device: device["DeviceID.ID"].value[0] as string,
device: device["DeviceID.ID"] as string,
objectName: i,
});
},
@@ -147,7 +154,7 @@ const component: ClosureComponent<Attrs> = () => {
);
}
if (device[object].writable === true) {
if (device[object.toString() + ":writable"]) {
rows.push(
m(
"tr",
@@ -161,8 +168,8 @@ const component: ClosureComponent<Attrs> = () => {
onclick: () => {
taskQueue.queueTask({
name: "addObject",
device: device["DeviceID.ID"].value[0] as string,
objectName: object,
device: device["DeviceID.ID"] as string,
objectName: object.toString(),
});
},
},
+42 -48
View File
@@ -2,54 +2,52 @@ import { ClosureComponent, Component, VnodeDOM } from "mithril";
import { m } from "../components.ts";
import * as taskQueue from "../task-queue.ts";
import * as store from "../store.ts";
import { evaluate } from "../../lib/common/expression/util.ts";
import Expression, { Value } from "../../lib/common/expression.ts";
import memoize from "../../lib/common/memoize.ts";
import timeAgo from "../timeago.ts";
import { icon } from "../tailwind-utility-components.ts";
const evaluateParam = memoize((exp, obj, now: number) => {
let timestamp = now;
exp = evaluate(exp, null, now, (e) => {
if (!Array.isArray(e)) return e;
for (let i = 1; i < e.length; ++i) {
if (
Array.isArray(e[i]) &&
e[i][0] === "PARAM" &&
!Array.isArray(e[i][1])
) {
let v = null;
const p = obj[e[i][1]];
if (p?.value) {
v = p.value[0];
timestamp = Math.min(timestamp, p.valueTimestamp);
const evaluateParam = memoize(
(
exp: Expression,
obj: any,
now: number,
): { value: Value; timestamp: number; parameter: string } => {
let timestamp = now;
const valueMap: Map<Expression.Literal, string> = new Map();
const lit = exp.evaluate((e): Expression.Literal => {
if (e instanceof Expression.Literal) return e;
if (e instanceof Expression.Parameter) {
let v = obj[e.path.toString()];
if (v) {
timestamp = Math.min(
timestamp,
obj[e.path.toString() + ":valueTimestamp"] ?? 0,
);
const t = obj[e.path.toString() + ":type"];
if (t === "xsd:dateTime" && typeof v === "number")
v = new Date(v).toLocaleString();
const val = new Expression.Literal(v);
valueMap.set(val, e.path.toString());
return val;
}
} else if (e instanceof Expression.FunctionCall) {
if (e.name === "NOW") return new Expression.Literal(now);
else if (e.name === "DATE_STRING") {
const v = e.args[0];
if (v instanceof Expression.Literal) {
return new Expression.Literal(
new Date(v.value as string | number).toLocaleString(),
);
}
}
e = e.slice();
e[i] = v;
}
}
if (e[0] === "FUNC" && e[1] === "DATE_STRING" && !Array.isArray(e[2]))
return new Date(e[2]).toLocaleString();
return e;
});
return new Expression.Literal(null);
});
let parameter = null;
let value = null;
if (!Array.isArray(exp)) {
value = exp;
} else if (exp[0] === "PARAM") {
const p = obj[exp[1]];
if (p?.value) {
timestamp = p.valueTimestamp;
value = p.value[0];
parameter = exp[1];
if (p.value[1] === "xsd:dateTime" && typeof value === "number")
value = new Date(value).toLocaleString();
}
}
return { value, timestamp, parameter };
});
return { value: lit.value, timestamp, parameter: valueMap.get(lit) };
},
);
const component: ClosureComponent = (): Component => {
return {
@@ -65,7 +63,7 @@ const component: ClosureComponent = (): Component => {
if (value == null) return null;
let edit;
if (device[parameter]?.writable) {
if (device[parameter + ":writable"]) {
edit = m(
"button",
{
@@ -73,13 +71,9 @@ const component: ClosureComponent = (): Component => {
onclick: () => {
taskQueue.stageSpv({
name: "setParameterValues",
devices: [device["DeviceID.ID"].value[0]],
devices: [device["DeviceID.ID"]],
parameterValues: [
[
parameter,
device[parameter].value[0],
device[parameter].value[1],
],
[parameter, device[parameter], device[parameter + ":type"]],
],
});
},
+14 -6
View File
@@ -1,13 +1,20 @@
import { ClosureComponent, Component } from "mithril";
import { ClosureComponent } from "mithril";
import { m } from "../components.ts";
import * as taskQueue from "../task-queue.ts";
import * as store from "../store.ts";
import * as notifications from "../notifications.ts";
import Expression from "../../lib/common/expression.ts";
import { FlatDevice } from "../../lib/ui/db.ts";
const component: ClosureComponent = (): Component => {
interface Attrs {
device: FlatDevice;
parameters: Record<string, Expression>;
}
const component: ClosureComponent<Attrs> = () => {
return {
view: (vnode) => {
const device = vnode.attrs["device"];
const device = vnode.attrs.device;
return m(
"button",
@@ -19,15 +26,16 @@ const component: ClosureComponent = (): Component => {
e.target.disabled = true;
const params = Object.values(vnode.attrs["parameters"])
.map((exp) => {
if (!Array.isArray(exp) || exp[0] !== "PARAM") return null;
return store.evaluateExpression(exp[1], device) as string;
if (exp instanceof Expression.Parameter)
return exp.path.toString();
return null;
})
.filter((exp) => !!exp);
const task = {
name: "getParameterValues",
parameterNames: params,
device: device["DeviceID.ID"].value[0],
device: device["DeviceID.ID"] as string,
};
taskQueue
+5 -4
View File
@@ -4,7 +4,7 @@ import * as notifications from "../notifications.ts";
import * as store from "../store.ts";
import { icon } from "../tailwind-utility-components.ts";
import { decodeTag } from "../../lib/util.ts";
import { Expression } from "../../lib/types.ts";
import Expression from "../../lib/common/expression.ts";
import { FlatDevice } from "../../lib/ui/db.ts";
interface Attrs {
@@ -22,7 +22,8 @@ const component: ClosureComponent<Attrs> = () => {
const tags = [];
for (const p of Object.keys(device))
if (p.startsWith("Tags.")) tags.push(decodeTag(p.slice(5)));
if (p.startsWith("Tags.") && p.lastIndexOf(":") === -1)
tags.push(decodeTag(p.slice(5)));
tags.sort();
@@ -60,7 +61,7 @@ const component: ClosureComponent<Attrs> = () => {
"flex-shrink-0 ml-0.5 h-4 w-4 rounded-full inline-flex items-center justify-center text-yellow-400 hover:bg-yellow-200 hover:text-yellow-500 focus:outline-hidden focus:bg-yellow-500 focus:text-white",
onclick: (e) => {
e.target.disabled = true;
const deviceId = device["DeviceID.ID"].value[0] as string;
const deviceId = device["DeviceID.ID"] as string;
store
.updateTags(deviceId, { [tag]: false })
.then(() => {
@@ -103,7 +104,7 @@ const component: ClosureComponent<Attrs> = () => {
"flex-shrink-0 h-4 w-4 rounded-full inline-flex items-center justify-center text-yellow-400 hover:bg-yellow-200 hover:text-yellow-500 focus:outline-hidden focus:bg-yellow-500 focus:text-white",
onclick: (e) => {
e.target.disabled = true;
const deviceId = device["DeviceID.ID"].value[0] as string;
const deviceId = device["DeviceID.ID"] as string;
const tag = prompt(`Enter tag to assign to device:`);
if (!tag) {
e.target.disabled = false;
+3 -3
View File
@@ -5,7 +5,7 @@ import * as notifications from "./notifications.ts";
import putFormComponent from "./put-form-component.ts";
import uiConfigComponent from "./ui-config-component.ts";
import * as overlay from "./overlay.ts";
import { parse, stringify } from "../lib/common/expression/parser.ts";
import Expression from "../lib/common/expression.ts";
import { loadCodeMirror, loadYaml } from "./dynamic-loader.ts";
import { icon } from "./tailwind-utility-components.ts";
@@ -30,7 +30,7 @@ function putActionHandler(action, _object, isNew?): Promise<ValidationErrors> {
if (!id.match(regex)) return void resolve({ _id: "Invalid ID" });
try {
object.value = stringify(parse(object.value || ""));
object.value = Expression.parse(object.value || "").toString();
} catch {
return void resolve({
value: "Config value must be valid expression",
@@ -232,7 +232,7 @@ export const component: ClosureComponent = (): Component => {
},
);
const confs = store.fetch("config", true);
const confs = store.fetch("config", new Expression.Literal(true));
let newConfig;
const subs = [];
+111 -1
View File
@@ -1,3 +1,113 @@
export default window.clientConfig;
import Expression from "../lib/common/expression.ts";
export const configSnapshot = window.configSnapshot;
export const genieacsVersion = window.genieacsVersion;
type Filters = { label: string; parameter: Expression; type: string }[];
type pageSize = number;
type overview = {
charts: {
[name: string]: {
label: string;
slices: {
label: string;
filter: Expression;
color: string;
}[];
};
};
groups: {
label: string;
charts: string[];
}[];
};
type Index = {
label: string;
type?: string;
parameter: Expression;
unsortable: boolean;
raw: NestedRecord;
}[];
type NestedRecord = { [k: string]: Expression | NestedRecord };
const conf: NestedRecord = {};
for (const [key, value] of Object.entries(window.clientConfig)) {
const exp = Expression.parse(value).evaluate((e) => e);
let ref = conf;
const keyParts = key.split(".");
while (keyParts.length > 1) {
const k = keyParts.shift();
if (ref[k] == null || typeof ref[k] !== "object") ref[k] = {};
ref = ref[k] as NestedRecord;
}
ref[keyParts[0]] = exp;
}
export const filters: Filters = [];
export let pageSize: number = 10;
export const overview: overview = { charts: {}, groups: [] };
export const index: Index = [];
export let device: NestedRecord = {};
for (const obj of Object.values(conf["filters"] || {})) {
let label = "";
let parameter: Expression = new Expression.Literal(false);
let type = "string";
if (obj["label"] instanceof Expression.Literal)
label = obj["label"].value as string;
if (obj["parameter"] instanceof Expression) parameter = obj["parameter"];
if (obj["type"] instanceof Expression.Literal)
type = obj["type"].value as string;
filters.push({ label, parameter, type });
}
for (const obj of Object.values(conf["index"] || {})) {
let label = "";
let parameter: Expression = new Expression.Literal(null);
let unsortable = false;
let type = "";
if (obj["label"] instanceof Expression.Literal)
label = obj["label"].value as string;
if (obj["type"] instanceof Expression.Literal)
type = obj["type"].value as string;
if (obj["parameter"] instanceof Expression) parameter = obj["parameter"];
if (obj["unsortable"] instanceof Expression.Literal)
unsortable = obj["unsortable"].value as boolean;
index.push({ label, type, parameter, unsortable, raw: obj });
}
for (const obj of Object.values(conf["overview"]?.["groups"] || {})) {
let label = "";
const charts: string[] = [];
if (obj["label"] instanceof Expression.Literal)
label = obj["label"].value as string;
for (const chart of Object.values(obj["charts"] || {})) {
if (chart instanceof Expression.Literal) charts.push(chart.value as string);
}
overview.groups.push({ label, charts });
}
for (const [name, obj] of Object.entries(conf["overview"]?.["charts"] || {})) {
const slices: { label: string; filter: Expression; color: string }[] = [];
for (const slice of Object.values(obj["slices"] || {})) {
let label = "";
let filter: Expression = new Expression.Literal(false);
let color = "";
if (slice["label"] instanceof Expression.Literal)
label = slice["label"].value as string;
if (slice["filter"] instanceof Expression) filter = slice["filter"];
if (slice["color"] instanceof Expression.Literal)
color = slice["color"].value as string;
slices.push({ label, filter, color });
}
let label = "";
if (obj["label"] instanceof Expression.Literal)
label = obj["label"].value as string;
overview.charts[name] = { label, slices };
}
if (conf["pageSize"] instanceof Expression.Literal)
pageSize = +conf["pageSize"].value || 10;
device = conf["device"] as NestedRecord;
+20 -9
View File
@@ -1,7 +1,9 @@
import { ClosureComponent, Component } from "mithril";
import { ClosureComponent } from "mithril";
import { m } from "./components.ts";
import config from "./config.ts";
import { device as deviceConfig } from "./config.ts";
import * as store from "./store.ts";
import Expression from "../lib/common/expression.ts";
import Path from "../lib/common/path.ts";
export function init(
args: Record<string, unknown>,
@@ -14,16 +16,25 @@ export function init(
return Promise.resolve({
deviceId: args.id,
deviceFilter: ["=", ["PARAM", "DeviceID.ID"], args.id],
deviceFilter: new Expression.Binary(
"=",
new Expression.Parameter(Path.parse("DeviceID.ID")),
new Expression.Literal(args.id as string),
),
});
}
export const component: ClosureComponent = (): Component => {
interface Attrs {
deviceId: string;
deviceFilter: Expression;
}
export const component: ClosureComponent<Attrs> = () => {
return {
view: (vnode) => {
document.title = `${vnode.attrs["deviceId"]} - Devices - GenieACS`;
document.title = `${vnode.attrs.deviceId} - Devices - GenieACS`;
const dev = store.fetch("devices", vnode.attrs["deviceFilter"]);
const dev = store.fetch("devices", vnode.attrs.deviceFilter);
if (!dev.value.length) {
if (!dev.fulfilling) {
return m(
@@ -38,15 +49,15 @@ export const component: ClosureComponent = (): Component => {
);
}
const conf = config.ui.device;
const conf = deviceConfig;
const cmps = [];
for (const c of Object.values(conf)) {
cmps.push(
m.context(
{ device: dev.value[0], deviceQuery: dev },
store.evaluateExpression(c["type"], null) as string,
c,
store.evaluateExpression(c["type"], {}).value as string,
c as any,
),
);
}
+68 -56
View File
@@ -1,63 +1,72 @@
import { ClosureComponent, Component, Children } from "mithril";
import { ClosureComponent, Children } from "mithril";
import { m } from "./components.ts";
import config from "./config.ts";
import { pageSize as PAGE_SIZE, index as indexConfig } from "./config.ts";
import indexTableComponent from "./index-table-component.ts";
import filterComponent from "./filter-component.ts";
import * as store from "./store.ts";
import { queueTask, stageDownload } from "./task-queue.ts";
import * as notifications from "./notifications.ts";
import { parse, stringify, map } from "../lib/common/expression/parser.ts";
import { evaluate, extractParams } from "../lib/common/expression/util.ts";
import Expression, { extractPaths } from "../lib/common/expression.ts";
import memoize from "../lib/common/memoize.ts";
import * as smartQuery from "./smart-query.ts";
const PAGE_SIZE = config.ui.pageSize || 10;
const memoizedParse = memoize(parse);
const memoizedJsonParse = memoize(JSON.parse);
const memoizedGetSortable = memoize((p) => {
const expressionParams = extractParams(p);
if (expressionParams.length === 1) {
const param = evaluate(expressionParams[0]);
if (typeof param === "string") return param;
}
const memoizedGetSortable = memoize((p: Expression) => {
const expressionParams = extractPaths(p);
if (expressionParams.length === 1) return expressionParams[0];
return null;
});
const getDownloadUrl = memoize((filter, indexParameters) => {
const columns = {};
for (const p of indexParameters)
columns[store.evaluateExpression(p.label, null) as string] = stringify(
p.parameter,
);
return `api/devices.csv?${m.buildQueryString({
filter: stringify(filter),
columns: JSON.stringify(columns),
})}`;
});
const getDownloadUrl = memoize(
(
filter: Expression,
indexParameters: { label: string; parameter: Expression }[],
) => {
const columns = {};
for (const p of indexParameters) columns[p.label] = p.parameter.toString();
return `api/devices.csv?${m.buildQueryString({
filter: filter.toString(),
columns: JSON.stringify(columns),
})}`;
},
);
const unpackSmartQuery = memoize((query) => {
return map(query, (e) => {
if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q")
return smartQuery.unpack("devices", e[2], e[3]);
const unpackSmartQuery = memoize((query: Expression) => {
return query.evaluate((e) => {
if (e instanceof Expression.FunctionCall) {
if (e.name === "Q") {
if (
e.args[0] instanceof Expression.Literal &&
e.args[1] instanceof Expression.Literal
) {
return smartQuery.unpack(
"devices",
e.args[0].value as string,
e.args[1].value as string,
);
}
}
}
return e;
});
});
export function init(
args: Record<string, unknown>,
): Promise<Record<string, unknown>> {
export function init(args: Record<string, unknown>): Promise<Attrs> {
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.hasOwnProperty("filter") ? "" + args["filter"] : "";
const sort = args.hasOwnProperty("sort") ? "" + args["sort"] : "";
const indexParameters = Object.values(config.ui.index);
let filter: Expression = null;
let sort: Record<string, number> = null;
if (args.hasOwnProperty("filter"))
filter = Expression.parse(args["filter"] as string);
if (args.hasOwnProperty("sort")) sort = JSON.parse(args["sort"] as string);
const indexParameters = indexConfig;
if (!indexParameters.length) {
indexParameters.push({
label: "ID",
parameter: ["PARAM", "DeviceID.ID"],
parameter: Expression.parse("DeviceID.ID"),
unsortable: false,
raw: {},
});
}
resolve({ filter, indexParameters, sort });
@@ -220,11 +229,17 @@ function renderActions(selected: Set<string>): Children {
return buttons;
}
export const component: ClosureComponent = (): Component => {
interface Attrs {
indexParameters: typeof indexConfig;
filter?: Expression;
sort?: Record<string, number>;
}
export const component: ClosureComponent<Attrs> = () => {
return {
view: (vnode) => {
document.title = "Devices - GenieACS";
const attributes = vnode.attrs["indexParameters"];
const attributes = vnode.attrs.indexParameters;
function showMore(): void {
vnode.state["showCount"] =
@@ -232,22 +247,22 @@ export const component: ClosureComponent = (): Component => {
m.redraw();
}
function onFilterChanged(filter): void {
const ops = { filter };
if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"];
function onFilterChanged(filter: Expression): void {
const ops = {};
if (!(filter instanceof Expression.Literal && filter.value))
ops["filter"] = filter.toString();
if (vnode.attrs.sort) ops["sort"] = vnode.attrs.sort;
m.route.set("/devices", ops);
}
const sort = vnode.attrs["sort"]
? memoizedJsonParse(vnode.attrs["sort"])
: {};
const sort = vnode.attrs.sort || {};
const sortAttributes = {};
for (let i = 0; i < attributes.length; i++) {
const attr = attributes[i];
if (attr.unsortable) continue;
const param = memoizedGetSortable(attr.parameter);
if (param) sortAttributes[i] = sort[param] || 0;
if (param) sortAttributes[i] = sort[param.toString()] || 0;
}
function onSortChange(sortedAttrs): void {
@@ -256,17 +271,16 @@ export const component: ClosureComponent = (): Component => {
const param = memoizedGetSortable(
attributes[Math.abs(index) - 1].parameter,
);
_sort[param] = Math.sign(index);
_sort[param.toString()] = Math.sign(index);
}
const ops = { sort: JSON.stringify(_sort) };
if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"];
m.route.set("/devices", ops);
}
let filter = vnode.attrs["filter"]
? memoizedParse(vnode.attrs["filter"])
: true;
filter = unpackSmartQuery(filter);
const filter = unpackSmartQuery(
vnode.attrs["filter"] ?? new Expression.Literal(true),
);
const devs = store.fetch("devices", filter, {
limit: vnode.state["showCount"] || PAGE_SIZE,
@@ -280,15 +294,15 @@ export const component: ClosureComponent = (): Component => {
return m.context(
{ device: device, parameter: attr.parameter },
attr.type || "parameter",
attr,
attr.raw,
);
};
const attrs = {};
attrs["attributes"] = attributes.map((a) => ({
...a,
label: store.evaluateExpression(a.label, null),
type: store.evaluateExpression(a.type, null),
label: a.label,
type: a.type,
}));
attrs["data"] = devs.value;
attrs["total"] = count.value;
@@ -301,9 +315,7 @@ export const component: ClosureComponent = (): Component => {
return m(
"a.text-cyan-700 hover:text-cyan-900",
{
href: `#!/devices/${encodeURIComponent(
device["DeviceID.ID"].value[0],
)}`,
href: `#!/devices/${encodeURIComponent(device["DeviceID.ID"])}`,
},
"Show",
);
+2 -1
View File
@@ -18,6 +18,7 @@ import {
queueTask,
StageTask,
} from "./task-queue.ts";
import Expression from "../lib/common/expression.ts";
const invalid: WeakSet<StageTask> = new WeakSet();
@@ -95,7 +96,7 @@ function renderStagingSpv(task: StageTask, queueFunc, cancelFunc): Children {
function renderStagingDownload(task: StageTask): Children {
if (!task.fileName || !task.fileType) invalid.add(task);
else invalid.delete(task);
const files = store.fetch("files", true);
const files = store.fetch("files", new Expression.Literal(true));
let oui = "";
let productClass = "";
for (const d of task.devices) {
+30 -18
View File
@@ -1,18 +1,15 @@
import { ClosureComponent, Component, Children } from "mithril";
import { m } from "./components.ts";
import config from "./config.ts";
import { pageSize as PAGE_SIZE } from "./config.ts";
import indexTableComponent from "./index-table-component.ts";
import filterComponent from "./filter-component.ts";
import * as store from "./store.ts";
import * as notifications from "./notifications.ts";
import memoize from "../lib/common/memoize.ts";
import * as smartQuery from "./smart-query.ts";
import { map, parse, stringify } from "../lib/common/expression/parser.ts";
import { stringify as yamlStringify } from "../lib/common/yaml.ts";
import Expression from "../lib/common/expression.ts";
const PAGE_SIZE = config.ui.pageSize || 10;
const memoizedParse = memoize(parse);
const memoizedJsonParse = memoize(JSON.parse);
const attributes = [
@@ -33,15 +30,27 @@ const getDownloadUrl = memoize((filter) => {
}
return `api/faults.csv?${m.buildQueryString({
filter: stringify(filter),
filter: filter.toString(),
columns: JSON.stringify(cols),
})}`;
});
const unpackSmartQuery = memoize((query) => {
return map(query, (e) => {
if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q")
return smartQuery.unpack("faults", e[2], e[3]);
const unpackSmartQuery = memoize((query: Expression) => {
return query.evaluate((e) => {
if (e instanceof Expression.FunctionCall) {
if (e.name === "Q") {
if (
e.args[0] instanceof Expression.Literal &&
e.args[1] instanceof Expression.Literal
) {
return smartQuery.unpack(
"faults",
e.args[0].value as string,
e.args[1].value as string,
);
}
}
}
return e;
});
});
@@ -66,9 +75,11 @@ export function init(
new Error("You are not authorized to view this page"),
);
}
const sort = args.hasOwnProperty("sort") ? "" + args["sort"] : "";
const filter = args.hasOwnProperty("filter") ? "" + args["filter"] : "";
let filter: Expression = null;
let sort: Record<string, number> = null;
if (args.hasOwnProperty("filter"))
filter = Expression.parse(args["filter"] as string);
if (args.hasOwnProperty("sort")) sort = JSON.parse(args["sort"] as string);
return Promise.resolve({ filter, sort });
}
@@ -84,7 +95,9 @@ export const component: ClosureComponent = (): Component => {
}
function onFilterChanged(filter): void {
const ops = { filter };
const ops = {};
if (!(filter instanceof Expression.Literal && filter.value))
ops["filter"] = filter.toString();
if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"];
m.route.set("/faults", ops);
}
@@ -108,10 +121,9 @@ export const component: ClosureComponent = (): Component => {
m.route.set("/faults", ops);
}
let filter = vnode.attrs["filter"]
? memoizedParse(vnode.attrs["filter"])
: true;
filter = unpackSmartQuery(filter);
const filter = unpackSmartQuery(
vnode.attrs["filter"] ?? new Expression.Literal(true),
);
const faults = store.fetch("faults", filter, {
limit: vnode.state["showCount"] || PAGE_SIZE,
+30 -17
View File
@@ -1,6 +1,6 @@
import { ClosureComponent, Component, Children } from "mithril";
import { m } from "./components.ts";
import config from "./config.ts";
import { pageSize as PAGE_SIZE } from "./config.ts";
import filterComponent from "./filter-component.ts";
import * as store from "./store.ts";
import * as notifications from "./notifications.ts";
@@ -9,11 +9,8 @@ import putFormComponent from "./put-form-component.ts";
import indexTableComponent from "./index-table-component.ts";
import * as overlay from "./overlay.ts";
import * as smartQuery from "./smart-query.ts";
import { map, parse, stringify } from "../lib/common/expression/parser.ts";
import Expression from "../lib/common/expression.ts";
const PAGE_SIZE = config.ui.pageSize || 10;
const memoizedParse = memoize(parse);
const memoizedJsonParse = memoize(JSON.parse);
const attributes: {
@@ -46,10 +43,22 @@ const formData = {
.concat([{ id: "file", label: "File", type: "file" }]),
};
const unpackSmartQuery = memoize((query) => {
return map(query, (e) => {
if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q")
return smartQuery.unpack("files", e[2], e[3]);
const unpackSmartQuery = memoize((query: Expression) => {
return query.evaluate((e) => {
if (e instanceof Expression.FunctionCall) {
if (e.name === "Q") {
if (
e.args[0] instanceof Expression.Literal &&
e.args[1] instanceof Expression.Literal
) {
return smartQuery.unpack(
"files",
e.args[0].value as string,
e.args[1].value as string,
);
}
}
}
return e;
});
});
@@ -87,7 +96,7 @@ const getDownloadUrl = memoize((filter) => {
const cols = {};
for (const attr of attributes) cols[attr.label] = attr.id;
return `api/files.csv?${m.buildQueryString({
filter: stringify(filter),
filter: filter.toString(),
columns: JSON.stringify(cols),
})}`;
});
@@ -101,8 +110,11 @@ export function init(
);
}
const sort = args.hasOwnProperty("sort") ? "" + args["sort"] : "";
const filter = args.hasOwnProperty("filter") ? "" + args["filter"] : "";
let filter: Expression = null;
let sort: Record<string, number> = null;
if (args.hasOwnProperty("filter"))
filter = Expression.parse(args["filter"] as string);
if (args.hasOwnProperty("sort")) sort = JSON.parse(args["sort"] as string);
return Promise.resolve({ filter, sort });
}
@@ -118,7 +130,9 @@ export const component: ClosureComponent = (): Component => {
}
function onFilterChanged(filter): void {
const ops = { filter };
const ops = {};
if (!(filter instanceof Expression.Literal && filter.value))
ops["filter"] = filter.toString();
if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"];
m.route.set("/files", ops);
}
@@ -140,10 +154,9 @@ export const component: ClosureComponent = (): Component => {
m.route.set("/files", ops);
}
let filter = vnode.attrs["filter"]
? memoizedParse(vnode.attrs["filter"])
: true;
filter = unpackSmartQuery(filter);
const filter = unpackSmartQuery(
vnode.attrs["filter"] ?? new Expression.Literal(true),
);
const files = store.fetch("files", filter, {
limit: vnode.state["showCount"] || PAGE_SIZE,
+51 -33
View File
@@ -1,10 +1,9 @@
import m, { ClosureComponent } from "mithril";
import { parse, stringify, map } from "../lib/common/expression/parser.ts";
import memoize from "../lib/common/memoize.ts";
import Autocomplete from "./autocomplete-compnent.ts";
import * as smartQuery from "./smart-query.ts";
import { validQuery } from "../lib/db/synth.ts";
import { Expression } from "../lib/types.ts";
import Expression from "../lib/common/expression.ts";
const getAutocomplete = memoize((resource) => {
const labels = smartQuery.getLabels(resource);
@@ -22,20 +21,35 @@ const getAutocomplete = memoize((resource) => {
return autocomplete;
});
function parseFilter(resource, f): Expression {
function parseFilter(resource: string, f: string): Expression {
let exp;
if (/^[\s0-9a-zA-Z]+:/.test(f)) {
const k = f.split(":", 1)[0];
const v = f.slice(k.length + 1).trim();
exp = ["FUNC", "Q", k.trim(), v];
exp = new Expression.FunctionCall("Q", [
new Expression.Literal(k.trim()),
new Expression.Literal(v),
]);
} else {
exp = parse(f);
exp = Expression.parse(f);
}
const unpacked = map(exp, (e) => {
if (Array.isArray(e) && e[0] === "FUNC") {
if (e[1] === "Q") return smartQuery.unpack(resource, e[2], e[3]);
else if (e[1] === "NOW") return Date.now();
const unpacked = exp.evaluate((e) => {
if (e instanceof Expression.FunctionCall) {
if (e.name === "NOW") return new Expression.Literal(Date.now());
else if (e.name === "Q") {
if (
e.args[0] instanceof Expression.Literal &&
e.args[1] instanceof Expression.Literal
) {
const r = smartQuery.unpack(
resource,
e.args[0].value as string,
e.args[1].value as string,
);
return r;
}
}
}
return e;
});
@@ -46,28 +60,33 @@ function parseFilter(resource, f): Expression {
return exp;
}
function stringifyFilter(f: Expression): string {
if (Array.isArray(f) && f[0] === "FUNC" && f[1] === "Q")
return `${f[2]}: ${f[3]}`;
return stringify(f);
}
function splitFilter(filter: string): string[] {
function splitFilter(filter: Expression): string[] {
if (!filter) return [""];
const list: string[] = [];
const f = 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));
if (filter instanceof Expression.Literal && filter.value) return [""];
const list: Expression[] = [filter];
const res: string[] = [];
while (list.length) {
const f = list.pop();
if (f instanceof Expression.Binary && f.operator === "AND") {
list.push(f.right);
list.push(f.left);
} else if (f instanceof Expression.FunctionCall && f.name === "Q") {
const l = f.args[0] as Expression.Literal;
const r = f.args[1] as Expression.Literal;
res.push(`${l.value}: ${r.value}`);
} else {
res.push(f.toString());
}
}
list.push("");
return list;
res.push("");
return res;
}
interface Attrs {
resource: string;
filter: string;
onChange: (filter: string) => void;
filter?: Expression;
onChange: (filter: Expression) => void;
}
const component: ClosureComponent<Attrs> = (initialVnode) => {
@@ -80,23 +99,22 @@ const component: ClosureComponent<Attrs> = (initialVnode) => {
filterTouched = false;
filterInvalid = 0;
filterList = filterList.filter((f) => f);
const list = filterList.map((f, idx) => {
let filter: Expression = new Expression.Literal(true);
for (const [idx, f] of filterList.entries()) {
try {
return parseFilter(attrs.resource, f);
filter = Expression.and(filter, parseFilter(attrs.resource, f));
} catch {
filterInvalid |= 1 << idx;
}
return null;
});
}
filterList.push("");
if (filterInvalid) {
m.redraw();
return;
}
if (list.length === 0) attrs.onChange("");
else if (list.length > 1) attrs.onChange(stringify(["AND", ...list]));
else attrs.onChange(stringify(list[0]));
if (!filterList.length) attrs.onChange(null);
else attrs.onChange(filter);
}
return {
@@ -120,7 +138,7 @@ const component: ClosureComponent<Attrs> = (initialVnode) => {
"appearance-none rounded-none relative block w-full px-3 py-2 border-stone-300 placeholder-stone-500 text-stone-900 focus:ring-cyan-500 focus:border-cyan-500 focus:z-10 sm:text-sm";
if (idx === 0) classNames += " rounded-t-md";
if (idx === filterList.length - 1) classNames += " rounded-b-md";
if (filterInvalid & (1 << idx)) classNames += " text-red-700";
if (filterInvalid & (1 << idx)) classNames += " !text-red-700";
return m(`input`, {
type: "text",
+4 -4
View File
@@ -63,7 +63,7 @@ function renderTable(
checked: records.length && selected.size === records.length,
onchange: (e) => {
for (const record of records) {
const id = record["_id"] || record["DeviceID.ID"].value[0];
const id = record["_id"] ?? record["DeviceID.ID"];
if (e.target.checked) selected.add(id);
else selected.delete(id);
}
@@ -148,7 +148,7 @@ function renderTable(
// Table rows
const rows = [];
for (const record of records) {
const id = record["_id"] || record["DeviceID.ID"].value[0];
const id = record["_id"] ?? record["DeviceID.ID"];
const tds = [];
const isSelected = selected.has(id);
if (buttons.length) {
@@ -224,7 +224,7 @@ function renderTable(
{
class: isSelected ? "bg-stone-50" : "",
onclick: (e) => {
if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) {
if (e.target.closest("input, button, a")) {
e.redraw = false;
return;
}
@@ -339,7 +339,7 @@ const component: ClosureComponent = (): Component => {
const _selected = new Set<string>();
for (const record of data) {
const id = record["_id"] || record["DeviceID.ID"].value[0];
const id = record["_id"] ?? record["DeviceID.ID"];
if (selected.has(id)) _selected.add(id);
}
+25 -28
View File
@@ -1,31 +1,29 @@
import { ClosureComponent, Component } from "mithril";
import { ClosureComponent } from "mithril";
import { m } from "./components.ts";
import config from "./config.ts";
import { overview } from "./config.ts";
import * as store from "./store.ts";
import pieChartComponent from "./pie-chart-component.ts";
const GROUPS = config.ui.overview.groups || {};
const CHARTS = {};
for (const group of Object.values(GROUPS)) {
for (const chartName of Object.values(group["charts"]) as string[])
CHARTS[chartName] = config.ui.overview.charts[chartName];
const GROUPS = overview.groups;
const CHARTS: typeof overview.charts = {};
for (const group of GROUPS) {
for (const chartName of group.charts)
CHARTS[chartName] = overview.charts[chartName];
}
function queryCharts(charts: Record<string, unknown>): Record<string, unknown> {
function queryCharts(charts: typeof overview.charts): typeof 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 = slice["filter"];
chart["slices"][sliceName] = slice = Object.assign({}, slice);
slice["count"] = store.count("devices", filter);
charts[chartName] = chart = { ...chart };
chart.slices = chart.slices.map((s) => ({ ...s }));
for (const slice of chart.slices) {
slice["count"] = store.count("devices", slice.filter);
}
}
return charts;
}
export function init(): Promise<Record<string, unknown>> {
export function init(): Promise<{ charts: typeof overview.charts }> {
if (!window.authorizer.hasAccess("devices", 1)) {
return Promise.reject(
new Error("You are not authorized to view this page"),
@@ -35,37 +33,36 @@ export function init(): Promise<Record<string, unknown>> {
return Promise.resolve({ charts: queryCharts(CHARTS) });
}
export const component: ClosureComponent = (): Component => {
interface Attrs {
charts: typeof overview.charts;
}
export const component: ClosureComponent<Attrs> = () => {
return {
view: (vnode) => {
document.title = "Overview - GenieACS";
const children = [];
for (const group of Object.values(GROUPS)) {
if (group["label"]) {
for (const group of GROUPS) {
if (group.label) {
children.push(
m(
"h1.text-xl font-medium text-stone-900 mb-5",
store.evaluateExpression(group["label"], null),
),
m("h1.text-xl font-medium text-stone-900 mb-5", group["label"]),
);
}
const groupChildren = [];
for (const chartName of Object.values(group["charts"]) as string[]) {
const chart = vnode.attrs["charts"][chartName];
for (const chartName of group.charts) {
const chart = vnode.attrs.charts[chartName];
const chartChildren = [];
if (chart.label) {
chartChildren.push(
m(
"h2.text-lg font-semibold text-stone-700 truncate mb-5 text-center",
store.evaluateExpression(chart.label, null),
chart.label,
),
);
}
const attrs = {};
attrs["chart"] = chart;
chartChildren.push(m(pieChartComponent, attrs));
chartChildren.push(m(pieChartComponent, { chart }));
groupChildren.push(
m(
+33 -19
View File
@@ -1,6 +1,6 @@
import { Children, ClosureComponent, Component } from "mithril";
import { m } from "./components.ts";
import config from "./config.ts";
import { pageSize as PAGE_SIZE } from "./config.ts";
import * as store from "./store.ts";
import * as notifications from "./notifications.ts";
import memoize from "../lib/common/memoize.ts";
@@ -8,12 +8,10 @@ import putFormComponent from "./put-form-component.ts";
import indexTableComponent from "./index-table-component.ts";
import * as overlay from "./overlay.ts";
import * as smartQuery from "./smart-query.ts";
import { map, parse, stringify } from "../lib/common/expression/parser.ts";
import Expression from "../lib/common/expression.ts";
import filterComponent from "./filter-component.ts";
const PAGE_SIZE = config.ui.pageSize || 10;
const memoizedParse = memoize(parse);
const memoizedParse = memoize((str) => Expression.parse(str));
const memoizedJsonParse = memoize(JSON.parse);
const attributes = [
@@ -60,10 +58,22 @@ function getExcerpt(text: string, maxLength = 80, maxLines = 10): string[] {
return lines;
}
const unpackSmartQuery = memoize((query) => {
return map(query, (e) => {
if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q")
return smartQuery.unpack("permissions", e[2], e[3]);
const unpackSmartQuery = memoize((query: Expression) => {
return query.evaluate((e) => {
if (e instanceof Expression.FunctionCall) {
if (e.name === "Q") {
if (
e.args[0] instanceof Expression.Literal &&
e.args[1] instanceof Expression.Literal
) {
return smartQuery.unpack(
"permissions",
e.args[0].value as string,
e.args[1].value as string,
);
}
}
}
return e;
});
});
@@ -89,7 +99,7 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
if (object.filter) {
try {
object.filter = stringify(memoizedParse(object.filter));
object.filter = memoizedParse(object.filter).toString();
} catch {
return void resolve({
filter: "Filter must be valid expression",
@@ -99,7 +109,7 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
if (object.validate) {
try {
object.validate = stringify(memoizedParse(object.validate));
object.validate = memoizedParse(object.validate).toString();
} catch {
return void resolve({
validate: "Validate must be valid expression",
@@ -163,7 +173,7 @@ const getDownloadUrl = memoize((filter) => {
const cols = {};
for (const attr of attributes) cols[attr.label] = attr.id;
return `api/permissions.csv?${m.buildQueryString({
filter: stringify(filter),
filter: filter.toString(),
columns: JSON.stringify(cols),
})}`;
});
@@ -176,8 +186,11 @@ export function init(
new Error("You are not authorized to view this page"),
);
}
const sort = args.hasOwnProperty("sort") ? "" + args["sort"] : "";
const filter = args.hasOwnProperty("filter") ? "" + args["filter"] : "";
let filter: Expression = null;
let sort: Record<string, number> = null;
if (args.hasOwnProperty("filter"))
filter = Expression.parse(args["filter"] as string);
if (args.hasOwnProperty("sort")) sort = JSON.parse(args["sort"] as string);
return Promise.resolve({ filter, sort });
}
@@ -193,7 +206,9 @@ export const component: ClosureComponent = (): Component => {
}
function onFilterChanged(filter): void {
const ops = { filter };
const ops = {};
if (!(filter instanceof Expression.Literal && filter.value))
ops["filter"] = filter.toString();
if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"];
m.route.set("/permissions", ops);
}
@@ -218,10 +233,9 @@ export const component: ClosureComponent = (): Component => {
m.route.set("/permissions", ops);
}
let filter = vnode.attrs["filter"]
? memoizedParse(vnode.attrs["filter"])
: true;
filter = unpackSmartQuery(filter);
const filter = unpackSmartQuery(
vnode.attrs["filter"] ?? new Expression.Literal(true),
);
const permissions = store.fetch("permissions", filter, {
limit: vnode.state["showCount"] || PAGE_SIZE,
+21 -14
View File
@@ -1,12 +1,8 @@
import { ClosureComponent, Component, Children } from "mithril";
import { ClosureComponent, Children } from "mithril";
import { m } from "./components.ts";
import { stringify } from "../lib/common/expression/parser.ts";
import memoize from "../lib/common/memoize.ts";
import * as store from "./store.ts";
import Expression from "../lib/common/expression.ts";
const memoizedStringify = memoize(stringify);
function drawChart(chartData): Children {
function drawChart(chartData: Attrs["chart"]): Children {
const slices = chartData.slices;
const total: number = Array.from(Object.values(chartData.slices)).reduce(
(a: number, s) => a + (s["count"]["value"] || 0),
@@ -27,10 +23,10 @@ function drawChart(chartData): Children {
m(
"td",
m("span.inline-block w-3 h-3 border border-stone-200 mr-1", {
style: `background-color: ${store.evaluateExpression(slice["color"], null)} !important;`,
style: `background-color: ${slice.color} !important;`,
}),
),
m("td.w-full", store.evaluateExpression(slice["label"], null)),
m("td.w-full", slice.label),
m(
"td.text-stone-500 text-right tabular-nums",
`${Math.round(percent * 100)}%`,
@@ -41,7 +37,7 @@ function drawChart(chartData): Children {
"a.text-cyan-700 hover:text-cyan-900 font-medium ml-2",
{
href: `#!/devices/?${m.buildQueryString({
filter: memoizedStringify(slice["filter"]),
filter: slice.filter.toString(),
})}`,
},
slice["count"]["value"] || 0,
@@ -67,7 +63,7 @@ function drawChart(chartData): Children {
paths.push(
m("path.stroke-white stroke-1", {
d: sketch,
fill: store.evaluateExpression(slice["color"], null),
fill: slice.color,
}),
);
@@ -81,7 +77,7 @@ function drawChart(chartData): Children {
"a.opacity-0 hover:opacity-100 focus-visible:opacity-100 outline-hidden",
{
"xlink:href": `#!/devices/?${m.buildQueryString({
filter: memoizedStringify(slice["filter"]),
filter: slice.filter.toString(),
})}`,
},
[
@@ -137,10 +133,21 @@ function drawChart(chartData): Children {
);
}
const component: ClosureComponent = (): Component => {
interface Attrs {
chart: {
label: string;
slices: {
label: string;
filter: Expression;
color: string;
}[];
};
}
const component: ClosureComponent<Attrs> = () => {
return {
view: (vnode) => {
return drawChart(vnode.attrs["chart"]);
return drawChart(vnode.attrs.chart);
},
};
};
+36 -19
View File
@@ -1,6 +1,6 @@
import { ClosureComponent, Component, Children, Vnode } from "mithril";
import { m } from "./components.ts";
import config from "./config.ts";
import { pageSize as PAGE_SIZE } from "./config.ts";
import filterComponent from "./filter-component.ts";
import * as overlay from "./overlay.ts";
import * as store from "./store.ts";
@@ -9,11 +9,9 @@ import putFormComponent from "./put-form-component.ts";
import indexTableComponent from "./index-table-component.ts";
import memoize from "../lib/common/memoize.ts";
import * as smartQuery from "./smart-query.ts";
import { map, parse, stringify } from "../lib/common/expression/parser.ts";
import Expression from "../lib/common/expression.ts";
const PAGE_SIZE = config.ui.pageSize || 10;
const memoizedParse = memoize(parse);
const memoizedParse = memoize((str) => Expression.parse(str));
const memoizedJsonParse = memoize(JSON.parse);
const attributes = [
@@ -27,10 +25,22 @@ const attributes = [
{ id: "provisionArgs", label: "Arguments", type: "textarea" },
];
const unpackSmartQuery = memoize((query) => {
return map(query, (e) => {
if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q")
return smartQuery.unpack("presets", e[2], e[3]);
const unpackSmartQuery = memoize((query: Expression) => {
return query.evaluate((e) => {
if (e instanceof Expression.FunctionCall) {
if (e.name === "Q") {
if (
e.args[0] instanceof Expression.Literal &&
e.args[1] instanceof Expression.Literal
) {
return smartQuery.unpack(
"presets",
e.args[0].value as string,
e.args[1].value as string,
);
}
}
}
return e;
});
});
@@ -71,7 +81,7 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
if (object.precondition) {
try {
object.precondition = stringify(memoizedParse(object.precondition));
object.precondition = memoizedParse(object.precondition).toString();
} catch {
return void resolve({
precondition: "Precondition must be valid expression",
@@ -134,7 +144,7 @@ const getDownloadUrl = memoize((filter) => {
const cols = {};
for (const attr of attributes) cols[attr.label] = attr.id;
return `api/presets.csv?${m.buildQueryString({
filter: stringify(filter),
filter: filter.toString(),
columns: JSON.stringify(cols),
})}`;
});
@@ -148,8 +158,11 @@ export function init(
);
}
const sort = args.hasOwnProperty("sort") ? "" + args["sort"] : "";
const filter = args.hasOwnProperty("filter") ? "" + args["filter"] : "";
let filter: Expression = null;
let sort: Record<string, number> = null;
if (args.hasOwnProperty("filter"))
filter = Expression.parse(args["filter"] as string);
if (args.hasOwnProperty("sort")) sort = JSON.parse(args["sort"] as string);
return Promise.resolve({ filter, sort });
}
@@ -165,7 +178,9 @@ export const component: ClosureComponent = (): Component => {
}
function onFilterChanged(filter): void {
const ops = { filter };
const ops = {};
if (!(filter instanceof Expression.Literal && filter.value))
ops["filter"] = filter.toString();
if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"];
m.route.set("/presets", ops);
}
@@ -197,10 +212,9 @@ export const component: ClosureComponent = (): Component => {
m.route.set("/presets", ops);
}
let filter = vnode.attrs["filter"]
? memoizedParse(vnode.attrs["filter"])
: true;
filter = unpackSmartQuery(filter);
const filter = unpackSmartQuery(
vnode.attrs["filter"] ?? new Expression.Literal(true),
);
const presets = store.fetch("presets", filter, {
limit: vnode.state["showCount"] || PAGE_SIZE,
@@ -220,7 +234,10 @@ export const component: ClosureComponent = (): Component => {
"instances",
]);
const provisions = store.fetch("provisions", true);
const provisions = store.fetch(
"provisions",
new Expression.Literal(true),
);
if (provisions.fulfilled) {
for (const p of provisions.value) {
userDefinedProvisions.add(p["_id"]);
+30 -17
View File
@@ -1,6 +1,6 @@
import { ClosureComponent, Component, Children } from "mithril";
import { m } from "./components.ts";
import config from "./config.ts";
import { pageSize as PAGE_SIZE } from "./config.ts";
import filterComponent from "./filter-component.ts";
import * as store from "./store.ts";
import * as notifications from "./notifications.ts";
@@ -9,12 +9,9 @@ import putFormComponent from "./put-form-component.ts";
import indexTableComponent from "./index-table-component.ts";
import * as overlay from "./overlay.ts";
import * as smartQuery from "./smart-query.ts";
import { map, parse, stringify } from "../lib/common/expression/parser.ts";
import Expression from "../lib/common/expression.ts";
import { loadCodeMirror } from "./dynamic-loader.ts";
const PAGE_SIZE = config.ui.pageSize || 10;
const memoizedParse = memoize(parse);
const memoizedJsonParse = memoize(JSON.parse);
const attributes = [
@@ -22,10 +19,22 @@ const attributes = [
{ id: "script", label: "Script", type: "code" },
];
const unpackSmartQuery = memoize((query) => {
return map(query, (e) => {
if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q")
return smartQuery.unpack("provisions", e[2], e[3]);
const unpackSmartQuery = memoize((query: Expression) => {
return query.evaluate((e) => {
if (e instanceof Expression.FunctionCall) {
if (e.name === "Q") {
if (
e.args[0] instanceof Expression.Literal &&
e.args[1] instanceof Expression.Literal
) {
return smartQuery.unpack(
"provisions",
e.args[0].value as string,
e.args[1].value as string,
);
}
}
}
return e;
});
});
@@ -104,7 +113,7 @@ const getDownloadUrl = memoize((filter) => {
const cols = {};
for (const attr of attributes) cols[attr.label] = attr.id;
return `api/provisions.csv?${m.buildQueryString({
filter: stringify(filter),
filter: filter.toString(),
columns: JSON.stringify(cols),
})}`;
});
@@ -118,8 +127,11 @@ export function init(
);
}
const sort = args.hasOwnProperty("sort") ? "" + args["sort"] : "";
const filter = args.hasOwnProperty("filter") ? "" + args["filter"] : "";
let filter: Expression = null;
let sort: Record<string, number> = null;
if (args.hasOwnProperty("filter"))
filter = Expression.parse(args["filter"] as string);
if (args.hasOwnProperty("sort")) sort = JSON.parse(args["sort"] as string);
return new Promise((resolve, reject) => {
loadCodeMirror()
@@ -142,7 +154,9 @@ export const component: ClosureComponent = (): Component => {
}
function onFilterChanged(filter): void {
const ops = { filter };
const ops = {};
if (!(filter instanceof Expression.Literal && filter.value))
ops["filter"] = filter.toString();
if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"];
m.route.set("/provisions", ops);
}
@@ -164,10 +178,9 @@ export const component: ClosureComponent = (): Component => {
m.route.set("/provisions", ops);
}
let filter = vnode.attrs["filter"]
? memoizedParse(vnode.attrs["filter"])
: true;
filter = unpackSmartQuery(filter);
const filter = unpackSmartQuery(
vnode.attrs["filter"] ?? new Expression.Literal(true),
);
const provisions = store.fetch("provisions", filter, {
limit: vnode.state["showCount"] || PAGE_SIZE,
+150 -72
View File
@@ -1,51 +1,103 @@
import config from "./config.ts";
import { Expression } from "../lib/types.ts";
import { filters } from "./config.ts";
import Expression from "../lib/common/expression.ts";
import { encodeTag } from "../lib/util.ts";
import Path from "../lib/common/path.ts";
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" },
Device: {
parameter: new Expression.Parameter(Path.parse("device")),
type: "string",
},
Channel: {
parameter: new Expression.Parameter(Path.parse("channel")),
type: "string",
},
Code: {
parameter: new Expression.Parameter(Path.parse("code")),
type: "string",
},
Retries: {
parameter: new Expression.Parameter(Path.parse("retries")),
type: "number",
},
Timestamp: {
parameter: new Expression.Parameter(Path.parse("timestamp")),
type: "timestamp",
},
},
presets: {
ID: { parameter: ["PARAM", "_id"], type: "string" },
Channel: { parameter: ["PARAM", "channel"], type: "string" },
Weight: { parameter: ["PARAM", "weight"], type: "number" },
ID: {
parameter: new Expression.Parameter(Path.parse("_id")),
type: "string",
},
Channel: {
parameter: new Expression.Parameter(Path.parse("channel")),
type: "string",
},
Weight: {
parameter: new Expression.Parameter(Path.parse("weight")),
type: "number",
},
},
provisions: {
ID: { parameter: ["PARAM", "_id"], type: "string" },
ID: {
parameter: new Expression.Parameter(Path.parse("_id")),
type: "string",
},
},
virtualParameters: {
ID: { parameter: ["PARAM", "_id"], type: "string" },
ID: {
parameter: new Expression.Parameter(Path.parse("_id")),
type: "string",
},
},
files: {
ID: { parameter: ["PARAM", "_id"], type: "string" },
Type: { parameter: ["PARAM", "metadata.fileType"], type: "string" },
OUI: { parameter: ["PARAM", "metadata.oui"], type: "string" },
ID: {
parameter: new Expression.Parameter(Path.parse("_id")),
type: "string",
},
Type: {
parameter: new Expression.Parameter(Path.parse("metadata.fileType")),
type: "string",
},
OUI: {
parameter: new Expression.Parameter(Path.parse("metadata.oui")),
type: "string",
},
"Product class": {
parameter: ["PARAM", "metadata.productClass"],
parameter: new Expression.Parameter(Path.parse("metadata.productClass")),
type: "string",
},
Version: {
parameter: new Expression.Parameter(Path.parse("metadata.version")),
type: "string",
},
Version: { parameter: ["PARAM", "metadata.version"], type: "string" },
},
permissions: {
Role: { parameter: ["PARAM", "role"], type: "string" },
Resource: { parameter: ["PARAM", "resource"], type: "string" },
Access: { parameter: ["PARAM", "access"], type: "number" },
Role: {
parameter: new Expression.Parameter(Path.parse("role")),
type: "string",
},
Resource: {
parameter: new Expression.Parameter(Path.parse("resource")),
type: "string",
},
Access: {
parameter: new Expression.Parameter(Path.parse("access")),
type: "number",
},
},
users: {
Username: {
parameter: new Expression.Parameter(Path.parse("_id")),
type: "string",
},
},
users: { Username: { parameter: ["PARAM", "_id"], type: "string" } },
};
for (const v of Object.values(
config.ui.filters as Record<
string,
{ label: string; parameter: string; type: string }
>,
)) {
for (const v of filters) {
resources.devices[v.label] = {
parameter: v.parameter,
type: (v.type || "").split(",").map((s) => s.trim()),
@@ -70,7 +122,7 @@ function queryNumber(param: Expression, value: string): Expression {
const v = parseInt(value);
if (v !== +value) return null;
return [op, param, v];
return new Expression.Binary(op, param, new Expression.Literal(v));
}
function queryTimestamp(param: Expression, value: string): Expression {
@@ -86,52 +138,71 @@ function queryTimestamp(param: Expression, value: string): Expression {
let v = parseInt(value);
if (v !== +value) v = Date.parse(value);
if (isNaN(v)) return null;
return [op, param, v];
return new Expression.Binary(op, param, new Expression.Literal(v));
}
function queryString(param: Expression, value: string): Expression {
return ["LIKE", ["FUNC", "LOWER", param], value.toLowerCase()];
return new Expression.Binary(
"LIKE",
new Expression.FunctionCall("LOWER", [param]),
new Expression.Literal(value.toLowerCase()),
);
}
function queryStringCaseSensitive(
param: Expression,
value: string,
): Expression {
return ["LIKE", param, value];
return new Expression.Binary("LIKE", param, new Expression.Literal(value));
}
function queryStringMonoCase(param: Expression, value: string): Expression {
return [
"OR",
["LIKE", param, value.toLowerCase()],
["LIKE", param, value.toUpperCase()],
];
return Expression.or(
new Expression.Binary(
"LIKE",
param,
new Expression.Literal(value.toLowerCase()),
),
new Expression.Binary(
"LIKE",
param,
new Expression.Literal(value.toUpperCase()),
),
);
}
function queryMac(param: Expression, value: string): Expression {
value = value.replace(/[^a-f0-9]/gi, "").toLowerCase();
if (!value) return null;
if (value.length === 12) {
return [
"OR",
["=", param, value.replace(/(..)(?!$)/g, "$1:").toLowerCase()],
["=", param, value.replace(/(..)(?!$)/g, "$1:").toUpperCase()],
];
value = value.replace(/(..)(?!$)/g, "$1:");
return Expression.or(
new Expression.Binary(
"=",
param,
new Expression.Literal(value.toLowerCase()),
),
new Expression.Binary(
"=",
param,
new Expression.Literal(value.toUpperCase()),
),
);
}
return [
"OR",
[
param = new Expression.FunctionCall("LOWER", [param]);
return Expression.or(
new Expression.Binary(
"LIKE",
["FUNC", "LOWER", param],
`%${value.replace(/(..)(?!$)/g, "$1:")}%`,
],
[
param,
new Expression.Literal(`%${value.replace(/(..)(?!$)/g, "$1:")}%`),
),
new Expression.Binary(
"LIKE",
["FUNC", "LOWER", param],
`%${value.replace(/(.)(.)/g, "$1:$2")}%`,
],
];
param,
new Expression.Literal(`%${value.replace(/(.)(.)/g, "$1:$2")}%`),
),
);
}
function queryMacWildcard(param: Expression, value: string): Expression {
@@ -143,23 +214,32 @@ function queryMacWildcard(param: Expression, value: string): Expression {
p.replace(/(.)(.)/gi, "$1:$2"),
]);
const res = new Set();
const set = new Set<string>();
for (let i = 0; i < 2 ** groups.length; ++i) {
const r = groups.map((g, j) => g[(i >> j) & 1]).join("%");
if (/^[a-f0-9]:/i.test(r) || /:[a-f0-9]$/i.test(r)) continue;
res.add(r.toLocaleLowerCase());
res.add(r.toUpperCase());
set.add(r.toLocaleLowerCase());
set.add(r.toUpperCase());
}
if (!res.size) return queryStringMonoCase(param, value);
if (!set.size) return queryStringMonoCase(param, value);
const clauses = [...res].map((r) => ["LIKE", param, r]);
if (clauses.length === 1) return clauses[0];
return ["OR", ...clauses];
let res: Expression = new Expression.Literal(false);
for (const s of set) {
res = Expression.or(
res,
new Expression.Binary("LIKE", param, new Expression.Literal(s)),
);
}
return res;
}
function queryTag(tag: string): Expression {
const t = encodeTag(tag);
return ["IS NOT NULL", ["PARAM", `Tags.${t}`]];
return new Expression.Unary(
"IS NOT NULL",
new Expression.Parameter(Path.parse(`Tags.${t}`)),
);
}
export function getTip(resource: string, label: string): string {
@@ -214,21 +294,21 @@ export function unpack(
if (!resources[resource]) return null;
const type = resources[resource][label].type;
value = value.trim();
const res: Expression = ["OR"];
let res: Expression = new Expression.Literal(false);
if (type.length === 0 || type.includes("number")) {
const q = queryNumber(resources[resource][label].parameter, value);
if (q) res.push(q);
if (q) res = Expression.or(res, q);
}
if (type.length === 0 || type.includes("string")) {
const q = queryString(resources[resource][label].parameter, value);
if (q) res.push(q);
if (q) res = Expression.or(res, q);
}
if (type.length === 0 || type.includes("timestamp")) {
const q = queryTimestamp(resources[resource][label].parameter, value);
if (q) res.push(q);
if (q) res = Expression.or(res, q);
}
if (type.includes("string-casesensitive")) {
@@ -236,30 +316,28 @@ export function unpack(
resources[resource][label].parameter,
value,
);
if (q) res.push(q);
if (q) res = Expression.or(res, q);
}
if (type.includes("string-monocase")) {
const q = queryStringMonoCase(resources[resource][label].parameter, value);
if (q) res.push(q);
if (q) res = Expression.or(res, q);
}
if (type.includes("mac")) {
const q = queryMac(resources[resource][label].parameter, value);
if (q) res.push(q);
if (q) res = Expression.or(res, q);
}
if (type.includes("mac-wildcard")) {
const q = queryMacWildcard(resources[resource][label].parameter, value);
if (q) res.push(q);
if (q) res = Expression.or(res, q);
}
if (type.includes("tag")) {
const q = queryTag(value);
if (q) res.push(q);
if (q) res = Expression.or(res, q);
}
if (res.length <= 1) return null;
else if (res.length === 2) return res[1];
else return res;
return res;
}
+85 -41
View File
@@ -1,8 +1,8 @@
import m from "mithril";
import { stringify } from "../lib/common/expression/parser.ts";
import { or, and, evaluate } from "../lib/common/expression/util.ts";
import Expression from "../lib/common/expression.ts";
import Path from "../lib/common/path.ts";
import memoize from "../lib/common/memoize.ts";
import { Expression, Task } from "../lib/types.ts";
import { Task } from "../lib/types.ts";
import * as notifications from "./notifications.ts";
import { configSnapshot, genieacsVersion } from "./config.ts";
import { QueueTask } from "./task-queue.ts";
@@ -14,7 +14,30 @@ import {
toBookmark,
} from "../lib/common/expression/pagination.ts";
const memoizedStringify = memoize(stringify);
function evaluate(exp: Expression, timestamp: number): Expression;
function evaluate(
exp: Expression,
timestamp: number,
obj: Record<string, unknown>,
): Expression.Literal;
function evaluate(
exp: Expression,
timestamp: number,
obj?: Record<string, unknown>,
): Expression {
return exp.evaluate((e) => {
if (e instanceof Expression.Literal) return e;
else if (e instanceof Expression.FunctionCall) {
if (e.name === "NOW") return new Expression.Literal(timestamp);
} else if (e instanceof Expression.Parameter && obj) {
let v = obj[e.path.toString()];
if (v == null) return new Expression.Literal(null);
if (typeof v === "object") v = v["value"]?.[0];
return new Expression.Literal(v as any);
}
return e;
});
}
const memoizedEvaluate = memoize(evaluate);
let fulfillTimestamp = 0;
@@ -59,7 +82,7 @@ for (const r of [
objects: new Map(),
count: new Map(),
fetch: new Map(),
combinedFilter: null,
combinedFilter: new Expression.Literal(false) as Expression,
};
}
@@ -215,13 +238,11 @@ export async function xhrRequest(
}
export function unpackExpression(exp: Expression): Expression {
if (!Array.isArray(exp)) return exp;
const e = memoizedEvaluate(exp, null, fulfillTimestamp + clockSkew);
return e;
return memoizedEvaluate(exp, fulfillTimestamp + clockSkew);
}
export function count(resourceType: string, filter: Expression): QueryResponse {
const filterStr = memoizedStringify(filter);
const filterStr = filter.toString();
let queryResponse = resources[resourceType].count.get(filterStr);
if (queryResponse) return queryResponse;
@@ -271,7 +292,8 @@ function compareFunction(sort: {
function findMatches(resourceType, filter, sort, limit): any[] {
let value = [];
for (const obj of resources[resourceType].objects.values())
if (evaluate(filter, obj, fulfillTimestamp + clockSkew)) value.push(obj);
if (evaluate(filter, fulfillTimestamp + clockSkew, obj).value)
value.push(obj);
value = value.sort(compareFunction(sort));
if (limit) value = value.slice(0, limit);
@@ -284,7 +306,6 @@ export function fetch(
filter: Expression,
options: { limit?: number; sort?: { [param: string]: number } } = {},
): QueryResponse {
const filterStr = memoizedStringify(filter);
const sort = Object.assign({}, options.sort);
const limit = options.limit || 0;
@@ -292,7 +313,7 @@ export function fetch(
sort["DeviceID.ID"] = sort["DeviceID.ID"] || 1;
else sort["_id"] = sort["_id"] || 1;
const key = `${filterStr}:${limit}:${JSON.stringify(sort)}`;
const key = `${filter.toString()}:${limit}:${JSON.stringify(sort)}`;
let queryResponse = resources[resourceType].fetch.get(key);
if (queryResponse) return queryResponse;
@@ -308,7 +329,10 @@ export function fetch(
);
const matches = findMatches(resourceType, satisfied, sort, limit);
queries.value.set(queryResponse, matches);
if (!diff || (limit && matches.length >= limit))
if (
(diff instanceof Expression.Literal && !diff.value) ||
(limit && matches.length >= limit)
)
queries.fulfilled.set(queryResponse, fulfillTimestamp);
else queries.unsatisfied.set(queryResponse, diff);
return queryResponse;
@@ -336,7 +360,7 @@ export function fulfill(accessTimestamp: number): void {
url:
`api/${resourceType}/?` +
m.buildQueryString({
filter: memoizedStringify(filter),
filter: filter.toString(),
}),
extract: (xhr) => {
if (xhr.status === 403) throw new Error("Not authorized");
@@ -393,7 +417,7 @@ export function fulfill(accessTimestamp: number): void {
url:
`api/${resourceType}/?` +
m.buildQueryString({
filter: memoizedStringify(filter),
filter: filter.toString(),
limit: 1,
skip: limit - 1,
sort: JSON.stringify(sort),
@@ -419,16 +443,19 @@ export function fulfill(accessTimestamp: number): void {
let updated = false;
const allPromises2 = [];
for (const [resourceType, toFetch] of Object.entries(toFetchAll)) {
let combinedFilter = null;
let combinedFilter = new Expression.Literal(false) as Expression;
for (const queryResponse of toFetch) {
let filter = queries.filter.get(queryResponse);
filter = memoizedEvaluate(filter, null, fulfillTimestamp + clockSkew);
filter = memoizedEvaluate(filter, fulfillTimestamp + clockSkew);
const bookmark = queries.bookmark.get(queryResponse);
const sort = queries.sort.get(queryResponse);
if (bookmark)
filter = and(filter, bookmarkToExpression(bookmark, sort));
combinedFilter = or(combinedFilter, filter);
filter = Expression.and(
filter,
bookmarkToExpression(bookmark, sort),
);
combinedFilter = Expression.or(combinedFilter, filter);
}
const [union, diff] = unionDiff(
@@ -436,19 +463,18 @@ export function fulfill(accessTimestamp: number): void {
combinedFilter,
);
if (!diff) {
if (diff instanceof Expression.Literal && !diff.value) {
for (const queryResponse of toFetch) {
let filter = queries.filter.get(queryResponse);
filter = memoizedEvaluate(
filter,
null,
fulfillTimestamp + clockSkew,
);
filter = memoizedEvaluate(filter, fulfillTimestamp + clockSkew);
const limit = queries.limit.get(queryResponse);
const bookmark = queries.bookmark.get(queryResponse);
const sort = queries.sort.get(queryResponse);
if (bookmark)
filter = and(filter, bookmarkToExpression(bookmark, sort));
filter = Expression.and(
filter,
bookmarkToExpression(bookmark, sort),
);
queries.value.set(
queryResponse,
@@ -462,7 +488,11 @@ export function fulfill(accessTimestamp: number): void {
}
let deleted = new Set<string>();
if (!resources[resourceType].combinedFilter)
if (
resources[resourceType].combinedFilter instanceof
Expression.Literal &&
!resources[resourceType].combinedFilter.value
)
deleted = new Set(resources[resourceType].objects.keys());
const combinedFilterDiff = diff;
@@ -474,15 +504,12 @@ export function fulfill(accessTimestamp: number): void {
url:
`api/${resourceType}/?` +
m.buildQueryString({
filter: memoizedStringify(combinedFilterDiff),
filter: combinedFilterDiff.toString(),
}),
background: false,
}).then((res) => {
for (const r of res as any[]) {
const id =
resourceType === "devices"
? r["DeviceID.ID"].value[0]
: r["_id"];
const id = r["DeviceID.ID"] ?? r["_id"];
resources[resourceType].objects.set(id, r);
deleted.delete(id);
}
@@ -490,7 +517,8 @@ export function fulfill(accessTimestamp: number): void {
for (const d of deleted) {
const obj = resources[resourceType].objects.get(d);
if (
evaluate(combinedFilterDiff, obj, fulfillTimestamp + clockSkew)
evaluate(combinedFilterDiff, fulfillTimestamp + clockSkew, obj)
.value
)
resources[resourceType].objects.delete(d);
}
@@ -502,7 +530,10 @@ export function fulfill(accessTimestamp: number): void {
const bookmark = queries.bookmark.get(queryResponse);
const sort = queries.sort.get(queryResponse);
if (bookmark)
filter = and(filter, bookmarkToExpression(bookmark, sort));
filter = Expression.and(
filter,
bookmarkToExpression(bookmark, sort),
);
queries.value.set(
queryResponse,
@@ -530,7 +561,7 @@ export function setTimestamp(t: number): void {
if (t > fulfillTimestamp) {
fulfillTimestamp = t;
for (const resource of Object.values(resources))
resource.combinedFilter = null;
resource.combinedFilter = new Expression.Literal(false);
}
}
@@ -607,23 +638,32 @@ export function putResource(
}
export function queryConfig(pattern = "%"): Promise<any[]> {
const filter = stringify(["LIKE", ["PARAM", "_id"], pattern]);
const filter = new Expression.Binary(
"LIKE",
new Expression.Parameter(Path.parse("_id")),
new Expression.Literal(pattern),
);
return xhrRequest({
method: "GET",
url: `api/config/?${m.buildQueryString({ filter: filter })}`,
url: `api/config/?${m.buildQueryString({ filter: filter.toString() })}`,
background: true,
});
}
export function resourceExists(resource: string, id: string): Promise<number> {
const param = resource === "devices" ? "DeviceID.ID" : "_id";
const filter = ["=", ["PARAM", param], id];
const filter = new Expression.Binary(
"=",
new Expression.Parameter(Path.parse(param)),
new Expression.Literal(id),
);
return xhrRequest({
method: "HEAD",
url:
`api/${resource}/?` +
m.buildQueryString({
filter: memoizedStringify(filter),
filter: filter.toString(),
}),
extract: (xhr) => {
if (xhr.status === 403) throw new Error("Not authorized");
@@ -636,12 +676,16 @@ export function resourceExists(resource: string, id: string): Promise<number> {
});
}
export function evaluateExpression(exp: Expression): Expression;
export function evaluateExpression(
exp: Expression,
obj: Record<string, unknown>,
): Expression.Literal;
export function evaluateExpression(
exp: Expression,
obj?: Record<string, unknown>,
): Expression {
if (!Array.isArray(exp)) return exp;
return memoizedEvaluate(exp, obj, fulfillTimestamp + clockSkew);
return memoizedEvaluate(exp, fulfillTimestamp + clockSkew, obj);
}
export function changePassword(
+2 -2
View File
@@ -4,7 +4,7 @@ import * as store from "./store.ts";
import { yaml } from "./dynamic-loader.ts";
import * as configFunctions from "./config-functions.ts";
import codeEditorComponent from "./code-editor-component.ts";
import { parse } from "../lib/common/expression/parser.ts";
import Expression from "../lib/common/expression.ts";
function putActionHandler(prefix: string[], dataYaml: string): Promise<any> {
return new Promise((resolve, reject) => {
@@ -27,7 +27,7 @@ function putActionHandler(prefix: string[], dataYaml: string): Promise<any> {
}
// Try parse to ensure valid expressions
for (const v of Object.values(updated)) parse(v as string);
for (const v of Object.values(updated)) Expression.parse(v as string);
store
.queryConfig(`${prefix.join(".")}.%`)
+34 -18
View File
@@ -1,6 +1,6 @@
import { Children, ClosureComponent, Component } from "mithril";
import { m } from "./components.ts";
import config from "./config.ts";
import { pageSize as PAGE_SIZE } from "./config.ts";
import * as store from "./store.ts";
import * as notifications from "./notifications.ts";
import memoize from "../lib/common/memoize.ts";
@@ -8,13 +8,10 @@ import putFormComponent from "./put-form-component.ts";
import indexTableComponent from "./index-table-component.ts";
import * as overlay from "./overlay.ts";
import * as smartQuery from "./smart-query.ts";
import { map, parse, stringify } from "../lib/common/expression/parser.ts";
import Expression from "../lib/common/expression.ts";
import filterComponent from "./filter-component.ts";
import changePasswordComponent from "./change-password-component.ts";
const PAGE_SIZE = config.ui.pageSize || 10;
const memoizedParse = memoize(parse);
const memoizedJsonParse = memoize(JSON.parse);
const attributes = [
@@ -22,10 +19,22 @@ const attributes = [
{ id: "roles", label: "Roles", type: "multi", options: [] },
];
const unpackSmartQuery = memoize((query) => {
return map(query, (e) => {
if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q")
return smartQuery.unpack("users", e[2], e[3]);
const unpackSmartQuery = memoize((query: Expression) => {
return query.evaluate((e) => {
if (e instanceof Expression.FunctionCall) {
if (e.name === "Q") {
if (
e.args[0] instanceof Expression.Literal &&
e.args[1] instanceof Expression.Literal
) {
return smartQuery.unpack(
"users",
e.args[0].value as string,
e.args[1].value as string,
);
}
}
}
return e;
});
});
@@ -120,7 +129,7 @@ const getDownloadUrl = memoize((filter) => {
for (const attr of attributes) cols[attr.label] = attr.id;
return `api/users.csv?${m.buildQueryString({
filter: stringify(filter),
filter: filter.toString(),
columns: JSON.stringify(cols),
})}`;
});
@@ -134,8 +143,11 @@ export function init(
);
}
const sort = args.hasOwnProperty("sort") ? "" + args["sort"] : "";
const filter = args.hasOwnProperty("filter") ? "" + args["filter"] : "";
let filter: Expression = null;
let sort: Record<string, number> = null;
if (args.hasOwnProperty("filter"))
filter = Expression.parse(args["filter"] as string);
if (args.hasOwnProperty("sort")) sort = JSON.parse(args["sort"] as string);
return Promise.resolve({ filter, sort });
}
@@ -151,7 +163,9 @@ export const component: ClosureComponent = (): Component => {
}
function onFilterChanged(filter): void {
const ops = { filter };
const ops = {};
if (!(filter instanceof Expression.Literal && filter.value))
ops["filter"] = filter.toString();
if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"];
m.route.set("/users", ops);
}
@@ -176,10 +190,9 @@ export const component: ClosureComponent = (): Component => {
m.route.set("/users", ops);
}
let filter = vnode.attrs["filter"]
? memoizedParse(vnode.attrs["filter"])
: true;
filter = unpackSmartQuery(filter);
const filter = unpackSmartQuery(
vnode.attrs["filter"] ?? new Expression.Literal(true),
);
const users = store.fetch("users", filter, {
limit: vnode.state["showCount"] || PAGE_SIZE,
@@ -189,7 +202,10 @@ export const component: ClosureComponent = (): Component => {
const count = store.count("users", filter);
// Getting the roles
const permissions = store.fetch("permissions", true);
const permissions = store.fetch(
"permissions",
new Expression.Literal(true),
);
if (permissions.fulfilled) {
for (const attr of attributes) {
if (attr.id === "roles")
+30 -17
View File
@@ -1,6 +1,6 @@
import { ClosureComponent, Component, Children } from "mithril";
import { m } from "./components.ts";
import config from "./config.ts";
import { pageSize as PAGE_SIZE } from "./config.ts";
import filterComponent from "./filter-component.ts";
import * as store from "./store.ts";
import * as notifications from "./notifications.ts";
@@ -9,12 +9,9 @@ import putFormComponent from "./put-form-component.ts";
import indexTableComponent from "./index-table-component.ts";
import * as overlay from "./overlay.ts";
import * as smartQuery from "./smart-query.ts";
import { map, parse, stringify } from "../lib/common/expression/parser.ts";
import Expression from "../lib/common/expression.ts";
import { loadCodeMirror } from "./dynamic-loader.ts";
const PAGE_SIZE = config.ui.pageSize || 10;
const memoizedParse = memoize(parse);
const memoizedJsonParse = memoize(JSON.parse);
const attributes = [
@@ -22,10 +19,22 @@ const attributes = [
{ id: "script", label: "Script", type: "code" },
];
const unpackSmartQuery = memoize((query) => {
return map(query, (e) => {
if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q")
return smartQuery.unpack("virtualParameters", e[2], e[3]);
const unpackSmartQuery = memoize((query: Expression) => {
return query.evaluate((e) => {
if (e instanceof Expression.FunctionCall) {
if (e.name === "Q") {
if (
e.args[0] instanceof Expression.Literal &&
e.args[1] instanceof Expression.Literal
) {
return smartQuery.unpack(
"virtualParameters",
e.args[0].value as string,
e.args[1].value as string,
);
}
}
}
return e;
});
});
@@ -104,7 +113,7 @@ const getDownloadUrl = memoize((filter) => {
const cols = {};
for (const attr of attributes) cols[attr.label] = attr.id;
return `api/virtualParameters.csv?${m.buildQueryString({
filter: stringify(filter),
filter: filter.toString(),
columns: JSON.stringify(cols),
})}`;
});
@@ -118,8 +127,11 @@ export function init(
);
}
const sort = args.hasOwnProperty("sort") ? "" + args["sort"] : "";
const filter = args.hasOwnProperty("filter") ? "" + args["filter"] : "";
let filter: Expression = null;
let sort: Record<string, number> = null;
if (args.hasOwnProperty("filter"))
filter = Expression.parse(args["filter"] as string);
if (args.hasOwnProperty("sort")) sort = JSON.parse(args["sort"] as string);
return new Promise((resolve, reject) => {
loadCodeMirror()
@@ -142,7 +154,9 @@ export const component: ClosureComponent = (): Component => {
}
function onFilterChanged(filter): void {
const ops = { filter };
const ops = {};
if (!(filter instanceof Expression.Literal && filter.value))
ops["filter"] = filter.toString();
if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"];
m.route.set("/virtualParameters", ops);
}
@@ -164,10 +178,9 @@ export const component: ClosureComponent = (): Component => {
m.route.set("/virtualParameters", ops);
}
let filter = vnode.attrs["filter"]
? memoizedParse(vnode.attrs["filter"])
: true;
filter = unpackSmartQuery(filter);
const filter = unpackSmartQuery(
vnode.attrs["filter"] ?? new Expression.Literal(true),
);
const virtualParameters = store.fetch("virtualParameters", filter, {
limit: vnode.state["showCount"] || PAGE_SIZE,