fix echart

This commit is contained in:
Mateusz Gruszczyński
2026-03-26 10:19:23 +01:00
parent 3bd706a548
commit b13a35c310
3 changed files with 75 additions and 11 deletions

View File

@@ -60,7 +60,6 @@ const STORAGE_KEYS = {
liveWidgets: "pv-live-widgets-v4", liveWidgets: "pv-live-widgets-v4",
liveMetrics: "pv-live-metrics-v4", liveMetrics: "pv-live-metrics-v4",
archiveMetrics: "pv-archive-metrics-v4", archiveMetrics: "pv-archive-metrics-v4",
archiveAutoRefresh: "pv-archive-autorefresh-v1",
liveAutoRefresh: "pv-live-autorefresh-v1", liveAutoRefresh: "pv-live-autorefresh-v1",
}; };
@@ -518,7 +517,6 @@ export default function App() {
const [archiveRange, setArchiveRange] = useState("today"); const [archiveRange, setArchiveRange] = useState("today");
const [liveMetrics, setLiveMetrics] = useState<string[]>(() => readStorage(STORAGE_KEYS.liveMetrics, DEFAULT_LIVE_METRICS)); const [liveMetrics, setLiveMetrics] = useState<string[]>(() => readStorage(STORAGE_KEYS.liveMetrics, DEFAULT_LIVE_METRICS));
const [archiveMetrics, setArchiveMetrics] = useState<string[]>(() => readStorage(STORAGE_KEYS.archiveMetrics, DEFAULT_LIVE_METRICS)); const [archiveMetrics, setArchiveMetrics] = useState<string[]>(() => readStorage(STORAGE_KEYS.archiveMetrics, DEFAULT_LIVE_METRICS));
const [archiveAutoRefresh, setArchiveAutoRefresh] = useState<boolean>(() => readStorage<boolean>(STORAGE_KEYS.archiveAutoRefresh, true, (raw) => raw === "true"));
const [liveAutoRefresh, setLiveAutoRefresh] = useState<boolean>(() => readStorage<boolean>(STORAGE_KEYS.liveAutoRefresh, true, (raw) => raw === "true")); const [liveAutoRefresh, setLiveAutoRefresh] = useState<boolean>(() => readStorage<boolean>(STORAGE_KEYS.liveAutoRefresh, true, (raw) => raw === "true"));
const [liveWidgets, setLiveWidgets] = useState<LiveWidgetId[]>(() => getVisibleLiveWidgets(readStorage<LiveWidgetId[]>(STORAGE_KEYS.liveWidgets, DEFAULT_LIVE_WIDGETS))); const [liveWidgets, setLiveWidgets] = useState<LiveWidgetId[]>(() => getVisibleLiveWidgets(readStorage<LiveWidgetId[]>(STORAGE_KEYS.liveWidgets, DEFAULT_LIVE_WIDGETS)));
const [viewMode, setViewMode] = useState<ViewMode>(() => { const fromUrl = parseViewModeFromLocation(); return fromUrl === "kiosk" ? fromUrl : readStorage<ViewMode>(STORAGE_KEYS.viewMode, "normal", (raw) => (raw === "kiosk" ? "kiosk" : "normal")); }); const [viewMode, setViewMode] = useState<ViewMode>(() => { const fromUrl = parseViewModeFromLocation(); return fromUrl === "kiosk" ? fromUrl : readStorage<ViewMode>(STORAGE_KEYS.viewMode, "normal", (raw) => (raw === "kiosk" ? "kiosk" : "normal")); });
@@ -572,7 +570,6 @@ export default function App() {
useEffect(() => { writeStorage(STORAGE_KEYS.liveWidgets, liveWidgets); }, [liveWidgets]); useEffect(() => { writeStorage(STORAGE_KEYS.liveWidgets, liveWidgets); }, [liveWidgets]);
useEffect(() => { writeStorage(STORAGE_KEYS.liveMetrics, liveMetrics); }, [liveMetrics]); useEffect(() => { writeStorage(STORAGE_KEYS.liveMetrics, liveMetrics); }, [liveMetrics]);
useEffect(() => { writeStorage(STORAGE_KEYS.archiveMetrics, archiveMetrics); }, [archiveMetrics]); useEffect(() => { writeStorage(STORAGE_KEYS.archiveMetrics, archiveMetrics); }, [archiveMetrics]);
useEffect(() => { writeStorage(STORAGE_KEYS.archiveAutoRefresh, String(archiveAutoRefresh)); }, [archiveAutoRefresh]);
useEffect(() => { writeStorage(STORAGE_KEYS.liveAutoRefresh, String(liveAutoRefresh)); }, [liveAutoRefresh]); useEffect(() => { writeStorage(STORAGE_KEYS.liveAutoRefresh, String(liveAutoRefresh)); }, [liveAutoRefresh]);
const dataEnabled = authenticated || authEnabled === false; const dataEnabled = authenticated || authEnabled === false;
@@ -632,12 +629,11 @@ export default function App() {
const effectiveBucket = kioskActive ? effectiveKioskSettings.analytics_bucket : bucket; const effectiveBucket = kioskActive ? effectiveKioskSettings.analytics_bucket : bucket;
const effectiveCompare = kioskActive ? effectiveKioskSettings.compare_mode : compare; const effectiveCompare = kioskActive ? effectiveKioskSettings.compare_mode : compare;
const historyQuery = useRealtimeHistory(effectiveRealtimeRange, dataEnabled, { publicKiosk: publicMode, pauseAutoRefresh: !liveAutoRefresh }); const historyQuery = useRealtimeHistory(effectiveRealtimeRange, dataEnabled, { publicKiosk: publicMode, pauseAutoRefresh: !liveAutoRefresh });
const archiveManualRangeActive = Boolean(archiveStart && archiveEnd);
const sanitizedCompareRanges = compareRanges.filter((item) => item.start && item.end); 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 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 analyticsQuery = useAnalytics(analyticsStart && analyticsEnd && !kioskActive ? "custom" : effectiveAnalyticsRange, effectiveBucket, effectiveCompare, dataEnabled, analyticsOptions);
const historical = useHistoricalImport(hasWarehouseAccess); const historical = useHistoricalImport(hasWarehouseAccess);
const archiveQuery = useRealtimeHistory(archiveStart && archiveEnd ? "custom" : archiveRange, dataEnabled, { start: archiveStart || undefined, end: archiveEnd || undefined, metrics: archiveMetrics, publicKiosk: publicMode, pauseAutoRefresh: !archiveAutoRefresh }); const archiveQuery = useRealtimeHistory(archiveStart && archiveEnd ? "custom" : archiveRange, dataEnabled, { start: archiveStart || undefined, end: archiveEnd || undefined, metrics: archiveMetrics, publicKiosk: publicMode, pauseAutoRefresh: true });
const rawRealtimeHistoryData = useMemo(() => trimSingleDayHistory(historyQuery.data, effectiveRealtimeRange), [historyQuery.data, effectiveRealtimeRange]); const rawRealtimeHistoryData = useMemo(() => trimSingleDayHistory(historyQuery.data, effectiveRealtimeRange), [historyQuery.data, effectiveRealtimeRange]);
const liveHistoryData = useMemo(() => filterHistoryByMetrics(rawRealtimeHistoryData, liveHistoryMetrics), [rawRealtimeHistoryData, liveHistoryMetrics]); const liveHistoryData = useMemo(() => filterHistoryByMetrics(rawRealtimeHistoryData, liveHistoryMetrics), [rawRealtimeHistoryData, liveHistoryMetrics]);
const archiveHistoryData = useMemo(() => filterHistoryByMetrics(trimSingleDayHistory(archiveQuery.data, archiveRange), archiveMetrics), [archiveQuery.data, archiveRange, archiveMetrics]); const archiveHistoryData = useMemo(() => filterHistoryByMetrics(trimSingleDayHistory(archiveQuery.data, archiveRange), archiveMetrics), [archiveQuery.data, archiveRange, archiveMetrics]);
@@ -793,9 +789,7 @@ export default function App() {
const subnavRefreshControls = activeTab === "realtime" const subnavRefreshControls = activeTab === "realtime"
? <div className="pv-subnav-refresh-group"><RefreshModeSwitch language={language} autoEnabled={liveAutoRefresh} onChange={setLiveAutoRefresh} compact /></div> ? <div className="pv-subnav-refresh-group"><RefreshModeSwitch language={language} autoEnabled={liveAutoRefresh} onChange={setLiveAutoRefresh} compact /></div>
: activeTab === "archive" : null;
? <div className="pv-subnav-refresh-group"><RefreshModeSwitch language={language} autoEnabled={archiveAutoRefresh} onChange={setArchiveAutoRefresh} compact /></div>
: null;
const menu = ( const menu = (
<div className="pv-subnav border-bottom"> <div className="pv-subnav border-bottom">
@@ -833,7 +827,7 @@ export default function App() {
<div className="page">{navbar}{menu}<div className="page-wrapper"><div className="page-body"><div className="container-xl"> <div className="page">{navbar}{menu}<div className="page-wrapper"><div className="page-body"><div className="container-xl">
{activeTab === "realtime" && <><PageHeader title={t(language, "realtimeOverview")} subtitle={t(language, "realtimeSubtitle")}><div className="pv-page-header-range-grid pv-page-header-range-grid--single"><SegmentedSelect label={t(language, "liveRange")} value={realtimeRange} onChange={setRealtimeRange} options={liveRangeOptions(language)} /></div></PageHeader><div className="row row-cards g-3">{effectiveLiveWidgets.map((widgetId) => renderLiveWidget(widgetId))}</div></>} {activeTab === "realtime" && <><PageHeader title={t(language, "realtimeOverview")} subtitle={t(language, "realtimeSubtitle")}><div className="pv-page-header-range-grid pv-page-header-range-grid--single"><SegmentedSelect label={t(language, "liveRange")} value={realtimeRange} onChange={setRealtimeRange} options={liveRangeOptions(language)} /></div></PageHeader><div className="row row-cards g-3">{effectiveLiveWidgets.map((widgetId) => renderLiveWidget(widgetId))}</div></>}
{activeTab === "archive" && <><PageHeader title={language === "en" ? "Historical live data" : "Dane chwilowe z historii"} subtitle={language === "en" ? "Browse all instant metrics and zoom the chart with touch gestures or the slider below." : "Przeglądaj metryki chwilowe i przybliżaj wykres gestem szczypania albo suwakiem pod wykresem."}><div className="pv-page-header-range-grid"><SegmentedSelect label={language === "en" ? "Day" : "Dzień"} value={SINGLE_DAY_ARCHIVE_KEYS.has(archiveRange) ? archiveRange : ""} onChange={(value) => { setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); setArchiveAutoRefresh(true); }} options={archiveQuickRangeOptions(language)} /><SelectField label={language === "en" ? "Last period" : "Ostatnie dni"} value={archiveListRangeOptions(language).some((item) => item.key === archiveRange) ? archiveRange : ""} onChange={(value) => { setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); setArchiveAutoRefresh(true); }} options={[{ key: "", label: language === "en" ? "Choose range" : "Wybierz zakres" }, ...archiveListRangeOptions(language), { key: "custom", label: language === "en" ? "Custom range" : "Własny zakres" }]} /></div></PageHeader><div className="row row-cards g-3"><div className="col-12"><LiveHistoryPanel data={archiveHistoryData} language={language} theme={theme} title={language === "en" ? "Historical chart" : "Wykres historyczny"} subtitle={SINGLE_DAY_ARCHIVE_KEYS.has(archiveRange) ? (language === "en" ? "Single-day view is automatically trimmed to active data hours." : "Widok dnia jest automatycznie przycinany do aktywnych godzin danych.") : (language === "en" ? "Raw instant metrics from InfluxDB only." : "Tylko surowe metryki chwilowe z InfluxDB.")} footer={<div className="pv-chart-footer-controls"><div className="pv-date-field"><label className="form-label small mb-1">{language === "en" ? "From" : "Od"}</label><input className="form-control pv-date-input" type="datetime-local" value={archiveStart} onChange={(e) => { setArchiveRange("custom"); setArchiveStart(e.target.value); setArchiveAutoRefresh(false); }} /></div><div className="pv-date-field"><label className="form-label small mb-1">{language === "en" ? "To" : "Do"}</label><input className="form-control pv-date-input" type="datetime-local" value={archiveEnd} onChange={(e) => { setArchiveRange("custom"); setArchiveEnd(e.target.value); setArchiveAutoRefresh(false); }} /></div><div className="pv-chart-footer-status"><span className={`badge ${archiveAutoRefresh && !archiveManualRangeActive ? "bg-green-lt text-green" : "bg-amber-lt text-amber"}`}>{archiveAutoRefresh && !archiveManualRangeActive ? (language === "en" ? "Auto refresh active" : "Auto-odświeżanie aktywne") : (language === "en" ? "Auto refresh paused" : "Auto-odświeżanie zatrzymane")}</span><div className="text-secondary small">{archiveManualRangeActive ? (language === "en" ? "Manual date range disables automatic refresh until you switch it back on." : "Ręczny zakres dat zatrzymuje auto-odświeżanie, dopóki nie włączysz go ponownie.") : (language === "en" ? "Use the picker below to freeze the chart on a chosen time window." : "Użyj pickera poniżej, aby zamrozić wykres na wybranym zakresie czasu.")}</div></div></div>} /></div><div className="col-12"><MetricSelectorCard language={language} title={language === "en" ? "Metrics under chart" : "Metryki pod wykresem"} items={chartMetricCandidates.filter((item) => item.metric_id !== "energy_total")} selected={archiveMetrics.filter((item) => item !== "energy_total")} onChange={setArchiveMetrics} /></div></div></>} {activeTab === "archive" && <><PageHeader title={language === "en" ? "Historical live data" : "Dane chwilowe z historii"} subtitle={language === "en" ? "Browse all instant metrics and zoom the chart with touch gestures or the slider below." : "Przeglądaj metryki chwilowe i przybliżaj wykres gestem szczypania albo suwakiem pod wykresem."}><div className="pv-page-header-range-grid"><SegmentedSelect label={language === "en" ? "Day" : "Dzień"} value={SINGLE_DAY_ARCHIVE_KEYS.has(archiveRange) ? archiveRange : ""} onChange={(value) => { setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={archiveQuickRangeOptions(language)} /><SelectField label={language === "en" ? "Last period" : "Ostatnie dni"} value={archiveListRangeOptions(language).some((item) => 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" }]} /></div></PageHeader><div className="row row-cards g-3"><div className="col-12"><LiveHistoryPanel chartKey={[archiveRange, archiveStart, archiveEnd, archiveMetrics.join(",")].join("|")} data={archiveHistoryData} language={language} theme={theme} title={language === "en" ? "Historical chart" : "Wykres historyczny"} subtitle={SINGLE_DAY_ARCHIVE_KEYS.has(archiveRange) ? (language === "en" ? "Single-day view is automatically trimmed to active data hours." : "Widok dnia jest automatycznie przycinany do aktywnych godzin danych.") : (language === "en" ? "Raw instant metrics from InfluxDB only." : "Tylko surowe metryki chwilowe z InfluxDB.")} footer={<div className="pv-chart-footer-controls"><div className="pv-date-field"><label className="form-label small mb-1">{language === "en" ? "From" : "Od"}</label><input className="form-control pv-date-input" type="datetime-local" value={archiveStart} onChange={(e) => { setArchiveRange("custom"); setArchiveStart(e.target.value); }} /></div><div className="pv-date-field"><label className="form-label small mb-1">{language === "en" ? "To" : "Do"}</label><input className="form-control pv-date-input" type="datetime-local" value={archiveEnd} onChange={(e) => { setArchiveRange("custom"); setArchiveEnd(e.target.value); }} /></div><div className="pv-chart-footer-status"><span className="badge bg-blue-lt text-blue">{language === "en" ? "Refresh on change" : "Odświeżanie po zmianie"}</span><div className="text-secondary small">{language === "en" ? "This chart reloads only when you change the range or selected metrics." : "Ten wykres przeładowuje się tylko po zmianie zakresu albo wybranych metryk."}</div></div></div>} /></div><div className="col-12"><MetricSelectorCard language={language} title={language === "en" ? "Metrics under chart" : "Metryki pod wykresem"} items={chartMetricCandidates.filter((item) => item.metric_id !== "energy_total")} selected={archiveMetrics.filter((item) => item !== "energy_total")} onChange={setArchiveMetrics} /></div></div></>}
{activeTab === "analytics" && <><PageHeader title={t(language, "analyticsOverview")} subtitle={t(language, "analyticsSubtitle")}><div className="pv-filter-grid"><SegmentedSelect label={t(language, "range")} value={analyticsStart && analyticsEnd ? "custom" : analyticsRange} onChange={(value) => { 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" }]} /><SegmentedSelect label={t(language, "bucket")} value={bucket} onChange={setBucket} options={config.capabilities.buckets.map((item) => ({ key: item.key, label: translateBucket(language, item.key) }))} /><SelectField label={language === "en" ? "Comparison" : "Porównanie"} value={compare} onChange={setCompare} options={comparisonOptions(language)} /></div></PageHeader>{analyticsStart || analyticsEnd || (analyticsRange === "custom") || compare === "custom_multi" ? <div className="card pv-card mb-3"><div className="card-body d-flex flex-column gap-3"><div className="row g-3"><div className="col-md-3 pv-date-field"><label className="form-label">{language === "en" ? "Start" : "Od"}</label><input className="form-control pv-date-input" type="datetime-local" value={analyticsStart} onChange={(e) => setAnalyticsStart(e.target.value)} /></div><div className="col-md-3 pv-date-field"><label className="form-label">{language === "en" ? "End" : "Do"}</label><input className="form-control pv-date-input" type="datetime-local" value={analyticsEnd} onChange={(e) => setAnalyticsEnd(e.target.value)} /></div><div className="col-md-6 d-flex align-items-end"><button className="btn btn-outline-secondary" onClick={() => { setAnalyticsStart(""); setAnalyticsEnd(""); }}>{language === "en" ? "Use preset range" : "Wróć do gotowych zakresów"}</button></div></div>{compare === "custom_multi" ? <div className="border rounded-3 p-3"><div className="fw-semibold mb-3">{language === "en" ? "Comparison ranges" : "Zakresy porównawcze"}</div><div className="row g-3">{compareRanges.map((item, index) => <div className="col-12" key={item.key}><div className="row g-2 align-items-end"><div className="col-md-3"><label className="form-label">{language === "en" ? `Range ${index + 1} label` : `Etykieta ${index + 1}`}</label><input className="form-control" value={item.label} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, label: e.target.value } : current))} /></div><div className="col-md-3 pv-date-field"><label className="form-label">{language === "en" ? "From" : "Od"}</label><input className="form-control pv-date-input" type="datetime-local" value={item.start} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, start: e.target.value } : current))} /></div><div className="col-md-3 pv-date-field"><label className="form-label">{language === "en" ? "To" : "Do"}</label><input className="form-control pv-date-input" type="datetime-local" value={item.end} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, end: e.target.value } : current))} /></div><div className="col-md-3"><button className="btn btn-outline-secondary w-100" onClick={() => setCompareRanges(compareRanges.length > 1 ? compareRanges.filter((current) => current.key !== item.key) : compareRanges)}>{language === "en" ? "Remove range" : "Usuń zakres"}</button></div></div></div>)}<div className="col-12"><button className="btn btn-primary" onClick={() => setCompareRanges([...compareRanges, { key: `cmp_${Date.now()}`, label: `${language === "en" ? "Range" : "Zakres"} ${compareRanges.length + 1}`, start: "", end: "" }])}>{language === "en" ? "Add range" : "Dodaj zakres"}</button></div></div></div> : null}</div></div> : null}<div className="row row-cards g-3"><div className="col-12"><SummaryCards summary={summary} language={language} locale={locale} compareLabel={comparisonOptions(language).find((item) => item.key === compare)?.label ?? compare} /></div><div className="col-12">{allWidgets.production}</div><div className="col-12">{allWidgets.comparison}</div></div></>} {activeTab === "analytics" && <><PageHeader title={t(language, "analyticsOverview")} subtitle={t(language, "analyticsSubtitle")}><div className="pv-filter-grid"><SegmentedSelect label={t(language, "range")} value={analyticsStart && analyticsEnd ? "custom" : analyticsRange} onChange={(value) => { 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" }]} /><SegmentedSelect label={t(language, "bucket")} value={bucket} onChange={setBucket} options={config.capabilities.buckets.map((item) => ({ key: item.key, label: translateBucket(language, item.key) }))} /><SelectField label={language === "en" ? "Comparison" : "Porównanie"} value={compare} onChange={setCompare} options={comparisonOptions(language)} /></div></PageHeader>{analyticsStart || analyticsEnd || (analyticsRange === "custom") || compare === "custom_multi" ? <div className="card pv-card mb-3"><div className="card-body d-flex flex-column gap-3"><div className="row g-3"><div className="col-md-3 pv-date-field"><label className="form-label">{language === "en" ? "Start" : "Od"}</label><input className="form-control pv-date-input" type="datetime-local" value={analyticsStart} onChange={(e) => setAnalyticsStart(e.target.value)} /></div><div className="col-md-3 pv-date-field"><label className="form-label">{language === "en" ? "End" : "Do"}</label><input className="form-control pv-date-input" type="datetime-local" value={analyticsEnd} onChange={(e) => setAnalyticsEnd(e.target.value)} /></div><div className="col-md-6 d-flex align-items-end"><button className="btn btn-outline-secondary" onClick={() => { setAnalyticsStart(""); setAnalyticsEnd(""); }}>{language === "en" ? "Use preset range" : "Wróć do gotowych zakresów"}</button></div></div>{compare === "custom_multi" ? <div className="border rounded-3 p-3"><div className="fw-semibold mb-3">{language === "en" ? "Comparison ranges" : "Zakresy porównawcze"}</div><div className="row g-3">{compareRanges.map((item, index) => <div className="col-12" key={item.key}><div className="row g-2 align-items-end"><div className="col-md-3"><label className="form-label">{language === "en" ? `Range ${index + 1} label` : `Etykieta ${index + 1}`}</label><input className="form-control" value={item.label} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, label: e.target.value } : current))} /></div><div className="col-md-3 pv-date-field"><label className="form-label">{language === "en" ? "From" : "Od"}</label><input className="form-control pv-date-input" type="datetime-local" value={item.start} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, start: e.target.value } : current))} /></div><div className="col-md-3 pv-date-field"><label className="form-label">{language === "en" ? "To" : "Do"}</label><input className="form-control pv-date-input" type="datetime-local" value={item.end} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, end: e.target.value } : current))} /></div><div className="col-md-3"><button className="btn btn-outline-secondary w-100" onClick={() => setCompareRanges(compareRanges.length > 1 ? compareRanges.filter((current) => current.key !== item.key) : compareRanges)}>{language === "en" ? "Remove range" : "Usuń zakres"}</button></div></div></div>)}<div className="col-12"><button className="btn btn-primary" onClick={() => setCompareRanges([...compareRanges, { key: `cmp_${Date.now()}`, label: `${language === "en" ? "Range" : "Zakres"} ${compareRanges.length + 1}`, start: "", end: "" }])}>{language === "en" ? "Add range" : "Dodaj zakres"}</button></div></div></div> : null}</div></div> : null}<div className="row row-cards g-3"><div className="col-12"><SummaryCards summary={summary} language={language} locale={locale} compareLabel={comparisonOptions(language).find((item) => item.key === compare)?.label ?? compare} /></div><div className="col-12">{allWidgets.production}</div><div className="col-12">{allWidgets.comparison}</div></div></>}
@@ -879,7 +873,26 @@ function translateRangeLabel(language: Language, key: string, fallback: string):
function HeroCards({ cards, locale, language }: { cards: SnapshotPayload["hero_cards"]; locale: string; language: Language }) { return <div className="row row-cards g-3">{cards.map((card) => <div key={card.metric_id} className="col-12 col-sm-6 col-xl-3"><div className="card pv-card pv-hero-card h-100"><div className="card-body"><div className="d-flex align-items-center justify-content-between mb-3"><span className="avatar avatar-sm bg-primary-lt text-primary border-0">{iconForMetric(card.metric_id)}</span><span className="badge bg-primary-lt text-primary text-uppercase">{card.unit || "live"}</span></div><div className="text-secondary text-uppercase small mb-1">{labelForMetric(language, card.metric_id, card.label)}</div><div className="display-6 fw-bold mb-1">{formatValue(card.value, card.unit, 2, locale)}</div><div className="text-secondary small">{card.subtitle}</div></div></div></div>)}</div>; } function HeroCards({ cards, locale, language }: { cards: SnapshotPayload["hero_cards"]; locale: string; language: Language }) { return <div className="row row-cards g-3">{cards.map((card) => <div key={card.metric_id} className="col-12 col-sm-6 col-xl-3"><div className="card pv-card pv-hero-card h-100"><div className="card-body"><div className="d-flex align-items-center justify-content-between mb-3"><span className="avatar avatar-sm bg-primary-lt text-primary border-0">{iconForMetric(card.metric_id)}</span><span className="badge bg-primary-lt text-primary text-uppercase">{card.unit || "live"}</span></div><div className="text-secondary text-uppercase small mb-1">{labelForMetric(language, card.metric_id, card.label)}</div><div className="display-6 fw-bold mb-1">{formatValue(card.value, card.unit, 2, locale)}</div><div className="text-secondary small">{card.subtitle}</div></div></div></div>)}</div>; }
function QuickMetrics({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "quickMetrics")}</h3></div><div className="card-body d-flex flex-column gap-3">{metrics.map((metric) => <div key={metric.metric_id} className="d-flex align-items-center justify-content-between"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div></div>; } function QuickMetrics({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "quickMetrics")}</h3></div><div className="card-body d-flex flex-column gap-3">{metrics.map((metric) => <div key={metric.metric_id} className="d-flex align-items-center justify-content-between"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div></div>; }
function StatusStat({ label, value }: { label: string; value: string }) { return <div className="col-6 col-lg-4 col-xl-2"><div className="border rounded-3 p-3 h-100"><div className="text-secondary small mb-1">{label}</div><div className="fw-semibold">{value}</div></div></div>; } function StatusStat({ label, value }: { label: string; value: string }) { return <div className="col-6 col-lg-4 col-xl-2"><div className="border rounded-3 p-3 h-100"><div className="text-secondary small mb-1">{label}</div><div className="fw-semibold">{value}</div></div></div>; }
function LiveHistoryPanel({ data, language, theme, title, subtitle, footer, freezeUpdates = false }: { data?: HistoryPayload; language: Language; theme: ThemeMode; title: string; subtitle: string; footer?: ReactNode; freezeUpdates?: boolean }) { const [displayData, setDisplayData] = useState<HistoryPayload | undefined>(data); const latestIncomingRef = useRef<HistoryPayload | undefined>(data); useEffect(() => { latestIncomingRef.current = data; if (!freezeUpdates) setDisplayData(data); }, [data, freezeUpdates]); const chartOption = useMemo(() => buildLiveHistoryOption(displayData, theme, language), [displayData, theme, language]); return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{title}</h3><div className="text-secondary small">{subtitle}</div></div></div><div className="card-body"><EChart option={chartOption} className="pv-chart" />{footer ? <div className="mt-3 pt-3 border-top">{footer}</div> : null}</div></div>; } function LiveHistoryPanel({ data, language, theme, title, subtitle, footer, freezeUpdates = false, chartKey }: { data?: HistoryPayload; language: Language; theme: ThemeMode; title: string; subtitle: string; footer?: ReactNode; freezeUpdates?: boolean; chartKey?: string }) {
const [displayData, setDisplayData] = useState<HistoryPayload | undefined>(data);
useEffect(() => {
if (!freezeUpdates) {
setDisplayData(data);
}
}, [data, freezeUpdates]);
useEffect(() => {
if (!freezeUpdates && chartKey) {
setDisplayData(data);
}
}, [chartKey, data, freezeUpdates]);
const resolvedData = freezeUpdates ? displayData : data;
const chartOption = useMemo(() => buildLiveHistoryOption(resolvedData, theme, language), [resolvedData, theme, language]);
return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{title}</h3><div className="text-secondary small">{subtitle}</div></div></div><div className="card-body"><EChart key={chartKey} option={chartOption} className="pv-chart" />{footer ? <div className="mt-3 pt-3 border-top">{footer}</div> : null}</div></div>;
}
function SystemStatus({ items, locale, language }: { items: MetricValue[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "systemStatus")}</h3></div><div className="card-body d-flex flex-column gap-3">{items.map((metric) => <div key={metric.metric_id} className="d-flex align-items-center justify-content-between gap-3 status-row border rounded-3 px-3 py-2"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || metric.status}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div></div>; } function SystemStatus({ items, locale, language }: { items: MetricValue[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "systemStatus")}</h3></div><div className="card-body d-flex flex-column gap-3">{items.map((metric) => <div key={metric.metric_id} className="d-flex align-items-center justify-content-between gap-3 status-row border rounded-3 px-3 py-2"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || metric.status}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div></div>; }
function StringPanels({ rows, locale, language }: { rows: SnapshotGroupRow[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "strings")}</h3></div><div className="card-body"><div className="row g-3">{rows.map((row) => <div key={row.id} className="col-12 col-md-6"><div className="string-panel border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{row.label}</div><div className="d-flex flex-column gap-2">{Object.values(row.values).map((metric) => <div key={metric.metric_id} className="d-flex justify-content-between"><span>{labelForMetric(language, metric.metric_id, metric.label)}</span><span className="fw-medium">{formatValue(metric.value, metric.unit, 2, locale)}</span></div>)}</div></div></div>)}</div></div></div>; } function StringPanels({ rows, locale, language }: { rows: SnapshotGroupRow[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "strings")}</h3></div><div className="card-body"><div className="row g-3">{rows.map((row) => <div key={row.id} className="col-12 col-md-6"><div className="string-panel border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{row.label}</div><div className="d-flex flex-column gap-2">{Object.values(row.values).map((metric) => <div key={metric.metric_id} className="d-flex justify-content-between"><span>{labelForMetric(language, metric.metric_id, metric.label)}</span><span className="fw-medium">{formatValue(metric.value, metric.unit, 2, locale)}</span></div>)}</div></div></div>)}</div></div></div>; }
function StatusDot({ ok }: { ok: boolean }) { return <span className={`d-inline-block rounded-circle ${ok ? "bg-green" : "bg-yellow"}`} style={{ width: 10, height: 10 }} />; } function StatusDot({ ok }: { ok: boolean }) { return <span className={`d-inline-block rounded-circle ${ok ? "bg-green" : "bg-yellow"}`} style={{ width: 10, height: 10 }} />; }

View File

@@ -29,7 +29,9 @@ export function EChart({ option, className = "h-80 w-full" }: EChartProps) {
return; return;
} }
chartRef.current.setOption(option, { notMerge: false, lazyUpdate: true }); chartRef.current.clear();
chartRef.current.setOption(option, { notMerge: true, lazyUpdate: false });
chartRef.current.resize();
}, [option]); }, [option]);
useEffect(() => { useEffect(() => {

View File

@@ -0,0 +1,49 @@
import { useEffect, useRef } from "react";
import * as echarts from "echarts";
import type { EChartsOption } from "echarts";
interface EChartProps {
option: EChartsOption;
className?: string;
}
export function EChart({ option, className = "h-80 w-full" }: EChartProps) {
const ref = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<echarts.EChartsType | null>(null);
useEffect(() => {
if (!ref.current || chartRef.current) {
return;
}
chartRef.current = echarts.init(ref.current);
return () => {
chartRef.current?.dispose();
chartRef.current = null;
};
}, []);
useEffect(() => {
if (!chartRef.current) {
return;
}
chartRef.current.setOption(option, { notMerge: false, lazyUpdate: true });
}, [option]);
useEffect(() => {
if (!ref.current || !chartRef.current) {
return;
}
const observer = new ResizeObserver(() => chartRef.current?.resize());
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, []);
return <div ref={ref} className={className} />;
}