initial commit
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
const META_PREFIX = "meta:isp:";
|
||||
|
||||
export function buildPointKey(isp, pointIndex) {
|
||||
return `${isp}:${pointIndex}`;
|
||||
}
|
||||
|
||||
export function sanitizeIspName(value) {
|
||||
const normalized = String(value || "").toLowerCase();
|
||||
|
||||
if (!/^isp\d+$/.test(normalized)) {
|
||||
throw new Error("Invalid ISP table name.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function sanitizePointIndex(value) {
|
||||
const pointIndex = Number(value);
|
||||
|
||||
if (!Number.isInteger(pointIndex) || pointIndex < 1 || pointIndex > 200) {
|
||||
throw new Error("Point index must be between 1 and 200.");
|
||||
}
|
||||
|
||||
return pointIndex;
|
||||
}
|
||||
|
||||
export function parseTrendValue(rawValue, factor = 1) {
|
||||
if (rawValue === null || rawValue === undefined) {
|
||||
return { raw: null, numeric: null };
|
||||
}
|
||||
|
||||
const raw = String(rawValue).trim();
|
||||
|
||||
if (!raw) {
|
||||
return { raw: null, numeric: null };
|
||||
}
|
||||
|
||||
const normalized = raw.replace(",", ".");
|
||||
const numeric = Number(normalized);
|
||||
const safeFactor = Number.isFinite(Number(factor)) ? Number(factor) : 1;
|
||||
|
||||
return {
|
||||
raw,
|
||||
numeric: Number.isFinite(numeric) ? numeric * safeFactor : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAvailableIsps(pool, dbName) {
|
||||
const [tables] = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
t.TABLE_NAME AS tableName,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS c
|
||||
WHERE c.TABLE_SCHEMA = t.TABLE_SCHEMA
|
||||
AND c.TABLE_NAME = t.TABLE_NAME
|
||||
AND c.COLUMN_NAME LIKE 'Wert%'
|
||||
) AS pointCount
|
||||
FROM information_schema.TABLES t
|
||||
WHERE t.TABLE_SCHEMA = ?
|
||||
AND t.TABLE_NAME LIKE 'isp%'
|
||||
ORDER BY t.TABLE_NAME
|
||||
`,
|
||||
[dbName]
|
||||
);
|
||||
|
||||
const latestMap = 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}\``
|
||||
);
|
||||
latestMap.set(safeTable, rows[0]?.latestTimestamp || null);
|
||||
})
|
||||
);
|
||||
|
||||
return tables.map((table) => ({
|
||||
tableName: sanitizeIspName(table.tableName),
|
||||
pointCount: Number(table.pointCount || 0),
|
||||
latestTimestamp: latestMap.get(sanitizeIspName(table.tableName)) || 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,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isp,
|
||||
displayName: isp.toUpperCase(),
|
||||
description: "",
|
||||
points,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureIspMetadata(redis, isp, pointCount = 200) {
|
||||
const safeIsp = sanitizeIspName(isp);
|
||||
const key = `${META_PREFIX}${safeIsp}`;
|
||||
const existing = await redis.get(key);
|
||||
|
||||
if (existing) {
|
||||
const parsed = JSON.parse(existing);
|
||||
|
||||
for (let index = 1; index <= pointCount; index += 1) {
|
||||
if (!parsed.points?.[String(index)]) {
|
||||
parsed.points[String(index)] = createDefaultMetadata(safeIsp, pointCount).points[String(index)];
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const metadata = createDefaultMetadata(safeIsp, pointCount);
|
||||
await redis.set(key, JSON.stringify(metadata));
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export async function updateIspMetadata(redis, isp, updater) {
|
||||
const metadata = await ensureIspMetadata(redis, isp);
|
||||
const updated = updater(JSON.parse(JSON.stringify(metadata)));
|
||||
await redis.set(`${META_PREFIX}${sanitizeIspName(isp)}`, JSON.stringify(updated));
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function getPointDescriptors(redis, points) {
|
||||
const grouped = new Map();
|
||||
|
||||
points.forEach((point) => {
|
||||
const isp = sanitizeIspName(point.isp);
|
||||
const pointIndex = sanitizePointIndex(point.pointIndex);
|
||||
|
||||
if (!grouped.has(isp)) {
|
||||
grouped.set(isp, []);
|
||||
}
|
||||
|
||||
grouped.get(isp).push(pointIndex);
|
||||
});
|
||||
|
||||
const descriptors = [];
|
||||
|
||||
for (const [isp, pointIndexes] of grouped.entries()) {
|
||||
const metadata = await ensureIspMetadata(redis, isp);
|
||||
|
||||
pointIndexes.forEach((pointIndex) => {
|
||||
const pointMeta = metadata.points[String(pointIndex)];
|
||||
descriptors.push({
|
||||
isp,
|
||||
pointIndex,
|
||||
key: buildPointKey(isp, pointIndex),
|
||||
displayName: metadata.displayName,
|
||||
alias: pointMeta.alias,
|
||||
unit: pointMeta.unit,
|
||||
factor: pointMeta.factor,
|
||||
kind: pointMeta.kind,
|
||||
min: pointMeta.min,
|
||||
max: pointMeta.max,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
export async function fetchTrendSeries(pool, redis, points, range) {
|
||||
const descriptors = await getPointDescriptors(redis, points);
|
||||
const grouped = descriptors.reduce((accumulator, descriptor) => {
|
||||
if (!accumulator.has(descriptor.isp)) {
|
||||
accumulator.set(descriptor.isp, []);
|
||||
}
|
||||
|
||||
accumulator.get(descriptor.isp).push(descriptor);
|
||||
return accumulator;
|
||||
}, new Map());
|
||||
|
||||
const mergedRows = new Map();
|
||||
|
||||
for (const [isp, pointDescriptors] of grouped.entries()) {
|
||||
const columns = pointDescriptors
|
||||
.map((descriptor) => `\`Wert${descriptor.pointIndex}\``)
|
||||
.join(", ");
|
||||
const limit = range.maxRows || 4000;
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT datum, ${columns} FROM \`${isp}\` WHERE datum BETWEEN ? AND ? ORDER BY datum DESC LIMIT ?`,
|
||||
[range.from, range.to, limit]
|
||||
);
|
||||
|
||||
rows.reverse().forEach((row) => {
|
||||
const timestamp = new Date(row.datum).toISOString();
|
||||
|
||||
if (!mergedRows.has(timestamp)) {
|
||||
mergedRows.set(timestamp, { timestamp });
|
||||
}
|
||||
|
||||
const target = mergedRows.get(timestamp);
|
||||
|
||||
pointDescriptors.forEach((descriptor) => {
|
||||
const rawValue = row[`Wert${descriptor.pointIndex}`];
|
||||
const parsed = parseTrendValue(rawValue, descriptor.factor);
|
||||
target[descriptor.key] = parsed.numeric;
|
||||
target[`${descriptor.key}:raw`] = parsed.raw;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
series: descriptors,
|
||||
rows: [...mergedRows.values()].sort((left, right) =>
|
||||
left.timestamp.localeCompare(right.timestamp)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLatestValues(pool, redis, points) {
|
||||
const descriptors = await getPointDescriptors(redis, points);
|
||||
const grouped = descriptors.reduce((accumulator, descriptor) => {
|
||||
if (!accumulator.has(descriptor.isp)) {
|
||||
accumulator.set(descriptor.isp, []);
|
||||
}
|
||||
|
||||
accumulator.get(descriptor.isp).push(descriptor);
|
||||
return accumulator;
|
||||
}, new Map());
|
||||
|
||||
const values = [];
|
||||
|
||||
for (const [isp, pointDescriptors] of grouped.entries()) {
|
||||
const columns = pointDescriptors
|
||||
.map((descriptor) => `\`Wert${descriptor.pointIndex}\``)
|
||||
.join(", ");
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT datum, ${columns} FROM \`${isp}\` ORDER BY datum DESC LIMIT 1`
|
||||
);
|
||||
|
||||
const row = rows[0];
|
||||
|
||||
pointDescriptors.forEach((descriptor) => {
|
||||
const parsed = parseTrendValue(row?.[`Wert${descriptor.pointIndex}`], descriptor.factor);
|
||||
values.push({
|
||||
...descriptor,
|
||||
timestamp: row?.datum ? new Date(row.datum).toISOString() : null,
|
||||
raw: parsed.raw,
|
||||
value: parsed.numeric,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
export function normalizeRange(input = {}) {
|
||||
const presetDays = Number(input.presetDays || 1);
|
||||
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);
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
maxRows: Number.isFinite(maxRows) ? Math.max(100, Math.min(maxRows, 10000)) : 4000,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
const SELECTION_SET_KEY = "selection:ids";
|
||||
const SELECTION_SEQ_KEY = "selection:seq";
|
||||
|
||||
function safeParse(jsonValue) {
|
||||
return jsonValue ? JSON.parse(jsonValue) : null;
|
||||
}
|
||||
|
||||
export async function listSelections(redis, user) {
|
||||
const ids = await redis.smembers(SELECTION_SET_KEY);
|
||||
const items = await Promise.all(ids.map((id) => redis.get(`selection:${id}`)));
|
||||
|
||||
return items
|
||||
.map((item) => safeParse(item))
|
||||
.filter(Boolean)
|
||||
.filter((selection) => selection.shared || selection.ownerId === user.sub || user.role === "admin")
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
}
|
||||
|
||||
export async function getSelection(redis, selectionId) {
|
||||
return safeParse(await redis.get(`selection:${selectionId}`));
|
||||
}
|
||||
|
||||
export async function saveSelection(redis, selection, existingSelectionId) {
|
||||
const selectionId = existingSelectionId || `sel-${await redis.incr(SELECTION_SEQ_KEY)}`;
|
||||
const now = new Date().toISOString();
|
||||
const payload = {
|
||||
...selection,
|
||||
id: selectionId,
|
||||
updatedAt: now,
|
||||
createdAt: selection.createdAt || now,
|
||||
};
|
||||
|
||||
await redis.set(`selection:${selectionId}`, JSON.stringify(payload));
|
||||
await redis.sadd(SELECTION_SET_KEY, selectionId);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function deleteSelection(redis, selectionId) {
|
||||
await redis.del(`selection:${selectionId}`);
|
||||
await redis.srem(SELECTION_SET_KEY, selectionId);
|
||||
}
|
||||
|
||||
export async function getDashboard(redis, userId) {
|
||||
return safeParse(await redis.get(`dashboard:${userId}`)) || { widgets: [] };
|
||||
}
|
||||
|
||||
export async function saveDashboard(redis, userId, dashboard) {
|
||||
const payload = {
|
||||
widgets: dashboard.widgets || [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await redis.set(`dashboard:${userId}`, JSON.stringify(payload));
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function getPreferences(redis, userId) {
|
||||
return safeParse(await redis.get(`preferences:${userId}`)) || { theme: "system" };
|
||||
}
|
||||
|
||||
export async function savePreferences(redis, userId, preferences) {
|
||||
const payload = {
|
||||
theme: preferences.theme || "system",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await redis.set(`preferences:${userId}`, JSON.stringify(payload));
|
||||
return payload;
|
||||
}
|
||||
Reference in New Issue
Block a user