Refactor database credentials and user permissions; update UI components for better usability and accessibility

- Changed default MariaDB password in docker-compose.yml from 'SE3112' to '3112'.
- Added user creation and permissions for 'wago' in the MariaDB initialization script.
- Updated unit options in App.jsx to include a digital/bool option.
- Enhanced LoginScreen component to support inline display and improved error handling.
- Improved point filtering logic in PointLibrary and adjusted default unit handling.
- Refined authentication checks for dashboard and admin permissions.
- Simplified API calls by removing unnecessary token checks.
- Enhanced TrendChart component with better data handling and formatting.
- Updated CSS styles for improved layout and responsiveness in trend views and sidebar.
This commit is contained in:
jhartworks
2026-07-01 11:58:42 +02:00
parent e34626f15b
commit 2b9ff12a9f
11 changed files with 412 additions and 279 deletions
+283 -225
View File
@@ -102,12 +102,13 @@ const pointColors = [
];
const DEFAULT_UNIT_OPTIONS = [
{ symbol: "\u00B0C", label: "Grad Celsius", isSystem: true },
{ symbol: "°C", label: "Grad Celsius", isSystem: true },
{ symbol: "%", label: "Prozent", isSystem: true },
{ symbol: "K", label: "Kelvin", isSystem: true },
{ symbol: "V", label: "Volt", isSystem: true },
{ symbol: "A", label: "Ampere", isSystem: true },
{ symbol: "Pa", label: "Pascal", isSystem: true },
{ symbol: "bool", label: "Digital / Bool", isSystem: true },
];
const UNIT_PRESETS = DEFAULT_UNIT_OPTIONS.map((unit) => unit.symbol);
const CUSTOM_UNIT_VALUE = "__custom__";
@@ -164,6 +165,10 @@ function getDefaultPointDataType(protocol) {
return "holding-register";
}
function getDefaultUnitForKind(kind) {
return kind === "digital" ? "bool" : "°C";
}
function getPointDataTypeOptions(protocol) {
if (protocol === "modbus-tcp") {
return MODBUS_DATA_TYPE_OPTIONS;
@@ -543,7 +548,7 @@ function IconButton({ title, onClick, children, danger = false }) {
);
}
function LoginScreen({ onLogin, error, busy }) {
function LoginScreen({ onLogin, error, busy, inline = false }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
@@ -553,7 +558,7 @@ function LoginScreen({ onLogin, error, busy }) {
};
return (
<div className="login-screen">
<div className={inline ? "login-screen inline-login" : "login-screen"}>
<form className="login-card" onSubmit={handleSubmit}>
<div className="logo-lockup">
<img src="/logo.png" alt="SE Logo" />
@@ -609,6 +614,7 @@ function PointLibrary({ metadata, filterText, onFilterTextChange, onAddPoint })
const search = filterText.trim().toLowerCase();
return getOrderedPointEntries(metadata)
.filter((point) => normalizeDisplayText(point.alias).trim().length > 0)
.filter((point) => {
if (!search) {
return true;
@@ -711,7 +717,7 @@ export default function App() {
const [isCreatingSource, setIsCreatingSource] = useState(false);
const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft);
const [sourceModalOpen, setSourceModalOpen] = useState(false);
const emptyManualDatapointDraft = (protocol = defaultSourceDraft.protocol) => ({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: getDefaultPointDataType(protocol), kind: "analog", scale: "1", writeMode: "", condition: "" });
const emptyManualDatapointDraft = (protocol = defaultSourceDraft.protocol, kind = "analog") => ({ alias: "", address: "", pointIndex: "", unit: getDefaultUnitForKind(kind), dataType: getDefaultPointDataType(protocol), kind, scale: "1", writeMode: "", condition: "" });
const [collectorPointFilter, setCollectorPointFilter] = useState("");
const [manualDatapointDraft, setManualDatapointDraft] = useState(emptyManualDatapointDraft());
const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null });
@@ -719,13 +725,14 @@ export default function App() {
const trendIsps = useMemo(() => isps.filter((isp) => Number(isp.rowCount) > 0), [isps]);
const selectedMetadata = selectedIsp ? metadataCache[selectedIsp] : null;
const canViewDashboard = hasPermission(user, "view_dashboard");
const canViewTrends = hasPermission(user, "view_trends");
const canEditAliases = hasPermission(user, "edit_aliases");
const canManageSources = hasPermission(user, "manage_sources");
const canManageUsers = hasPermission(user, "manage_users");
const canManageRoles = hasPermission(user, "manage_roles");
const canViewDebug = hasPermission(user, "view_debug");
const isAuthenticated = Boolean(token);
const canViewDashboard = !isAuthenticated || hasPermission(user, "view_dashboard");
const canViewTrends = !isAuthenticated || hasPermission(user, "view_trends");
const canEditAliases = isAuthenticated && hasPermission(user, "edit_aliases");
const canManageSources = isAuthenticated && hasPermission(user, "manage_sources");
const canManageUsers = isAuthenticated && hasPermission(user, "manage_users");
const canManageRoles = isAuthenticated && hasPermission(user, "manage_roles");
const canViewDebug = isAuthenticated && hasPermission(user, "view_debug");
const canAccessAdmin = canEditAliases || canManageSources || canManageUsers || canManageRoles || canViewDebug;
const firstAdminSection = canEditAliases ? "aliases" : canManageSources ? "sources" : canManageUsers ? "users" : canManageRoles ? "roles" : canViewDebug ? "debug" : "aliases";
const sidebarCollapsed = !sidebarPinned && !sidebarHover;
@@ -777,10 +784,7 @@ export default function App() {
};
const refreshIspCatalog = async () => {
if (!token) {
return [];
}
const result = await api.getIsps(token);
const result = await api.getIsps(token || undefined);
const nextIsps = result.isps || [];
setIsps(nextIsps);
return nextIsps;
@@ -843,18 +847,31 @@ export default function App() {
}, [activeView, adminSection]);
useEffect(() => {
if (!token) {
setSessionReady(true);
setPreferencesReady(false);
return;
}
let ignore = false;
async function bootstrap() {
setSessionReady(false);
resetFlash();
try {
if (!token) {
const [ispResult, dashboardResult] = await Promise.all([
api.getIsps(),
api.getDashboard(),
]);
if (ignore) {
return;
}
setUser(null);
setIsps(ispResult.isps || []);
setSelections([]);
setDashboard(dashboardResult);
setActiveView((current) => current === "admin" ? "dashboard" : current);
setPreferencesReady(false);
return;
}
const [me, pref, ispResult, selectionResult, dashboardResult] = await Promise.all([
api.me(token),
api.getPreferences(token),
@@ -879,10 +896,12 @@ export default function App() {
} catch (error) {
if (!ignore) {
setBootError(error.message);
setLoginError(error.message);
setToken("");
setUser(null);
localStorage.removeItem("seltd-token");
if (token) {
setLoginError(error.message);
setToken("");
setUser(null);
localStorage.removeItem("seltd-token");
}
}
} finally {
if (!ignore) {
@@ -952,11 +971,11 @@ export default function App() {
}, [csvDelimiter, preferencesReady, sidebarPinned, themeMode, token]);
useEffect(() => {
if (!token || !selectedIsp || metadataCache[selectedIsp]) {
if (!selectedIsp || metadataCache[selectedIsp]) {
return;
}
api.getAliases(token, selectedIsp)
api.getAliases(token || undefined, selectedIsp)
.then((result) => setMetadataCache((current) => ({ ...current, [selectedIsp]: result })))
.catch((error) => setBootError(error.message));
}, [metadataCache, selectedIsp, token]);
@@ -975,7 +994,7 @@ export default function App() {
}, [adminIsp, token]);
useEffect(() => {
if (!token || workingPoints.length === 0) {
if (workingPoints.length === 0) {
return;
}
@@ -984,7 +1003,7 @@ export default function App() {
return;
}
Promise.all(missingIsps.map((isp) => api.getAliases(token, isp)))
Promise.all(missingIsps.map((isp) => api.getAliases(token || undefined, isp)))
.then((results) => {
setMetadataCache((current) => {
const next = { ...current };
@@ -998,12 +1017,12 @@ export default function App() {
}, [metadataCache, token, workingPoints]);
useEffect(() => {
if (!token || dashboard.widgets.length === 0) {
if (dashboard.widgets.length === 0) {
setDashboardData({});
return;
}
api.loadDashboardData(token, { widgets: dashboard.widgets })
api.loadDashboardData(token || undefined, { widgets: dashboard.widgets })
.then((result) => setDashboardData(Object.fromEntries(result.widgets.map((widget) => [widget.id, widget]))))
.catch((error) => setBootError(error.message));
}, [dashboard, token]);
@@ -1045,11 +1064,6 @@ export default function App() {
}, [canEditAliases, canManageSources, token]);
useEffect(() => {
if (!token) {
return undefined;
}
let ignore = false;
const refreshIsps = () => {
refreshIspCatalog().catch(() => undefined);
};
@@ -1064,7 +1078,6 @@ export default function App() {
window.addEventListener("focus", refreshIsps);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
ignore = true;
window.clearInterval(timer);
window.removeEventListener("focus", refreshIsps);
document.removeEventListener("visibilitychange", handleVisibilityChange);
@@ -1106,6 +1119,7 @@ export default function App() {
setManualDatapointDraft((current) => ({
...current,
dataType: getDefaultPointDataType(currentSource.protocol || "modbus-tcp"),
unit: current.unit || getDefaultUnitForKind(current.kind || "analog"),
}));
}, [currentSource]);
@@ -1204,7 +1218,8 @@ export default function App() {
};
const addPoint = (pointIndex, isp = selectedIsp) => {
if (!isp || workingPoints.some((point) => point.isp === isp && point.pointIndex === pointIndex)) {
const pointMeta = metadataCache[isp]?.points?.[String(pointIndex)];
if (!isp || !normalizeDisplayText(pointMeta?.alias).trim() || workingPoints.some((point) => point.isp === isp && point.pointIndex === pointIndex)) {
return;
}
@@ -1900,7 +1915,7 @@ export default function App() {
}
if (passwordDraft.nextPassword !== passwordDraft.repeatPassword) {
setBootError("Die neuen Passw?rter stimmen nicht ?berein.");
setBootError("Die neuen Passwörter stimmen nicht überein.");
return;
}
@@ -1910,16 +1925,12 @@ export default function App() {
nextPassword: passwordDraft.nextPassword,
});
setPasswordDraft({ currentPassword: "", nextPassword: "", repeatPassword: "" });
setSuccessMessage("Passwort ge?ndert.");
setSuccessMessage("Passwort geändert.");
} catch (error) {
setBootError(error.message);
}
};
if (!token) {
return <LoginScreen onLogin={handleLogin} error={loginError} busy={busy} />;
}
if (!sessionReady) {
return <div className="login-screen"><div className="panel">Sitzung wird geladen ...</div></div>;
}
@@ -1942,7 +1953,7 @@ export default function App() {
</div>
) : null}
</div>
<IconButton title={sidebarPinned ? "Seitenleiste l?sen" : "Seitenleiste anheften"} onClick={() => setSidebarPinned((current) => !current)}>
<IconButton title={sidebarPinned ? "Seitenleiste lösen" : "Seitenleiste anheften"} onClick={() => setSidebarPinned((current) => !current)}>
<PinIcon pinned={sidebarPinned} />
</IconButton>
</div>
@@ -1958,6 +1969,9 @@ export default function App() {
if (view.id === "admin") {
return canAccessAdmin;
}
if (view.id === "settings") {
return isAuthenticated;
}
return true;
}).map((view) => (
<button
@@ -1974,14 +1988,21 @@ export default function App() {
{!sidebarCollapsed ? (
<div className="sidebar-footer">
<div className="user-card">
<strong>{getUserDisplayName(user)}</strong>
<span>{user?.role === "admin" ? "Systemzugang" : user?.role === "technician" ? "Technikzugang" : "Benutzerzugang"}</span>
</div>
<button className="ghost-button compact-button sidebar-logout" type="button" onClick={handleLogout}>Abmelden</button>
{isAuthenticated ? (
<>
<div className="user-card">
<strong>{getUserDisplayName(user)}</strong>
<span>{user?.role === "admin" ? "Systemzugang" : user?.role === "technician" ? "Technikzugang" : "Benutzerzugang"}</span>
</div>
<button className="ghost-button compact-button sidebar-logout" type="button" onClick={handleLogout}>Abmelden</button>
</>
) : (
<button className="ghost-button compact-button sidebar-login" type="button" onClick={() => setActiveView("settings")}>Anmelden</button>
)}
<SmallFootnote />
</div>
) : null}
</aside>
<main className="content">
@@ -2147,118 +2168,157 @@ export default function App() {
{activeView === "trends" ? (
<>
{!trendFocus ? (
<section className="grid two compact-grid">
<div className="panel slim-panel">
<div className="panel-header compact-header">
<div>
<h3>Trendquelle</h3>
<p>Im Dropdown erscheinen nur Tabellen, die bereits Daten enthalten.</p>
<section className="trend-layout">
<div className="trend-main">
{trendResult ? (
<TrendChart
result={trendResult}
title="Trendansicht"
expanded={trendFocus}
onToggleExpanded={() => setTrendFocus((current) => !current)}
csvDelimiter={csvDelimiter}
onCsvDelimiterChange={setCsvDelimiter}
allowExports
allowFocusMode
/>
) : (
<div className="panel empty-state slim-empty">
<h3>Trendansicht</h3>
<p>Nach dem Laden erscheinen hier Graph, Exporte und Vollbildansicht.</p>
</div>
</div>
<label>
<span>ISP</span>
<select value={selectedIsp} onChange={(event) => setSelectedIsp(event.target.value)}>
{trendIsps.map((isp) => <option key={isp.tableName} value={isp.tableName}>{normalizeDisplayText(isp.displayName, isp.tableName)}</option>)}
</select>
</label>
<PointLibrary metadata={selectedMetadata} filterText={pointFilter} onFilterTextChange={setPointFilter} onAddPoint={addPoint} />
)}
{latestValues.length ? (
<section className="latest-grid">
{latestValues.map((value) => (
<div key={value.key} className="stat-card latest-card" style={{ borderLeftColor: resolveLatestColor(value) }}>
<div>
<small>{normalizeDisplayText(value.displayName)}</small>
<strong>{normalizeDisplayText(value.alias)}</strong>
<span className="value-stamp">{formatTimestampLabel(value.timestamp)}</span>
</div>
<div className="stat-value">{value.value !== null ? formatValue(value.value) : normalizeDisplayText(value.raw, "--")}<span>{normalizeDisplayUnit(value.unit)}</span></div>
</div>
))}
</section>
) : null}
</div>
<div className="panel slim-panel">
<div className="panel-header compact-header">
<div>
<h3>Aktuelle Punkte</h3>
<p>Mehrere ISPs können gemeinsam angezeigt und gespeichert werden.</p>
<div className="trend-side">
<div className="panel slim-panel">
<div className="panel-header compact-header">
<div>
<h3>Datenpunktauswahl</h3>
<p>Im Dropdown erscheinen nur Tabellen, die bereits Daten enthalten.</p>
</div>
</div>
<button className="ghost-button compact-button" type="button" onClick={startNewSelection}>Neue Auswahl</button>
</div>
<div className="selection-chips">
{workingPoints.map((point) => {
const meta = metadataCache[point.isp]?.points?.[String(point.pointIndex)];
const color = resolvePointColor(point, metadataCache);
return (
<button
key={`${point.isp}-${point.pointIndex}`}
type="button"
className="chip"
onClick={() => removePoint(point)}
style={{ borderLeftColor: color, background: `${color}12` }}
>
<strong>{normalizeDisplayText(metadataCache[point.isp]?.displayName, point.isp)}</strong>
<span>{normalizeDisplayText(meta?.alias, `Wert ${point.pointIndex}`)}</span>
</button>
);
})}
</div>
<div className="form-grid compact-forms">
<label>
<span>Zeitraum</span>
<select value={trendRange.presetDays} onChange={(event) => setTrendRange((current) => ({ ...current, presetDays: event.target.value }))}>
{TREND_PRESET_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
<span>ISP</span>
<select value={selectedIsp} onChange={(event) => setSelectedIsp(event.target.value)}>
{trendIsps.map((isp) => <option key={isp.tableName} value={isp.tableName}>{normalizeDisplayText(isp.displayName, isp.tableName)}</option>)}
</select>
</label>
<label>
<span>Max. Zeilen</span>
<input type="number" min="100" max="10000" value={trendRange.maxRows} onChange={(event) => setTrendRange((current) => ({ ...current, maxRows: event.target.value }))} />
</label>
<label>
<span>Start</span>
<input type="datetime-local" value={trendRange.from} onChange={(event) => setTrendRange((current) => ({ ...current, from: event.target.value }))} />
</label>
<label>
<span>Ende</span>
<input type="datetime-local" value={trendRange.to} onChange={(event) => setTrendRange((current) => ({ ...current, to: event.target.value }))} />
</label>
<PointLibrary metadata={selectedMetadata} filterText={pointFilter} onFilterTextChange={setPointFilter} onAddPoint={addPoint} />
</div>
<div className="button-row">
<button className="primary-button compact-button" type="button" onClick={runTrendQuery}>{busy ? "Lädt ..." : "Trend anzeigen"}</button>
<div className="panel slim-panel">
<div className="panel-header compact-header">
<div>
<h3>Aktuelle Punkte</h3>
<p>Mehrere ISPs können gemeinsam angezeigt werden.</p>
</div>
<button className="ghost-button compact-button" type="button" onClick={startNewSelection}>Neue Auswahl</button>
</div>
<div className="selection-chips">
{workingPoints.map((point) => {
const meta = metadataCache[point.isp]?.points?.[String(point.pointIndex)];
const color = resolvePointColor(point, metadataCache);
return (
<button
key={`${point.isp}-${point.pointIndex}`}
type="button"
className="chip"
onClick={() => removePoint(point)}
style={{ borderLeftColor: color, background: `${color}12` }}
>
<strong>{normalizeDisplayText(metadataCache[point.isp]?.displayName, point.isp)}</strong>
<span>{normalizeDisplayText(meta?.alias, `Wert ${point.pointIndex}`)}</span>
</button>
);
})}
</div>
<div className="form-grid compact-forms">
<label>
<span>Zeitraum</span>
<select value={trendRange.presetDays} onChange={(event) => setTrendRange((current) => ({ ...current, presetDays: event.target.value }))}>
{TREND_PRESET_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label>
<span>Max. Zeilen</span>
<input type="number" min="100" max="10000" value={trendRange.maxRows} onChange={(event) => setTrendRange((current) => ({ ...current, maxRows: event.target.value }))} />
</label>
<label>
<span>Start</span>
<input type="datetime-local" value={trendRange.from} onChange={(event) => setTrendRange((current) => ({ ...current, from: event.target.value }))} />
</label>
<label>
<span>Ende</span>
<input type="datetime-local" value={trendRange.to} onChange={(event) => setTrendRange((current) => ({ ...current, to: event.target.value }))} />
</label>
</div>
<div className="button-row">
<button className="primary-button compact-button" type="button" onClick={runTrendQuery}>{busy ? "Lädt ..." : "Trend anzeigen"}</button>
</div>
{isAuthenticated ? (
<>
<label>
<span>Gespeicherte Auswahl</span>
<select
value={selectedSelectionId}
onChange={(event) => {
if (!event.target.value) {
startNewSelection();
return;
}
const selection = selections.find((item) => item.id === event.target.value);
if (selection) {
loadSelection(selection);
}
}}
>
<option value="">Neue Auswahl</option>
{selections.map((selection) => <option key={selection.id} value={selection.id}>{selection.name}</option>)}
</select>
</label>
<div className="form-grid compact-forms">
<label>
<span>Name</span>
<input value={selectionDraft.name} onChange={(event) => setSelectionDraft((current) => ({ ...current, name: event.target.value }))} />
</label>
<label>
<span>Beschreibung</span>
<input value={selectionDraft.description} onChange={(event) => setSelectionDraft((current) => ({ ...current, description: event.target.value }))} />
</label>
</div>
<label className="checkbox-field">
<input type="checkbox" checked={selectionDraft.shared} onChange={(event) => setSelectionDraft((current) => ({ ...current, shared: event.target.checked }))} />
<span>Auswahl für andere Nutzer freigeben</span>
</label>
<button className="primary-button compact-button" type="button" onClick={saveCurrentSelection}>Auswahl speichern</button>
</>
) : null}
</div>
<label>
<span>Gespeicherte Auswahl</span>
<select
value={selectedSelectionId}
onChange={(event) => {
if (!event.target.value) {
startNewSelection();
return;
}
const selection = selections.find((item) => item.id === event.target.value);
if (selection) {
loadSelection(selection);
}
}}
>
<option value="">Neue Auswahl</option>
{selections.map((selection) => <option key={selection.id} value={selection.id}>{selection.name}</option>)}
</select>
</label>
<div className="form-grid compact-forms">
<label>
<span>Name</span>
<input value={selectionDraft.name} onChange={(event) => setSelectionDraft((current) => ({ ...current, name: event.target.value }))} />
</label>
<label>
<span>Beschreibung</span>
<input value={selectionDraft.description} onChange={(event) => setSelectionDraft((current) => ({ ...current, description: event.target.value }))} />
</label>
</div>
<label className="checkbox-field">
<input type="checkbox" checked={selectionDraft.shared} onChange={(event) => setSelectionDraft((current) => ({ ...current, shared: event.target.checked }))} />
<span>Auswahl für andere Nutzer freigeben</span>
</label>
<button className="primary-button compact-button" type="button" onClick={saveCurrentSelection}>Auswahl speichern</button>
</div>
</section>
) : null}
{trendResult ? (
) : trendResult ? (
<TrendChart
result={trendResult}
title="Trendansicht"
@@ -2272,24 +2332,9 @@ export default function App() {
) : (
<div className="panel empty-state slim-empty">
<h3>Trendansicht</h3>
<p>Nach dem Laden erscheinen hier Graph, Exporte und Fokusansicht.</p>
<p>Nach dem Laden erscheinen hier Graph, Exporte und Vollbildansicht.</p>
</div>
)}
{latestValues.length && !trendFocus ? (
<section className="latest-grid">
{latestValues.map((value) => (
<div key={value.key} className="stat-card latest-card" style={{ borderLeftColor: resolveLatestColor(value) }}>
<div>
<small>{normalizeDisplayText(value.displayName)}</small>
<strong>{normalizeDisplayText(value.alias)}</strong>
<span className="value-stamp">{formatTimestampLabel(value.timestamp)}</span>
</div>
<div className="stat-value">{value.value !== null ? formatValue(value.value) : normalizeDisplayText(value.raw, "--")}<span>{normalizeDisplayUnit(value.unit)}</span></div>
</div>
))}
</section>
) : null}
</>
) : null}
@@ -2380,7 +2425,7 @@ export default function App() {
<input value={point.alias} onChange={(event) => updateAliasPoint(String(point.pointIndex), { alias: event.target.value })} />
<UnitField units={availableUnits} value={point.unit} onChange={(nextValue) => updateAliasPoint(String(point.pointIndex), { unit: nextValue })} />
<ScaleField value={String(point.factor ?? "1")} onChange={(nextValue) => updateAliasPoint(String(point.pointIndex), { factor: nextValue })} />
<select value={point.kind} onChange={(event) => updateAliasPoint(String(point.pointIndex), { kind: event.target.value })}>
<select value={point.kind} onChange={(event) => updateAliasPoint(String(point.pointIndex), { kind: event.target.value, unit: point.unit && normalizeDisplayUnit(point.unit) !== "?C" ? point.unit : getDefaultUnitForKind(event.target.value) })}>
<option value="analog">analog</option>
<option value="digital">digital</option>
</select>
@@ -2501,7 +2546,7 @@ export default function App() {
<button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); openSourceEditor(source.id); }}>Bearbeiten</button>
{source.protocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runOpcuaScan(); }}>OPC scannen</button> : null}
{source.protocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runBacnetScan(); }}>BACnet scannen</button> : null}
<button className="ghost-button compact-button danger" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); deleteCollectorSource(); }}>L?schen</button>
<button className="ghost-button compact-button danger" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); deleteCollectorSource(); }}>Löschen</button>
</div>
</button>
);
@@ -2516,7 +2561,7 @@ export default function App() {
<h3>Datenpunkte & Werkzeuge</h3>
<p>Import, Scan und Einzelpunkte liegen rechts bei der aktiven Quelle.</p>
</div>
<small className="muted">Aktive Quelle: {normalizeDisplayText(currentSource?.name, "noch keine ausgew?hlt")}</small>
<small className="muted">Aktive Quelle: {normalizeDisplayText(currentSource?.name, "noch keine ausgewählt")}</small>
</div>
<div className="protocol-grid source-tools-grid">
@@ -2543,8 +2588,8 @@ export default function App() {
{sourceToolProtocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={runBacnetScan}>Scan mit Host/Port der aktiven Quelle</button> : null}
</div>
{sourceToolProtocol === "opc-ua" ? <small className="muted">{scanResults.opcua ? (scanResults.opcua.reachable ? `${scanResults.opcua.nodes?.length || 0} Node(s), ${scanResults.opcua.endpoints?.length || 0} Endpunkt(e)` : "Host nicht erreichbar") : "Verwendet Host und Port der aktiven Quelle."}</small> : null}
{sourceToolProtocol === "bacnet-ip" ? <small className="muted">{scanResults.bacnet ? `${scanResults.bacnet.devices?.length || 0} Ger?t(e) gefunden` : "F?r mehrere Hosts im Host-Feld Komma, Leerzeichen oder Semikolon verwenden."}</small> : null}
{sourceToolProtocol === "modbus-tcp" ? <small className="muted">F?r 192.168.153.50 ist Input Register / FC4 best?tigt. Den Registertyp w?hlst du pro Datenpunkt ?ber Datenart.</small> : null}
{sourceToolProtocol === "bacnet-ip" ? <small className="muted">{scanResults.bacnet ? `${scanResults.bacnet.devices?.length || 0} Gerät(e) gefunden` : "Für mehrere Hosts im Host-Feld Komma, Leerzeichen oder Semikolon verwenden."}</small> : null}
{sourceToolProtocol === "modbus-tcp" ? <small className="muted">Für 192.168.153.50 ist Input Register / FC4 bestätigt. Den Registertyp wählst du pro Datenpunkt über Datenart.</small> : null}
{sourceToolProtocol === "knx-ip" ? <small className="muted">KNX-Import oder Einzelpunkt rechts anlegen. Gefundene und importierte Punkte landen unten links.</small> : null}
</div>
</div>
@@ -2567,7 +2612,7 @@ export default function App() {
</label>
<label>
<span>Typ</span>
<select value={manualDatapointDraft.kind} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, kind: event.target.value }))}>
<select value={manualDatapointDraft.kind} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, kind: event.target.value, unit: current.unit && normalizeDisplayUnit(current.unit) !== "?C" ? current.unit : getDefaultUnitForKind(event.target.value) }))}>
<option value="analog">analog</option>
<option value="digital">digital</option>
</select>
@@ -2600,7 +2645,7 @@ export default function App() {
</label>
<label className="span-two">
<span>Bedingung / Notiz</span>
<input value={manualDatapointDraft.condition} placeholder="z.B. nur wenn Anlage l?uft" onChange={(event) => setManualDatapointDraft((current) => ({ ...current, condition: event.target.value }))} />
<input value={manualDatapointDraft.condition} placeholder="z.B. nur wenn Anlage läuft" onChange={(event) => setManualDatapointDraft((current) => ({ ...current, condition: event.target.value }))} />
</label>
</div>
<div className="button-row wrap-tight">
@@ -2620,7 +2665,7 @@ export default function App() {
<h3>{sourceDraft.id ? "Quelle bearbeiten" : "Neue Quelle"}</h3>
<p>Host, Port, Schreibmodus und Poll-Intervall werden hier zentral verwaltet.</p>
</div>
<button className="ghost-button compact-button" type="button" onClick={closeSourceEditor}>Schlie?en</button>
<button className="ghost-button compact-button" type="button" onClick={closeSourceEditor}>Schließen</button>
</div>
<div className="form-grid compact-forms">
@@ -2646,7 +2691,7 @@ export default function App() {
<input value={sourceDraft.port} onChange={(event) => setSourceDraft((current) => ({ ...current, port: event.target.value }))} />
</label>
<label>
<span>Ger?te-ID</span>
<span>Geräte-ID</span>
<input value={sourceDraft.deviceId} onChange={(event) => setSourceDraft((current) => ({ ...current, deviceId: event.target.value }))} />
</label>
<label className="span-two">
@@ -2666,11 +2711,11 @@ export default function App() {
</label>
</div>
<small className="muted">Der Tabellenname wird automatisch passend erzeugt. So taucht die Quelle sp?ter sicher in der Trendansicht auf.</small>
<small className="muted">Der Tabellenname wird automatisch passend erzeugt. So taucht die Quelle später sicher in der Trendansicht auf.</small>
<div className="button-row wrap-tight">
<button className="primary-button compact-button" type="button" onClick={saveCollectorSource}>Quelle speichern</button>
{selectedSourceId ? <button className="ghost-button compact-button danger" type="button" onClick={deleteCollectorSource}>Quelle l?schen</button> : null}
{selectedSourceId ? <button className="ghost-button compact-button danger" type="button" onClick={deleteCollectorSource}>Quelle löschen</button> : null}
</div>
</div>
</div>
@@ -2746,7 +2791,7 @@ export default function App() {
</div>
</div>
)) : null}
{!filteredAvailableCollectorPoints.length && !filteredOpcuaNodes.length && !filteredBacnetPoints.length ? <div className="empty-state slim-empty"><p>{collectorPointFilter.trim() ? "Keine Treffer f?r die aktuelle Suche." : "Nach Scan oder Import erscheinen die gefundenen Punkte genau hier links."}</p></div> : null}
{!filteredAvailableCollectorPoints.length && !filteredOpcuaNodes.length && !filteredBacnetPoints.length ? <div className="empty-state slim-empty"><p>{collectorPointFilter.trim() ? "Keine Treffer für die aktuelle Suche." : "Nach Scan oder Import erscheinen die gefundenen Punkte genau hier links."}</p></div> : null}
</div>
</div>
@@ -2916,7 +2961,7 @@ export default function App() {
<h3>Letzte Scans</h3>
<div className="selection-list compact">
<div className="selection-card source-card preview-card"><strong>OPC UA</strong><span>{debugSnapshot?.latestScans?.opcua?.reachable ? `${debugSnapshot.latestScans.opcua.endpoints.length} Endpunkte` : "kein Treffer"}</span></div>
<div className="selection-card source-card preview-card"><strong>BACnet</strong><span>{debugSnapshot?.latestScans?.bacnet?.devices?.length || 0} Ger?te</span></div>
<div className="selection-card source-card preview-card"><strong>BACnet</strong><span>{debugSnapshot?.latestScans?.bacnet?.devices?.length || 0} Geräte</span></div>
</div>
</div>
</div>
@@ -2941,61 +2986,74 @@ export default function App() {
) : null}
{activeView === "settings" ? (
<section className="grid two compact-grid">
<div className="panel slim-panel">
!isAuthenticated ? (
<section className="panel slim-panel">
<div className="panel-header compact-header">
<div>
<h3>Darstellung</h3>
<p>Farbschema und Bedienung werden pro Benutzer in Redis gespeichert.</p>
<h3>Anmeldung</h3>
<p>Mit Login werden Adminmenü und Einstellungen freigeschaltet.</p>
</div>
</div>
<label>
<span>Farbschema</span>
<ThemeSelect value={themeMode} onChange={setThemeMode} />
</label>
<label>
<span>CSV-Trennzeichen</span>
<select value={csvDelimiter} onChange={(event) => setCsvDelimiter(event.target.value)}>
<option value=";">Semikolon ;</option>
<option value=",">Komma ,</option>
<option value="\t">Tabulator</option>
</select>
</label>
<label className="checkbox-field">
<input type="checkbox" checked={sidebarPinned} onChange={(event) => setSidebarPinned(event.target.checked)} />
<span>Seitenleiste standardmäßig angeheftet lassen</span>
</label>
</div>
<div className="panel slim-panel">
<div className="panel-header compact-header">
<div>
<h3>Passwort ändern</h3>
<p>Aktuelles Passwort bestätigen und danach ein neues setzen.</p>
<LoginScreen onLogin={handleLogin} error={loginError} busy={busy} inline />
</section>
) : (
<section className="grid two compact-grid">
<div className="panel slim-panel">
<div className="panel-header compact-header">
<div>
<h3>Darstellung</h3>
<p>Farbschema und Bedienung werden pro Benutzer in Redis gespeichert.</p>
</div>
</div>
<label>
<span>Farbschema</span>
<ThemeSelect value={themeMode} onChange={setThemeMode} />
</label>
<label>
<span>CSV-Trennzeichen</span>
<select value={csvDelimiter} onChange={(event) => setCsvDelimiter(event.target.value)}>
<option value=";">Semikolon ;</option>
<option value=",">Komma ,</option>
<option value=" ">Tabulator</option>
</select>
</label>
<label className="checkbox-field">
<input type="checkbox" checked={sidebarPinned} onChange={(event) => setSidebarPinned(event.target.checked)} />
<span>Seitenleiste standardmäßig angeheftet lassen</span>
</label>
</div>
<div className="form-grid compact-forms">
<label>
<span>Aktuelles Passwort</span>
<input type="password" value={passwordDraft.currentPassword} onChange={(event) => setPasswordDraft((current) => ({ ...current, currentPassword: event.target.value }))} />
</label>
<label>
<span>Neues Passwort</span>
<input type="password" value={passwordDraft.nextPassword} onChange={(event) => setPasswordDraft((current) => ({ ...current, nextPassword: event.target.value }))} />
</label>
<label className="span-two">
<span>Neues Passwort wiederholen</span>
<input type="password" value={passwordDraft.repeatPassword} onChange={(event) => setPasswordDraft((current) => ({ ...current, repeatPassword: event.target.value }))} />
</label>
<div className="panel slim-panel">
<div className="panel-header compact-header">
<div>
<h3>Passwort ändern</h3>
<p>Aktuelles Passwort bestätigen und danach ein neues setzen.</p>
</div>
</div>
<div className="form-grid compact-forms">
<label>
<span>Aktuelles Passwort</span>
<input type="password" value={passwordDraft.currentPassword} onChange={(event) => setPasswordDraft((current) => ({ ...current, currentPassword: event.target.value }))} />
</label>
<label>
<span>Neues Passwort</span>
<input type="password" value={passwordDraft.nextPassword} onChange={(event) => setPasswordDraft((current) => ({ ...current, nextPassword: event.target.value }))} />
</label>
<label className="span-two">
<span>Neues Passwort wiederholen</span>
<input type="password" value={passwordDraft.repeatPassword} onChange={(event) => setPasswordDraft((current) => ({ ...current, repeatPassword: event.target.value }))} />
</label>
</div>
<button className="primary-button compact-button" type="button" onClick={changePassword}>Passwort speichern</button>
</div>
<button className="primary-button compact-button" type="button" onClick={changePassword}>Passwort speichern</button>
</div>
</section>
</section>
)
) : null}
</main>
</div>
);
+17 -11
View File
@@ -57,6 +57,11 @@ function getAnalogDomain(rows, visibleSeries) {
return [min - padding, max + padding];
}
function formatTrendNumber(value) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric.toFixed(2) : "--";
}
export default function TrendChart({
result,
title = "Trendansicht",
@@ -112,15 +117,6 @@ export default function TrendChart({
const yDomain = zoomDomain?.y || baseYDomain;
const hasFocusSelection = selectedKeys.length > 0 && selectedKeys.length < series.length;
if (!rows.length) {
return (
<div className={compact ? "panel chart-panel compact-chart empty-state" : "panel chart-panel empty-state"}>
<h3>{title}</h3>
<p>Keine Daten im gewählten Zeitraum.</p>
</div>
);
}
const mapDragToDomain = (clientX, clientY) => {
const rect = wrapperRef.current?.getBoundingClientRect();
if (!rect) {
@@ -343,6 +339,15 @@ export default function TrendChart({
await exportGraphic();
};
if (!rows.length) {
return (
<div className={compact ? "panel chart-panel compact-chart empty-state" : "panel chart-panel empty-state"}>
<h3>{title}</h3>
<p>Keine Daten im gewählten Zeitraum.</p>
</div>
);
}
return (
<div className={expanded ? "panel chart-panel focus-mode" : compact ? "panel chart-panel compact-chart" : "panel chart-panel"}>
<div className="panel-header compact-header chart-toolbar">
@@ -378,7 +383,7 @@ export default function TrendChart({
</>
) : 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}
{allowFocusMode ? <button className="ghost-button" type="button" onClick={onToggleExpanded}>{expanded ? "Normalansicht" : "Vollbild"}</button> : null}
</div>
</div>
@@ -400,7 +405,7 @@ export default function TrendChart({
minTickGap={28}
stroke="var(--muted)"
/>
{analogSeries.length ? <YAxis yAxisId="analog" stroke="var(--muted)" domain={yDomain} width={52} /> : null}
{analogSeries.length ? <YAxis yAxisId="analog" stroke="var(--muted)" domain={yDomain} width={52} tickFormatter={formatTrendNumber} /> : null}
{digitalSeries.length ? (
<YAxis
yAxisId="digital"
@@ -414,6 +419,7 @@ export default function TrendChart({
) : null}
<Tooltip
labelFormatter={(value) => new Date(value).toLocaleString("de-DE")}
formatter={(value, name) => [formatTrendNumber(value), name]}
contentStyle={{
background: "var(--surface-strong)",
border: "1px solid var(--border-strong)",
+26 -1
View File
@@ -382,6 +382,12 @@ span {
padding-inline: 0.7rem;
}
.sidebar-login {
justify-self: start;
border-radius: 999px;
padding-inline: 0.7rem;
}
.content {
padding: 0.55rem 0.65rem 0.7rem;
display: grid;
@@ -638,6 +644,24 @@ span {
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
}
.trend-layout {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 0.95fr);
gap: 0.65rem;
align-items: start;
}
.trend-main,
.trend-side {
display: grid;
gap: 0.65rem;
align-content: start;
}
.trend-side {
min-width: 0;
}
.stat-card,
.gauge-card,
.user-row {
@@ -864,7 +888,8 @@ span {
.alias-row,
.protocol-grid,
.inline-fields,
.inline-fields.multi-line {
.inline-fields.multi-line,
.trend-layout {
grid-template-columns: 1fr;
}
}