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