ux
This commit is contained in:
1 parent
db26938791
commit
55a3c1ab7b
6 files changed
+256
-32
No files matched your search
@@ -1,5 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
import platform
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from app.core_settings import get_settings
|
||||
@@ -7,10 +11,12 @@ from app.services.capabilities import build_capabilities
|
||||
from app.services.catalog import get_catalog
|
||||
from app.services.kiosk_settings import get_kiosk_settings_service
|
||||
from app.services.auth import get_auth_service
|
||||
from app.services.influx_http import InfluxHTTPService
|
||||
from app.utils.serialization import to_plain
|
||||
|
||||
|
||||
dashboard_blueprint = Blueprint("dashboard", __name__)
|
||||
_APP_STARTED_AT = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dashboard_blueprint.get("/dashboard/config")
|
||||
@@ -78,3 +84,38 @@ def update_dashboard_kiosk_settings():
|
||||
return jsonify({"detail": str(exc)}), 403
|
||||
except ValueError as exc:
|
||||
return jsonify({"detail": str(exc)}), 400
|
||||
|
||||
|
||||
@dashboard_blueprint.get("/dashboard/diagnostics")
|
||||
def dashboard_diagnostics():
|
||||
settings = get_settings()
|
||||
influx_diagnostics = InfluxHTTPService(settings).diagnose()
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"app": {
|
||||
"name": settings.app_name,
|
||||
"version": settings.version,
|
||||
"debug": settings.debug,
|
||||
"timezone": settings.timezone,
|
||||
"site_name": settings.site_name,
|
||||
"installed_power_kwp": settings.installed_power_kwp,
|
||||
"auth_enabled": settings.auth["enabled"],
|
||||
"started_at": _APP_STARTED_AT.isoformat(),
|
||||
"uptime_seconds": max(int((now - _APP_STARTED_AT).total_seconds()), 0),
|
||||
"python_version": platform.python_version(),
|
||||
"pid": os.getpid(),
|
||||
},
|
||||
"api": {
|
||||
"status": "ok",
|
||||
"prefix": settings.api_prefix,
|
||||
"cors_origins_count": len(settings.cors_origins),
|
||||
},
|
||||
"influx": influx_diagnostics,
|
||||
"storage": {
|
||||
"sqlite_path": settings.storage["sqlite_path"],
|
||||
"historical_import_enabled": settings.history["enabled"],
|
||||
"auto_sync_enabled": settings.history["auto_sync_enabled"],
|
||||
"default_chunk_days": settings.history["default_chunk_days"],
|
||||
},
|
||||
}
|
||||
return jsonify(to_plain(payload))
|
||||
@@ -165,6 +165,34 @@ class InfluxHTTPService:
|
||||
logger.warning("Influx last_before error for %s: %s", metric.id, exc)
|
||||
return None
|
||||
|
||||
def diagnose(self) -> dict:
|
||||
config = self.settings.influx
|
||||
payload = {
|
||||
"status": "connected",
|
||||
"reachable": True,
|
||||
"database_exists": False,
|
||||
"url": self.base_url,
|
||||
"database": config["database"],
|
||||
"username_masked": _mask_secret(config.get("username") or ""),
|
||||
"verify_ssl": bool(config.get("verify_ssl", False)),
|
||||
"timeout_seconds": int(config.get("timeout_seconds", 15)),
|
||||
"error": None,
|
||||
}
|
||||
try:
|
||||
series = self._execute("SHOW DATABASES")
|
||||
databases: set[str] = set()
|
||||
for item in series:
|
||||
for row in self._rows_from_series(item):
|
||||
value = row.get("name")
|
||||
if isinstance(value, str):
|
||||
databases.add(value)
|
||||
payload["database_exists"] = config["database"] in databases
|
||||
except Exception as exc:
|
||||
payload["status"] = "error"
|
||||
payload["reachable"] = False
|
||||
payload["error"] = str(exc)
|
||||
return payload
|
||||
|
||||
def _single_value(self, query: str) -> SeriesPoint | None:
|
||||
try:
|
||||
series = self._execute(query)
|
||||
@@ -239,3 +267,11 @@ def _parse_time(value: str | None) -> datetime | None:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _mask_secret(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
if len(value) <= 2:
|
||||
return "*" * len(value)
|
||||
return value[:1] + ("*" * max(len(value) - 2, 1)) + value[-1:]
|
||||
+89
-32
@@ -33,6 +33,7 @@ import type {
|
||||
AuthUsersPayload,
|
||||
BucketPoint,
|
||||
DashboardConfig,
|
||||
DiagnosticsPayload,
|
||||
DistributionPayload,
|
||||
HistoryPayload,
|
||||
HistoricalStatus,
|
||||
@@ -135,65 +136,113 @@ function buildTablerChartTheme(theme: ThemeMode) {
|
||||
? { text: "#cbd5e1", grid: "rgba(255,255,255,0.08)", tooltip: "rgba(15, 23, 42, 0.96)", series: ["#4dabf7", "#20c997", "#f59f00", "#e64980", "#9775fa", "#ff922b", "#66d9e8", "#adb5bd", "#94d82d", "#ffa8a8"] }
|
||||
: { text: "#334155", grid: "rgba(15,23,42,0.12)", tooltip: "rgba(255,255,255,0.98)", series: ["#206bc4", "#2fb344", "#f59f00", "#d63384", "#7950f2", "#fd7e14", "#1098ad", "#868e96", "#74b816", "#fa5252"] };
|
||||
}
|
||||
function formatChartNumber(value: number | string | null | undefined, locale: string): string {
|
||||
if (value === null || value === undefined || value === "" || Number.isNaN(Number(value))) return "--";
|
||||
return Number(value).toLocaleString(locale, { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
}
|
||||
function buildUnitLabel(units: Array<string | undefined | null>): string {
|
||||
const unique = units.filter((item): item is string => Boolean(item)).filter((item, index, list) => list.indexOf(item) === index);
|
||||
return unique.join(" / ");
|
||||
}
|
||||
function buildLiveHistoryOption(history: HistoryPayload | undefined, theme: ThemeMode, language: Language): EChartsOption {
|
||||
const palette = buildTablerChartTheme(theme);
|
||||
const series = history?.series ?? [];
|
||||
const locale = localeForLanguage(language);
|
||||
const unitLabel = buildUnitLabel(series.map((item) => item.unit));
|
||||
return {
|
||||
color: palette.series,
|
||||
tooltip: { trigger: "axis", backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text } },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
backgroundColor: palette.tooltip,
|
||||
borderColor: palette.grid,
|
||||
textStyle: { color: palette.text },
|
||||
formatter: (params: any) => {
|
||||
const rows = Array.isArray(params) ? params : [params];
|
||||
const header = rows[0]?.axisValueLabel ?? "";
|
||||
return [header, ...rows.map((row) => `${row.marker ?? ""} ${row.seriesName}: ${formatChartNumber(row.value, locale)}`)].join("<br/>");
|
||||
},
|
||||
},
|
||||
legend: { top: 0, textStyle: { color: palette.text }, itemGap: 16 },
|
||||
grid: { left: 12, right: 16, top: 48, bottom: 12, containLabel: true },
|
||||
xAxis: { type: "category", boundaryGap: false, axisLabel: { color: palette.text }, axisLine: { lineStyle: { color: palette.grid } }, data: (series[0]?.points ?? []).map((point: any) => formatShortTime(point.timestamp, localeForLanguage(language))) },
|
||||
yAxis: { type: "value", axisLabel: { color: palette.text }, splitLine: { lineStyle: { color: palette.grid } } },
|
||||
series: series.map((item, index) => ({ name: item.label, type: "line", smooth: true, connectNulls: true, showSymbol: false, lineStyle: { width: index === 0 ? 3 : 2 }, emphasis: { focus: "series" }, data: item.points.map((point: any) => point.value) })),
|
||||
xAxis: { type: "category", boundaryGap: false, axisLabel: { color: palette.text }, axisLine: { lineStyle: { color: palette.grid } }, data: (series[0]?.points ?? []).map((point: any) => formatShortTime(point.timestamp, locale)) },
|
||||
yAxis: { type: "value", name: unitLabel, nameTextStyle: { color: palette.text }, axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) }, splitLine: { lineStyle: { color: palette.grid } } },
|
||||
series: series.map((item, index) => ({ name: item.unit ? `${item.label} [${item.unit}]` : item.label, type: "line", smooth: true, connectNulls: true, showSymbol: false, lineStyle: { width: index === 0 ? 3 : 2 }, emphasis: { focus: "series" }, data: item.points.map((point: any) => point.value) })),
|
||||
};
|
||||
}
|
||||
function buildBarOption(points: BucketPoint[], unit: string, theme: ThemeMode, language: Language): EChartsOption {
|
||||
const palette = buildTablerChartTheme(theme);
|
||||
const locale = localeForLanguage(language);
|
||||
return {
|
||||
color: [palette.series[0]],
|
||||
tooltip: { trigger: "axis", backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text }, valueFormatter: (value) => formatValue(Number(value), unit, 2, localeForLanguage(language)) },
|
||||
tooltip: { trigger: "axis", backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text }, valueFormatter: (value) => formatValue(Number(value), unit, 2, locale) },
|
||||
grid: { left: 12, right: 16, top: 16, bottom: 40, containLabel: true },
|
||||
xAxis: { type: "category", axisLabel: { color: palette.text, rotate: points.length > 12 ? 32 : 0 }, axisLine: { lineStyle: { color: palette.grid } }, data: points.map((point) => point.label) },
|
||||
yAxis: { type: "value", name: unit, nameTextStyle: { color: palette.text }, axisLabel: { color: palette.text }, splitLine: { lineStyle: { color: palette.grid } } },
|
||||
yAxis: { type: "value", name: unit, nameTextStyle: { color: palette.text }, axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) }, splitLine: { lineStyle: { color: palette.grid } } },
|
||||
series: [{ type: "bar", barMaxWidth: 24, itemStyle: { borderRadius: [6, 6, 0, 0] }, data: points.map((point) => point.value) }],
|
||||
};
|
||||
}
|
||||
function buildComparisonOption(data: AnalyticsPayload | undefined, theme: ThemeMode, language: Language, comparisonDisplayMode: "line" | "bar"): EChartsOption {
|
||||
const palette = buildTablerChartTheme(theme);
|
||||
const current = data?.current ?? [];
|
||||
const locale = localeForLanguage(language);
|
||||
const unit = data?.unit ?? "";
|
||||
const comparisonSeries = (data?.comparisons?.length ? data.comparisons : [{ key: data?.compare_mode ?? "comparison", label: t(language, "comparisonPeriod"), points: data?.comparison ?? [] }])
|
||||
.filter((item) => item.points?.length)
|
||||
.map((item) => ({ ...item, label: translateCompareMode(language, item.label || item.key) }));
|
||||
const verticalLegend = comparisonSeries.length > 2;
|
||||
return {
|
||||
color: palette.series,
|
||||
tooltip: { trigger: "axis", backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text } },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
backgroundColor: palette.tooltip,
|
||||
borderColor: palette.grid,
|
||||
textStyle: { color: palette.text },
|
||||
formatter: (params: any) => {
|
||||
const rows = Array.isArray(params) ? params : [params];
|
||||
const header = rows[0]?.axisValueLabel ?? "";
|
||||
return [header, ...rows.map((row) => `${row.marker ?? ""} ${row.seriesName}: ${formatValue(Number(row.value), unit, 2, locale)}`)].join("<br/>");
|
||||
},
|
||||
},
|
||||
legend: verticalLegend
|
||||
? { type: "scroll", orient: "vertical", top: 12, right: 0, bottom: 12, textStyle: { color: palette.text }, pageTextStyle: { color: palette.text } }
|
||||
: { type: "scroll", top: 0, left: 0, right: 0, textStyle: { color: palette.text }, pageTextStyle: { color: palette.text } },
|
||||
grid: { left: 16, right: verticalLegend ? 180 : 20, top: verticalLegend ? 20 : 48, bottom: 18, containLabel: true },
|
||||
xAxis: { type: "category", axisLabel: { color: palette.text, interval: 0, rotate: current.length > 12 ? 35 : 0 }, axisLine: { lineStyle: { color: palette.grid } }, data: current.map((point) => point.label) },
|
||||
yAxis: { type: "value", axisLabel: { color: palette.text }, splitLine: { lineStyle: { color: palette.grid } } },
|
||||
yAxis: { type: "value", name: unit, nameTextStyle: { color: palette.text }, axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) }, splitLine: { lineStyle: { color: palette.grid } } },
|
||||
series: [
|
||||
{ name: t(language, "currentPeriod"), type: "bar", barMaxWidth: comparisonDisplayMode === "bar" ? 18 : 22, emphasis: { focus: "series" }, data: current.map((point) => point.value) },
|
||||
{ name: unit ? `${t(language, "currentPeriod")} [${unit}]` : t(language, "currentPeriod"), type: "bar", barMaxWidth: comparisonDisplayMode === "bar" ? 18 : 22, emphasis: { focus: "series" }, data: current.map((point) => point.value) },
|
||||
...comparisonSeries.map((seriesItem) => comparisonDisplayMode === "bar"
|
||||
? { name: seriesItem.label, type: "bar" as const, barMaxWidth: 18, emphasis: { focus: "series" as const }, data: current.map((_, pointIndex) => seriesItem.points[pointIndex]?.value ?? null) }
|
||||
: { name: seriesItem.label, type: "line" as const, smooth: true, showSymbol: false, emphasis: { focus: "series" as const }, data: current.map((_, pointIndex) => seriesItem.points[pointIndex]?.value ?? null) }),
|
||||
? { name: unit ? `${seriesItem.label} [${unit}]` : seriesItem.label, type: "bar" as const, barMaxWidth: 18, emphasis: { focus: "series" as const }, data: current.map((_, pointIndex) => seriesItem.points[pointIndex]?.value ?? null) }
|
||||
: { name: unit ? `${seriesItem.label} [${unit}]` : seriesItem.label, type: "line" as const, smooth: true, showSymbol: false, emphasis: { focus: "series" as const }, data: current.map((_, pointIndex) => seriesItem.points[pointIndex]?.value ?? null) }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildPieOption(data: DistributionPayload | undefined, theme: ThemeMode): EChartsOption {
|
||||
function buildPieOption(data: DistributionPayload | undefined, theme: ThemeMode, language: Language): EChartsOption {
|
||||
const palette = buildTablerChartTheme(theme);
|
||||
const locale = localeForLanguage(language);
|
||||
const unit = data?.unit ?? "";
|
||||
const slices = [...(data?.slices ?? [])].sort((a, b) => b.value - a.value).slice(0, 12);
|
||||
return {
|
||||
color: palette.series,
|
||||
tooltip: { trigger: "axis", axisPointer: { type: "shadow" }, backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text } },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "shadow" },
|
||||
backgroundColor: palette.tooltip,
|
||||
borderColor: palette.grid,
|
||||
textStyle: { color: palette.text },
|
||||
formatter: (params: any) => {
|
||||
const row = Array.isArray(params) ? params[0] : params;
|
||||
const index = row?.dataIndex ?? 0;
|
||||
const slice = slices[index];
|
||||
if (!slice) return "";
|
||||
return `${row.name}<br/>${formatValue(slice.value, unit, 2, locale)} · ${formatChartNumber(slice.share, locale)}%`;
|
||||
},
|
||||
},
|
||||
grid: { left: 16, right: 28, top: 8, bottom: 8, containLabel: true },
|
||||
xAxis: { type: "value", axisLabel: { color: palette.text }, splitLine: { lineStyle: { color: palette.grid } } },
|
||||
xAxis: { type: "value", name: unit, nameTextStyle: { color: palette.text }, axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) }, splitLine: { lineStyle: { color: palette.grid } } },
|
||||
yAxis: { type: "category", axisLabel: { color: palette.text }, data: slices.map((item) => item.label) },
|
||||
series: [{ type: "bar", data: slices.map((item) => ({ value: item.value, label: { show: true, position: "right", formatter: `${item.share}%`, color: palette.text } })), barMaxWidth: 22, itemStyle: { borderRadius: [0, 6, 6, 0] } }],
|
||||
series: [{ type: "bar", data: slices.map((item) => ({ value: item.value, label: { show: true, position: "right", formatter: `${formatChartNumber(item.value, locale)} ${unit} · ${formatChartNumber(item.share, locale)}%`, color: palette.text } })), barMaxWidth: 22, itemStyle: { borderRadius: [0, 6, 6, 0] } }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -410,6 +459,7 @@ export default function App() {
|
||||
const liveHistoryData = useMemo(() => trimSingleDayHistory(historyQuery.data, effectiveRealtimeRange), [historyQuery.data, effectiveRealtimeRange]);
|
||||
const archiveHistoryData = useMemo(() => trimSingleDayHistory(archiveQuery.data, archiveRange), [archiveQuery.data, archiveRange]);
|
||||
const usersQuery = useQuery<AuthUsersPayload>({ queryKey: ["auth-users"], queryFn: api.getUsers, enabled: dataEnabled && (authQuery.data?.role === "admin"), staleTime: 15_000 });
|
||||
const diagnosticsQuery = useQuery<DiagnosticsPayload>({ queryKey: ["diagnostics"], queryFn: api.getDiagnostics, enabled: dataEnabled && !publicMode, staleTime: 20_000 });
|
||||
|
||||
const loginMutation = useMutation({ mutationFn: () => api.login(loginForm.username, loginForm.password), onSuccess: async () => { setLoginError(null); setLoginForm((value) => ({ ...value, password: "" })); await queryClient.invalidateQueries({ queryKey: ["auth-status"] }); await queryClient.invalidateQueries({ queryKey: ["dashboard-config"] }); }, onError: (error: Error) => setLoginError(parseError(error) || t(language, "loginError")) });
|
||||
const logoutMutation = useMutation({ mutationFn: api.logout, onSuccess: async () => { await queryClient.clear(); await queryClient.invalidateQueries({ queryKey: ["auth-status"] }); } });
|
||||
@@ -518,9 +568,9 @@ export default function App() {
|
||||
|
||||
{activeTab === "warehouse" && <><PageHeader title={language === "en" ? "Data warehouse" : "Hurtownia danych"} subtitle={language === "en" ? "Historical import and coverage." : "Import historyczny i pokrycie danych."} /><div className="row row-cards g-3"><div className="col-12 col-xxl-8"><HistoricalPanel status={historical.status.data} language={language} locale={locale} /></div><div className="col-12 col-xxl-4"><ImportControls status={historical.status.data} language={language} onStart={(payload) => historical.start.mutate(payload)} onSyncNow={() => historical.syncNow.mutate()} onCancel={() => historical.cancel.mutate()} /></div></div></>}
|
||||
|
||||
{activeTab === "kiosk" && <><PageHeader title={t(language, "kiosk")} subtitle={language === "en" ? "Kiosk mode configuration" : "Konfiguracja trybu kiosk"} /><div className="row row-cards g-3"><div className="col-12 col-xxl-8"><KioskSettingsEditorPanel language={language} value={kioskEditorMode === "public" ? publicKioskDraft : privateKioskDraft} onChange={(value) => applyKioskDraftChange(kioskEditorMode, value)} onModeChange={setKioskEditorMode} selectedMode={kioskEditorMode} labels={widgetLabels} buckets={config.capabilities.buckets} compareModes={config.capabilities.comparison_modes} saving={saveKioskSettingsMutation.isPending && saveKioskSettingsMutation.variables?.mode === kioskEditorMode} dirty={currentKioskDirty} canSave={canPersistKioskSettings} saveNotice={kioskSaveNotice[kioskEditorMode]} onSave={saveCurrentKioskSettings} onReset={() => resetKioskDraft(kioskEditorMode)} /></div><div className="col-12 col-xxl-4"><KioskLinkPanel language={language} publicKioskUrl={publicKioskUrl} privateKioskUrl={privateKioskUrl} publicSettings={publicKioskDraft} privateSettings={privateKioskDraft} /></div></div></>}
|
||||
{activeTab === "kiosk" && <><PageHeader title={t(language, "kiosk")} subtitle={language === "en" ? "Choose kiosk type, tune ranges and arrange sections." : "Wybierz typ kiosku, ustaw zakresy i ułóż sekcje."} /><div className="row row-cards g-3"><div className="col-12 col-xxl-8"><KioskSettingsEditorPanel language={language} value={kioskEditorMode === "public" ? publicKioskDraft : privateKioskDraft} onChange={(value) => applyKioskDraftChange(kioskEditorMode, value)} onModeChange={setKioskEditorMode} selectedMode={kioskEditorMode} labels={widgetLabels} buckets={config.capabilities.buckets} compareModes={config.capabilities.comparison_modes} saving={saveKioskSettingsMutation.isPending && saveKioskSettingsMutation.variables?.mode === kioskEditorMode} dirty={currentKioskDirty} canSave={canPersistKioskSettings} saveNotice={kioskSaveNotice[kioskEditorMode]} onSave={saveCurrentKioskSettings} onReset={() => resetKioskDraft(kioskEditorMode)} /></div><div className="col-12 col-xxl-4"><KioskLinkPanel language={language} publicKioskUrl={publicKioskUrl} privateKioskUrl={privateKioskUrl} publicSettings={publicKioskDraft} privateSettings={privateKioskDraft} /></div></div></>}
|
||||
|
||||
{activeTab === "settings" && <><PageHeader title={t(language, "settings")} subtitle={language === "en" ? "Appearance, metric blocks and admin users." : "Wygląd, bloki metryk i użytkownicy."} /><div className="row row-cards g-3"><div className="col-12 col-xl-5"><AppearancePanel language={language} theme={theme} setTheme={setTheme} viewMode={viewMode} setViewMode={setViewMode} userName={authQuery.data?.display_name ?? authQuery.data?.user ?? ""} /></div><div className="col-12 col-xl-7"><LiveChartMetricsPanel language={language} items={metricCandidates.filter((item) => item.metric_id !== "energy_total")} selected={liveMetrics.filter((item) => item !== "energy_total")} onChange={setLiveMetrics} /></div><div className="col-12"><BlockVisibilityPanel language={language} items={metricCandidates} config={blockConfig} onChange={setBlockConfig} /></div>{isAdmin ? <div className="col-12"><AdminUsersPanel language={language} users={usersQuery.data?.items ?? []} newUser={newUser} onNewUserChange={setNewUser} onCreate={() => createUserMutation.mutate()} passwordReset={passwordReset} onPasswordResetChange={setPasswordReset} onResetPassword={() => resetPasswordMutation.mutate()} /></div> : null}</div></>}
|
||||
{activeTab === "settings" && <><PageHeader title={t(language, "settings")} subtitle={language === "en" ? "Quick settings, dashboard visibility and live diagnostics." : "Szybkie ustawienia, widoczność dashboardu i diagnostyka live."} /><div className="row row-cards g-3"><div className="col-12 col-xl-5"><AppearancePanel language={language} setLanguage={setLanguage} theme={theme} setTheme={setTheme} viewMode={viewMode} setViewMode={setViewMode} userName={authQuery.data?.display_name ?? authQuery.data?.user ?? ""} /></div><div className="col-12 col-xl-7"><DiagnosticPanel language={language} locale={locale} data={diagnosticsQuery.data} loading={diagnosticsQuery.isLoading} onRefresh={() => diagnosticsQuery.refetch()} /></div><div className="col-12 col-xl-6"><LiveChartMetricsPanel language={language} items={metricCandidates.filter((item) => item.metric_id !== "energy_total")} selected={liveMetrics.filter((item) => item !== "energy_total")} onChange={setLiveMetrics} /></div><div className="col-12 col-xl-6"><BlockVisibilityPanel language={language} items={metricCandidates} config={blockConfig} onChange={setBlockConfig} /></div>{isAdmin ? <div className="col-12"><AdminUsersPanel language={language} users={usersQuery.data?.items ?? []} newUser={newUser} onNewUserChange={setNewUser} onCreate={() => createUserMutation.mutate()} passwordReset={passwordReset} onPasswordResetChange={setPasswordReset} onResetPassword={() => resetPasswordMutation.mutate()} /></div> : null}</div></>}
|
||||
</div></div></div></div>
|
||||
);
|
||||
}
|
||||
@@ -549,28 +599,35 @@ function PageHeader({ title, subtitle, children }: { title: string; subtitle: st
|
||||
function SegmentedSelect({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return <div className="pv-segmented"><div className="pv-segmented-label">{label}</div><div className="btn-group pv-segmented-group" role="group" aria-label={label}>{options.map((option) => <button key={option.key} className={`btn btn-sm ${value === option.key ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => onChange(option.key)}>{option.label}</button>)}</div></div>; }
|
||||
function SelectField({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return <div className="pv-filter-field"><label className="form-label small mb-1">{label}</label><select className="form-select form-select-sm" value={value} onChange={(event) => onChange(event.target.value)}>{options.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}</select></div>; }
|
||||
function translateRangeLabel(language: Language, key: string, fallback: string): string { const map: Record<string, { pl: string; en: string }> = { today: { pl: "Dziś", en: "Today" }, yesterday: { pl: "Wczoraj", en: "Yesterday" }, "7d": { pl: "7 dni", en: "7d" }, "30d": { pl: "30 dni", en: "30d" }, "90d": { pl: "90 dni", en: "90d" }, "365d": { pl: "365 dni", en: "365 days" }, custom: { pl: "Ręczny", en: "Custom" } }; return map[key]?.[language] ?? fallback; }
|
||||
function HeroCards({ cards, locale, language }: { cards: SnapshotPayload["hero_cards"]; locale: string; language: Language }) { return <div className="row row-cards g-3">{cards.map((card) => <div key={card.metric_id} className="col-12 col-sm-6 col-xl-3"><div className="card pv-card pv-hero-card h-100"><div className="card-body"><div className="d-flex align-items-center justify-content-between mb-3"><span className="avatar avatar-sm bg-primary-lt text-primary border-0">{iconForMetric(card.metric_id)}</span><span className="badge bg-primary-lt text-primary text-uppercase">{card.unit || "live"}</span></div><div className="text-secondary text-uppercase small mb-1">{labelForMetric(language, card.metric_id, card.label)}</div><div className="display-6 fw-bold mb-1">{formatValue(card.value, card.unit, card.unit === "kWh" ? 2 : 2, locale)}</div><div className="text-secondary small">{card.subtitle}</div></div></div></div>)}</div>; }
|
||||
function QuickMetrics({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "quickMetrics")}</h3></div><div className="list-group list-group-flush list-group-hoverable">{metrics.map((metric) => <div className="list-group-item" key={metric.metric_id}><div className="row align-items-center"><div className="col-auto text-primary">{iconForMetric(metric.metric_id)}</div><div className="col text-truncate"><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || t(language, "status")}</div></div><div className="col-auto fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div></div>)}</div></div>; }
|
||||
function HeroCards({ cards, locale, language }: { cards: SnapshotPayload["hero_cards"]; locale: string; language: Language }) { return <div className="row row-cards g-3">{cards.map((card) => <div key={card.metric_id} className="col-12 col-sm-6 col-xl-3"><div className="card pv-card pv-hero-card h-100"><div className="card-body"><div className="d-flex align-items-center justify-content-between mb-3"><span className="avatar avatar-sm bg-primary-lt text-primary border-0">{iconForMetric(card.metric_id)}</span><span className="badge bg-primary-lt text-primary text-uppercase">{card.unit || "live"}</span></div><div className="text-secondary text-uppercase small mb-1">{labelForMetric(language, card.metric_id, card.label)}</div><div className="display-6 fw-bold mb-1">{formatValue(card.value, card.unit, 2, locale)}</div><div className="text-secondary small">{card.subtitle}</div></div></div></div>)}</div>; }
|
||||
function QuickMetrics({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "quickMetrics")}</h3></div><div className="card-body d-flex flex-column gap-3">{metrics.map((metric) => <div key={metric.metric_id} className="d-flex align-items-center justify-content-between"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div></div>; }
|
||||
function StatusStat({ label, value }: { label: string; value: string }) { return <div className="col-6 col-lg-4 col-xl-2"><div className="border rounded-3 p-3 h-100"><div className="text-secondary small mb-1">{label}</div><div className="fw-semibold">{value}</div></div></div>; }
|
||||
function LiveHistoryPanel({ data, language, theme, title, subtitle }: { data?: HistoryPayload; language: Language; theme: ThemeMode; title: string; subtitle: string }) { return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{title}</h3><div className="text-secondary small">{subtitle}</div></div></div><div className="card-body"><EChart option={buildLiveHistoryOption(data, theme, language)} className="pv-chart" /></div></div>; }
|
||||
function StatusPanel({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "systemStatus")}</h3></div><div className="card-body d-flex flex-column gap-3">{metrics.map((metric) => <div key={metric.metric_id} className="d-flex justify-content-between align-items-center border rounded-3 px-3 py-2 status-row"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || t(language, "status")}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div></div>; }
|
||||
function StringsPanel({ rows, locale, language }: { rows: SnapshotGroupRow[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "strings")}</h3></div><div className="card-body"><div className="row g-3">{rows.length === 0 ? <div className="col-12 text-secondary">{t(language, "noDataDescription")}</div> : rows.map((row) => <div className="col-12 col-md-6" key={row.id}><div className="border rounded-3 p-3 h-100 string-panel"><div className="d-flex align-items-center justify-content-between mb-3"><div className="fw-semibold">{row.label}</div><span className="badge bg-azure-lt text-azure">DC</span></div><div className="d-flex flex-column gap-2">{Object.values(row.values).map((metric) => <div key={metric.metric_id} className="d-flex justify-content-between small"><span className="text-secondary">{labelForMetric(language, metric.metric_id, metric.label)}</span><span className="fw-medium">{formatValue(metric.value, metric.unit, 2, locale)}</span></div>)}</div></div></div>)}</div></div></div>; }
|
||||
function SystemStatus({ items, locale, language }: { items: MetricValue[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "systemStatus")}</h3></div><div className="card-body d-flex flex-column gap-3">{items.map((metric) => <div key={metric.metric_id} className="d-flex align-items-center justify-content-between gap-3 status-row border rounded-3 px-3 py-2"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || metric.status}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div></div>; }
|
||||
function StringPanels({ rows, locale, language }: { rows: SnapshotGroupRow[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "strings")}</h3></div><div className="card-body"><div className="row g-3">{rows.map((row) => <div key={row.id} className="col-12 col-md-6"><div className="string-panel border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{row.label}</div><div className="d-flex flex-column gap-2">{Object.values(row.values).map((metric) => <div key={metric.metric_id} className="d-flex justify-content-between"><span>{labelForMetric(language, metric.metric_id, metric.label)}</span><span className="fw-medium">{formatValue(metric.value, metric.unit, 2, locale)}</span></div>)}</div></div></div>)}</div></div></div>; }
|
||||
function StatusPanel({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return <SystemStatus items={metrics} locale={locale} language={language} />; }
|
||||
function StringsPanel({ rows, locale, language }: { rows: SnapshotGroupRow[]; locale: string; language: Language }) { return <StringPanels rows={rows} locale={locale} language={language} />; }
|
||||
function SummaryCards({ summary, language, locale, compareLabel }: { summary?: AnalyticsPayload["summary"]; language: Language; locale: string; compareLabel: string }) { const items = [{ key: t(language, "summaryTotal"), value: formatValue(summary?.total, summary?.unit ?? "kWh", 2, locale), badge: compareLabel }, { key: t(language, "summaryAverage"), value: formatValue(summary?.average_bucket, summary?.unit ?? "kWh", 2, locale) }, { key: t(language, "summaryBest"), value: summary ? `${summary.best_bucket_label} · ${formatValue(summary.best_bucket_value, summary.unit, 2, locale)}` : "--" }, { key: t(language, "summaryCo2"), value: formatValue(summary?.co2_saved_kg, "kg", 1, locale) }]; return <div className="row row-cards g-3">{items.map((item) => <div className="col-12 col-sm-6 col-xl-3" key={item.key}><div className="card pv-card h-100"><div className="card-body"><div className="text-secondary text-uppercase small mb-1">{item.key}</div><div className="h2 mb-0">{item.value}</div>{item.badge ? <div className="mt-2"><span className="badge bg-primary-lt text-primary">{item.badge}</span></div> : null}</div></div></div>)}</div>; }
|
||||
|
||||
function ProductionPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) { return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{t(language, "chartProduction")}</h3><div className="text-secondary small">{t(language, "chartProductionSubtitle")}</div></div></div><div className="card-body"><EChart option={buildBarOption(data?.current ?? [], data?.unit ?? "kWh", theme, language)} className="pv-chart" /></div></div>; }
|
||||
function ComparisonPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) {
|
||||
const [comparisonDisplayMode, setComparisonDisplayMode] = useState<"line" | "bar">("line");
|
||||
return <div className="card pv-card h-100"><div className="card-header d-flex flex-column flex-lg-row gap-3 align-items-start align-items-lg-center justify-content-between"><div><h3 className="card-title">{t(language, "chartComparison")}</h3><div className="text-secondary small">{comparisonDisplayMode === "line"
|
||||
? (language === "en" ? "Current period stays in bars, and comparisons are shown as green lines for readability." : "Możesz widok przełączyć do słupków w celu poprawy czytelności.")
|
||||
: (language === "en" ? "Switch to grouped bars when you want a direct bar-to-bar comparison." : "Przełącz do porównania wykresu linią.")}</div></div><SegmentedSelect label={language === "en" ? "Comparison style" : "Styl porównania"} value={comparisonDisplayMode} onChange={(value) => setComparisonDisplayMode(value as "line" | "bar")} options={[{ key: "line", label: language === "en" ? "Line" : "Linia" }, { key: "bar", label: language === "en" ? "Bars" : "Słupki" }]} /></div><div className="card-body"><EChart option={buildComparisonOption(data, theme, language, comparisonDisplayMode)} className="pv-chart" /></div></div>;
|
||||
? (language === "en" ? "Line view works better when comparing many periods." : "Widok liniowy lepiej działa przy większej liczbie porównań.")
|
||||
: (language === "en" ? "Grouped bars make direct value comparison easier." : "Słupki ułatwiają porównanie wartości 1:1.")}</div></div><SegmentedSelect label={language === "en" ? "Comparison style" : "Styl porównania"} value={comparisonDisplayMode} onChange={(value) => setComparisonDisplayMode(value as "line" | "bar")} options={[{ key: "line", label: language === "en" ? "Line" : "Linia" }, { key: "bar", label: language === "en" ? "Bars" : "Słupki" }]} /></div><div className="card-body"><EChart option={buildComparisonOption(data, theme, language, comparisonDisplayMode)} className="pv-chart" /></div></div>;
|
||||
}
|
||||
function DistributionPanel({ data, language, theme, locale }: { data?: DistributionPayload; language: Language; theme: ThemeMode; locale: string }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "chartDistribution")}</h3></div><div className="card-body"><div className="mb-3 fw-semibold">{formatValue(data?.total, data?.unit ?? "kWh", 2, locale)}</div><EChart option={buildPieOption(data, theme)} className="pv-chart-sm" /></div></div>; }
|
||||
function DistributionPanel({ data, language, theme, locale }: { data?: DistributionPayload; language: Language; theme: ThemeMode; locale: string }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "chartDistribution")}</h3></div><div className="card-body"><div className="mb-3 fw-semibold">{formatValue(data?.total, data?.unit ?? "kWh", 2, locale)}</div><EChart option={buildPieOption(data, theme, language)} className="pv-chart-sm" /></div></div>; }
|
||||
function HistoricalPanel({ status, language, locale, compact = false }: { status?: HistoricalStatus; language: Language; locale: string; compact?: boolean }) { if (!status) return <div className="card pv-card h-100"><div className="card-body text-secondary">{t(language, "noDataDescription")}</div></div>; return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{language === "en" ? "Data warehouse" : "Hurtownia danych"}</h3><div className="text-secondary small">{t(language, "importArchiveSubtitle")}</div></div></div><div className="card-body d-flex flex-column gap-4"><div className="row g-3"><StatusStat label={t(language, "status")} value={status.message || status.state} /><StatusStat label={t(language, "coverage")} value={formatPercent(status.coverage.coverage_pct ?? 0, 1, locale)} /><StatusStat label={t(language, "importedDays")} value={formatValue(status.coverage.imported_days, "", 0, locale)} /><StatusStat label={t(language, "missingDays")} value={formatValue(status.coverage.missing_days, "", 0, locale)} /><StatusStat label={t(language, "throughput")} value={`${formatValue(status.avg_days_per_minute ?? 0, "", 1, locale)} / min`} /><StatusStat label={t(language, "eta")} value={formatDurationShort(status.estimated_remaining_seconds, locale)} /></div>{!compact ? <><div><div className="d-flex justify-content-between small mb-2"><span className="text-secondary">{t(language, "activeChunk")}</span><span className="fw-medium">{status.active_chunk_index}/{status.total_chunks}</span></div><div className="progress progress-sm"><div className="progress-bar" style={{ width: `${Math.min((status.processed_days / Math.max(status.total_days, 1)) * 100, 100)}%` }} /></div></div><div className="row g-3"><div className="col-12 col-xl-6"><div className="table-responsive"><table className="table table-vcenter card-table table-sm"><thead><tr><th>{t(language, "recentChunks")}</th><th>{t(language, "status")}</th><th className="text-end">kWh</th></tr></thead><tbody>{status.recent_chunks.map((chunk) => <tr key={`${chunk.chunk_index}-${chunk.start_date}`}><td><div className="fw-medium">#{chunk.chunk_index}</div><div className="text-secondary small">{chunk.start_date} → {chunk.end_date}</div></td><td>{chunk.state}</td><td className="text-end">{formatValue(chunk.energy_kwh, "kWh", 2, locale)}</td></tr>)}</tbody></table></div></div><div className="col-12 col-xl-6"><div className="list-group list-group-flush">{status.recent_events.map((event, index) => <div className="list-group-item px-0" key={`${event.timestamp}-${index}`}><div className="d-flex justify-content-between gap-2"><div><div className="fw-medium">{event.title}</div><div className="text-secondary small">{event.message}</div></div><div className="text-secondary small text-nowrap">{formatShortTime(event.timestamp, locale)}</div></div></div>)}</div></div></div></> : null}</div></div>; }
|
||||
function StatusStat({ label, value }: { label: string; value: string }) { return <div className="col-6 col-md-4 col-xl-2"><div className="border rounded-3 px-3 py-2 h-100 bg-body-tertiary"><div className="text-secondary small mb-1">{label}</div><div className="fw-semibold">{value}</div></div></div>; }
|
||||
function ImportControls({ status, language, onStart, onSyncNow, onCancel }: { status?: HistoricalStatus; language: Language; onStart: (payload: { start_date?: string; end_date?: string; chunk_days?: number; force?: boolean }) => void; onSyncNow: () => void; onCancel: () => void; }) { const [startDate, setStartDate] = useState(status?.available_start_date ?? ""); const [endDate, setEndDate] = useState(status?.available_end_date ?? ""); const [chunkDays, setChunkDays] = useState(String(status?.default_chunk_days ?? 7)); useEffect(() => { if (!status) return; setStartDate((current) => current || status.available_start_date || ""); setEndDate((current) => current || status.available_end_date || ""); setChunkDays((current) => current || String(status.default_chunk_days || 7)); }, [status]); return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{language === "en" ? "Import controls" : "Sterowanie importem"}</h3></div><div className="card-body d-flex flex-column gap-3"><div><label className="form-label">{t(language, "startDate")}</label><input className="form-control" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} /></div><div><label className="form-label">{t(language, "endDate")}</label><input className="form-control" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} /></div><div><label className="form-label">{t(language, "chunkDays")}</label><input className="form-control" type="number" min={1} max={31} value={chunkDays} onChange={(event) => setChunkDays(event.target.value)} /></div><div className="d-grid gap-2"><button className="btn btn-primary" onClick={() => onStart({ start_date: startDate || undefined, end_date: endDate || undefined, chunk_days: Number(chunkDays) || undefined, force: true })}><IconPlayerPlay size={18} className="me-1" />{t(language, "startImport")}</button><button className="btn btn-outline-secondary" onClick={onSyncNow}><IconRefresh size={18} className="me-1" />{t(language, "syncMissing")}</button><button className="btn btn-outline-danger" onClick={onCancel} disabled={!status?.running}><IconX size={18} className="me-1" />{t(language, "cancel")}</button></div></div></div>; }
|
||||
function KioskLayoutPanel({ language, widgets, onChange, labels }: { language: Language; widgets: WidgetId[]; onChange: (value: WidgetId[]) => void; labels: Map<WidgetId, string>; }) { const available = widgetOrder.map((item) => item.id); const selected = widgets; const unselected = available.filter((item) => !selected.includes(item)); const move = (id: WidgetId, direction: -1 | 1) => { const index = selected.indexOf(id); if (index === -1) return; const target = index + direction; if (target < 0 || target >= selected.length) return; const next = [...selected]; [next[index], next[target]] = [next[target], next[index]]; onChange(next); }; const toggle = (id: WidgetId) => { if (selected.includes(id)) { const next = selected.filter((item) => item !== id); onChange(next.length ? next : selected); return; } onChange([...selected, id]); }; return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{t(language, "kioskLayout")}</h3><div className="text-secondary small">{t(language, "kioskLayoutSubtitle")}</div></div></div><div className="card-body"><div className="alert alert-info py-2">{t(language, "infoSave")}</div><div className="row g-3"><div className="col-12 col-lg-7"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{t(language, "selected")}</div><div className="d-flex flex-column gap-2">{selected.map((id) => <div key={id} className="d-flex align-items-center justify-content-between gap-2 border rounded-3 px-3 py-2 bg-body-tertiary"><span>{labels.get(id)}</span><div className="btn-list"><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, -1)}>{t(language, "moveUp")}</button><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, 1)}>{t(language, "moveDown")}</button><button className="btn btn-sm btn-outline-danger" onClick={() => toggle(id)}><IconX size={16} /></button></div></div>)}</div></div></div><div className="col-12 col-lg-5"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{t(language, "available")}</div><div className="d-flex flex-wrap gap-2">{unselected.map((id) => <button key={id} className="btn btn-outline-primary" onClick={() => toggle(id)}>{labels.get(id)}</button>)}</div></div></div></div></div></div>; }
|
||||
function KioskSettingsEditorPanel({ language, value, onChange, onSave, onReset, selectedMode, onModeChange, labels, buckets, compareModes, saving, dirty, canSave, saveNotice }: { language: Language; value: KioskSettingsPayload; onChange: (value: KioskSettingsPayload) => void; onSave: () => void; onReset: () => void; selectedMode: "public" | "private"; onModeChange: (value: "public" | "private") => void; labels: Map<WidgetId, string>; buckets: Array<{ key: string; label: string }>; compareModes: string[]; saving: boolean; dirty: boolean; canSave: boolean; saveNotice: string | null; }) { const widgets = toWidgetIds(value.widgets); return <div className="d-flex flex-column gap-3"><div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Kiosk settings" : "Ustawienia kiosku"}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="btn-group w-100"><button className={`btn ${selectedMode === "private" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => onModeChange("private")}>{language === "en" ? "Logged-in kiosk" : "Kiosk prywatny"}</button><button className={`btn ${selectedMode === "public" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => onModeChange("public")}>{language === "en" ? "Public kiosk" : "Kiosk publiczny"}</button></div><div className={`alert py-2 mb-0 ${dirty ? "alert-warning" : "alert-success"}`}>{dirty ? (language === "en" ? "You have local changes." : "Masz lokalne zmiany.") : (language === "en" ? "No unsaved changes." : "Brak niezapisanych zmian.")}{saveNotice ? <span className="d-block mt-1">{saveNotice}</span> : null}</div><div className="row g-3"><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Live chart range" : "Zakres wykresu live"}</label><select className="form-select" value={value.realtime_range} onChange={(e) => onChange({ ...value, realtime_range: e.target.value })}>{liveRangeOptions(language).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Analytics range" : "Zakres analityki"}</label><select className="form-select" value={value.analytics_range} onChange={(e) => onChange({ ...value, analytics_range: e.target.value })}>{analyticsRangeOptions(language).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">Bucket</label><select className="form-select" value={value.analytics_bucket} onChange={(e) => onChange({ ...value, analytics_bucket: e.target.value })}>{buckets.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Comparison" : "Porównanie"}</label><select className="form-select" value={value.compare_mode} onChange={(e) => onChange({ ...value, compare_mode: e.target.value })}><option value="none">{translateCompareMode(language, "none")}</option>{compareModes.filter((item) => item !== "none").map((item) => <option key={item} value={item}>{translateCompareMode(language, item)}</option>)}</select></div></div><div className="d-flex justify-content-end gap-2"><button className="btn btn-outline-secondary" onClick={onReset} disabled={saving || !dirty}>{language === "en" ? "Discard changes" : "Odrzuć zmiany"}</button><button className="btn btn-primary" onClick={onSave} disabled={saving || !dirty || !canSave}>{saving ? (language === "en" ? "Saving..." : "Zapisywanie...") : (language === "en" ? "Save kiosk settings" : "Zapisz ustawienia kiosku")}</button></div></div></div><KioskLayoutPanel language={language} widgets={widgets} onChange={(widgetsValue) => onChange({ ...value, widgets: widgetsValue })} labels={labels} /></div>; }
|
||||
function KioskLinkPanel({ language, publicKioskUrl, privateKioskUrl, publicSettings, privateSettings }: { language: Language; publicKioskUrl: string; privateKioskUrl: string; publicSettings: KioskSettingsPayload; privateSettings: KioskSettingsPayload }) { const [copied, setCopied] = useState<"public" | "private" | null>(null); const copy = async (value: string, mode: "public" | "private") => { await navigator.clipboard.writeText(value); setCopied(mode); window.setTimeout(() => setCopied(null), 1500); }; return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{language === "en" ? "Kiosk links" : "Linki kiosku"}</h3></div><div className="card-body d-flex flex-column gap-4"><div className="d-flex flex-column gap-2"><div className="fw-semibold">{language === "en" ? "Public kiosk" : "Kiosk publiczny"}</div><div className="text-secondary small">{language === "en" ? "Read-only access without login." : "Podgląd bez logowania, tylko odczyt."}</div><input className="form-control" value={publicKioskUrl} readOnly /><div className="small text-secondary">{language === "en" ? "Ranges:" : "Zakresy:"} live {publicSettings.realtime_range}, analytics {publicSettings.analytics_range}</div><button className="btn btn-primary" onClick={() => copy(publicKioskUrl, "public")}>{copied === "public" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy public link" : "Kopiuj link publiczny")}</button></div><div className="d-flex flex-column gap-2 border-top pt-3"><div className="fw-semibold">{language === "en" ? "Private kiosk" : "Kiosk prywatny"}</div><div className="text-secondary small">{language === "en" ? "Requires login and uses private kiosk settings." : "Wymaga logowania i używa prywatnych ustawień kiosku."}</div><input className="form-control" value={privateKioskUrl} readOnly /><div className="small text-secondary">{language === "en" ? "Ranges:" : "Zakresy:"} live {privateSettings.realtime_range}, analytics {privateSettings.analytics_range}</div><button className="btn btn-outline-primary" onClick={() => copy(privateKioskUrl, "private")}>{copied === "private" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy private link" : "Kopiuj link prywatny")}</button></div></div></div>; }
|
||||
function AppearancePanel({ language, theme, setTheme, viewMode, setViewMode, userName }: { language: Language; theme: ThemeMode; setTheme: (value: ThemeMode) => void; viewMode: ViewMode; setViewMode: (value: ViewMode) => void; userName: string; }) { return <div className="card pv-card h-100"><div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2"><div><h3 className="card-title">{language === "en" ? "Appearance and mode" : "Wygląd i tryb pracy"}</h3><div className="text-secondary small">{language === "en" ? "Most-used display settings in one place." : "Najczęściej używane ustawienia wyświetlania w jednym miejscu."}</div></div>{userName ? <span className="badge bg-primary-lt text-primary">{userName}</span> : null}</div><div className="card-body d-flex flex-column gap-4"><div><div className="fw-semibold mb-2">{t(language, "theme")}</div><div className="btn-group w-100"><button className={`btn ${theme === "light" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setTheme("light")}>{t(language, "light")}</button><button className={`btn ${theme === "dark" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setTheme("dark")}>{t(language, "dark")}</button></div></div><div className="border-top pt-3"><div className="fw-semibold mb-2">{t(language, "viewMode")}</div><div className="btn-group w-100"><button className={`btn ${viewMode === "normal" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setViewMode("normal")}>{t(language, "normalMode")}</button><button className={`btn ${viewMode === "kiosk" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setViewMode("kiosk")}>{t(language, "kioskMode")}</button></div></div></div></div>; }
|
||||
function MetricSelectorCard({ language, title, items, selected, onChange }: { language: Language; title: string; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; }) { const toggle = (metricId: string) => onChange(selected.includes(metricId) ? selected.filter((item) => item !== metricId) : [...selected, metricId]); return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{title}</h3></div><div className="card-body d-flex flex-column gap-2">{items.map((item) => <label key={item.metric_id} className="form-check"><input className="form-check-input" type="checkbox" checked={selected.includes(item.metric_id)} onChange={() => toggle(item.metric_id)} /><span className="form-check-label">{item.label} <span className="text-secondary small">{item.unit}</span></span></label>)}</div></div>; }
|
||||
function LiveChartMetricsPanel({ language, items, selected, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; }) { return <MetricSelectorCard language={language} title={language === "en" ? "Live chart metrics" : "Metryki wykresu live"} items={items} selected={selected} onChange={onChange} />; }
|
||||
function BlockVisibilityPanel({ language, items, config, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; config: Record<BlockTarget, string[]>; onChange: (value: Record<BlockTarget, string[]>) => void; }) { const toggle = (target: BlockTarget, metricId: string) => { const selected = config[target]; onChange({ ...config, [target]: selected.includes(metricId) ? selected.filter((item) => item !== metricId) : [...selected, metricId] }); }; return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{language === "en" ? "Metric visibility in blocks" : "Widoczność metryk w blokach"}</h3></div><div className="card-body"><div className="row g-3"><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">Hero metrics</div>{items.map((item) => <label key={`hero-${item.metric_id}`} className="form-check d-block mb-2"><input className="form-check-input" type="checkbox" checked={config.hero.includes(item.metric_id)} onChange={() => toggle("hero", item.metric_id)} /><span className="form-check-label">{item.label}</span></label>)}</div></div><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">Quick metrics</div>{items.map((item) => <label key={`quick-${item.metric_id}`} className="form-check d-block mb-2"><input className="form-check-input" type="checkbox" checked={config.quick.includes(item.metric_id)} onChange={() => toggle("quick", item.metric_id)} /><span className="form-check-label">{item.label}</span></label>)}</div></div></div></div></div>; }
|
||||
function KioskModeCard({ active, title, subtitle, onClick }: { active: boolean; title: string; subtitle: string; onClick: () => void; }) { return <button className={`btn text-start w-100 p-3 ${active ? "btn-primary" : "btn-outline-secondary"}`} onClick={onClick}><div className="fw-semibold mb-1">{title}</div><div className={active ? "text-white text-opacity-75 small" : "text-secondary small"}>{subtitle}</div></button>; }
|
||||
function KioskLayoutPanel({ language, widgets, onChange, labels }: { language: Language; widgets: WidgetId[]; onChange: (value: WidgetId[]) => void; labels: Map<WidgetId, string>; }) { const available = widgetOrder.map((item) => item.id); const selected = widgets; const unselected = available.filter((item) => !selected.includes(item)); const move = (id: WidgetId, direction: -1 | 1) => { const index = selected.indexOf(id); if (index === -1) return; const target = index + direction; if (target < 0 || target >= selected.length) return; const next = [...selected]; [next[index], next[target]] = [next[target], next[index]]; onChange(next); }; const toggle = (id: WidgetId) => { if (selected.includes(id)) { const next = selected.filter((item) => item !== id); onChange(next.length ? next : selected); return; } onChange([...selected, id]); }; return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{language === "en" ? "3. Section order" : "3. Kolejność sekcji"}</h3><div className="text-secondary small">{language === "en" ? "Top list is shown in kiosk from left to right, top to bottom." : "Lista u góry jest wyświetlana w kiosku dokładnie w tej kolejności."}</div></div></div><div className="card-body"><div className="row g-3"><div className="col-12"><div className="alert alert-info py-2 mb-0">{language === "en" ? "Tip: keep the most important sections first: hero, chart, strings/status." : "Wskazówka: na początku trzymaj najważniejsze sekcje: hero, wykres, stringi/status."}</div></div><div className="col-12 col-lg-7"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{language === "en" ? "Visible in kiosk" : "Widoczne w kiosku"}</div><div className="d-flex flex-column gap-2">{selected.map((id, index) => <div key={id} className="d-flex align-items-center justify-content-between gap-2 border rounded-3 px-3 py-2 bg-body-tertiary"><div><div className="fw-medium">{index + 1}. {labels.get(id)}</div><div className="text-secondary small">{widgetOrder.find((item) => item.id === id)?.tab}</div></div><div className="btn-list"><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, -1)}>{language === "en" ? "Up" : "Wyżej"}</button><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, 1)}>{language === "en" ? "Down" : "Niżej"}</button><button className="btn btn-sm btn-outline-danger" onClick={() => toggle(id)}><IconX size={16} /></button></div></div>)}</div></div></div><div className="col-12 col-lg-5"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{language === "en" ? "Available sections" : "Dostępne sekcje"}</div><div className="d-flex flex-wrap gap-2">{unselected.map((id) => <button key={id} className="btn btn-outline-primary" onClick={() => toggle(id)}>+ {labels.get(id)}</button>)}</div></div></div></div></div></div>; }
|
||||
function KioskSettingsEditorPanel({ language, value, onChange, onSave, onReset, selectedMode, onModeChange, labels, buckets, compareModes, saving, dirty, canSave, saveNotice }: { language: Language; value: KioskSettingsPayload; onChange: (value: KioskSettingsPayload) => void; onSave: () => void; onReset: () => void; selectedMode: "public" | "private"; onModeChange: (value: "public" | "private") => void; labels: Map<WidgetId, string>; buckets: Array<{ key: string; label: string }>; compareModes: string[]; saving: boolean; dirty: boolean; canSave: boolean; saveNotice: string | null; }) { const widgets = toWidgetIds(value.widgets); return <div className="d-flex flex-column gap-3"><div className="card pv-card"><div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2"><div><h3 className="card-title">{language === "en" ? "1. Kiosk type and ranges" : "1. Typ kiosku i zakresy"}</h3><div className="text-secondary small">{language === "en" ? "First choose who will use the kiosk, then fine-tune what they see." : "Najpierw wybierz odbiorcę kiosku, potem dopracuj to co widzi."}</div></div><span className={`badge ${dirty ? "bg-yellow-lt text-yellow" : "bg-green-lt text-green"}`}>{dirty ? (language === "en" ? "Unsaved changes" : "Niezapisane zmiany") : (language === "en" ? "Up to date" : "Aktualne")}</span></div><div className="card-body d-flex flex-column gap-4"><div className="row g-3"><div className="col-12 col-md-6"><KioskModeCard active={selectedMode === "private"} title={language === "en" ? "Private kiosk" : "Kiosk prywatny"} subtitle={language === "en" ? "For logged-in users. Uses private ranges and layout." : "Dla zalogowanych użytkowników. Korzysta z prywatnych zakresów i układu."} onClick={() => onModeChange("private")} /></div><div className="col-12 col-md-6"><KioskModeCard active={selectedMode === "public"} title={language === "en" ? "Public kiosk" : "Kiosk publiczny"} subtitle={language === "en" ? "Read-only screen without login. Safer for TV or hallway display." : "Ekran tylko do odczytu bez logowania. Dobry na TV lub monitor ogólnodostępny."} onClick={() => onModeChange("public")} /></div></div><div className="row g-3"><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Live chart range" : "Zakres wykresu live"}</label><select className="form-select" value={value.realtime_range} onChange={(e) => onChange({ ...value, realtime_range: e.target.value })}>{liveRangeOptions(language).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Analytics range" : "Zakres analityki"}</label><select className="form-select" value={value.analytics_range} onChange={(e) => onChange({ ...value, analytics_range: e.target.value })}>{analyticsRangeOptions(language).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Aggregation" : "Agregacja"}</label><select className="form-select" value={value.analytics_bucket} onChange={(e) => onChange({ ...value, analytics_bucket: e.target.value })}>{buckets.map((item) => <option key={item.key} value={item.key}>{translateBucket(language, item.key)}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Comparison on kiosk" : "Porównanie w kiosku"}</label><select className="form-select" value={value.compare_mode} onChange={(e) => onChange({ ...value, compare_mode: e.target.value })}><option value="none">{translateCompareMode(language, "none")}</option>{compareModes.filter((item) => item !== "none").map((item) => <option key={item} value={item}>{translateCompareMode(language, item)}</option>)}</select></div></div><div className="row g-3"><StatusStat label={language === "en" ? "Visible sections" : "Widoczne sekcje"} value={String(widgets.length)} /><StatusStat label={language === "en" ? "Live" : "Live"} value={value.realtime_range} /><StatusStat label={language === "en" ? "Analytics" : "Analityka"} value={value.analytics_range} /><StatusStat label={language === "en" ? "Bucket" : "Bucket"} value={translateBucket(language, value.analytics_bucket)} /><StatusStat label={language === "en" ? "Comparison" : "Porównanie"} value={translateCompareMode(language, value.compare_mode)} /><StatusStat label={language === "en" ? "Save state" : "Stan zapisu"} value={saveNotice || (dirty ? (language === "en" ? "Waiting to save" : "Czeka na zapis") : (language === "en" ? "Saved" : "Zapisane"))} /></div><div className="d-flex flex-wrap justify-content-end gap-2 border-top pt-3"><button className="btn btn-outline-secondary" onClick={onReset} disabled={saving || !dirty}>{language === "en" ? "Discard changes" : "Odrzuć zmiany"}</button><button className="btn btn-primary" onClick={onSave} disabled={savLine truncated
|
||||
function KioskLinkPanel({ language, publicKioskUrl, privateKioskUrl, publicSettings, privateSettings }: { language: Language; publicKioskUrl: string; privateKioskUrl: string; publicSettings: KioskSettingsPayload; privateSettings: KioskSettingsPayload }) { const [copied, setCopied] = useState<"public" | "private" | null>(null); const copy = async (value: string, mode: "public" | "private") => { await navigator.clipboard.writeText(value); setCopied(mode); window.setTimeout(() => setCopied(null), 1500); }; return <div className="d-flex flex-column gap-3"><div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "2. Share kiosk" : "2. Udostępnianie kiosku"}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="border rounded-3 p-3"><div className="d-flex justify-content-between align-items-start gap-2 mb-2"><div><div className="fw-semibold">{language === "en" ? "Public kiosk" : "Kiosk publiczny"}</div><div className="text-secondary small">{language === "en" ? "No login required." : "Nie wymaga logowania."}</div></div><span className="badge bg-green-lt text-green">TV</span></div><input className="form-control mb-2" value={publicKioskUrl} readOnly /><div className="small text-secondary mb-3">{language === "en" ? "Live" : "Live"}: {publicSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {publicSettings.analytics_range}</div><button className="btn btn-primary w-100" onClick={() => copy(publicKioskUrl, "public")}>{copied === "public" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy public link" : "Kopiuj link publiczny")}</button></div><div className="border rounded-3 p-3"><div className="d-flex justify-content-between align-items-start gap-2 mb-2"><div><div className="fw-semibold">{language === "en" ? "Private kiosk" : "Kiosk prywatny"}</div><div className="text-secondary small">{language === "en" ? "Requires login." : "Wymaga logowania."}</div></div><span className="badge bg-primary-lt text-primary">{language === "en" ? "Secure" : "Bezpieczny"}</span></div><input className="form-control mb-2" value={privateKioskUrl} readOnly /><div className="small text-secondary mb-3">{language === "en" ? "Live" : "Live"}: {privateSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {privateSettings.analytics_range}</div><button className="btn btn-outline-primary w-100" onClick={() => copy(privateKioskUrl, "private")}>{copied === "private" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy private link" : "Kopiuj link prywatny")}</button></div></div></div><div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Quick guidance" : "Szybka wskazówka"}</h3></div><div className="card-body"><div className="text-secondary small">{language === "en" ? "Public kiosk is best for shared screens. Private kiosk is better when you want full data access after login." : "Publiczny kiosk sprawdzi się na współdzielonych ekranach. Prywatny kiosk jest lepszy, gdy po zalogowaniu ma być dostęp do pełnych danych."}</div></div></div></div>; }
|
||||
function ActionTile({ active, icon, title, subtitle, onClick }: { active: boolean; icon: ReactNode; title: string; subtitle: string; onClick: () => void; }) { return <button className={`btn text-start w-100 h-100 p-3 ${active ? "btn-primary" : "btn-outline-secondary"}`} onClick={onClick}><div className="d-flex align-items-center gap-2 mb-2"><span>{icon}</span><span className="fw-semibold">{title}</span></div><div className={active ? "text-white text-opacity-75 small" : "text-secondary small"}>{subtitle}</div></button>; }
|
||||
function AppearancePanel({ language, setLanguage, theme, setTheme, viewMode, setViewMode, userName }: { language: Language; setLanguage: (value: Language) => void; theme: ThemeMode; setTheme: (value: ThemeMode) => void; viewMode: ViewMode; setViewMode: (value: ViewMode) => void; userName: string; }) { return <div className="card pv-card h-100"><div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2"><div><h3 className="card-title">{language === "en" ? "Quick settings" : "Szybkie ustawienia"}</h3><div className="text-secondary small">{language === "en" ? "Most useful display options in one place." : "Najważniejsze opcje ekranu w jednym miejscu."}</div></div>{userName ? <span className="badge bg-primary-lt text-primary">{userName}</span> : null}</div><div className="card-body d-flex flex-column gap-4"><div><div className="fw-semibold mb-2">{t(language, "theme")}</div><div className="row g-2"><div className="col-12 col-sm-6"><ActionTile active={theme === "light"} icon={<IconSun size={18} />} title={t(language, "light")} subtitle={language === "en" ? "Bright interface for office work." : "Jasny interfejs do pracy w dzień."} onClick={() => setTheme("light")} /></div><div className="col-12 col-sm-6"><ActionTile active={theme === "dark"} icon={<IconMoon size={18} />} title={t(language, "dark")} subtitle={language === "en" ? "Better contrast for dashboards and TV." : "Lepszy kontrast dla dashboardów i TV."} onClick={() => setTheme("dark")} /></div></div></div><div className="border-top pt-3"><div className="fw-semibold mb-2">{t(language, "viewMode")}</div><div className="row g-2"><div className="col-12 col-sm-6"><ActionTile active={viewMode === "normal"} icon={<IconSettings size={18} />} title={t(language, "normalMode")} subtitle={language === "en" ? "Full navigation and controls." : "Pełna nawigacja i sterowanie."} onClick={() => setViewMode("normal")} /></div><div className="col-12 col-sm-6"><ActionTile active={viewMode === "kiosk"} icon={<IconDeviceDesktop size={18} />} title={t(language, "kioskMode")} subtitle={language === "en" ? "Simplified screen for wall monitor." : "Uproszczony widok na ekran ścienny."} onClick={() => setViewMode("kiosk")} /></div></div></div><div className="border-top pt-3"><div className="fw-semibold mb-2">{language === "en" ? "Language" : "Język"}</div><div className="row g-2"><div className="col-12 col-sm-6"><ActionTile active={language === "pl"} icon={<IconLanguage size={18} />} title="Polski" subtitle={language === "en" ? "Switch interface to Polish." : "Przełącz interfejs na polski."} onClick={() => setLanguage("pl")} /></div><div className="col-12 col-sm-6"><ActionTile active={language === "en"} icon={<IconLanguage size={18} />} title="English" subtitle={language === "en" ? "Switch interface to English." : "Przełącz interfejs na angielski."} onClick={() => setLanguage("en")} /></div></div></div></div></div>; }
|
||||
function MetricSelectorCard({ language, title, items, selected, onChange }: { language: Language; title: string; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; }) { const toggle = (metricId: string) => onChange(selected.includes(metricId) ? selected.filter((item) => item !== metricId) : [...selected, metricId]); return <div className="card pv-card h-100"><div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2"><div><h3 className="card-title">{title}</h3><div className="text-secondary small">{language === "en" ? "Select what should appear on charts." : "Wybierz co ma pojawiać się na wykresach."}</div></div><span className="badge bg-primary-lt text-primary">{selected.length}</span></div><div className="card-body"><div className="d-flex flex-wrap gap-2">{items.map((item) => <button key={item.metric_id} className={`btn ${selected.includes(item.metric_id) ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => toggle(item.metric_id)}>{item.label} <span className="opacity-75">({item.unit})</span></button>)}</div></div></div>; }
|
||||
function LiveChartMetricsPanel({ language, items, selected, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; }) { return <MetricSelectorCard language={language} title={language === "en" ? "Live chart metrics" : "Metryki wykresów"} items={items} selected={selected} onChange={onChange} />; }
|
||||
function BlockVisibilityPanel({ language, items, config, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; config: Record<BlockTarget, string[]>; onChange: (value: Record<BlockTarget, string[]>) => void; }) { const toggle = (target: BlockTarget, metricId: string) => { const selected = config[target]; onChange({ ...config, [target]: selected.includes(metricId) ? selected.filter((item) => item !== metricId) : [...selected, metricId] }); }; const Section = ({ target, title }: { target: BlockTarget; title: string }) => <div className="border rounded-3 p-3 h-100"><div className="d-flex justify-content-between align-items-center mb-3"><div className="fw-semibold">{title}</div><span className="badge bg-primary-lt text-primary">{config[target].length}</span></div><div className="d-flex flex-wrap gap-2">{items.map((item) => <button key={`${target}-${item.metric_id}`} className={`btn btn-sm ${config[target].includes(item.metric_id) ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => toggle(target, item.metric_id)}>{item.label}</button>)}</div></div>; return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{language === "en" ? "Metric visibility in blocks" : "Widoczność metryk w blokach"}</h3><div className="text-secondary small">{language === "en" ? "Control which KPIs appear in hero and quick sections." : "Steruj tym, które KPI pojawiają się w sekcji hero i szybkich metrykach."}</div></div></div><div className="card-body"><div className="row g-3"><div className="col-12"><Section target="hero" title="Hero metrics" /></div><div className="col-12"><Section target="quick" title="Quick metrics" /></div></div></div></div>; }
|
||||
function DiagnosticBadge({ ok, label }: { ok: boolean; label: string }) { return <span className={`badge ${ok ? "bg-green-lt text-green" : "bg-red-lt text-red"}`}>{label}</span>; }
|
||||
function DiagnosticPanel({ language, locale, data, loading, onRefresh }: { language: Language; locale: string; data?: DiagnosticsPayload; loading: boolean; onRefresh: () => void; }) { return <div className="card pv-card h-100"><div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2"><div><h3 className="card-title">{language === "en" ? "Diagnostics" : "Diagnostyka"}</h3><div className="text-secondary small">{language === "en" ? "API, InfluxDB and application status." : "Stan API, InfluxDB i aplikacji."}</div></div><button className="btn btn-outline-secondary btn-sm" onClick={onRefresh}><IconRefresh size={16} className="me-1" />{language === "en" ? "Refresh" : "Odśwież"}</button></div><div className="card-body d-flex flex-column gap-3">{loading && !data ? <div className="text-secondary">{t(language, "loading")}…</div> : null}{data ? <><div className="row g-3"><StatusStat label="API" value={data.api.status} /><StatusStat label="InfluxDB" value={data.influx.status} /><StatusStat label={language === "en" ? "Uptime" : "Czas pracy"} value={formatDurationShort(data.app.uptime_seconds, locale)} /><StatusStat label={language === "en" ? "Version" : "Wersja"} value={data.app.version} /><StatusStat label="PID" value={String(data.app.pid)} /><StatusStat label="Python" value={data.app.python_version} /></div><div className="row g-3"><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100 d-flex flex-column gap-2"><div className="d-flex justify-content-between align-items-center"><div className="fw-semibold">{language === "en" ? "InfluxDB connection" : "Połączenie z InfluxDB"}</div><DiagnosticBadge ok={data.influx.reachable} label={data.influx.reachable ? (language === "en" ? "Connected" : "Połączono") : (language === "en" ? "Error" : "Błąd")} /></div><div className="small text-secondary">URL</div><div className="fw-medium">{data.influx.url}</div><div className="small text-secondary">{language === "en" ? "Database" : "Baza"}</div><div className="fw-medium">{data.influx.database}</div><div className="small text-secondary">{language === "en" ? "User" : "Użytkownik"}</div><div className="fw-medium">{data.influx.username_masked || "—"}</div><div className="small text-secondary">{language === "en" ? "Timeout / SSL" : "Timeout / SSL"}</div><div className="fw-medium">{data.influx.timeout_seconds}s / {data.influx.verify_ssl ? "verify" : "no-verify"}</div>{data.influx.error ? <div className="alert alert-danger py-2 mb-0">{data.influx.error}</div> : null}</div></div><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100 d-flex flex-column gap-2"><div className="d-flex justify-content-between align-items-center"><div className="fw-semibold">{language === "en" ? "Application details" : "Szczegóły aplikacji"}</div><DiagnosticBadge ok={data.api.status === "ok"} label={data.api.status} /></div><div className="small text-secondary">{language === "en" ? "API prefix" : "Prefix API"}</div><div className="fw-medium">{data.api.prefix}</div><div className="small text-secondary">{language === "en" ? "Started at" : "Uruchomiono"}</div><div className="fw-medium">{formatDateTime(data.app.started_at, locale)}</div><div className="small text-secondary">{language === "en" ? "Timezone" : "Strefa czasu"}</div><div className="fw-medium">{data.app.timezone}</div><div className="small text-secondary">{language === "en" ? "SQLite" : "SQLite"}</div><div className="fw-medium text-break">{data.storage.sqlite_path}</div><div className="small text-secondary">{language === "en" ? "History sync" : "Synchronizacja historii"}</div><div className="fw-medium">{data.storage.historical_import_enabled ? (language === "en" ? "Enabled" : "Włączona") : (language === "en" ? "Disabled" : "Wyłączona")} · auto: {data.storage.auto_sync_enabled ? "on" : "off"} · chunk: {data.storage.default_chunk_days}</div></div></div></div></> : <div className="text-secondary">{language === "en" ? "No diagnostics data." : "Brak danych diagnostycznych."}</div>}</div></div>; }
|
||||
function AdminUsersPanel({ language, users, newUser, onNewUserChange, onCreate, passwordReset, onPasswordResetChange, onResetPassword }: { language: Language; users: AuthUsersPayload["items"]; newUser: { username: string; display_name: string; password: string; role: string }; onNewUserChange: (value: { username: string; display_name: string; password: string; role: string }) => void; onCreate: () => void; passwordReset: { username: string; password: string }; onPasswordResetChange: (value: { username: string; password: string }) => void; onResetPassword: () => void; }) { return <div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Admin user management" : "Zarządzanie użytkownikami"}</h3></div><div className="card-body"><div className="row g-3"><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{language === "en" ? "Create user" : "Dodaj użytkownika"}</div><div className="row g-2"><div className="col-md-6"><input className="form-control" placeholder={language === "en" ? "Username" : "Login"} value={newUser.username} onChange={(e) => onNewUserChange({ ...newUser, username: e.target.value })} /></div><div className="col-md-6"><input className="form-control" placeholder={language === "en" ? "Display name" : "Nazwa"} value={newUser.display_name} onChange={(e) => onNewUserChange({ ...newUser, display_name: e.target.value })} /></div><div className="col-md-6"><input className="form-control" type="password" placeholder={language === "en" ? "Password" : "Hasło"} value={newUser.password} onChange={(e) => onNewUserChange({ ...newUser, password: e.target.value })} /></div><div className="col-md-6"><select className="form-select" value={newUser.role} onChange={(e) => onNewUserChange({ ...newUser, role: e.target.value })}><option value="user">user</option><option value="admin">admin</option></select></div><div className="col-12"><button className="btn btn-primary" onClick={onCreate}>{language === "en" ? "Create user" : "Dodaj użytkownika"}</button></div></div></div></div><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{language === "en" ? "Reset password" : "Zmiana hasła"}</div><div className="row g-2"><div className="col-md-6"><select className="form-select" value={passwordReset.username} onChange={(e) => onPasswordResetChange({ ...passwordReset, username: e.target.value })}><option value="">{language === "en" ? "Select user" : "Wybierz użytkownika"}</option>{users.map((user) => <option key={user.username} value={user.username}>{user.username}</option>)}</select></div><div className="col-md-6"><input className="form-control" type="password" placeholder={language === "en" ? "New password" : "Nowe hasło"} value={passwordReset.password} onChange={(e) => onPasswordResetChange({ ...passwordReset, password: e.target.value })} /></div><div className="col-12"><button className="btn btn-outline-primary" onClick={onResetPassword}>{language === "en" ? "Reset password" : "Zmień hasło"}</button></div></div></div></div><div className="col-12"><div className="table-responsive"><table className="table table-vcenter card-table"><thead><tr><th>{language === "en" ? "Username" : "Login"}</th><th>{language === "en" ? "Display name" : "Nazwa"}</th><th>Role</th><th>{language === "en" ? "Updated" : "Aktualizacja"}</th></tr></thead><tbody>{users.map((user) => <tr key={user.username}><td>{user.username}</td><td>{user.display_name}</td><td><span className="badge bg-primary-lt text-primary">{user.role}</span></td><td>{formatDateTime(user.updated_at, language === "en" ? "en-GB" : "pl-PL")}</td></tr>)}</tbody></table></div></div></div></div></div>; }
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AuthStatus,
|
||||
AuthUsersPayload,
|
||||
DashboardConfig,
|
||||
DiagnosticsPayload,
|
||||
KioskSettingsPayload,
|
||||
DistributionPayload,
|
||||
HistoryPayload,
|
||||
@@ -127,6 +128,47 @@ export const api = {
|
||||
DEMO_MODE
|
||||
? demoResponse(() => ({ ...demoHistoricalStatus, running: false, state: "cancelled", message: "Tryb demo: anulowano" }))
|
||||
: request<HistoricalStatus>("/historical/cancel", { method: "POST", body: JSON.stringify({}) }),
|
||||
getDiagnostics: (): Promise<DiagnosticsPayload> => (
|
||||
DEMO_MODE
|
||||
? demoResponse<DiagnosticsPayload>(() => ({
|
||||
app: {
|
||||
name: demoConfig.app.name,
|
||||
version: demoConfig.app.version,
|
||||
debug: true,
|
||||
timezone: demoConfig.app.timezone,
|
||||
site_name: demoConfig.app.site_name,
|
||||
installed_power_kwp: demoConfig.app.installed_power_kwp,
|
||||
auth_enabled: true,
|
||||
started_at: new Date(Date.now() - 1000 * 60 * 42).toISOString(),
|
||||
uptime_seconds: 1000 * 60 * 42,
|
||||
python_version: "demo",
|
||||
pid: 1,
|
||||
},
|
||||
api: {
|
||||
status: "ok",
|
||||
prefix: "/api/v1",
|
||||
cors_origins_count: 2,
|
||||
},
|
||||
influx: {
|
||||
status: "connected",
|
||||
reachable: true,
|
||||
database_exists: true,
|
||||
url: "http://127.0.0.1:8086",
|
||||
database: "ha",
|
||||
username_masked: "demo",
|
||||
verify_ssl: false,
|
||||
timeout_seconds: 15,
|
||||
error: null,
|
||||
},
|
||||
storage: {
|
||||
sqlite_path: "/data/pv_insight.sqlite3",
|
||||
historical_import_enabled: true,
|
||||
auto_sync_enabled: true,
|
||||
default_chunk_days: 7,
|
||||
},
|
||||
}))
|
||||
: request<DiagnosticsPayload>("/dashboard/diagnostics")
|
||||
),
|
||||
getUsers: () => (DEMO_MODE ? demoResponse(() => ({ items: [] })) : request<AuthUsersPayload>("/auth/users")),
|
||||
createUser: (payload: { username: string; password: string; role: string; display_name?: string }) =>
|
||||
DEMO_MODE ? demoResponse(() => payload) : request("/auth/users", { method: "POST", body: JSON.stringify(payload) }),
|
||||
|
||||
@@ -200,3 +200,12 @@ body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.pv-card .btn.text-start {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.text-break {
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -273,3 +273,42 @@ export interface KioskSettingsPayload {
|
||||
updated_at?: string | null;
|
||||
updated_by?: string | null;
|
||||
}
|
||||
|
||||
|
||||
export interface DiagnosticsPayload {
|
||||
app: {
|
||||
name: string;
|
||||
version: string;
|
||||
debug: boolean;
|
||||
timezone: string;
|
||||
site_name: string;
|
||||
installed_power_kwp: number;
|
||||
auth_enabled: boolean;
|
||||
started_at: string;
|
||||
uptime_seconds: number;
|
||||
python_version: string;
|
||||
pid: number;
|
||||
};
|
||||
api: {
|
||||
status: string;
|
||||
prefix: string;
|
||||
cors_origins_count: number;
|
||||
};
|
||||
influx: {
|
||||
status: "connected" | "error";
|
||||
reachable: boolean;
|
||||
database_exists: boolean;
|
||||
url: string;
|
||||
database: string;
|
||||
username_masked: string;
|
||||
verify_ssl: boolean;
|
||||
timeout_seconds: number;
|
||||
error?: string | null;
|
||||
};
|
||||
storage: {
|
||||
sqlite_path: string;
|
||||
historical_import_enabled: boolean;
|
||||
auto_sync_enabled: boolean;
|
||||
default_chunk_days: number;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user