API added
This commit is contained in:
+81
-18
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user