Compare commits

...
25 Commits
Author SHA1 Message Date
Zaid Humaimidi 944a690d96 1.2.15 2026-03-26 14:12:26 -07:00
Zaid Humaimidi e7facc3bba Changelog for v1.2.15 2026-03-26 14:10:18 -07:00
Zaid Humaimidi aa83b62064 Update dependencies 2026-03-26 13:50:37 -07:00
Zaid Humaimidi f9ee39dfd0 Fix device ID parsing regression in Mithril 2.3.8
Device IDs containing literal % characters (e.g., "202BC1-CODA%2D5519-000000")
are double-encoded in URLs. Mithril 2.3.8 incorrectly double-decodes route
parameters, corrupting device IDs with special characters.

Workaround: Extract device ID directly from window.location.hash and decode
once to get the correct value.

See: https://github.com/MithrilJS/mithril.js/issues/3064
2026-03-26 13:20:19 -07:00
Zaid Humaimidi 087b95388f Fix RCE vulnerability in /api/ping endpoint
This addresses a follow-up to CVE-2021-46704 where the host validation
could be bypassed via domainToASCII stripping path components.

Changes:
- Normalize host with domainToASCII before validation
- Add round-trip check with domainToUnicode to catch path injection
- Switch from exec to execFile to prevent shell injection
- Add -- separator to ping arguments

Credit: JuHwiSang for reporting this vulnerability
2026-03-25 15:00:00 -07:00
Zaid Humaimidi cc723c0b50 1.2.14 2026-03-12 14:26:22 -07:00
Zaid Humaimidi a2a6fd82f3 Changelog for v1.2.14 2026-03-12 14:25:25 -07:00
Zaid Humaimidi 729beba0ed Update dependencies 2026-03-12 12:38:03 -07:00
Zaid Humaimidi 1b14387aa2 Fix covers() for false and contradictions 2026-03-12 10:21:03 -07:00
Zaid Humaimidi 49cdd39617 Fix (de)serialization bug in VersionedMap 2025-02-12 19:05:08 -08:00
Zaid Humaimidi 4be39ed742 Replace deprecated koa middleware 2024-10-04 11:28:04 -07:00
Zaid Humaimidi 8ddb692370 Prevent UI crash when malformed URL is sent 2024-10-04 11:05:07 -07:00
Zaid Humaimidi fc80a7d487 1.2.13 2024-06-06 12:55:38 -07:00
Zaid Humaimidi f34f10edc7 Changelog for v1.2.13 2024-06-06 12:55:12 -07:00
Zaid Humaimidi a416f68b76 Update dependencies 2024-06-06 12:52:53 -07:00
Zaid Humaimidi 2b61c8f7b8 Fix race condition when deleting multiple faults 2024-05-23 13:22:35 -07:00
Zaid Humaimidi 756912eeb1 Fix dynamic evaluation of UI config expressions 2024-04-22 14:14:12 -07:00
Zaid Humaimidi 5c4fc3263b Increase server timeout to 2 mins
To allow enough time for running unindexed queries in large deployments.
2024-04-18 12:16:44 -07:00
Zaid Humaimidi 307b9f57b0 Fix bug in Mongo query construction 2024-04-18 11:51:29 -07:00
Zaid Humaimidi 0f6604ffbd 1.2.12 2024-03-28 12:56:35 -07:00
Zaid Humaimidi f57a26c95b Changelog for v1.2.12 2024-03-28 12:04:31 -07:00
Zaid Humaimidi 60e6d8b175 Fix regression in CSV and JSON streaming 2024-03-27 14:08:35 -07:00
Zaid Humaimidi ab80a62c5c Fix bug preventing XMPP client from connecting 2024-03-27 13:57:09 -07:00
Zaid Humaimidi 3134b63b68 Add requirements.txt for RTD 2024-03-21 13:06:33 -07:00
Zaid Humaimidi 2b1bca7dde Add .readthedocs.yaml file 2024-03-21 12:27:40 -07:00
30 changed files with 3351 additions and 3075 deletions

No files matched your search

+35
View File
@@ -1,5 +1,40 @@
# Change Log
## 1.2.15 (2026-03-26)
- Fix critical RCE vulnerability in /api/ping endpoint (follow-up to
CVE-2021-46704). Credit: JuHwiSang.
- Fix device IDs containing special characters (e.g., %) not being handled
correctly in the UI due to a regression in Mithril 2.3.8.
## 1.2.14 (2026-03-12)
- Prevent UI crash when a malformed URL is sent to the server.
- Fix potential edge-case bugs in expression evaluation and session
serialization.
## 1.2.13 (2024-06-06)
- Increase connection timeout for UI and NBI from 30 to 120 seconds to avoid
timeouts when running unindexed queries in large deployments.
- Fix race condition causing 503 error when deleting multiple faults at once.
- Fix some UI config options not being evaluated as dynamic expressions.
- Fix an issue where certain edge-case query expressions were not being
correctly converted to MongoDB queries, resulting in inaccurate search
results.
## 1.2.12 (2024-03-28)
- Fix broken XMPP support in the previous release.
- Fix regression causing CSV downloads to be buffered in memory before being
streamed to the client.
## 1.2.11 (2024-03-21)
- Resolved an issue from the previous release that caused incompatibility with
+1 -1
View File
@@ -64,7 +64,7 @@ if (!cluster.worker) {
port: SERVICE_PORT,
host: SERVICE_ADDRESS,
ssl: key && cert ? { key, cert } : null,
timeout: 30000,
timeout: 120000,
};
// Need this for Node < 15
+1 -1
View File
@@ -64,7 +64,7 @@ if (!cluster.worker) {
port: SERVICE_PORT,
host: SERVICE_ADDRESS,
ssl: key && cert ? { key, cert } : null,
timeout: 30000,
timeout: 120000,
};
// Need this for Node < 15
+1
View File
@@ -177,3 +177,4 @@ CIDR
XMPP
PEM
JID
unindexed
+18
View File
@@ -0,0 +1,18 @@
# Read the Docs configuration file for Sphinx projects
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
version: 2
build:
os: ubuntu-22.04
tools:
python: "3.12"
sphinx:
configuration: docs/conf.py
formats: all
python:
install:
- requirements: docs/requirements.txt
+2 -2
View File
@@ -19,7 +19,7 @@ import json
# -- Project information -----------------------------------------------------
project = 'GenieACS Documentation'
copyright = '2019, GenieACS Inc.'
copyright = '2024, GenieACS Inc.'
author = 'GenieACS Inc.'
# The full version, including alpha/beta/rc tags
@@ -58,7 +58,7 @@ html_theme_options = {
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# html_static_path = ['_static']
master_doc = 'index'
+1
View File
@@ -0,0 +1 @@
sphinx_rtd_theme==2.0.0
+4 -4
View File
@@ -2,13 +2,13 @@ import { PermissionSet, Expression } from "../types.ts";
import { evaluate, or } from "./expression/util.ts";
export default class Authorizer {
private declare permissionSets: PermissionSet[];
private declare validatorCache: WeakMap<
declare private permissionSets: PermissionSet[];
declare private validatorCache: WeakMap<
any,
(mutationType, mutation, any) => boolean
>;
private declare hasAccessCache: Map<string, boolean>;
private declare getFilterCache: Map<string, Expression>;
declare private hasAccessCache: Map<string, boolean>;
declare private getFilterCache: Map<string, Expression>;
public constructor(permissionSets: PermissionSet[]) {
this.permissionSets = permissionSets;
+3 -3
View File
@@ -9,8 +9,8 @@ const TWO = BI.BigInt(2);
const NEGATIVE_ONE = BI.BigInt(-1);
class Indeterminates {
public declare map: Map<string, number>;
public declare sortedKeys: string[];
declare public map: Map<string, number>;
declare public sortedKeys: string[];
public constructor(str?: string) {
this.map = new Map();
@@ -98,7 +98,7 @@ function findGcd(a: BI.bigint, b: BI.bigint): BI.bigint {
}
class Polynomial {
public declare terms: Term[];
declare public terms: Term[];
public constructor(terms: Term[]) {
this.terms = terms;
+1 -1
View File
@@ -955,7 +955,7 @@ export function covers(expr1: Expression, expr2: Expression): boolean {
expr2 = normalize(expr2);
if (!expr2) return true;
expr1 = normalize(expr1);
if (!Array.isArray(expr1)) return !!expr1;
if (!Array.isArray(expr1) && expr1) return true;
const synt1 = Clause.fromExpression(expr1);
const synt2 = Clause.fromExpression(expr2);
+3 -3
View File
@@ -1,9 +1,9 @@
import Path from "./path.ts";
export default class PathSet {
private declare lengthIndex: Set<Path>[];
private declare fragmentIndex: Map<string, Set<Path>>[];
private declare stringIndex: Map<string, Path>;
declare private lengthIndex: Set<Path>[];
declare private fragmentIndex: Map<string, Set<Path>>[];
declare private stringIndex: Map<string, Path>;
public constructor() {
this.lengthIndex = [];
+5 -5
View File
@@ -30,11 +30,11 @@ function legalChar(c: number): boolean {
}
export default class Path {
public declare readonly segments: Segments;
public declare readonly wildcard: number;
public declare readonly alias: number;
protected declare _string: string;
protected declare _stringIndex: number[];
declare public readonly segments: Segments;
declare public readonly wildcard: number;
declare public readonly alias: number;
declare protected _string: string;
declare protected _stringIndex: number[];
protected static parseAliasValue(
pattern: string,
+17 -5
View File
@@ -578,6 +578,8 @@ class MongoSynthContext extends SynthContextBase<Clause, MongoClause> {
toQuery(minterms: Minterm[]): Filter<unknown> {
const or = [];
const ejsonOps = ["$oid", "$date", "$regularExpression"];
for (const minterm of minterms) {
const query = {};
loop: for (const clause of minterm) {
@@ -588,8 +590,6 @@ class MongoSynthContext extends SynthContextBase<Clause, MongoClause> {
if (Object.keys(q).length !== 1)
throw new Error("Invalid query expression");
const [param, value] = Object.entries(q)[0];
if (Object.getPrototypeOf(value).constructor !== Object)
throw new Error("Invalid query expression");
const dests = [query, ...(query["$and"] ?? [])];
for (const dest of dests) {
if (!(param in dest)) {
@@ -599,17 +599,29 @@ class MongoSynthContext extends SynthContextBase<Clause, MongoClause> {
let src = value;
let dst = dest[param];
if (Object.getPrototypeOf(src).constructor !== Object) continue;
if (Object.getPrototypeOf(dst).constructor !== Object) continue;
let srcKeys = Object.keys(src);
let dstKeys = Object.keys(dst);
if (src["$not"] && dst["$not"]) {
if ([...srcKeys, ...dstKeys].every((k) => k === "$not")) {
src = src["$not"];
dst = dst["$not"];
if (Object.getPrototypeOf(src).constructor !== Object) continue;
if (Object.getPrototypeOf(dst).constructor !== Object) continue;
srcKeys = Object.keys(src);
dstKeys = Object.keys(dst);
}
if (!Object.keys(src).some((k) => k in dst)) {
if (srcKeys.some((k) => dstKeys.includes(k))) continue;
// Don't mix regular operators with EJSON special operators
if (srcKeys.some((k) => ejsonOps.includes(k))) continue;
if (dstKeys.some((k) => ejsonOps.includes(k))) continue;
Object.assign(dst, src);
continue loop;
}
}
query["$and"] ??= [];
query["$and"].push({ [param]: value });
+1 -1
View File
@@ -3,7 +3,7 @@ interface Instance {
}
export default class InstanceSet {
private declare set: Set<Instance>;
declare private set: Set<Instance>;
public constructor() {
this.set = new Set();
+16 -7
View File
@@ -1,6 +1,6 @@
import { platform } from "node:os";
import { exec } from "node:child_process";
import { domainToASCII } from "node:url";
import { execFile } from "node:child_process";
import { domainToASCII, domainToUnicode } from "node:url";
export interface PingResult {
packetsTransmitted: number;
@@ -16,9 +16,16 @@ function isValidHost(host: string): boolean {
// Valid chars in IPv4, IPv6, domain names
if (/^[a-zA-Z0-9\-.:[\]-]+$/.test(host)) return true;
const lowercase = host.toLowerCase();
const ascii = domainToASCII(lowercase);
// Check if input is an IDN convert to Punycode
// Can't merge with above because domainToASCII doesn't accept IP addresses
return /^[a-zA-Z0-9\-.:[\]-]+$/.test(domainToASCII(host));
if (!/^[a-zA-Z0-9\-.:[\]-]+$/.test(ascii)) return false;
// Round-trip to ensure the original host matches its normalized form.
// This prevents bypasses where domainToASCII strips paths (e.g. "example.com/;cmd").
return lowercase === domainToUnicode(ascii);
}
export function parsePing(osPlatform: string, stdout: string): PingResult {
@@ -73,24 +80,26 @@ export function ping(
): void {
// Validate input to prevent possible remote code execution
// Credit to Alex Hordijk for reporting this vulnerability
// Credit to JuHwiSang for reporting a follow-up vulnerability
if (!isValidHost(host)) return callback(new Error("Invalid host"));
host = host.replace("[", "").replace("]", "");
let cmd: string;
let args: string[];
switch (platform()) {
case "linux":
cmd = `ping -w 1 -i 0.2 -c 3 ${host}`;
args = ["-w", "1", "-i", "0.2", "-c", "3", "--", host];
break;
case "freebsd":
// Send a single packet because on FreeBSD only superuser can send
// packets that are only 200 ms apart.
cmd = `ping -t 1 -c 3 ${host}`;
args = ["-t", "1", "-c", "3", "--", host];
break;
default:
return callback(new Error("Platform not supported"));
}
exec(cmd, (err, stdout) => {
// Use execFile instead of exec to avoid shell interpretation of the host
execFile("ping", args, (err, stdout) => {
if (err) return callback(err);
const parsed: PingResult = parsePing(platform(), stdout);
return callback(err, parsed, stdout);
+15 -10
View File
@@ -1,10 +1,10 @@
import { constants } from "node:zlib";
import Koa from "koa";
import Router from "koa-router";
import Router from "@koa/router";
import * as jwt from "jsonwebtoken";
import koaStatic from "koa-static";
import koaSend from "koa-send";
import koaCompress from "koa-compress";
import koaBodyParser from "koa-bodyparser";
import koaBodyParser from "@koa/bodyparser";
import koaJwt from "koa-jwt";
import * as config from "./config.ts";
import api from "./ui/api.ts";
@@ -18,12 +18,6 @@ import { version as VERSION } from "../package.json";
import memoize from "./common/memoize.ts";
import { APP_JS, APP_CSS, FAVICON_PNG } from "../build/assets.ts";
declare module "koa" {
interface Request {
body: any;
}
}
const koa = new Koa();
const router = new Router();
@@ -262,6 +256,17 @@ koa.use(
);
koa.use(router.routes());
koa.use(koaStatic(config.ROOT_DIR + "/public"));
koa.use(async (ctx, next) => {
await next();
if (ctx.method !== "HEAD" && ctx.method !== "GET") return;
if (ctx.body != null || ctx.status !== 404) return;
if (/(?:^|[\\/])\.\.(?:[\\/]|$)/.test(ctx.path)) return;
try {
await koaSend(ctx, ctx.path, { root: config.ROOT_DIR + "/public" });
} catch (err) {
if (err.status !== 404) throw err;
}
});
export const listener = koa.callback();
+27 -25
View File
@@ -1,5 +1,5 @@
import { PassThrough } from "node:stream";
import Router from "koa-router";
import { Readable } from "node:stream";
import Router from "@koa/router";
import { ObjectId } from "mongodb";
import * as db from "./db.ts";
import * as apiFunctions from "../api-functions.ts";
@@ -88,10 +88,9 @@ router.get(`/devices/:id.csv`, async (ctx) => {
.replace(/[:.]/g, "")}.csv`,
);
ctx.body = new PassThrough();
ctx.body.write(
"Parameter,Object,Object timestamp,Writable,Writable timestamp,Value,Value type,Value timestamp,Notification,Notification timestamp,Access list,Access list timestamp\n",
);
const lines: string[] = [
"Parameter,Object,Object timestamp,Writable,Writable timestamp,Value,Value type,Value timestamp,Notification,Notification timestamp,Access list,Access list timestamp",
];
for (const k of Object.keys(device).sort()) {
const p = device[k];
@@ -118,9 +117,9 @@ router.get(`/devices/:id.csv`, async (ctx) => {
p.accessList ? p.accessList.join(", ") : "",
new Date(p.accessListTimestamp).toJSON(),
];
ctx.body.write(row.map((r) => (r != null ? r : "")).join(",") + "\n");
lines.push(row.map((r) => (r != null ? r : "")).join(","));
}
ctx.body.end();
ctx.body = lines.join("\n");
logger.accessInfo(log);
});
@@ -201,16 +200,17 @@ for (const [resource, flags] of Object.entries(resources)) {
]);
}
ctx.body = new PassThrough();
ctx.type = "application/json";
let c = 0;
ctx.body.write("[\n");
for await (const obj of db.query(resource, filter, options))
ctx.body.write((c++ ? "," : "") + JSON.stringify(obj) + "\n");
ctx.body.end("]");
logger.accessInfo(log);
ctx.type = "application/json";
ctx.body = Readable.from(
(async function* () {
let c = 0;
yield "[\n";
for await (const obj of db.query(resource, filter, options))
yield (c++ ? "," : "") + JSON.stringify(obj) + "\n";
yield "]";
})(),
);
});
// CSV download
@@ -260,15 +260,16 @@ for (const [resource, flags] of Object.entries(resources)) {
]);
}
ctx.body = new PassThrough();
logger.accessInfo(log);
ctx.type = "text/csv";
ctx.attachment(
`${resource}-${new Date(now).toISOString().replace(/[:.]/g, "")}.csv`,
);
ctx.body.write(
Object.keys(columns).map((k) => `"${k.replace(/"/, '""')}"`) + "\n",
);
ctx.body = Readable.from(
(async function* () {
yield Object.keys(columns).map((k) => `"${k.replace(/"/, '""')}"`) +
"\n";
for await (const obj of db.query(resource, filter, options)) {
const arr = Object.values(columns).map((exp) => {
const v = evaluate(exp, obj, null, (e) => {
@@ -278,7 +279,8 @@ for (const [resource, flags] of Object.entries(resources)) {
if (e[1] === "Tags") {
const tags = [];
for (const p in obj)
if (p.startsWith("Tags.")) tags.push(decodeTag(p.slice(5)));
if (p.startsWith("Tags."))
tags.push(decodeTag(p.slice(5)));
return tags.join(", ");
}
@@ -310,10 +312,10 @@ for (const [resource, flags] of Object.entries(resources)) {
if (typeof v === "string") return `"${v.replace(/"/g, '""')}"`;
return v;
});
ctx.body.write(arr.join(",") + "\n");
yield arr.join(",") + "\n";
}
ctx.body.end();
logger.accessInfo(log);
})(),
);
});
router.head(`/${resource}/:id`, async (ctx) => {
+9 -5
View File
@@ -7,10 +7,10 @@ interface Revisions<V> {
}
export default class VersionedMap<K, V> {
private declare _sizeDiff: number[];
private declare _revision: number;
private declare map: Map<K, (V | symbol)[]>;
public declare dirty: number;
declare private _sizeDiff: number[];
declare private _revision: number;
declare private map: Map<K, (V | symbol)[]>;
declare public dirty: number;
public constructor() {
this._sizeDiff = [0];
@@ -112,6 +112,8 @@ export default class VersionedMap<K, V> {
prev = v;
}
if (prev === NONEXISTENT && !res.delete) return null;
return res;
}
@@ -122,6 +124,8 @@ export default class VersionedMap<K, V> {
del,
);
if (!mutations) return;
const revisions = [];
let prev: V | symbol = NONEXISTENT;
@@ -149,7 +153,7 @@ export default class VersionedMap<K, V> {
public *diff(): IterableIterator<[K, V, V]> {
for (const [key, revisions] of this.map) {
if (revisions.length === 1) continue;
if (revisions.length <= 1) continue;
let first = revisions[0];
let last = revisions[revisions.length - 1];
if (first === NONEXISTENT && last === NONEXISTENT) continue;
+16 -22
View File
@@ -321,16 +321,19 @@ export default class XmppClient extends EventEmitter {
this._iqStanzaCallbacks = new Map();
}
static connect(opts: XmppClientOptions): Promise<XmppClient> {
static async connect(opts: XmppClientOptions): Promise<XmppClient> {
function connectSocket(host: string, port: number): Promise<net.Socket> {
return new Promise((resolve, reject) => {
let socket = new net.Socket();
const socket = new net.Socket();
socket.on("error", reject);
socket.connect(
opts.port || 5222,
opts.host,
void async function () {
socket.connect(port, host, () => {
socket.removeListener("error", reject);
resolve(socket);
});
});
}
let socket = await connectSocket(opts.host, opts.port || 5222);
try {
let status = 1;
while (status) {
@@ -338,15 +341,13 @@ export default class XmppClient extends EventEmitter {
socket = await upgradeTls(socket, opts.host);
status = await xmppStream(
socket,
init(
socket,
opts.host,
opts.username,
opts.password,
opts.resource,
),
init(socket, opts.host, opts.username, opts.password, opts.resource),
);
}
} catch (err) {
socket.destroy();
throw err;
}
const client = new XmppClient();
client._socket = socket;
@@ -357,14 +358,7 @@ export default class XmppClient extends EventEmitter {
socket.on("error", client._onError.bind(client));
if (opts.timeout)
socket.setTimeout(opts.timeout, client.close.bind(client));
resolve(client);
} catch (err) {
socket.destroy();
reject(err);
}
},
);
});
return client;
}
close(): void {
+3036 -2923
View File
File diff suppressed because it is too large. Load diff
+25 -27
View File
@@ -1,6 +1,6 @@
{
"name": "genieacs",
"version": "1.2.11",
"version": "1.2.15",
"description": "A TR-069 Auto Configuration Server (ACS)",
"repository": {
"type": "git",
@@ -26,44 +26,42 @@
},
"dependencies": {
"@breejs/later": "^4.2.0",
"@koa/bodyparser": "^5.1.2",
"@koa/router": "^13.1.1",
"bson": "^4.7.2",
"espresso-iisojs": "^1.0.8",
"iconv-lite": "^0.6.3",
"ipaddr.js": "^2.0.1",
"jsonwebtoken": "^9.0.2",
"koa": "^2.15.2",
"koa-bodyparser": "^4.3.0",
"koa-compress": "^5.1.0",
"ipaddr.js": "^2.3.0",
"jsonwebtoken": "^9.0.3",
"koa": "^2.16.4",
"koa-compress": "^5.2.1",
"koa-jwt": "^4.0.3",
"koa-router": "^12.0.0",
"koa-static": "^5.0.0",
"koa-send": "^5.0.1",
"mongodb": "^4.16.0",
"parsimmon": "^1.18.1",
"seedrandom": "^3.0.5"
},
"devDependencies": {
"@types/codemirror": "^5.60.15",
"@types/jsonwebtoken": "^9.0.6",
"@types/codemirror": "^5.60.17",
"@types/jsonwebtoken": "^9.0.10",
"@types/koa": "^2.15.0",
"@types/koa-compress": "^4.0.6",
"@types/koa-router": "^7.4.8",
"@types/koa-static": "^4.0.4",
"@types/mithril": "^2.2.6",
"@types/node": "^20.11.30",
"@types/koa-compress": "^4.0.7",
"@types/mithril": "^2.2.8",
"@types/node": "^20.19.37",
"@types/parsimmon": "^1.10.9",
"@types/seedrandom": "^3.0.8",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"codemirror": "^5.65.16",
"esbuild": "^0.20.2",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"mithril": "^2.2.2",
"prettier": "^3.2.5",
"sql.js": "^1.10.2",
"svgo": "^3.2.0",
"typescript": "^5.4.3",
"yaml": "^1.10.2"
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"codemirror": "^5.65.21",
"esbuild": "^0.27.4",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.2",
"mithril": "^2.3.8",
"prettier": "^3.8.1",
"sql.js": "^1.14.1",
"svgo": "^3.3.3",
"typescript": "^5.9.3",
"yaml": "^1.10.3"
},
"engines": {
"node": ">=12.13.0"
+35 -1
View File
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert";
import initSqlJs from "sql.js/dist/sql-asm.js";
import { minimize, unionDiff } from "../lib/common/expression/synth.ts";
import { covers, minimize, unionDiff } from "../lib/common/expression/synth.ts";
import { parse, stringify } from "../lib/common/expression/parser.ts";
const STRING_VALUES = [null, "", "a", "ab", "ab10", "ab-10"];
@@ -115,3 +115,37 @@ void test("unionDiff", async () => {
assert.strictEqual(setsEqual(res4, diffSet), true);
}
});
void test("covers", async () => {
assert.strictEqual(covers(false, false), true);
assert.strictEqual(covers(false, parse("decimal > 5 AND decimal < 3")), true);
assert.strictEqual(covers(parse("true"), parse("decimal > 0")), true);
assert.strictEqual(covers(parse("true"), parse("false")), true);
assert.strictEqual(covers(parse("false"), parse("false")), true);
assert.strictEqual(covers(parse("false"), parse("decimal > 0")), false);
assert.strictEqual(covers(parse("decimal >= 0"), parse("decimal > 0")), true);
assert.strictEqual(
covers(parse("decimal > 0"), parse("decimal >= 0")),
false,
);
const cases = [
["decimal >= 0", "decimal > 0"],
["decimal > 0", "decimal > 5"],
["string IS NOT NULL", "string = 'a'"],
["true", "decimal > 0"],
];
for (const [c1, c2] of cases) {
const res1 = await query(c1);
const res2 = await query(c2);
const coversResult = covers(parse(c1), parse(c2));
const actuallyCovers = Array.from(res2).every((r) => res1.has(r));
assert.strictEqual(
coversResult,
actuallyCovers,
`covers(${c1}, ${c2}) should match actual coverage`,
);
}
});
+2 -1
View File
@@ -11,7 +11,8 @@
"strictFunctionTypes": true,
"isolatedModules": true,
"noEmit": true,
"allowImportingTsExtensions": true
"allowImportingTsExtensions": true,
"skipLibCheck": true
},
"include": [
"./bin/*.ts",
+7 -7
View File
@@ -4,13 +4,13 @@ type AutocompleteCallback = (
) => void;
export default class Autocomplete {
private declare callback: AutocompleteCallback;
private declare element: HTMLInputElement;
private declare hideTimeout: NodeJS.Timeout;
private declare visible: boolean;
private declare default: string;
private declare selection: number;
private declare container: HTMLElement;
declare private callback: AutocompleteCallback;
declare private element: HTMLInputElement;
declare private hideTimeout: NodeJS.Timeout;
declare private visible: boolean;
declare private default: string;
declare private selection: number;
declare private container: HTMLElement;
public constructor(className: string, callback: AutocompleteCallback) {
this.callback = callback;
+6 -2
View File
@@ -27,10 +27,14 @@ const component: ClosureComponent = (): Component => {
cx: "0.5em",
cy: "0.5em",
r: "0.4em",
fill: slice["color"],
fill: evaluateExpression(slice["color"], null),
}),
);
return m("span.overview-dot", dot, `${slice["label"]}`);
return m(
"span.overview-dot",
dot,
evaluateExpression(slice["label"], null),
);
}
}
return null;
+1 -1
View File
@@ -79,7 +79,7 @@ export function structureConfig(config: Config[]): any {
if (idx >= 0 && idx < MAX_ARRAY_SIZE && String(idx) === key) {
const pos = Math.floor(idx / MAX_BITS);
if (!indexes[pos]) indexes[pos] = 0;
indexes[pos] |= 1 << idx % MAX_BITS;
indexes[pos] |= 1 << (idx % MAX_BITS);
} else {
indexes = [];
break;
+34 -3
View File
@@ -3,6 +3,29 @@ import { m } from "./components.ts";
import config from "./config.ts";
import * as store from "./store.ts";
// Workaround for Mithril 2.3.8 regression
// https://github.com/MithrilJS/mithril.js/issues/3064
function getPathParamFromHash(hash: string, prefix: string): string | null {
if (!hash.startsWith(prefix)) return null;
const pathWithQuery = hash.slice(prefix.length);
// Stop at query string or hash fragment (mirrors Mithril's parsePathname)
const queryIdx = pathWithQuery.indexOf("?");
const hashIdx = pathWithQuery.indexOf("#");
let pathEnd = pathWithQuery.length;
if (queryIdx >= 0) pathEnd = Math.min(pathEnd, queryIdx);
if (hashIdx >= 0) pathEnd = Math.min(pathEnd, hashIdx);
const rawPath = pathWithQuery.slice(0, pathEnd);
// Normalize: collapse slashes, remove trailing slash (matches Mithril)
const normalizedPath = rawPath.replace(/\/{2,}/g, "/").replace(/\/$/, "");
// Decode once (Mithril already decoded with decodeURIComponentSafe)
return decodeURIComponent(normalizedPath);
}
export function init(
args: Record<string, unknown>,
): Promise<Record<string, unknown>> {
@@ -12,9 +35,13 @@ export function init(
);
}
const deviceId =
getPathParamFromHash(window.location.hash, "#!/devices/") ??
(args.id as string);
return Promise.resolve({
deviceId: args.id,
deviceFilter: ["=", ["PARAM", "DeviceID.ID"], args.id],
deviceId: deviceId,
deviceFilter: ["=", ["PARAM", "DeviceID.ID"], deviceId],
});
}
@@ -39,7 +66,11 @@ export const component: ClosureComponent = (): Component => {
for (const c of Object.values(conf)) {
cmps.push(
m.context({ device: dev.value[0], deviceQuery: dev }, c["type"], c),
m.context(
{ device: dev.value[0], deviceQuery: dev },
store.evaluateExpression(c["type"], null) as string,
c,
),
);
}
+9 -2
View File
@@ -26,7 +26,10 @@ const memoizedGetSortable = memoize((p) => {
const getDownloadUrl = memoize((filter, indexParameters) => {
const columns = {};
for (const p of indexParameters) columns[p.label] = stringify(p.parameter);
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),
@@ -282,7 +285,11 @@ export const component: ClosureComponent = (): Component => {
};
const attrs = {};
attrs["attributes"] = attributes;
attrs["attributes"] = attributes.map((a) => ({
...a,
label: store.evaluateExpression(a.label, null),
type: store.evaluateExpression(a.type, null),
}));
attrs["data"] = devs.value;
attrs["total"] = count.value;
attrs["showMoreCallback"] = showMore;
+16 -10
View File
@@ -46,6 +46,18 @@ const unpackSmartQuery = memoize((query) => {
});
});
async function deleteFaults(faults: Iterable<string>): Promise<void> {
const proms: Map<string, Promise<void>> = new Map();
for (const f of faults) {
const deviceId = f.split(":", 1)[0];
let p = proms.get(deviceId);
if (p == null) p = store.deleteResource("faults", f);
else p = p.then(() => store.deleteResource("faults", f));
proms.set(deviceId, p);
}
await Promise.all(proms.values());
}
export function init(
args: Record<string, unknown>,
): Promise<Record<string, unknown>> {
@@ -154,16 +166,10 @@ export const component: ClosureComponent = (): Component => {
if (!confirm(`Deleting ${selected.size} faults. Are you sure?`))
return;
Promise.all(
Array.from(selected).map((id) =>
store.deleteResource("faults", id),
),
)
.then((res) => {
notifications.push(
"success",
`${res.length} faults deleted`,
);
const c = selected.size;
deleteFaults(selected)
.then(() => {
notifications.push("success", `${c} faults deleted`);
store.setTimestamp(Date.now());
})
.catch((err) => {
+4 -3
View File
@@ -2,6 +2,7 @@ import { ClosureComponent, Component, 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";
const memoizedStringify = memoize(stringify);
@@ -24,9 +25,9 @@ function drawChart(chartData): Children {
legend.push(
m(".legend-line", [
m("span.color", {
style: `background-color: ${slice["color"]} !important;`,
style: `background-color: ${store.evaluateExpression(slice["color"], null)} !important;`,
}),
`${slice["label"]}: `,
`${store.evaluateExpression(slice["label"], null)}: `,
m(
"a",
{
@@ -57,7 +58,7 @@ function drawChart(chartData): Children {
paths.push(
m("path", {
d: sketch,
fill: slice["color"],
fill: store.evaluateExpression(slice["color"], null),
}),
);