Add update script, enhance admin features, and improve styles

- Created a PowerShell script (update.ps1) for automating the process of pulling the latest repository changes, stopping, building, and starting the Docker Compose stack.
- Updated App.jsx to include new admin sections for alarms and integrations, added state management for runner jobs, and enhanced permission checks.
- Extended the API module (api.js) with new endpoints for managing integrations and runner jobs.
- Improved CSS styles for a more compact and responsive layout in the admin interface, focusing on source and datapoint cards.
This commit is contained in:
jhartworks
2026-07-09 15:24:58 +02:00
parent f247d2827d
commit 19d27f9de9
14 changed files with 1077 additions and 54 deletions
+7
View File
@@ -0,0 +1,7 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY src ./src
RUN mkdir -p /exports
CMD ["npm", "start"]
+13
View File
@@ -0,0 +1,13 @@
{
"name": "seltd-runner",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"ioredis": "^5.6.1",
"mysql2": "^3.12.0"
}
}
+187
View File
@@ -0,0 +1,187 @@
import dns from "node:dns";
import { createReadStream, mkdirSync, statSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import https from "node:https";
import path from "node:path";
import Redis from "ioredis";
import mysql from "mysql2/promise";
const redis = new Redis(process.env.REDIS_URL || "redis://redis:6379", { maxRetriesPerRequest: 2 });
const db = mysql.createPool({
host: process.env.DB_HOST || "mariadb",
port: Number(process.env.DB_PORT || 3306),
database: process.env.DB_NAME || "wago",
user: process.env.DB_USER || "root",
password: process.env.DB_PASSWORD || "SE3112",
charset: "utf8mb4",
waitForConnections: true,
connectionLimit: 3,
});
const exportDir = process.env.EXPORT_DIR || "/exports";
const tickMs = Number(process.env.RUNNER_TICK_MS || 30000);
function safeParse(value, fallback = null) {
try { return value ? JSON.parse(value) : fallback; } catch { return fallback; }
}
function csvCell(value, delimiter) {
const text = String(value ?? "");
return /["\r\n;]/.test(text) || text.includes(delimiter) ? `"${text.replace(/"/g, '""')}"` : text;
}
function localDateKey(date = new Date()) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function localTime(date = new Date()) {
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
}
function sanitizeTableName(value) {
const table = String(value || "").toLowerCase();
if (!/^(isp[a-z0-9_]*|trend_[a-z0-9_]+)$/.test(table)) throw new Error(`Ungueltige Tabelle: ${value}`);
return table;
}
async function isNarrowTable(table) {
const [rows] = await db.query(
"SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = 'point_index' LIMIT 1",
[table],
);
return rows.length > 0;
}
async function getPointMeta(isp, pointIndex) {
const raw = await redis.get(`meta:isp:${isp}`);
const meta = safeParse(raw, {});
const point = (meta.points || []).find((item) => Number(item.pointIndex) === Number(pointIndex));
return { alias: point?.alias || `Wert${pointIndex}`, unit: point?.unit || "" };
}
async function exportSelection(job, selection) {
const delimiter = job.delimiter || ";";
const hours = Number(job.rangeHours || 24);
const since = new Date(Date.now() - Math.max(1, hours) * 3600000);
const rows = [["Zeit", "Tabelle", "Wertnummer", "Alias", "Wert", "Einheit"]];
for (const point of selection.points || []) {
const table = sanitizeTableName(point.isp);
const pointIndex = Number(point.pointIndex);
const meta = await getPointMeta(table, pointIndex);
if (await isNarrowTable(table)) {
const [data] = await db.query(
`SELECT datum, alias, value FROM \`${table}\` WHERE point_index = ? AND datum >= ? ORDER BY datum ASC`,
[pointIndex, since],
);
for (const item of data) rows.push([item.datum?.toISOString?.() || item.datum, table, pointIndex, item.alias || meta.alias, item.value, meta.unit]);
} else {
const column = `Wert${pointIndex}`;
const [data] = await db.query(
`SELECT datum, \`${column}\` AS value FROM \`${table}\` WHERE datum >= ? ORDER BY datum ASC`,
[since],
);
for (const item of data) rows.push([item.datum?.toISOString?.() || item.datum, table, pointIndex, meta.alias, item.value, meta.unit]);
}
}
mkdirSync(exportDir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const fileName = `${selection.name || selection.id || "selection"}-${stamp}.csv`.replace(/[^a-zA-Z0-9_.-]+/g, "_");
const filePath = path.join(exportDir, fileName);
await writeFile(filePath, rows.map((row) => row.map((cell) => csvCell(cell, delimiter)).join(delimiter)).join("\r\n"), "utf8");
return filePath;
}
async function postMultipartIpv4(url, fields, attachmentPath = "") {
const boundary = `----seltd-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const chunks = [];
const pushField = (name, value) => {
chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value ?? ""}\r\n`));
};
Object.entries(fields).forEach(([key, value]) => pushField(key, value));
if (attachmentPath) {
const fileName = path.basename(attachmentPath);
chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="${fileName}"\r\nContent-Type: text/csv\r\n\r\n`));
chunks.push(createReadStream(attachmentPath));
chunks.push(Buffer.from("\r\n"));
}
chunks.push(Buffer.from(`--${boundary}--\r\n`));
const target = new URL(url);
const length = chunks.reduce((sum, chunk) => sum + (typeof chunk.path === "string" ? statSync(chunk.path).size : chunk.length), 0);
await new Promise((resolve, reject) => {
const req = https.request({
method: "POST",
hostname: target.hostname,
path: `${target.pathname}${target.search}`,
port: target.port || 443,
lookup: (hostname, options, cb) => dns.lookup(hostname, { ...options, family: 4 }, cb),
timeout: 15000,
headers: { "Content-Type": `multipart/form-data; boundary=${boundary}`, "Content-Length": length },
}, (res) => {
let body = "";
res.on("data", (chunk) => { body += chunk.toString("utf8"); });
res.on("end", () => res.statusCode >= 200 && res.statusCode < 300 ? resolve(body) : reject(new Error(`HTTP ${res.statusCode}: ${body}`)));
});
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("API timeout")));
const writeNext = (index) => {
if (index >= chunks.length) return req.end();
const chunk = chunks[index];
if (typeof chunk.pipe === "function") {
chunk.on("end", () => writeNext(index + 1));
chunk.on("error", reject);
chunk.pipe(req, { end: false });
} else {
req.write(chunk);
writeNext(index + 1);
}
};
writeNext(0);
});
}
async function maybeMail(job, filePath) {
if (!job.mailEnabled || !job.mailTo) return;
const settings = safeParse(await redis.get("integration:api"), {});
if (!settings.mailApiKey) return;
await postMultipartIpv4(settings.mailUrl || "https://mailapi.se-inno.de", {
api_key: settings.mailApiKey,
address: job.mailTo,
subject: job.mailSubject || "SE-LTD CSV Export",
message: job.mailMessage || "Automatischer CSV Export aus SE Local Trenddata.",
}, filePath);
}
async function runJob(job) {
const selection = safeParse(await redis.get(`selection:${job.selectionId}`));
if (!selection) throw new Error(`Selection nicht gefunden: ${job.selectionId}`);
const filePath = await exportSelection(job, selection);
await maybeMail(job, filePath);
await redis.set(`runner:last-result:${job.id}`, JSON.stringify({ ok: true, filePath, ranAt: new Date().toISOString() }));
}
async function tick() {
const ids = await redis.smembers("runner:job:ids");
const now = new Date();
const time = localTime(now);
const dateKey = localDateKey(now);
for (const id of ids) {
const job = safeParse(await redis.get(`runner:job:${id}`));
if (!job?.enabled || job.time !== time) continue;
const lastKey = `runner:last:${id}`;
if ((await redis.get(lastKey)) === dateKey) continue;
await redis.set(lastKey, dateKey);
runJob(job).catch(async (error) => {
await redis.set(`runner:last-result:${id}`, JSON.stringify({ ok: false, error: error.message, ranAt: new Date().toISOString() }));
console.error(error);
});
}
}
console.log("SE Local Trenddata runner started");
setInterval(() => tick().catch((error) => console.error(error)), tickMs);
tick().catch((error) => console.error(error));