update
This commit is contained in:
+4
-1
@@ -3,4 +3,7 @@ ADMIN_USERNAME=admin
|
|||||||
ADMIN_EMAIL=admin@example.com
|
ADMIN_EMAIL=admin@example.com
|
||||||
ADMIN_PASSWORD=ChangeMe123!
|
ADMIN_PASSWORD=ChangeMe123!
|
||||||
ADMIN_NAME=System Admin
|
ADMIN_NAME=System Admin
|
||||||
VITE_API_URL=http://localhost:18080/api
|
MARIADB_ROOT_PASSWORD=SE3112
|
||||||
|
DB_PASSWORD_FALLBACK=root
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
VITE_API_PORT=18080
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
.scratch/runtime
|
.scratch/runtime
|
||||||
|
data/
|
||||||
|
|||||||
@@ -1,43 +1,45 @@
|
|||||||
# SE Local Trenddata.
|
# SE Local Trenddata.
|
||||||
|
|
||||||
Modernisierte Trendoberflaeche fuer Wago-Bestandsdatenbanken mit React, Node.js, MariaDB und Redis.
|
Modernisierte Trendoberfläche für Wago-Bestandsdatenbanken mit React, Node.js, MariaDB, Redis, Dockhand und vorbereitetem Collector-Service.
|
||||||
|
|
||||||
## Enthalten
|
## Enthalten
|
||||||
- React-Frontend mit Light/Dark/System-Theme
|
- React-Frontend mit System-, Hell- und Dunkelmodus
|
||||||
- Username-Login und Rollen fuer `viewer`, `technician`, `admin`
|
- Username-Login, Rollen, Passwortwechsel und Benutzerverwaltung
|
||||||
- Alias-Verwaltung fuer `Wert1` bis `Wert200` je `ispXX`
|
- Alias-Verwaltung für `Wert1` bis `Wert200` je Trendtabelle
|
||||||
- CSV-Import und CSV-Export fuer Aliasdaten
|
- CSV-Import und CSV-Export für Aliasdaten
|
||||||
- Gespeicherte Auswahlen ueber mehrere ISPs
|
- Gespeicherte Auswahlen über mehrere ISPs hinweg
|
||||||
- Dashboard mit Graph-, Zahl- und Gauge-Widgets
|
- Dashboard mit Graph-, Zahl- und Zeigermanometer-Widgets
|
||||||
- Vollbild fuer die Trendansicht
|
- Fokusansicht für den Trendgraph statt Browser-Vollbild
|
||||||
- SQL-Import fuer vorhandene Langzeitdaten
|
- Export des Trendgraphs als CSV, XLSX, PNG, JPG, SVG und PDF
|
||||||
- Redis fuer Metadaten, Auswahlen, Dashboard und User-Praferenzen
|
- SQL-Import für vorhandene Langzeitdaten
|
||||||
- MariaDB-Init fuer `isp01` bis `isp20` plus `app_users`
|
- Redis für Metadaten, Auswahlen, Dashboard und Benutzerpräferenzen
|
||||||
|
- MariaDB-Init für `isp01` bis `isp20` plus `app_users`
|
||||||
|
- Dockhand im Compose-Stack
|
||||||
|
- Collector-Gerüst für spätere Modbus-, OPC-UA-, BACnet- und KNX-Anbindung
|
||||||
|
|
||||||
## Starten
|
## Starten
|
||||||
1. Optional `.env.example` nach `.env` kopieren und Werte anpassen.
|
1. Optional `.env.example` nach `.env` kopieren und Werte anpassen.
|
||||||
2. `docker compose up -d --build`
|
2. `docker compose up -d --build`
|
||||||
3. Frontend: `http://localhost:5173`
|
3. Frontend: `http://localhost:5173`
|
||||||
4. API: `http://localhost:18080/api`
|
4. API: `http://localhost:18080/api`
|
||||||
|
5. Dockhand: `http://localhost:3000`
|
||||||
|
6. Collector: `http://localhost:18110/health`
|
||||||
|
|
||||||
## Standard-Login
|
## Standard-Login
|
||||||
- Username: `admin`
|
- Username: `admin`
|
||||||
- Passwort: `ChangeMe123!`
|
- Passwort: `ChangeMe123!`
|
||||||
|
|
||||||
Bitte nach dem ersten Login in der Benutzerverwaltung einen eigenen Admin anlegen oder die Zugangsdaten anpassen.
|
## Datenbank-Kompatibilität
|
||||||
|
- Bestehende Anlagen können weiter mit `root` und Passwort `SE3112` auf die MariaDB gehen.
|
||||||
|
- Die API versucht zusätzlich als Fallback noch das ältere Root-Passwort `root`, damit vorhandene Entwicklungs-Volumes nicht sofort umgebaut werden müssen.
|
||||||
|
|
||||||
## Datenmodell
|
## Wago / SD-Karte
|
||||||
- Bestehende Trenddaten bleiben in den breiten Tabellen `isp01`, `isp02`, ...
|
- Für Wago-Systeme liegt mit [docker-compose.wago.yml](/C:/Projects/SeLTD/docker-compose.wago.yml) ein Override bereit.
|
||||||
- Die Anwendung erkennt vorhandene `isp`-Tabellen dynamisch.
|
- Start auf Wago mit SD-Karte: `docker compose -f docker-compose.yml -f docker-compose.wago.yml up -d --build`
|
||||||
- Metadaten wie Alias, Einheit, Faktor und Dashboard-Konfiguration liegen in Redis.
|
- Dabei landen die großen Daten unter `/media/sd/seltd/...`.
|
||||||
|
|
||||||
## Wichtige Container
|
## Hinweise
|
||||||
- `seltd-mariadb`
|
- Vorhandene SQL-Dumps können im Admin-Bereich direkt importiert werden.
|
||||||
- `seltd-redis`
|
- Leere Trendtabellen tauchen nicht im Trend-Dropdown auf, aber weiter im Admin-Bereich.
|
||||||
- `seltd-api`
|
- Der Collector ist bewusst erst als technisches Gerüst enthalten; Protokoll-Scan und Feldbus-Importe sind noch nicht vollständig implementiert.
|
||||||
- `seltd-web`
|
- Für Dockhand wurde der Container ergänzt. Eine sichere automatische Vorbelegung eines lokalen Admin-Users war ohne offizielle Initialisierungsdokumentation nicht belastbar genug und wurde deshalb nicht blind in die SQLite-Datenbank geschrieben.
|
||||||
|
|
||||||
## Hinweise fuer Bestandsdaten
|
|
||||||
- Vorhandene SQL-Dumps koennen im Admin-Bereich direkt importiert werden.
|
|
||||||
- Die Anwendung liest die Tabellenstruktur mit `datum`, `userlevel` und `Wert1..Wert200` direkt.
|
|
||||||
- Wenn spaeter weitere Tabellen wie `isp21` oder `isp35` auftauchen, werden sie automatisch in der Oberflaeche erkannt.
|
|
||||||
|
|||||||
@@ -17,4 +17,3 @@
|
|||||||
"mysql2": "^3.12.0"
|
"mysql2": "^3.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -8,10 +8,11 @@ export const config = {
|
|||||||
dbPort: Number(process.env.DB_PORT || 3306),
|
dbPort: Number(process.env.DB_PORT || 3306),
|
||||||
dbName: process.env.DB_NAME || "wago",
|
dbName: process.env.DB_NAME || "wago",
|
||||||
dbUser: process.env.DB_USER || "root",
|
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",
|
redisUrl: process.env.REDIS_URL || "redis://127.0.0.1:6379",
|
||||||
jwtSecret: process.env.JWT_SECRET || "change-me",
|
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",
|
adminUsername: process.env.ADMIN_USERNAME || "admin",
|
||||||
adminEmail: process.env.ADMIN_EMAIL || "admin@example.com",
|
adminEmail: process.env.ADMIN_EMAIL || "admin@example.com",
|
||||||
adminPassword: process.env.ADMIN_PASSWORD || "ChangeMe123!",
|
adminPassword: process.env.ADMIN_PASSWORD || "ChangeMe123!",
|
||||||
|
|||||||
+46
-2
@@ -1,14 +1,58 @@
|
|||||||
import mysql from "mysql2/promise";
|
import mysql from "mysql2/promise";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
|
|
||||||
export const pool = mysql.createPool({
|
function createPool(password) {
|
||||||
|
return mysql.createPool({
|
||||||
host: config.dbHost,
|
host: config.dbHost,
|
||||||
port: config.dbPort,
|
port: config.dbPort,
|
||||||
user: config.dbUser,
|
user: config.dbUser,
|
||||||
password: config.dbPassword,
|
password,
|
||||||
database: config.dbName,
|
database: config.dbName,
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 10,
|
connectionLimit: 10,
|
||||||
namedPlaceholders: true,
|
namedPlaceholders: true,
|
||||||
multipleStatements: 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 { config } from "./config.js";
|
||||||
import { pool } from "./db.js";
|
import { pool } from "./db.js";
|
||||||
import {
|
import {
|
||||||
|
createWideTrendTable,
|
||||||
ensureIspMetadata,
|
ensureIspMetadata,
|
||||||
fetchLatestValues,
|
fetchLatestValues,
|
||||||
fetchTrendSeries,
|
fetchTrendSeries,
|
||||||
getAvailableIsps,
|
getAvailableIsps,
|
||||||
|
normalizeEngineeringUnit,
|
||||||
normalizeRange,
|
normalizeRange,
|
||||||
sanitizeIspName,
|
sanitizeIspName,
|
||||||
sanitizePointIndex,
|
sanitizePointIndex,
|
||||||
@@ -29,9 +31,21 @@ import { redis } from "./redis.js";
|
|||||||
const app = express();
|
const app = express();
|
||||||
const wrap = (handler) => (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next);
|
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(
|
app.use(
|
||||||
cors({
|
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" }));
|
app.use(express.json({ limit: "50mb" }));
|
||||||
@@ -111,6 +125,22 @@ async function resolveSelectionPoints(body, user) {
|
|||||||
return assertPoints(body.points || []);
|
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() {
|
async function ensureUserSchema() {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`
|
`
|
||||||
@@ -209,20 +239,20 @@ app.post("/api/auth/login", wrap(async (req, res) => {
|
|||||||
const password = String(req.body.password || "");
|
const password = String(req.body.password || "");
|
||||||
|
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
"SELECT * FROM app_users WHERE username = ? AND active = 1 LIMIT 1",
|
"SELECT * FROM app_users WHERE (username = ? OR email = ?) AND active = 1 LIMIT 1",
|
||||||
[username]
|
[username, username]
|
||||||
);
|
);
|
||||||
|
|
||||||
const user = rows[0];
|
const user = rows[0];
|
||||||
|
|
||||||
if (!user) {
|
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);
|
const isValid = await bcrypt.compare(password, user.password_hash);
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return res.status(401).json({ error: "Invalid credentials." });
|
return res.status(401).json({ error: "Ungültige Zugangsdaten." });
|
||||||
}
|
}
|
||||||
|
|
||||||
const serializedUser = serializeUser(user);
|
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) => {
|
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]);
|
const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [req.user.sub]);
|
||||||
|
|
||||||
if (!rows[0]) {
|
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]) });
|
return res.json({ user: serializeUser(rows[0]) });
|
||||||
@@ -286,11 +342,12 @@ app.put(
|
|||||||
metadata.points[key] = {
|
metadata.points[key] = {
|
||||||
...metadata.points[key],
|
...metadata.points[key],
|
||||||
alias: String(point.alias || metadata.points[key].alias).trim() || metadata.points[key].alias,
|
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,
|
factor: Number.isFinite(Number(point.factor)) ? Number(point.factor) : metadata.points[key].factor,
|
||||||
kind: point.kind === "digital" ? "digital" : "analog",
|
kind: point.kind === "digital" ? "digital" : "analog",
|
||||||
min: Number.isFinite(Number(point.min)) ? Number(point.min) : metadata.points[key].min,
|
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,
|
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(
|
app.post(
|
||||||
"/api/admin/import-sql",
|
"/api/admin/import-sql",
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
@@ -310,7 +403,7 @@ app.post(
|
|||||||
const sql = normalizeSqlDump(req.body.sql);
|
const sql = normalizeSqlDump(req.body.sql);
|
||||||
|
|
||||||
if (!sql) {
|
if (!sql) {
|
||||||
throw new Error("SQL content is empty.");
|
throw new Error("SQL-Inhalt ist leer.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query(sql);
|
await pool.query(sql);
|
||||||
@@ -334,7 +427,7 @@ app.post("/api/users", authenticateToken, requireRoles("admin"), wrap(async (req
|
|||||||
const password = String(req.body.password || "");
|
const password = String(req.body.password || "");
|
||||||
|
|
||||||
if (!username || !name || password.length < 8) {
|
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);
|
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]);
|
const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [userId]);
|
||||||
|
|
||||||
if (!rows[0]) {
|
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 current = rows[0];
|
||||||
const username = typeof payload.username === "string"
|
const username = typeof payload.username === "string" ? payload.username.trim().toLowerCase() || current.username : current.username;
|
||||||
? payload.username.trim().toLowerCase() || current.username
|
|
||||||
: current.username;
|
|
||||||
const name = typeof payload.name === "string" ? payload.name.trim() || current.name : current.name;
|
const name = typeof payload.name === "string" ? payload.name.trim() || current.name : current.name;
|
||||||
const email = typeof payload.email === "string"
|
const email = typeof payload.email === "string" ? payload.email.trim().toLowerCase() || null : current.email;
|
||||||
? payload.email.trim().toLowerCase() || null
|
|
||||||
: current.email;
|
|
||||||
const role = ["viewer", "technician", "admin"].includes(payload.role) ? payload.role : current.role;
|
const role = ["viewer", "technician", "admin"].includes(payload.role) ? payload.role : current.role;
|
||||||
const active = typeof payload.active === "boolean" ? Number(payload.active) : current.active;
|
const active = typeof payload.active === "boolean" ? Number(payload.active) : current.active;
|
||||||
const passwordHash = payload.password
|
const passwordHash = payload.password ? await bcrypt.hash(String(payload.password), 10) : current.password_hash;
|
||||||
? await bcrypt.hash(String(payload.password), 10)
|
|
||||||
: current.password_hash;
|
|
||||||
|
|
||||||
await pool.query(
|
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);
|
const existing = await getSelection(redis, req.params.selectionId);
|
||||||
|
|
||||||
if (!existing) {
|
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") {
|
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 || []);
|
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);
|
const existing = await getSelection(redis, req.params.selectionId);
|
||||||
|
|
||||||
if (!existing) {
|
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") {
|
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);
|
await deleteSelection(redis, existing.id);
|
||||||
@@ -475,9 +562,7 @@ app.post("/api/dashboard/data", authenticateToken, wrap(async (req, res) => {
|
|||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
for (const widget of widgets) {
|
for (const widget of widgets) {
|
||||||
const points = widget.selectionId
|
const points = widget.selectionId ? await resolveSelectionPoints({ selectionId: widget.selectionId }, req.user) : assertPoints(widget.points || []);
|
||||||
? await resolveSelectionPoints({ selectionId: widget.selectionId }, req.user)
|
|
||||||
: assertPoints(widget.points || []);
|
|
||||||
|
|
||||||
if (widget.type === "chart") {
|
if (widget.type === "chart") {
|
||||||
const trendData = await fetchTrendSeries(pool, redis, points, normalizeRange(widget.range));
|
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." });
|
res.status(400).json({ error: error.message || "Unexpected error." });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await ensureUtf8Database();
|
||||||
await ensureUserSchema();
|
await ensureUserSchema();
|
||||||
await ensureAdminUser();
|
await ensureAdminUser();
|
||||||
|
|
||||||
app.listen(config.port, () => {
|
app.listen(config.port, () => {
|
||||||
console.log(`SE Local Trenddata API listening on port ${config.port}`);
|
console.log(`SE Local Trenddata API listening on port ${config.port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+184
-31
@@ -7,8 +7,8 @@ export function buildPointKey(isp, pointIndex) {
|
|||||||
export function sanitizeIspName(value) {
|
export function sanitizeIspName(value) {
|
||||||
const normalized = String(value || "").toLowerCase();
|
const normalized = String(value || "").toLowerCase();
|
||||||
|
|
||||||
if (!/^isp\d+$/.test(normalized)) {
|
if (!/^(isp[a-z0-9_]*|trend_[a-z0-9_]+)$/.test(normalized)) {
|
||||||
throw new Error("Invalid ISP table name.");
|
throw new Error("Invalid trend table name.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
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) {
|
export async function getAvailableIsps(pool, dbName) {
|
||||||
const [tables] = await pool.query(
|
const [tables] = await pool.query(
|
||||||
`
|
`
|
||||||
@@ -59,43 +166,42 @@ export async function getAvailableIsps(pool, dbName) {
|
|||||||
) AS pointCount
|
) AS pointCount
|
||||||
FROM information_schema.TABLES t
|
FROM information_schema.TABLES t
|
||||||
WHERE t.TABLE_SCHEMA = ?
|
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
|
ORDER BY t.TABLE_NAME
|
||||||
`,
|
`,
|
||||||
[dbName]
|
[dbName]
|
||||||
);
|
);
|
||||||
|
|
||||||
const latestMap = new Map();
|
const stats = new Map();
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
tables.map(async (table) => {
|
tables.map(async (table) => {
|
||||||
const safeTable = sanitizeIspName(table.tableName);
|
const safeTable = sanitizeIspName(table.tableName);
|
||||||
const [rows] = await pool.query(
|
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) => ({
|
return tables.map((table) => {
|
||||||
tableName: sanitizeIspName(table.tableName),
|
const safeTable = sanitizeIspName(table.tableName);
|
||||||
|
const info = stats.get(safeTable) || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
tableName: safeTable,
|
||||||
pointCount: Number(table.pointCount || 0),
|
pointCount: Number(table.pointCount || 0),
|
||||||
latestTimestamp: latestMap.get(sanitizeIspName(table.tableName)) || null,
|
rowCount: Number(info.rowCount || 0),
|
||||||
}));
|
latestTimestamp: info.latestTimestamp || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultMetadata(isp, pointCount = 200) {
|
export function createDefaultMetadata(isp, pointCount = 200) {
|
||||||
const points = {};
|
const points = {};
|
||||||
|
|
||||||
for (let index = 1; index <= pointCount; index += 1) {
|
for (let index = 1; index <= pointCount; index += 1) {
|
||||||
points[String(index)] = {
|
points[String(index)] = createDefaultPoint(index);
|
||||||
alias: `Wert${index}`,
|
|
||||||
unit: "",
|
|
||||||
factor: 1,
|
|
||||||
kind: "analog",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -113,11 +219,62 @@ export async function ensureIspMetadata(redis, isp, pointCount = 200) {
|
|||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const parsed = JSON.parse(existing);
|
const parsed = JSON.parse(existing);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (!parsed.points) {
|
||||||
|
parsed.points = {};
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
for (let index = 1; index <= pointCount; index += 1) {
|
for (let index = 1; index <= pointCount; index += 1) {
|
||||||
if (!parsed.points?.[String(index)]) {
|
const pointKey = String(index);
|
||||||
parsed.points[String(index)] = createDefaultMetadata(safeIsp, pointCount).points[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;
|
return parsed;
|
||||||
@@ -162,11 +319,12 @@ export async function getPointDescriptors(redis, points) {
|
|||||||
key: buildPointKey(isp, pointIndex),
|
key: buildPointKey(isp, pointIndex),
|
||||||
displayName: metadata.displayName,
|
displayName: metadata.displayName,
|
||||||
alias: pointMeta.alias,
|
alias: pointMeta.alias,
|
||||||
unit: pointMeta.unit,
|
unit: normalizeEngineeringUnit(pointMeta.unit),
|
||||||
factor: pointMeta.factor,
|
factor: pointMeta.factor,
|
||||||
kind: pointMeta.kind,
|
kind: pointMeta.kind,
|
||||||
min: pointMeta.min,
|
min: pointMeta.min,
|
||||||
max: pointMeta.max,
|
max: pointMeta.max,
|
||||||
|
color: pointMeta.color || "",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -218,9 +376,7 @@ export async function fetchTrendSeries(pool, redis, points, range) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
series: descriptors,
|
series: descriptors,
|
||||||
rows: [...mergedRows.values()].sort((left, right) =>
|
rows: [...mergedRows.values()].sort((left, right) => left.timestamp.localeCompare(right.timestamp)),
|
||||||
left.timestamp.localeCompare(right.timestamp)
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,10 +398,7 @@ export async function fetchLatestValues(pool, redis, points) {
|
|||||||
.map((descriptor) => `\`Wert${descriptor.pointIndex}\``)
|
.map((descriptor) => `\`Wert${descriptor.pointIndex}\``)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(`SELECT datum, ${columns} FROM \`${isp}\` ORDER BY datum DESC LIMIT 1`);
|
||||||
`SELECT datum, ${columns} FROM \`${isp}\` ORDER BY datum DESC LIMIT 1`
|
|
||||||
);
|
|
||||||
|
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
|
|
||||||
pointDescriptors.forEach((descriptor) => {
|
pointDescriptors.forEach((descriptor) => {
|
||||||
@@ -267,9 +420,7 @@ export function normalizeRange(input = {}) {
|
|||||||
const maxRows = Number(input.maxRows || 4000);
|
const maxRows = Number(input.maxRows || 4000);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const to = input.to ? new Date(input.to) : now;
|
const to = input.to ? new Date(input.to) : now;
|
||||||
const from = input.from
|
const from = input.from ? new Date(input.from) : new Date(now.getTime() - presetDays * 24 * 60 * 60 * 1000);
|
||||||
? new Date(input.from)
|
|
||||||
: new Date(now.getTime() - presetDays * 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
from,
|
from,
|
||||||
@@ -277,3 +428,5 @@ export function normalizeRange(input = {}) {
|
|||||||
maxRows: Number.isFinite(maxRows) ? Math.max(100, Math.min(maxRows, 10000)) : 4000,
|
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";
|
const SELECTION_SEQ_KEY = "selection:seq";
|
||||||
|
|
||||||
function safeParse(jsonValue) {
|
function safeParse(jsonValue) {
|
||||||
@@ -54,12 +54,21 @@ export async function saveDashboard(redis, userId, dashboard) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPreferences(redis, userId) {
|
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) {
|
export async function savePreferences(redis, userId, preferences) {
|
||||||
|
const current = await getPreferences(redis, userId);
|
||||||
const payload = {
|
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(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
await redis.set(`preferences:${userId}`, JSON.stringify(payload));
|
await redis.set(`preferences:${userId}`, JSON.stringify(payload));
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
EXPOSE 18110
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "seltd-collector",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
import { createServer } from "node:http";
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import net from "node:net";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const dataDir = join(__dirname, "..", "data");
|
||||||
|
const storeFile = join(dataDir, "sources.json");
|
||||||
|
const port = Number(process.env.PORT || 18110);
|
||||||
|
|
||||||
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
|
||||||
|
function baseStore() {
|
||||||
|
return {
|
||||||
|
sources: [],
|
||||||
|
datapoints: [],
|
||||||
|
scans: { opcua: [], bacnet: [] },
|
||||||
|
updatedAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStore() {
|
||||||
|
if (!existsSync(storeFile)) {
|
||||||
|
return baseStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(storeFile, "utf8"));
|
||||||
|
return {
|
||||||
|
...baseStore(),
|
||||||
|
...parsed,
|
||||||
|
scans: { ...baseStore().scans, ...(parsed.scans || {}) },
|
||||||
|
sources: Array.isArray(parsed.sources) ? parsed.sources : [],
|
||||||
|
datapoints: Array.isArray(parsed.datapoints) ? parsed.datapoints : [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return baseStore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStore(payload) {
|
||||||
|
writeFileSync(storeFile, JSON.stringify(payload, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function touchStore(mutator) {
|
||||||
|
const store = readStore();
|
||||||
|
const next = mutator(structuredClone(store)) || store;
|
||||||
|
next.updatedAt = new Date().toISOString();
|
||||||
|
writeStore(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replyJson(res, statusCode, body) {
|
||||||
|
res.writeHead(statusCode, {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
req.on("data", (chunk) => chunks.push(chunk));
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
const text = Buffer.concat(chunks).toString("utf8");
|
||||||
|
resolve(text ? JSON.parse(text) : {});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "") || "quelle";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProtocol(value) {
|
||||||
|
const protocol = String(value || "").toLowerCase();
|
||||||
|
if (["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"].includes(protocol)) {
|
||||||
|
return protocol;
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWriteMode(value) {
|
||||||
|
return String(value || "").toLowerCase() === "cov" ? "cov" : "interval";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePollInterval(value) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
return Number.isFinite(numeric) && numeric > 0 ? Math.round(numeric) : 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSource(store, body) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const source = {
|
||||||
|
id: body.id || `src-${Date.now()}`,
|
||||||
|
protocol: normalizeProtocol(body.protocol),
|
||||||
|
name: body.name || "Neue Quelle",
|
||||||
|
host: body.host || "",
|
||||||
|
port: body.port || "",
|
||||||
|
deviceId: body.deviceId || "",
|
||||||
|
displayName: body.displayName || body.name || "Neue Quelle",
|
||||||
|
tableName: body.tableName || `trend_${slugify(body.name || body.protocol || "daten")}`,
|
||||||
|
writeMode: normalizeWriteMode(body.writeMode),
|
||||||
|
pollIntervalSeconds: normalizePollInterval(body.pollIntervalSeconds),
|
||||||
|
options: {
|
||||||
|
...(body.options || {}),
|
||||||
|
writeMode: normalizeWriteMode(body.writeMode),
|
||||||
|
pollIntervalSeconds: normalizePollInterval(body.pollIntervalSeconds),
|
||||||
|
},
|
||||||
|
createdAt: body.createdAt || now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingIndex = store.sources.findIndex((item) => item.id === source.id);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
store.sources[existingIndex] = { ...store.sources[existingIndex], ...source };
|
||||||
|
} else {
|
||||||
|
store.sources.push(source);
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitDelimited(text, delimiter) {
|
||||||
|
return text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => line.split(delimiter).map((part) => part.trim().replace(/^"|"$/g, "")));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseModbusCsv(payload) {
|
||||||
|
const delimiter = payload.delimiter || ";";
|
||||||
|
const rows = splitDelimited(payload.csv || "", delimiter);
|
||||||
|
if (rows.length < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = rows[0].map((value) => value.toLowerCase());
|
||||||
|
return rows.slice(1).map((row, index) => {
|
||||||
|
const get = (name, fallback = "") => {
|
||||||
|
const columnIndex = header.indexOf(name);
|
||||||
|
return columnIndex >= 0 ? row[columnIndex] || fallback : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `dp-modbus-${Date.now()}-${index}`,
|
||||||
|
sourceId: payload.sourceId || "",
|
||||||
|
protocol: "modbus-tcp",
|
||||||
|
address: get("address", get("register", String(index + 1))),
|
||||||
|
pointIndex: Number(get("pointindex", index + 1)) || index + 1,
|
||||||
|
name: get("name", `Register ${index + 1}`),
|
||||||
|
alias: get("alias", get("name", `Register ${index + 1}`)),
|
||||||
|
unit: get("unit"),
|
||||||
|
dataType: get("datatype", "holding-register"),
|
||||||
|
scale: Number(get("scale", 1)) || 1,
|
||||||
|
host: payload.host || "",
|
||||||
|
tableName: payload.tableName || "",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeXmlEntities(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll(""", '"')
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKnxXml(payload) {
|
||||||
|
const xml = String(payload.xml || "");
|
||||||
|
const datapoints = [];
|
||||||
|
const pattern = /<GroupAddress\b([^>]*)\/?>(?:<\/GroupAddress>)?/gi;
|
||||||
|
let match;
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
while ((match = pattern.exec(xml))) {
|
||||||
|
const attrs = match[1];
|
||||||
|
const getAttr = (name) => {
|
||||||
|
const attrMatch = attrs.match(new RegExp(`${name}="([^"]*)"`, "i"));
|
||||||
|
return attrMatch ? decodeXmlEntities(attrMatch[1]) : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const address = getAttr("Address") || getAttr("address");
|
||||||
|
const name = getAttr("Name") || getAttr("name") || `KNX ${index + 1}`;
|
||||||
|
const dpt = getAttr("DatapointType") || getAttr("DPTs") || getAttr("DPT") || "";
|
||||||
|
|
||||||
|
if (address) {
|
||||||
|
datapoints.push({
|
||||||
|
id: `dp-knx-${Date.now()}-${index}`,
|
||||||
|
sourceId: payload.sourceId || "",
|
||||||
|
protocol: "knx-ip",
|
||||||
|
address,
|
||||||
|
pointIndex: index + 1,
|
||||||
|
name,
|
||||||
|
alias: name,
|
||||||
|
unit: "",
|
||||||
|
dataType: dpt || "group-address",
|
||||||
|
host: payload.host || "",
|
||||||
|
tableName: payload.tableName || "",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return datapoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryTcpConnect(host, portNumber, timeout = 1200) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!host || !portNumber) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = new net.Socket();
|
||||||
|
let settled = false;
|
||||||
|
const finish = (value) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
socket.destroy();
|
||||||
|
resolve(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.setTimeout(timeout);
|
||||||
|
socket.once("connect", () => finish(true));
|
||||||
|
socket.once("timeout", () => finish(false));
|
||||||
|
socket.once("error", () => finish(false));
|
||||||
|
socket.connect(portNumber, host);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
replyJson(res, 204, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && url.pathname === "/health") {
|
||||||
|
replyJson(res, 200, { ok: true, service: "collector", timestamp: new Date().toISOString() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && url.pathname === "/capabilities") {
|
||||||
|
replyJson(res, 200, {
|
||||||
|
protocols: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"],
|
||||||
|
imports: ["modbus-csv", "knx-xml"],
|
||||||
|
scans: ["opc-ua", "bacnet-ip"],
|
||||||
|
note: "Importe und erste Scan-Stubs sind aktiv. Poll-Intervall und Schreibmodus werden bereits pro Quelle gespeichert; die echte Live-Kommunikation folgt als nächste Ausbaustufe.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && url.pathname === "/sources") {
|
||||||
|
replyJson(res, 200, readStore());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && url.pathname === "/datapoints") {
|
||||||
|
const store = readStore();
|
||||||
|
const sourceId = url.searchParams.get("sourceId");
|
||||||
|
const items = sourceId ? store.datapoints.filter((item) => item.sourceId === sourceId) : store.datapoints;
|
||||||
|
replyJson(res, 200, { datapoints: items, total: items.length, updatedAt: store.updatedAt });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname === "/sources") {
|
||||||
|
try {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const next = touchStore((store) => {
|
||||||
|
const source = ensureSource(store, body);
|
||||||
|
return { ...store, lastSource: source.id };
|
||||||
|
});
|
||||||
|
replyJson(res, 201, next.sources.at(-1));
|
||||||
|
} catch {
|
||||||
|
replyJson(res, 400, { error: "Ungültige JSON-Daten." });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "PUT" && url.pathname.startsWith("/sources/")) {
|
||||||
|
try {
|
||||||
|
const sourceId = url.pathname.split("/").at(-1);
|
||||||
|
const body = await readBody(req);
|
||||||
|
const next = touchStore((store) => {
|
||||||
|
const existing = store.sources.find((item) => item.id === sourceId);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error("not-found");
|
||||||
|
}
|
||||||
|
ensureSource(store, { ...existing, ...body, id: sourceId });
|
||||||
|
return store;
|
||||||
|
});
|
||||||
|
replyJson(res, 200, next.sources.find((item) => item.id === sourceId));
|
||||||
|
} catch (error) {
|
||||||
|
replyJson(res, error.message === "not-found" ? 404 : 400, {
|
||||||
|
error: error.message === "not-found" ? "Quelle nicht gefunden." : "Ungültige JSON-Daten.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "DELETE" && url.pathname.startsWith("/sources/")) {
|
||||||
|
const sourceId = url.pathname.split("/").at(-1);
|
||||||
|
const next = touchStore((store) => ({
|
||||||
|
...store,
|
||||||
|
sources: store.sources.filter((item) => item.id !== sourceId),
|
||||||
|
datapoints: store.datapoints.filter((item) => item.sourceId !== sourceId),
|
||||||
|
}));
|
||||||
|
replyJson(res, 200, { ok: true, sources: next.sources.length, datapoints: next.datapoints.length });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname === "/imports/modbus-csv") {
|
||||||
|
try {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const imported = parseModbusCsv(body);
|
||||||
|
const next = touchStore((store) => {
|
||||||
|
if (body.sourceId) {
|
||||||
|
store.datapoints = store.datapoints.filter((item) => !(item.sourceId === body.sourceId && item.protocol === "modbus-tcp"));
|
||||||
|
}
|
||||||
|
store.datapoints.push(...imported);
|
||||||
|
return store;
|
||||||
|
});
|
||||||
|
replyJson(res, 200, { imported: imported.length, datapoints: imported, updatedAt: next.updatedAt });
|
||||||
|
} catch {
|
||||||
|
replyJson(res, 400, { error: "CSV-Import fehlgeschlagen." });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname === "/imports/knx-xml") {
|
||||||
|
try {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const imported = parseKnxXml(body);
|
||||||
|
const next = touchStore((store) => {
|
||||||
|
if (body.sourceId) {
|
||||||
|
store.datapoints = store.datapoints.filter((item) => !(item.sourceId === body.sourceId && item.protocol === "knx-ip"));
|
||||||
|
}
|
||||||
|
store.datapoints.push(...imported);
|
||||||
|
return store;
|
||||||
|
});
|
||||||
|
replyJson(res, 200, { imported: imported.length, datapoints: imported, updatedAt: next.updatedAt });
|
||||||
|
} catch {
|
||||||
|
replyJson(res, 400, { error: "KNX-XML-Import fehlgeschlagen." });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname === "/scan/opcua") {
|
||||||
|
try {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const host = body.host || "";
|
||||||
|
const portNumber = Number(body.port || 4840);
|
||||||
|
const reachable = await tryTcpConnect(host, portNumber);
|
||||||
|
const result = {
|
||||||
|
host,
|
||||||
|
port: portNumber,
|
||||||
|
reachable,
|
||||||
|
endpoints: reachable
|
||||||
|
? [
|
||||||
|
{ url: `opc.tcp://${host}:${portNumber}`, securityMode: "None", securityPolicy: "None" },
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
sampleNodes: reachable
|
||||||
|
? [
|
||||||
|
{ nodeId: "ns=0;i=2258", browseName: "Server", displayName: "Server" },
|
||||||
|
{ nodeId: "ns=0;i=2267", browseName: "ServerStatus", displayName: "ServerStatus" },
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
scannedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
touchStore((store) => {
|
||||||
|
store.scans.opcua.unshift(result);
|
||||||
|
store.scans.opcua = store.scans.opcua.slice(0, 20);
|
||||||
|
return store;
|
||||||
|
});
|
||||||
|
replyJson(res, 200, result);
|
||||||
|
} catch {
|
||||||
|
replyJson(res, 400, { error: "OPC-UA-Scan fehlgeschlagen." });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname === "/scan/bacnet") {
|
||||||
|
try {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const hosts = Array.isArray(body.hosts) ? body.hosts : [body.host].filter(Boolean);
|
||||||
|
const results = [];
|
||||||
|
for (const host of hosts) {
|
||||||
|
const reachable = await tryTcpConnect(host, Number(body.port || 47808), 650);
|
||||||
|
results.push({
|
||||||
|
host,
|
||||||
|
port: Number(body.port || 47808),
|
||||||
|
reachable,
|
||||||
|
deviceId: body.deviceId || "",
|
||||||
|
name: reachable ? `BACnet Gerät ${host}` : "Nicht erreichbar",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const payload = { devices: results, scannedAt: new Date().toISOString() };
|
||||||
|
touchStore((store) => {
|
||||||
|
store.scans.bacnet.unshift(payload);
|
||||||
|
store.scans.bacnet = store.scans.bacnet.slice(0, 20);
|
||||||
|
return store;
|
||||||
|
});
|
||||||
|
replyJson(res, 200, payload);
|
||||||
|
} catch {
|
||||||
|
replyJson(res, 400, { error: "BACnet-Scan fehlgeschlagen." });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
replyJson(res, 404, { error: "Nicht gefunden." });
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`SE Local Trenddata collector listening on port ${port}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
mariadb:
|
||||||
|
volumes:
|
||||||
|
- /media/sd/seltd/mariadb:/var/lib/mysql
|
||||||
|
- ./docker/mariadb/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
|
||||||
|
redis:
|
||||||
|
volumes:
|
||||||
|
- /media/sd/seltd/redis:/data
|
||||||
|
- ./docker/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
|
||||||
|
|
||||||
|
collector:
|
||||||
|
volumes:
|
||||||
|
- /media/sd/seltd/collector:/app/data
|
||||||
|
|
||||||
|
dockhand:
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- /media/sd/seltd/dockhand:/app/data
|
||||||
+27
-4
@@ -4,7 +4,7 @@
|
|||||||
container_name: seltd-mariadb
|
container_name: seltd-mariadb
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MARIADB_ROOT_PASSWORD: root
|
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112}
|
||||||
MARIADB_DATABASE: wago
|
MARIADB_DATABASE: wago
|
||||||
MARIADB_USER: app
|
MARIADB_USER: app
|
||||||
MARIADB_PASSWORD: app
|
MARIADB_PASSWORD: app
|
||||||
@@ -46,14 +46,15 @@
|
|||||||
DB_PORT: 3306
|
DB_PORT: 3306
|
||||||
DB_NAME: wago
|
DB_NAME: wago
|
||||||
DB_USER: root
|
DB_USER: root
|
||||||
DB_PASSWORD: root
|
DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112}
|
||||||
|
DB_PASSWORD_FALLBACK: ${DB_PASSWORD_FALLBACK:-root}
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
JWT_SECRET: ${JWT_SECRET:-change-me}
|
JWT_SECRET: ${JWT_SECRET:-change-me}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-ChangeMe123!}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-ChangeMe123!}
|
||||||
ADMIN_NAME: ${ADMIN_NAME:-System Admin}
|
ADMIN_NAME: ${ADMIN_NAME:-System Admin}
|
||||||
CORS_ORIGIN: http://localhost:5173
|
CORS_ORIGIN: ${CORS_ORIGIN:-*}
|
||||||
ports:
|
ports:
|
||||||
- "18080:8080"
|
- "18080:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -68,12 +69,34 @@
|
|||||||
container_name: seltd-web
|
container_name: seltd-web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
VITE_API_URL: ${VITE_API_URL:-http://localhost:18080/api}
|
VITE_API_PORT: ${VITE_API_PORT:-18080}
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
|
|
||||||
|
collector:
|
||||||
|
build:
|
||||||
|
context: ./collector
|
||||||
|
container_name: seltd-collector
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18110:18110"
|
||||||
|
volumes:
|
||||||
|
- collector_data:/app/data
|
||||||
|
|
||||||
|
dockhand:
|
||||||
|
image: fnsys/dockhand:latest
|
||||||
|
container_name: dockhand
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- dockhand_data:/app/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mariadb_data:
|
mariadb_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
collector_data:
|
||||||
|
dockhand_data:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE DATABASE IF NOT EXISTS `wago` CHARACTER SET latin1 COLLATE latin1_swedish_ci;
|
CREATE DATABASE IF NOT EXISTS `wago` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
USE `wago`;
|
USE `wago`;
|
||||||
|
|
||||||
DELIMITER //
|
DELIMITER //
|
||||||
@@ -25,7 +25,7 @@ BEGIN
|
|||||||
'`userlevel` INT(1) NOT NULL',
|
'`userlevel` INT(1) NOT NULL',
|
||||||
value_columns,
|
value_columns,
|
||||||
', PRIMARY KEY (`id`)',
|
', PRIMARY KEY (`id`)',
|
||||||
') ENGINE=InnoDB DEFAULT CHARSET=latin1;'
|
') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;'
|
||||||
);
|
);
|
||||||
|
|
||||||
PREPARE stmt FROM stmt_sql;
|
PREPARE stmt FROM stmt_sql;
|
||||||
|
|||||||
+3
-2
@@ -9,13 +9,14 @@
|
|||||||
"preview": "vite preview --host 0.0.0.0 --port 4173"
|
"preview": "vite preview --host 0.0.0.0 --port 4173"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"jspdf": "^2.5.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"recharts": "^2.15.1"
|
"recharts": "^2.15.1",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1112
-182
File diff suppressed because it is too large
Load Diff
+68
-32
@@ -1,50 +1,86 @@
|
|||||||
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:18080/api";
|
function resolveServiceUrl(explicitEnv, fallbackPort, suffix = "") {
|
||||||
|
const explicit = String(explicitEnv || "").trim();
|
||||||
|
if (explicit) {
|
||||||
|
return explicit.replace(/\/$/, "") + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
if (typeof window !== "undefined") {
|
||||||
const response = await fetch(API_URL + path, {
|
const { protocol, hostname } = window.location;
|
||||||
headers: {
|
return `${protocol}//${hostname}:${fallbackPort}${suffix}`;
|
||||||
"Content-Type": "application/json",
|
}
|
||||||
...(options.token ? { Authorization: "Bearer " + options.token } : {}),
|
|
||||||
|
return `http://localhost:${fallbackPort}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_URL = resolveServiceUrl(import.meta.env.VITE_API_URL, import.meta.env.VITE_API_PORT || "18080", "/api");
|
||||||
|
const COLLECTOR_URL = resolveServiceUrl(import.meta.env.VITE_COLLECTOR_URL, import.meta.env.VITE_COLLECTOR_PORT || "18110");
|
||||||
|
|
||||||
|
async function request(baseUrl, path, options = {}) {
|
||||||
|
const headers = {
|
||||||
|
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
const isJsonBody = options.body !== undefined && !(options.body instanceof FormData);
|
||||||
|
if (isJsonBody) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(baseUrl + path, {
|
||||||
|
headers,
|
||||||
method: options.method || "GET",
|
method: options.method || "GET",
|
||||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
body: options.body === undefined ? undefined : isJsonBody ? JSON.stringify(options.body) : options.body,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error || "Request failed.");
|
throw new Error(data.error || "Anfrage fehlgeschlagen.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const apiRequest = (path, options) => request(API_URL, path, options);
|
||||||
|
const collectorRequest = (path, options) => request(COLLECTOR_URL, path, options);
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
login: (body) => request("/auth/login", { method: "POST", body }),
|
login: (body) => apiRequest("/auth/login", { method: "POST", body }),
|
||||||
me: (token) => request("/auth/me", { token }),
|
me: (token) => apiRequest("/auth/me", { token }),
|
||||||
health: () => request("/health"),
|
changePassword: (token, body) => apiRequest("/auth/change-password", { method: "POST", token, body }),
|
||||||
getIsps: (token) => request("/isps", { token }),
|
health: () => apiRequest("/health"),
|
||||||
getAliases: (token, isp) => request("/isps/" + isp + "/aliases", { token }),
|
getIsps: (token) => apiRequest("/isps", { token }),
|
||||||
saveAliases: (token, isp, body) =>
|
getAliases: (token, isp) => apiRequest(`/isps/${isp}/aliases`, { token }),
|
||||||
request("/isps/" + isp + "/aliases", { method: "PUT", token, body }),
|
saveAliases: (token, isp, body) => apiRequest(`/isps/${isp}/aliases`, { method: "PUT", token, body }),
|
||||||
importSql: (token, body) => request("/admin/import-sql", { method: "POST", token, body }),
|
createTrendTable: (token, body) => apiRequest("/trend-tables", { method: "POST", token, body }),
|
||||||
getUsers: (token) => request("/users", { token }),
|
normalizeAliasMetadata: (token, body) => apiRequest("/admin/normalize-alias-metadata", { method: "POST", token, body }),
|
||||||
createUser: (token, body) => request("/users", { method: "POST", token, body }),
|
importSql: (token, body) => apiRequest("/admin/import-sql", { method: "POST", token, body }),
|
||||||
updateUser: (token, id, body) => request("/users/" + id, { method: "PATCH", token, body }),
|
getUsers: (token) => apiRequest("/users", { token }),
|
||||||
getSelections: (token) => request("/selections", { token }),
|
createUser: (token, body) => apiRequest("/users", { method: "POST", token, body }),
|
||||||
createSelection: (token, body) => request("/selections", { method: "POST", token, body }),
|
updateUser: (token, id, body) => apiRequest(`/users/${id}`, { method: "PATCH", token, body }),
|
||||||
updateSelection: (token, id, body) => request("/selections/" + id, { method: "PUT", token, body }),
|
getSelections: (token) => apiRequest("/selections", { token }),
|
||||||
deleteSelection: (token, id) => request("/selections/" + id, { method: "DELETE", token }),
|
createSelection: (token, body) => apiRequest("/selections", { method: "POST", token, body }),
|
||||||
queryTrends: (token, body) => request("/trends/query", { method: "POST", token, body }),
|
updateSelection: (token, id, body) => apiRequest(`/selections/${id}`, { method: "PUT", token, body }),
|
||||||
latestValues: (token, body) => request("/points/latest", { method: "POST", token, body }),
|
deleteSelection: (token, id) => apiRequest(`/selections/${id}`, { method: "DELETE", token }),
|
||||||
getDashboard: (token) => request("/dashboard", { token }),
|
queryTrends: (token, body) => apiRequest("/trends/query", { method: "POST", token, body }),
|
||||||
saveDashboard: (token, body) => request("/dashboard", { method: "PUT", token, body }),
|
latestValues: (token, body) => apiRequest("/points/latest", { method: "POST", token, body }),
|
||||||
loadDashboardData: (token, body) => request("/dashboard/data", { method: "POST", token, body }),
|
getDashboard: (token) => apiRequest("/dashboard", { token }),
|
||||||
getPreferences: (token) => request("/preferences", { token }),
|
saveDashboard: (token, body) => apiRequest("/dashboard", { method: "PUT", token, body }),
|
||||||
savePreferences: (token, body) => request("/preferences", { method: "PUT", token, body }),
|
loadDashboardData: (token, body) => apiRequest("/dashboard/data", { method: "POST", token, body }),
|
||||||
|
getPreferences: (token) => apiRequest("/preferences", { token }),
|
||||||
|
savePreferences: (token, body) => apiRequest("/preferences", { method: "PUT", token, body }),
|
||||||
|
getCollectorCapabilities: () => collectorRequest("/capabilities"),
|
||||||
|
getCollectorSources: () => collectorRequest("/sources"),
|
||||||
|
getCollectorDatapoints: (sourceId = "") => collectorRequest(sourceId ? `/datapoints?sourceId=${encodeURIComponent(sourceId)}` : "/datapoints"),
|
||||||
|
createCollectorSource: (body) => collectorRequest("/sources", { method: "POST", body }),
|
||||||
|
updateCollectorSource: (id, body) => collectorRequest(`/sources/${id}`, { method: "PUT", body }),
|
||||||
|
deleteCollectorSource: (id) => collectorRequest(`/sources/${id}`, { method: "DELETE" }),
|
||||||
|
importModbusCsv: (body) => collectorRequest("/imports/modbus-csv", { method: "POST", body }),
|
||||||
|
importKnxXml: (body) => collectorRequest("/imports/knx-xml", { method: "POST", body }),
|
||||||
|
scanOpcua: (body) => collectorRequest("/scan/opcua", { method: "POST", body }),
|
||||||
|
scanBacnet: (body) => collectorRequest("/scan/bacnet", { method: "POST", body }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export default function Gauge({ value, min = 0, max = 100, label, unit }) {
|
export default function Gauge({ value, min = 0, max = 100, label, unit, timestamp }) {
|
||||||
const safeMin = Number.isFinite(Number(min)) ? Number(min) : 0;
|
const safeMin = Number.isFinite(Number(min)) ? Number(min) : 0;
|
||||||
const safeMax = Number.isFinite(Number(max)) ? Number(max) : 100;
|
const safeMax = Number.isFinite(Number(max)) ? Number(max) : 100;
|
||||||
const safeValue = Number.isFinite(Number(value)) ? Number(value) : safeMin;
|
const safeValue = Number.isFinite(Number(value)) ? Number(value) : safeMin;
|
||||||
@@ -9,6 +9,7 @@ export default function Gauge({ value, min = 0, max = 100, label, unit }) {
|
|||||||
const cy = 110;
|
const cy = 110;
|
||||||
const x = cx + Math.cos(radians) * 72;
|
const x = cx + Math.cos(radians) * 72;
|
||||||
const y = cy + Math.sin(radians) * 72;
|
const y = cy + Math.sin(radians) * 72;
|
||||||
|
const timestampLabel = timestamp ? new Date(timestamp).toLocaleString("de-DE") : "Aktueller Wert";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="gauge-card">
|
<div className="gauge-card">
|
||||||
@@ -20,6 +21,7 @@ export default function Gauge({ value, min = 0, max = 100, label, unit }) {
|
|||||||
/>
|
/>
|
||||||
<circle cx="110" cy="110" r="7" className="gauge-center" />
|
<circle cx="110" cy="110" r="7" className="gauge-center" />
|
||||||
</svg>
|
</svg>
|
||||||
|
<div className="gauge-current">Aktuell</div>
|
||||||
<div className="gauge-value">
|
<div className="gauge-value">
|
||||||
<span>{Number.isFinite(safeValue) ? safeValue.toFixed(2) : "--"}</span>
|
<span>{Number.isFinite(safeValue) ? safeValue.toFixed(2) : "--"}</span>
|
||||||
<small>{unit}</small>
|
<small>{unit}</small>
|
||||||
@@ -29,7 +31,7 @@ export default function Gauge({ value, min = 0, max = 100, label, unit }) {
|
|||||||
<span>{safeMin}</span>
|
<span>{safeMin}</span>
|
||||||
<span>{safeMax}</span>
|
<span>{safeMax}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="gauge-stamp">{timestampLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +1,383 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { jsPDF } from "jspdf";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
import {
|
import {
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Legend,
|
|
||||||
Line,
|
Line,
|
||||||
LineChart,
|
LineChart,
|
||||||
|
ReferenceArea,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
const COLORS = ["#2563eb", "#dc2626", "#1d4ed8", "#b91c1c", "#3b82f6", "#ef4444"];
|
function downloadBlob(filename, blob) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TrendChart({ result, title = "Trendansicht" }) {
|
function quoteCell(value) {
|
||||||
const hostRef = useRef(null);
|
return `"${String(value ?? "").replaceAll('"', '""')}"`;
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
function buildRandomColor(index) {
|
||||||
const onChange = () => {
|
const hue = Math.round((Math.random() * 360 + index * 41) % 360);
|
||||||
setIsFullscreen(document.fullscreenElement === hostRef.current);
|
const saturation = 72 + (index % 3) * 4;
|
||||||
};
|
const lightness = 46 + (index % 4) * 3;
|
||||||
|
return `hsl(${hue} ${saturation}% ${lightness}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("fullscreenchange", onChange);
|
export default function TrendChart({
|
||||||
return () => document.removeEventListener("fullscreenchange", onChange);
|
result,
|
||||||
}, []);
|
title = "Trendansicht",
|
||||||
|
expanded = false,
|
||||||
|
onToggleExpanded,
|
||||||
|
csvDelimiter = ";",
|
||||||
|
onCsvDelimiterChange,
|
||||||
|
allowExports = true,
|
||||||
|
allowFocusMode = true,
|
||||||
|
compact = false,
|
||||||
|
}) {
|
||||||
|
const wrapperRef = useRef(null);
|
||||||
|
const chartHostRef = useRef(null);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState([]);
|
||||||
|
const [dragBox, setDragBox] = useState(null);
|
||||||
|
const [zoomDomain, setZoomDomain] = useState(null);
|
||||||
|
const [exportFormat, setExportFormat] = useState("csv");
|
||||||
|
const [transparentBackground, setTransparentBackground] = useState(false);
|
||||||
|
|
||||||
if (!result?.rows?.length) {
|
const series = useMemo(() => {
|
||||||
|
const fallbackColors = [];
|
||||||
|
return (result?.series || []).map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
color: item.color || (fallbackColors[index] ||= buildRandomColor(index)),
|
||||||
|
name: `${item.displayName} · ${item.alias}${item.unit ? ` (${item.unit})` : ""}`,
|
||||||
|
}));
|
||||||
|
}, [result]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => (result?.rows || []).map((row) => ({
|
||||||
|
...row,
|
||||||
|
timestampMs: new Date(row.timestamp).getTime(),
|
||||||
|
})), [result]);
|
||||||
|
|
||||||
|
const visibleSeries = selectedKeys.length ? series.filter((item) => selectedKeys.includes(item.key)) : series;
|
||||||
|
|
||||||
|
const baseXDomain = useMemo(() => {
|
||||||
|
if (!rows.length) {
|
||||||
|
return [0, 1];
|
||||||
|
}
|
||||||
|
return [rows[0].timestampMs, rows[rows.length - 1].timestampMs];
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const baseYDomain = useMemo(() => {
|
||||||
|
const values = [];
|
||||||
|
rows.forEach((row) => {
|
||||||
|
visibleSeries.forEach((item) => {
|
||||||
|
const value = row[item.key];
|
||||||
|
if (Number.isFinite(value)) {
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!values.length) {
|
||||||
|
return [0, 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
if (min === max) {
|
||||||
|
return [min - 1, max + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const padding = (max - min) * 0.08;
|
||||||
|
return [min - padding, max + padding];
|
||||||
|
}, [rows, visibleSeries]);
|
||||||
|
|
||||||
|
const xDomain = zoomDomain?.x || baseXDomain;
|
||||||
|
const yDomain = zoomDomain?.y || baseYDomain;
|
||||||
|
const hasFocusSelection = selectedKeys.length > 0 && selectedKeys.length < series.length;
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
return (
|
return (
|
||||||
<div className="panel chart-panel empty-state">
|
<div className={compact ? "panel chart-panel compact-chart empty-state" : "panel chart-panel empty-state"}>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
<p>Keine Daten im gewaehlten Zeitraum.</p>
|
<p>Keine Daten im gewählten Zeitraum.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = result.rows.map((row) => ({
|
const mapDragToDomain = (clientX, clientY) => {
|
||||||
...row,
|
const rect = wrapperRef.current?.getBoundingClientRect();
|
||||||
timestampLabel: new Date(row.timestamp).toLocaleString("de-DE"),
|
if (!rect) {
|
||||||
}));
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const toggleFullscreen = async () => {
|
const xRatio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||||
if (!hostRef.current) {
|
const yRatio = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
|
||||||
|
const xValue = xDomain[0] + (xDomain[1] - xDomain[0]) * xRatio;
|
||||||
|
const yValue = yDomain[1] - (yDomain[1] - yDomain[0]) * yRatio;
|
||||||
|
|
||||||
|
return {
|
||||||
|
xValue,
|
||||||
|
yValue,
|
||||||
|
xPixel: clientX - rect.left,
|
||||||
|
yPixel: clientY - rect.top,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (event) => {
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mapped = mapDragToDomain(event.clientX, event.clientY);
|
||||||
|
if (!mapped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDragBox({ start: mapped, end: mapped });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (event) => {
|
||||||
|
if (!dragBox) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mapped = mapDragToDomain(event.clientX, event.clientY);
|
||||||
|
if (!mapped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDragBox((current) => current ? { ...current, end: mapped } : current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (!dragBox) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.fullscreenElement === hostRef.current) {
|
const deltaX = Math.abs(dragBox.end.xPixel - dragBox.start.xPixel);
|
||||||
await document.exitFullscreen();
|
const deltaY = Math.abs(dragBox.end.yPixel - dragBox.start.yPixel);
|
||||||
|
if (deltaX < 12 || deltaY < 12) {
|
||||||
|
setDragBox(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await hostRef.current.requestFullscreen();
|
setZoomDomain({
|
||||||
|
x: [Math.min(dragBox.start.xValue, dragBox.end.xValue), Math.max(dragBox.start.xValue, dragBox.end.xValue)],
|
||||||
|
y: [Math.min(dragBox.start.yValue, dragBox.end.yValue), Math.max(dragBox.start.yValue, dragBox.end.yValue)],
|
||||||
|
});
|
||||||
|
setDragBox(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetZoom = () => {
|
||||||
|
setZoomDomain(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFocus = () => {
|
||||||
|
setSelectedKeys([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLegendClick = (event, key) => {
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
setSelectedKeys((current) => current.includes(key) ? current.filter((item) => item !== key) : current.concat(key));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedKeys((current) => current.length === 1 && current[0] === key ? [] : [key]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTabularRows = () => rows.map((row) => {
|
||||||
|
const item = {
|
||||||
|
Zeitstempel: new Date(row.timestampMs).toLocaleString("de-DE"),
|
||||||
|
ISO: new Date(row.timestampMs).toISOString(),
|
||||||
|
};
|
||||||
|
series.forEach((entry) => {
|
||||||
|
item[entry.name] = row[entry.key] ?? "";
|
||||||
|
});
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportCsv = () => {
|
||||||
|
const headers = ["timestamp"].concat(series.map((entry) => entry.name));
|
||||||
|
const lines = [headers]
|
||||||
|
.concat(rows.map((row) => [
|
||||||
|
new Date(row.timestampMs).toISOString(),
|
||||||
|
...series.map((entry) => row[entry.key] ?? ""),
|
||||||
|
]))
|
||||||
|
.map((line) => line.map((cell) => quoteCell(cell)).join(csvDelimiter));
|
||||||
|
downloadBlob("trend-export.csv", new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8;" }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportXlsx = () => {
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(buildTabularRows());
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, "Trend");
|
||||||
|
XLSX.writeFile(workbook, "trend-export.xlsx");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSvgMarkup = (transparent = false) => {
|
||||||
|
const svg = chartHostRef.current?.querySelector("svg");
|
||||||
|
if (!svg) {
|
||||||
|
throw new Error("SVG für Export nicht gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const clone = svg.cloneNode(true);
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const width = Math.max(900, Math.round(rect.width));
|
||||||
|
const height = Math.max(360, Math.round(rect.height));
|
||||||
|
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||||
|
clone.setAttribute("width", String(width));
|
||||||
|
clone.setAttribute("height", String(height));
|
||||||
|
clone.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||||
|
|
||||||
|
if (!transparent) {
|
||||||
|
const background = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||||
|
background.setAttribute("x", "0");
|
||||||
|
background.setAttribute("y", "0");
|
||||||
|
background.setAttribute("width", String(width));
|
||||||
|
background.setAttribute("height", String(height));
|
||||||
|
background.setAttribute("fill", "#ffffff");
|
||||||
|
clone.insertBefore(background, clone.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new XMLSerializer().serializeToString(clone);
|
||||||
|
};
|
||||||
|
|
||||||
|
const svgToImage = async (type = "png", transparent = false) => {
|
||||||
|
const svgMarkup = getSvgMarkup(transparent && type === "png");
|
||||||
|
const svgBlob = new Blob([svgMarkup], { type: "image/svg+xml;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(svgBlob);
|
||||||
|
const image = new Image();
|
||||||
|
const rect = chartHostRef.current?.getBoundingClientRect();
|
||||||
|
const width = Math.max(1200, Math.round(rect?.width || 1200));
|
||||||
|
const height = Math.max(expanded ? 720 : 420, Math.round(rect?.height || 420));
|
||||||
|
|
||||||
|
const dataUrl = await new Promise((resolve, reject) => {
|
||||||
|
image.onload = () => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!transparent) {
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.fillRect(0, 0, width, height);
|
||||||
|
}
|
||||||
|
context.drawImage(image, 0, 0, width, height);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(canvas.toDataURL(type === "jpg" ? "image/jpeg" : "image/png", 0.95));
|
||||||
|
};
|
||||||
|
image.onerror = reject;
|
||||||
|
image.src = url;
|
||||||
|
});
|
||||||
|
|
||||||
|
return dataUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportGraphic = async () => {
|
||||||
|
if (exportFormat === "svg") {
|
||||||
|
downloadBlob("trend-export.svg", new Blob([getSvgMarkup(transparentBackground)], { type: "image/svg+xml;charset=utf-8" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exportFormat === "png" || exportFormat === "jpg") {
|
||||||
|
const dataUrl = await svgToImage(exportFormat, exportFormat === "png" ? transparentBackground : false);
|
||||||
|
const response = await fetch(dataUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
downloadBlob(`trend-export.${exportFormat}`, blob);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exportFormat === "pdf") {
|
||||||
|
const dataUrl = await svgToImage("png", false);
|
||||||
|
const image = new Image();
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
image.onload = resolve;
|
||||||
|
image.src = dataUrl;
|
||||||
|
});
|
||||||
|
const pdf = new jsPDF({ orientation: "landscape", unit: "pt", format: [image.width, image.height] });
|
||||||
|
pdf.addImage(dataUrl, "PNG", 0, 0, image.width, image.height);
|
||||||
|
pdf.save("trend-export.pdf");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (exportFormat === "csv") {
|
||||||
|
exportCsv();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (exportFormat === "xlsx") {
|
||||||
|
exportXlsx();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await exportGraphic();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={hostRef} className="panel chart-panel">
|
<div className={expanded ? "panel chart-panel focus-mode" : compact ? "panel chart-panel compact-chart" : "panel chart-panel"}>
|
||||||
<div className="panel-header">
|
<div className="panel-header compact-header chart-toolbar">
|
||||||
|
<div>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
<button className="ghost-button" type="button" onClick={toggleFullscreen}>
|
<p>Ziehen mit gedrückter Maustaste zoomt Zeit und Wertebereich gemeinsam.</p>
|
||||||
{isFullscreen ? "Zurueck" : "Vollbild"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-wrapper">
|
<div className="chart-actions">
|
||||||
<ResponsiveContainer width="100%" height={isFullscreen ? 520 : 320}>
|
{allowExports ? (
|
||||||
<LineChart data={rows}>
|
<>
|
||||||
|
<select value={exportFormat} onChange={(event) => setExportFormat(event.target.value)}>
|
||||||
|
<option value="csv">CSV</option>
|
||||||
|
<option value="xlsx">XLSX</option>
|
||||||
|
<option value="png">PNG</option>
|
||||||
|
<option value="jpg">JPG</option>
|
||||||
|
<option value="svg">SVG</option>
|
||||||
|
<option value="pdf">PDF</option>
|
||||||
|
</select>
|
||||||
|
{exportFormat === "csv" ? (
|
||||||
|
<select value={csvDelimiter} onChange={(event) => onCsvDelimiterChange?.(event.target.value)}>
|
||||||
|
<option value=";">; Trennzeichen</option>
|
||||||
|
<option value=",">, Trennzeichen</option>
|
||||||
|
<option value="\t">Tab Trennzeichen</option>
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
{(exportFormat === "png" || exportFormat === "svg") ? (
|
||||||
|
<label className="chart-check">
|
||||||
|
<input type="checkbox" checked={transparentBackground} onChange={(event) => setTransparentBackground(event.target.checked)} />
|
||||||
|
<span>Transparent</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<button className="ghost-button" type="button" onClick={handleExport}>Export</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{zoomDomain ? <button className="ghost-button" type="button" onClick={resetZoom}>Zoom zurück</button> : null}
|
||||||
|
{allowFocusMode ? <button className="ghost-button" type="button" onClick={onToggleExpanded}>{expanded ? "Normalansicht" : "Fokus"}</button> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className={dragBox ? "chart-wrapper dragging" : "chart-wrapper"}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
>
|
||||||
|
<div ref={chartHostRef} className="chart-host">
|
||||||
|
<ResponsiveContainer width="100%" height={expanded ? 680 : compact ? 280 : 360}>
|
||||||
|
<LineChart data={rows} margin={{ top: 8, right: 18, left: 0, bottom: 8 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--grid)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--grid)" />
|
||||||
<XAxis dataKey="timestampLabel" minTickGap={30} stroke="var(--muted)" />
|
<XAxis
|
||||||
<YAxis stroke="var(--muted)" />
|
dataKey="timestampMs"
|
||||||
|
type="number"
|
||||||
|
scale="time"
|
||||||
|
domain={xDomain}
|
||||||
|
tickFormatter={(value) => new Date(value).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
|
||||||
|
minTickGap={28}
|
||||||
|
stroke="var(--muted)"
|
||||||
|
/>
|
||||||
|
<YAxis stroke="var(--muted)" domain={yDomain} width={52} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
labelFormatter={(value) => new Date(value).toLocaleString("de-DE")}
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
background: "var(--surface-strong)",
|
background: "var(--surface-strong)",
|
||||||
border: "1px solid var(--border-strong)",
|
border: "1px solid var(--border-strong)",
|
||||||
@@ -74,21 +385,62 @@ export default function TrendChart({ result, title = "Trendansicht" }) {
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Legend />
|
{dragBox ? (
|
||||||
{result.series.map((series, index) => (
|
<ReferenceArea
|
||||||
<Line
|
x1={Math.min(dragBox.start.xValue, dragBox.end.xValue)}
|
||||||
key={series.key}
|
x2={Math.max(dragBox.start.xValue, dragBox.end.xValue)}
|
||||||
type="monotone"
|
y1={Math.min(dragBox.start.yValue, dragBox.end.yValue)}
|
||||||
dataKey={series.key}
|
y2={Math.max(dragBox.start.yValue, dragBox.end.yValue)}
|
||||||
dot={false}
|
strokeOpacity={0.5}
|
||||||
strokeWidth={1.75}
|
fillOpacity={0.12}
|
||||||
stroke={COLORS[index % COLORS.length]}
|
fill="var(--brand-2)"
|
||||||
name={series.displayName + " / " + series.alias + (series.unit ? " (" + series.unit + ")" : "")}
|
|
||||||
/>
|
/>
|
||||||
))}
|
) : null}
|
||||||
|
{series.map((entry) => {
|
||||||
|
const isolated = selectedKeys.length > 0 && !selectedKeys.includes(entry.key);
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
key={entry.key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={entry.key}
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={selectedKeys.includes(entry.key) ? 2.35 : 1.8}
|
||||||
|
stroke={entry.color}
|
||||||
|
strokeOpacity={isolated ? 0.1 : 1}
|
||||||
|
isAnimationActive={false}
|
||||||
|
name={entry.name}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="legend-row">
|
||||||
|
<div className="legend-grid">
|
||||||
|
{series.map((entry) => {
|
||||||
|
const dimmed = selectedKeys.length > 0 && !selectedKeys.includes(entry.key);
|
||||||
|
const active = selectedKeys.includes(entry.key);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.key}
|
||||||
|
type="button"
|
||||||
|
className={active ? "legend-item active" : dimmed ? "legend-item dimmed" : "legend-item"}
|
||||||
|
onClick={(event) => handleLegendClick(event, entry.key)}
|
||||||
|
>
|
||||||
|
<span className="legend-swatch" style={{ background: entry.color }} />
|
||||||
|
<span>{entry.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{hasFocusSelection ? (
|
||||||
|
<div className="legend-side-actions">
|
||||||
|
<button className="ghost-button" type="button" onClick={clearFocus}>Alle Werte</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+521
-137
@@ -1,37 +1,43 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--bg: #f5f7fb;
|
--bg: #f4f6fb;
|
||||||
--bg-accent: linear-gradient(180deg, #f8fbff 0%, #eef3fb 100%);
|
--bg-accent: linear-gradient(180deg, #f8fbff 0%, #eef3fb 100%);
|
||||||
--surface: rgba(255, 255, 255, 0.92);
|
--surface: rgba(255, 255, 255, 0.96);
|
||||||
--surface-strong: #ffffff;
|
--surface-strong: #ffffff;
|
||||||
--text: #18212f;
|
--text: #18212f;
|
||||||
--muted: #6c7688;
|
--muted: #667085;
|
||||||
--border: rgba(37, 99, 235, 0.1);
|
--border: rgba(37, 99, 235, 0.1);
|
||||||
--border-strong: rgba(37, 99, 235, 0.18);
|
--border-strong: rgba(214, 40, 57, 0.24);
|
||||||
--brand: #2563eb;
|
--brand: #2563eb;
|
||||||
--brand-2: #dc2626;
|
--brand-2: #d62839;
|
||||||
--danger: #b91c1c;
|
--danger: #b91c1c;
|
||||||
--grid: rgba(37, 99, 235, 0.12);
|
--grid: rgba(214, 40, 57, 0.11);
|
||||||
--shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
|
--shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
|
||||||
--radius: 14px;
|
--radius: 14px;
|
||||||
|
--sidebar-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(249, 251, 255, 0.94) 100%);
|
||||||
|
--sidebar-border: rgba(37, 99, 235, 0.08);
|
||||||
|
--sidebar-accent: linear-gradient(180deg, var(--brand-2) 0%, #cf2d5c 55%, var(--brand) 100%);
|
||||||
font-family: "Segoe UI", Tahoma, sans-serif;
|
font-family: "Segoe UI", Tahoma, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--bg: #0f1724;
|
--bg: #0e1624;
|
||||||
--bg-accent: linear-gradient(180deg, #101827 0%, #0c1420 100%);
|
--bg-accent: linear-gradient(180deg, #101827 0%, #0b1320 100%);
|
||||||
--surface: rgba(14, 22, 36, 0.95);
|
--surface: rgba(16, 24, 38, 0.96);
|
||||||
--surface-strong: #121b2b;
|
--surface-strong: #121b2a;
|
||||||
--text: #edf2f7;
|
--text: #edf2f7;
|
||||||
--muted: #93a0b5;
|
--muted: #94a3b8;
|
||||||
--border: rgba(96, 165, 250, 0.12);
|
--border: rgba(96, 165, 250, 0.12);
|
||||||
--border-strong: rgba(96, 165, 250, 0.22);
|
--border-strong: rgba(248, 113, 113, 0.24);
|
||||||
--brand: #60a5fa;
|
--brand: #4f8df7;
|
||||||
--brand-2: #f87171;
|
--brand-2: #ef4444;
|
||||||
--danger: #f87171;
|
--danger: #f87171;
|
||||||
--grid: rgba(96, 165, 250, 0.16);
|
--grid: rgba(239, 68, 68, 0.15);
|
||||||
--shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
|
--shadow: 0 14px 36px rgba(0, 0, 0, 0.28);
|
||||||
|
--sidebar-bg: linear-gradient(180deg, rgba(15, 23, 38, 0.98) 0%, rgba(10, 16, 28, 0.96) 100%);
|
||||||
|
--sidebar-border: rgba(148, 163, 184, 0.12);
|
||||||
|
--sidebar-accent: linear-gradient(180deg, #ef4444 0%, #dc3b68 55%, #4f8df7 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -48,7 +54,7 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--bg-accent);
|
background: var(--bg-accent);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@@ -70,28 +76,36 @@ textarea {
|
|||||||
background: var(--surface-strong);
|
background: var(--surface-strong);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 0.55rem 0.7rem;
|
padding: 0.46rem 0.62rem;
|
||||||
|
min-height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus,
|
input:focus,
|
||||||
select:focus,
|
select:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--brand);
|
border-color: var(--brand-2);
|
||||||
|
box-shadow: 0 0 0 1px rgba(214, 40, 57, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.3rem;
|
gap: 0.28rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
small,
|
||||||
|
p,
|
||||||
|
span {
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
.login-screen {
|
.login-screen {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 1.25rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card,
|
.login-card,
|
||||||
@@ -103,33 +117,34 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
width: min(100%, 340px);
|
width: min(100%, 336px);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: 1rem;
|
padding: 0.95rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-lockup {
|
.logo-lockup {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.7rem;
|
gap: 0.62rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-lockup img {
|
.logo-lockup img {
|
||||||
width: 42px;
|
width: 34px;
|
||||||
height: 42px;
|
height: 34px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-title {
|
.logo-title {
|
||||||
font-size: 15px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
color: var(--brand-2);
|
color: var(--brand-2);
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
@@ -141,45 +156,173 @@ label {
|
|||||||
.point-item,
|
.point-item,
|
||||||
.selection-card,
|
.selection-card,
|
||||||
.chip,
|
.chip,
|
||||||
.icon-button {
|
.icon-button,
|
||||||
|
.legend-item {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: var(--surface-strong);
|
background: var(--surface-strong);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 0.5rem 0.7rem;
|
padding: 0.42rem 0.64rem;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.ghost-button,
|
||||||
|
.nav-button,
|
||||||
|
.theme-option,
|
||||||
|
.icon-button,
|
||||||
|
.legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button {
|
.primary-button {
|
||||||
background: linear-gradient(135deg, var(--brand), var(--brand-2));
|
background: linear-gradient(135deg, var(--brand-2) 0%, #b8275a 62%, var(--brand) 100%);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
color: white;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost-button,
|
.ghost-button,
|
||||||
.nav-button,
|
.nav-button,
|
||||||
.theme-option,
|
.theme-option,
|
||||||
.icon-button {
|
.icon-button,
|
||||||
|
.legend-item {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-button.active,
|
.nav-button.active,
|
||||||
.theme-option.active {
|
.theme-option.active,
|
||||||
border-color: var(--brand-2);
|
.legend-item.active {
|
||||||
|
border-color: rgba(214, 40, 57, 0.35);
|
||||||
color: var(--brand-2);
|
color: var(--brand-2);
|
||||||
|
background: rgba(214, 40, 57, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button.danger {
|
||||||
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 240px 1fr;
|
grid-template-columns: 188px 1fr;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-shell.trend-focus {
|
||||||
|
grid-template-columns: 72px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
padding: 0.9rem;
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 0.65rem 0.55rem 0.7rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
border-right: 1px solid var(--border);
|
width: 188px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
border-right: 1px solid var(--sidebar-border);
|
||||||
|
transition: width 160ms ease, padding 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: 5px;
|
||||||
|
background: var(--sidebar-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: 64px;
|
||||||
|
padding-inline: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-top {
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-brand-row {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .logo-lockup {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .logo-lockup img {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .nav-list {
|
||||||
|
width: 100%;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .nav-button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-top {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer,
|
||||||
|
.user-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand-row,
|
||||||
|
.panel-header,
|
||||||
|
.point-item,
|
||||||
|
.selection-card,
|
||||||
|
.user-row,
|
||||||
|
.stat-card,
|
||||||
|
.chart-toolbar,
|
||||||
|
.chart-actions,
|
||||||
|
.view-strip,
|
||||||
|
.alias-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand-row,
|
||||||
|
.panel-header,
|
||||||
|
.view-strip {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand-row {
|
||||||
|
padding: 0.12rem 0.08rem 0.52rem 0.15rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-list,
|
.nav-list,
|
||||||
@@ -188,57 +331,90 @@ label {
|
|||||||
.selection-chips,
|
.selection-chips,
|
||||||
.latest-grid,
|
.latest-grid,
|
||||||
.theme-toggle,
|
.theme-toggle,
|
||||||
.selection-actions,
|
.view-meta,
|
||||||
.topbar-actions {
|
.wrap-tight,
|
||||||
|
.chart-actions,
|
||||||
|
.legend-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.45rem;
|
gap: 0.38rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-list {
|
.nav-list {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-footer,
|
.nav-button {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0.38rem 0.58rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.user-card {
|
.user-card {
|
||||||
display: grid;
|
padding-top: 0.1rem;
|
||||||
gap: 0.45rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-card span,
|
.user-card span,
|
||||||
.panel p,
|
.panel p,
|
||||||
.stat-time,
|
|
||||||
.muted,
|
|
||||||
.point-item span,
|
.point-item span,
|
||||||
.selection-card span {
|
.selection-card span,
|
||||||
|
.muted,
|
||||||
|
.tiny-note {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.tiny-note {
|
||||||
padding: 0.9rem;
|
font-size: 10px;
|
||||||
display: grid;
|
text-align: center;
|
||||||
gap: 0.8rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.sidebar-footer {
|
||||||
display: flex;
|
align-self: end;
|
||||||
justify-content: space-between;
|
padding-top: 0.45rem;
|
||||||
gap: 0.7rem;
|
border-top: 1px solid var(--border);
|
||||||
align-items: center;
|
}
|
||||||
|
|
||||||
|
.sidebar-logout {
|
||||||
|
justify-self: start;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding-inline: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 0.55rem 0.65rem 0.7rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-content: start;
|
||||||
|
grid-auto-rows: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-strip {
|
||||||
|
padding: 0.02rem 0.08rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-badge,
|
||||||
|
.small-label,
|
||||||
|
.point-tag,
|
||||||
|
.alias-index {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--brand-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar h1,
|
.topbar h1,
|
||||||
.panel h3,
|
.panel h3,
|
||||||
.login-card h1 {
|
.login-card h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 17px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid.two,
|
.grid.two,
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.8rem;
|
gap: 0.6rem;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid.two {
|
.grid.two {
|
||||||
@@ -246,32 +422,47 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 0.8rem;
|
padding: 0.64rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.7rem;
|
gap: 0.58rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel > .primary-button,
|
||||||
display: flex;
|
.panel > .ghost-button {
|
||||||
justify-content: space-between;
|
justify-self: start;
|
||||||
gap: 0.7rem;
|
width: auto;
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header p {
|
.login-card > .primary-button {
|
||||||
margin: 0;
|
width: 100%;
|
||||||
|
justify-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0.36rem 0.68rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-header p,
|
||||||
|
.view-strip p {
|
||||||
|
margin: 0.08rem 0 0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid {
|
.form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 0.6rem;
|
gap: 0.52rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.span-two {
|
.span-two {
|
||||||
@@ -283,96 +474,187 @@ label {
|
|||||||
.user-list,
|
.user-list,
|
||||||
.selection-list {
|
.selection-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.45rem;
|
gap: 0.36rem;
|
||||||
max-height: 420px;
|
max-height: 420px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.point-item,
|
.selection-list.compact,
|
||||||
.selection-card,
|
.source-list,
|
||||||
.stack-card,
|
.datapoint-preview .selection-list {
|
||||||
.user-row,
|
max-height: none;
|
||||||
.stat-card,
|
overflow: visible;
|
||||||
.gauge-card {
|
|
||||||
background: var(--surface-strong);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.point-item,
|
.alias-table {
|
||||||
.selection-card,
|
max-height: 560px;
|
||||||
.user-row,
|
|
||||||
.stat-card {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.7rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack-card {
|
.stack-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
gap: 0.45rem;
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-card,
|
||||||
|
.point-item,
|
||||||
|
.user-row,
|
||||||
|
.stat-card,
|
||||||
|
.legend-item,
|
||||||
|
.chip {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-card.active {
|
||||||
|
border-color: rgba(214, 40, 57, 0.38);
|
||||||
|
background: rgba(214, 40, 57, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-card,
|
.selection-card,
|
||||||
.point-item,
|
.point-item,
|
||||||
.user-row,
|
.user-row,
|
||||||
.stat-card {
|
.stat-card {
|
||||||
padding: 0.55rem 0.65rem;
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0.58rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-card {
|
.point-copy,
|
||||||
text-align: left;
|
.user-copy {
|
||||||
}
|
display: grid;
|
||||||
|
gap: 0.06rem;
|
||||||
.point-tag,
|
|
||||||
.alias-index,
|
|
||||||
.small-label {
|
|
||||||
color: var(--brand-2);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-chips {
|
.selection-chips {
|
||||||
min-height: 42px;
|
min-height: 0;
|
||||||
|
gap: 0.34rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.15rem;
|
gap: 0.12rem;
|
||||||
text-align: left;
|
border-left: 3px solid var(--brand-2);
|
||||||
background: rgba(37, 99, 235, 0.06);
|
min-width: 94px;
|
||||||
|
padding: 0.34rem 0.42rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip strong,
|
||||||
|
.point-copy strong,
|
||||||
|
.selection-card strong,
|
||||||
|
.user-card strong,
|
||||||
|
.user-copy strong {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip span {
|
||||||
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-panel {
|
.chart-panel {
|
||||||
min-height: 390px;
|
min-height: 390px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-panel.focus-mode {
|
||||||
|
min-height: calc(100vh - 1.2rem);
|
||||||
|
}
|
||||||
|
|
||||||
.chart-wrapper {
|
.chart-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-panel:fullscreen {
|
.chart-wrapper.dragging {
|
||||||
padding: 1rem;
|
cursor: crosshair;
|
||||||
background: var(--surface-strong);
|
}
|
||||||
|
|
||||||
|
.chart-host {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-toolbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-actions {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.32rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-check input {
|
||||||
|
width: auto;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-grid {
|
||||||
|
gap: 0.34rem;
|
||||||
|
flex: 1 1 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
padding: 0.32rem 0.48rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item.dimmed {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-side-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.38rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.latest-grid {
|
.latest-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card,
|
||||||
border-radius: 12px;
|
.gauge-card,
|
||||||
|
.user-row {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
display: grid;
|
border-radius: 12px;
|
||||||
gap: 0.25rem;
|
}
|
||||||
|
|
||||||
|
.stat-card,
|
||||||
|
.latest-card {
|
||||||
|
border-left: 4px solid var(--brand-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 0.35rem;
|
gap: 0.32rem;
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,12 +663,17 @@ label {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.value-stamp {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
margin-top: 0.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
.gauge-card {
|
.gauge-card {
|
||||||
border: 1px solid var(--border);
|
padding: 0.65rem;
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0.7rem;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.25rem;
|
gap: 0.22rem;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +684,7 @@ label {
|
|||||||
|
|
||||||
.gauge-track {
|
.gauge-track {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: rgba(37, 99, 235, 0.18);
|
stroke: rgba(214, 40, 57, 0.16);
|
||||||
stroke-width: 12;
|
stroke-width: 12;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
}
|
}
|
||||||
@@ -412,43 +699,60 @@ label {
|
|||||||
fill: var(--brand);
|
fill: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gauge-current {
|
||||||
|
color: var(--brand-2);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.gauge-value {
|
.gauge-value {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gauge-range {
|
.gauge-range,
|
||||||
width: 100%;
|
.gauge-label,
|
||||||
display: flex;
|
.gauge-stamp {
|
||||||
justify-content: space-between;
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gauge-label {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alias-row {
|
.alias-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 64px 1.2fr 0.7fr 0.55fr 0.55fr;
|
grid-template-columns: 92px 1.55fr 0.8fr 0.65fr 0.65fr 0.8fr;
|
||||||
gap: 0.4rem;
|
gap: 0.58rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-row {
|
.alias-head {
|
||||||
border: 1px solid var(--border);
|
padding: 0 0.1rem;
|
||||||
border-radius: 10px;
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-field,
|
||||||
|
.color-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-field {
|
||||||
|
grid-template-columns: 38px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-field input[type="color"] {
|
||||||
|
padding: 0.12rem;
|
||||||
|
min-height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-box,
|
.error-box,
|
||||||
.success-box {
|
.success-box {
|
||||||
padding: 0.55rem 0.65rem;
|
padding: 0.5rem 0.62rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@@ -471,6 +775,66 @@ label {
|
|||||||
|
|
||||||
.checkbox-field input {
|
.checkbox-field input {
|
||||||
width: auto;
|
width: auto;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-button {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-button input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-subnav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.38rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-subnav .nav-button {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-panel,
|
||||||
|
.protocol-block,
|
||||||
|
.datapoint-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.52rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-block {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.56rem;
|
||||||
|
background: color-mix(in srgb, var(--surface-strong) 88%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 88px auto;
|
||||||
|
gap: 0.36rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-fields.multi-line {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 88px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
@@ -478,14 +842,34 @@ label {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.slim-empty {
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.app-shell {
|
.app-shell,
|
||||||
|
.app-shell.trend-focus {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar,
|
||||||
|
.sidebar.collapsed {
|
||||||
|
position: static;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.grid.two,
|
.grid.two,
|
||||||
.form-grid,
|
.form-grid,
|
||||||
.alias-row {
|
.alias-row,
|
||||||
|
.protocol-grid,
|
||||||
|
.inline-fields,
|
||||||
|
.inline-fields.multi-line {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user