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:
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
Reference in New Issue
Block a user