diff --git a/ZMIANY_WDROZONE.txt b/ZMIANY_WDROZONE.txt new file mode 100644 index 0000000..fa821a6 --- /dev/null +++ b/ZMIANY_WDROZONE.txt @@ -0,0 +1,53 @@ +Wdrożone poprawki: + +1. Responsywny time picker / zakresy na mobile +- poprawione przyciski zakresów +- poprawione pola datetime-local +- lepsze układanie filtrów na małych ekranach + +2. Ustawienia desktop - budowanie widoków strony głównej +- przebudowane na interaktywny układ z kolejnością, dodawaniem i usuwaniem metryk +- poprawione przyciski i czytelność sekcji + +3. Przebudowa layoutu ustawień + LIVE +- bardziej przyjazny układ ustawień +- interaktywne ustawianie kolejności sekcji LIVE +- podgląd kolejności sekcji + +4. Polskie znaki +- poprawione polskie znaki w wielu etykietach frontend/backend/demo + +5. DC moc i napięcie na wykresach +- rozróżnienie etykiet typu: DC1 moc [W], DC1 napięcie [V] + +6. Dane chwilowe - zoom i pinch +- dodany zoom/pinch w wykresie live/history przez dataZoom +- wykres danych chwilowych na pełną szerokość +- wybór metryk pod wykresem + +7. Build i weryfikacja +- frontend: npm run build OK +- backend: py_compile OK + + +[v6] +- Live page chart controls aligned further to the right in the header. +- Auto/Paused buttons visually separated for better readability. +- Live chart pause now freezes chart updates and preserves selected zoom area during unrelated re-renders. +- ECharts wrapper no longer recreates the chart instance on every render. +- Cache-Control/Pragma/Expires headers added in Vite dev server and Flask API responses. +- Remaining hardcoded Polish analytics titles/subtitles moved to i18n-backed strings. + +[v7] +- Live header controls are now pushed to the right edge of the header block instead of visually sticking to the title area. +- Live refresh mode uses a polished Auto/Paused switch instead of two plain buttons. +- Added a dedicated live header grid so spacing is cleaner and better balanced on desktop. + +[v8] +- Moved live/archive auto-refresh controls from page headers into the navigation bar area to eliminate desktop overflow. +- Live and historical header layouts are slimmer now and keep only range selectors. +- Added compact navbar refresh switch + refresh action styling for better fit and readability. +- Fixed live chart freeze logic: pausing auto-refresh now really keeps the current chart state and user-selected zoom area intact. +- Frontend build verified after the layout refactor. + +- v9: removed the navbar "Refresh now" button for chart views and reduced the compact Auto/Paused switch size to fit the top bar better. diff --git a/backend/app/app_factory.py b/backend/app/app_factory.py index b61b259..33b173a 100644 --- a/backend/app/app_factory.py +++ b/backend/app/app_factory.py @@ -101,7 +101,8 @@ def create_app() -> Flask: @app.after_request def append_cors_headers(response): - return _apply_cors(response) + response = _apply_cors(response) + return _apply_cache_headers(response) @app.errorhandler(HTTPException) def handle_http_exception(exc: HTTPException): @@ -123,7 +124,7 @@ def _register_cli_commands(app: Flask) -> None: auth_service = get_auth_service() @app.cli.command("create-admin") - @click.option("--username", required=True, help="Login") + @click.option("--username", required=True, help="Username") @click.option("--password", required=True, hide_input=True, confirmation_prompt=True, help="Password") @click.option("--display-name", default=None, help="Name") def create_admin_command(username: str, password: str, display_name: str | None): @@ -134,14 +135,14 @@ def _register_cli_commands(app: Flask) -> None: role="admin", display_name=display_name, ) - click.echo(f"Utworzono konto admina: {user.username}") + click.echo(f"Admin account created: {user.username}") except ValueError as exc: raise click.ClickException(str(exc)) from exc except Exception as exc: # pragma: no cover - raise click.ClickException(f"Cant create admin account: {exc}") from exc + raise click.ClickException(f"Cannot create admin account: {exc}") from exc @app.cli.command("create-user") - @click.option("--username", required=True, help="Login") + @click.option("--username", required=True, help="Username") @click.option("--password", required=True, hide_input=True, confirmation_prompt=True, help="Password") @click.option("--display-name", default=None, help="Name") def create_user_command(username: str, password: str, display_name: str | None): @@ -152,23 +153,23 @@ def _register_cli_commands(app: Flask) -> None: role="user", display_name=display_name, ) - click.echo(f"Admin created: {user.username}") + click.echo(f"User account created: {user.username}") except ValueError as exc: raise click.ClickException(str(exc)) from exc except Exception as exc: # pragma: no cover - raise click.ClickException(f"Cant create user account: {exc}") from exc + raise click.ClickException(f"Cannot create user account: {exc}") from exc @app.cli.command("reset-password") - @click.option("--username", required=True, help="Login") + @click.option("--username", required=True, help="Username") @click.option("--password", required=True, hide_input=True, confirmation_prompt=True, help="Password") def reset_password_command(username: str, password: str): try: user = auth_service.reset_password(username=username, new_password=password) - click.echo(f"Passowrd reseted for: {user.username}") + click.echo(f"Password reset for: {user.username}") except ValueError as exc: raise click.ClickException(str(exc)) from exc except Exception as exc: # pragma: no cover - raise click.ClickException(f"Can't password reset: {exc}") from exc + raise click.ClickException(f"Cannot reset password: {exc}") from exc def _bootstrap_background_services(debug: bool) -> None: @@ -191,3 +192,11 @@ def _apply_cors(response): response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, PUT" response.headers["Access-Control-Allow-Credentials"] = "true" return response + + +def _apply_cache_headers(response): + if request.path == "/" or request.path.startswith("/api/"): + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, proxy-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 23a3ddf..8a45fa1 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -78,3 +78,21 @@ def reset_password(username: str): return jsonify({"detail": str(exc)}), 403 except ValueError as exc: return jsonify({"detail": str(exc)}), 400 + + +@auth_blueprint.put("/auth/users//role") +def update_user_role(username: str): + payload = request.get_json(silent=True) or {} + try: + service.require_admin() + user = service.update_role(username=username, role=payload.get("role", "user")) + return jsonify(to_plain({ + "username": user.username, + "display_name": user.display_name, + "role": user.role, + "is_active": user.is_active, + })) + except PermissionError as exc: + return jsonify({"detail": str(exc)}), 403 + except ValueError as exc: + return jsonify({"detail": str(exc)}), 400 diff --git a/backend/app/routes/dashboard.py b/backend/app/routes/dashboard.py index 3256845..2645223 100644 --- a/backend/app/routes/dashboard.py +++ b/backend/app/routes/dashboard.py @@ -25,11 +25,11 @@ def _resolve_kiosk_mode(requested_mode: str, require_write_access: bool = False) if normalized_mode == "public": if require_write_access and auth_service.enabled and session.get("auth_role") != "admin": - raise PermissionError("Brak uprawnien do edycji publicznego kiosku") + raise PermissionError("You do not have permission to edit the public kiosk") return "public", "public" if normalized_mode != "private": - raise ValueError("Mode musi byc jednym z: public, private") + raise ValueError("Mode must be one of: public, private") if not auth_service.enabled: return "private", "private" diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 99dd53e..d9a59db 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -55,9 +55,9 @@ class AuthService: self._login_legacy_user(username, password) else: if not user.is_active: - raise ValueError("Konto jest nieaktywne") + raise ValueError("Account is inactive") if not check_password_hash(user.password_hash, password): - raise ValueError("Niepoprawny login lub haslo") + raise ValueError("Invalid username or password") self._set_session(user.username, user.display_name, user.role) return self.status() @@ -84,7 +84,7 @@ class AuthService: if not self.enabled: return if session.get(SESSION_ROLE_KEY) != "admin": - raise PermissionError("Brak uprawnien administratora") + raise PermissionError("Administrator permissions are required") def configure_app(self, app) -> None: max_age = int(self.settings.auth["session_max_age_seconds"]) @@ -101,7 +101,7 @@ class AuthService: clean_password = self._validate_password(password) resolved_display_name = (display_name or normalized_username).strip() if not resolved_display_name: - raise ValueError("Display name nie moze byc pusty") + raise ValueError("Display name cannot be empty") return self.user_repository.upsert_user( username=normalized_username, password_hash=generate_password_hash(clean_password), @@ -118,16 +118,33 @@ class AuthService: generate_password_hash(clean_password), ) if user is None: - raise ValueError(f"Uzytkownik '{normalized_username}' nie istnieje") + raise ValueError(f"User '{normalized_username}' does not exist") return user + def update_role(self, *, username: str, role: str) -> AuthUser: + normalized_username = self._normalize_username(username) + normalized_role = self._normalize_role(role) + user = self.user_repository.get_by_username(normalized_username) + if user is None: + raise ValueError(f"User '{normalized_username}' does not exist") + if user.role == normalized_role: + return user + if user.role == 'admin' and normalized_role != 'admin' and self.user_repository.count_admin_users() <= 1: + raise ValueError('At least one active admin user must remain') + updated = self.user_repository.update_role(normalized_username, normalized_role) + if updated is None: + raise ValueError(f"User '{normalized_username}' does not exist") + if session.get(SESSION_USER_KEY) == updated.username: + session[SESSION_ROLE_KEY] = updated.role + return updated + def _login_legacy_user(self, username: str, password: str) -> None: expected_username = self.settings.auth["username"] expected_password = self.settings.auth["password"] expected_password_hash = self.settings.auth.get("password_hash") if username != expected_username: - raise ValueError("Niepoprawny login lub haslo") + raise ValueError("Invalid username or password") if expected_password_hash: password_ok = check_password_hash(expected_password_hash, password) @@ -135,7 +152,7 @@ class AuthService: password_ok = password == expected_password if not password_ok: - raise ValueError("Niepoprawny login lub haslo") + raise ValueError("Invalid username or password") self._set_session( expected_username, @@ -153,19 +170,19 @@ class AuthService: def _normalize_username(self, username: str) -> str: normalized = (username or "").strip() if not normalized: - raise ValueError("Username nie moze byc pusty") + raise ValueError("Username cannot be empty") return normalized def _normalize_role(self, role: str) -> str: normalized = (role or "").strip().lower() if normalized not in VALID_ROLES: - raise ValueError("Rola musi byc jedna z: admin, user") + raise ValueError("Role must be one of: admin, user") return normalized def _validate_password(self, password: str) -> str: clean_password = password or "" if len(clean_password) < 8: - raise ValueError("Haslo musi miec co najmniej 8 znakow") + raise ValueError("Password must be at least 8 characters long") return clean_password diff --git a/backend/app/services/historical_sync.py b/backend/app/services/historical_sync.py index 762810e..cd501e7 100644 --- a/backend/app/services/historical_sync.py +++ b/backend/app/services/historical_sync.py @@ -82,7 +82,7 @@ class HistoricalSyncService: with self._state_lock: self._state.running = False self._state.state = "idle" - self._state.message = "Brak brakujacych dni do importu." + self._state.message = "There are no missing days to import." self._state.finished_at = datetime.utcnow() self._refresh_coverage(lock_held=True) self._refresh_available_bounds(lock_held=True) @@ -92,7 +92,7 @@ class HistoricalSyncService: resolved_start, resolved_end = resolved total_days = (resolved_end - resolved_start).days + 1 total_chunks = max(ceil(total_days / chunk_days), 1) - start_message = "Start importu archiwalnego" if not auto else "Start automatycznej synchronizacji archiwum" + start_message = "Historical import started" if not auto else "Automatic historical sync started" with self._state_lock: if self._worker and self._worker.is_alive(): @@ -138,18 +138,18 @@ class HistoricalSyncService: self._record_event( level="info", - title="Uruchomiono zadanie", - message=f"Zakres {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk {chunk_days} dni", + title="Job started", + message=f"Range {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk size {chunk_days} days", ) return self.status() def cancel(self) -> HistoricalImportStatus: self._cancel_event.set() with self._state_lock: - self._state.message = "Anulowanie zadania..." + self._state.message = "Cancelling job..." self._refresh_runtime_metrics(lock_held=True) snapshot = copy.deepcopy(self._state) - self._record_event(level="warn", title="Anulowanie", message="Uzytkownik poprosil o zatrzymanie zadania.") + self._record_event(level="warn", title="Cancellation requested", message="The user requested the job to stop.") return snapshot def run_blocking( @@ -185,8 +185,8 @@ class HistoricalSyncService: ) self._record_event( level="info", - title="Uruchomiono zadanie", - message=f"Zakres {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk {chunk_days} dni", + title="Job started", + message=f"Range {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk size {chunk_days} days", ) self._run_worker( start_date=resolved_start, @@ -239,8 +239,8 @@ class HistoricalSyncService: chunk_start = start_date while chunk_start <= end_date: if self._cancel_event.is_set(): - self._record_event(level="warn", title="Anulowano", message="Import archiwalny anulowany przez uzytkownika.") - self._finish("cancelled", running=False, message="Import archiwalny anulowany przez uzytkownika.") + self._record_event(level="warn", title="Cancelled", message="Historical import was cancelled by the user.") + self._finish("cancelled", running=False, message="Historical import was cancelled by the user.") return chunk_index += 1 @@ -259,10 +259,10 @@ class HistoricalSyncService: skipped_days=skipped, energy_kwh=energy_kwh, state="cancelled", - note="Chunk zatrzymany podczas przetwarzania", + note="Chunk stopped during processing", ) - self._record_event(level="warn", title="Anulowano", message="Import archiwalny anulowany przez uzytkownika.") - self._finish("cancelled", running=False, message="Import archiwalny anulowany przez uzytkownika.") + self._record_event(level="warn", title="Cancelled", message="Historical import was cancelled by the user.") + self._finish("cancelled", running=False, message="Historical import was cancelled by the user.") return self._close_chunk( @@ -271,23 +271,23 @@ class HistoricalSyncService: skipped_days=skipped, energy_kwh=energy_kwh, state="completed", - note=f"Chunk zakonczony: import {imported}, pominiete {skipped}", + note=f"Chunk completed: imported {imported}, skipped {skipped}", ) self._record_event( level="success", - title=f"Chunk {chunk_index}/{total_chunks} zakonczony", - message=f"Zakres {chunk_start.isoformat()} -> {chunk_end.isoformat()}, import {imported}, pominiete {skipped}, energia {energy_kwh:.2f} kWh", + title=f"Chunk {chunk_index}/{total_chunks} completed", + message=f"Range {chunk_start.isoformat()} -> {chunk_end.isoformat()}, imported {imported}, skipped {skipped}, energy {energy_kwh:.2f} kWh", chunk_index=chunk_index, ) chunk_start = chunk_end + timedelta(days=1) - final_message = "Synchronizacja archiwalna zakonczona" if auto else "Import archiwalny zakonczony" - self._record_event(level="success", title="Zakonczono", message=final_message) + final_message = "Historical synchronization completed" if auto else "Historical import completed" + self._record_event(level="success", title="Completed", message=final_message) self._finish("completed", running=False, message=final_message) except Exception as exc: logger.exception("Historical import failed") - self._record_event(level="error", title="Blad importu", message=str(exc)) - self._finish("failed", running=False, message="Import archiwalny zakonczyl sie bledem.", last_error=str(exc)) + self._record_event(level="error", title="Import error", message=str(exc)) + self._finish("failed", running=False, message="Historical import finished with an error.", last_error=str(exc)) def _process_chunk(self, *, chunk_index: int, start_day: date, end_day: date, force: bool) -> tuple[int, int, float, bool]: imported_days = 0 @@ -303,9 +303,9 @@ class HistoricalSyncService: self._advance_day( day, imported=False, - message=f"Pominieto {day.isoformat()} - dzien juz istnieje w cache", + message=f"Skipped {day.isoformat()} - day already exists in cache", level="warn", - title="Pominieto dzien", + title="Day skipped", chunk_index=chunk_index, ) continue @@ -316,9 +316,9 @@ class HistoricalSyncService: self._advance_day( day, imported=False, - message=f"Pominieto {day.isoformat()} - brak probek w InfluxDB", + message=f"Skipped {day.isoformat()} - no samples in InfluxDB", level="warn", - title="Brak probek", + title="No samples", chunk_index=chunk_index, ) continue @@ -336,9 +336,9 @@ class HistoricalSyncService: self._advance_day( day, imported=True, - message=f"Zaimportowano {day.isoformat()} ({total:.2f} kWh)", + message=f"Imported {day.isoformat()} ({total:.2f} kWh)", level="success", - title="Zaimportowano dzien", + title="Day imported", chunk_index=chunk_index, energy_kwh=total, ) @@ -366,7 +366,7 @@ class HistoricalSyncService: self._state.message = message self._refresh_coverage(lock_held=True) self._refresh_runtime_metrics(lock_held=True) - suffix = f" Energia: {energy_kwh:.2f} kWh." if imported and energy_kwh is not None else "" + suffix = f" Energy: {energy_kwh:.2f} kWh." if imported and energy_kwh is not None else "" self._record_event( level=level, title=title, @@ -383,19 +383,19 @@ class HistoricalSyncService: end_date=chunk_end, state="running", started_at=datetime.utcnow(), - note=f"Aktywny chunk {chunk_start.isoformat()} -> {chunk_end.isoformat()}", + note=f"Active chunk {chunk_start.isoformat()} -> {chunk_end.isoformat()}", ) with self._state_lock: self._state.current_chunk_start = chunk_start self._state.current_chunk_end = chunk_end self._state.active_chunk_index = chunk_index - self._state.message = f"Przetwarzanie zakresu {chunk_start.isoformat()} -> {chunk_end.isoformat()}" + self._state.message = f"Processing range {chunk_start.isoformat()} -> {chunk_end.isoformat()}" self._upsert_chunk_locked(chunk) self._refresh_runtime_metrics(lock_held=True) self._record_event( level="info", title=f"Chunk {chunk_index}/{total_chunks}", - message=f"Start zakresu {chunk_start.isoformat()} -> {chunk_end.isoformat()}", + message=f"Starting range {chunk_start.isoformat()} -> {chunk_end.isoformat()}", chunk_index=chunk_index, ) diff --git a/backend/app/services/kiosk_settings.py b/backend/app/services/kiosk_settings.py index a4428a8..18a9ec8 100644 --- a/backend/app/services/kiosk_settings.py +++ b/backend/app/services/kiosk_settings.py @@ -77,7 +77,7 @@ class KioskSettingsService: return normalized if normalized.startswith(USER_MODE_PREFIX) and len(normalized) > len(USER_MODE_PREFIX): return normalized - raise ValueError("Mode musi byc jednym z: public, private") + raise ValueError("Mode must be one of: public, private") def _normalize_widgets(self, widgets: Any) -> list[str]: if not isinstance(widgets, list): diff --git a/backend/app/services/realtime.py b/backend/app/services/realtime.py index 8c04d9d..0c3e834 100644 --- a/backend/app/services/realtime.py +++ b/backend/app/services/realtime.py @@ -50,34 +50,34 @@ class RealtimeService: inverter_temp = to_float(_value(latest, "inverter_temp")) hero_cards = [ - self._hero_card("ac_power", ac_power, subtitle="Aktualna moc AC"), - self._hero_card("dc_power_total", total_dc_power, label="Moc DC laczna", unit="W", subtitle="Suma stringow DC"), - self._hero_card("energy_today", energy_today, label="Energia dzis", unit="kWh", subtitle="Liczona z danych Influx"), - self._hero_card("energy_total", total_energy, label="Energia laczna", unit="kWh", subtitle="Licznik calkowity"), + self._hero_card("ac_power", ac_power, subtitle="Current AC power"), + self._hero_card("dc_power_total", total_dc_power, label="Total DC power", unit="W", subtitle="Sum of DC strings"), + self._hero_card("energy_today", energy_today, label="Energy today", unit="kWh", subtitle="Calculated from Influx data"), + self._hero_card("energy_total", total_energy, label="Total energy", unit="kWh", subtitle="Lifetime counter"), ] if inverter_temp is not None: - hero_cards.append(self._hero_card("inverter_temp", inverter_temp, label="Temp. falownika", unit="°C", subtitle="Sensor opcjonalny")) + hero_cards.append(self._hero_card("inverter_temp", inverter_temp, label="Inverter temperature", unit="°C", subtitle="Optional sensor")) kpis = { - "energy_today": custom_metric_value("energy_today", "Energia dzis", energy_today, unit="kWh", precision=2, status="ok"), - "energy_yesterday": custom_metric_value("energy_yesterday", "Energia wczoraj", energy_yesterday, unit="kWh", precision=2, status="ok"), + "energy_today": custom_metric_value("energy_today", "Energy today", energy_today, unit="kWh", precision=2, status="ok"), + "energy_yesterday": custom_metric_value("energy_yesterday", "Energy yesterday", energy_yesterday, unit="kWh", precision=2, status="ok"), "energy_total": custom_metric_value( "energy_total", - "Energia laczna", + "Total energy", total_energy, unit="kWh", precision=2, timestamp=_timestamp(latest, "energy_total"), status="ok", ), - "dc_power_total": custom_metric_value("dc_power_total", "Moc DC laczna", total_dc_power, unit="W", precision=0, status="ok"), + "dc_power_total": custom_metric_value("dc_power_total", "Total DC power", total_dc_power, unit="W", precision=0, status="ok"), } comparison = compare_delta_pct(energy_today, energy_yesterday) if comparison is not None: kpis["today_vs_yesterday"] = custom_metric_value( "today_vs_yesterday", - "Dzis vs wczoraj", + "Today vs yesterday", comparison, unit="%", precision=2, @@ -97,7 +97,7 @@ class RealtimeService: status.append( custom_metric_value( "data_refresh", - "Ostatni odczyt energii", + "Last energy reading", _timestamp(latest, "energy_total").isoformat() if _timestamp(latest, "energy_total") else None, status="ok" if _timestamp(latest, "energy_total") else "neutral", kind="text", @@ -144,7 +144,7 @@ class RealtimeService: series.append( { "metric_id": metric.id, - "label": metric.label if slot != "power" else group["label"], + "label": f"{group['label']} power" if slot == "power" else f"{group['label']} voltage" if slot == "voltage" else metric.label, "unit": metric.unit, "points": self.influx.gauge_history(metric, window.start, window.end, interval=interval, aggregate="mean"), } diff --git a/backend/app/storage/auth_users.py b/backend/app/storage/auth_users.py index 8800706..2aa9bad 100644 --- a/backend/app/storage/auth_users.py +++ b/backend/app/storage/auth_users.py @@ -108,6 +108,23 @@ class SQLiteAuthUserRepository: return None return self.get_by_username(username) + def update_role(self, username: str, role: str) -> AuthUser | None: + now = datetime.utcnow().isoformat() + with self.connect() as conn: + cursor = conn.execute( + "UPDATE auth_users SET role = ?, updated_at = ? WHERE username = ?", + (role, now, username), + ) + if cursor.rowcount == 0: + return None + return self.get_by_username(username) + + def count_admin_users(self) -> int: + with self.connect() as conn: + row = conn.execute( + "SELECT COUNT(*) AS count FROM auth_users WHERE role = 'admin' AND is_active = 1" + ).fetchone() + return int(row['count']) if row is not None else 0 def list_users(self) -> list[AuthUser]: with self.connect() as conn: diff --git a/backend/backfill.py b/backend/backfill.py index 48c86d2..68d57e6 100644 --- a/backend/backfill.py +++ b/backend/backfill.py @@ -7,11 +7,11 @@ from app.services.historical_sync import get_historical_sync_service from app.utils.serialization import to_plain -parser = argparse.ArgumentParser(description="Import dziennych agregatow PV z InfluxDB do lokalnego cache SQLite") -parser.add_argument("--start-date", dest="start_date", help="Data startowa YYYY-MM-DD") -parser.add_argument("--end-date", dest="end_date", help="Data koncowa YYYY-MM-DD") -parser.add_argument("--chunk-days", dest="chunk_days", type=int, default=7, help="Liczba dni na chunk") -parser.add_argument("--force", action="store_true", help="Nadpisz dni juz zapisane w cache") +parser = argparse.ArgumentParser(description="Import daily PV aggregates from InfluxDB into the local SQLite cache") +parser.add_argument("--start-date", dest="start_date", help="Start date YYYY-MM-DD") +parser.add_argument("--end-date", dest="end_date", help="End date YYYY-MM-DD") +parser.add_argument("--chunk-days", dest="chunk_days", type=int, default=7, help="Number of days per chunk") +parser.add_argument("--force", action="store_true", help="Overwrite days already stored in the cache") args = parser.parse_args() service = get_historical_sync_service() diff --git a/backend/config.py b/backend/config.py index f1435f9..239949d 100644 --- a/backend/config.py +++ b/backend/config.py @@ -59,7 +59,7 @@ APP_CONFIG = { } SITE_CONFIG = { - "site_name": os.getenv("SITE_NAME", "Domowa instalacja PV"), + "site_name": os.getenv("SITE_NAME", "Home PV installation"), "timezone": APP_CONFIG["timezone"], "installed_power_kwp": env_float("PV_INSTALLED_POWER_KWP", 9.99), "currency": os.getenv("SITE_CURRENCY", "PLN"), @@ -91,20 +91,20 @@ CORS_ORIGINS = [ ] TIME_RANGES = { - "today": {"label": "Dzis", "special": "today"}, - "yesterday": {"label": "Wczoraj", "special": "yesterday"}, + "today": {"label": "Today", "special": "today"}, + "yesterday": {"label": "Yesterday", "special": "yesterday"}, "6h": {"label": "6h", "seconds": 6 * 3600}, "12h": {"label": "12h", "seconds": 12 * 3600}, "24h": {"label": "24h", "seconds": 24 * 3600}, "48h": {"label": "48h", "seconds": 48 * 3600}, - "1d": {"label": "1 dzien", "seconds": 1 * 24 * 3600}, - "3d": {"label": "3 dni", "seconds": 3 * 24 * 3600}, - "7d": {"label": "7 dni", "seconds": 7 * 24 * 3600}, - "14d": {"label": "14 dni", "seconds": 14 * 24 * 3600}, - "30d": {"label": "30 dni", "seconds": 30 * 24 * 3600}, - "60d": {"label": "60 dni", "seconds": 60 * 24 * 3600}, - "90d": {"label": "90 dni", "seconds": 90 * 24 * 3600}, - "365d": {"label": "365 dni", "seconds": 365 * 24 * 3600}, + "1d": {"label": "1 day", "seconds": 1 * 24 * 3600}, + "3d": {"label": "3 days", "seconds": 3 * 24 * 3600}, + "7d": {"label": "7 days", "seconds": 7 * 24 * 3600}, + "14d": {"label": "14 days", "seconds": 14 * 24 * 3600}, + "30d": {"label": "30 days", "seconds": 30 * 24 * 3600}, + "60d": {"label": "60 days", "seconds": 60 * 24 * 3600}, + "90d": {"label": "90 days", "seconds": 90 * 24 * 3600}, + "365d": {"label": "365 days", "seconds": 365 * 24 * 3600}, "ytd": {"label": "YTD", "special": "ytd"}, } @@ -119,20 +119,20 @@ ANALYTICS = { "default_range": os.getenv("ANALYTICS_DEFAULT_RANGE", "30d"), "default_bucket": os.getenv("ANALYTICS_DEFAULT_BUCKET", "day"), "bucket_labels": { - "day": "Dzien", - "week": "Tydzien", - "month": "Miesiac", - "year": "Rok", + "day": "Day", + "week": "Week", + "month": "Month", + "year": "Year", }, "compare_modes": { - "none": "Porownanie", - "previous_period": "Poprzedni okres", - "previous_year": "Poprzedni rok", - "previous_year_2": "2 lata wstecz", - "previous_year_3": "3 lata wstecz", - "previous_month_12": "12 miesiecy wstecz", - "previous_month_24": "24 miesiace wstecz", - "custom_multi": "Wlasne zakresy", + "none": "Comparison", + "previous_period": "Previous period", + "previous_year": "Previous year", + "previous_year_2": "2 years back", + "previous_year_3": "3 years back", + "previous_month_12": "12 months back", + "previous_month_24": "24 months back", + "custom_multi": "Custom ranges", }, } @@ -208,7 +208,7 @@ register_metric( entity_id=os.getenv("PV_AC_POWER_ENTITY", "sofarsolar_ac_power"), measurement=os.getenv("PV_AC_POWER_MEASUREMENT", "W"), unit="W", - label="Moc AC", + label="AC Power", precision=0, ) register_metric( @@ -216,7 +216,7 @@ register_metric( entity_id=os.getenv("PV_TOTAL_ENERGY_ENTITY", "sofarsolar_energy_total"), measurement=os.getenv("PV_TOTAL_ENERGY_MEASUREMENT", "kWh"), unit="kWh", - label="Energia laczna", + label="Total Energy", kind="counter", precision=2, ) @@ -225,7 +225,7 @@ register_metric( entity_id=os.getenv("PV_INVERTER_TEMP_ENTITY", "sofarsolar_temprature_inverter"), measurement=os.getenv("PV_INVERTER_TEMP_MEASUREMENT", "°C"), unit="°C", - label="Temperatura falownika", + label="Inverter Temperature", precision=1, ) @@ -243,7 +243,7 @@ for index, defaults in STRING_DEFAULTS.items(): entity_id=os.getenv(f"PV_STRING_{index}_POWER_ENTITY", defaults["power"]), measurement=os.getenv(f"PV_STRING_{index}_POWER_MEASUREMENT", "W"), unit="W", - label=f"{label} moc", + label=f"{label} power", precision=0, ) voltage_metric_id = register_metric( @@ -251,7 +251,7 @@ for index, defaults in STRING_DEFAULTS.items(): entity_id=os.getenv(f"PV_STRING_{index}_VOLTAGE_ENTITY", defaults["voltage"]), measurement=os.getenv(f"PV_STRING_{index}_VOLTAGE_MEASUREMENT", "V"), unit="V", - label=f"{label} napiecie", + label=f"{label} voltage", precision=1, ) diff --git a/deploy/nginx/default.conf b/deploy/nginx/default.conf index 2bada72..cd8964e 100644 --- a/deploy/nginx/default.conf +++ b/deploy/nginx/default.conf @@ -10,6 +10,19 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; + } + + location /assets/ { + proxy_pass http://frontend:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + add_header Cache-Control "public, max-age=31536000, immutable" always; } location / { @@ -19,5 +32,8 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; } } diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 21ae07d..22a5b39 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -2,10 +2,16 @@ server { listen 80; server_name _; server_tokens off; - + root /usr/share/nginx/html; index index.html; + location /assets/ { + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable" always; + try_files $uri =404; + } + location /api/ { proxy_pass http://backend:8105; proxy_http_version 1.1; @@ -13,6 +19,9 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Real-IP $remote_addr; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; } location = /health { @@ -22,9 +31,22 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Real-IP $remote_addr; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; + } + + location = /index.html { + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; + try_files $uri =404; } location / { + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; try_files $uri $uri/ /index.html; } -} \ No newline at end of file +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a9dc62e..e5dfbfe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -60,6 +60,8 @@ const STORAGE_KEYS = { liveWidgets: "pv-live-widgets-v4", liveMetrics: "pv-live-metrics-v4", archiveMetrics: "pv-archive-metrics-v4", + archiveAutoRefresh: "pv-archive-autorefresh-v1", + liveAutoRefresh: "pv-live-autorefresh-v1", }; const DEFAULT_KIOSK_WIDGETS: WidgetId[] = ["hero", "history", "strings", "status", "production", "comparison", "importStatus"]; @@ -226,9 +228,11 @@ function buildLiveHistoryOption(history: HistoryPayload | undefined, theme: Them nameTextStyle: { color: palette.text }, axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) }, splitLine: index === 0 ? { lineStyle: { color: palette.grid } } : { show: false }, + axisLine: { lineStyle: { color: palette.grid } }, }; }); return { + animation: false, color: palette.series, tooltip: { trigger: "axis", @@ -241,11 +245,41 @@ function buildLiveHistoryOption(history: HistoryPayload | undefined, theme: Them return [header, ...rows.map((row) => `${row.marker ?? ""} ${row.seriesName}: ${formatChartNumber(row.value, locale)}`)].join("
"); }, }, - legend: { top: 0, textStyle: { color: palette.text }, itemGap: 16 }, - grid: { left: 12, right: 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: 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) })), + legend: { type: "scroll", top: 0, textStyle: { color: palette.text }, itemGap: 16 }, + grid: { left: 12, right: yAxes.length > 1 ? 52 : 16, top: 48, bottom: 72, 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: 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 } }, + axisLine: { lineStyle: { color: palette.grid } }, + }], + dataZoom: [ + { type: "inside", zoomOnMouseWheel: true, moveOnMouseMove: true, moveOnMouseWheel: true, preventDefaultMouseMove: true }, + { type: "slider", height: 28, bottom: 18, borderColor: palette.grid, fillerColor: "rgba(32, 107, 196, 0.16)", handleSize: "80%" }, + ], + series: series.map((item, index) => ({ + name: item.unit ? `${item.label} [${item.unit}]` : item.label, + type: "line", + smooth: true, + connectNulls: true, + showSymbol: false, + sampling: "lttb", + 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 { @@ -477,13 +511,15 @@ export default function App() { const [compare, setCompare] = useState("previous_year"); const [analyticsStart, setAnalyticsStart] = useState(""); const [analyticsEnd, setAnalyticsEnd] = useState(""); - const [compareRanges, setCompareRanges] = useState>([{ key: "cmp_1", label: "Porównanie 1", start: "", end: "" }, { key: "cmp_2", label: "Porównanie 2", start: "", end: "" }]); + const [compareRanges, setCompareRanges] = useState>([{ key: "cmp_1", label: "Comparison 1", start: "", end: "" }, { key: "cmp_2", label: "Comparison 2", start: "", end: "" }]); const initialArchiveWindow = archivePresetWindow("today"); const [archiveStart, setArchiveStart] = useState(initialArchiveWindow?.start ?? ""); const [archiveEnd, setArchiveEnd] = useState(initialArchiveWindow?.end ?? ""); 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 [archiveAutoRefresh, setArchiveAutoRefresh] = useState(() => readStorage(STORAGE_KEYS.archiveAutoRefresh, true, (raw) => raw === "true")); + const [liveAutoRefresh, setLiveAutoRefresh] = useState(() => readStorage(STORAGE_KEYS.liveAutoRefresh, true, (raw) => raw === "true")); 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))); @@ -536,6 +572,8 @@ export default function App() { useEffect(() => { writeStorage(STORAGE_KEYS.liveWidgets, liveWidgets); }, [liveWidgets]); useEffect(() => { writeStorage(STORAGE_KEYS.liveMetrics, liveMetrics); }, [liveMetrics]); useEffect(() => { writeStorage(STORAGE_KEYS.archiveMetrics, archiveMetrics); }, [archiveMetrics]); + useEffect(() => { writeStorage(STORAGE_KEYS.archiveAutoRefresh, String(archiveAutoRefresh)); }, [archiveAutoRefresh]); + useEffect(() => { writeStorage(STORAGE_KEYS.liveAutoRefresh, String(liveAutoRefresh)); }, [liveAutoRefresh]); const dataEnabled = authenticated || authEnabled === false; const currentRole = publicMode ? null : (authQuery.data?.role ?? null); @@ -593,12 +631,13 @@ export default function App() { 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, { publicKiosk: publicMode }); + const historyQuery = useRealtimeHistory(effectiveRealtimeRange, dataEnabled, { publicKiosk: publicMode, pauseAutoRefresh: !liveAutoRefresh }); + const archiveManualRangeActive = Boolean(archiveStart && archiveEnd); 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 archiveQuery = useRealtimeHistory(archiveStart && archiveEnd ? "custom" : archiveRange, dataEnabled, { start: archiveStart || undefined, end: archiveEnd || undefined, metrics: archiveMetrics, publicKiosk: publicMode, pauseAutoRefresh: !archiveAutoRefresh }); 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]); @@ -609,6 +648,7 @@ export default function App() { const logoutMutation = useMutation({ mutationFn: api.logout, onSuccess: async () => { await queryClient.clear(); await queryClient.invalidateQueries({ queryKey: ["auth-status"] }); } }); const createUserMutation = useMutation({ mutationFn: () => api.createUser(newUser), onSuccess: async () => { setNewUser({ username: "", display_name: "", password: "", role: "user" }); await usersQuery.refetch(); } }); const resetPasswordMutation = useMutation({ mutationFn: () => api.resetUserPassword(passwordReset.username, passwordReset.password), onSuccess: async () => { setPasswordReset({ username: "", password: "" }); await usersQuery.refetch(); } }); + const updateUserRoleMutation = useMutation({ mutationFn: ({ username, role }: { username: string; role: string }) => api.updateUserRole(username, role), onSuccess: async () => { await usersQuery.refetch(); await queryClient.invalidateQueries({ queryKey: ["auth-status"] }); } }); const saveKioskSettingsMutation = useMutation({ mutationFn: (payload: KioskSettingsPayload) => api.saveKioskSettings(payload), @@ -680,11 +720,11 @@ export default function App() { const allWidgets: Record = { hero: , quickMetrics: , - history: , + history: {liveAutoRefresh ? (language === "en" ? "Auto refresh active" : "Auto-odświeżanie aktywne") : (language === "en" ? "Auto refresh paused" : "Auto-odświeżanie zatrzymane")}
{liveAutoRefresh ? (language === "en" ? "The live chart refreshes automatically every 30 seconds." : "Wykres live odświeża się automatycznie co 30 sekund.") : (language === "en" ? "The live chart is frozen until you resume automatic refresh or refresh it manually." : "Wykres live jest zamrożony, dopóki nie wznowisz auto-odświeżania albo nie odświeżysz go ręcznie.")}
} />, status: , strings: , production: , - comparison: effectiveCompare !== "none" ? : null, + comparison: , distribution: null, importStatus: , }; @@ -751,6 +791,12 @@ export default function App() { { id: "settings" as TabKey, label: t(language, "settings"), icon: , visible: hasSettingsAccess }, ].filter((item) => item.visible); + const subnavRefreshControls = activeTab === "realtime" + ?
+ : activeTab === "archive" + ?
+ : null; + const menu = (
@@ -767,9 +813,12 @@ export default function App() { ))}
-
- - {t(language, "updatedAt")}: {formatDateTime(lastUpdated, locale)} +
+ {subnavRefreshControls} +
+ + {t(language, "updatedAt")}: {formatDateTime(lastUpdated, locale)} +
@@ -782,17 +831,17 @@ export default function App() { return (
{navbar}{menu}
- {activeTab === "realtime" && <>
{effectiveLiveWidgets.map((widgetId) => renderLiveWidget(widgetId))}
} + {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); setArchiveAutoRefresh(true); }} options={archiveQuickRangeOptions(language)} /> 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" }]} />
{ setArchiveRange("custom"); setArchiveStart(e.target.value); setArchiveAutoRefresh(false); }} />
{ setArchiveRange("custom"); setArchiveEnd(e.target.value); setArchiveAutoRefresh(false); }} />
{archiveAutoRefresh && !archiveManualRangeActive ? (language === "en" ? "Auto refresh active" : "Auto-odświeżanie aktywne") : (language === "en" ? "Auto refresh paused" : "Auto-odświeżanie zatrzymane")}
{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.")}
} />
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 === "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}
{allWidgets.comparison}
} {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} 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" && <>
item.metric_id !== "energy_total")} selected={liveMetrics.filter((item) => item !== "energy_total")} onChange={setLiveMetrics} />
diagnosticsQuery.refetch()} />
{isAdmin ?
createUserMutation.mutate()} passwordReset={passwordReset} onPasswordResetChange={setPasswordReset} onResetPassword={() => resetPasswordMutation.mutate()} onRoleChange={(username, role) => updateUserRoleMutation.mutate({ username, role })} roleUpdating={updateUserRoleMutation.isPending} />
: null}
}
); } @@ -815,16 +864,22 @@ function applyArchivePreset(rangeKey: string, setStart: (value: string) => void, } } function LoadingScreen({ language }: { language: Language }) { return
{t(language, "loading")}…
; } -function LoginPage({ language, theme, form, onChange, onSubmit, onThemeToggle, onLanguageToggle, loading, error }: { language: Language; theme: ThemeMode; form: { username: string; password: string }; onChange: (value: { username: string; password: string }) => void; onSubmit: () => void; onThemeToggle: () => void; onLanguageToggle: () => void; loading: boolean; error: string | null; }) { return

{t(language, "loginTitle")}

{t(language, "loginSubtitle")}
onChange({ ...form, username: event.target.value })} autoComplete="username" />
onChange({ ...form, password: event.target.value })} autoComplete="current-password" onKeyDown={(event) => event.key === "Enter" && onSubmit()} />
{error ?
{error}
: null}
; } +function LoginPage({ language, theme, form, onChange, onSubmit, onThemeToggle, onLanguageToggle, loading, error }: { language: Language; theme: ThemeMode; form: { username: string; password: string }; onChange: (value: { username: string; password: string }) => void; onSubmit: () => void; onThemeToggle: () => void; onLanguageToggle: () => void; loading: boolean; error: string | null; }) { return

{t(language, "loginTitle")}

{t(language, "loginSubtitle")}
onChange({ ...form, username: event.target.value })} autoComplete="username" />
onChange({ ...form, password: event.target.value })} autoComplete="current-password" onKeyDown={(event) => event.key === "Enter" && onSubmit()} />
{error ?
{error}
: null}
{language === "en" ? "Use your account to manage dashboards, analytics and user permissions." : "Użyj swojego konta, aby zarządzać dashboardami, analityką i uprawnieniami użytkowników."}
; } function NavItem({ icon, active, onClick, label }: { icon: ReactElement; active: boolean; onClick: () => void; label: string }) { return
  • ; } -function PageHeader({ title, subtitle, children }: { title: string; subtitle: string; children?: ReactNode }) { return
    PV Insight

    {title}

    {subtitle}
    {children ?
    {children}
    : null}
    ; } +function PageHeader({ title, subtitle, children }: { title: string; subtitle: string; children?: ReactNode }) { return
    PV Insight

    {title}

    {subtitle}
    {children ?
    {children}
    : null}
    ; } +function RefreshModeSwitch({ language, autoEnabled, onChange, compact = false }: { language: Language; autoEnabled: boolean; onChange: (value: boolean) => void; compact?: boolean }) { + const autoLabel = language === "en" ? "Auto" : "Auto"; + const pausedLabel = language === "en" ? "Paused" : "Pauza"; + const switchLabel = language === "en" ? "Chart refresh mode" : "Tryb odświeżania wykresu"; + return
    {compact ? null :
    {language === "en" ? "Chart refresh" : "Odświeżanie wykresu"}
    }
    ; +} function SegmentedSelect({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return
    {label}
    {options.map((option) => )}
    ; } -function SelectField({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return
    ; } +function SelectField({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return
    ; } function translateRangeLabel(language: Language, key: string, fallback: string): string { const map: Record = { today: { pl: "Dziś", en: "Today" }, yesterday: { pl: "Wczoraj", en: "Yesterday" }, "7d": { pl: "7 dni", en: "7d" }, "30d": { pl: "30 dni", en: "30d" }, "90d": { pl: "90 dni", en: "90d" }, "365d": { pl: "365 dni", en: "365 days" }, custom: { pl: "Ręczny", en: "Custom" } }; return map[key]?.[language] ?? fallback; } function HeroCards({ cards, locale, language }: { cards: SnapshotPayload["hero_cards"]; locale: string; language: Language }) { return
    {cards.map((card) =>
    {iconForMetric(card.metric_id)}{card.unit || "live"}
    {labelForMetric(language, card.metric_id, card.label)}
    {formatValue(card.value, card.unit, 2, locale)}
    {card.subtitle}
    )}
    ; } function QuickMetrics({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return

    {t(language, "quickMetrics")}

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

    {title}

    {subtitle}
    ; } +function 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(data); const latestIncomingRef = useRef(data); useEffect(() => { latestIncomingRef.current = data; if (!freezeUpdates) setDisplayData(data); }, [data, freezeUpdates]); const chartOption = useMemo(() => buildLiveHistoryOption(displayData, theme, language), [displayData, theme, language]); return

    {title}

    {subtitle}
    {footer ?
    {footer}
    : null}
    ; } 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 StatusDot({ ok }: { ok: boolean }) { return ; } @@ -842,11 +897,14 @@ function StringsPanel({ rows, locale, language }: { rows: SnapshotGroupRow[]; lo function SummaryCards({ summary, language, locale, compareLabel }: { summary?: AnalyticsPayload["summary"]; language: Language; locale: string; compareLabel: string }) { const items = [{ key: t(language, "summaryTotal"), value: formatValue(summary?.total, summary?.unit ?? "kWh", 2, locale), badge: compareLabel }, { key: t(language, "summaryAverage"), value: formatValue(summary?.average_bucket, summary?.unit ?? "kWh", 2, locale) }, { key: t(language, "summaryBest"), value: summary ? `${summary.best_bucket_label} · ${formatValue(summary.best_bucket_value, summary.unit, 2, locale)}` : "--" }, { key: t(language, "summaryCo2"), value: formatValue(summary?.co2_saved_kg, "kg", 1, locale) }]; return
    {items.map((item) =>
    {item.key}
    {item.value}
    {item.badge ?
    {item.badge}
    : null}
    )}
    ; } function ProductionPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) { return

    {t(language, "chartProduction")}

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

    {t(language, "chartComparison")}

    {comparisonDisplayMode === "line" - ? (language === "en" ? "Line view works better when comparing many periods." : "Widok liniowy lepiej działa przy większej liczbie porównań.") - : (language === "en" ? "Grouped bars make direct value comparison easier." : "Słupki ułatwiają porównanie wartości 1:1.")}
    setComparisonDisplayMode(value as "line" | "bar")} options={[{ key: "line", label: language === "en" ? "Line" : "Linia" }, { key: "bar", label: language === "en" ? "Bars" : "Słupki" }]} />
    ; + const hasComparisonData = Boolean(data?.comparisons?.some((item) => item.points?.length) || data?.comparison?.length); + return

    {t(language, "chartComparison")}

    {compareMode === "none" + ? (language === "en" ? "Comparison chart restored. It shows the current period until you pick a comparison mode." : "Wykres porównań został przywrócony. Pokazuje bieżący okres, dopóki nie wybierzesz trybu porównania.") + : comparisonDisplayMode === "line" + ? (language === "en" ? "Line view works better when comparing many periods." : "Widok liniowy lepiej działa przy większej liczbie porównań.") + : (language === "en" ? "Grouped bars make direct value comparison easier." : "Słupki ułatwiają porównanie wartości 1:1.")}
    setComparisonDisplayMode(value as "line" | "bar")} options={[{ key: "line", label: language === "en" ? "Line" : "Linia" }, { key: "bar", label: language === "en" ? "Bars" : "Słupki" }]} />
    {hasComparisonData || (data?.current?.length ?? 0) > 0 ? :
    {language === "en" ? "No data available for the comparison chart yet." : "Brak danych do wykresu porównań."}
    }
    ; } function DistributionPanel({ data, language, theme, locale }: { data?: DistributionPayload; language: Language; theme: ThemeMode; locale: string }) { return

    {t(language, "chartDistribution")}

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

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

    {t(language, "importArchiveSubtitle")}
    {!compact ? <>
    {t(language, "activeChunk")}{status.active_chunk_index}/{status.total_chunks}
    {status.recent_chunks.map((chunk) => )}
    {t(language, "recentChunks")}{t(language, "status")}kWh
    #{chunk.chunk_index}
    {chunk.start_date} → {chunk.end_date}
    {chunk.state}{formatValue(chunk.energy_kwh, "kWh", 2, locale)}
    {status.recent_events.map((event, index) =>
    {event.title}
    {event.message}
    {formatShortTime(event.timestamp, locale)}
    )}
    : null}
    ; } @@ -903,29 +961,65 @@ function KioskChartGroupsPanel({ language, items, groups, onChange, historyEnabl 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 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 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, "kioskCharts") }, - { id: "status", label: t(language, "systemStatus") }, - { id: "strings", label: t(language, "strings") }, + const items: Array<{ id: LiveWidgetId; label: string; description: string }> = [ + { id: "hero", label: language === "en" ? "Hero cards" : "Karty hero", description: language === "en" ? "Top KPI summary" : "Najważniejsze KPI na górze" }, + { id: "quickMetrics", label: t(language, "quickMetrics"), description: language === "en" ? "Compact KPI list" : "Szybka lista KPI" }, + { id: "history", label: t(language, "kioskCharts"), description: language === "en" ? "Main live chart" : "Główny wykres live" }, + { id: "status", label: t(language, "systemStatus"), description: language === "en" ? "Connection and device status" : "Połączenie i stan urządzenia" }, + { id: "strings", label: t(language, "strings"), description: language === "en" ? "String power and voltage" : "Moc i napięcie stringów" }, ]; + const visible = selected; + const hidden = items.filter((item) => !visible.includes(item.id)); + const move = (id: LiveWidgetId, direction: -1 | 1) => { + const index = visible.indexOf(id); + if (index === -1) return; + const target = index + direction; + if (target < 0 || target >= visible.length) return; + const next = [...visible]; + [next[index], next[target]] = [next[target], next[index]]; + onChange(next); + }; const toggle = (id: LiveWidgetId) => { - if (selected.includes(id)) { - const next = selected.filter((item) => item !== id); - onChange(next.length ? next : selected); + if (visible.includes(id)) { + const next = visible.filter((item) => item !== id); + onChange(next.length ? next : visible); return; } - onChange([...selected, id]); + onChange([...visible, 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) => )}
    ; + return

    {language === "en" ? "LIVE layout" : "Układ LIVE"}

    {language === "en" ? "Decide which sections are visible and in what order they appear." : "Ustaw, które sekcje są widoczne i w jakiej kolejności się pokazują."}
    {visible.length}
    {language === "en" ? "The order below directly controls the desktop LIVE dashboard." : "Kolejność poniżej bezpośrednio steruje układem desktopowego dashboardu LIVE."}
    {language === "en" ? "Visible and ordered" : "Widoczne i uporządkowane"}
    {visible.map((id, index) => { const item = items.find((entry) => entry.id === id); if (!item) return null; return
    {index + 1}
    {item.label}
    {item.description}
    ; })}
    {language === "en" ? "Hidden sections" : "Ukryte sekcje"}
    {hidden.length ?
    {hidden.map((item) => )}
    :
    {language === "en" ? "All available sections are currently visible." : "Wszystkie dostępne sekcje są teraz widoczne."}
    }
    {language === "en" ? "Quick preview" : "Szybki podgląd"}
    {visible.map((id, index) => `${index + 1}. ${items.find((item) => item.id === id)?.label ?? id}`).join(" → ")}
    ; +} +function BlockVisibilityPanel({ language, items, config, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; config: Record; onChange: (value: Record) => void; }) { + const updateTarget = (target: BlockTarget, next: string[]) => onChange({ ...config, [target]: next }); + const renderSection = (target: BlockTarget, title: string, subtitle: string) => { + const selected = config[target]; + const hidden = items.filter((item) => !selected.includes(item.metric_id)); + const move = (metricId: string, direction: -1 | 1) => { + const index = selected.indexOf(metricId); + if (index === -1) return; + const targetIndex = index + direction; + if (targetIndex < 0 || targetIndex >= selected.length) return; + const next = [...selected]; + [next[index], next[targetIndex]] = [next[targetIndex], next[index]]; + updateTarget(target, next); + }; + const toggle = (metricId: string) => { + if (selected.includes(metricId)) { + const next = selected.filter((item) => item !== metricId); + updateTarget(target, next.length ? next : selected); + return; + } + updateTarget(target, [...selected, metricId]); + }; + return
    {title}
    {subtitle}
    {selected.length}
    {selected.map((metricId, index) => { const metric = items.find((item) => item.metric_id === metricId); if (!metric) return null; return
    {index + 1}
    {metric.label}
    {metric.unit || "—"}
    ; })}
    {language === "en" ? "Add metric" : "Dodaj metrykę"}
    {hidden.map((item) => )}
    {!hidden.length ?
    {language === "en" ? "All metrics are already used in this block." : "Wszystkie metryki są już użyte w tym bloku."}
    : null}
    ; + }; + return

    {language === "en" ? "Homepage block builder" : "Budowanie bloków strony głównej"}

    {language === "en" ? "Arrange metrics interactively instead of relying on click order." : "Układaj metryki interaktywnie zamiast polegać na kolejności klikania."}
    {language === "en" ? "The first items appear first on desktop, so you can control hierarchy visually." : "Pierwsze elementy wyświetlają się jako pierwsze na desktopie, więc hierarchię ustawiasz wizualnie."}
    {renderSection("hero", language === "en" ? "Hero metrics" : "Karty hero", language === "en" ? "Top KPI cards on the homepage." : "Górne karty KPI na stronie głównej.")}{renderSection("quick", language === "en" ? "Quick metrics" : "Szybkie metryki", language === "en" ? "Compact KPI list next to the chart." : "Kompaktowa lista KPI obok wykresu.")}
    ; } -function BlockVisibilityPanel({ language, items, config, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; config: Record; onChange: (value: Record) => void; }) { const toggle = (target: BlockTarget, metricId: string) => { const selected = config[target]; onChange({ ...config, [target]: selected.includes(metricId) ? selected.filter((item) => item !== metricId) : [...selected, metricId] }); }; const Section = ({ target, title }: { target: BlockTarget; title: string }) =>
    {title}
    {config[target].length}
    {items.map((item) => )}
    ; return

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

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

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

    {language === "en" ? "API, InfluxDB and application status." : "Stan API, InfluxDB i aplikacji."}
    {loading && !data ?
    {t(language, "loading")}…
    : null}{data ? <>
    {language === "en" ? "InfluxDB connection" : "Połączenie z InfluxDB"}
    URL
    {data.influx.url}
    {language === "en" ? "Database" : "Baza"}
    {data.influx.database}
    {language === "en" ? "User" : "Użytkownik"}
    {data.influx.username_masked || "—"}
    {language === "en" ? "Timeout / SSL" : "Timeout / SSL"}
    {data.influx.timeout_seconds}s / {data.influx.verify_ssl ? "verify" : "no-verify"}
    {data.influx.error ?
    {data.influx.error}
    : null}
    {language === "en" ? "Application details" : "Szczegóły aplikacji"}
    {language === "en" ? "API prefix" : "Prefix API"}
    {data.api.prefix}
    {language === "en" ? "Started at" : "Uruchomiono"}
    {formatDateTime(data.app.started_at, locale)}
    {language === "en" ? "Timezone" : "Strefa czasu"}
    {data.app.timezone}
    {language === "en" ? "SQLite" : "SQLite"}
    {data.storage.sqlite_path}
    {language === "en" ? "History sync" : "Synchronizacja historii"}
    {data.storage.historical_import_enabled ? (language === "en" ? "Enabled" : "Włączona") : (language === "en" ? "Disabled" : "Wyłączona")} · auto: {data.storage.auto_sync_enabled ? "on" : "off"} · chunk: {data.storage.default_chunk_days}
    :
    {language === "en" ? "No diagnostics data." : "Brak danych diagnostycznych."}
    }
    ; } -function AdminUsersPanel({ language, users, newUser, onNewUserChange, onCreate, passwordReset, onPasswordResetChange, onResetPassword }: { language: Language; users: AuthUsersPayload["items"]; newUser: { username: string; display_name: string; password: string; role: string }; onNewUserChange: (value: { username: string; display_name: string; password: string; role: string }) => void; onCreate: () => void; passwordReset: { username: string; password: string }; onPasswordResetChange: (value: { username: string; password: string }) => void; onResetPassword: () => void; }) { return

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

    {language === "en" ? "Create user" : "Dodaj użytkownika"}
    onNewUserChange({ ...newUser, username: e.target.value })} />
    onNewUserChange({ ...newUser, display_name: e.target.value })} />
    onNewUserChange({ ...newUser, password: e.target.value })} />
    {language === "en" ? "Reset password" : "Zmiana hasła"}
    onPasswordResetChange({ ...passwordReset, password: e.target.value })} />
    {users.map((user) => )}
    {language === "en" ? "Username" : "Login"}{language === "en" ? "Display name" : "Nazwa"}Role{language === "en" ? "Updated" : "Aktualizacja"}
    {user.username}{user.display_name}{user.role}{formatDateTime(user.updated_at, language === "en" ? "en-GB" : "pl-PL")}
    ; } +function AdminUsersPanel({ language, users, currentUsername, newUser, onNewUserChange, onCreate, passwordReset, onPasswordResetChange, onResetPassword, onRoleChange, roleUpdating }: { language: Language; users: AuthUsersPayload["items"]; currentUsername?: string | null; newUser: { username: string; display_name: string; password: string; role: string }; onNewUserChange: (value: { username: string; display_name: string; password: string; role: string }) => void; onCreate: () => void; passwordReset: { username: string; password: string }; onPasswordResetChange: (value: { username: string; password: string }) => void; onResetPassword: () => void; onRoleChange: (username: string, role: string) => void; roleUpdating: boolean; }) { return

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

    {language === "en" ? "Create user" : "Dodaj użytkownika"}
    onNewUserChange({ ...newUser, username: e.target.value })} />
    onNewUserChange({ ...newUser, display_name: e.target.value })} />
    onNewUserChange({ ...newUser, password: e.target.value })} />
    {language === "en" ? "Reset password" : "Zmiana hasła"}
    onPasswordResetChange({ ...passwordReset, password: e.target.value })} />
    {language === "en" ? "Permissions" : "Uprawnienia"}
    {language === "en" ? "Switch accounts between user and admin directly in the table below." : "Przełączaj konta między user i admin bezpośrednio w tabeli poniżej."}
    {language === "en" ? "The last active admin cannot be downgraded." : "Ostatni aktywny administrator nie może zostać zdegradowany."}
    {users.map((user) => { const nextRole = user.role === "admin" ? "user" : "admin"; return ; })}
    {language === "en" ? "Username" : "Login"}{language === "en" ? "Display name" : "Nazwa"}Role{language === "en" ? "Updated" : "Aktualizacja"}{language === "en" ? "Actions" : "Akcje"}
    {user.username}
    {currentUsername === user.username ?
    {language === "en" ? "Current session" : "Bieżąca sesja"}
    : null}
    {user.display_name}{user.role}{formatDateTime(user.updated_at, language === "en" ? "en-GB" : "pl-PL")}
    ; } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6394903..d76bad1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -179,6 +179,8 @@ export const api = { DEMO_MODE ? demoResponse(() => payload) : request("/auth/users", { method: "POST", body: JSON.stringify(payload) }), resetUserPassword: (username: string, password: string) => DEMO_MODE ? demoResponse(() => ({ username })) : request(`/auth/users/${encodeURIComponent(username)}/reset-password`, { method: "POST", body: JSON.stringify({ password }) }), + updateUserRole: (username: string, role: string) => + DEMO_MODE ? demoResponse(() => ({ username, role })) : request(`/auth/users/${encodeURIComponent(username)}/role`, { method: "PUT", body: JSON.stringify({ role }) }), }; export const wsBaseUrl = (): string => { diff --git a/frontend/src/components/analytics/ComparisonChart.tsx b/frontend/src/components/analytics/ComparisonChart.tsx index adb75b5..17c7e64 100644 --- a/frontend/src/components/analytics/ComparisonChart.tsx +++ b/frontend/src/components/analytics/ComparisonChart.tsx @@ -2,15 +2,17 @@ import type { EChartsOption } from "echarts"; import { Card } from "../common/Card"; import { EChart } from "../common/EChart"; import type { BucketPoint } from "../../types"; +import { t, type Language } from "../../i18n"; interface ComparisonChartProps { current: BucketPoint[]; comparison: BucketPoint[]; unit: string; compareMode: string; + language?: Language; } -export function ComparisonChart({ current, comparison, unit, compareMode }: ComparisonChartProps) { +export function ComparisonChart({ current, comparison, unit, compareMode, language = "en" }: ComparisonChartProps) { const option: EChartsOption = { tooltip: { trigger: "axis", @@ -43,14 +45,14 @@ export function ComparisonChart({ current, comparison, unit, compareMode }: Comp }, series: [ { - name: "Aktualny okres", + name: t(language, "currentPeriod"), type: "bar", barGap: "20%", itemStyle: { color: "#60a5fa", borderRadius: [10, 10, 0, 0] }, data: current.map((item) => item.value), }, { - name: compareMode === "previous_year" ? "Poprzedni rok" : "Poprzedni okres", + name: compareMode === "previous_year" ? t(language, "comparePreviousYear") : t(language, "comparePreviousPeriod"), type: "bar", itemStyle: { color: "#f59e0b", borderRadius: [10, 10, 0, 0] }, data: current.map((_, index) => comparison[index]?.value ?? 0), @@ -59,7 +61,7 @@ export function ComparisonChart({ current, comparison, unit, compareMode }: Comp }; return ( - + ); diff --git a/frontend/src/components/analytics/PeriodControls.tsx b/frontend/src/components/analytics/PeriodControls.tsx index d2bfee3..ba8e6ab 100644 --- a/frontend/src/components/analytics/PeriodControls.tsx +++ b/frontend/src/components/analytics/PeriodControls.tsx @@ -1,4 +1,5 @@ import { Card } from "../common/Card"; +import { t, type Language } from "../../i18n"; interface PeriodControlsProps { rangeKey: string; @@ -10,6 +11,7 @@ interface PeriodControlsProps { onRangeChange: (value: string) => void; onBucketChange: (value: string) => void; onCompareChange: (value: string) => void; + language?: Language; } export function PeriodControls({ @@ -22,12 +24,13 @@ export function PeriodControls({ onRangeChange, onBucketChange, onCompareChange, + language = "en", }: PeriodControlsProps) { return ( - +
    + +
    +
    + +
    + +

    When date fields are empty, the backend automatically detects the import range based on the first InfluxDB sample and the last day already stored in SQLite.

    +
    +
    +
    + + + + {availableRangeReady ? : null} +
    + {mutationError ?
    {mutationError}
    : null} + {status.error ?
    {status.error.message}
    : null} +
    +
    +
    +
    +
    Operational task status
    +
    {payload?.message || "No active task"}
    +
    +
    + {payload?.job_id ? job {payload.job_id} : null} + {payload?.running ? "running" : payload?.state || "idle"} +
    +
    +
    +
    Chunk {payload?.active_chunk_index ?? 0} / {payload?.total_chunks ?? 0}{progress}%
    +
    +
    Available range in InfluxDB
    {formatDate(payload?.available_start_date)} - {formatDate(payload?.available_end_date)}
    {payload?.coverage?.available_days ?? 0} detected archive days
    +
    Range stored locally
    {formatDate(payload?.coverage?.first_day)} - {formatDate(payload?.coverage?.last_day)}
    {payload?.coverage?.imported_days ?? 0} days in cache, {formatValue(payload?.coverage?.total_energy_kwh ?? 0, "kWh", 1)}
    +
    Current chunk
    {formatDate(payload?.current_chunk_start)} - {formatDate(payload?.current_chunk_end)}
    Last day: {formatDate(payload?.current_date)}
    +
    Timings and delays
    elapsed {formatDurationShort(payload?.elapsed_seconds)}
    start {formatDateTime(payload?.started_at)} / finish {formatDateTime(payload?.finished_at)}
    {payload?.last_error ?
    Error: {payload.last_error}
    : null}
    +
    +
    +
    +
    +
    {visibleChunks.length ? visibleChunks.map((chunk) => ) :
    The chunk list will appear after the first backfill starts.
    }
    +
    {visibleEvents.length ? visibleEvents.map((event, index) => ) :
    Operation history will appear after the task starts.
    }
    +
    -
    -
    -

    Gdy pola dat sa puste, backend sam wykryje zakres do importu na podstawie pierwszej probki w InfluxDB i ostatniego dnia juz zapisanego w SQLite.

    -
    {availableRangeReady ? : null}
    - {mutationError ?
    {mutationError}
    : null} - {status.error ?
    {status.error.message}
    : null} -
    -
    Operacyjny status zadania
    {payload?.message || "Brak aktywnego zadania"}
    {payload?.job_id ? job {payload.job_id} : null}{payload?.running ? "w trakcie" : payload?.state || "idle"}
    Chunk {payload?.active_chunk_index ?? 0} / {payload?.total_chunks ?? 0}{progress}%
    -
    Dostepny zakres w InfluxDB
    {formatDate(payload?.available_start_date)} - {formatDate(payload?.available_end_date)}
    {payload?.coverage?.available_days ?? 0} dni wykrytego archiwum
    Zakres zapisany lokalnie
    {formatDate(payload?.coverage?.first_day)} - {formatDate(payload?.coverage?.last_day)}
    {payload?.coverage?.imported_days ?? 0} dni w cache, {formatValue(payload?.coverage?.total_energy_kwh ?? 0, "kWh", 1)}
    Aktualny chunk
    {formatDate(payload?.current_chunk_start)} - {formatDate(payload?.current_chunk_end)}
    Ostatni dzien: {formatDate(payload?.current_date)}
    Czasy i opoznienia
    elapsed {formatDurationShort(payload?.elapsed_seconds)}
    start {formatDateTime(payload?.started_at)} / koniec {formatDateTime(payload?.finished_at)}
    {payload?.last_error ?
    Blad: {payload.last_error}
    : null}
    -
    {visibleChunks.length ? visibleChunks.map((chunk) => ) :
    Lista chunkow pojawi sie po uruchomieniu pierwszego backfillu.
    }
    {visibleEvents.length ? visibleEvents.map((event, index) => ) :
    Historia operacji pojawi sie po starcie zadania.
    }
    -
    + ); } diff --git a/frontend/src/demo/data.ts b/frontend/src/demo/data.ts index b084416..8802600 100644 --- a/frontend/src/demo/data.ts +++ b/frontend/src/demo/data.ts @@ -53,21 +53,21 @@ export const demoConfig: DashboardConfig = { { key: "ytd", label: "YTD" }, ], buckets: [ - { key: "day", label: "Dzien" }, - { key: "week", label: "Tydzien" }, - { key: "month", label: "Miesiac" }, + { key: "day", label: "Dzień" }, + { key: "week", label: "Tydzień" }, + { key: "month", label: "Miesiąc" }, { key: "year", label: "Rok" }, ], historical_import_enabled: true, history: { enabled: true, default_chunk_days: 7, auto_sync_enabled: true, auto_sync_interval_minutes: 30 }, }, visible_entities: [ - { metric_id: "ac_power", label: "Moc AC calkowita", entity_id: "sofarsolar_ac_power", measurement: "W", unit: "W", kind: "gauge" }, - { metric_id: "energy_total", label: "Energia total", entity_id: "sofarsolar_energy_total", measurement: "kWh", unit: "kWh", kind: "counter" }, + { metric_id: "ac_power", label: "Moc AC całkowita", entity_id: "sofarsolar_ac_power", measurement: "W", unit: "W", kind: "gauge" }, + { metric_id: "energy_total", label: "Energia całkowita", entity_id: "sofarsolar_energy_total", measurement: "kWh", unit: "kWh", kind: "counter" }, { metric_id: "string_1_power", label: "Moc stringu DC1", entity_id: "sofarsolar_dc1_power", measurement: "W", unit: "W", kind: "gauge" }, - { metric_id: "string_1_voltage", label: "Napiecie stringu DC1", entity_id: "sofarsolar_dc1_voltage", measurement: "V", unit: "V", kind: "gauge" }, + { metric_id: "string_1_voltage", label: "Napięcie 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: "string_2_voltage", label: "Napięcie stringu DC2", entity_id: "sofarsolar_dc2_voltage", measurement: "V", unit: "V", kind: "gauge" }, { metric_id: "inverter_temp", label: "Temperatura falownika", entity_id: "sofarsolar_temprature_inverter", measurement: "°C", unit: "°C", kind: "gauge" }, ], }; @@ -82,20 +82,20 @@ export const demoSnapshot = (): SnapshotPayload => ({ { 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" }, + energy_today: { metric_id: "energy_today", label: "Energia dziś", unit: "kWh", value: 31.8, precision: 2, kind: "counter", status: "ok" }, energy_yesterday: { metric_id: "energy_yesterday", label: "Energia wczoraj", unit: "kWh", value: 28.4, precision: 2, kind: "counter", status: "ok" }, - today_vs_yesterday: { metric_id: "today_vs_yesterday", label: "Dzis vs wczoraj", unit: "%", value: 12, precision: 0, kind: "gauge", status: "ok" }, - dc_power_total: { metric_id: "dc_power_total", label: "Moc DC total", unit: "W", value: 6760, precision: 0, kind: "gauge", status: "ok" }, - energy_total: { metric_id: "energy_total", label: "Energia total", unit: "kWh", value: 18264.3, precision: 1, kind: "counter", status: "ok" }, + today_vs_yesterday: { metric_id: "today_vs_yesterday", label: "Dziś vs wczoraj", unit: "%", value: 12, precision: 0, kind: "gauge", status: "ok" }, + dc_power_total: { metric_id: "dc_power_total", label: "Moc DC łączna", unit: "W", value: 6760, precision: 0, kind: "gauge", status: "ok" }, + energy_total: { metric_id: "energy_total", label: "Energia całkowita", unit: "kWh", value: 18264.3, precision: 1, kind: "counter", status: "ok" }, }, strings: [ - { id: "dc1", label: "String 1 / Wschod", meta: {}, values: { power: { metric_id: "string_1_power", label: "Moc", unit: "W", value: 3450, precision: 0, kind: "gauge", status: "ok" }, voltage: { metric_id: "string_1_voltage", label: "Napiecie", unit: "V", value: 382, precision: 0, kind: "gauge", status: "ok" } } }, - { id: "dc2", label: "String 2 / Zachod", meta: {}, values: { power: { metric_id: "string_2_power", label: "Moc", unit: "W", value: 3310, precision: 0, kind: "gauge", status: "ok" }, voltage: { metric_id: "string_2_voltage", label: "Napiecie", unit: "V", value: 375, precision: 0, kind: "gauge", status: "ok" } } }, + { id: "dc1", label: "String 1 / Wschód", meta: {}, values: { power: { metric_id: "string_1_power", label: "Moc", unit: "W", value: 3450, precision: 0, kind: "gauge", status: "ok" }, voltage: { metric_id: "string_1_voltage", label: "Napięcie", unit: "V", value: 382, precision: 0, kind: "gauge", status: "ok" } } }, + { id: "dc2", label: "String 2 / Zachód", meta: {}, values: { power: { metric_id: "string_2_power", label: "Moc", unit: "W", value: 3310, precision: 0, kind: "gauge", status: "ok" }, voltage: { metric_id: "string_2_voltage", label: "Napięcie", unit: "V", value: 375, precision: 0, kind: "gauge", status: "ok" } } }, ], phases: [], status: [ { 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" }, + { metric_id: "data_freshness", label: "Świeżość danych", unit: "", value: "3 s temu", precision: 0, kind: "text", status: "ok" }, ], faults: [], }); @@ -110,8 +110,8 @@ 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: "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: "string_1_power", label: "DC1 moc", unit: "W", points: historyPoints([0, 80, 620, 1320, 2140, 2860, 3250, 3490, 3450, 3300, 2920, 2480]) }, + { metric_id: "string_2_power", label: "DC2 moc", 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]) }, ], }; @@ -133,7 +133,7 @@ export const demoDistribution: DistributionPayload = { unit: "kWh", bucket: "day", total: 289.5, - slices: [ { label: "DC1 / Wschod", value: 154.2, share: 53.3 }, { label: "DC2 / Zachod", value: 135.3, share: 46.7 } ], + slices: [ { label: "DC1 / Wschód", value: 154.2, share: 53.3 }, { label: "DC2 / Zachód", value: 135.3, share: 46.7 } ], }; export const demoHistoricalStatus: HistoricalStatus = { @@ -171,10 +171,10 @@ export const demoHistoricalStatus: HistoricalStatus = { { chunk_index: 157, total_chunks: 209, start_date: "2025-01-05", end_date: "2025-01-11", processed_days: 4, imported_days: 4, skipped_days: 0, energy_kwh: 51.4, state: "running", started_at: isoAt(-6.5), finished_at: null, duration_seconds: null, note: "Aktywny chunk 2025-01-05 -> 2025-01-11" }, ], recent_events: [ - { timestamp: isoAt(-1.1), level: "success", title: "Zaimportowano dzien", message: "Zaimportowano 2025-01-08 (13.10 kWh). Energia: 13.10 kWh.", day: "2025-01-08", chunk_index: 157 }, - { timestamp: isoAt(-1.9), level: "success", title: "Zaimportowano dzien", message: "Zaimportowano 2025-01-07 (12.84 kWh). Energia: 12.84 kWh.", day: "2025-01-07", chunk_index: 157 }, - { timestamp: isoAt(-2.5), level: "success", title: "Zaimportowano dzien", message: "Zaimportowano 2025-01-06 (12.56 kWh). Energia: 12.56 kWh.", day: "2025-01-06", chunk_index: 157 }, - { timestamp: isoAt(-3.2), level: "success", title: "Zaimportowano dzien", message: "Zaimportowano 2025-01-05 (12.90 kWh). Energia: 12.90 kWh.", day: "2025-01-05", chunk_index: 157 }, + { timestamp: isoAt(-1.1), level: "success", title: "Zaimportowano dzień", message: "Zaimportowano 2025-01-08 (13.10 kWh). Energia: 13.10 kWh.", day: "2025-01-08", chunk_index: 157 }, + { timestamp: isoAt(-1.9), level: "success", title: "Zaimportowano dzień", message: "Zaimportowano 2025-01-07 (12.84 kWh). Energia: 12.84 kWh.", day: "2025-01-07", chunk_index: 157 }, + { timestamp: isoAt(-2.5), level: "success", title: "Zaimportowano dzień", message: "Zaimportowano 2025-01-06 (12.56 kWh). Energia: 12.56 kWh.", day: "2025-01-06", chunk_index: 157 }, + { timestamp: isoAt(-3.2), level: "success", title: "Zaimportowano dzień", message: "Zaimportowano 2025-01-05 (12.90 kWh). Energia: 12.90 kWh.", day: "2025-01-05", chunk_index: 157 }, { timestamp: isoAt(-3.7), level: "info", title: "Chunk 157/209", message: "Start zakresu 2025-01-05 -> 2025-01-11", day: null, chunk_index: 157 }, { timestamp: isoAt(-6.8), level: "success", title: "Chunk 156/209 zakonczony", message: "Zakres 2024-12-29 -> 2025-01-04, import 7, pominiete 0, energia 92.60 kWh", day: null, chunk_index: 156 }, ], diff --git a/frontend/src/hooks/useRealtimeHistory.ts b/frontend/src/hooks/useRealtimeHistory.ts index 450d146..f12fd44 100644 --- a/frontend/src/hooks/useRealtimeHistory.ts +++ b/frontend/src/hooks/useRealtimeHistory.ts @@ -4,13 +4,18 @@ import { api } from "../api/client"; export function useRealtimeHistory( rangeKey: string, enabled = true, - options?: { start?: string; end?: string; metrics?: string[]; publicKiosk?: boolean }, + options?: { start?: string; end?: string; metrics?: string[]; publicKiosk?: boolean; pauseAutoRefresh?: boolean }, ) { + const manualWindow = Boolean(options?.start || options?.end); + const autoRefreshEnabled = !manualWindow && !options?.pauseAutoRefresh; + return useQuery({ - queryKey: ["realtime-history", rangeKey, options?.start, options?.end, options?.metrics?.join(","), options?.publicKiosk], + queryKey: ["realtime-history", rangeKey, options?.start, options?.end, options?.metrics?.join(","), options?.publicKiosk, autoRefreshEnabled], queryFn: () => api.getRealtimeHistory(rangeKey, options), - staleTime: 20 * 1000, - refetchInterval: options?.start || options?.end ? false : 30 * 1000, + staleTime: autoRefreshEnabled ? 20 * 1000 : Infinity, + refetchInterval: autoRefreshEnabled ? 30 * 1000 : false, + refetchOnWindowFocus: autoRefreshEnabled, + refetchOnReconnect: autoRefreshEnabled, enabled, }); } diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index e55a52c..4fa6e7b 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -38,6 +38,8 @@ const messages = { chartProduction: "Produkcja", chartProductionSubtitle: "Agregacja w wybranym bucketcie", chartComparison: "Porównanie okresów", + chartComparisonSubtitle: "Wspólne słupki dla aktualnego i porównawczego okresu", + chartComparisonControlSubtitle: "Dzień / tydzień / miesiąc z porównaniem do poprzedniego okresu lub roku", chartDistribution: "Rozkład produkcji", currentPeriod: "Bieżący okres", comparisonPeriod: "Porównanie", @@ -145,6 +147,8 @@ const messages = { chartProduction: "Production", chartProductionSubtitle: "Aggregated by selected bucket", chartComparison: "Period comparison", + chartComparisonSubtitle: "Shared bars for the current and comparison periods", + chartComparisonControlSubtitle: "Day / week / month with previous period or previous year comparison", chartDistribution: "Production distribution", currentPeriod: "Current period", comparisonPeriod: "Comparison", @@ -227,6 +231,14 @@ const metricLabels: Record = { today_vs_yesterday: { pl: "Dziś vs wczoraj", en: "Today vs yesterday" }, dc_power_total: { pl: "Moc DC łącznie", en: "Total DC power" }, inverter_temp: { pl: "Temperatura falownika", en: "Inverter temperature" }, + string_1_power: { pl: "DC1 moc", en: "DC1 power" }, + string_1_voltage: { pl: "DC1 napięcie", en: "DC1 voltage" }, + string_2_power: { pl: "DC2 moc", en: "DC2 power" }, + string_2_voltage: { pl: "DC2 napięcie", en: "DC2 voltage" }, + string_3_power: { pl: "DC3 moc", en: "DC3 power" }, + string_3_voltage: { pl: "DC3 napięcie", en: "DC3 voltage" }, + string_4_power: { pl: "DC4 moc", en: "DC4 power" }, + string_4_voltage: { pl: "DC4 napięcie", en: "DC4 voltage" }, }; export function t(language: Language, key: MessageKey): string { diff --git a/frontend/src/index.css b/frontend/src/index.css index a486e15..12a8f8a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -165,6 +165,10 @@ body { align-items: end; } +.pv-filter-grid-live { + grid-template-columns: minmax(260px, 1.45fr) minmax(240px, 0.92fr) minmax(210px, 0.95fr); +} + .pv-filter-grid-archive { grid-template-columns: minmax(240px, 1.35fr) minmax(180px, 0.9fr) repeat(2, minmax(160px, 1fr)); } @@ -196,6 +200,7 @@ body { @media (max-width: 992px) { .pv-filter-grid, + .pv-filter-grid-live, .pv-filter-grid-archive { grid-template-columns: 1fr; } @@ -247,6 +252,25 @@ body { min-width: 1.1rem; } +.pv-subnav-side { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.85rem; + min-width: 0; + margin-left: auto; +} + +.pv-subnav-refresh-group { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; + min-width: 0; +} + .pv-menu-meta { display: inline-flex; align-items: center; @@ -254,11 +278,354 @@ body { color: var(--tblr-secondary); font-size: 0.875rem; white-space: nowrap; + min-width: 0; +} + +@media (max-width: 1200px) { + .pv-subnav-side { + width: 100%; + justify-content: space-between; + } } @media (max-width: 992px) { + .pv-subnav-side { + width: 100%; + flex-direction: column; + align-items: stretch; + } + + .pv-subnav-refresh-group { + width: 100%; + flex-direction: column; + align-items: stretch; + } + .pv-menu-meta { width: 100%; + justify-content: center; white-space: normal; } } + + +.pv-date-field, +.pv-filter-field { + min-width: 0; +} + +.pv-date-input { + min-height: 2.8rem; + font-size: 0.95rem; +} + +.pv-action-tile, +.pv-chip-button { + border-radius: 0.9rem !important; +} + +.pv-order-card { + border: 1px solid var(--tblr-border-color, rgba(15, 23, 42, 0.12)); + border-radius: 1rem; + padding: 0.9rem 1rem; + background: color-mix(in srgb, var(--tblr-bg-surface, #fff) 90%, transparent); +} + +.pv-order-index { + width: 2rem; + min-width: 2rem; + height: 2rem; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + background: rgba(32, 107, 196, 0.12); + color: var(--tblr-primary, #206bc4); +} + +@media (max-width: 992px) { + .page-header .col-auto.ms-auto { + width: 100%; + } +} + +@media (max-width: 576px) { + .pv-segmented-group { + display: grid !important; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .pv-segmented-group > .btn, + .pv-chip-button, + .pv-action-tile, + .pv-order-card .btn { + width: 100%; + } + + .pv-filter-grid-archive { + grid-template-columns: 1fr; + } + + .pv-date-input { + min-height: 3rem; + } + + .pv-order-card .btn-list { + width: 100%; + display: grid; + grid-template-columns: 1fr; + gap: 0.5rem; + } +} + +.login-layout { + min-height: 72vh; +} + +.login-card-enhanced { + overflow: hidden; + border-width: 1px; +} + +.login-card-enhanced::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 5px; + background: linear-gradient(90deg, rgba(32, 107, 196, 0.95), rgba(47, 179, 68, 0.9)); +} + +.login-card-enhanced .card-body { + position: relative; +} + +.login-showcase-card { + border: 1px solid rgba(32, 107, 196, 0.14); + border-radius: 1rem; + padding: 2rem; + background: + radial-gradient(circle at top right, rgba(32, 107, 196, 0.18), transparent 30%), + rgba(255, 255, 255, 0.74); + box-shadow: var(--pv-card-shadow); +} + +[data-bs-theme="dark"] .login-showcase-card { + background: + radial-gradient(circle at top right, rgba(32, 107, 196, 0.22), transparent 32%), + rgba(11, 18, 32, 0.82); + border-color: rgba(148, 163, 184, 0.18); +} + +.login-showcase-badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0.75rem; + border-radius: 999px; + background: rgba(32, 107, 196, 0.12); + color: #206bc4; + font-size: 0.8rem; + font-weight: 600; + margin-bottom: 1rem; +} + +.login-showcase-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; +} + +.login-stat-card { + padding: 1rem; + border-radius: 0.9rem; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(255, 255, 255, 0.78); +} + +[data-bs-theme="dark"] .login-stat-card { + background: rgba(15, 23, 42, 0.84); + border-color: rgba(148, 163, 184, 0.14); +} + +.login-input-stack { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.login-submit-button { + margin-top: 0.25rem; +} + +.login-footer-note { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(148, 163, 184, 0.18); + color: var(--tblr-secondary); + font-size: 0.88rem; +} + + +.pv-page-header-actions { + width: 100%; + display: flex; + justify-content: flex-end; +} + +.pv-page-header-actions--wide { + width: min(100%, 920px); + margin-left: auto; +} + +.pv-page-header-actions--right { + justify-items: end; + align-items: end; +} + +.pv-page-header-actions--right > :first-child { + justify-self: stretch; +} + +.pv-page-header-range-grid { + display: grid; + grid-template-columns: minmax(260px, 1fr) minmax(220px, 0.92fr); + gap: 0.75rem; + width: min(100%, 620px); +} + +.pv-page-header-range-grid--single { + grid-template-columns: minmax(280px, 1fr); + width: min(100%, 420px); +} + +.pv-refresh-panel { + min-width: 240px; +} + +.pv-refresh-panel--compact { + width: min(100%, 176px); + min-width: 0; +} + +.pv-refresh-switch { + position: relative; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: center; + width: 100%; + min-height: 3rem; + padding: 0.28rem; + border: 1px solid color-mix(in srgb, var(--tblr-border-color, rgba(148, 163, 184, 0.2)) 90%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--tblr-bg-surface, #fff) 88%, rgba(32, 107, 196, 0.05)); + color: var(--tblr-secondary); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); + overflow: hidden; + transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; +} + +.pv-refresh-switch:hover, +.pv-refresh-switch:focus-visible { + border-color: color-mix(in srgb, var(--tblr-primary, #206bc4) 36%, var(--tblr-border-color, rgba(148, 163, 184, 0.18))); + box-shadow: 0 0 0 0.12rem rgba(32, 107, 196, 0.12); +} + +.pv-refresh-panel--compact .pv-refresh-switch { + min-height: 2.5rem; + padding: 0.18rem; +} + +.pv-refresh-switch-thumb { + position: absolute; + top: 0.28rem; + bottom: 0.28rem; + left: 0.28rem; + width: calc(50% - 0.28rem); + border-radius: 999px; + background: linear-gradient(135deg, rgba(32, 107, 196, 0.96), rgba(52, 118, 220, 0.86)); + box-shadow: 0 10px 24px rgba(32, 107, 196, 0.2); + transition: transform 0.22s ease; +} + +.pv-refresh-switch.is-paused .pv-refresh-switch-thumb { + transform: translateX(100%); + background: linear-gradient(135deg, rgba(100, 116, 139, 0.96), rgba(71, 85, 105, 0.86)); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18); +} + +.pv-refresh-switch-option { + position: relative; + z-index: 1; + min-width: 0; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.35rem; + padding: 0 0.75rem; + font-size: 0.92rem; + font-weight: 700; + color: var(--tblr-secondary); + transition: color 0.18s ease, opacity 0.18s ease; +} + +.pv-refresh-panel--compact .pv-refresh-switch-thumb { + top: 0.18rem; + bottom: 0.18rem; + left: 0.18rem; + width: calc(50% - 0.18rem); +} + +.pv-refresh-panel--compact .pv-refresh-switch-option { + min-height: 1.95rem; + padding: 0 0.7rem; + font-size: 0.84rem; +} + +.pv-refresh-switch.is-auto .pv-refresh-switch-option--auto, +.pv-refresh-switch.is-paused .pv-refresh-switch-option--paused { + color: #fff; +} + +.pv-refresh-switch.is-auto .pv-refresh-switch-option--paused, +.pv-refresh-switch.is-paused .pv-refresh-switch-option--auto { + opacity: 0.82; +} + +.pv-chart-footer-controls { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)) minmax(220px, 1.1fr); + gap: 0.85rem; + align-items: end; +} + +.pv-chart-footer-status { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +@media (max-width: 992px) { + .pv-chart-footer-controls, + .pv-page-header-range-grid, + .pv-page-header-range-grid--single { + grid-template-columns: 1fr; + width: 100%; + } + + .pv-page-header-actions, + .pv-page-header-actions--wide { + width: 100%; + } +} + +@media (max-width: 576px) { + .pv-refresh-panel { + min-width: 0; + } + + .pv-refresh-switch-option { + font-size: 0.88rem; + padding: 0 0.55rem; + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index beec05d..4408032 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,14 +2,22 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +const noStoreHeaders = { + "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", + Pragma: "no-cache", + Expires: "0", +}; + export default defineConfig({ plugins: [react(), tailwindcss()], server: { host: "0.0.0.0", - port: 5173 + port: 5173, + headers: noStoreHeaders, }, preview: { host: "0.0.0.0", - port: 4173 - } + port: 4173, + headers: noStoreHeaders, + }, });