This commit is contained in:
jhartworks
2026-06-23 10:59:58 +02:00
parent 9b651f1cd6
commit a445fef521
21 changed files with 3161 additions and 664 deletions
+185 -32
View File
@@ -7,8 +7,8 @@ export function buildPointKey(isp, pointIndex) {
export function sanitizeIspName(value) {
const normalized = String(value || "").toLowerCase();
if (!/^isp\d+$/.test(normalized)) {
throw new Error("Invalid ISP table name.");
if (!/^(isp[a-z0-9_]*|trend_[a-z0-9_]+)$/.test(normalized)) {
throw new Error("Invalid trend table name.");
}
return normalized;
@@ -45,6 +45,113 @@ export function parseTrendValue(rawValue, factor = 1) {
};
}
function buildValueColumns() {
const columns = [];
for (let index = 1; index <= 200; index += 1) {
columns.push('`Wert' + index + '` TEXT NULL');
}
return columns.join(", ");
}
function hslToHex(hue, saturation, lightness) {
const s = saturation / 100;
const l = lightness / 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs((hue / 60) % 2 - 1));
const m = l - c / 2;
let red = 0;
let green = 0;
let blue = 0;
if (hue < 60) {
red = c;
green = x;
} else if (hue < 120) {
red = x;
green = c;
} else if (hue < 180) {
green = c;
blue = x;
} else if (hue < 240) {
green = x;
blue = c;
} else if (hue < 300) {
red = x;
blue = c;
} else {
red = c;
blue = x;
}
const toHex = (value) => Math.round((value + m) * 255).toString(16).padStart(2, "0");
return `#${toHex(red)}${toHex(green)}${toHex(blue)}`;
}
function parseHslColor(value) {
const match = String(value || "").match(/^hsl\((\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%\)$/i);
if (!match) {
return null;
}
return {
hue: Number(match[1]),
saturation: Number(match[2]),
lightness: Number(match[3]),
};
}
function createRandomPointColor(seed) {
const hue = Math.round((seed * 47 + 17 + Math.random() * 28) % 360);
const saturation = 72 + (seed % 4) * 4;
const lightness = 45 + (seed % 3) * 4;
return hslToHex(hue, saturation, lightness);
}
export function normalizeEngineeringUnit(value) {
const raw = String(value ?? "").trim();
if (!raw) {
return "°C";
}
const normalized = raw
.replace(/°/g, "°")
.replace(/^\?C$/i, "°C")
.replace(/^°\s*C$/i, "°C");
return normalized || "°C";
}
function createDefaultPoint(index) {
return {
alias: `Wert${index}`,
unit: "°C",
factor: 1,
kind: "analog",
min: 0,
max: 100,
color: createRandomPointColor(index),
};
}
export async function createWideTrendTable(pool, tableName) {
const safeTable = sanitizeIspName(tableName);
const sql = `
CREATE TABLE IF NOT EXISTS \`${safeTable}\` (
\`id\` INT(11) NOT NULL AUTO_INCREMENT,
\`datum\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
\`userlevel\` INT(1) NOT NULL,
${buildValueColumns()},
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`;
await pool.query(sql);
return safeTable;
}
export async function getAvailableIsps(pool, dbName) {
const [tables] = await pool.query(
`
@@ -59,43 +166,42 @@ export async function getAvailableIsps(pool, dbName) {
) AS pointCount
FROM information_schema.TABLES t
WHERE t.TABLE_SCHEMA = ?
AND t.TABLE_NAME LIKE 'isp%'
AND (t.TABLE_NAME LIKE 'isp%' OR t.TABLE_NAME LIKE 'trend_%')
ORDER BY t.TABLE_NAME
`,
[dbName]
);
const latestMap = new Map();
const stats = new Map();
await Promise.all(
tables.map(async (table) => {
const safeTable = sanitizeIspName(table.tableName);
const [rows] = await pool.query(
`SELECT MAX(datum) AS latestTimestamp FROM \`${safeTable}\``
`SELECT COUNT(*) AS rowCount, MAX(datum) AS latestTimestamp FROM \`${safeTable}\``
);
latestMap.set(safeTable, rows[0]?.latestTimestamp || null);
stats.set(safeTable, rows[0] || { rowCount: 0, latestTimestamp: null });
})
);
return tables.map((table) => ({
tableName: sanitizeIspName(table.tableName),
pointCount: Number(table.pointCount || 0),
latestTimestamp: latestMap.get(sanitizeIspName(table.tableName)) || null,
}));
return tables.map((table) => {
const safeTable = sanitizeIspName(table.tableName);
const info = stats.get(safeTable) || {};
return {
tableName: safeTable,
pointCount: Number(table.pointCount || 0),
rowCount: Number(info.rowCount || 0),
latestTimestamp: info.latestTimestamp || null,
};
});
}
export function createDefaultMetadata(isp, pointCount = 200) {
const points = {};
for (let index = 1; index <= pointCount; index += 1) {
points[String(index)] = {
alias: `Wert${index}`,
unit: "",
factor: 1,
kind: "analog",
min: 0,
max: 100,
};
points[String(index)] = createDefaultPoint(index);
}
return {
@@ -113,11 +219,62 @@ export async function ensureIspMetadata(redis, isp, pointCount = 200) {
if (existing) {
const parsed = JSON.parse(existing);
let changed = false;
if (!parsed.points) {
parsed.points = {};
changed = true;
}
for (let index = 1; index <= pointCount; index += 1) {
if (!parsed.points?.[String(index)]) {
parsed.points[String(index)] = createDefaultMetadata(safeIsp, pointCount).points[String(index)];
const pointKey = String(index);
const defaultPoint = createDefaultPoint(index);
if (!parsed.points[pointKey]) {
parsed.points[pointKey] = defaultPoint;
changed = true;
continue;
}
const point = parsed.points[pointKey];
if (!point.alias) {
point.alias = defaultPoint.alias;
changed = true;
}
const normalizedUnit = normalizeEngineeringUnit(point.unit);
if (normalizedUnit !== point.unit) {
point.unit = normalizedUnit;
changed = true;
}
if (!point.kind) {
point.kind = defaultPoint.kind;
changed = true;
}
if (!Number.isFinite(Number(point.factor))) {
point.factor = defaultPoint.factor;
changed = true;
}
if (!Number.isFinite(Number(point.min))) {
point.min = defaultPoint.min;
changed = true;
}
if (!Number.isFinite(Number(point.max))) {
point.max = defaultPoint.max;
changed = true;
}
const parsedHslColor = parseHslColor(point.color);
if (parsedHslColor) {
point.color = hslToHex(parsedHslColor.hue, parsedHslColor.saturation, parsedHslColor.lightness);
changed = true;
}
if (!point.color) {
point.color = defaultPoint.color;
changed = true;
}
}
if (changed) {
await redis.set(key, JSON.stringify(parsed));
}
return parsed;
@@ -162,11 +319,12 @@ export async function getPointDescriptors(redis, points) {
key: buildPointKey(isp, pointIndex),
displayName: metadata.displayName,
alias: pointMeta.alias,
unit: pointMeta.unit,
unit: normalizeEngineeringUnit(pointMeta.unit),
factor: pointMeta.factor,
kind: pointMeta.kind,
min: pointMeta.min,
max: pointMeta.max,
color: pointMeta.color || "",
});
});
}
@@ -218,9 +376,7 @@ export async function fetchTrendSeries(pool, redis, points, range) {
return {
series: descriptors,
rows: [...mergedRows.values()].sort((left, right) =>
left.timestamp.localeCompare(right.timestamp)
),
rows: [...mergedRows.values()].sort((left, right) => left.timestamp.localeCompare(right.timestamp)),
};
}
@@ -242,10 +398,7 @@ export async function fetchLatestValues(pool, redis, points) {
.map((descriptor) => `\`Wert${descriptor.pointIndex}\``)
.join(", ");
const [rows] = await pool.query(
`SELECT datum, ${columns} FROM \`${isp}\` ORDER BY datum DESC LIMIT 1`
);
const [rows] = await pool.query(`SELECT datum, ${columns} FROM \`${isp}\` ORDER BY datum DESC LIMIT 1`);
const row = rows[0];
pointDescriptors.forEach((descriptor) => {
@@ -267,9 +420,7 @@ export function normalizeRange(input = {}) {
const maxRows = Number(input.maxRows || 4000);
const now = new Date();
const to = input.to ? new Date(input.to) : now;
const from = input.from
? new Date(input.from)
: new Date(now.getTime() - presetDays * 24 * 60 * 60 * 1000);
const from = input.from ? new Date(input.from) : new Date(now.getTime() - presetDays * 24 * 60 * 60 * 1000);
return {
from,
@@ -277,3 +428,5 @@ export function normalizeRange(input = {}) {
maxRows: Number.isFinite(maxRows) ? Math.max(100, Math.min(maxRows, 10000)) : 4000,
};
}
+12 -3
View File
@@ -1,4 +1,4 @@
const SELECTION_SET_KEY = "selection:ids";
const SELECTION_SET_KEY = "selection:ids";
const SELECTION_SEQ_KEY = "selection:seq";
function safeParse(jsonValue) {
@@ -54,12 +54,21 @@ export async function saveDashboard(redis, userId, dashboard) {
}
export async function getPreferences(redis, userId) {
return safeParse(await redis.get(`preferences:${userId}`)) || { theme: "system" };
return safeParse(await redis.get(`preferences:${userId}`)) || {
theme: "system",
csvDelimiter: ";",
sidebarPinned: true,
};
}
export async function savePreferences(redis, userId, preferences) {
const current = await getPreferences(redis, userId);
const payload = {
theme: preferences.theme || "system",
...current,
...preferences,
theme: preferences.theme || current.theme || "system",
csvDelimiter: preferences.csvDelimiter || current.csvDelimiter || ";",
sidebarPinned: typeof preferences.sidebarPinned === "boolean" ? preferences.sidebarPinned : current.sidebarPinned,
updatedAt: new Date().toISOString(),
};
await redis.set(`preferences:${userId}`, JSON.stringify(payload));