diff --git a/backend/app/routes/dashboard.py b/backend/app/routes/dashboard.py index e370b40..3256845 100644 --- a/backend/app/routes/dashboard.py +++ b/backend/app/routes/dashboard.py @@ -31,7 +31,7 @@ def _resolve_kiosk_mode(requested_mode: str, require_write_access: bool = False) if normalized_mode != "private": raise ValueError("Mode musi byc jednym z: public, private") - if (not auth_service.enabled) or session.get("auth_role") == "admin": + if not auth_service.enabled: return "private", "private" username = session.get("auth_user") diff --git a/backend/app/services/kiosk_settings.py b/backend/app/services/kiosk_settings.py index 75bb495..a4428a8 100644 --- a/backend/app/services/kiosk_settings.py +++ b/backend/app/services/kiosk_settings.py @@ -11,6 +11,8 @@ from app.storage.kiosk_settings import SQLiteKioskSettingsRepository VALID_MODES = {"public", "private"} USER_MODE_PREFIX = "user:" DEFAULT_WIDGETS = ["hero", "history", "strings", "status", "production", "comparison", "importStatus"] +DEFAULT_HERO_METRICS = ["ac_power", "dc_power_total", "energy_today", "energy_total"] +DEFAULT_CHART_GROUPS = [{"id": "overview", "title": None, "metric_ids": ["ac_power", "dc_power_total", "inverter_temp"]}] VALID_WIDGETS = {"hero", "quickMetrics", "history", "status", "strings", "production", "comparison", "distribution", "importStatus"} VALID_REALTIME_RANGES = {"today", "yesterday", "6h", "12h", "24h", "48h", "7d"} VALID_ANALYTICS_RANGES = {"today", "yesterday", "7d", "30d", "90d", "365d", "custom"} @@ -42,10 +44,12 @@ class KioskSettingsService: return { "mode": mode, "widgets": list(DEFAULT_WIDGETS), + "hero_metric_ids": list(DEFAULT_HERO_METRICS), "realtime_range": self._default_realtime_range(), "analytics_range": self._default_analytics_range(), "analytics_bucket": self._default_analytics_bucket(), "compare_mode": self._default_compare_mode(), + "chart_groups": self._normalize_chart_groups(None), "updated_at": None, "updated_by": None, } @@ -54,10 +58,12 @@ class KioskSettingsService: cleaned = { "mode": mode, "widgets": self._normalize_widgets(payload.get("widgets")), + "hero_metric_ids": self._normalize_metric_ids(payload.get("hero_metric_ids"), DEFAULT_HERO_METRICS), "realtime_range": self._normalize_realtime_range(payload.get("realtime_range")), "analytics_range": self._normalize_analytics_range(payload.get("analytics_range")), "analytics_bucket": self._normalize_bucket(payload.get("analytics_bucket")), "compare_mode": self._normalize_compare_mode(payload.get("compare_mode")), + "chart_groups": self._normalize_chart_groups(payload.get("chart_groups")), "updated_at": payload.get("updated_at"), "updated_by": payload.get("updated_by"), } @@ -83,6 +89,16 @@ class KioskSettingsService: normalized.append(widget) return normalized or list(DEFAULT_WIDGETS) + def _normalize_metric_ids(self, metric_ids: Any, defaults: list[str]) -> list[str]: + if not isinstance(metric_ids, list): + return list(defaults) + normalized: list[str] = [] + for item in metric_ids: + value = str(item or "").strip() + if value and value not in normalized: + normalized.append(value) + return normalized[:12] or list(defaults) + def _normalize_realtime_range(self, value: Any) -> str: normalized = str(value or self._default_realtime_range()).strip() return normalized if normalized in VALID_REALTIME_RANGES else self._default_realtime_range() @@ -99,6 +115,31 @@ class KioskSettingsService: normalized = str(value or self._default_compare_mode()).strip() return normalized if normalized in self.settings.analytics["compare_modes"] else self._default_compare_mode() + def _normalize_chart_groups(self, groups: Any) -> list[dict[str, Any]]: + if not isinstance(groups, list): + return [dict(item) for item in DEFAULT_CHART_GROUPS] + + normalized: list[dict[str, Any]] = [] + for index, item in enumerate(groups): + if not isinstance(item, dict): + continue + raw_metrics = item.get("metric_ids") + if not isinstance(raw_metrics, list): + continue + metric_ids: list[str] = [] + for metric in raw_metrics: + value = str(metric or "").strip() + if value and value not in metric_ids: + metric_ids.append(value) + if not metric_ids: + continue + chart_id = str(item.get("id") or f"chart_{index + 1}").strip() or f"chart_{index + 1}" + title_raw = item.get("title") + title = None if title_raw is None else str(title_raw).strip() or None + normalized.append({"id": chart_id[:80], "title": title, "metric_ids": metric_ids[:24]}) + + return normalized[:8] or [dict(item) for item in DEFAULT_CHART_GROUPS] + def _default_realtime_range(self) -> str: raw = str(self.settings.realtime.get("history_default_range", "12h")) return raw if raw in VALID_REALTIME_RANGES else "12h" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bb7b9cf..9dfe386 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,6 +35,7 @@ import type { DashboardConfig, DiagnosticsPayload, DistributionPayload, + KioskChartGroup, HistoryPayload, HistoricalStatus, KioskSettingsPayload, @@ -47,6 +48,7 @@ type ThemeMode = "light" | "dark"; type TabKey = "realtime" | "archive" | "analytics" | "warehouse" | "kiosk" | "settings"; type ViewMode = "normal" | "kiosk"; type WidgetId = "hero" | "quickMetrics" | "history" | "status" | "strings" | "production" | "comparison" | "distribution" | "importStatus"; +type LiveWidgetId = "hero" | "quickMetrics" | "history" | "status" | "strings"; type BlockTarget = "hero" | "quick"; const STORAGE_KEYS = { @@ -55,16 +57,20 @@ const STORAGE_KEYS = { kioskWidgets: "pv-kiosk-widgets-v4", viewMode: "pv-view-mode-v4", blockConfig: "pv-block-config-v4", + liveWidgets: "pv-live-widgets-v4", liveMetrics: "pv-live-metrics-v4", archiveMetrics: "pv-archive-metrics-v4", }; const DEFAULT_KIOSK_WIDGETS: WidgetId[] = ["hero", "history", "strings", "status", "production", "comparison", "importStatus"]; +const DEFAULT_LIVE_WIDGETS: LiveWidgetId[] = ["hero", "quickMetrics", "history", "status", "strings"]; const DEFAULT_BLOCK_CONFIG: Record = { hero: ["ac_power", "dc_power_total", "energy_today", "energy_total"], quick: ["energy_today", "energy_yesterday", "energy_total", "dc_power_total", "today_vs_yesterday"], }; const DEFAULT_LIVE_METRICS = ["ac_power", "string_1_power", "string_2_power"]; +const DEFAULT_KIOSK_HERO_METRICS = ["ac_power", "dc_power_total", "energy_today", "energy_total"]; +const DEFAULT_KIOSK_CHART_GROUPS: KioskChartGroup[] = [{ id: "overview", title: null, metric_ids: ["ac_power", "dc_power_total", "inverter_temp"] }]; function getKioskRouteMode(): "public" | "private" | null { const pathname = window.location.pathname.replace(/\/+$/, "") || "/"; if (pathname.endsWith("/kiosk/public")) return "public"; @@ -121,7 +127,7 @@ function buildWidgetLabel(language: Language, widgetId: WidgetId): string { const labels: Record = { hero: language === "en" ? "Hero metrics" : "Karty hero", quickMetrics: t(language, "quickMetrics"), - history: t(language, "chartPowerHistory"), + history: t(language, "kioskCharts"), status: t(language, "systemStatus"), strings: t(language, "strings"), production: t(language, "chartProduction"), @@ -144,11 +150,84 @@ 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 buildDefaultKioskChartGroups(): KioskChartGroup[] { + return DEFAULT_KIOSK_CHART_GROUPS.map((group) => ({ ...group, metric_ids: [...group.metric_ids] })); +} +function sanitizeKioskHeroMetrics(metricIds: string[] | undefined, items?: Array<{ metric_id: string; label: string; unit: string }>): string[] { + const allowed = items?.length ? new Set(items.map((item) => item.metric_id)) : null; + const normalized = Array.from(new Set((metricIds ?? []).map((metricId) => String(metricId || "").trim()).filter(Boolean))).filter((metricId) => !allowed || allowed.has(metricId)); + if (normalized.length) return normalized.slice(0, 8); + if (items?.length) { + const fallback = DEFAULT_KIOSK_HERO_METRICS.filter((metricId) => allowed?.has(metricId)); + return (fallback.length ? fallback : items.slice(0, 4).map((item) => item.metric_id)).slice(0, 8); + } + return [...DEFAULT_KIOSK_HERO_METRICS]; +} +function sanitizeKioskChartGroups(groups: KioskChartGroup[] | undefined, items?: Array<{ metric_id: string; label: string; unit: string }>): KioskChartGroup[] { + const allowed = items?.length ? new Set(items.map((item) => item.metric_id)) : null; + const normalized = (groups ?? []).map((group, index) => ({ + id: String(group?.id || `chart_${index + 1}`), + title: (group?.title ?? "") || null, + metric_ids: Array.from(new Set((group?.metric_ids ?? []).map((metricId) => String(metricId || "").trim()).filter(Boolean))).filter((metricId) => !allowed || allowed.has(metricId)), + })).filter((group) => group.metric_ids.length); + if (normalized.length) return normalized.slice(0, 8); + if (items?.length) { + const preferred = ["ac_power", "dc_power_total", "inverter_temp"].filter((metricId) => allowed?.has(metricId)); + const fallback = preferred.length ? preferred : items.slice(0, 3).map((item) => item.metric_id); + return [{ id: "overview", title: null, metric_ids: fallback }]; + } + return buildDefaultKioskChartGroups(); +} +function buildChartGroupAutoTitle(group: KioskChartGroup, items: Array<{ metric_id: string; label: string; unit: string }>, language: Language): string { + if (group.title?.trim()) return group.title.trim(); + const labels = group.metric_ids.map((metricId) => items.find((item) => item.metric_id === metricId)?.label).filter(Boolean) as string[]; + if (labels.length === 1) return labels[0]; + if (labels.length >= 2 && labels.length <= 3) return labels.join(" / "); + return language === "en" ? `Chart ${group.id}` : `Wykres ${group.id}`; +} +function classifyChartMetric(item: { metric_id: string; label: string; unit: string }): "temp" | "ac" | "dc" | "other" { + const metricId = item.metric_id.toLowerCase(); + const label = item.label.toLowerCase(); + const unit = item.unit.toLowerCase(); + if (metricId.includes("temp") || label.includes("temp") || unit.includes("°") || unit.includes("c")) return "temp"; + if (metricId.includes("ac") || label.includes(" ac") || label.startsWith("ac") || label.includes("falownik")) return "ac"; + if (metricId.includes("dc") || metricId.includes("string_") || label.includes("dc") || label.includes("string")) return "dc"; + return "other"; +} +function buildPresetKioskChartGroups(language: Language, items: Array<{ metric_id: string; label: string; unit: string }>, preset: "single" | "split"): KioskChartGroup[] { + if (!items.length) return buildDefaultKioskChartGroups(); + if (preset === "single") return [{ id: "overview", title: null, metric_ids: items.map((item) => item.metric_id).slice(0, 12) }]; + const grouped: Record<"ac" | "dc" | "temp" | "other", Array<{ metric_id: string; label: string; unit: string }>> = { ac: [], dc: [], temp: [], other: [] }; + items.forEach((item) => grouped[classifyChartMetric(item)].push(item)); + const charts: KioskChartGroup[] = []; + if (grouped.ac.length) charts.push({ id: "ac", title: "AC", metric_ids: grouped.ac.map((item) => item.metric_id).slice(0, 12) }); + if (grouped.dc.length) charts.push({ id: "dc", title: "DC", metric_ids: grouped.dc.map((item) => item.metric_id).slice(0, 12) }); + if (grouped.temp.length) charts.push({ id: "temp", title: language === "en" ? "Temperature" : "Temperatura", metric_ids: grouped.temp.map((item) => item.metric_id).slice(0, 12) }); + if (grouped.other.length) charts.push({ id: "other", title: language === "en" ? "Other" : "Inne", metric_ids: grouped.other.map((item) => item.metric_id).slice(0, 12) }); + return charts.length ? charts : [{ id: "overview", title: null, metric_ids: items.map((item) => item.metric_id).slice(0, 12) }]; +} +function filterHistoryByChartGroup(history: HistoryPayload | undefined, group: KioskChartGroup): HistoryPayload | undefined { + return filterHistoryByMetrics(history, group.metric_ids); +} 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)); + const units = series + .map((item) => item.unit || "") + .filter((item, index, list) => list.indexOf(item) === index); + const yAxes = units.map((unit, index) => { + const position: "left" | "right" = index % 2 === 0 ? "left" : "right"; + return { + type: "value" as const, + name: unit, + position, + offset: index > 1 ? Math.floor((index - 1) / 2) * 56 : 0, + nameTextStyle: { color: palette.text }, + axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) }, + splitLine: index === 0 ? { lineStyle: { color: palette.grid } } : { show: false }, + }; + }); return { color: palette.series, tooltip: { @@ -163,10 +242,10 @@ function buildLiveHistoryOption(history: HistoryPayload | undefined, theme: Them }, }, legend: { top: 0, textStyle: { color: palette.text }, itemGap: 16 }, - grid: { left: 12, right: 16, top: 48, bottom: 12, containLabel: true }, + grid: { left: 12, right: yAxes.length > 1 ? 44 : 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, 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) })), + yAxis: yAxes.length ? yAxes : [{ type: "value" as const, name: buildUnitLabel(series.map((item) => item.unit)), 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, yAxisIndex: Math.max(units.indexOf(item.unit || ""), 0), 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 { @@ -347,12 +426,33 @@ function getInitialLanguage(config?: DashboardConfig): Language { } function getVisibleWidgets(ids: WidgetId[]): WidgetId[] { const base = ids.filter((id, index) => ids.indexOf(id) === index); return base.length > 0 ? base : DEFAULT_KIOSK_WIDGETS; } function toWidgetIds(ids: string[]): WidgetId[] { return getVisibleWidgets(ids.filter((id): id is WidgetId => widgetOrder.some((item) => item.id === id as WidgetId))); } -function getMetricCandidates(snapshot: SnapshotPayload, config?: DashboardConfig) { - const fromConfig = (config?.visible_entities ?? []) - .filter((item) => item.kind === "gauge") - .map((item) => ({ metric_id: item.metric_id, label: item.label, unit: item.unit })); +function getVisibleLiveWidgets(ids: LiveWidgetId[]): LiveWidgetId[] { + const base = ids.filter((id, index) => ids.indexOf(id) === index); + return base.length ? base : DEFAULT_LIVE_WIDGETS; +} +function toLiveWidgetIds(ids: string[]): LiveWidgetId[] { + return getVisibleLiveWidgets(ids.filter((id): id is LiveWidgetId => DEFAULT_LIVE_WIDGETS.includes(id as LiveWidgetId))); +} +function metricToHeroCard(metric: MetricValue): SnapshotPayload["hero_cards"][number] { + const numeric = typeof metric.value === "number" ? metric.value : Number(metric.value); + const accent = metric.metric_id.includes("temp") ? (Number.isFinite(numeric) && numeric >= 70 ? "rose" : Number.isFinite(numeric) && numeric >= 55 ? "amber" : "emerald") : (metric.status === "warn" ? "amber" : metric.status === "critical" ? "rose" : "emerald"); + return { metric_id: metric.metric_id, label: metric.label, value: metric.value, unit: metric.unit, accent, subtitle: metric.unit || metric.status || "" }; +} +function heroCardToMetric(card: SnapshotPayload["hero_cards"][number]): MetricValue { + const numeric = typeof card.value === "number" ? card.value : Number(card.value); + return { metric_id: card.metric_id, label: card.label, unit: card.unit, value: card.value, precision: Number.isFinite(numeric) && !Number.isInteger(numeric) ? 2 : 0, kind: typeof card.value === "string" ? "text" : "gauge", status: "neutral" }; +} +function getChartMetricCandidates(config?: DashboardConfig) { const map = new Map(); - fromConfig.forEach((item) => map.set(item.metric_id, item)); + (config?.visible_entities ?? []) + .filter((item) => item.kind === "gauge") + .forEach((item) => map.set(item.metric_id, { metric_id: item.metric_id, label: item.label, unit: item.unit })); + return [...map.values()]; +} +function getBlockMetricCandidates(snapshot: SnapshotPayload) { + const map = new Map(); + snapshot.hero_cards.forEach((card) => map.set(card.metric_id, { metric_id: card.metric_id, label: card.label, unit: card.unit })); + Object.values(snapshot.kpis ?? {}).forEach((metric) => map.set(metric.metric_id, { metric_id: metric.metric_id, label: metric.label, unit: metric.unit })); return [...map.values()]; } @@ -384,11 +484,12 @@ export default function App() { const [archiveRange, setArchiveRange] = useState("today"); const [liveMetrics, setLiveMetrics] = useState(() => readStorage(STORAGE_KEYS.liveMetrics, DEFAULT_LIVE_METRICS)); const [archiveMetrics, setArchiveMetrics] = useState(() => readStorage(STORAGE_KEYS.archiveMetrics, DEFAULT_LIVE_METRICS)); + const [liveWidgets, setLiveWidgets] = useState(() => getVisibleLiveWidgets(readStorage(STORAGE_KEYS.liveWidgets, DEFAULT_LIVE_WIDGETS))); const [viewMode, setViewMode] = useState(() => { const fromUrl = parseViewModeFromLocation(); return fromUrl === "kiosk" ? fromUrl : readStorage(STORAGE_KEYS.viewMode, "normal", (raw) => (raw === "kiosk" ? "kiosk" : "normal")); }); const [kioskWidgets, setKioskWidgets] = useState(() => getVisibleWidgets(readStorage(STORAGE_KEYS.kioskWidgets, DEFAULT_KIOSK_WIDGETS))); const [kioskEditorMode, setKioskEditorMode] = useState<"private" | "public">("private"); - const [privateKioskDraft, setPrivateKioskDraft] = useState({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }); - const [publicKioskDraft, setPublicKioskDraft] = useState({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }); + const [privateKioskDraft, setPrivateKioskDraft] = useState({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, hero_metric_ids: DEFAULT_KIOSK_HERO_METRICS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none", chart_groups: buildDefaultKioskChartGroups() }); + const [publicKioskDraft, setPublicKioskDraft] = useState({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, hero_metric_ids: DEFAULT_KIOSK_HERO_METRICS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none", chart_groups: buildDefaultKioskChartGroups() }); const [blockConfig, setBlockConfig] = useState>(() => readStorage(STORAGE_KEYS.blockConfig, DEFAULT_BLOCK_CONFIG)); const [loginForm, setLoginForm] = useState({ username: "", password: "" }); const [loginError, setLoginError] = useState(null); @@ -397,8 +498,8 @@ export default function App() { const [kioskSaveNotice, setKioskSaveNotice] = useState>({ public: null, private: null }); const initializedRef = useRef(false); const defaultKioskSerializedRef = useRef>({ - public: JSON.stringify({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }), - private: JSON.stringify({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }), + public: JSON.stringify({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, hero_metric_ids: DEFAULT_KIOSK_HERO_METRICS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none", chart_groups: buildDefaultKioskChartGroups() }), + private: JSON.stringify({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, hero_metric_ids: DEFAULT_KIOSK_HERO_METRICS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none", chart_groups: buildDefaultKioskChartGroups() }), }); const lastSyncedKioskRef = useRef>({ public: defaultKioskSerializedRef.current.public, @@ -417,13 +518,13 @@ export default function App() { }, [config, publicMode]); useEffect(() => { if (!privateKioskSettingsQuery.data) return; - const normalized = { ...privateKioskSettingsQuery.data, mode: "private" as const }; + const normalized = { ...privateKioskSettingsQuery.data, mode: "private" as const, hero_metric_ids: sanitizeKioskHeroMetrics(privateKioskSettingsQuery.data.hero_metric_ids), chart_groups: sanitizeKioskChartGroups(privateKioskSettingsQuery.data.chart_groups) }; lastSyncedKioskRef.current.private = JSON.stringify(normalized); applyKioskDraftChange("private", normalized); }, [privateKioskSettingsQuery.data]); useEffect(() => { if (!publicKioskSettingsQuery.data) return; - const normalized = { ...publicKioskSettingsQuery.data, mode: "public" as const }; + const normalized = { ...publicKioskSettingsQuery.data, mode: "public" as const, hero_metric_ids: sanitizeKioskHeroMetrics(publicKioskSettingsQuery.data.hero_metric_ids), chart_groups: sanitizeKioskChartGroups(publicKioskSettingsQuery.data.chart_groups) }; lastSyncedKioskRef.current.public = JSON.stringify(normalized); applyKioskDraftChange("public", normalized); }, [publicKioskSettingsQuery.data]); @@ -432,6 +533,7 @@ export default function App() { useEffect(() => { syncViewModeToLocation(viewMode); writeStorage(STORAGE_KEYS.viewMode, viewMode); }, [viewMode]); useEffect(() => { writeStorage(STORAGE_KEYS.kioskWidgets, kioskWidgets); }, [kioskWidgets]); useEffect(() => { writeStorage(STORAGE_KEYS.blockConfig, blockConfig); }, [blockConfig]); + useEffect(() => { writeStorage(STORAGE_KEYS.liveWidgets, liveWidgets); }, [liveWidgets]); useEffect(() => { writeStorage(STORAGE_KEYS.liveMetrics, liveMetrics); }, [liveMetrics]); useEffect(() => { writeStorage(STORAGE_KEYS.archiveMetrics, archiveMetrics); }, [archiveMetrics]); @@ -443,34 +545,62 @@ export default function App() { const canSavePrivateKioskSettings = !publicMode && dataEnabled; const canSavePublicKioskSettings = !publicMode && isAdmin; const { snapshot, connected, lastUpdated } = useRealtimeSocket(dataEnabled); - const metricCandidates = useMemo(() => getMetricCandidates(snapshot, config), [snapshot, config]); + const chartMetricCandidates = useMemo(() => getChartMetricCandidates(config), [config]); + const blockMetricCandidates = useMemo(() => getBlockMetricCandidates(snapshot), [snapshot]); useEffect(() => { - if (!metricCandidates.length) return; - const allowed = new Set(metricCandidates.map((item) => item.metric_id)); + if (!chartMetricCandidates.length) return; + const allowed = new Set(chartMetricCandidates.map((item) => item.metric_id)); setLiveMetrics((current) => { const filtered = current.filter((item) => allowed.has(item)); - return filtered.length ? filtered : metricCandidates.slice(0, 3).map((item) => item.metric_id); + return filtered.length ? filtered : chartMetricCandidates.slice(0, 3).map((item) => item.metric_id); }); setArchiveMetrics((current) => { const filtered = current.filter((item) => allowed.has(item)); - return filtered.length ? filtered : metricCandidates.slice(0, 3).map((item) => item.metric_id); + return filtered.length ? filtered : chartMetricCandidates.slice(0, 3).map((item) => item.metric_id); }); - }, [metricCandidates]); - const liveHistoryMetrics = useMemo(() => liveMetrics.filter((item) => item !== "inverter_temp"), [liveMetrics]); + }, [chartMetricCandidates]); + useEffect(() => { + if (!blockMetricCandidates.length) return; + const allowed = new Set(blockMetricCandidates.map((item) => item.metric_id)); + setBlockConfig((current) => { + const next = { + hero: current.hero.filter((item) => allowed.has(item)), + quick: current.quick.filter((item) => allowed.has(item)), + }; + return { + hero: next.hero.length ? next.hero : blockMetricCandidates.filter((item) => DEFAULT_BLOCK_CONFIG.hero.includes(item.metric_id)).map((item) => item.metric_id), + quick: next.quick.length ? next.quick : blockMetricCandidates.filter((item) => DEFAULT_BLOCK_CONFIG.quick.includes(item.metric_id)).map((item) => item.metric_id), + }; + }); + }, [blockMetricCandidates]); + useEffect(() => { + if (!chartMetricCandidates.length) return; + setPrivateKioskDraft((current) => ({ ...current, chart_groups: sanitizeKioskChartGroups(current.chart_groups, chartMetricCandidates) })); + setPublicKioskDraft((current) => ({ ...current, chart_groups: sanitizeKioskChartGroups(current.chart_groups, chartMetricCandidates) })); + }, [chartMetricCandidates]); + useEffect(() => { + if (!blockMetricCandidates.length) return; + setPrivateKioskDraft((current) => ({ ...current, hero_metric_ids: sanitizeKioskHeroMetrics(current.hero_metric_ids, blockMetricCandidates) })); + setPublicKioskDraft((current) => ({ ...current, hero_metric_ids: sanitizeKioskHeroMetrics(current.hero_metric_ids, blockMetricCandidates) })); + }, [blockMetricCandidates]); + const liveHistoryMetrics = useMemo(() => liveMetrics, [liveMetrics]); const effectiveKioskSettings = publicMode ? publicKioskDraft : privateKioskDraft; + const effectiveKioskChartGroups = useMemo(() => sanitizeKioskChartGroups(effectiveKioskSettings.chart_groups, chartMetricCandidates), [effectiveKioskSettings.chart_groups, chartMetricCandidates]); + const effectiveKioskHeroMetricIds = useMemo(() => sanitizeKioskHeroMetrics(effectiveKioskSettings.hero_metric_ids, blockMetricCandidates), [effectiveKioskSettings.hero_metric_ids, blockMetricCandidates]); const kioskActive = publicMode || privateKioskRoute || viewMode === "kiosk"; const effectiveKioskWidgets = toWidgetIds(kioskActive ? effectiveKioskSettings.widgets : kioskWidgets); const effectiveRealtimeRange = kioskActive ? effectiveKioskSettings.realtime_range : realtimeRange; const effectiveAnalyticsRange = kioskActive ? effectiveKioskSettings.analytics_range : analyticsRange; const effectiveBucket = kioskActive ? effectiveKioskSettings.analytics_bucket : bucket; const effectiveCompare = kioskActive ? effectiveKioskSettings.compare_mode : compare; - const historyQuery = useRealtimeHistory(effectiveRealtimeRange, dataEnabled, { metrics: liveHistoryMetrics, publicKiosk: publicMode }); + const historyQuery = useRealtimeHistory(effectiveRealtimeRange, dataEnabled, { publicKiosk: publicMode }); const sanitizedCompareRanges = compareRanges.filter((item) => item.start && item.end); const analyticsOptions = analyticsStart && analyticsEnd && !kioskActive ? { start: analyticsStart, end: analyticsEnd, publicKiosk: publicMode, compareRanges: effectiveCompare === "custom_multi" ? sanitizedCompareRanges : undefined } : { publicKiosk: publicMode, compareRanges: effectiveCompare === "custom_multi" ? sanitizedCompareRanges : undefined }; const analyticsQuery = useAnalytics(analyticsStart && analyticsEnd && !kioskActive ? "custom" : effectiveAnalyticsRange, effectiveBucket, effectiveCompare, dataEnabled, analyticsOptions); const historical = useHistoricalImport(hasWarehouseAccess); const archiveQuery = useRealtimeHistory(archiveStart && archiveEnd ? "custom" : archiveRange, dataEnabled, { start: archiveStart || undefined, end: archiveEnd || undefined, metrics: archiveMetrics, publicKiosk: publicMode }); - const liveHistoryData = useMemo(() => filterHistoryByMetrics(trimSingleDayHistory(historyQuery.data, effectiveRealtimeRange), liveHistoryMetrics), [historyQuery.data, effectiveRealtimeRange, liveHistoryMetrics]); + const rawRealtimeHistoryData = useMemo(() => trimSingleDayHistory(historyQuery.data, effectiveRealtimeRange), [historyQuery.data, effectiveRealtimeRange]); + const liveHistoryData = useMemo(() => filterHistoryByMetrics(rawRealtimeHistoryData, liveHistoryMetrics), [rawRealtimeHistoryData, liveHistoryMetrics]); const archiveHistoryData = useMemo(() => filterHistoryByMetrics(trimSingleDayHistory(archiveQuery.data, archiveRange), archiveMetrics), [archiveQuery.data, archiveRange, archiveMetrics]); const usersQuery = useQuery({ queryKey: ["auth-users"], queryFn: api.getUsers, enabled: dataEnabled && isAdmin, staleTime: 15_000 }); const diagnosticsQuery = useQuery({ queryKey: ["diagnostics"], queryFn: api.getDiagnostics, enabled: hasSettingsAccess, staleTime: 20_000 }); @@ -495,7 +625,7 @@ export default function App() { }, }); const applyKioskDraftChange = (mode: "public" | "private", next: KioskSettingsPayload) => { - const normalized: KioskSettingsPayload = { ...next, mode }; + const normalized: KioskSettingsPayload = { ...next, mode, hero_metric_ids: sanitizeKioskHeroMetrics(next.hero_metric_ids), chart_groups: sanitizeKioskChartGroups(next.chart_groups) }; if (mode === "public") setPublicKioskDraft(normalized); else setPrivateKioskDraft(normalized); setKioskSaveNotice((current) => ({ ...current, [mode]: null })); }; @@ -530,25 +660,68 @@ export default function App() { const locale = localeForLanguage(language); const widgetLabels = useMemo(() => { const map = new Map(); for (const item of widgetOrder) map.set(item.id, buildWidgetLabel(language, item.id)); return map; }, [language]); + const effectiveLiveWidgets = useMemo(() => getVisibleLiveWidgets(toLiveWidgetIds(liveWidgets)), [liveWidgets]); const summary = analyticsQuery.production.data?.summary; - const topStatus = snapshot.status ?? []; - const heroCards = snapshot.hero_cards.filter((card) => blockConfig.hero.includes(card.metric_id)); - const quickMetrics = Object.values(snapshot.kpis ?? {}).filter((metric) => blockConfig.quick.includes(metric.metric_id)); + const statusHiddenMetrics = new Set(["inverter_temp", "data_refresh", "data_freshness"]); + const topStatus = (snapshot.status ?? []).filter((metric) => !statusHiddenMetrics.has(metric.metric_id)); + const metricLookup = useMemo(() => { + const map = new Map(); + snapshot.hero_cards.forEach((card) => map.set(card.metric_id, heroCardToMetric(card))); + Object.values(snapshot.kpis ?? {}).forEach((metric) => map.set(metric.metric_id, metric)); + return map; + }, [snapshot.hero_cards, snapshot.kpis]); + const heroCardLookup = useMemo(() => new Map(snapshot.hero_cards.map((card) => [card.metric_id, card])), [snapshot.hero_cards]); + const heroCards = blockConfig.hero.map((metricId) => heroCardLookup.get(metricId) ?? (metricLookup.get(metricId) ? metricToHeroCard(metricLookup.get(metricId)!) : null)).filter(Boolean) as SnapshotPayload["hero_cards"]; + const kioskHeroCards = effectiveKioskHeroMetricIds.map((metricId) => heroCardLookup.get(metricId) ?? (metricLookup.get(metricId) ? metricToHeroCard(metricLookup.get(metricId)!) : null)).filter(Boolean) as SnapshotPayload["hero_cards"]; + const quickMetrics = blockConfig.quick.map((metricId) => metricLookup.get(metricId)).filter(Boolean) as MetricValue[]; const publicKioskUrl = `${window.location.origin}/kiosk/public`; const privateKioskUrl = `${window.location.origin}/kiosk/private`; const allWidgets: Record = { - hero: , + hero: , quickMetrics: , history: , - status: , + status: , strings: , production: , comparison: effectiveCompare !== "none" ? : null, distribution: null, importStatus: , }; - const renderWidget = (widgetId: WidgetId) => { const content = allWidgets[widgetId]; if (!content) return null; return
{content}
; }; + const renderWidget = (widgetId: WidgetId) => { + if (widgetId === "history") { + const groups = effectiveKioskChartGroups; + const columnClass = groups.length <= 1 ? "col-12" : "col-12 col-xxl-6"; + return groups.map((group, index) => ( +
+ chartMetricCandidates.find((item) => item.metric_id === metricId)?.label).filter(Boolean).join(" · ") || t(language, "realtimeSubtitle")} + /> +
+ )); + } + const content = allWidgets[widgetId]; + if (!content) return null; + return
{content}
; + }; + const renderLiveWidget = (widgetId: LiveWidgetId) => { + const content = allWidgets[widgetId]; + if (!content) return null; + const className = widgetId === "hero" + ? "col-12" + : widgetId === "quickMetrics" + ? "col-12 col-xl-4" + : widgetId === "history" + ? "col-12 col-xl-8" + : widgetId === "status" + ? "col-12 col-xl-4" + : "col-12 col-xl-8"; + return
{content}
; + }; if ((!publicMode && authQuery.isLoading) || (authEnabled && !authenticated && loginMutation.isPending)) return ; if (authEnabled && !authenticated) return loginMutation.mutate()} onThemeToggle={() => setTheme((current) => (current === "dark" ? "light" : "dark"))} onLanguageToggle={() => setLanguage((current) => (current === "pl" ? "en" : "pl"))} loading={loginMutation.isPending} error={loginError} />; @@ -609,17 +782,17 @@ export default function App() { return (
{navbar}{menu}
- {activeTab === "realtime" && <>
{renderWidget("hero")}
{allWidgets.quickMetrics}
{allWidgets.history}
{allWidgets.status}
{allWidgets.strings}
} + {activeTab === "realtime" && <>
{effectiveLiveWidgets.map((widgetId) => renderLiveWidget(widgetId))}
} - {activeTab === "archive" && <>
{ setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={archiveQuickRangeOptions(language)} /> item.key === archiveRange) ? archiveRange : ""} onChange={(value) => { setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={[{ key: "", label: language === "en" ? "Choose range" : "Wybierz zakres" }, ...archiveListRangeOptions(language), { key: "custom", label: language === "en" ? "Custom range" : "Własny zakres" }]} />
{ setArchiveRange("custom"); setArchiveStart(e.target.value); }} />
{ setArchiveRange("custom"); setArchiveEnd(e.target.value); }} />
item.metric_id !== "energy_total")} selected={archiveMetrics.filter((item) => item !== "energy_total")} onChange={setArchiveMetrics} />
} + {activeTab === "archive" && <>
{ setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={archiveQuickRangeOptions(language)} /> item.key === archiveRange) ? archiveRange : ""} onChange={(value) => { setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={[{ key: "", label: language === "en" ? "Choose range" : "Wybierz zakres" }, ...archiveListRangeOptions(language), { key: "custom", label: language === "en" ? "Custom range" : "Własny zakres" }]} />
{ setArchiveRange("custom"); setArchiveStart(e.target.value); }} />
{ setArchiveRange("custom"); setArchiveEnd(e.target.value); }} />
item.metric_id !== "energy_total")} selected={archiveMetrics.filter((item) => item !== "energy_total")} onChange={setArchiveMetrics} />
} {activeTab === "analytics" && <>
{ if (value !== "custom") { setAnalyticsRange(value); setAnalyticsStart(""); setAnalyticsEnd(""); } }} options={[...config.capabilities.ranges.filter((item) => !["6h", "24h", "48h", "1d", "3d", "14d", "60d", "ytd"].includes(item.key)).map((item) => ({ key: item.key, label: translateRangeLabel(language, item.key, item.label) })), { key: "custom", label: language === "en" ? "Custom" : "Ręczny" }]} /> ({ key: item.key, label: translateBucket(language, item.key) }))} />
{analyticsStart || analyticsEnd || (analyticsRange === "custom") || compare === "custom_multi" ?
setAnalyticsStart(e.target.value)} />
setAnalyticsEnd(e.target.value)} />
{compare === "custom_multi" ?
{language === "en" ? "Comparison ranges" : "Zakresy porównawcze"}
{compareRanges.map((item, index) =>
setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, label: e.target.value } : current))} />
setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, start: e.target.value } : current))} />
setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, end: e.target.value } : current))} />
)}
: null}
: null}
item.key === compare)?.label ?? compare} />
{allWidgets.production}
{compare !== "none" ?
{allWidgets.comparison}
: null}
} {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={canPersistCurrentKioskSettings} saveNotice={kioskSaveNotice[kioskEditorMode]} onSave={saveCurrentKioskSettings} onReset={() => resetKioskDraft(kioskEditorMode)} allowPublicMode={isAdmin} />
} + {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={canPersistCurrentKioskSettings} saveNotice={kioskSaveNotice[kioskEditorMode]} onSave={saveCurrentKioskSettings} onReset={() => resetKioskDraft(kioskEditorMode)} allowPublicMode={isAdmin} chartItems={chartMetricCandidates} heroItems={blockMetricCandidates} />
} - {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}
} + {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}
}
); } @@ -654,7 +827,17 @@ function StatusStat({ label, value }: { label: string; value: string }) { return function LiveHistoryPanel({ data, language, theme, title, subtitle }: { data?: HistoryPayload; language: Language; theme: ThemeMode; title: string; subtitle: string }) { return

{title}

{subtitle}
; } 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 StatusDot({ ok }: { ok: boolean }) { return ; } +function StatusPanel({ metrics, locale, language, connected, lastUpdated, diagnostics, config }: { metrics: MetricValue[]; locale: string; language: Language; connected: boolean; lastUpdated?: string | null; diagnostics?: DiagnosticsPayload; config?: DashboardConfig }) { + const infoItems = [ + { label: language === "en" ? "Live feed" : "Połączenie live", value: connected ? (language === "en" ? "Connected" : "Połączono") : (language === "en" ? "Waiting" : "Oczekiwanie"), dot: }, + diagnostics ? { label: "InfluxDB", value: diagnostics.influx.reachable ? (language === "en" ? "Connected" : "Połączono") : (language === "en" ? "Error" : "Błąd"), dot: } : null, + { label: language === "en" ? "Last update" : "Ostatni odczyt", value: formatDateTime(lastUpdated, locale) }, + config ? { label: language === "en" ? "System" : "System", value: `${config.app.version} · ${config.app.timezone}` } : null, + config ? { label: language === "en" ? "Installed power" : "Moc instalacji", value: `${formatValue(config.app.installed_power_kwp, "kWp", 2, locale)}` } : null, + ].filter(Boolean) as Array<{ label: string; value: string; dot?: ReactNode }>; + return

{t(language, "systemStatus")}

{infoItems.map((item) =>
{item.label}
{item.dot ?? null}{item.value}
)}
{metrics.length ?
{metrics.map((metric) =>
{labelForMetric(language, metric.metric_id, metric.label)}
{metric.unit || metric.status}
{formatValue(metric.value, metric.unit, 2, locale)}
)}
: null}
; +} 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}
)}
; } @@ -670,16 +853,78 @@ function HistoricalPanel({ status, language, locale, compact = false }: { status 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 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, allowPublicMode }: { 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; allowPublicMode: boolean; }) { +function KioskSettingsEditorPanel({ language, value, onChange, onSave, onReset, selectedMode, onModeChange, labels, buckets, compareModes, saving, dirty, canSave, saveNotice, allowPublicMode, chartItems, heroItems }: { 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; allowPublicMode: boolean; chartItems: Array<{ metric_id: string; label: string; unit: string }>; heroItems: Array<{ metric_id: string; label: string; unit: string }>; }) { const widgets = toWidgetIds(value.widgets); - return

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

{allowPublicMode ? (language === "en" ? "Choose the kiosk audience first, then tune the content." : "Najpierw wybierz odbiorcę kiosku, potem dopracuj zawartość.") : (language === "en" ? "These settings affect only your private kiosk after login." : "Te ustawienia dotyczą tylko Twojego prywatnego kiosku po zalogowaniu.")}
{dirty ? (language === "en" ? "Unsaved changes" : "Niezapisane zmiany") : (language === "en" ? "Up to date" : "Aktualne")}
{allowPublicMode ?
onModeChange("private")} />
onModeChange("public")} />
:
{language === "en" ? "You can manage your own private kiosk layout and ranges here." : "Tutaj ustawisz własny prywatny kiosk: układ, zakresy i porównanie."}
}
onChange({ ...value, widgets: widgetsValue })} labels={labels} />
; + const chartGroups = sanitizeKioskChartGroups(value.chart_groups, chartItems); + const heroMetricIds = sanitizeKioskHeroMetrics(value.hero_metric_ids, heroItems); + return

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

{allowPublicMode ? (language === "en" ? "Choose the kiosk audience first, then tune the content." : "Najpierw wybierz odbiorcę kiosku, potem dopracuj zawartość.") : (language === "en" ? "These settings affect only your private kiosk after login and are stored per user." : "Te ustawienia dotyczą tylko Twojego prywatnego kiosku po zalogowaniu i zapisują się osobno dla użytkownika.")}
{dirty ? (language === "en" ? "Unsaved changes" : "Niezapisane zmiany") : (language === "en" ? "Up to date" : "Aktualne")}
{allowPublicMode ?
onModeChange("private")} />
onModeChange("public")} />
:
{language === "en" ? "You can manage your own private kiosk layout and ranges here." : "Tutaj ustawisz własny prywatny kiosk: układ, zakresy i porównanie."}
}
onChange({ ...value, widgets: widgetsValue })} labels={labels} /> onChange({ ...value, hero_metric_ids: heroMetricIdsValue })} /> onChange({ ...value, chart_groups: groupsValue })} />
; } +function KioskHeroPanel({ language, items, selected, onChange, heroEnabled }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; heroEnabled: boolean; }) { + const safeSelected = sanitizeKioskHeroMetrics(selected, items); + const toggle = (metricId: string) => { + const next = safeSelected.includes(metricId) + ? (safeSelected.length > 1 ? safeSelected.filter((item) => item !== metricId) : safeSelected) + : [...safeSelected, metricId]; + onChange(sanitizeKioskHeroMetrics(next, items)); + }; + return

{language === "en" ? "3. Hero card content" : "3. Zawartość kart hero"}

{language === "en" ? "Choose exactly which KPIs should appear in the kiosk hero section." : "Wybierz dokładnie, które KPI mają pojawiać się w sekcji hero kiosku."}
{safeSelected.length}
{!heroEnabled ?
{language === "en" ? "Hero section is hidden in kiosk layout. Enable Hero section above to display these cards." : "Sekcja hero jest ukryta w układzie kiosku. Włącz sekcję Hero powyżej, aby pokazać te karty."}
: null}
{items.map((item) => )}
; +} + +function KioskChartGroupsPanel({ language, items, groups, onChange, historyEnabled }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; groups: KioskChartGroup[]; onChange: (value: KioskChartGroup[]) => void; historyEnabled: boolean; }) { + const safeGroups = sanitizeKioskChartGroups(groups, items); + const updateGroup = (groupId: string, updater: (group: KioskChartGroup) => KioskChartGroup) => onChange(safeGroups.map((group) => group.id === groupId ? updater(group) : group)); + const move = (groupId: string, direction: -1 | 1) => { + const index = safeGroups.findIndex((group) => group.id === groupId); + if (index === -1) return; + const target = index + direction; + if (target < 0 || target >= safeGroups.length) return; + const next = [...safeGroups]; + [next[index], next[target]] = [next[target], next[index]]; + onChange(next); + }; + const addGroup = () => { + const fallbackMetricIds = items.slice(0, Math.min(items.length, 3)).map((item) => item.metric_id); + if (!fallbackMetricIds.length) return; + onChange([...safeGroups, { id: `chart_${Date.now()}`, title: null, metric_ids: [fallbackMetricIds[0]] }]); + }; + const removeGroup = (groupId: string) => { + const next = safeGroups.filter((group) => group.id !== groupId); + onChange(next.length ? next : sanitizeKioskChartGroups([], items)); + }; + const toggleMetric = (groupId: string, metricId: string) => updateGroup(groupId, (group) => ({ + ...group, + metric_ids: group.metric_ids.includes(metricId) + ? (group.metric_ids.length > 1 ? group.metric_ids.filter((item) => item !== metricId) : group.metric_ids) + : [...group.metric_ids, metricId], + })); + return

{language === "en" ? "4. Kiosk chart content" : "4. Zawartość wykresów kiosku"}

{language === "en" ? "Create one combined chart or split kiosk into separate AC, DC, temperature and other charts." : "Ułóż jeden wykres zbiorczy albo kilka osobnych kart, np. AC, DC i temperatura."}
{!historyEnabled ?
{language === "en" ? "The chart section is hidden in kiosk layout. Enable History section above to show these charts." : "Sekcja wykresów jest ukryta w układzie kiosku. Włącz sekcję wykresu powyżej, aby je pokazać."}
: null}{safeGroups.map((group, index) =>
{language === "en" ? `Chart ${index + 1}` : `Wykres ${index + 1}`}
updateGroup(group.id, (current) => ({ ...current, title: event.target.value || null }))} />
{group.metric_ids.length}
{items.map((item) => )}
)}{!items.length ?
{language === "en" ? "No realtime metrics available for kiosk charts." : "Brak dostępnych metryk live do wykresów kiosku."}
: null}
; +} + + function KioskLinkPanel({ language, publicKioskUrl, privateKioskUrl, publicSettings, privateSettings, showPublicLink }: { language: Language; publicKioskUrl: string; privateKioskUrl: string; publicSettings: KioskSettingsPayload; privateSettings: KioskSettingsPayload; showPublicLink: boolean }) { 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
{showPublicLink ?

{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" ? "Private kiosk link" : "Link do prywatnego kiosku"}

{language === "en" ? "Your private kiosk" : "Twój prywatny kiosk"}
{language === "en" ? "Requires login and uses your saved kiosk layout." : "Wymaga logowania i używa Twojego zapisanego układu kiosku."}
{language === "en" ? "User" : "User"}
{language === "en" ? "Live" : "Live"}: {privateSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {privateSettings.analytics_range}
}

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

{showPublicLink ? (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.") : (language === "en" ? "Private kiosk keeps your own layout and ranges separate from the admin configuration." : "Prywatny kiosk zachowuje Twój własny układ i zakresy oddzielnie od konfiguracji administratora.")}
; } -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 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")} onClick={() => setTheme("light")} />
} title={t(language, "dark")} onClick={() => setTheme("dark")} />
{t(language, "viewMode")}
} title={t(language, "normalMode")} onClick={() => setViewMode("normal")} />
} title={t(language, "kioskMode")} onClick={() => setViewMode("kiosk")} />
{language === "en" ? "Language" : "Język"}
} title="Polski" onClick={() => setLanguage("pl")} />
} title="English" 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 LiveSectionVisibilityPanel({ language, selected, onChange }: { language: Language; selected: LiveWidgetId[]; onChange: (value: LiveWidgetId[]) => void; }) { + const items: Array<{ id: LiveWidgetId; label: string }> = [ + { id: "hero", label: language === "en" ? "Hero" : "Hero" }, + { id: "quickMetrics", label: t(language, "quickMetrics") }, + { id: "history", label: t(language, "chartPowerHistory") }, + { id: "status", label: t(language, "systemStatus") }, + { id: "strings", label: t(language, "strings") }, + ]; + const toggle = (id: LiveWidgetId) => { + if (selected.includes(id)) { + const next = selected.filter((item) => item !== id); + onChange(next.length ? next : selected); + return; + } + onChange([...selected, id]); + }; + return

{language === "en" ? "LIVE section visibility" : "Widoczność sekcji LIVE"}

{language === "en" ? "Show or hide whole blocks on the live dashboard." : "Włączaj i wyłączaj całe bloki na dashboardzie Live."}
{items.map((item) => )}
; +} 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."}
}
; } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2407572..6394903 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4,6 +4,7 @@ import type { AuthUsersPayload, DashboardConfig, DiagnosticsPayload, + KioskChartGroup, KioskSettingsPayload, DistributionPayload, HistoryPayload, @@ -68,9 +69,13 @@ async function demoResponse(factory: () => T): Promise { return clone(factory()); } +function demoKioskChartGroups(): KioskChartGroup[] { + return [{ id: "overview", title: null, metric_ids: ["ac_power", "dc_power_total", "inverter_temp"] }]; +} + export const api = { getConfig: () => (DEMO_MODE ? demoResponse(() => demoConfig) : request("/dashboard/config")), - getKioskSettings: (mode: "public" | "private") => (DEMO_MODE ? demoResponse(() => ({ mode, widgets: ["hero", "history", "strings", "status", "production", "comparison", "importStatus"], realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" })) : request(`/dashboard/kiosk-settings?mode=${mode}`)), + getKioskSettings: (mode: "public" | "private") => (DEMO_MODE ? demoResponse(() => ({ mode, widgets: ["hero", "history", "strings", "status", "production", "comparison", "importStatus"], hero_metric_ids: ["ac_power", "dc_power_total", "energy_today", "energy_total"], realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none", chart_groups: demoKioskChartGroups() })) : request(`/dashboard/kiosk-settings?mode=${mode}`)), saveKioskSettings: (payload: KioskSettingsPayload) => (DEMO_MODE ? demoResponse(() => payload) : request("/dashboard/kiosk-settings", { method: "PUT", body: JSON.stringify(payload) })), getAuthStatus: () => (DEMO_MODE ? demoResponse(() => demoAuthStatus) : request("/auth/status")), login: (username: string, password: string) => diff --git a/frontend/src/demo/data.ts b/frontend/src/demo/data.ts index 77bb0f6..b084416 100644 --- a/frontend/src/demo/data.ts +++ b/frontend/src/demo/data.ts @@ -68,7 +68,7 @@ export const demoConfig: DashboardConfig = { { metric_id: "string_1_voltage", label: "Napiecie stringu DC1", entity_id: "sofarsolar_dc1_voltage", measurement: "V", unit: "V", kind: "gauge" }, { metric_id: "string_2_power", label: "Moc stringu DC2", entity_id: "sofarsolar_dc2_power", measurement: "W", unit: "W", kind: "gauge" }, { metric_id: "string_2_voltage", label: "Napiecie stringu DC2", entity_id: "sofarsolar_dc2_voltage", measurement: "V", unit: "V", kind: "gauge" }, - { metric_id: "inverter_temperature", label: "Temperatura falownika", entity_id: "sofarsolar_temprature_inverter", measurement: "°C", unit: "°C", kind: "gauge" }, + { metric_id: "inverter_temp", label: "Temperatura falownika", entity_id: "sofarsolar_temprature_inverter", measurement: "°C", unit: "°C", kind: "gauge" }, ], }; @@ -77,9 +77,9 @@ export const demoSnapshot = (): SnapshotPayload => ({ hero_cards: [ { metric_id: "ac_power", label: "Produkcja AC", value: 6840, unit: "W", accent: "emerald", subtitle: "Aktualna moc oddawana przez falownik" }, { metric_id: "energy_today", label: "Dzisiaj", value: 31.8, unit: "kWh", accent: "amber", subtitle: "Liczone z energy_total / fallback z AC power" }, - { metric_id: "dc1_power", label: "String DC1", value: 3450, unit: "W", accent: "emerald", subtitle: "Wschod" }, - { metric_id: "dc2_power", label: "String DC2", value: 3310, unit: "W", accent: "emerald", subtitle: "Zachod" }, - { metric_id: "inverter_temperature", label: "Temp. falownika", value: 47.3, unit: "°C", accent: "rose", subtitle: "Live status termiczny" }, + { metric_id: "string_1_power", label: "String DC1", value: 3450, unit: "W", accent: "emerald", subtitle: "Wschód" }, + { metric_id: "string_2_power", label: "String DC2", value: 3310, unit: "W", accent: "emerald", subtitle: "Zachód" }, + { metric_id: "inverter_temp", label: "Temp. falownika", value: 47.3, unit: "°C", accent: "rose", subtitle: "Live status termiczny" }, ], kpis: { energy_today: { metric_id: "energy_today", label: "Energia dzis", unit: "kWh", value: 31.8, precision: 2, kind: "counter", status: "ok" }, @@ -94,7 +94,7 @@ export const demoSnapshot = (): SnapshotPayload => ({ ], phases: [], status: [ - { metric_id: "inverter_temperature", label: "Temperatura falownika", unit: "°C", value: 47.3, precision: 1, kind: "gauge", status: "ok" }, + { metric_id: "inverter_temp", label: "Temperatura falownika", unit: "°C", value: 47.3, precision: 1, kind: "gauge", status: "ok" }, { metric_id: "data_freshness", label: "Swiezosc danych", unit: "", value: "3 s temu", precision: 0, kind: "text", status: "ok" }, ], faults: [], @@ -110,9 +110,9 @@ export const demoHistory: HistoryPayload = { end: isoAt(0), series: [ { metric_id: "ac_power", label: "Moc AC", unit: "W", points: historyPoints([0, 120, 860, 1840, 2760, 3920, 5180, 6020, 6840, 6500, 5710, 4980]) }, - { metric_id: "dc1_power", label: "DC1", unit: "W", points: historyPoints([0, 80, 620, 1320, 2140, 2860, 3250, 3490, 3450, 3300, 2920, 2480]) }, - { metric_id: "dc2_power", label: "DC2", unit: "W", points: historyPoints([0, 40, 240, 520, 880, 1260, 1930, 2530, 3310, 3200, 2790, 2410]) }, - { metric_id: "inverter_temperature", label: "Temp. falownika", unit: "°C", points: historyPoints([22, 24, 27, 31, 35, 39, 42, 45, 47.3, 46.8, 44.1, 41.2]) }, + { metric_id: "string_1_power", label: "DC1", unit: "W", points: historyPoints([0, 80, 620, 1320, 2140, 2860, 3250, 3490, 3450, 3300, 2920, 2480]) }, + { metric_id: "string_2_power", label: "DC2", unit: "W", points: historyPoints([0, 40, 240, 520, 880, 1260, 1930, 2530, 3310, 3200, 2790, 2410]) }, + { metric_id: "inverter_temp", label: "Temp. falownika", unit: "°C", points: historyPoints([22, 24, 27, 31, 35, 39, 42, 45, 47.3, 46.8, 44.1, 41.2]) }, ], }; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 64dabb3..e55a52c 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -34,7 +34,7 @@ const messages = { settingsSubtitle: "Import archiwum, wygląd, kiosk, bezpieczeństwo", noData: "Brak danych", noDataDescription: "Brak odpowiedzi z backendu lub InfluxDB.", - chartPowerHistory: "Historia mocy i temperatury", + kioskCharts: "Wykresy", chartProduction: "Produkcja", chartProductionSubtitle: "Agregacja w wybranym bucketcie", chartComparison: "Porównanie okresów", @@ -141,7 +141,7 @@ const messages = { settingsSubtitle: "Archive import, appearance, kiosk, security", noData: "No data", noDataDescription: "No response from backend or InfluxDB.", - chartPowerHistory: "Power and temperature history", + kioskCharts: "Charts", chartProduction: "Production", chartProductionSubtitle: "Aggregated by selected bucket", chartComparison: "Period comparison", diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7248892..f8d33b6 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -263,13 +263,21 @@ export interface AuthUsersPayload { } +export interface KioskChartGroup { + id: string; + title?: string | null; + metric_ids: string[]; +} + export interface KioskSettingsPayload { mode: "public" | "private"; widgets: string[]; + hero_metric_ids: string[]; realtime_range: string; analytics_range: string; analytics_bucket: string; compare_mode: string; + chart_groups: KioskChartGroup[]; updated_at?: string | null; updated_by?: string | null; }