diff --git a/backend/app/routes/dashboard.py b/backend/app/routes/dashboard.py index 534371e..7379bfb 100644 --- a/backend/app/routes/dashboard.py +++ b/backend/app/routes/dashboard.py @@ -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)) diff --git a/backend/app/services/influx_http.py b/backend/app/services/influx_http.py index 6b750eb..cc571ed 100644 --- a/backend/app/services/influx_http.py +++ b/backend/app/services/influx_http.py @@ -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:] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7da6256..44b7a21 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 { + 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("
"); + }, + }, 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("
"); + }, + }, 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}
${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({ queryKey: ["auth-users"], queryFn: api.getUsers, enabled: dataEnabled && (authQuery.data?.role === "admin"), staleTime: 15_000 }); + const diagnosticsQuery = useQuery({ 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" && <>
historical.start.mutate(payload)} onSyncNow={() => historical.syncNow.mutate()} onCancel={() => historical.cancel.mutate()} />
} - {activeTab === "kiosk" && <>
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)} />
} + {activeTab === "kiosk" && <>
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)} />
} - {activeTab === "settings" && <>
item.metric_id !== "energy_total")} selected={liveMetrics.filter((item) => item !== "energy_total")} onChange={setLiveMetrics} />
{isAdmin ?
createUserMutation.mutate()} passwordReset={passwordReset} onPasswordResetChange={setPasswordReset} onResetPassword={() => resetPasswordMutation.mutate()} />
: null}
} + {activeTab === "settings" && <>
diagnosticsQuery.refetch()} />
item.metric_id !== "energy_total")} selected={liveMetrics.filter((item) => item !== "energy_total")} onChange={setLiveMetrics} />
{isAdmin ?
createUserMutation.mutate()} passwordReset={passwordReset} onPasswordResetChange={setPasswordReset} onResetPassword={() => resetPasswordMutation.mutate()} />
: null}
} ); } @@ -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
{label}
{options.map((option) => )}
; } function SelectField({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return
; } function translateRangeLabel(language: Language, key: string, fallback: string): string { const map: Record = { 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
{cards.map((card) =>
{iconForMetric(card.metric_id)}{card.unit || "live"}
{labelForMetric(language, card.metric_id, card.label)}
{formatValue(card.value, card.unit, card.unit === "kWh" ? 2 : 2, locale)}
{card.subtitle}
)}
; } -function QuickMetrics({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return

{t(language, "quickMetrics")}

{metrics.map((metric) =>
{iconForMetric(metric.metric_id)}
{labelForMetric(language, metric.metric_id, metric.label)}
{metric.unit || t(language, "status")}
{formatValue(metric.value, metric.unit, 2, locale)}
)}
; } +function HeroCards({ cards, locale, language }: { cards: SnapshotPayload["hero_cards"]; locale: string; language: Language }) { return
{cards.map((card) =>
{iconForMetric(card.metric_id)}{card.unit || "live"}
{labelForMetric(language, card.metric_id, card.label)}
{formatValue(card.value, card.unit, 2, locale)}
{card.subtitle}
)}
; } +function QuickMetrics({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return

{t(language, "quickMetrics")}

{metrics.map((metric) =>
{labelForMetric(language, metric.metric_id, metric.label)}
{metric.unit}
{formatValue(metric.value, metric.unit, 2, locale)}
)}
; } +function StatusStat({ label, value }: { label: string; value: string }) { return
{label}
{value}
; } function LiveHistoryPanel({ data, language, theme, title, subtitle }: { data?: HistoryPayload; language: Language; theme: ThemeMode; title: string; subtitle: string }) { return

{title}

{subtitle}
; } -function StatusPanel({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return

{t(language, "systemStatus")}

{metrics.map((metric) =>
{labelForMetric(language, metric.metric_id, metric.label)}
{metric.unit || t(language, "status")}
{formatValue(metric.value, metric.unit, 2, locale)}
)}
; } -function StringsPanel({ rows, locale, language }: { rows: SnapshotGroupRow[]; locale: string; language: Language }) { return

{t(language, "strings")}

{rows.length === 0 ?
{t(language, "noDataDescription")}
: rows.map((row) =>
{row.label}
DC
{Object.values(row.values).map((metric) =>
{labelForMetric(language, metric.metric_id, metric.label)}{formatValue(metric.value, metric.unit, 2, locale)}
)}
)}
; } +function SystemStatus({ items, locale, language }: { items: MetricValue[]; locale: string; language: Language }) { return

{t(language, "systemStatus")}

{items.map((metric) =>
{labelForMetric(language, metric.metric_id, metric.label)}
{metric.unit || metric.status}
{formatValue(metric.value, metric.unit, 2, locale)}
)}
; } +function StringPanels({ rows, locale, language }: { rows: SnapshotGroupRow[]; locale: string; language: Language }) { return

{t(language, "strings")}

{rows.map((row) =>
{row.label}
{Object.values(row.values).map((metric) =>
{labelForMetric(language, metric.metric_id, metric.label)}{formatValue(metric.value, metric.unit, 2, locale)}
)}
)}
; } +function StatusPanel({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return ; } +function StringsPanel({ rows, locale, language }: { rows: SnapshotGroupRow[]; locale: string; language: Language }) { return ; } 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
{items.map((item) =>
{item.key}
{item.value}
{item.badge ?
{item.badge}
: null}
)}
; } + function ProductionPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) { return

{t(language, "chartProduction")}

{t(language, "chartProductionSubtitle")}
; } function ComparisonPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) { const [comparisonDisplayMode, setComparisonDisplayMode] = useState<"line" | "bar">("line"); return

{t(language, "chartComparison")}

{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ą.")}
setComparisonDisplayMode(value as "line" | "bar")} options={[{ key: "line", label: language === "en" ? "Line" : "Linia" }, { key: "bar", label: language === "en" ? "Bars" : "Słupki" }]} />
; + ? (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.")} setComparisonDisplayMode(value as "line" | "bar")} options={[{ key: "line", label: language === "en" ? "Line" : "Linia" }, { key: "bar", label: language === "en" ? "Bars" : "Słupki" }]} />
; } -function DistributionPanel({ data, language, theme, locale }: { data?: DistributionPayload; language: Language; theme: ThemeMode; locale: string }) { return

{t(language, "chartDistribution")}

{formatValue(data?.total, data?.unit ?? "kWh", 2, locale)}
; } +function DistributionPanel({ data, language, theme, locale }: { data?: DistributionPayload; language: Language; theme: ThemeMode; locale: string }) { return

{t(language, "chartDistribution")}

{formatValue(data?.total, data?.unit ?? "kWh", 2, locale)}
; } function HistoricalPanel({ status, language, locale, compact = false }: { status?: HistoricalStatus; language: Language; locale: string; compact?: boolean }) { if (!status) return
{t(language, "noDataDescription")}
; return

{language === "en" ? "Data warehouse" : "Hurtownia danych"}

{t(language, "importArchiveSubtitle")}
{!compact ? <>
{t(language, "activeChunk")}{status.active_chunk_index}/{status.total_chunks}
{status.recent_chunks.map((chunk) => )}
{t(language, "recentChunks")}{t(language, "status")}kWh
#{chunk.chunk_index}
{chunk.start_date} → {chunk.end_date}
{chunk.state}{formatValue(chunk.energy_kwh, "kWh", 2, locale)}
{status.recent_events.map((event, index) =>
{event.title}
{event.message}
{formatShortTime(event.timestamp, locale)}
)}
: null}
; } -function StatusStat({ label, value }: { label: string; value: string }) { return
{label}
{value}
; } 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

{language === "en" ? "Import controls" : "Sterowanie importem"}

setStartDate(event.target.value)} />
setEndDate(event.target.value)} />
setChunkDays(event.target.value)} />
; } -function KioskLayoutPanel({ language, widgets, onChange, labels }: { language: Language; widgets: WidgetId[]; onChange: (value: WidgetId[]) => void; labels: Map; }) { 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

{t(language, "kioskLayout")}

{t(language, "kioskLayoutSubtitle")}
{t(language, "infoSave")}
{t(language, "selected")}
{selected.map((id) =>
{labels.get(id)}
)}
{t(language, "available")}
{unselected.map((id) => )}
; } -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; buckets: Array<{ key: string; label: string }>; compareModes: string[]; saving: boolean; dirty: boolean; canSave: boolean; saveNotice: string | null; }) { const widgets = toWidgetIds(value.widgets); return

{language === "en" ? "Kiosk settings" : "Ustawienia kiosku"}

{dirty ? (language === "en" ? "You have local changes." : "Masz lokalne zmiany.") : (language === "en" ? "No unsaved changes." : "Brak niezapisanych zmian.")}{saveNotice ? {saveNotice} : null}
onChange({ ...value, widgets: widgetsValue })} labels={labels} />
; } -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

{language === "en" ? "Kiosk links" : "Linki kiosku"}

{language === "en" ? "Public kiosk" : "Kiosk publiczny"}
{language === "en" ? "Read-only access without login." : "Podgląd bez logowania, tylko odczyt."}
{language === "en" ? "Ranges:" : "Zakresy:"} live {publicSettings.realtime_range}, analytics {publicSettings.analytics_range}
{language === "en" ? "Private kiosk" : "Kiosk prywatny"}
{language === "en" ? "Requires login and uses private kiosk settings." : "Wymaga logowania i używa prywatnych ustawień kiosku."}
{language === "en" ? "Ranges:" : "Zakresy:"} live {privateSettings.realtime_range}, analytics {privateSettings.analytics_range}
; } -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

{language === "en" ? "Appearance and mode" : "Wygląd i tryb pracy"}

{language === "en" ? "Most-used display settings in one place." : "Najczęściej używane ustawienia wyświetlania w jednym miejscu."}
{userName ? {userName} : null}
{t(language, "theme")}
{t(language, "viewMode")}
; } -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

{title}

{items.map((item) => )}
; } -function LiveChartMetricsPanel({ language, items, selected, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; }) { return ; } -function BlockVisibilityPanel({ language, items, config, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; config: Record; onChange: (value: Record) => 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

{language === "en" ? "Metric visibility in blocks" : "Widoczność metryk w blokach"}

Hero metrics
{items.map((item) => )}
Quick metrics
{items.map((item) => )}
; } +function KioskModeCard({ active, title, subtitle, onClick }: { active: boolean; title: string; subtitle: string; onClick: () => void; }) { return ; } +function KioskLayoutPanel({ language, widgets, onChange, labels }: { language: Language; widgets: WidgetId[]; onChange: (value: WidgetId[]) => void; labels: Map; }) { 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

{language === "en" ? "3. Section order" : "3. Kolejność sekcji"}

{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."}
{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."}
{language === "en" ? "Visible in kiosk" : "Widoczne w kiosku"}
{selected.map((id, index) =>
{index + 1}. {labels.get(id)}
{widgetOrder.find((item) => item.id === id)?.tab}
)}
{language === "en" ? "Available sections" : "Dostępne sekcje"}
{unselected.map((id) => )}
; } +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; buckets: Array<{ key: string; label: string }>; compareModes: string[]; saving: boolean; dirty: boolean; canSave: boolean; saveNotice: string | null; }) { const widgets = toWidgetIds(value.widgets); return

{language === "en" ? "1. Kiosk type and ranges" : "1. Typ kiosku i zakresy"}

{language === "en" ? "First choose who will use the kiosk, then fine-tune what they see." : "Najpierw wybierz odbiorcę kiosku, potem dopracuj to co widzi."}
{dirty ? (language === "en" ? "Unsaved changes" : "Niezapisane zmiany") : (language === "en" ? "Up to date" : "Aktualne")}
onModeChange("private")} />
onModeChange("public")} />
onChange({ ...value, widgets: widgetsValue })} labels={labels} />
; } +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

{language === "en" ? "2. Share kiosk" : "2. Udostępnianie kiosku"}

{language === "en" ? "Public kiosk" : "Kiosk publiczny"}
{language === "en" ? "No login required." : "Nie wymaga logowania."}
TV
{language === "en" ? "Live" : "Live"}: {publicSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {publicSettings.analytics_range}
{language === "en" ? "Private kiosk" : "Kiosk prywatny"}
{language === "en" ? "Requires login." : "Wymaga logowania."}
{language === "en" ? "Secure" : "Bezpieczny"}
{language === "en" ? "Live" : "Live"}: {privateSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {privateSettings.analytics_range}

{language === "en" ? "Quick guidance" : "Szybka wskazówka"}

{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."}
; } +function ActionTile({ active, icon, title, subtitle, onClick }: { active: boolean; icon: ReactNode; title: string; subtitle: string; onClick: () => void; }) { return ; } +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

{language === "en" ? "Quick settings" : "Szybkie ustawienia"}

{language === "en" ? "Most useful display options in one place." : "Najważniejsze opcje ekranu w jednym miejscu."}
{userName ? {userName} : null}
{t(language, "theme")}
} title={t(language, "light")} subtitle={language === "en" ? "Bright interface for office work." : "Jasny interfejs do pracy w dzień."} onClick={() => setTheme("light")} />
} title={t(language, "dark")} subtitle={language === "en" ? "Better contrast for dashboards and TV." : "Lepszy kontrast dla dashboardów i TV."} onClick={() => setTheme("dark")} />
{t(language, "viewMode")}
} title={t(language, "normalMode")} subtitle={language === "en" ? "Full navigation and controls." : "Pełna nawigacja i sterowanie."} onClick={() => setViewMode("normal")} />
} title={t(language, "kioskMode")} subtitle={language === "en" ? "Simplified screen for wall monitor." : "Uproszczony widok na ekran ścienny."} onClick={() => setViewMode("kiosk")} />
{language === "en" ? "Language" : "Język"}
} title="Polski" subtitle={language === "en" ? "Switch interface to Polish." : "Przełącz interfejs na polski."} onClick={() => setLanguage("pl")} />
} title="English" subtitle={language === "en" ? "Switch interface to English." : "Przełącz interfejs na angielski."} onClick={() => setLanguage("en")} />
; } +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

{title}

{language === "en" ? "Select what should appear on charts." : "Wybierz co ma pojawiać się na wykresach."}
{selected.length}
{items.map((item) => )}
; } +function LiveChartMetricsPanel({ language, items, selected, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; }) { return ; } +function BlockVisibilityPanel({ language, items, config, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; config: Record; onChange: (value: Record) => 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 }) =>
{title}
{config[target].length}
{items.map((item) => )}
; return

{language === "en" ? "Metric visibility in blocks" : "Widoczność metryk w blokach"}

{language === "en" ? "Control which KPIs appear in hero and quick sections." : "Steruj tym, które KPI pojawiają się w sekcji hero i szybkich metrykach."}
; } +function DiagnosticBadge({ ok, label }: { ok: boolean; label: string }) { return {label}; } +function DiagnosticPanel({ language, locale, data, loading, onRefresh }: { language: Language; locale: string; data?: DiagnosticsPayload; loading: boolean; onRefresh: () => void; }) { return

{language === "en" ? "Diagnostics" : "Diagnostyka"}

{language === "en" ? "API, InfluxDB and application status." : "Stan API, InfluxDB i aplikacji."}
{loading && !data ?
{t(language, "loading")}…
: null}{data ? <>
{language === "en" ? "InfluxDB connection" : "Połączenie z InfluxDB"}
URL
{data.influx.url}
{language === "en" ? "Database" : "Baza"}
{data.influx.database}
{language === "en" ? "User" : "Użytkownik"}
{data.influx.username_masked || "—"}
{language === "en" ? "Timeout / SSL" : "Timeout / SSL"}
{data.influx.timeout_seconds}s / {data.influx.verify_ssl ? "verify" : "no-verify"}
{data.influx.error ?
{data.influx.error}
: null}
{language === "en" ? "Application details" : "Szczegóły aplikacji"}
{language === "en" ? "API prefix" : "Prefix API"}
{data.api.prefix}
{language === "en" ? "Started at" : "Uruchomiono"}
{formatDateTime(data.app.started_at, locale)}
{language === "en" ? "Timezone" : "Strefa czasu"}
{data.app.timezone}
{language === "en" ? "SQLite" : "SQLite"}
{data.storage.sqlite_path}
{language === "en" ? "History sync" : "Synchronizacja historii"}
{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}
:
{language === "en" ? "No diagnostics data." : "Brak danych diagnostycznych."}
}
; } 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

{language === "en" ? "Admin user management" : "Zarządzanie użytkownikami"}

{language === "en" ? "Create user" : "Dodaj użytkownika"}
onNewUserChange({ ...newUser, username: e.target.value })} />
onNewUserChange({ ...newUser, display_name: e.target.value })} />
onNewUserChange({ ...newUser, password: e.target.value })} />
{language === "en" ? "Reset password" : "Zmiana hasła"}
onPasswordResetChange({ ...passwordReset, password: e.target.value })} />
{users.map((user) => )}
{language === "en" ? "Username" : "Login"}{language === "en" ? "Display name" : "Nazwa"}Role{language === "en" ? "Updated" : "Aktualizacja"}
{user.username}{user.display_name}{user.role}{formatDateTime(user.updated_at, language === "en" ? "en-GB" : "pl-PL")}
; } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d547687..2407572 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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("/historical/cancel", { method: "POST", body: JSON.stringify({}) }), + getDiagnostics: (): Promise => ( + DEMO_MODE + ? demoResponse(() => ({ + 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("/dashboard/diagnostics") + ), getUsers: () => (DEMO_MODE ? demoResponse(() => ({ items: [] })) : request("/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) }), diff --git a/frontend/src/index.css b/frontend/src/index.css index 29ff050..a7b4258 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -200,3 +200,12 @@ body { grid-template-columns: 1fr; } } + + +.pv-card .btn.text-start { + white-space: normal; +} + +.text-break { + word-break: break-word; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c3cf82a..953e9fa 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; + }; +}