API added

This commit is contained in:
jhartworks
2026-07-24 13:43:47 +02:00
parent 19d27f9de9
commit 986a43d5a5
12 changed files with 1112 additions and 146 deletions
+81 -18
View File
@@ -1,4 +1,4 @@
import dns from "node:dns";
import dns from "node:dns";
import { createReadStream, mkdirSync, statSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import https from "node:https";
@@ -18,26 +18,51 @@ const db = mysql.createPool({
connectionLimit: 3,
});
const exportDir = process.env.EXPORT_DIR || "/exports";
const tickMs = Number(process.env.RUNNER_TICK_MS || 30000);
const tickMs = Math.max(500, Number(process.env.RUNNER_TICK_MS || 1000));
const timeZone = process.env.RUNNER_TIMEZONE || "Europe/Berlin";
function safeParse(value, fallback = null) {
try { return value ? JSON.parse(value) : fallback; } catch { return fallback; }
}
function formatExportValue(value) {
if (value === null || value === undefined) return null;
if (typeof value === "boolean") return value ? "1" : "0";
const text = String(value).trim();
if (!text) return null;
const numeric = Number(text);
// German decimals prevent Excel from treating values such as 24.2 as dates.
if (Number.isFinite(numeric)) {
return new Intl.NumberFormat("de-DE", { useGrouping: false, maximumFractionDigits: 10 }).format(numeric);
}
return text;
}
function csvCell(value, delimiter) {
const text = String(value ?? "");
return /["\r\n;]/.test(text) || text.includes(delimiter) ? `"${text.replace(/"/g, '""')}"` : text;
}
function localDateParts(date = new Date()) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
return Object.fromEntries(parts.filter((part) => part.type !== "literal").map((part) => [part.type, part.value]));
}
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}`;
const parts = localDateParts(date);
return `${parts.year}-${parts.month}-${parts.day}`;
}
function localTime(date = new Date()) {
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
const parts = localDateParts(date);
return `${parts.hour}:${parts.minute}`;
}
function sanitizeTableName(value) {
@@ -57,7 +82,11 @@ async function isNarrowTable(table) {
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));
const points = meta.points || {};
// Legacy metadata stores values by index, newer sources use an array.
const point = Array.isArray(points)
? points.find((item) => Number(item.pointIndex) === Number(pointIndex))
: points[String(pointIndex)] || points[Number(pointIndex)];
return { alias: point?.alias || `Wert${pointIndex}`, unit: point?.unit || "" };
}
@@ -76,14 +105,22 @@ async function exportSelection(job, selection) {
`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]);
for (const item of data) {
const value = formatExportValue(item.value);
if (value === null) continue;
rows.push([item.datum?.toISOString?.() || item.datum, table, pointIndex, item.alias || meta.alias, 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]);
for (const item of data) {
const value = formatExportValue(item.value);
if (value === null) continue;
rows.push([item.datum?.toISOString?.() || item.datum, table, pointIndex, meta.alias, value, meta.unit]);
}
}
}
@@ -91,7 +128,8 @@ async function exportSelection(job, selection) {
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");
// Excel uses the BOM to reliably recognize UTF-8 when opening CSV files directly.
await writeFile(filePath, `\uFEFF${rows.map((row) => row.map((cell) => csvCell(cell, delimiter)).join(delimiter)).join("\r\n")}`, "utf8");
return filePath;
}
@@ -144,16 +182,24 @@ async function postMultipartIpv4(url, fields, attachmentPath = "") {
});
}
function parseMailRecipients(value) {
return [...new Set(String(value || "").split(/[;,]/).map((entry) => entry.trim()).filter(Boolean))];
}
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);
const recipients = parseMailRecipients(job.mailTo);
if (!recipients.length) return;
for (const address of recipients) {
await postMultipartIpv4(settings.mailUrl || "https://mailapi.se-inno.de", {
api_key: settings.mailApiKey,
address,
subject: job.mailSubject || "SE-LTD CSV Export",
message: job.mailMessage || "Automatischer CSV Export aus SE Local Trenddata.",
}, filePath);
}
}
async function runJob(job) {
@@ -164,7 +210,24 @@ async function runJob(job) {
await redis.set(`runner:last-result:${job.id}`, JSON.stringify({ ok: true, filePath, ranAt: new Date().toISOString() }));
}
async function runQueuedJobs() {
for (let index = 0; index < 20; index += 1) {
const queued = safeParse(await redis.lpop("runner:run-now"));
if (!queued?.jobId) return;
const job = safeParse(await redis.get(`runner:job:${queued.jobId}`));
if (!job) continue;
try {
await runJob(job);
console.log(`Runner-Test ausgeführt: ${job.name || job.id}`);
} catch (error) {
await redis.set(`runner:last-result:${job.id}`, JSON.stringify({ ok: false, error: error.message, ranAt: new Date().toISOString() }));
console.error(`Runner-Test fehlgeschlagen (${job.name || job.id}):`, error.message);
}
}
}
async function tick() {
await runQueuedJobs();
const ids = await redis.smembers("runner:job:ids");
const now = new Date();
const time = localTime(now);
@@ -182,6 +245,6 @@ async function tick() {
}
}
console.log("SE Local Trenddata runner started");
console.log(`SE Local Trenddata runner started (${timeZone}, ${tickMs} ms)`);
setInterval(() => tick().catch((error) => console.error(error)), tickMs);
tick().catch((error) => console.error(error));