update
This commit is contained in:
+1
-2
@@ -16,5 +16,4 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mysql2": "^3.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+3
-2
@@ -8,10 +8,11 @@ export const config = {
|
||||
dbPort: Number(process.env.DB_PORT || 3306),
|
||||
dbName: process.env.DB_NAME || "wago",
|
||||
dbUser: process.env.DB_USER || "root",
|
||||
dbPassword: process.env.DB_PASSWORD || "root",
|
||||
dbPassword: process.env.DB_PASSWORD || "SE3112",
|
||||
dbPasswordFallback: process.env.DB_PASSWORD_FALLBACK || "root",
|
||||
redisUrl: process.env.REDIS_URL || "redis://127.0.0.1:6379",
|
||||
jwtSecret: process.env.JWT_SECRET || "change-me",
|
||||
corsOrigin: process.env.CORS_ORIGIN || "http://localhost:5173",
|
||||
corsOrigin: process.env.CORS_ORIGIN || "*",
|
||||
adminUsername: process.env.ADMIN_USERNAME || "admin",
|
||||
adminEmail: process.env.ADMIN_EMAIL || "admin@example.com",
|
||||
adminPassword: process.env.ADMIN_PASSWORD || "ChangeMe123!",
|
||||
|
||||
+55
-11
@@ -1,14 +1,58 @@
|
||||
import mysql from "mysql2/promise";
|
||||
import { config } from "./config.js";
|
||||
|
||||
export const pool = mysql.createPool({
|
||||
host: config.dbHost,
|
||||
port: config.dbPort,
|
||||
user: config.dbUser,
|
||||
password: config.dbPassword,
|
||||
database: config.dbName,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
namedPlaceholders: true,
|
||||
multipleStatements: true,
|
||||
});
|
||||
function createPool(password) {
|
||||
return mysql.createPool({
|
||||
host: config.dbHost,
|
||||
port: config.dbPort,
|
||||
user: config.dbUser,
|
||||
password,
|
||||
database: config.dbName,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
namedPlaceholders: true,
|
||||
multipleStatements: true,
|
||||
charset: "utf8mb4",
|
||||
});
|
||||
}
|
||||
|
||||
const candidatePasswords = [...new Set([config.dbPassword, config.dbPasswordFallback].filter(Boolean))];
|
||||
let activePassword = candidatePasswords[0];
|
||||
let activePool = createPool(activePassword);
|
||||
|
||||
function isAccessDenied(error) {
|
||||
return error?.code === "ER_ACCESS_DENIED_ERROR";
|
||||
}
|
||||
|
||||
async function query(sql, values) {
|
||||
try {
|
||||
return await activePool.query(sql, values);
|
||||
} catch (error) {
|
||||
if (!isAccessDenied(error) || candidatePasswords.length < 2) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const password of candidatePasswords) {
|
||||
if (password === activePassword) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackPool = createPool(password);
|
||||
try {
|
||||
const result = await fallbackPool.query(sql, values);
|
||||
activePool = fallbackPool;
|
||||
activePassword = password;
|
||||
return result;
|
||||
} catch (fallbackError) {
|
||||
await fallbackPool.end().catch(() => undefined);
|
||||
if (!isAccessDenied(fallbackError)) {
|
||||
throw fallbackError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const pool = { query };
|
||||
|
||||
+113
-26
@@ -5,10 +5,12 @@ import { authenticateToken, createToken, requireRoles } from "./auth.js";
|
||||
import { config } from "./config.js";
|
||||
import { pool } from "./db.js";
|
||||
import {
|
||||
createWideTrendTable,
|
||||
ensureIspMetadata,
|
||||
fetchLatestValues,
|
||||
fetchTrendSeries,
|
||||
getAvailableIsps,
|
||||
normalizeEngineeringUnit,
|
||||
normalizeRange,
|
||||
sanitizeIspName,
|
||||
sanitizePointIndex,
|
||||
@@ -29,9 +31,21 @@ import { redis } from "./redis.js";
|
||||
const app = express();
|
||||
const wrap = (handler) => (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next);
|
||||
|
||||
const allowedOrigins = config.corsOrigin
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: config.corsOrigin,
|
||||
origin(origin, callback) {
|
||||
if (!origin || allowedOrigins.includes("*") || allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(new Error("Origin not allowed."));
|
||||
},
|
||||
})
|
||||
);
|
||||
app.use(express.json({ limit: "50mb" }));
|
||||
@@ -111,6 +125,22 @@ async function resolveSelectionPoints(body, user) {
|
||||
return assertPoints(body.points || []);
|
||||
}
|
||||
|
||||
async function ensureUtf8Database() {
|
||||
await pool.query(`ALTER DATABASE \`${config.dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||
|
||||
const [tables] = await pool.query(
|
||||
`SELECT TABLE_NAME AS tableName
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = ?
|
||||
AND (TABLE_NAME = 'app_users' OR TABLE_NAME LIKE 'isp%' OR TABLE_NAME LIKE 'trend_%')`,
|
||||
[config.dbName]
|
||||
);
|
||||
|
||||
for (const table of tables) {
|
||||
await pool.query(`ALTER TABLE \`${table.tableName}\` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureUserSchema() {
|
||||
await pool.query(
|
||||
`
|
||||
@@ -209,20 +239,20 @@ app.post("/api/auth/login", wrap(async (req, res) => {
|
||||
const password = String(req.body.password || "");
|
||||
|
||||
const [rows] = await pool.query(
|
||||
"SELECT * FROM app_users WHERE username = ? AND active = 1 LIMIT 1",
|
||||
[username]
|
||||
"SELECT * FROM app_users WHERE (username = ? OR email = ?) AND active = 1 LIMIT 1",
|
||||
[username, username]
|
||||
);
|
||||
|
||||
const user = rows[0];
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Invalid credentials." });
|
||||
return res.status(401).json({ error: "Ungültige Zugangsdaten." });
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!isValid) {
|
||||
return res.status(401).json({ error: "Invalid credentials." });
|
||||
return res.status(401).json({ error: "Ungültige Zugangsdaten." });
|
||||
}
|
||||
|
||||
const serializedUser = serializeUser(user);
|
||||
@@ -233,11 +263,37 @@ app.post("/api/auth/login", wrap(async (req, res) => {
|
||||
});
|
||||
}));
|
||||
|
||||
app.post("/api/auth/change-password", authenticateToken, wrap(async (req, res) => {
|
||||
const currentPassword = String(req.body.currentPassword || "");
|
||||
const nextPassword = String(req.body.nextPassword || "");
|
||||
|
||||
if (nextPassword.length < 8) {
|
||||
return res.status(400).json({ error: "Das neue Passwort muss mindestens 8 Zeichen lang sein." });
|
||||
}
|
||||
|
||||
const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [req.user.sub]);
|
||||
const user = rows[0];
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "Benutzer nicht gefunden." });
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(currentPassword, user.password_hash);
|
||||
|
||||
if (!isValid) {
|
||||
return res.status(400).json({ error: "Das aktuelle Passwort stimmt nicht." });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(nextPassword, 10);
|
||||
await pool.query("UPDATE app_users SET password_hash = ? WHERE id = ?", [passwordHash, user.id]);
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
|
||||
app.get("/api/auth/me", authenticateToken, wrap(async (req, res) => {
|
||||
const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [req.user.sub]);
|
||||
|
||||
if (!rows[0]) {
|
||||
return res.status(404).json({ error: "User not found." });
|
||||
return res.status(404).json({ error: "Benutzer nicht gefunden." });
|
||||
}
|
||||
|
||||
return res.json({ user: serializeUser(rows[0]) });
|
||||
@@ -286,11 +342,12 @@ app.put(
|
||||
metadata.points[key] = {
|
||||
...metadata.points[key],
|
||||
alias: String(point.alias || metadata.points[key].alias).trim() || metadata.points[key].alias,
|
||||
unit: String(point.unit || metadata.points[key].unit).trim(),
|
||||
unit: normalizeEngineeringUnit(point.unit || metadata.points[key].unit),
|
||||
factor: Number.isFinite(Number(point.factor)) ? Number(point.factor) : metadata.points[key].factor,
|
||||
kind: point.kind === "digital" ? "digital" : "analog",
|
||||
min: Number.isFinite(Number(point.min)) ? Number(point.min) : metadata.points[key].min,
|
||||
max: Number.isFinite(Number(point.max)) ? Number(point.max) : metadata.points[key].max,
|
||||
color: typeof point.color === "string" ? point.color : metadata.points[key].color,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -302,6 +359,42 @@ app.put(
|
||||
})
|
||||
);
|
||||
|
||||
app.post("/api/trend-tables", authenticateToken, requireRoles("admin"), wrap(async (req, res) => {
|
||||
const rawName = String(req.body.tableName || "").trim().toLowerCase();
|
||||
const displayName = String(req.body.displayName || rawName).trim();
|
||||
const safeName = await createWideTrendTable(pool, rawName.startsWith("trend_") ? rawName : `trend_${rawName}`);
|
||||
const metadata = await updateIspMetadata(redis, safeName, (current) => ({
|
||||
...current,
|
||||
displayName: displayName || current.displayName,
|
||||
}));
|
||||
res.status(201).json({ tableName: safeName, metadata });
|
||||
}));
|
||||
|
||||
app.post(
|
||||
"/api/admin/normalize-alias-metadata",
|
||||
authenticateToken,
|
||||
requireRoles("technician", "admin"),
|
||||
wrap(async (req, res) => {
|
||||
const targetIsp = req.body?.isp ? sanitizeIspName(req.body.isp) : null;
|
||||
|
||||
if (!targetIsp && req.user.role !== "admin") {
|
||||
return res.status(403).json({ error: "Nur Admins dürfen alle ISPs auf einmal bereinigen." });
|
||||
}
|
||||
|
||||
const targets = targetIsp
|
||||
? [{ tableName: targetIsp, pointCount: 200 }]
|
||||
: await getAvailableIsps(pool, config.dbName);
|
||||
|
||||
const normalized = [];
|
||||
for (const isp of targets) {
|
||||
const metadata = await ensureIspMetadata(redis, isp.tableName, isp.pointCount || 200);
|
||||
normalized.push({ isp: isp.tableName, metadata });
|
||||
}
|
||||
|
||||
res.json({ ok: true, normalizedCount: normalized.length, items: normalized });
|
||||
})
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/admin/import-sql",
|
||||
authenticateToken,
|
||||
@@ -310,7 +403,7 @@ app.post(
|
||||
const sql = normalizeSqlDump(req.body.sql);
|
||||
|
||||
if (!sql) {
|
||||
throw new Error("SQL content is empty.");
|
||||
throw new Error("SQL-Inhalt ist leer.");
|
||||
}
|
||||
|
||||
await pool.query(sql);
|
||||
@@ -334,7 +427,7 @@ app.post("/api/users", authenticateToken, requireRoles("admin"), wrap(async (req
|
||||
const password = String(req.body.password || "");
|
||||
|
||||
if (!username || !name || password.length < 8) {
|
||||
return res.status(400).json({ error: "Username, name and password are required." });
|
||||
return res.status(400).json({ error: "Username, Name und Passwort sind erforderlich." });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
@@ -357,22 +450,16 @@ app.patch("/api/users/:id", authenticateToken, requireRoles("admin"), wrap(async
|
||||
const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [userId]);
|
||||
|
||||
if (!rows[0]) {
|
||||
return res.status(404).json({ error: "User not found." });
|
||||
return res.status(404).json({ error: "Benutzer nicht gefunden." });
|
||||
}
|
||||
|
||||
const current = rows[0];
|
||||
const username = typeof payload.username === "string"
|
||||
? payload.username.trim().toLowerCase() || current.username
|
||||
: current.username;
|
||||
const username = typeof payload.username === "string" ? payload.username.trim().toLowerCase() || current.username : current.username;
|
||||
const name = typeof payload.name === "string" ? payload.name.trim() || current.name : current.name;
|
||||
const email = typeof payload.email === "string"
|
||||
? payload.email.trim().toLowerCase() || null
|
||||
: current.email;
|
||||
const email = typeof payload.email === "string" ? payload.email.trim().toLowerCase() || null : current.email;
|
||||
const role = ["viewer", "technician", "admin"].includes(payload.role) ? payload.role : current.role;
|
||||
const active = typeof payload.active === "boolean" ? Number(payload.active) : current.active;
|
||||
const passwordHash = payload.password
|
||||
? await bcrypt.hash(String(payload.password), 10)
|
||||
: current.password_hash;
|
||||
const passwordHash = payload.password ? await bcrypt.hash(String(payload.password), 10) : current.password_hash;
|
||||
|
||||
await pool.query(
|
||||
`
|
||||
@@ -410,11 +497,11 @@ app.put("/api/selections/:selectionId", authenticateToken, wrap(async (req, res)
|
||||
const existing = await getSelection(redis, req.params.selectionId);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Selection not found." });
|
||||
return res.status(404).json({ error: "Auswahl nicht gefunden." });
|
||||
}
|
||||
|
||||
if (existing.ownerId !== req.user.sub && req.user.role !== "admin") {
|
||||
return res.status(403).json({ error: "You can only edit your own selections." });
|
||||
return res.status(403).json({ error: "Du kannst nur eigene Auswahlen bearbeiten." });
|
||||
}
|
||||
|
||||
const points = assertPoints(req.body.points || existing.points || []);
|
||||
@@ -437,11 +524,11 @@ app.delete("/api/selections/:selectionId", authenticateToken, wrap(async (req, r
|
||||
const existing = await getSelection(redis, req.params.selectionId);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Selection not found." });
|
||||
return res.status(404).json({ error: "Auswahl nicht gefunden." });
|
||||
}
|
||||
|
||||
if (existing.ownerId !== req.user.sub && req.user.role !== "admin") {
|
||||
return res.status(403).json({ error: "You can only delete your own selections." });
|
||||
return res.status(403).json({ error: "Du kannst nur eigene Auswahlen löschen." });
|
||||
}
|
||||
|
||||
await deleteSelection(redis, existing.id);
|
||||
@@ -475,9 +562,7 @@ app.post("/api/dashboard/data", authenticateToken, wrap(async (req, res) => {
|
||||
const results = [];
|
||||
|
||||
for (const widget of widgets) {
|
||||
const points = widget.selectionId
|
||||
? await resolveSelectionPoints({ selectionId: widget.selectionId }, req.user)
|
||||
: assertPoints(widget.points || []);
|
||||
const points = widget.selectionId ? await resolveSelectionPoints({ selectionId: widget.selectionId }, req.user) : assertPoints(widget.points || []);
|
||||
|
||||
if (widget.type === "chart") {
|
||||
const trendData = await fetchTrendSeries(pool, redis, points, normalizeRange(widget.range));
|
||||
@@ -506,9 +591,11 @@ app.use((error, _req, res, _next) => {
|
||||
res.status(400).json({ error: error.message || "Unexpected error." });
|
||||
});
|
||||
|
||||
await ensureUtf8Database();
|
||||
await ensureUserSchema();
|
||||
await ensureAdminUser();
|
||||
|
||||
app.listen(config.port, () => {
|
||||
console.log(`SE Local Trenddata API listening on port ${config.port}`);
|
||||
});
|
||||
|
||||
|
||||
+185
-32
@@ -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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user