poprawki i zmiany ux
This commit is contained in:
1 parent
fd0f645251
commit
138059945e
28 files changed
+990
-215
No files matched your search
@@ -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.
|
||||||
+19
-10
@@ -101,7 +101,8 @@ def create_app() -> Flask:
|
|||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def append_cors_headers(response):
|
def append_cors_headers(response):
|
||||||
return _apply_cors(response)
|
response = _apply_cors(response)
|
||||||
|
return _apply_cache_headers(response)
|
||||||
|
|
||||||
@app.errorhandler(HTTPException)
|
@app.errorhandler(HTTPException)
|
||||||
def handle_http_exception(exc: HTTPException):
|
def handle_http_exception(exc: HTTPException):
|
||||||
@@ -123,7 +124,7 @@ def _register_cli_commands(app: Flask) -> None:
|
|||||||
auth_service = get_auth_service()
|
auth_service = get_auth_service()
|
||||||
|
|
||||||
@app.cli.command("create-admin")
|
@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("--password", required=True, hide_input=True, confirmation_prompt=True, help="Password")
|
||||||
@click.option("--display-name", default=None, help="Name")
|
@click.option("--display-name", default=None, help="Name")
|
||||||
def create_admin_command(username: str, password: str, display_name: str | None):
|
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",
|
role="admin",
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
)
|
)
|
||||||
click.echo(f"Utworzono konto admina: {user.username}")
|
click.echo(f"Admin account created: {user.username}")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise click.ClickException(str(exc)) from exc
|
raise click.ClickException(str(exc)) from exc
|
||||||
except Exception as exc: # pragma: no cover
|
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")
|
@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("--password", required=True, hide_input=True, confirmation_prompt=True, help="Password")
|
||||||
@click.option("--display-name", default=None, help="Name")
|
@click.option("--display-name", default=None, help="Name")
|
||||||
def create_user_command(username: str, password: str, display_name: str | None):
|
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",
|
role="user",
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
)
|
)
|
||||||
click.echo(f"Admin created: {user.username}")
|
click.echo(f"User account created: {user.username}")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise click.ClickException(str(exc)) from exc
|
raise click.ClickException(str(exc)) from exc
|
||||||
except Exception as exc: # pragma: no cover
|
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")
|
@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")
|
@click.option("--password", required=True, hide_input=True, confirmation_prompt=True, help="Password")
|
||||||
def reset_password_command(username: str, password: str):
|
def reset_password_command(username: str, password: str):
|
||||||
try:
|
try:
|
||||||
user = auth_service.reset_password(username=username, new_password=password)
|
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:
|
except ValueError as exc:
|
||||||
raise click.ClickException(str(exc)) from exc
|
raise click.ClickException(str(exc)) from exc
|
||||||
except Exception as exc: # pragma: no cover
|
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:
|
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-Methods"] = "GET, POST, OPTIONS, PUT"
|
||||||
response.headers["Access-Control-Allow-Credentials"] = "true"
|
response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||||
return response
|
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
|
||||||
@@ -78,3 +78,21 @@ def reset_password(username: str):
|
|||||||
return jsonify({"detail": str(exc)}), 403
|
return jsonify({"detail": str(exc)}), 403
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"detail": str(exc)}), 400
|
return jsonify({"detail": str(exc)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@auth_blueprint.put("/auth/users/<username>/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
|
||||||
@@ -25,11 +25,11 @@ def _resolve_kiosk_mode(requested_mode: str, require_write_access: bool = False)
|
|||||||
|
|
||||||
if normalized_mode == "public":
|
if normalized_mode == "public":
|
||||||
if require_write_access and auth_service.enabled and session.get("auth_role") != "admin":
|
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"
|
return "public", "public"
|
||||||
|
|
||||||
if normalized_mode != "private":
|
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:
|
if not auth_service.enabled:
|
||||||
return "private", "private"
|
return "private", "private"
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ class AuthService:
|
|||||||
self._login_legacy_user(username, password)
|
self._login_legacy_user(username, password)
|
||||||
else:
|
else:
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise ValueError("Konto jest nieaktywne")
|
raise ValueError("Account is inactive")
|
||||||
if not check_password_hash(user.password_hash, password):
|
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)
|
self._set_session(user.username, user.display_name, user.role)
|
||||||
|
|
||||||
return self.status()
|
return self.status()
|
||||||
@@ -84,7 +84,7 @@ class AuthService:
|
|||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
return
|
return
|
||||||
if session.get(SESSION_ROLE_KEY) != "admin":
|
if session.get(SESSION_ROLE_KEY) != "admin":
|
||||||
raise PermissionError("Brak uprawnien administratora")
|
raise PermissionError("Administrator permissions are required")
|
||||||
|
|
||||||
def configure_app(self, app) -> None:
|
def configure_app(self, app) -> None:
|
||||||
max_age = int(self.settings.auth["session_max_age_seconds"])
|
max_age = int(self.settings.auth["session_max_age_seconds"])
|
||||||
@@ -101,7 +101,7 @@ class AuthService:
|
|||||||
clean_password = self._validate_password(password)
|
clean_password = self._validate_password(password)
|
||||||
resolved_display_name = (display_name or normalized_username).strip()
|
resolved_display_name = (display_name or normalized_username).strip()
|
||||||
if not resolved_display_name:
|
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(
|
return self.user_repository.upsert_user(
|
||||||
username=normalized_username,
|
username=normalized_username,
|
||||||
password_hash=generate_password_hash(clean_password),
|
password_hash=generate_password_hash(clean_password),
|
||||||
@@ -118,16 +118,33 @@ class AuthService:
|
|||||||
generate_password_hash(clean_password),
|
generate_password_hash(clean_password),
|
||||||
)
|
)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise ValueError(f"Uzytkownik '{normalized_username}' nie istnieje")
|
raise ValueError(f"User '{normalized_username}' does not exist")
|
||||||
return user
|
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:
|
def _login_legacy_user(self, username: str, password: str) -> None:
|
||||||
expected_username = self.settings.auth["username"]
|
expected_username = self.settings.auth["username"]
|
||||||
expected_password = self.settings.auth["password"]
|
expected_password = self.settings.auth["password"]
|
||||||
expected_password_hash = self.settings.auth.get("password_hash")
|
expected_password_hash = self.settings.auth.get("password_hash")
|
||||||
|
|
||||||
if username != expected_username:
|
if username != expected_username:
|
||||||
raise ValueError("Niepoprawny login lub haslo")
|
raise ValueError("Invalid username or password")
|
||||||
|
|
||||||
if expected_password_hash:
|
if expected_password_hash:
|
||||||
password_ok = check_password_hash(expected_password_hash, password)
|
password_ok = check_password_hash(expected_password_hash, password)
|
||||||
@@ -135,7 +152,7 @@ class AuthService:
|
|||||||
password_ok = password == expected_password
|
password_ok = password == expected_password
|
||||||
|
|
||||||
if not password_ok:
|
if not password_ok:
|
||||||
raise ValueError("Niepoprawny login lub haslo")
|
raise ValueError("Invalid username or password")
|
||||||
|
|
||||||
self._set_session(
|
self._set_session(
|
||||||
expected_username,
|
expected_username,
|
||||||
@@ -153,19 +170,19 @@ class AuthService:
|
|||||||
def _normalize_username(self, username: str) -> str:
|
def _normalize_username(self, username: str) -> str:
|
||||||
normalized = (username or "").strip()
|
normalized = (username or "").strip()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
raise ValueError("Username nie moze byc pusty")
|
raise ValueError("Username cannot be empty")
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
def _normalize_role(self, role: str) -> str:
|
def _normalize_role(self, role: str) -> str:
|
||||||
normalized = (role or "").strip().lower()
|
normalized = (role or "").strip().lower()
|
||||||
if normalized not in VALID_ROLES:
|
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
|
return normalized
|
||||||
|
|
||||||
def _validate_password(self, password: str) -> str:
|
def _validate_password(self, password: str) -> str:
|
||||||
clean_password = password or ""
|
clean_password = password or ""
|
||||||
if len(clean_password) < 8:
|
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
|
return clean_password
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ class HistoricalSyncService:
|
|||||||
with self._state_lock:
|
with self._state_lock:
|
||||||
self._state.running = False
|
self._state.running = False
|
||||||
self._state.state = "idle"
|
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._state.finished_at = datetime.utcnow()
|
||||||
self._refresh_coverage(lock_held=True)
|
self._refresh_coverage(lock_held=True)
|
||||||
self._refresh_available_bounds(lock_held=True)
|
self._refresh_available_bounds(lock_held=True)
|
||||||
@@ -92,7 +92,7 @@ class HistoricalSyncService:
|
|||||||
resolved_start, resolved_end = resolved
|
resolved_start, resolved_end = resolved
|
||||||
total_days = (resolved_end - resolved_start).days + 1
|
total_days = (resolved_end - resolved_start).days + 1
|
||||||
total_chunks = max(ceil(total_days / chunk_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:
|
with self._state_lock:
|
||||||
if self._worker and self._worker.is_alive():
|
if self._worker and self._worker.is_alive():
|
||||||
@@ -138,18 +138,18 @@ class HistoricalSyncService:
|
|||||||
|
|
||||||
self._record_event(
|
self._record_event(
|
||||||
level="info",
|
level="info",
|
||||||
title="Uruchomiono zadanie",
|
title="Job started",
|
||||||
message=f"Zakres {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk {chunk_days} dni",
|
message=f"Range {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk size {chunk_days} days",
|
||||||
)
|
)
|
||||||
return self.status()
|
return self.status()
|
||||||
|
|
||||||
def cancel(self) -> HistoricalImportStatus:
|
def cancel(self) -> HistoricalImportStatus:
|
||||||
self._cancel_event.set()
|
self._cancel_event.set()
|
||||||
with self._state_lock:
|
with self._state_lock:
|
||||||
self._state.message = "Anulowanie zadania..."
|
self._state.message = "Cancelling job..."
|
||||||
self._refresh_runtime_metrics(lock_held=True)
|
self._refresh_runtime_metrics(lock_held=True)
|
||||||
snapshot = copy.deepcopy(self._state)
|
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
|
return snapshot
|
||||||
|
|
||||||
def run_blocking(
|
def run_blocking(
|
||||||
@@ -185,8 +185,8 @@ class HistoricalSyncService:
|
|||||||
)
|
)
|
||||||
self._record_event(
|
self._record_event(
|
||||||
level="info",
|
level="info",
|
||||||
title="Uruchomiono zadanie",
|
title="Job started",
|
||||||
message=f"Zakres {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk {chunk_days} dni",
|
message=f"Range {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk size {chunk_days} days",
|
||||||
)
|
)
|
||||||
self._run_worker(
|
self._run_worker(
|
||||||
start_date=resolved_start,
|
start_date=resolved_start,
|
||||||
@@ -239,8 +239,8 @@ class HistoricalSyncService:
|
|||||||
chunk_start = start_date
|
chunk_start = start_date
|
||||||
while chunk_start <= end_date:
|
while chunk_start <= end_date:
|
||||||
if self._cancel_event.is_set():
|
if self._cancel_event.is_set():
|
||||||
self._record_event(level="warn", title="Anulowano", 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="Import archiwalny anulowany przez uzytkownika.")
|
self._finish("cancelled", running=False, message="Historical import was cancelled by the user.")
|
||||||
return
|
return
|
||||||
|
|
||||||
chunk_index += 1
|
chunk_index += 1
|
||||||
@@ -259,10 +259,10 @@ class HistoricalSyncService:
|
|||||||
skipped_days=skipped,
|
skipped_days=skipped,
|
||||||
energy_kwh=energy_kwh,
|
energy_kwh=energy_kwh,
|
||||||
state="cancelled",
|
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._record_event(level="warn", title="Cancelled", message="Historical import was cancelled by the user.")
|
||||||
self._finish("cancelled", running=False, message="Import archiwalny anulowany przez uzytkownika.")
|
self._finish("cancelled", running=False, message="Historical import was cancelled by the user.")
|
||||||
return
|
return
|
||||||
|
|
||||||
self._close_chunk(
|
self._close_chunk(
|
||||||
@@ -271,23 +271,23 @@ class HistoricalSyncService:
|
|||||||
skipped_days=skipped,
|
skipped_days=skipped,
|
||||||
energy_kwh=energy_kwh,
|
energy_kwh=energy_kwh,
|
||||||
state="completed",
|
state="completed",
|
||||||
note=f"Chunk zakonczony: import {imported}, pominiete {skipped}",
|
note=f"Chunk completed: imported {imported}, skipped {skipped}",
|
||||||
)
|
)
|
||||||
self._record_event(
|
self._record_event(
|
||||||
level="success",
|
level="success",
|
||||||
title=f"Chunk {chunk_index}/{total_chunks} zakonczony",
|
title=f"Chunk {chunk_index}/{total_chunks} completed",
|
||||||
message=f"Zakres {chunk_start.isoformat()} -> {chunk_end.isoformat()}, import {imported}, pominiete {skipped}, energia {energy_kwh:.2f} kWh",
|
message=f"Range {chunk_start.isoformat()} -> {chunk_end.isoformat()}, imported {imported}, skipped {skipped}, energy {energy_kwh:.2f} kWh",
|
||||||
chunk_index=chunk_index,
|
chunk_index=chunk_index,
|
||||||
)
|
)
|
||||||
chunk_start = chunk_end + timedelta(days=1)
|
chunk_start = chunk_end + timedelta(days=1)
|
||||||
|
|
||||||
final_message = "Synchronizacja archiwalna zakonczona" if auto else "Import archiwalny zakonczony"
|
final_message = "Historical synchronization completed" if auto else "Historical import completed"
|
||||||
self._record_event(level="success", title="Zakonczono", message=final_message)
|
self._record_event(level="success", title="Completed", message=final_message)
|
||||||
self._finish("completed", running=False, message=final_message)
|
self._finish("completed", running=False, message=final_message)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Historical import failed")
|
logger.exception("Historical import failed")
|
||||||
self._record_event(level="error", title="Blad importu", message=str(exc))
|
self._record_event(level="error", title="Import error", message=str(exc))
|
||||||
self._finish("failed", running=False, message="Import archiwalny zakonczyl sie bledem.", last_error=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]:
|
def _process_chunk(self, *, chunk_index: int, start_day: date, end_day: date, force: bool) -> tuple[int, int, float, bool]:
|
||||||
imported_days = 0
|
imported_days = 0
|
||||||
@@ -303,9 +303,9 @@ class HistoricalSyncService:
|
|||||||
self._advance_day(
|
self._advance_day(
|
||||||
day,
|
day,
|
||||||
imported=False,
|
imported=False,
|
||||||
message=f"Pominieto {day.isoformat()} - dzien juz istnieje w cache",
|
message=f"Skipped {day.isoformat()} - day already exists in cache",
|
||||||
level="warn",
|
level="warn",
|
||||||
title="Pominieto dzien",
|
title="Day skipped",
|
||||||
chunk_index=chunk_index,
|
chunk_index=chunk_index,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -316,9 +316,9 @@ class HistoricalSyncService:
|
|||||||
self._advance_day(
|
self._advance_day(
|
||||||
day,
|
day,
|
||||||
imported=False,
|
imported=False,
|
||||||
message=f"Pominieto {day.isoformat()} - brak probek w InfluxDB",
|
message=f"Skipped {day.isoformat()} - no samples in InfluxDB",
|
||||||
level="warn",
|
level="warn",
|
||||||
title="Brak probek",
|
title="No samples",
|
||||||
chunk_index=chunk_index,
|
chunk_index=chunk_index,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -336,9 +336,9 @@ class HistoricalSyncService:
|
|||||||
self._advance_day(
|
self._advance_day(
|
||||||
day,
|
day,
|
||||||
imported=True,
|
imported=True,
|
||||||
message=f"Zaimportowano {day.isoformat()} ({total:.2f} kWh)",
|
message=f"Imported {day.isoformat()} ({total:.2f} kWh)",
|
||||||
level="success",
|
level="success",
|
||||||
title="Zaimportowano dzien",
|
title="Day imported",
|
||||||
chunk_index=chunk_index,
|
chunk_index=chunk_index,
|
||||||
energy_kwh=total,
|
energy_kwh=total,
|
||||||
)
|
)
|
||||||
@@ -366,7 +366,7 @@ class HistoricalSyncService:
|
|||||||
self._state.message = message
|
self._state.message = message
|
||||||
self._refresh_coverage(lock_held=True)
|
self._refresh_coverage(lock_held=True)
|
||||||
self._refresh_runtime_metrics(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(
|
self._record_event(
|
||||||
level=level,
|
level=level,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -383,19 +383,19 @@ class HistoricalSyncService:
|
|||||||
end_date=chunk_end,
|
end_date=chunk_end,
|
||||||
state="running",
|
state="running",
|
||||||
started_at=datetime.utcnow(),
|
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:
|
with self._state_lock:
|
||||||
self._state.current_chunk_start = chunk_start
|
self._state.current_chunk_start = chunk_start
|
||||||
self._state.current_chunk_end = chunk_end
|
self._state.current_chunk_end = chunk_end
|
||||||
self._state.active_chunk_index = chunk_index
|
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._upsert_chunk_locked(chunk)
|
||||||
self._refresh_runtime_metrics(lock_held=True)
|
self._refresh_runtime_metrics(lock_held=True)
|
||||||
self._record_event(
|
self._record_event(
|
||||||
level="info",
|
level="info",
|
||||||
title=f"Chunk {chunk_index}/{total_chunks}",
|
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,
|
chunk_index=chunk_index,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class KioskSettingsService:
|
|||||||
return normalized
|
return normalized
|
||||||
if normalized.startswith(USER_MODE_PREFIX) and len(normalized) > len(USER_MODE_PREFIX):
|
if normalized.startswith(USER_MODE_PREFIX) and len(normalized) > len(USER_MODE_PREFIX):
|
||||||
return normalized
|
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]:
|
def _normalize_widgets(self, widgets: Any) -> list[str]:
|
||||||
if not isinstance(widgets, list):
|
if not isinstance(widgets, list):
|
||||||
|
|||||||
@@ -50,34 +50,34 @@ class RealtimeService:
|
|||||||
inverter_temp = to_float(_value(latest, "inverter_temp"))
|
inverter_temp = to_float(_value(latest, "inverter_temp"))
|
||||||
|
|
||||||
hero_cards = [
|
hero_cards = [
|
||||||
self._hero_card("ac_power", ac_power, subtitle="Aktualna moc AC"),
|
self._hero_card("ac_power", ac_power, subtitle="Current AC power"),
|
||||||
self._hero_card("dc_power_total", total_dc_power, label="Moc DC laczna", unit="W", subtitle="Suma stringow DC"),
|
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="Energia dzis", unit="kWh", subtitle="Liczona z danych Influx"),
|
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="Energia laczna", unit="kWh", subtitle="Licznik calkowity"),
|
self._hero_card("energy_total", total_energy, label="Total energy", unit="kWh", subtitle="Lifetime counter"),
|
||||||
]
|
]
|
||||||
if inverter_temp is not None:
|
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 = {
|
kpis = {
|
||||||
"energy_today": custom_metric_value("energy_today", "Energia dzis", energy_today, 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", "Energia wczoraj", energy_yesterday, 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": custom_metric_value(
|
||||||
"energy_total",
|
"energy_total",
|
||||||
"Energia laczna",
|
"Total energy",
|
||||||
total_energy,
|
total_energy,
|
||||||
unit="kWh",
|
unit="kWh",
|
||||||
precision=2,
|
precision=2,
|
||||||
timestamp=_timestamp(latest, "energy_total"),
|
timestamp=_timestamp(latest, "energy_total"),
|
||||||
status="ok",
|
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)
|
comparison = compare_delta_pct(energy_today, energy_yesterday)
|
||||||
if comparison is not None:
|
if comparison is not None:
|
||||||
kpis["today_vs_yesterday"] = custom_metric_value(
|
kpis["today_vs_yesterday"] = custom_metric_value(
|
||||||
"today_vs_yesterday",
|
"today_vs_yesterday",
|
||||||
"Dzis vs wczoraj",
|
"Today vs yesterday",
|
||||||
comparison,
|
comparison,
|
||||||
unit="%",
|
unit="%",
|
||||||
precision=2,
|
precision=2,
|
||||||
@@ -97,7 +97,7 @@ class RealtimeService:
|
|||||||
status.append(
|
status.append(
|
||||||
custom_metric_value(
|
custom_metric_value(
|
||||||
"data_refresh",
|
"data_refresh",
|
||||||
"Ostatni odczyt energii",
|
"Last energy reading",
|
||||||
_timestamp(latest, "energy_total").isoformat() if _timestamp(latest, "energy_total") else None,
|
_timestamp(latest, "energy_total").isoformat() if _timestamp(latest, "energy_total") else None,
|
||||||
status="ok" if _timestamp(latest, "energy_total") else "neutral",
|
status="ok" if _timestamp(latest, "energy_total") else "neutral",
|
||||||
kind="text",
|
kind="text",
|
||||||
@@ -144,7 +144,7 @@ class RealtimeService:
|
|||||||
series.append(
|
series.append(
|
||||||
{
|
{
|
||||||
"metric_id": metric.id,
|
"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,
|
"unit": metric.unit,
|
||||||
"points": self.influx.gauge_history(metric, window.start, window.end, interval=interval, aggregate="mean"),
|
"points": self.influx.gauge_history(metric, window.start, window.end, interval=interval, aggregate="mean"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,23 @@ class SQLiteAuthUserRepository:
|
|||||||
return None
|
return None
|
||||||
return self.get_by_username(username)
|
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]:
|
def list_users(self) -> list[AuthUser]:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
|
|||||||
+5
-5
@@ -7,11 +7,11 @@ from app.services.historical_sync import get_historical_sync_service
|
|||||||
from app.utils.serialization import to_plain
|
from app.utils.serialization import to_plain
|
||||||
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Import dziennych agregatow PV z InfluxDB do lokalnego cache SQLite")
|
parser = argparse.ArgumentParser(description="Import daily PV aggregates from InfluxDB into the local SQLite cache")
|
||||||
parser.add_argument("--start-date", dest="start_date", help="Data startowa YYYY-MM-DD")
|
parser.add_argument("--start-date", dest="start_date", help="Start date YYYY-MM-DD")
|
||||||
parser.add_argument("--end-date", dest="end_date", help="Data koncowa 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="Liczba dni na chunk")
|
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="Nadpisz dni juz zapisane w cache")
|
parser.add_argument("--force", action="store_true", help="Overwrite days already stored in the cache")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
service = get_historical_sync_service()
|
service = get_historical_sync_service()
|
||||||
|
|||||||
+28
-28
@@ -59,7 +59,7 @@ APP_CONFIG = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SITE_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"],
|
"timezone": APP_CONFIG["timezone"],
|
||||||
"installed_power_kwp": env_float("PV_INSTALLED_POWER_KWP", 9.99),
|
"installed_power_kwp": env_float("PV_INSTALLED_POWER_KWP", 9.99),
|
||||||
"currency": os.getenv("SITE_CURRENCY", "PLN"),
|
"currency": os.getenv("SITE_CURRENCY", "PLN"),
|
||||||
@@ -91,20 +91,20 @@ CORS_ORIGINS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
TIME_RANGES = {
|
TIME_RANGES = {
|
||||||
"today": {"label": "Dzis", "special": "today"},
|
"today": {"label": "Today", "special": "today"},
|
||||||
"yesterday": {"label": "Wczoraj", "special": "yesterday"},
|
"yesterday": {"label": "Yesterday", "special": "yesterday"},
|
||||||
"6h": {"label": "6h", "seconds": 6 * 3600},
|
"6h": {"label": "6h", "seconds": 6 * 3600},
|
||||||
"12h": {"label": "12h", "seconds": 12 * 3600},
|
"12h": {"label": "12h", "seconds": 12 * 3600},
|
||||||
"24h": {"label": "24h", "seconds": 24 * 3600},
|
"24h": {"label": "24h", "seconds": 24 * 3600},
|
||||||
"48h": {"label": "48h", "seconds": 48 * 3600},
|
"48h": {"label": "48h", "seconds": 48 * 3600},
|
||||||
"1d": {"label": "1 dzien", "seconds": 1 * 24 * 3600},
|
"1d": {"label": "1 day", "seconds": 1 * 24 * 3600},
|
||||||
"3d": {"label": "3 dni", "seconds": 3 * 24 * 3600},
|
"3d": {"label": "3 days", "seconds": 3 * 24 * 3600},
|
||||||
"7d": {"label": "7 dni", "seconds": 7 * 24 * 3600},
|
"7d": {"label": "7 days", "seconds": 7 * 24 * 3600},
|
||||||
"14d": {"label": "14 dni", "seconds": 14 * 24 * 3600},
|
"14d": {"label": "14 days", "seconds": 14 * 24 * 3600},
|
||||||
"30d": {"label": "30 dni", "seconds": 30 * 24 * 3600},
|
"30d": {"label": "30 days", "seconds": 30 * 24 * 3600},
|
||||||
"60d": {"label": "60 dni", "seconds": 60 * 24 * 3600},
|
"60d": {"label": "60 days", "seconds": 60 * 24 * 3600},
|
||||||
"90d": {"label": "90 dni", "seconds": 90 * 24 * 3600},
|
"90d": {"label": "90 days", "seconds": 90 * 24 * 3600},
|
||||||
"365d": {"label": "365 dni", "seconds": 365 * 24 * 3600},
|
"365d": {"label": "365 days", "seconds": 365 * 24 * 3600},
|
||||||
"ytd": {"label": "YTD", "special": "ytd"},
|
"ytd": {"label": "YTD", "special": "ytd"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,20 +119,20 @@ ANALYTICS = {
|
|||||||
"default_range": os.getenv("ANALYTICS_DEFAULT_RANGE", "30d"),
|
"default_range": os.getenv("ANALYTICS_DEFAULT_RANGE", "30d"),
|
||||||
"default_bucket": os.getenv("ANALYTICS_DEFAULT_BUCKET", "day"),
|
"default_bucket": os.getenv("ANALYTICS_DEFAULT_BUCKET", "day"),
|
||||||
"bucket_labels": {
|
"bucket_labels": {
|
||||||
"day": "Dzien",
|
"day": "Day",
|
||||||
"week": "Tydzien",
|
"week": "Week",
|
||||||
"month": "Miesiac",
|
"month": "Month",
|
||||||
"year": "Rok",
|
"year": "Year",
|
||||||
},
|
},
|
||||||
"compare_modes": {
|
"compare_modes": {
|
||||||
"none": "Porownanie",
|
"none": "Comparison",
|
||||||
"previous_period": "Poprzedni okres",
|
"previous_period": "Previous period",
|
||||||
"previous_year": "Poprzedni rok",
|
"previous_year": "Previous year",
|
||||||
"previous_year_2": "2 lata wstecz",
|
"previous_year_2": "2 years back",
|
||||||
"previous_year_3": "3 lata wstecz",
|
"previous_year_3": "3 years back",
|
||||||
"previous_month_12": "12 miesiecy wstecz",
|
"previous_month_12": "12 months back",
|
||||||
"previous_month_24": "24 miesiace wstecz",
|
"previous_month_24": "24 months back",
|
||||||
"custom_multi": "Wlasne zakresy",
|
"custom_multi": "Custom ranges",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ register_metric(
|
|||||||
entity_id=os.getenv("PV_AC_POWER_ENTITY", "sofarsolar_ac_power"),
|
entity_id=os.getenv("PV_AC_POWER_ENTITY", "sofarsolar_ac_power"),
|
||||||
measurement=os.getenv("PV_AC_POWER_MEASUREMENT", "W"),
|
measurement=os.getenv("PV_AC_POWER_MEASUREMENT", "W"),
|
||||||
unit="W",
|
unit="W",
|
||||||
label="Moc AC",
|
label="AC Power",
|
||||||
precision=0,
|
precision=0,
|
||||||
)
|
)
|
||||||
register_metric(
|
register_metric(
|
||||||
@@ -216,7 +216,7 @@ register_metric(
|
|||||||
entity_id=os.getenv("PV_TOTAL_ENERGY_ENTITY", "sofarsolar_energy_total"),
|
entity_id=os.getenv("PV_TOTAL_ENERGY_ENTITY", "sofarsolar_energy_total"),
|
||||||
measurement=os.getenv("PV_TOTAL_ENERGY_MEASUREMENT", "kWh"),
|
measurement=os.getenv("PV_TOTAL_ENERGY_MEASUREMENT", "kWh"),
|
||||||
unit="kWh",
|
unit="kWh",
|
||||||
label="Energia laczna",
|
label="Total Energy",
|
||||||
kind="counter",
|
kind="counter",
|
||||||
precision=2,
|
precision=2,
|
||||||
)
|
)
|
||||||
@@ -225,7 +225,7 @@ register_metric(
|
|||||||
entity_id=os.getenv("PV_INVERTER_TEMP_ENTITY", "sofarsolar_temprature_inverter"),
|
entity_id=os.getenv("PV_INVERTER_TEMP_ENTITY", "sofarsolar_temprature_inverter"),
|
||||||
measurement=os.getenv("PV_INVERTER_TEMP_MEASUREMENT", "°C"),
|
measurement=os.getenv("PV_INVERTER_TEMP_MEASUREMENT", "°C"),
|
||||||
unit="°C",
|
unit="°C",
|
||||||
label="Temperatura falownika",
|
label="Inverter Temperature",
|
||||||
precision=1,
|
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"]),
|
entity_id=os.getenv(f"PV_STRING_{index}_POWER_ENTITY", defaults["power"]),
|
||||||
measurement=os.getenv(f"PV_STRING_{index}_POWER_MEASUREMENT", "W"),
|
measurement=os.getenv(f"PV_STRING_{index}_POWER_MEASUREMENT", "W"),
|
||||||
unit="W",
|
unit="W",
|
||||||
label=f"{label} moc",
|
label=f"{label} power",
|
||||||
precision=0,
|
precision=0,
|
||||||
)
|
)
|
||||||
voltage_metric_id = register_metric(
|
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"]),
|
entity_id=os.getenv(f"PV_STRING_{index}_VOLTAGE_ENTITY", defaults["voltage"]),
|
||||||
measurement=os.getenv(f"PV_STRING_{index}_VOLTAGE_MEASUREMENT", "V"),
|
measurement=os.getenv(f"PV_STRING_{index}_VOLTAGE_MEASUREMENT", "V"),
|
||||||
unit="V",
|
unit="V",
|
||||||
label=f"{label} napiecie",
|
label=f"{label} voltage",
|
||||||
precision=1,
|
precision=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,19 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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 / {
|
location / {
|
||||||
@@ -19,5 +32,8 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,12 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8105;
|
proxy_pass http://backend:8105;
|
||||||
proxy_http_version 1.1;
|
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-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
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 {
|
location = /health {
|
||||||
@@ -22,9 +31,22 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
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 / {
|
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;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+131
-37
@@ -60,6 +60,8 @@ const STORAGE_KEYS = {
|
|||||||
liveWidgets: "pv-live-widgets-v4",
|
liveWidgets: "pv-live-widgets-v4",
|
||||||
liveMetrics: "pv-live-metrics-v4",
|
liveMetrics: "pv-live-metrics-v4",
|
||||||
archiveMetrics: "pv-archive-metrics-v4",
|
archiveMetrics: "pv-archive-metrics-v4",
|
||||||
|
archiveAutoRefresh: "pv-archive-autorefresh-v1",
|
||||||
|
liveAutoRefresh: "pv-live-autorefresh-v1",
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_KIOSK_WIDGETS: WidgetId[] = ["hero", "history", "strings", "status", "production", "comparison", "importStatus"];
|
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 },
|
nameTextStyle: { color: palette.text },
|
||||||
axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) },
|
axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) },
|
||||||
splitLine: index === 0 ? { lineStyle: { color: palette.grid } } : { show: false },
|
splitLine: index === 0 ? { lineStyle: { color: palette.grid } } : { show: false },
|
||||||
|
axisLine: { lineStyle: { color: palette.grid } },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
animation: false,
|
||||||
color: palette.series,
|
color: palette.series,
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: "axis",
|
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("<br/>");
|
return [header, ...rows.map((row) => `${row.marker ?? ""} ${row.seriesName}: ${formatChartNumber(row.value, locale)}`)].join("<br/>");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
legend: { top: 0, textStyle: { color: palette.text }, itemGap: 16 },
|
legend: { type: "scroll", top: 0, textStyle: { color: palette.text }, itemGap: 16 },
|
||||||
grid: { left: 12, right: yAxes.length > 1 ? 44 : 16, top: 48, bottom: 12, containLabel: true },
|
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)) },
|
xAxis: {
|
||||||
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 } } }],
|
type: "category",
|
||||||
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) })),
|
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 {
|
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 [compare, setCompare] = useState("previous_year");
|
||||||
const [analyticsStart, setAnalyticsStart] = useState("");
|
const [analyticsStart, setAnalyticsStart] = useState("");
|
||||||
const [analyticsEnd, setAnalyticsEnd] = useState("");
|
const [analyticsEnd, setAnalyticsEnd] = useState("");
|
||||||
const [compareRanges, setCompareRanges] = useState<Array<{ key: string; label: string; start: string; end: string }>>([{ key: "cmp_1", label: "Porównanie 1", start: "", end: "" }, { key: "cmp_2", label: "Porównanie 2", start: "", end: "" }]);
|
const [compareRanges, setCompareRanges] = useState<Array<{ key: string; label: string; start: string; end: string }>>([{ key: "cmp_1", label: "Comparison 1", start: "", end: "" }, { key: "cmp_2", label: "Comparison 2", start: "", end: "" }]);
|
||||||
const initialArchiveWindow = archivePresetWindow("today");
|
const initialArchiveWindow = archivePresetWindow("today");
|
||||||
const [archiveStart, setArchiveStart] = useState(initialArchiveWindow?.start ?? "");
|
const [archiveStart, setArchiveStart] = useState(initialArchiveWindow?.start ?? "");
|
||||||
const [archiveEnd, setArchiveEnd] = useState(initialArchiveWindow?.end ?? "");
|
const [archiveEnd, setArchiveEnd] = useState(initialArchiveWindow?.end ?? "");
|
||||||
const [archiveRange, setArchiveRange] = useState("today");
|
const [archiveRange, setArchiveRange] = useState("today");
|
||||||
const [liveMetrics, setLiveMetrics] = useState<string[]>(() => readStorage(STORAGE_KEYS.liveMetrics, DEFAULT_LIVE_METRICS));
|
const [liveMetrics, setLiveMetrics] = useState<string[]>(() => readStorage(STORAGE_KEYS.liveMetrics, DEFAULT_LIVE_METRICS));
|
||||||
const [archiveMetrics, setArchiveMetrics] = useState<string[]>(() => readStorage(STORAGE_KEYS.archiveMetrics, DEFAULT_LIVE_METRICS));
|
const [archiveMetrics, setArchiveMetrics] = useState<string[]>(() => readStorage(STORAGE_KEYS.archiveMetrics, DEFAULT_LIVE_METRICS));
|
||||||
|
const [archiveAutoRefresh, setArchiveAutoRefresh] = useState<boolean>(() => readStorage<boolean>(STORAGE_KEYS.archiveAutoRefresh, true, (raw) => raw === "true"));
|
||||||
|
const [liveAutoRefresh, setLiveAutoRefresh] = useState<boolean>(() => readStorage<boolean>(STORAGE_KEYS.liveAutoRefresh, true, (raw) => raw === "true"));
|
||||||
const [liveWidgets, setLiveWidgets] = useState<LiveWidgetId[]>(() => getVisibleLiveWidgets(readStorage<LiveWidgetId[]>(STORAGE_KEYS.liveWidgets, DEFAULT_LIVE_WIDGETS)));
|
const [liveWidgets, setLiveWidgets] = useState<LiveWidgetId[]>(() => getVisibleLiveWidgets(readStorage<LiveWidgetId[]>(STORAGE_KEYS.liveWidgets, DEFAULT_LIVE_WIDGETS)));
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>(() => { const fromUrl = parseViewModeFromLocation(); return fromUrl === "kiosk" ? fromUrl : readStorage<ViewMode>(STORAGE_KEYS.viewMode, "normal", (raw) => (raw === "kiosk" ? "kiosk" : "normal")); });
|
const [viewMode, setViewMode] = useState<ViewMode>(() => { const fromUrl = parseViewModeFromLocation(); return fromUrl === "kiosk" ? fromUrl : readStorage<ViewMode>(STORAGE_KEYS.viewMode, "normal", (raw) => (raw === "kiosk" ? "kiosk" : "normal")); });
|
||||||
const [kioskWidgets, setKioskWidgets] = useState<WidgetId[]>(() => getVisibleWidgets(readStorage<WidgetId[]>(STORAGE_KEYS.kioskWidgets, DEFAULT_KIOSK_WIDGETS)));
|
const [kioskWidgets, setKioskWidgets] = useState<WidgetId[]>(() => getVisibleWidgets(readStorage<WidgetId[]>(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.liveWidgets, liveWidgets); }, [liveWidgets]);
|
||||||
useEffect(() => { writeStorage(STORAGE_KEYS.liveMetrics, liveMetrics); }, [liveMetrics]);
|
useEffect(() => { writeStorage(STORAGE_KEYS.liveMetrics, liveMetrics); }, [liveMetrics]);
|
||||||
useEffect(() => { writeStorage(STORAGE_KEYS.archiveMetrics, archiveMetrics); }, [archiveMetrics]);
|
useEffect(() => { writeStorage(STORAGE_KEYS.archiveMetrics, archiveMetrics); }, [archiveMetrics]);
|
||||||
|
useEffect(() => { writeStorage(STORAGE_KEYS.archiveAutoRefresh, String(archiveAutoRefresh)); }, [archiveAutoRefresh]);
|
||||||
|
useEffect(() => { writeStorage(STORAGE_KEYS.liveAutoRefresh, String(liveAutoRefresh)); }, [liveAutoRefresh]);
|
||||||
|
|
||||||
const dataEnabled = authenticated || authEnabled === false;
|
const dataEnabled = authenticated || authEnabled === false;
|
||||||
const currentRole = publicMode ? null : (authQuery.data?.role ?? null);
|
const currentRole = publicMode ? null : (authQuery.data?.role ?? null);
|
||||||
@@ -593,12 +631,13 @@ export default function App() {
|
|||||||
const effectiveAnalyticsRange = kioskActive ? effectiveKioskSettings.analytics_range : analyticsRange;
|
const effectiveAnalyticsRange = kioskActive ? effectiveKioskSettings.analytics_range : analyticsRange;
|
||||||
const effectiveBucket = kioskActive ? effectiveKioskSettings.analytics_bucket : bucket;
|
const effectiveBucket = kioskActive ? effectiveKioskSettings.analytics_bucket : bucket;
|
||||||
const effectiveCompare = kioskActive ? effectiveKioskSettings.compare_mode : compare;
|
const effectiveCompare = kioskActive ? effectiveKioskSettings.compare_mode : compare;
|
||||||
const historyQuery = useRealtimeHistory(effectiveRealtimeRange, dataEnabled, { publicKiosk: publicMode });
|
const historyQuery = useRealtimeHistory(effectiveRealtimeRange, dataEnabled, { publicKiosk: publicMode, pauseAutoRefresh: !liveAutoRefresh });
|
||||||
|
const archiveManualRangeActive = Boolean(archiveStart && archiveEnd);
|
||||||
const sanitizedCompareRanges = compareRanges.filter((item) => item.start && item.end);
|
const sanitizedCompareRanges = compareRanges.filter((item) => item.start && item.end);
|
||||||
const analyticsOptions = analyticsStart && analyticsEnd && !kioskActive ? { start: analyticsStart, end: analyticsEnd, publicKiosk: publicMode, compareRanges: effectiveCompare === "custom_multi" ? sanitizedCompareRanges : undefined } : { publicKiosk: publicMode, compareRanges: effectiveCompare === "custom_multi" ? sanitizedCompareRanges : undefined };
|
const analyticsOptions = analyticsStart && analyticsEnd && !kioskActive ? { start: analyticsStart, end: analyticsEnd, publicKiosk: publicMode, compareRanges: effectiveCompare === "custom_multi" ? sanitizedCompareRanges : undefined } : { publicKiosk: publicMode, compareRanges: effectiveCompare === "custom_multi" ? sanitizedCompareRanges : undefined };
|
||||||
const analyticsQuery = useAnalytics(analyticsStart && analyticsEnd && !kioskActive ? "custom" : effectiveAnalyticsRange, effectiveBucket, effectiveCompare, dataEnabled, analyticsOptions);
|
const analyticsQuery = useAnalytics(analyticsStart && analyticsEnd && !kioskActive ? "custom" : effectiveAnalyticsRange, effectiveBucket, effectiveCompare, dataEnabled, analyticsOptions);
|
||||||
const historical = useHistoricalImport(hasWarehouseAccess);
|
const historical = useHistoricalImport(hasWarehouseAccess);
|
||||||
const archiveQuery = useRealtimeHistory(archiveStart && archiveEnd ? "custom" : archiveRange, dataEnabled, { start: archiveStart || undefined, end: archiveEnd || undefined, metrics: archiveMetrics, publicKiosk: publicMode });
|
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 rawRealtimeHistoryData = useMemo(() => trimSingleDayHistory(historyQuery.data, effectiveRealtimeRange), [historyQuery.data, effectiveRealtimeRange]);
|
||||||
const liveHistoryData = useMemo(() => filterHistoryByMetrics(rawRealtimeHistoryData, liveHistoryMetrics), [rawRealtimeHistoryData, liveHistoryMetrics]);
|
const liveHistoryData = useMemo(() => filterHistoryByMetrics(rawRealtimeHistoryData, liveHistoryMetrics), [rawRealtimeHistoryData, liveHistoryMetrics]);
|
||||||
const archiveHistoryData = useMemo(() => filterHistoryByMetrics(trimSingleDayHistory(archiveQuery.data, archiveRange), archiveMetrics), [archiveQuery.data, archiveRange, archiveMetrics]);
|
const archiveHistoryData = useMemo(() => filterHistoryByMetrics(trimSingleDayHistory(archiveQuery.data, archiveRange), archiveMetrics), [archiveQuery.data, archiveRange, archiveMetrics]);
|
||||||
@@ -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 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 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 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({
|
const saveKioskSettingsMutation = useMutation({
|
||||||
mutationFn: (payload: KioskSettingsPayload) => api.saveKioskSettings(payload),
|
mutationFn: (payload: KioskSettingsPayload) => api.saveKioskSettings(payload),
|
||||||
@@ -680,11 +720,11 @@ export default function App() {
|
|||||||
const allWidgets: Record<WidgetId, ReactElement | null> = {
|
const allWidgets: Record<WidgetId, ReactElement | null> = {
|
||||||
hero: <HeroCards cards={kioskActive ? kioskHeroCards : heroCards} locale={locale} language={language} />,
|
hero: <HeroCards cards={kioskActive ? kioskHeroCards : heroCards} locale={locale} language={language} />,
|
||||||
quickMetrics: <QuickMetrics metrics={quickMetrics} locale={locale} language={language} />,
|
quickMetrics: <QuickMetrics metrics={quickMetrics} locale={locale} language={language} />,
|
||||||
history: <LiveHistoryPanel data={liveHistoryData} language={language} theme={theme} title={t(language, "kioskCharts")} subtitle={t(language, "realtimeSubtitle")} />,
|
history: <LiveHistoryPanel data={liveHistoryData} language={language} theme={theme} title={t(language, "kioskCharts")} subtitle={t(language, "realtimeSubtitle")} freezeUpdates={!liveAutoRefresh} footer={<div className="d-flex flex-wrap align-items-center justify-content-between gap-2"><span className={`badge ${liveAutoRefresh ? "bg-green-lt text-green" : "bg-amber-lt text-amber"}`}>{liveAutoRefresh ? (language === "en" ? "Auto refresh active" : "Auto-odświeżanie aktywne") : (language === "en" ? "Auto refresh paused" : "Auto-odświeżanie zatrzymane")}</span><div className="text-secondary small">{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.")}</div></div>} />,
|
||||||
status: <StatusPanel metrics={topStatus} locale={locale} language={language} connected={connected} lastUpdated={lastUpdated} diagnostics={diagnosticsQuery.data} config={config} />,
|
status: <StatusPanel metrics={topStatus} locale={locale} language={language} connected={connected} lastUpdated={lastUpdated} diagnostics={diagnosticsQuery.data} config={config} />,
|
||||||
strings: <StringsPanel rows={snapshot.strings} locale={locale} language={language} />,
|
strings: <StringsPanel rows={snapshot.strings} locale={locale} language={language} />,
|
||||||
production: <ProductionPanel data={analyticsQuery.production.data} language={language} theme={theme} />,
|
production: <ProductionPanel data={analyticsQuery.production.data} language={language} theme={theme} />,
|
||||||
comparison: effectiveCompare !== "none" ? <ComparisonPanel data={analyticsQuery.production.data} language={language} theme={theme} /> : null,
|
comparison: <ComparisonPanel data={analyticsQuery.production.data} language={language} theme={theme} compareMode={effectiveCompare} />,
|
||||||
distribution: null,
|
distribution: null,
|
||||||
importStatus: <HistoricalPanel status={historical.status.data} language={language} locale={locale} compact />,
|
importStatus: <HistoricalPanel status={historical.status.data} language={language} locale={locale} compact />,
|
||||||
};
|
};
|
||||||
@@ -751,6 +791,12 @@ export default function App() {
|
|||||||
{ id: "settings" as TabKey, label: t(language, "settings"), icon: <IconSettings size={18} />, visible: hasSettingsAccess },
|
{ id: "settings" as TabKey, label: t(language, "settings"), icon: <IconSettings size={18} />, visible: hasSettingsAccess },
|
||||||
].filter((item) => item.visible);
|
].filter((item) => item.visible);
|
||||||
|
|
||||||
|
const subnavRefreshControls = activeTab === "realtime"
|
||||||
|
? <div className="pv-subnav-refresh-group"><RefreshModeSwitch language={language} autoEnabled={liveAutoRefresh} onChange={setLiveAutoRefresh} compact /></div>
|
||||||
|
: activeTab === "archive"
|
||||||
|
? <div className="pv-subnav-refresh-group"><RefreshModeSwitch language={language} autoEnabled={archiveAutoRefresh} onChange={setArchiveAutoRefresh} compact /></div>
|
||||||
|
: null;
|
||||||
|
|
||||||
const menu = (
|
const menu = (
|
||||||
<div className="pv-subnav border-bottom">
|
<div className="pv-subnav border-bottom">
|
||||||
<div className="container-xl">
|
<div className="container-xl">
|
||||||
@@ -767,6 +813,8 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="pv-subnav-side">
|
||||||
|
{subnavRefreshControls}
|
||||||
<div className="pv-menu-meta">
|
<div className="pv-menu-meta">
|
||||||
<IconClockHour4 size={16} />
|
<IconClockHour4 size={16} />
|
||||||
<span>{t(language, "updatedAt")}: {formatDateTime(lastUpdated, locale)}</span>
|
<span>{t(language, "updatedAt")}: {formatDateTime(lastUpdated, locale)}</span>
|
||||||
@@ -774,6 +822,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (viewMode === "kiosk" || publicMode) {
|
if (viewMode === "kiosk" || publicMode) {
|
||||||
@@ -782,17 +831,17 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">{navbar}{menu}<div className="page-wrapper"><div className="page-body"><div className="container-xl">
|
<div className="page">{navbar}{menu}<div className="page-wrapper"><div className="page-body"><div className="container-xl">
|
||||||
{activeTab === "realtime" && <><PageHeader title={t(language, "realtimeOverview")} subtitle={t(language, "realtimeSubtitle")}><SegmentedSelect label={t(language, "liveRange")} value={realtimeRange} onChange={setRealtimeRange} options={liveRangeOptions(language)} /></PageHeader><div className="row row-cards g-3">{effectiveLiveWidgets.map((widgetId) => renderLiveWidget(widgetId))}</div></>}
|
{activeTab === "realtime" && <><PageHeader title={t(language, "realtimeOverview")} subtitle={t(language, "realtimeSubtitle")}><div className="pv-page-header-range-grid pv-page-header-range-grid--single"><SegmentedSelect label={t(language, "liveRange")} value={realtimeRange} onChange={setRealtimeRange} options={liveRangeOptions(language)} /></div></PageHeader><div className="row row-cards g-3">{effectiveLiveWidgets.map((widgetId) => renderLiveWidget(widgetId))}</div></>}
|
||||||
|
|
||||||
{activeTab === "archive" && <><PageHeader title={language === "en" ? "Historical live data" : "Dane chwilowe z historii"} subtitle={language === "en" ? "Browse all instant metrics." : "Podgląd metryk chwilowych dla dowolnego okresu."}><div className="pv-filter-grid pv-filter-grid-archive"><SegmentedSelect label={language === "en" ? "Day" : "Dzień"} value={SINGLE_DAY_ARCHIVE_KEYS.has(archiveRange) ? archiveRange : ""} onChange={(value) => { setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={archiveQuickRangeOptions(language)} /><SelectField label={language === "en" ? "Last period" : "Ostatnie dni"} value={archiveListRangeOptions(language).some((item) => item.key === archiveRange) ? archiveRange : ""} onChange={(value) => { setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={[{ key: "", label: language === "en" ? "Choose range" : "Wybierz zakres" }, ...archiveListRangeOptions(language), { key: "custom", label: language === "en" ? "Custom range" : "Własny zakres" }]} /><div><label className="form-label small mb-1">{language === "en" ? "From" : "Od"}</label><input className="form-control form-control-sm" type="datetime-local" value={archiveStart} onChange={(e) => { setArchiveRange("custom"); setArchiveStart(e.target.value); }} /></div><div><label className="form-label small mb-1">{language === "en" ? "To" : "Do"}</label><input className="form-control form-control-sm" type="datetime-local" value={archiveEnd} onChange={(e) => { setArchiveRange("custom"); setArchiveEnd(e.target.value); }} /></div></div></PageHeader><div className="row row-cards g-3"><div className="col-12 col-xl-4"><MetricSelectorCard language={language} title={language === "en" ? "Metrics on chart" : "Metryki na wykresie"} items={chartMetricCandidates.filter((item) => item.metric_id !== "energy_total")} selected={archiveMetrics.filter((item) => item !== "energy_total")} onChange={setArchiveMetrics} /></div><div className="col-12 col-xl-8"><LiveHistoryPanel data={archiveHistoryData} language={language} theme={theme} title={language === "en" ? "Historical chart" : "Wykres historyczny"} subtitle={SINGLE_DAY_ARCHIVE_KEYS.has(archiveRange) ? (language === "en" ? "Single-day view is automatically trimmed to active data hours." : "Widok dnia jest automatycznie przycinany do aktywnych godzin danych.") : (language === "en" ? "Raw instant metrics from InfluxDB only." : "Tylko surowe metryki chwilowe z InfluxDB.")} /></div></div></>}
|
{activeTab === "archive" && <><PageHeader title={language === "en" ? "Historical live data" : "Dane chwilowe z historii"} subtitle={language === "en" ? "Browse all instant metrics and zoom the chart with touch gestures or the slider below." : "Przeglądaj metryki chwilowe i przybliżaj wykres gestem szczypania albo suwakiem pod wykresem."}><div className="pv-page-header-range-grid"><SegmentedSelect label={language === "en" ? "Day" : "Dzień"} value={SINGLE_DAY_ARCHIVE_KEYS.has(archiveRange) ? archiveRange : ""} onChange={(value) => { setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); setArchiveAutoRefresh(true); }} options={archiveQuickRangeOptions(language)} /><SelectField label={language === "en" ? "Last period" : "Ostatnie dni"} value={archiveListRangeOptions(language).some((item) => item.key === archiveRange) ? archiveRange : ""} onChange={(value) => { setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); setArchiveAutoRefresh(true); }} options={[{ key: "", label: language === "en" ? "Choose range" : "Wybierz zakres" }, ...archiveListRangeOptions(language), { key: "custom", label: language === "en" ? "Custom range" : "Własny zakres" }]} /></div></PageHeader><div className="row row-cards g-3"><div className="col-12"><LiveHistoryPanel data={archiveHistoryData} language={language} theme={theme} title={language === "en" ? "Historical chart" : "Wykres historyczny"} subtitle={SINGLE_DAY_ARCHIVE_KEYS.has(archiveRange) ? (language === "en" ? "Single-day view is automatically trimmed to active data hours." : "Widok dnia jest automatycznie przycinany do aktywnych godzin danych.") : (language === "en" ? "Raw instant metrics from InfluxDB only." : "Tylko surowe metryki chwilowe z InfluxDB.")} footer={<div className="pv-chart-footer-controls"><div className="pv-date-field"><label className="form-label small mb-1">{language === "en" ? "From" : "Od"}</label><input className="form-control pv-date-input" type="datetime-local" value={archiveStart} onChange={(e) => { setArchiveRange("custom"); setArchiveStart(e.target.value); setArchiveAutoRefresh(false); }} /></div><div className="pv-date-field"><label className="form-label small mb-1">{language === "en" ? "To" : "Do"}</label><input className="form-control pv-date-input" type="datetime-local" value={archiveEnd} onChange={(e) => { setArchiveRange("custom"); setArchiveEnd(e.target.value); setArchiveAutoRefresh(false); }} /></div><div className="pv-chart-footer-status"><span className={`badge ${archiveAutoRefresh && !archiveManualRangeActive ? "bg-green-lt text-green" : "bg-amber-lt text-amber"}`}>{archiveAutoRefresh && !archiveManualRangeActive ? (language === "en" ? "Auto refresh active" : "Auto-odświeżanie aktywne") : (language === "en" ? "Auto refresh paused" : "Auto-odświeżanie zatrzymane")}</span><div className="text-secondary small">{archiveManualRangeActive ? (language === "en" ? "Manual date range disables automatic refresh until you switch it back on." : "Ręczny zakres dat zatrzymuje auto-odświeżanie, dopóki nie włączysz go ponownie.") : (language === "en" ? "Use the picker below to freeze the chart on a chosen time window." : "Użyj pickera poniżej, aby zamrozić wykres na wybranym zakresie czasu.")}</div></div></div>} /></div><div className="col-12"><MetricSelectorCard language={language} title={language === "en" ? "Metrics under chart" : "Metryki pod wykresem"} items={chartMetricCandidates.filter((item) => item.metric_id !== "energy_total")} selected={archiveMetrics.filter((item) => item !== "energy_total")} onChange={setArchiveMetrics} /></div></div></>}
|
||||||
|
|
||||||
{activeTab === "analytics" && <><PageHeader title={t(language, "analyticsOverview")} subtitle={t(language, "analyticsSubtitle")}><div className="pv-filter-grid"><SegmentedSelect label={t(language, "range")} value={analyticsStart && analyticsEnd ? "custom" : analyticsRange} onChange={(value) => { if (value !== "custom") { setAnalyticsRange(value); setAnalyticsStart(""); setAnalyticsEnd(""); } }} options={[...config.capabilities.ranges.filter((item) => !["6h", "24h", "48h", "1d", "3d", "14d", "60d", "ytd"].includes(item.key)).map((item) => ({ key: item.key, label: translateRangeLabel(language, item.key, item.label) })), { key: "custom", label: language === "en" ? "Custom" : "Ręczny" }]} /><SegmentedSelect label={t(language, "bucket")} value={bucket} onChange={setBucket} options={config.capabilities.buckets.map((item) => ({ key: item.key, label: translateBucket(language, item.key) }))} /><SelectField label={language === "en" ? "Comparison" : "Porównanie"} value={compare} onChange={setCompare} options={comparisonOptions(language)} /></div></PageHeader>{analyticsStart || analyticsEnd || (analyticsRange === "custom") || compare === "custom_multi" ? <div className="card pv-card mb-3"><div className="card-body d-flex flex-column gap-3"><div className="row g-3"><div className="col-md-3"><label className="form-label">{language === "en" ? "Start" : "Od"}</label><input className="form-control" type="datetime-local" value={analyticsStart} onChange={(e) => setAnalyticsStart(e.target.value)} /></div><div className="col-md-3"><label className="form-label">{language === "en" ? "End" : "Do"}</label><input className="form-control" type="datetime-local" value={analyticsEnd} onChange={(e) => setAnalyticsEnd(e.target.value)} /></div><div className="col-md-6 d-flex align-items-end"><button className="btn btn-outline-secondary" onClick={() => { setAnalyticsStart(""); setAnalyticsEnd(""); }}>{language === "en" ? "Use preset range" : "Wróć do gotowych zakresów"}</button></div></div>{compare === "custom_multi" ? <div className="border rounded-3 p-3"><div className="fw-semibold mb-3">{language === "en" ? "Comparison ranges" : "Zakresy porównawcze"}</div><div className="row g-3">{compareRanges.map((item, index) => <div className="col-12" key={item.key}><div className="row g-2 align-items-end"><div className="col-md-3"><label className="form-label">{language === "en" ? `Range ${index + 1} label` : `Etykieta ${index + 1}`}</label><input className="form-control" value={item.label} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, label: e.target.value } : current))} /></div><div className="col-md-3"><label className="form-label">{language === "en" ? "From" : "Od"}</label><input className="form-control" type="datetime-local" value={item.start} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, start: e.target.value } : current))} /></div><div className="col-md-3"><label className="form-label">{language === "en" ? "To" : "Do"}</label><input className="form-control" type="datetime-local" value={item.end} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, end: e.target.value } : current))} /></div><div className="col-md-3"><button className="btn btn-outline-secondary w-100" onClick={() => setCompareRanges(compareRanges.length > 1 ? compareRanges.filter((current) => current.key !== item.key) : compareRanges)}>{language === "en" ? "Remove range" : "Usuń zakres"}</button></div></div></div>)}<div className="col-12"><button className="btn btn-primary" onClick={() => setCompareRanges([...compareRanges, { key: `cmp_${Date.now()}`, label: `${language === "en" ? "Range" : "Zakres"} ${compareRanges.length + 1}`, start: "", end: "" }])}>{language === "en" ? "Add range" : "Dodaj zakres"}</button></div></div></div> : null}</div></div> : null}<div className="row row-cards g-3"><div className="col-12"><SummaryCards summary={summary} language={language} locale={locale} compareLabel={comparisonOptions(language).find((item) => item.key === compare)?.label ?? compare} /></div><div className="col-12">{allWidgets.production}</div>{compare !== "none" ? <div className="col-12">{allWidgets.comparison}</div> : null}</div></>}
|
{activeTab === "analytics" && <><PageHeader title={t(language, "analyticsOverview")} subtitle={t(language, "analyticsSubtitle")}><div className="pv-filter-grid"><SegmentedSelect label={t(language, "range")} value={analyticsStart && analyticsEnd ? "custom" : analyticsRange} onChange={(value) => { if (value !== "custom") { setAnalyticsRange(value); setAnalyticsStart(""); setAnalyticsEnd(""); } }} options={[...config.capabilities.ranges.filter((item) => !["6h", "24h", "48h", "1d", "3d", "14d", "60d", "ytd"].includes(item.key)).map((item) => ({ key: item.key, label: translateRangeLabel(language, item.key, item.label) })), { key: "custom", label: language === "en" ? "Custom" : "Ręczny" }]} /><SegmentedSelect label={t(language, "bucket")} value={bucket} onChange={setBucket} options={config.capabilities.buckets.map((item) => ({ key: item.key, label: translateBucket(language, item.key) }))} /><SelectField label={language === "en" ? "Comparison" : "Porównanie"} value={compare} onChange={setCompare} options={comparisonOptions(language)} /></div></PageHeader>{analyticsStart || analyticsEnd || (analyticsRange === "custom") || compare === "custom_multi" ? <div className="card pv-card mb-3"><div className="card-body d-flex flex-column gap-3"><div className="row g-3"><div className="col-md-3 pv-date-field"><label className="form-label">{language === "en" ? "Start" : "Od"}</label><input className="form-control pv-date-input" type="datetime-local" value={analyticsStart} onChange={(e) => setAnalyticsStart(e.target.value)} /></div><div className="col-md-3 pv-date-field"><label className="form-label">{language === "en" ? "End" : "Do"}</label><input className="form-control pv-date-input" type="datetime-local" value={analyticsEnd} onChange={(e) => setAnalyticsEnd(e.target.value)} /></div><div className="col-md-6 d-flex align-items-end"><button className="btn btn-outline-secondary" onClick={() => { setAnalyticsStart(""); setAnalyticsEnd(""); }}>{language === "en" ? "Use preset range" : "Wróć do gotowych zakresów"}</button></div></div>{compare === "custom_multi" ? <div className="border rounded-3 p-3"><div className="fw-semibold mb-3">{language === "en" ? "Comparison ranges" : "Zakresy porównawcze"}</div><div className="row g-3">{compareRanges.map((item, index) => <div className="col-12" key={item.key}><div className="row g-2 align-items-end"><div className="col-md-3"><label className="form-label">{language === "en" ? `Range ${index + 1} label` : `Etykieta ${index + 1}`}</label><input className="form-control" value={item.label} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, label: e.target.value } : current))} /></div><div className="col-md-3 pv-date-field"><label className="form-label">{language === "en" ? "From" : "Od"}</label><input className="form-control pv-date-input" type="datetime-local" value={item.start} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, start: e.target.value } : current))} /></div><div className="col-md-3 pv-date-field"><label className="form-label">{language === "en" ? "To" : "Do"}</label><input className="form-control pv-date-input" type="datetime-local" value={item.end} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, end: e.target.value } : current))} /></div><div className="col-md-3"><button className="btn btn-outline-secondary w-100" onClick={() => setCompareRanges(compareRanges.length > 1 ? compareRanges.filter((current) => current.key !== item.key) : compareRanges)}>{language === "en" ? "Remove range" : "Usuń zakres"}</button></div></div></div>)}<div className="col-12"><button className="btn btn-primary" onClick={() => setCompareRanges([...compareRanges, { key: `cmp_${Date.now()}`, label: `${language === "en" ? "Range" : "Zakres"} ${compareRanges.length + 1}`, start: "", end: "" }])}>{language === "en" ? "Add range" : "Dodaj zakres"}</button></div></div></div> : null}</div></div> : null}<div className="row row-cards g-3"><div className="col-12"><SummaryCards summary={summary} language={language} locale={locale} compareLabel={comparisonOptions(language).find((item) => item.key === compare)?.label ?? compare} /></div><div className="col-12">{allWidgets.production}</div><div className="col-12">{allWidgets.comparison}</div></div></>}
|
||||||
|
|
||||||
{activeTab === "warehouse" && <><PageHeader title={language === "en" ? "Data warehouse" : "Hurtownia danych"} subtitle={language === "en" ? "Historical import and coverage." : "Import historyczny i pokrycie danych."} /><div className="row row-cards g-3"><div className="col-12 col-xxl-8"><HistoricalPanel status={historical.status.data} language={language} locale={locale} /></div><div className="col-12 col-xxl-4"><ImportControls status={historical.status.data} language={language} onStart={(payload) => historical.start.mutate(payload)} onSyncNow={() => historical.syncNow.mutate()} onCancel={() => historical.cancel.mutate()} /></div></div></>}
|
{activeTab === "warehouse" && <><PageHeader title={language === "en" ? "Data warehouse" : "Hurtownia danych"} subtitle={language === "en" ? "Historical import and coverage." : "Import historyczny i pokrycie danych."} /><div className="row row-cards g-3"><div className="col-12 col-xxl-8"><HistoricalPanel status={historical.status.data} language={language} locale={locale} /></div><div className="col-12 col-xxl-4"><ImportControls status={historical.status.data} language={language} onStart={(payload) => historical.start.mutate(payload)} onSyncNow={() => historical.syncNow.mutate()} onCancel={() => historical.cancel.mutate()} /></div></div></>}
|
||||||
|
|
||||||
{activeTab === "kiosk" && <><PageHeader title={t(language, "kiosk")} subtitle={language === "en" ? "Choose kiosk type, tune ranges and arrange sections." : "Wybierz typ kiosku, ustaw zakresy i ułóż sekcje."} /><div className="row row-cards g-3"><div className="col-12 col-xxl-8"><KioskSettingsEditorPanel language={language} value={kioskEditorMode === "public" ? publicKioskDraft : privateKioskDraft} onChange={(value) => applyKioskDraftChange(kioskEditorMode, value)} onModeChange={setKioskEditorMode} selectedMode={kioskEditorMode} labels={widgetLabels} buckets={config.capabilities.buckets} compareModes={config.capabilities.comparison_modes} saving={saveKioskSettingsMutation.isPending && saveKioskSettingsMutation.variables?.mode === kioskEditorMode} dirty={currentKioskDirty} canSave={canPersistCurrentKioskSettings} saveNotice={kioskSaveNotice[kioskEditorMode]} onSave={saveCurrentKioskSettings} onReset={() => resetKioskDraft(kioskEditorMode)} allowPublicMode={isAdmin} chartItems={chartMetricCandidates} heroItems={blockMetricCandidates} /></div><div className="col-12 col-xxl-4"><KioskLinkPanel language={language} publicKioskUrl={publicKioskUrl} privateKioskUrl={privateKioskUrl} publicSettings={publicKioskDraft} privateSettings={privateKioskDraft} showPublicLink={isAdmin} /></div></div></>}
|
{activeTab === "kiosk" && <><PageHeader title={t(language, "kiosk")} subtitle={language === "en" ? "Choose kiosk type, tune ranges and arrange sections." : "Wybierz typ kiosku, ustaw zakresy i ułóż sekcje."} /><div className="row row-cards g-3"><div className="col-12 col-xxl-8"><KioskSettingsEditorPanel language={language} value={kioskEditorMode === "public" ? publicKioskDraft : privateKioskDraft} onChange={(value) => applyKioskDraftChange(kioskEditorMode, value)} onModeChange={setKioskEditorMode} selectedMode={kioskEditorMode} labels={widgetLabels} buckets={config.capabilities.buckets} compareModes={config.capabilities.comparison_modes} saving={saveKioskSettingsMutation.isPending && saveKioskSettingsMutation.variables?.mode === kioskEditorMode} dirty={currentKioskDirty} canSave={canPersistCurrentKioskSettings} saveNotice={kioskSaveNotice[kioskEditorMode]} onSave={saveCurrentKioskSettings} onReset={() => resetKioskDraft(kioskEditorMode)} allowPublicMode={isAdmin} chartItems={chartMetricCandidates} heroItems={blockMetricCandidates} /></div><div className="col-12 col-xxl-4"><KioskLinkPanel language={language} publicKioskUrl={publicKioskUrl} privateKioskUrl={privateKioskUrl} publicSettings={publicKioskDraft} privateSettings={privateKioskDraft} showPublicLink={isAdmin} /></div></div></>}
|
||||||
|
|
||||||
{activeTab === "settings" && <><PageHeader title={t(language, "settings")} subtitle={language === "en" ? "Quick settings, dashboard visibility and live diagnostics." : "Szybkie ustawienia, widoczność dashboardu i diagnostyka live."} /><div className="row row-cards g-3"><div className="col-12 col-xl-5"><AppearancePanel language={language} setLanguage={setLanguage} theme={theme} setTheme={setTheme} viewMode={viewMode} setViewMode={setViewMode} userName={authQuery.data?.display_name ?? authQuery.data?.user ?? ""} /></div><div className="col-12 col-xl-7"><DiagnosticPanel language={language} locale={locale} data={diagnosticsQuery.data} loading={diagnosticsQuery.isLoading} onRefresh={() => diagnosticsQuery.refetch()} /></div><div className="col-12 col-xl-6"><LiveSectionVisibilityPanel language={language} selected={effectiveLiveWidgets} onChange={setLiveWidgets} /></div><div className="col-12 col-xl-6"><LiveChartMetricsPanel language={language} items={chartMetricCandidates.filter((item) => item.metric_id !== "energy_total")} selected={liveMetrics.filter((item) => item !== "energy_total")} onChange={setLiveMetrics} /></div><div className="col-12"><BlockVisibilityPanel language={language} items={blockMetricCandidates} config={blockConfig} onChange={setBlockConfig} /></div>{isAdmin ? <div className="col-12"><AdminUsersPanel language={language} users={usersQuery.data?.items ?? []} newUser={newUser} onNewUserChange={setNewUser} onCreate={() => createUserMutation.mutate()} passwordReset={passwordReset} onPasswordResetChange={setPasswordReset} onResetPassword={() => resetPasswordMutation.mutate()} /></div> : null}</div></>}
|
{activeTab === "settings" && <><PageHeader title={t(language, "settings")} subtitle={language === "en" ? "Friendlier settings layout with interactive LIVE ordering, homepage blocks and diagnostics." : "Przyjaźniejszy układ ustawień z interaktywną kolejnością LIVE, blokami strony głównej i diagnostyką."} /><div className="row row-cards g-3"><div className="col-12 col-xxl-4"><AppearancePanel language={language} setLanguage={setLanguage} theme={theme} setTheme={setTheme} viewMode={viewMode} setViewMode={setViewMode} userName={authQuery.data?.display_name ?? authQuery.data?.user ?? ""} /></div><div className="col-12 col-xxl-8"><LiveSectionVisibilityPanel language={language} selected={effectiveLiveWidgets} onChange={setLiveWidgets} /></div><div className="col-12"><BlockVisibilityPanel language={language} items={blockMetricCandidates} config={blockConfig} onChange={setBlockConfig} /></div><div className="col-12"><LiveChartMetricsPanel language={language} items={chartMetricCandidates.filter((item) => item.metric_id !== "energy_total")} selected={liveMetrics.filter((item) => item !== "energy_total")} onChange={setLiveMetrics} /></div><div className="col-12"><DiagnosticPanel language={language} locale={locale} data={diagnosticsQuery.data} loading={diagnosticsQuery.isLoading} onRefresh={() => diagnosticsQuery.refetch()} /></div>{isAdmin ? <div className="col-12"><AdminUsersPanel language={language} users={usersQuery.data?.items ?? []} currentUsername={authQuery.data?.user ?? null} newUser={newUser} onNewUserChange={setNewUser} onCreate={() => createUserMutation.mutate()} passwordReset={passwordReset} onPasswordResetChange={setPasswordReset} onResetPassword={() => resetPasswordMutation.mutate()} onRoleChange={(username, role) => updateUserRoleMutation.mutate({ username, role })} roleUpdating={updateUserRoleMutation.isPending} /></div> : null}</div></>}
|
||||||
</div></div></div></div>
|
</div></div></div></div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -815,16 +864,22 @@ function applyArchivePreset(rangeKey: string, setStart: (value: string) => void,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function LoadingScreen({ language }: { language: Language }) { return <div className="page page-center"><div className="container container-tight py-4 text-center"><div className="spinner-border text-primary mb-3" role="status" /><div className="text-secondary">{t(language, "loading")}…</div></div></div>; }
|
function LoadingScreen({ language }: { language: Language }) { return <div className="page page-center"><div className="container container-tight py-4 text-center"><div className="spinner-border text-primary mb-3" role="status" /><div className="text-secondary">{t(language, "loading")}…</div></div></div>; }
|
||||||
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 <div className="page page-center login-page-shell"><div className="container container-tight py-4"><div className="text-center mb-4"><div className="avatar avatar-xl bg-primary-lt text-primary mb-3 border-0 mx-auto"><IconBolt size={28} /></div><h1 className="h2 mb-1">{t(language, "loginTitle")}</h1><div className="text-secondary">{t(language, "loginSubtitle")}</div></div><div className="card card-md login-card"><div className="card-body"><div className="d-flex justify-content-end gap-2 mb-3"><button className="btn btn-icon btn-ghost-secondary" onClick={onThemeToggle}>{theme === "dark" ? <IconSun size={18} /> : <IconMoon size={18} />}</button><button className="btn btn-icon btn-ghost-secondary" onClick={onLanguageToggle}><IconLanguage size={18} /></button></div><div className="mb-3"><label className="form-label">{t(language, "username")}</label><input className="form-control" value={form.username} onChange={(event) => onChange({ ...form, username: event.target.value })} autoComplete="username" /></div><div className="mb-3"><label className="form-label">{t(language, "password")}</label><input className="form-control" type="password" value={form.password} onChange={(event) => onChange({ ...form, password: event.target.value })} autoComplete="current-password" onKeyDown={(event) => event.key === "Enter" && onSubmit()} /></div>{error ? <div className="alert alert-danger py-2">{error}</div> : null}<button className="btn btn-primary w-100" onClick={onSubmit} disabled={loading}><IconLogin2 size={18} className="me-1" />{t(language, "signIn")}</button></div></div></div></div>; }
|
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 <div className="page page-center login-page-shell"><div className="container py-4"><div className="row justify-content-center align-items-stretch g-4 login-layout"><div className="col-12 col-md-10 col-lg-7 col-xl-5"><div className="card card-md login-card login-card-enhanced"><div className="card-body"><div className="d-flex justify-content-between align-items-start gap-3 mb-4"><div><h1 className="h2 mb-1">{t(language, "loginTitle")}</h1><div className="text-secondary">{t(language, "loginSubtitle")}</div></div><div className="d-flex gap-2"><button className="btn btn-icon btn-ghost-secondary" onClick={onThemeToggle}>{theme === "dark" ? <IconSun size={18} /> : <IconMoon size={18} />}</button><button className="btn btn-icon btn-ghost-secondary" onClick={onLanguageToggle}><IconLanguage size={18} /></button></div></div><div className="login-input-stack"><div><label className="form-label">{t(language, "username")}</label><input className="form-control form-control-lg" value={form.username} onChange={(event) => onChange({ ...form, username: event.target.value })} autoComplete="username" /></div><div><label className="form-label">{t(language, "password")}</label><input className="form-control form-control-lg" type="password" value={form.password} onChange={(event) => onChange({ ...form, password: event.target.value })} autoComplete="current-password" onKeyDown={(event) => event.key === "Enter" && onSubmit()} /></div>{error ? <div className="alert alert-danger py-2 mb-0">{error}</div> : null}<button className="btn btn-primary btn-lg w-100 login-submit-button" onClick={onSubmit} disabled={loading}><IconLogin2 size={18} className="me-1" />{t(language, "signIn")}</button></div><div className="login-footer-note">{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."}</div></div></div></div></div></div></div>; }
|
||||||
function NavItem({ icon, active, onClick, label }: { icon: ReactElement; active: boolean; onClick: () => void; label: string }) { return <li className="nav-item"><button className={`nav-link border-0 bg-transparent pv-nav-link ${active ? "active" : ""}`} onClick={onClick}><span className="pv-nav-icon">{icon}</span><span className="pv-nav-title">{label}</span></button></li>; }
|
function NavItem({ icon, active, onClick, label }: { icon: ReactElement; active: boolean; onClick: () => void; label: string }) { return <li className="nav-item"><button className={`nav-link border-0 bg-transparent pv-nav-link ${active ? "active" : ""}`} onClick={onClick}><span className="pv-nav-icon">{icon}</span><span className="pv-nav-title">{label}</span></button></li>; }
|
||||||
function PageHeader({ title, subtitle, children }: { title: string; subtitle: string; children?: ReactNode }) { return <div className="page-header d-print-none mb-3"><div className="row align-items-center"><div className="col"><div className="page-pretitle">PV Insight</div><h2 className="page-title mb-1">{title}</h2><div className="text-secondary">{subtitle}</div></div>{children ? <div className="col-auto ms-auto">{children}</div> : null}</div></div>; }
|
function PageHeader({ title, subtitle, children }: { title: string; subtitle: string; children?: ReactNode }) { return <div className="page-header d-print-none mb-3"><div className="row align-items-center g-3"><div className="col"><div className="page-pretitle">PV Insight</div><h2 className="page-title mb-1">{title}</h2><div className="text-secondary">{subtitle}</div></div>{children ? <div className="col-12 col-xl d-flex justify-content-xl-end"><div className="pv-page-header-actions">{children}</div></div> : null}</div></div>; }
|
||||||
|
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 <div className={`pv-refresh-toggle pv-refresh-panel ${compact ? "pv-refresh-panel--compact" : "w-100"}`}>{compact ? null : <div className="small text-secondary mb-2">{language === "en" ? "Chart refresh" : "Odświeżanie wykresu"}</div>}<button type="button" className={`pv-refresh-switch ${autoEnabled ? "is-auto" : "is-paused"}`} role="switch" aria-checked={autoEnabled} aria-label={switchLabel} title={switchLabel} onClick={() => onChange(!autoEnabled)}><span className="pv-refresh-switch-option pv-refresh-switch-option--auto">{autoLabel}</span><span className="pv-refresh-switch-option pv-refresh-switch-option--paused">{pausedLabel}</span><span className="pv-refresh-switch-thumb" aria-hidden="true" /></button></div>;
|
||||||
|
}
|
||||||
function SegmentedSelect({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return <div className="pv-segmented"><div className="pv-segmented-label">{label}</div><div className="btn-group pv-segmented-group" role="group" aria-label={label}>{options.map((option) => <button key={option.key} className={`btn btn-sm ${value === option.key ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => onChange(option.key)}>{option.label}</button>)}</div></div>; }
|
function SegmentedSelect({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return <div className="pv-segmented"><div className="pv-segmented-label">{label}</div><div className="btn-group pv-segmented-group" role="group" aria-label={label}>{options.map((option) => <button key={option.key} className={`btn btn-sm ${value === option.key ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => onChange(option.key)}>{option.label}</button>)}</div></div>; }
|
||||||
function SelectField({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return <div className="pv-filter-field"><label className="form-label small mb-1">{label}</label><select className="form-select form-select-sm" value={value} onChange={(event) => onChange(event.target.value)}>{options.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}</select></div>; }
|
function SelectField({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return <div className="pv-filter-field"><label className="form-label small mb-1">{label}</label><select className="form-select pv-date-input" value={value} onChange={(event) => onChange(event.target.value)}>{options.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}</select></div>; }
|
||||||
function translateRangeLabel(language: Language, key: string, fallback: string): string { const map: Record<string, { pl: string; en: string }> = { today: { pl: "Dziś", en: "Today" }, yesterday: { pl: "Wczoraj", en: "Yesterday" }, "7d": { pl: "7 dni", en: "7d" }, "30d": { pl: "30 dni", en: "30d" }, "90d": { pl: "90 dni", en: "90d" }, "365d": { pl: "365 dni", en: "365 days" }, custom: { pl: "Ręczny", en: "Custom" } }; return map[key]?.[language] ?? fallback; }
|
function translateRangeLabel(language: Language, key: string, fallback: string): string { const map: Record<string, { pl: string; en: string }> = { today: { pl: "Dziś", en: "Today" }, yesterday: { pl: "Wczoraj", en: "Yesterday" }, "7d": { pl: "7 dni", en: "7d" }, "30d": { pl: "30 dni", en: "30d" }, "90d": { pl: "90 dni", en: "90d" }, "365d": { pl: "365 dni", en: "365 days" }, custom: { pl: "Ręczny", en: "Custom" } }; return map[key]?.[language] ?? fallback; }
|
||||||
function HeroCards({ cards, locale, language }: { cards: SnapshotPayload["hero_cards"]; locale: string; language: Language }) { return <div className="row row-cards g-3">{cards.map((card) => <div key={card.metric_id} className="col-12 col-sm-6 col-xl-3"><div className="card pv-card pv-hero-card h-100"><div className="card-body"><div className="d-flex align-items-center justify-content-between mb-3"><span className="avatar avatar-sm bg-primary-lt text-primary border-0">{iconForMetric(card.metric_id)}</span><span className="badge bg-primary-lt text-primary text-uppercase">{card.unit || "live"}</span></div><div className="text-secondary text-uppercase small mb-1">{labelForMetric(language, card.metric_id, card.label)}</div><div className="display-6 fw-bold mb-1">{formatValue(card.value, card.unit, 2, locale)}</div><div className="text-secondary small">{card.subtitle}</div></div></div></div>)}</div>; }
|
function HeroCards({ cards, locale, language }: { cards: SnapshotPayload["hero_cards"]; locale: string; language: Language }) { return <div className="row row-cards g-3">{cards.map((card) => <div key={card.metric_id} className="col-12 col-sm-6 col-xl-3"><div className="card pv-card pv-hero-card h-100"><div className="card-body"><div className="d-flex align-items-center justify-content-between mb-3"><span className="avatar avatar-sm bg-primary-lt text-primary border-0">{iconForMetric(card.metric_id)}</span><span className="badge bg-primary-lt text-primary text-uppercase">{card.unit || "live"}</span></div><div className="text-secondary text-uppercase small mb-1">{labelForMetric(language, card.metric_id, card.label)}</div><div className="display-6 fw-bold mb-1">{formatValue(card.value, card.unit, 2, locale)}</div><div className="text-secondary small">{card.subtitle}</div></div></div></div>)}</div>; }
|
||||||
function QuickMetrics({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "quickMetrics")}</h3></div><div className="card-body d-flex flex-column gap-3">{metrics.map((metric) => <div key={metric.metric_id} className="d-flex align-items-center justify-content-between"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div></div>; }
|
function QuickMetrics({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "quickMetrics")}</h3></div><div className="card-body d-flex flex-column gap-3">{metrics.map((metric) => <div key={metric.metric_id} className="d-flex align-items-center justify-content-between"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div></div>; }
|
||||||
function StatusStat({ label, value }: { label: string; value: string }) { return <div className="col-6 col-lg-4 col-xl-2"><div className="border rounded-3 p-3 h-100"><div className="text-secondary small mb-1">{label}</div><div className="fw-semibold">{value}</div></div></div>; }
|
function StatusStat({ label, value }: { label: string; value: string }) { return <div className="col-6 col-lg-4 col-xl-2"><div className="border rounded-3 p-3 h-100"><div className="text-secondary small mb-1">{label}</div><div className="fw-semibold">{value}</div></div></div>; }
|
||||||
function LiveHistoryPanel({ data, language, theme, title, subtitle }: { data?: HistoryPayload; language: Language; theme: ThemeMode; title: string; subtitle: string }) { return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{title}</h3><div className="text-secondary small">{subtitle}</div></div></div><div className="card-body"><EChart option={buildLiveHistoryOption(data, theme, language)} className="pv-chart" /></div></div>; }
|
function LiveHistoryPanel({ data, language, theme, title, subtitle, footer, freezeUpdates = false }: { data?: HistoryPayload; language: Language; theme: ThemeMode; title: string; subtitle: string; footer?: ReactNode; freezeUpdates?: boolean }) { const [displayData, setDisplayData] = useState<HistoryPayload | undefined>(data); const latestIncomingRef = useRef<HistoryPayload | undefined>(data); useEffect(() => { latestIncomingRef.current = data; if (!freezeUpdates) setDisplayData(data); }, [data, freezeUpdates]); const chartOption = useMemo(() => buildLiveHistoryOption(displayData, theme, language), [displayData, theme, language]); return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{title}</h3><div className="text-secondary small">{subtitle}</div></div></div><div className="card-body"><EChart option={chartOption} className="pv-chart" />{footer ? <div className="mt-3 pt-3 border-top">{footer}</div> : null}</div></div>; }
|
||||||
function SystemStatus({ items, locale, language }: { items: MetricValue[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "systemStatus")}</h3></div><div className="card-body d-flex flex-column gap-3">{items.map((metric) => <div key={metric.metric_id} className="d-flex align-items-center justify-content-between gap-3 status-row border rounded-3 px-3 py-2"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || metric.status}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div></div>; }
|
function SystemStatus({ items, locale, language }: { items: MetricValue[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "systemStatus")}</h3></div><div className="card-body d-flex flex-column gap-3">{items.map((metric) => <div key={metric.metric_id} className="d-flex align-items-center justify-content-between gap-3 status-row border rounded-3 px-3 py-2"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || metric.status}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div></div>; }
|
||||||
function StringPanels({ rows, locale, language }: { rows: SnapshotGroupRow[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "strings")}</h3></div><div className="card-body"><div className="row g-3">{rows.map((row) => <div key={row.id} className="col-12 col-md-6"><div className="string-panel border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{row.label}</div><div className="d-flex flex-column gap-2">{Object.values(row.values).map((metric) => <div key={metric.metric_id} className="d-flex justify-content-between"><span>{labelForMetric(language, metric.metric_id, metric.label)}</span><span className="fw-medium">{formatValue(metric.value, metric.unit, 2, locale)}</span></div>)}</div></div></div>)}</div></div></div>; }
|
function StringPanels({ rows, locale, language }: { rows: SnapshotGroupRow[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "strings")}</h3></div><div className="card-body"><div className="row g-3">{rows.map((row) => <div key={row.id} className="col-12 col-md-6"><div className="string-panel border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{row.label}</div><div className="d-flex flex-column gap-2">{Object.values(row.values).map((metric) => <div key={metric.metric_id} className="d-flex justify-content-between"><span>{labelForMetric(language, metric.metric_id, metric.label)}</span><span className="fw-medium">{formatValue(metric.value, metric.unit, 2, locale)}</span></div>)}</div></div></div>)}</div></div></div>; }
|
||||||
function StatusDot({ ok }: { ok: boolean }) { return <span className={`d-inline-block rounded-circle ${ok ? "bg-green" : "bg-yellow"}`} style={{ width: 10, height: 10 }} />; }
|
function StatusDot({ ok }: { ok: boolean }) { return <span className={`d-inline-block rounded-circle ${ok ? "bg-green" : "bg-yellow"}`} style={{ width: 10, height: 10 }} />; }
|
||||||
@@ -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 <div className="row row-cards g-3">{items.map((item) => <div className="col-12 col-sm-6 col-xl-3" key={item.key}><div className="card pv-card h-100"><div className="card-body"><div className="text-secondary text-uppercase small mb-1">{item.key}</div><div className="h2 mb-0">{item.value}</div>{item.badge ? <div className="mt-2"><span className="badge bg-primary-lt text-primary">{item.badge}</span></div> : null}</div></div></div>)}</div>; }
|
function SummaryCards({ summary, language, locale, compareLabel }: { summary?: AnalyticsPayload["summary"]; language: Language; locale: string; compareLabel: string }) { const items = [{ key: t(language, "summaryTotal"), value: formatValue(summary?.total, summary?.unit ?? "kWh", 2, locale), badge: compareLabel }, { key: t(language, "summaryAverage"), value: formatValue(summary?.average_bucket, summary?.unit ?? "kWh", 2, locale) }, { key: t(language, "summaryBest"), value: summary ? `${summary.best_bucket_label} · ${formatValue(summary.best_bucket_value, summary.unit, 2, locale)}` : "--" }, { key: t(language, "summaryCo2"), value: formatValue(summary?.co2_saved_kg, "kg", 1, locale) }]; return <div className="row row-cards g-3">{items.map((item) => <div className="col-12 col-sm-6 col-xl-3" key={item.key}><div className="card pv-card h-100"><div className="card-body"><div className="text-secondary text-uppercase small mb-1">{item.key}</div><div className="h2 mb-0">{item.value}</div>{item.badge ? <div className="mt-2"><span className="badge bg-primary-lt text-primary">{item.badge}</span></div> : null}</div></div></div>)}</div>; }
|
||||||
|
|
||||||
function ProductionPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) { return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{t(language, "chartProduction")}</h3><div className="text-secondary small">{t(language, "chartProductionSubtitle")}</div></div></div><div className="card-body"><EChart option={buildBarOption(data?.current ?? [], data?.unit ?? "kWh", theme, language)} className="pv-chart" /></div></div>; }
|
function ProductionPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) { return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{t(language, "chartProduction")}</h3><div className="text-secondary small">{t(language, "chartProductionSubtitle")}</div></div></div><div className="card-body"><EChart option={buildBarOption(data?.current ?? [], data?.unit ?? "kWh", theme, language)} className="pv-chart" /></div></div>; }
|
||||||
function ComparisonPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) {
|
function ComparisonPanel({ data, language, theme, compareMode }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode; compareMode: string }) {
|
||||||
const [comparisonDisplayMode, setComparisonDisplayMode] = useState<"line" | "bar">("line");
|
const [comparisonDisplayMode, setComparisonDisplayMode] = useState<"line" | "bar">("line");
|
||||||
return <div className="card pv-card h-100"><div className="card-header d-flex flex-column flex-lg-row gap-3 align-items-start align-items-lg-center justify-content-between"><div><h3 className="card-title">{t(language, "chartComparison")}</h3><div className="text-secondary small">{comparisonDisplayMode === "line"
|
const hasComparisonData = Boolean(data?.comparisons?.some((item) => item.points?.length) || data?.comparison?.length);
|
||||||
|
return <div className="card pv-card h-100"><div className="card-header d-flex flex-column flex-lg-row gap-3 align-items-start align-items-lg-center justify-content-between"><div><h3 className="card-title">{t(language, "chartComparison")}</h3><div className="text-secondary small">{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" ? "Line view works better when comparing many periods." : "Widok liniowy lepiej działa przy większej liczbie porównań.")
|
||||||
: (language === "en" ? "Grouped bars make direct value comparison easier." : "Słupki ułatwiają porównanie wartości 1:1.")}</div></div><SegmentedSelect label={language === "en" ? "Comparison style" : "Styl porównania"} value={comparisonDisplayMode} onChange={(value) => setComparisonDisplayMode(value as "line" | "bar")} options={[{ key: "line", label: language === "en" ? "Line" : "Linia" }, { key: "bar", label: language === "en" ? "Bars" : "Słupki" }]} /></div><div className="card-body"><EChart option={buildComparisonOption(data, theme, language, comparisonDisplayMode)} className="pv-chart" /></div></div>;
|
: (language === "en" ? "Grouped bars make direct value comparison easier." : "Słupki ułatwiają porównanie wartości 1:1.")}</div></div><SegmentedSelect label={language === "en" ? "Comparison style" : "Styl porównania"} value={comparisonDisplayMode} onChange={(value) => setComparisonDisplayMode(value as "line" | "bar")} options={[{ key: "line", label: language === "en" ? "Line" : "Linia" }, { key: "bar", label: language === "en" ? "Bars" : "Słupki" }]} /></div><div className="card-body">{hasComparisonData || (data?.current?.length ?? 0) > 0 ? <EChart option={buildComparisonOption(data, theme, language, comparisonDisplayMode)} className="pv-chart" /> : <div className="text-secondary">{language === "en" ? "No data available for the comparison chart yet." : "Brak danych do wykresu porównań."}</div>}</div></div>;
|
||||||
}
|
}
|
||||||
function DistributionPanel({ data, language, theme, locale }: { data?: DistributionPayload; language: Language; theme: ThemeMode; locale: string }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "chartDistribution")}</h3></div><div className="card-body"><div className="mb-3 fw-semibold">{formatValue(data?.total, data?.unit ?? "kWh", 2, locale)}</div><EChart option={buildPieOption(data, theme, language)} className="pv-chart-sm" /></div></div>; }
|
function DistributionPanel({ data, language, theme, locale }: { data?: DistributionPayload; language: Language; theme: ThemeMode; locale: string }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "chartDistribution")}</h3></div><div className="card-body"><div className="mb-3 fw-semibold">{formatValue(data?.total, data?.unit ?? "kWh", 2, locale)}</div><EChart option={buildPieOption(data, theme, language)} className="pv-chart-sm" /></div></div>; }
|
||||||
function HistoricalPanel({ status, language, locale, compact = false }: { status?: HistoricalStatus; language: Language; locale: string; compact?: boolean }) { if (!status) return <div className="card pv-card h-100"><div className="card-body text-secondary">{t(language, "noDataDescription")}</div></div>; return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{language === "en" ? "Data warehouse" : "Hurtownia danych"}</h3><div className="text-secondary small">{t(language, "importArchiveSubtitle")}</div></div></div><div className="card-body d-flex flex-column gap-4"><div className="row g-3"><StatusStat label={t(language, "status")} value={status.message || status.state} /><StatusStat label={t(language, "coverage")} value={formatPercent(status.coverage.coverage_pct ?? 0, 1, locale)} /><StatusStat label={t(language, "importedDays")} value={formatValue(status.coverage.imported_days, "", 0, locale)} /><StatusStat label={t(language, "missingDays")} value={formatValue(status.coverage.missing_days, "", 0, locale)} /><StatusStat label={t(language, "throughput")} value={`${formatValue(status.avg_days_per_minute ?? 0, "", 1, locale)} / min`} /><StatusStat label={t(language, "eta")} value={formatDurationShort(status.estimated_remaining_seconds, locale)} /></div>{!compact ? <><div><div className="d-flex justify-content-between small mb-2"><span className="text-secondary">{t(language, "activeChunk")}</span><span className="fw-medium">{status.active_chunk_index}/{status.total_chunks}</span></div><div className="progress progress-sm"><div className="progress-bar" style={{ width: `${Math.min((status.processed_days / Math.max(status.total_days, 1)) * 100, 100)}%` }} /></div></div><div className="row g-3"><div className="col-12 col-xl-6"><div className="table-responsive"><table className="table table-vcenter card-table table-sm"><thead><tr><th>{t(language, "recentChunks")}</th><th>{t(language, "status")}</th><th className="text-end">kWh</th></tr></thead><tbody>{status.recent_chunks.map((chunk) => <tr key={`${chunk.chunk_index}-${chunk.start_date}`}><td><div className="fw-medium">#{chunk.chunk_index}</div><div className="text-secondary small">{chunk.start_date} → {chunk.end_date}</div></td><td>{chunk.state}</td><td className="text-end">{formatValue(chunk.energy_kwh, "kWh", 2, locale)}</td></tr>)}</tbody></table></div></div><div className="col-12 col-xl-6"><div className="list-group list-group-flush">{status.recent_events.map((event, index) => <div className="list-group-item px-0" key={`${event.timestamp}-${index}`}><div className="d-flex justify-content-between gap-2"><div><div className="fw-medium">{event.title}</div><div className="text-secondary small">{event.message}</div></div><div className="text-secondary small text-nowrap">{formatShortTime(event.timestamp, locale)}</div></div></div>)}</div></div></div></> : null}</div></div>; }
|
function HistoricalPanel({ status, language, locale, compact = false }: { status?: HistoricalStatus; language: Language; locale: string; compact?: boolean }) { if (!status) return <div className="card pv-card h-100"><div className="card-body text-secondary">{t(language, "noDataDescription")}</div></div>; return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{language === "en" ? "Data warehouse" : "Hurtownia danych"}</h3><div className="text-secondary small">{t(language, "importArchiveSubtitle")}</div></div></div><div className="card-body d-flex flex-column gap-4"><div className="row g-3"><StatusStat label={t(language, "status")} value={status.message || status.state} /><StatusStat label={t(language, "coverage")} value={formatPercent(status.coverage.coverage_pct ?? 0, 1, locale)} /><StatusStat label={t(language, "importedDays")} value={formatValue(status.coverage.imported_days, "", 0, locale)} /><StatusStat label={t(language, "missingDays")} value={formatValue(status.coverage.missing_days, "", 0, locale)} /><StatusStat label={t(language, "throughput")} value={`${formatValue(status.avg_days_per_minute ?? 0, "", 1, locale)} / min`} /><StatusStat label={t(language, "eta")} value={formatDurationShort(status.estimated_remaining_seconds, locale)} /></div>{!compact ? <><div><div className="d-flex justify-content-between small mb-2"><span className="text-secondary">{t(language, "activeChunk")}</span><span className="fw-medium">{status.active_chunk_index}/{status.total_chunks}</span></div><div className="progress progress-sm"><div className="progress-bar" style={{ width: `${Math.min((status.processed_days / Math.max(status.total_days, 1)) * 100, 100)}%` }} /></div></div><div className="row g-3"><div className="col-12 col-xl-6"><div className="table-responsive"><table className="table table-vcenter card-table table-sm"><thead><tr><th>{t(language, "recentChunks")}</th><th>{t(language, "status")}</th><th className="text-end">kWh</th></tr></thead><tbody>{status.recent_chunks.map((chunk) => <tr key={`${chunk.chunk_index}-${chunk.start_date}`}><td><div className="fw-medium">#{chunk.chunk_index}</div><div className="text-secondary small">{chunk.start_date} → {chunk.end_date}</div></td><td>{chunk.state}</td><td className="text-end">{formatValue(chunk.energy_kwh, "kWh", 2, locale)}</td></tr>)}</tbody></table></div></div><div className="col-12 col-xl-6"><div className="list-group list-group-flush">{status.recent_events.map((event, index) => <div className="list-group-item px-0" key={`${event.timestamp}-${index}`}><div className="d-flex justify-content-between gap-2"><div><div className="fw-medium">{event.title}</div><div className="text-secondary small">{event.message}</div></div><div className="text-secondary small text-nowrap">{formatShortTime(event.timestamp, locale)}</div></div></div>)}</div></div></div></> : null}</div></div>; }
|
||||||
@@ -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 <div className="d-flex flex-column gap-3">{showPublicLink ? <div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "2. Share kiosk" : "2. Udostępnianie kiosku"}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="border rounded-3 p-3"><div className="d-flex justify-content-between align-items-start gap-2 mb-2"><div><div className="fw-semibold">{language === "en" ? "Public kiosk" : "Kiosk publiczny"}</div><div className="text-secondary small">{language === "en" ? "No login required." : "Nie wymaga logowania."}</div></div><span className="badge bg-green-lt text-green">TV</span></div><input className="form-control mb-2" value={publicKioskUrl} readOnly /><div className="small text-secondary mb-3">{language === "en" ? "Live" : "Live"}: {publicSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {publicSettings.analytics_range}</div><button className="btn btn-primary w-100" onClick={() => copy(publicKioskUrl, "public")}>{copied === "public" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy public link" : "Kopiuj link publiczny")}</button></div><div className="border rounded-3 p-3"><div className="d-flex justify-content-between align-items-start gap-2 mb-2"><div><div className="fw-semibold">{language === "en" ? "Private kiosk" : "Kiosk prywatny"}</div><div className="text-secondary small">{language === "en" ? "Requires login." : "Wymaga logowania."}</div></div><span className="badge bg-primary-lt text-primary">{language === "en" ? "Secure" : "Bezpieczny"}</span></div><input className="form-control mb-2" value={privateKioskUrl} readOnly /><div className="small text-secondary mb-3">{language === "en" ? "Live" : "Live"}: {privateSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {privateSettings.analytics_range}</div><button className="btn btn-outline-primary w-100" onClick={() => copy(privateKioskUrl, "private")}>{copied === "private" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy private link" : "Kopiuj link prywatny")}</button></div></div></div> : <div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Private kiosk link" : "Link do prywatnego kiosku"}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="border rounded-3 p-3"><div className="d-flex justify-content-between align-items-start gap-2 mb-2"><div><div className="fw-semibold">{language === "en" ? "Your private kiosk" : "Twój prywatny kiosk"}</div><div className="text-secondary small">{language === "en" ? "Requires login and uses your saved kiosk layout." : "Wymaga logowania i używa Twojego zapisanego układu kiosku."}</div></div><span className="badge bg-primary-lt text-primary">{language === "en" ? "User" : "User"}</span></div><input className="form-control mb-2" value={privateKioskUrl} readOnly /><div className="small text-secondary mb-3">{language === "en" ? "Live" : "Live"}: {privateSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {privateSettings.analytics_range}</div><button className="btn btn-primary w-100" onClick={() => copy(privateKioskUrl, "private")}>{copied === "private" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy private link" : "Kopiuj link prywatny")}</button></div></div></div>}<div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Quick guidance" : "Szybka wskazówka"}</h3></div><div className="card-body"><div className="text-secondary small">{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.")}</div></div></div></div>; }
|
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 <div className="d-flex flex-column gap-3">{showPublicLink ? <div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "2. Share kiosk" : "2. Udostępnianie kiosku"}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="border rounded-3 p-3"><div className="d-flex justify-content-between align-items-start gap-2 mb-2"><div><div className="fw-semibold">{language === "en" ? "Public kiosk" : "Kiosk publiczny"}</div><div className="text-secondary small">{language === "en" ? "No login required." : "Nie wymaga logowania."}</div></div><span className="badge bg-green-lt text-green">TV</span></div><input className="form-control mb-2" value={publicKioskUrl} readOnly /><div className="small text-secondary mb-3">{language === "en" ? "Live" : "Live"}: {publicSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {publicSettings.analytics_range}</div><button className="btn btn-primary w-100" onClick={() => copy(publicKioskUrl, "public")}>{copied === "public" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy public link" : "Kopiuj link publiczny")}</button></div><div className="border rounded-3 p-3"><div className="d-flex justify-content-between align-items-start gap-2 mb-2"><div><div className="fw-semibold">{language === "en" ? "Private kiosk" : "Kiosk prywatny"}</div><div className="text-secondary small">{language === "en" ? "Requires login." : "Wymaga logowania."}</div></div><span className="badge bg-primary-lt text-primary">{language === "en" ? "Secure" : "Bezpieczny"}</span></div><input className="form-control mb-2" value={privateKioskUrl} readOnly /><div className="small text-secondary mb-3">{language === "en" ? "Live" : "Live"}: {privateSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {privateSettings.analytics_range}</div><button className="btn btn-outline-primary w-100" onClick={() => copy(privateKioskUrl, "private")}>{copied === "private" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy private link" : "Kopiuj link prywatny")}</button></div></div></div> : <div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Private kiosk link" : "Link do prywatnego kiosku"}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="border rounded-3 p-3"><div className="d-flex justify-content-between align-items-start gap-2 mb-2"><div><div className="fw-semibold">{language === "en" ? "Your private kiosk" : "Twój prywatny kiosk"}</div><div className="text-secondary small">{language === "en" ? "Requires login and uses your saved kiosk layout." : "Wymaga logowania i używa Twojego zapisanego układu kiosku."}</div></div><span className="badge bg-primary-lt text-primary">{language === "en" ? "User" : "User"}</span></div><input className="form-control mb-2" value={privateKioskUrl} readOnly /><div className="small text-secondary mb-3">{language === "en" ? "Live" : "Live"}: {privateSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {privateSettings.analytics_range}</div><button className="btn btn-primary w-100" onClick={() => copy(privateKioskUrl, "private")}>{copied === "private" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy private link" : "Kopiuj link prywatny")}</button></div></div></div>}<div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Quick guidance" : "Szybka wskazówka"}</h3></div><div className="card-body"><div className="text-secondary small">{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.")}</div></div></div></div>; }
|
||||||
function ActionTile({ active, icon, title, subtitle, onClick }: { active: boolean; icon: ReactNode; title: string; subtitle?: string; onClick: () => void; }) { return <button className={`btn text-start w-100 h-100 p-3 ${active ? "btn-primary" : "btn-outline-secondary"}`} onClick={onClick}><div className={`d-flex align-items-center gap-2 ${subtitle ? "mb-2" : ""}`}><span>{icon}</span><span className="fw-semibold">{title}</span></div>{subtitle ? <div className={active ? "text-white text-opacity-75 small" : "text-secondary small"}>{subtitle}</div> : null}</button>; }
|
function ActionTile({ active, icon, title, subtitle, onClick }: { active: boolean; icon: ReactNode; title: string; subtitle?: string; onClick: () => void; }) { return <button className={`btn text-start w-100 h-100 p-3 pv-action-tile ${active ? "btn-primary" : "btn-outline-secondary"}`} onClick={onClick}><div className={`d-flex align-items-center gap-2 ${subtitle ? "mb-2" : ""}`}><span>{icon}</span><span className="fw-semibold">{title}</span></div>{subtitle ? <div className={active ? "text-white text-opacity-75 small" : "text-secondary small"}>{subtitle}</div> : null}</button>; }
|
||||||
function AppearancePanel({ language, setLanguage, theme, setTheme, viewMode, setViewMode, userName }: { language: Language; setLanguage: (value: Language) => void; theme: ThemeMode; setTheme: (value: ThemeMode) => void; viewMode: ViewMode; setViewMode: (value: ViewMode) => void; userName: string; }) { return <div className="card pv-card h-100"><div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2"><div><h3 className="card-title">{language === "en" ? "Quick settings" : "Szybkie ustawienia"}</h3><div className="text-secondary small">{language === "en" ? "Most useful display options in one place." : "Najważniejsze opcje ekranu w jednym miejscu."}</div></div>{userName ? <span className="badge bg-primary-lt text-primary">{userName}</span> : null}</div><div className="card-body d-flex flex-column gap-4"><div><div className="fw-semibold mb-2">{t(language, "theme")}</div><div className="row g-2"><div className="col-12 col-sm-6"><ActionTile active={theme === "light"} icon={<IconSun size={18} />} title={t(language, "light")} onClick={() => setTheme("light")} /></div><div className="col-12 col-sm-6"><ActionTile active={theme === "dark"} icon={<IconMoon size={18} />} title={t(language, "dark")} onClick={() => setTheme("dark")} /></div></div></div><div className="border-top pt-3"><div className="fw-semibold mb-2">{t(language, "viewMode")}</div><div className="row g-2"><div className="col-12 col-sm-6"><ActionTile active={viewMode === "normal"} icon={<IconSettings size={18} />} title={t(language, "normalMode")} onClick={() => setViewMode("normal")} /></div><div className="col-12 col-sm-6"><ActionTile active={viewMode === "kiosk"} icon={<IconDeviceDesktop size={18} />} title={t(language, "kioskMode")} onClick={() => setViewMode("kiosk")} /></div></div></div><div className="border-top pt-3"><div className="fw-semibold mb-2">{language === "en" ? "Language" : "Język"}</div><div className="row g-2"><div className="col-12 col-sm-6"><ActionTile active={language === "pl"} icon={<IconLanguage size={18} />} title="Polski" onClick={() => setLanguage("pl")} /></div><div className="col-12 col-sm-6"><ActionTile active={language === "en"} icon={<IconLanguage size={18} />} title="English" onClick={() => setLanguage("en")} /></div></div></div></div></div>; }
|
function AppearancePanel({ language, setLanguage, theme, setTheme, viewMode, setViewMode, userName }: { language: Language; setLanguage: (value: Language) => void; theme: ThemeMode; setTheme: (value: ThemeMode) => void; viewMode: ViewMode; setViewMode: (value: ViewMode) => void; userName: string; }) { return <div className="card pv-card h-100"><div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2"><div><h3 className="card-title">{language === "en" ? "Quick settings" : "Szybkie ustawienia"}</h3><div className="text-secondary small">{language === "en" ? "Most useful display options in one place." : "Najważniejsze opcje ekranu w jednym miejscu."}</div></div>{userName ? <span className="badge bg-primary-lt text-primary">{userName}</span> : null}</div><div className="card-body d-flex flex-column gap-4"><div><div className="fw-semibold mb-2">{t(language, "theme")}</div><div className="row g-2"><div className="col-12 col-sm-6"><ActionTile active={theme === "light"} icon={<IconSun size={18} />} title={t(language, "light")} onClick={() => setTheme("light")} /></div><div className="col-12 col-sm-6"><ActionTile active={theme === "dark"} icon={<IconMoon size={18} />} title={t(language, "dark")} onClick={() => setTheme("dark")} /></div></div></div><div className="border-top pt-3"><div className="fw-semibold mb-2">{t(language, "viewMode")}</div><div className="row g-2"><div className="col-12 col-sm-6"><ActionTile active={viewMode === "normal"} icon={<IconSettings size={18} />} title={t(language, "normalMode")} onClick={() => setViewMode("normal")} /></div><div className="col-12 col-sm-6"><ActionTile active={viewMode === "kiosk"} icon={<IconDeviceDesktop size={18} />} title={t(language, "kioskMode")} onClick={() => setViewMode("kiosk")} /></div></div></div><div className="border-top pt-3"><div className="fw-semibold mb-2">{language === "en" ? "Language" : "Język"}</div><div className="row g-2"><div className="col-12 col-sm-6"><ActionTile active={language === "pl"} icon={<IconLanguage size={18} />} title="Polski" onClick={() => setLanguage("pl")} /></div><div className="col-12 col-sm-6"><ActionTile active={language === "en"} icon={<IconLanguage size={18} />} title="English" onClick={() => setLanguage("en")} /></div></div></div></div></div>; }
|
||||||
function MetricSelectorCard({ language, title, items, selected, onChange }: { language: Language; title: string; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; }) { const toggle = (metricId: string) => onChange(selected.includes(metricId) ? selected.filter((item) => item !== metricId) : [...selected, metricId]); return <div className="card pv-card h-100"><div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2"><div><h3 className="card-title">{title}</h3><div className="text-secondary small">{language === "en" ? "Select what should appear on charts." : "Wybierz co ma pojawiać się na wykresach."}</div></div><span className="badge bg-primary-lt text-primary">{selected.length}</span></div><div className="card-body"><div className="d-flex flex-wrap gap-2">{items.map((item) => <button key={item.metric_id} className={`btn ${selected.includes(item.metric_id) ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => toggle(item.metric_id)}>{item.label} <span className="opacity-75">({item.unit})</span></button>)}</div></div></div>; }
|
function MetricSelectorCard({ language, title, items, selected, onChange }: { language: Language; title: string; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; }) { const toggle = (metricId: string) => onChange(selected.includes(metricId) ? selected.filter((item) => item !== metricId) : [...selected, metricId]); return <div className="card pv-card h-100"><div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2"><div><h3 className="card-title">{title}</h3><div className="text-secondary small">{language === "en" ? "Select what should appear on charts." : "Wybierz co ma pojawiać się na wykresach."}</div></div><span className="badge bg-primary-lt text-primary">{selected.length}</span></div><div className="card-body"><div className="d-flex flex-wrap gap-2">{items.map((item) => <button key={item.metric_id} className={`btn pv-chip-button ${selected.includes(item.metric_id) ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => toggle(item.metric_id)}>{item.label} <span className="opacity-75">({item.unit})</span></button>)}</div></div></div>; }
|
||||||
function LiveChartMetricsPanel({ language, items, selected, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; }) { return <MetricSelectorCard language={language} title={language === "en" ? "Live chart metrics" : "Metryki wykresów"} items={items} selected={selected} onChange={onChange} />; }
|
function LiveChartMetricsPanel({ language, items, selected, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; }) { return <MetricSelectorCard language={language} title={language === "en" ? "LIVE chart metrics" : "Metryki wykresu LIVE"} items={items} selected={selected} onChange={onChange} />; }
|
||||||
function LiveSectionVisibilityPanel({ language, selected, onChange }: { language: Language; selected: LiveWidgetId[]; onChange: (value: LiveWidgetId[]) => void; }) {
|
function LiveSectionVisibilityPanel({ language, selected, onChange }: { language: Language; selected: LiveWidgetId[]; onChange: (value: LiveWidgetId[]) => void; }) {
|
||||||
const items: Array<{ id: LiveWidgetId; label: string }> = [
|
const items: Array<{ id: LiveWidgetId; label: string; description: string }> = [
|
||||||
{ id: "hero", label: language === "en" ? "Hero" : "Hero" },
|
{ 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") },
|
{ id: "quickMetrics", label: t(language, "quickMetrics"), description: language === "en" ? "Compact KPI list" : "Szybka lista KPI" },
|
||||||
{ id: "history", label: t(language, "kioskCharts") },
|
{ id: "history", label: t(language, "kioskCharts"), description: language === "en" ? "Main live chart" : "Główny wykres live" },
|
||||||
{ id: "status", label: t(language, "systemStatus") },
|
{ 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") },
|
{ 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) => {
|
const toggle = (id: LiveWidgetId) => {
|
||||||
if (selected.includes(id)) {
|
if (visible.includes(id)) {
|
||||||
const next = selected.filter((item) => item !== id);
|
const next = visible.filter((item) => item !== id);
|
||||||
onChange(next.length ? next : selected);
|
onChange(next.length ? next : visible);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onChange([...selected, id]);
|
onChange([...visible, id]);
|
||||||
};
|
};
|
||||||
return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{language === "en" ? "LIVE section visibility" : "Widoczność sekcji LIVE"}</h3><div className="text-secondary small">{language === "en" ? "Show or hide whole blocks on the live dashboard." : "Włączaj i wyłączaj całe bloki na dashboardzie Live."}</div></div></div><div className="card-body d-flex flex-wrap gap-2">{items.map((item) => <button key={item.id} className={`btn ${selected.includes(item.id) ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => toggle(item.id)}>{item.label}</button>)}</div></div>;
|
return <div className="card pv-card h-100"><div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2"><div><h3 className="card-title">{language === "en" ? "LIVE layout" : "Układ LIVE"}</h3><div className="text-secondary small">{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ą."}</div></div><span className="badge bg-primary-lt text-primary">{visible.length}</span></div><div className="card-body d-flex flex-column gap-3"><div className="alert alert-info py-2 mb-0">{language === "en" ? "The order below directly controls the desktop LIVE dashboard." : "Kolejność poniżej bezpośrednio steruje układem desktopowego dashboardu LIVE."}</div><div className="row g-3"><div className="col-12 col-xl-8"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{language === "en" ? "Visible and ordered" : "Widoczne i uporządkowane"}</div><div className="d-flex flex-column gap-2">{visible.map((id, index) => { const item = items.find((entry) => entry.id === id); if (!item) return null; return <div key={id} className="pv-order-card"><div className="d-flex align-items-start gap-3"><div className="pv-order-index">{index + 1}</div><div className="flex-grow-1"><div className="fw-semibold">{item.label}</div><div className="text-secondary small">{item.description}</div></div><div className="btn-list"><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, -1)}>{language === "en" ? "Up" : "Wyżej"}</button><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, 1)}>{language === "en" ? "Down" : "Niżej"}</button><button className="btn btn-sm btn-outline-danger" onClick={() => toggle(id)}>{language === "en" ? "Hide" : "Ukryj"}</button></div></div></div>; })}</div></div></div><div className="col-12 col-xl-4"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{language === "en" ? "Hidden sections" : "Ukryte sekcje"}</div>{hidden.length ? <div className="d-flex flex-wrap gap-2">{hidden.map((item) => <button key={item.id} className="btn btn-outline-primary pv-chip-button" onClick={() => toggle(item.id)}>+ {item.label}</button>)}</div> : <div className="text-secondary small">{language === "en" ? "All available sections are currently visible." : "Wszystkie dostępne sekcje są teraz widoczne."}</div>}<div className="border-top mt-3 pt-3"><div className="fw-semibold mb-2">{language === "en" ? "Quick preview" : "Szybki podgląd"}</div><div className="small text-secondary">{visible.map((id, index) => `${index + 1}. ${items.find((item) => item.id === id)?.label ?? id}`).join(" → ")}</div></div></div></div></div></div></div>;
|
||||||
|
}
|
||||||
|
function BlockVisibilityPanel({ language, items, config, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; config: Record<BlockTarget, string[]>; onChange: (value: Record<BlockTarget, string[]>) => void; }) {
|
||||||
|
const 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 <div className="col-12 col-xxl-6"><div className="border rounded-3 p-3 h-100 d-flex flex-column gap-3"><div className="d-flex align-items-center justify-content-between gap-2"><div><div className="fw-semibold">{title}</div><div className="text-secondary small">{subtitle}</div></div><span className="badge bg-primary-lt text-primary">{selected.length}</span></div><div className="d-flex flex-column gap-2">{selected.map((metricId, index) => { const metric = items.find((item) => item.metric_id === metricId); if (!metric) return null; return <div key={`${target}-${metricId}`} className="pv-order-card"><div className="d-flex align-items-start gap-3"><div className="pv-order-index">{index + 1}</div><div className="flex-grow-1"><div className="fw-semibold">{metric.label}</div><div className="text-secondary small">{metric.unit || "—"}</div></div><div className="btn-list"><button className="btn btn-sm btn-outline-secondary" onClick={() => move(metricId, -1)}>{language === "en" ? "Up" : "Wyżej"}</button><button className="btn btn-sm btn-outline-secondary" onClick={() => move(metricId, 1)}>{language === "en" ? "Down" : "Niżej"}</button><button className="btn btn-sm btn-outline-danger" onClick={() => toggle(metricId)}>{language === "en" ? "Remove" : "Usuń"}</button></div></div></div>; })}</div><div className="border-top pt-3"><div className="fw-semibold mb-2">{language === "en" ? "Add metric" : "Dodaj metrykę"}</div><div className="d-flex flex-wrap gap-2">{hidden.map((item) => <button key={`${target}-add-${item.metric_id}`} className="btn btn-outline-primary btn-sm pv-chip-button" onClick={() => toggle(item.metric_id)}>+ {item.label} <span className="opacity-75">({item.unit})</span></button>)}</div>{!hidden.length ? <div className="small text-secondary">{language === "en" ? "All metrics are already used in this block." : "Wszystkie metryki są już użyte w tym bloku."}</div> : null}</div></div></div>;
|
||||||
|
};
|
||||||
|
return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{language === "en" ? "Homepage block builder" : "Budowanie bloków strony głównej"}</h3><div className="text-secondary small">{language === "en" ? "Arrange metrics interactively instead of relying on click order." : "Układaj metryki interaktywnie zamiast polegać na kolejności klikania."}</div></div></div><div className="card-body"><div className="alert alert-info py-2">{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."}</div><div className="row g-3">{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.")}</div></div></div>;
|
||||||
}
|
}
|
||||||
function BlockVisibilityPanel({ language, items, config, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; config: Record<BlockTarget, string[]>; onChange: (value: Record<BlockTarget, string[]>) => void; }) { const toggle = (target: BlockTarget, metricId: string) => { const selected = config[target]; onChange({ ...config, [target]: selected.includes(metricId) ? selected.filter((item) => item !== metricId) : [...selected, metricId] }); }; const Section = ({ target, title }: { target: BlockTarget; title: string }) => <div className="border rounded-3 p-3 h-100"><div className="d-flex justify-content-between align-items-center mb-3"><div className="fw-semibold">{title}</div><span className="badge bg-primary-lt text-primary">{config[target].length}</span></div><div className="d-flex flex-wrap gap-2">{items.map((item) => <button key={`${target}-${item.metric_id}`} className={`btn btn-sm ${config[target].includes(item.metric_id) ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => toggle(target, item.metric_id)}>{item.label}</button>)}</div></div>; return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{language === "en" ? "Metric visibility in blocks" : "Widoczność metryk w blokach"}</h3><div className="text-secondary small">{language === "en" ? "Control which KPIs appear in hero and quick sections." : "Steruj tym, które KPI pojawiają się w sekcji hero i szybkich metrykach."}</div></div></div><div className="card-body"><div className="row g-3"><div className="col-12"><Section target="hero" title="Hero metrics" /></div><div className="col-12"><Section target="quick" title="Quick metrics" /></div></div></div></div>; }
|
|
||||||
function DiagnosticBadge({ ok, label }: { ok: boolean; label: string }) { return <span className={`badge ${ok ? "bg-green-lt text-green" : "bg-red-lt text-red"}`}>{label}</span>; }
|
function DiagnosticBadge({ ok, label }: { ok: boolean; label: string }) { return <span className={`badge ${ok ? "bg-green-lt text-green" : "bg-red-lt text-red"}`}>{label}</span>; }
|
||||||
function DiagnosticPanel({ language, locale, data, loading, onRefresh }: { language: Language; locale: string; data?: DiagnosticsPayload; loading: boolean; onRefresh: () => void; }) { return <div className="card pv-card h-100"><div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2"><div><h3 className="card-title">{language === "en" ? "Diagnostics" : "Diagnostyka"}</h3><div className="text-secondary small">{language === "en" ? "API, InfluxDB and application status." : "Stan API, InfluxDB i aplikacji."}</div></div><button className="btn btn-outline-secondary btn-sm" onClick={onRefresh}><IconRefresh size={16} className="me-1" />{language === "en" ? "Refresh" : "Odśwież"}</button></div><div className="card-body d-flex flex-column gap-3">{loading && !data ? <div className="text-secondary">{t(language, "loading")}…</div> : null}{data ? <><div className="row g-3"><StatusStat label="API" value={data.api.status} /><StatusStat label="InfluxDB" value={data.influx.status} /><StatusStat label={language === "en" ? "Uptime" : "Czas pracy"} value={formatDurationShort(data.app.uptime_seconds, locale)} /><StatusStat label={language === "en" ? "Version" : "Wersja"} value={data.app.version} /><StatusStat label="PID" value={String(data.app.pid)} /><StatusStat label="Python" value={data.app.python_version} /></div><div className="row g-3"><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100 d-flex flex-column gap-2"><div className="d-flex justify-content-between align-items-center"><div className="fw-semibold">{language === "en" ? "InfluxDB connection" : "Połączenie z InfluxDB"}</div><DiagnosticBadge ok={data.influx.reachable} label={data.influx.reachable ? (language === "en" ? "Connected" : "Połączono") : (language === "en" ? "Error" : "Błąd")} /></div><div className="small text-secondary">URL</div><div className="fw-medium">{data.influx.url}</div><div className="small text-secondary">{language === "en" ? "Database" : "Baza"}</div><div className="fw-medium">{data.influx.database}</div><div className="small text-secondary">{language === "en" ? "User" : "Użytkownik"}</div><div className="fw-medium">{data.influx.username_masked || "—"}</div><div className="small text-secondary">{language === "en" ? "Timeout / SSL" : "Timeout / SSL"}</div><div className="fw-medium">{data.influx.timeout_seconds}s / {data.influx.verify_ssl ? "verify" : "no-verify"}</div>{data.influx.error ? <div className="alert alert-danger py-2 mb-0">{data.influx.error}</div> : null}</div></div><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100 d-flex flex-column gap-2"><div className="d-flex justify-content-between align-items-center"><div className="fw-semibold">{language === "en" ? "Application details" : "Szczegóły aplikacji"}</div><DiagnosticBadge ok={data.api.status === "ok"} label={data.api.status} /></div><div className="small text-secondary">{language === "en" ? "API prefix" : "Prefix API"}</div><div className="fw-medium">{data.api.prefix}</div><div className="small text-secondary">{language === "en" ? "Started at" : "Uruchomiono"}</div><div className="fw-medium">{formatDateTime(data.app.started_at, locale)}</div><div className="small text-secondary">{language === "en" ? "Timezone" : "Strefa czasu"}</div><div className="fw-medium">{data.app.timezone}</div><div className="small text-secondary">{language === "en" ? "SQLite" : "SQLite"}</div><div className="fw-medium text-break">{data.storage.sqlite_path}</div><div className="small text-secondary">{language === "en" ? "History sync" : "Synchronizacja historii"}</div><div className="fw-medium">{data.storage.historical_import_enabled ? (language === "en" ? "Enabled" : "Włączona") : (language === "en" ? "Disabled" : "Wyłączona")} · auto: {data.storage.auto_sync_enabled ? "on" : "off"} · chunk: {data.storage.default_chunk_days}</div></div></div></div></> : <div className="text-secondary">{language === "en" ? "No diagnostics data." : "Brak danych diagnostycznych."}</div>}</div></div>; }
|
function DiagnosticPanel({ language, locale, data, loading, onRefresh }: { language: Language; locale: string; data?: DiagnosticsPayload; loading: boolean; onRefresh: () => void; }) { return <div className="card pv-card h-100"><div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2"><div><h3 className="card-title">{language === "en" ? "Diagnostics" : "Diagnostyka"}</h3><div className="text-secondary small">{language === "en" ? "API, InfluxDB and application status." : "Stan API, InfluxDB i aplikacji."}</div></div><button className="btn btn-outline-secondary btn-sm" onClick={onRefresh}><IconRefresh size={16} className="me-1" />{language === "en" ? "Refresh" : "Odśwież"}</button></div><div className="card-body d-flex flex-column gap-3">{loading && !data ? <div className="text-secondary">{t(language, "loading")}…</div> : null}{data ? <><div className="row g-3"><StatusStat label="API" value={data.api.status} /><StatusStat label="InfluxDB" value={data.influx.status} /><StatusStat label={language === "en" ? "Uptime" : "Czas pracy"} value={formatDurationShort(data.app.uptime_seconds, locale)} /><StatusStat label={language === "en" ? "Version" : "Wersja"} value={data.app.version} /><StatusStat label="PID" value={String(data.app.pid)} /><StatusStat label="Python" value={data.app.python_version} /></div><div className="row g-3"><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100 d-flex flex-column gap-2"><div className="d-flex justify-content-between align-items-center"><div className="fw-semibold">{language === "en" ? "InfluxDB connection" : "Połączenie z InfluxDB"}</div><DiagnosticBadge ok={data.influx.reachable} label={data.influx.reachable ? (language === "en" ? "Connected" : "Połączono") : (language === "en" ? "Error" : "Błąd")} /></div><div className="small text-secondary">URL</div><div className="fw-medium">{data.influx.url}</div><div className="small text-secondary">{language === "en" ? "Database" : "Baza"}</div><div className="fw-medium">{data.influx.database}</div><div className="small text-secondary">{language === "en" ? "User" : "Użytkownik"}</div><div className="fw-medium">{data.influx.username_masked || "—"}</div><div className="small text-secondary">{language === "en" ? "Timeout / SSL" : "Timeout / SSL"}</div><div className="fw-medium">{data.influx.timeout_seconds}s / {data.influx.verify_ssl ? "verify" : "no-verify"}</div>{data.influx.error ? <div className="alert alert-danger py-2 mb-0">{data.influx.error}</div> : null}</div></div><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100 d-flex flex-column gap-2"><div className="d-flex justify-content-between align-items-center"><div className="fw-semibold">{language === "en" ? "Application details" : "Szczegóły aplikacji"}</div><DiagnosticBadge ok={data.api.status === "ok"} label={data.api.status} /></div><div className="small text-secondary">{language === "en" ? "API prefix" : "Prefix API"}</div><div className="fw-medium">{data.api.prefix}</div><div className="small text-secondary">{language === "en" ? "Started at" : "Uruchomiono"}</div><div className="fw-medium">{formatDateTime(data.app.started_at, locale)}</div><div className="small text-secondary">{language === "en" ? "Timezone" : "Strefa czasu"}</div><div className="fw-medium">{data.app.timezone}</div><div className="small text-secondary">{language === "en" ? "SQLite" : "SQLite"}</div><div className="fw-medium text-break">{data.storage.sqlite_path}</div><div className="small text-secondary">{language === "en" ? "History sync" : "Synchronizacja historii"}</div><div className="fw-medium">{data.storage.historical_import_enabled ? (language === "en" ? "Enabled" : "Włączona") : (language === "en" ? "Disabled" : "Wyłączona")} · auto: {data.storage.auto_sync_enabled ? "on" : "off"} · chunk: {data.storage.default_chunk_days}</div></div></div></div></> : <div className="text-secondary">{language === "en" ? "No diagnostics data." : "Brak danych diagnostycznych."}</div>}</div></div>; }
|
||||||
function AdminUsersPanel({ language, users, newUser, onNewUserChange, onCreate, passwordReset, onPasswordResetChange, onResetPassword }: { language: Language; users: AuthUsersPayload["items"]; newUser: { username: string; display_name: string; password: string; role: string }; onNewUserChange: (value: { username: string; display_name: string; password: string; role: string }) => void; onCreate: () => void; passwordReset: { username: string; password: string }; onPasswordResetChange: (value: { username: string; password: string }) => void; onResetPassword: () => void; }) { return <div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Admin user management" : "Zarządzanie użytkownikami"}</h3></div><div className="card-body"><div className="row g-3"><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{language === "en" ? "Create user" : "Dodaj użytkownika"}</div><div className="row g-2"><div className="col-md-6"><input className="form-control" placeholder={language === "en" ? "Username" : "Login"} value={newUser.username} onChange={(e) => onNewUserChange({ ...newUser, username: e.target.value })} /></div><div className="col-md-6"><input className="form-control" placeholder={language === "en" ? "Display name" : "Nazwa"} value={newUser.display_name} onChange={(e) => onNewUserChange({ ...newUser, display_name: e.target.value })} /></div><div className="col-md-6"><input className="form-control" type="password" placeholder={language === "en" ? "Password" : "Hasło"} value={newUser.password} onChange={(e) => onNewUserChange({ ...newUser, password: e.target.value })} /></div><div className="col-md-6"><select className="form-select" value={newUser.role} onChange={(e) => onNewUserChange({ ...newUser, role: e.target.value })}><option value="user">user</option><option value="admin">admin</option></select></div><div className="col-12"><button className="btn btn-primary" onClick={onCreate}>{language === "en" ? "Create user" : "Dodaj użytkownika"}</button></div></div></div></div><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{language === "en" ? "Reset password" : "Zmiana hasła"}</div><div className="row g-2"><div className="col-md-6"><select className="form-select" value={passwordReset.username} onChange={(e) => onPasswordResetChange({ ...passwordReset, username: e.target.value })}><option value="">{language === "en" ? "Select user" : "Wybierz użytkownika"}</option>{users.map((user) => <option key={user.username} value={user.username}>{user.username}</option>)}</select></div><div className="col-md-6"><input className="form-control" type="password" placeholder={language === "en" ? "New password" : "Nowe hasło"} value={passwordReset.password} onChange={(e) => onPasswordResetChange({ ...passwordReset, password: e.target.value })} /></div><div className="col-12"><button className="btn btn-outline-primary" onClick={onResetPassword}>{language === "en" ? "Reset password" : "Zmień hasło"}</button></div></div></div></div><div className="col-12"><div className="table-responsive"><table className="table table-vcenter card-table"><thead><tr><th>{language === "en" ? "Username" : "Login"}</th><th>{language === "en" ? "Display name" : "Nazwa"}</th><th>Role</th><th>{language === "en" ? "Updated" : "Aktualizacja"}</th></tr></thead><tbody>{users.map((user) => <tr key={user.username}><td>{user.username}</td><td>{user.display_name}</td><td><span className="badge bg-primary-lt text-primary">{user.role}</span></td><td>{formatDateTime(user.updated_at, language === "en" ? "en-GB" : "pl-PL")}</td></tr>)}</tbody></table></div></div></div></div></div>; }
|
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 <div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Admin user management" : "Zarządzanie użytkownikami"}</h3></div><div className="card-body"><div className="row g-3"><div className="col-12 col-xl-4"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{language === "en" ? "Create user" : "Dodaj użytkownika"}</div><div className="row g-2"><div className="col-md-6 col-xl-12"><input className="form-control" placeholder={language === "en" ? "Username" : "Login"} value={newUser.username} onChange={(e) => onNewUserChange({ ...newUser, username: e.target.value })} /></div><div className="col-md-6 col-xl-12"><input className="form-control" placeholder={language === "en" ? "Display name" : "Nazwa"} value={newUser.display_name} onChange={(e) => onNewUserChange({ ...newUser, display_name: e.target.value })} /></div><div className="col-md-6 col-xl-12"><input className="form-control" type="password" placeholder={language === "en" ? "Password" : "Hasło"} value={newUser.password} onChange={(e) => onNewUserChange({ ...newUser, password: e.target.value })} /></div><div className="col-md-6 col-xl-12"><select className="form-select" value={newUser.role} onChange={(e) => onNewUserChange({ ...newUser, role: e.target.value })}><option value="user">user</option><option value="admin">admin</option></select></div><div className="col-12"><button className="btn btn-primary w-100" onClick={onCreate}>{language === "en" ? "Create user" : "Dodaj użytkownika"}</button></div></div></div></div><div className="col-12 col-xl-4"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{language === "en" ? "Reset password" : "Zmiana hasła"}</div><div className="row g-2"><div className="col-md-6 col-xl-12"><select className="form-select" value={passwordReset.username} onChange={(e) => onPasswordResetChange({ ...passwordReset, username: e.target.value })}><option value="">{language === "en" ? "Select user" : "Wybierz użytkownika"}</option>{users.map((user) => <option key={user.username} value={user.username}>{user.username}</option>)}</select></div><div className="col-md-6 col-xl-12"><input className="form-control" type="password" placeholder={language === "en" ? "New password" : "Nowe hasło"} value={passwordReset.password} onChange={(e) => onPasswordResetChange({ ...passwordReset, password: e.target.value })} /></div><div className="col-12"><button className="btn btn-outline-primary w-100" onClick={onResetPassword}>{language === "en" ? "Reset password" : "Zmień hasło"}</button></div></div></div></div><div className="col-12 col-xl-4"><div className="border rounded-3 p-3 h-100 d-flex flex-column gap-2"><div className="fw-semibold">{language === "en" ? "Permissions" : "Uprawnienia"}</div><div className="text-secondary small">{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."}</div><div className="alert alert-info py-2 mb-0">{language === "en" ? "The last active admin cannot be downgraded." : "Ostatni aktywny administrator nie może zostać zdegradowany."}</div></div></div><div className="col-12"><div className="table-responsive"><table className="table table-vcenter card-table"><thead><tr><th>{language === "en" ? "Username" : "Login"}</th><th>{language === "en" ? "Display name" : "Nazwa"}</th><th>Role</th><th>{language === "en" ? "Updated" : "Aktualizacja"}</th><th className="text-end">{language === "en" ? "Actions" : "Akcje"}</th></tr></thead><tbody>{users.map((user) => { const nextRole = user.role === "admin" ? "user" : "admin"; return <tr key={user.username}><td><div className="fw-semibold">{user.username}</div>{currentUsername === user.username ? <div className="small text-secondary">{language === "en" ? "Current session" : "Bieżąca sesja"}</div> : null}</td><td>{user.display_name}</td><td><span className={`badge ${user.role === "admin" ? "bg-primary-lt text-primary" : "bg-secondary-lt text-secondary"}`}>{user.role}</span></td><td>{formatDateTime(user.updated_at, language === "en" ? "en-GB" : "pl-PL")}</td><td className="text-end"><button className="btn btn-sm btn-outline-primary" onCliLine truncated
|
||||||
@@ -179,6 +179,8 @@ export const api = {
|
|||||||
DEMO_MODE ? demoResponse(() => payload) : request("/auth/users", { method: "POST", body: JSON.stringify(payload) }),
|
DEMO_MODE ? demoResponse(() => payload) : request("/auth/users", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
resetUserPassword: (username: string, password: string) =>
|
resetUserPassword: (username: string, password: string) =>
|
||||||
DEMO_MODE ? demoResponse(() => ({ username })) : request(`/auth/users/${encodeURIComponent(username)}/reset-password`, { method: "POST", body: JSON.stringify({ password }) }),
|
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 => {
|
export const wsBaseUrl = (): string => {
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ import type { EChartsOption } from "echarts";
|
|||||||
import { Card } from "../common/Card";
|
import { Card } from "../common/Card";
|
||||||
import { EChart } from "../common/EChart";
|
import { EChart } from "../common/EChart";
|
||||||
import type { BucketPoint } from "../../types";
|
import type { BucketPoint } from "../../types";
|
||||||
|
import { t, type Language } from "../../i18n";
|
||||||
|
|
||||||
interface ComparisonChartProps {
|
interface ComparisonChartProps {
|
||||||
current: BucketPoint[];
|
current: BucketPoint[];
|
||||||
comparison: BucketPoint[];
|
comparison: BucketPoint[];
|
||||||
unit: string;
|
unit: string;
|
||||||
compareMode: 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 = {
|
const option: EChartsOption = {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: "axis",
|
trigger: "axis",
|
||||||
@@ -43,14 +45,14 @@ export function ComparisonChart({ current, comparison, unit, compareMode }: Comp
|
|||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: "Aktualny okres",
|
name: t(language, "currentPeriod"),
|
||||||
type: "bar",
|
type: "bar",
|
||||||
barGap: "20%",
|
barGap: "20%",
|
||||||
itemStyle: { color: "#60a5fa", borderRadius: [10, 10, 0, 0] },
|
itemStyle: { color: "#60a5fa", borderRadius: [10, 10, 0, 0] },
|
||||||
data: current.map((item) => item.value),
|
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",
|
type: "bar",
|
||||||
itemStyle: { color: "#f59e0b", borderRadius: [10, 10, 0, 0] },
|
itemStyle: { color: "#f59e0b", borderRadius: [10, 10, 0, 0] },
|
||||||
data: current.map((_, index) => comparison[index]?.value ?? 0),
|
data: current.map((_, index) => comparison[index]?.value ?? 0),
|
||||||
@@ -59,7 +61,7 @@ export function ComparisonChart({ current, comparison, unit, compareMode }: Comp
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="Porownanie okresow" subtitle="Wspolne slupki dla aktualnego i porownawczego okresu">
|
<Card title={t(language, "chartComparison")} subtitle={t(language, "chartComparisonSubtitle")}>
|
||||||
<EChart option={option} className="h-[340px] w-full" />
|
<EChart option={option} className="h-[340px] w-full" />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Card } from "../common/Card";
|
import { Card } from "../common/Card";
|
||||||
|
import { t, type Language } from "../../i18n";
|
||||||
|
|
||||||
interface PeriodControlsProps {
|
interface PeriodControlsProps {
|
||||||
rangeKey: string;
|
rangeKey: string;
|
||||||
@@ -10,6 +11,7 @@ interface PeriodControlsProps {
|
|||||||
onRangeChange: (value: string) => void;
|
onRangeChange: (value: string) => void;
|
||||||
onBucketChange: (value: string) => void;
|
onBucketChange: (value: string) => void;
|
||||||
onCompareChange: (value: string) => void;
|
onCompareChange: (value: string) => void;
|
||||||
|
language?: Language;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PeriodControls({
|
export function PeriodControls({
|
||||||
@@ -22,12 +24,13 @@ export function PeriodControls({
|
|||||||
onRangeChange,
|
onRangeChange,
|
||||||
onBucketChange,
|
onBucketChange,
|
||||||
onCompareChange,
|
onCompareChange,
|
||||||
|
language = "en",
|
||||||
}: PeriodControlsProps) {
|
}: PeriodControlsProps) {
|
||||||
return (
|
return (
|
||||||
<Card title="Porownanie okresow" subtitle="Dzien / tydzien / miesiac + poprzedni okres lub poprzedni rok">
|
<Card title={t(language, "chartComparison")} subtitle={t(language, "chartComparisonControlSubtitle")}>
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">Zakres</span>
|
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">{t(language, "range")}</span>
|
||||||
<select
|
<select
|
||||||
value={rangeKey}
|
value={rangeKey}
|
||||||
onChange={(event) => onRangeChange(event.target.value)}
|
onChange={(event) => onRangeChange(event.target.value)}
|
||||||
@@ -42,7 +45,7 @@ export function PeriodControls({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">Bucket</span>
|
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">{t(language, "bucket")}</span>
|
||||||
<select
|
<select
|
||||||
value={bucket}
|
value={bucket}
|
||||||
onChange={(event) => onBucketChange(event.target.value)}
|
onChange={(event) => onBucketChange(event.target.value)}
|
||||||
@@ -57,7 +60,7 @@ export function PeriodControls({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">Porownanie</span>
|
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">{t(language, "comparisonPeriod")}</span>
|
||||||
<select
|
<select
|
||||||
value={compare}
|
value={compare}
|
||||||
onChange={(event) => onCompareChange(event.target.value)}
|
onChange={(event) => onCompareChange(event.target.value)}
|
||||||
|
|||||||
@@ -9,23 +9,41 @@ interface EChartProps {
|
|||||||
|
|
||||||
export function EChart({ option, className = "h-80 w-full" }: EChartProps) {
|
export function EChart({ option, className = "h-80 w-full" }: EChartProps) {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
const chartRef = useRef<echarts.EChartsType | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) {
|
if (!ref.current || chartRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chart = echarts.init(ref.current);
|
chartRef.current = echarts.init(ref.current);
|
||||||
chart.setOption(option);
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => chart.resize());
|
return () => {
|
||||||
|
chartRef.current?.dispose();
|
||||||
|
chartRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chartRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chartRef.current.setOption(option, { notMerge: false, lazyUpdate: true });
|
||||||
|
}, [option]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current || !chartRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => chartRef.current?.resize());
|
||||||
observer.observe(ref.current);
|
observer.observe(ref.current);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
chart.dispose();
|
|
||||||
};
|
};
|
||||||
}, [option]);
|
}, []);
|
||||||
|
|
||||||
return <div ref={ref} className={className} />;
|
return <div ref={ref} className={className} />;
|
||||||
}
|
}
|
||||||
@@ -2,13 +2,15 @@ import type { EChartsOption } from "echarts";
|
|||||||
import { Card } from "../common/Card";
|
import { Card } from "../common/Card";
|
||||||
import { EChart } from "../common/EChart";
|
import { EChart } from "../common/EChart";
|
||||||
import type { HistoryPayload } from "../../types";
|
import type { HistoryPayload } from "../../types";
|
||||||
|
import type { Language } from "../../i18n";
|
||||||
|
|
||||||
interface LiveHistoryChartProps {
|
interface LiveHistoryChartProps {
|
||||||
history?: HistoryPayload;
|
history?: HistoryPayload;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
language?: Language;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LiveHistoryChart({ history, title = "Dane chwilowe" }: LiveHistoryChartProps) {
|
export function LiveHistoryChart({ history, title = "Live data", language = "en" }: LiveHistoryChartProps) {
|
||||||
const option: EChartsOption = {
|
const option: EChartsOption = {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: "axis",
|
trigger: "axis",
|
||||||
@@ -33,7 +35,7 @@ export function LiveHistoryChart({ history, title = "Dane chwilowe" }: LiveHisto
|
|||||||
axisLabel: { color: "#94a3b8" },
|
axisLabel: { color: "#94a3b8" },
|
||||||
axisLine: { lineStyle: { color: "rgba(255,255,255,0.08)" } },
|
axisLine: { lineStyle: { color: "rgba(255,255,255,0.08)" } },
|
||||||
data: history?.series[0]?.points.map((point) =>
|
data: history?.series[0]?.points.map((point) =>
|
||||||
new Date(point.timestamp).toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit" })
|
new Date(point.timestamp).toLocaleTimeString(language === "en" ? "en-GB" : "pl-PL", { hour: "2-digit", minute: "2-digit" })
|
||||||
) ?? [],
|
) ?? [],
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
@@ -56,7 +58,7 @@ export function LiveHistoryChart({ history, title = "Dane chwilowe" }: LiveHisto
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title={title}
|
title={title}
|
||||||
subtitle="Moc AC, moce stringow DC i opcjonalnie temperatura falownika w jednym widoku live"
|
subtitle={language === "en" ? "AC power, DC string power and optional inverter temperature in one live view" : "Moc AC, moce stringów DC i opcjonalnie temperatura falownika w jednym widoku live"}
|
||||||
>
|
>
|
||||||
<EChart option={option} className="h-[340px] w-full" />
|
<EChart option={option} className="h-[340px] w-full" />
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Card } from "../common/Card";
|
import { Card } from "../common/Card";
|
||||||
import { ValuePair } from "../common/ValuePair";
|
import { ValuePair } from "../common/ValuePair";
|
||||||
import type { SnapshotGroupRow } from "../../types";
|
import type { SnapshotGroupRow } from "../../types";
|
||||||
|
import type { Language } from "../../i18n";
|
||||||
|
|
||||||
interface PhaseGridProps {
|
interface PhaseGridProps {
|
||||||
rows: SnapshotGroupRow[];
|
rows: SnapshotGroupRow[];
|
||||||
|
language?: Language;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PhaseGrid({ rows }: PhaseGridProps) {
|
export function PhaseGrid({ rows, language = "en" }: PhaseGridProps) {
|
||||||
return (
|
return (
|
||||||
<Card title="Fazy AC" subtitle="Napiece, prady i moce pozorne na falowniku">
|
<Card title={language === "en" ? "AC phases" : "Fazy AC"} subtitle={language === "en" ? "Voltage, current and apparent power on the inverter" : "Napięcie, prądy i moce pozorne na falowniku"}>
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<div key={row.id} className="rounded-3xl border border-white/10 bg-slate-950/40 p-4">
|
<div key={row.id} className="rounded-3xl border border-white/10 bg-slate-950/40 p-4">
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { Card } from "../common/Card";
|
import { Card } from "../common/Card";
|
||||||
import { ValuePair } from "../common/ValuePair";
|
import { ValuePair } from "../common/ValuePair";
|
||||||
import type { SnapshotGroupRow } from "../../types";
|
import type { SnapshotGroupRow } from "../../types";
|
||||||
|
import type { Language } from "../../i18n";
|
||||||
|
|
||||||
interface StringGridProps {
|
interface StringGridProps {
|
||||||
rows: SnapshotGroupRow[];
|
rows: SnapshotGroupRow[];
|
||||||
|
language?: Language;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slotOrder = ["power", "voltage"] as const;
|
const slotOrder = ["power", "voltage"] as const;
|
||||||
|
|
||||||
export function StringGrid({ rows }: StringGridProps) {
|
export function StringGrid({ rows, language = "en" }: StringGridProps) {
|
||||||
return (
|
return (
|
||||||
<Card title="Stringi DC" subtitle="Widok automatycznie skaluje sie do liczby stringow i dostepnych metryk z config.py">
|
<Card title={language === "en" ? "DC strings" : "Stringi DC"} subtitle={language === "en" ? "The layout automatically scales to the number of strings and available metrics from config.py" : "Widok automatycznie skaluje się do liczby stringów i dostępnych metryk z config.py"}>
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{rows.map((row) => {
|
{rows.map((row) => {
|
||||||
const visibleSlots = slotOrder.filter((slot) => row.values[slot]);
|
const visibleSlots = slotOrder.filter((slot) => row.values[slot]);
|
||||||
|
|||||||
@@ -9,24 +9,24 @@ interface ConfigPanelProps {
|
|||||||
export function ConfigPanel({ config }: ConfigPanelProps) {
|
export function ConfigPanel({ config }: ConfigPanelProps) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_1fr]">
|
<div className="grid gap-6 xl:grid-cols-[1.1fr_1fr]">
|
||||||
<Card title="Architektura i UX" subtitle="Co zostalo przygotowane w tej wersji">
|
<Card title="Architecture and UX" subtitle="What is prepared in this version">
|
||||||
<div className="grid gap-4 text-sm text-slate-300">
|
<div className="grid gap-4 text-sm text-slate-300">
|
||||||
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
|
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
|
||||||
<div className="font-medium text-white">Backend</div>
|
<div className="font-medium text-white">Backend</div>
|
||||||
<p className="mt-2">Flask + modularne serwisy, bez uvicorna i bez pydantic-core. Odczyt z InfluxDB idzie po HTTP API, a agregaty historyczne trafiaja do lokalnego cache SQLite.</p>
|
<p className="mt-2">Flask with modular services, without uvicorn and without pydantic-core. InfluxDB reads go through the HTTP API, while historical aggregates are stored in the local SQLite cache.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
|
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
|
||||||
<div className="font-medium text-white">Frontend</div>
|
<div className="font-medium text-white">Frontend</div>
|
||||||
<p className="mt-2">React + TypeScript + Vite + Tailwind, responsywne karty, live charts i widok mobilny bez osobnej wersji aplikacji.</p>
|
<p className="mt-2">React + TypeScript + Vite + Tailwind, responsive cards, live charts and mobile view without a separate app version.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
|
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
|
||||||
<div className="font-medium text-white">Logika danych</div>
|
<div className="font-medium text-white">Data logic</div>
|
||||||
<p className="mt-2">Produkcja dzienna, tygodniowa, miesieczna i roczna jest liczona z surowych danych Influxa na podstawie licznika energii calkowitej, a gdy go brak — z mocy AC. Pelne dni sa cache'owane lokalnie.</p>
|
<p className="mt-2">Daily, weekly, monthly and yearly production is calculated from raw Influx data using the total energy counter, and when it is missing, from AC power. Full days are cached locally.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Konfiguracja deploymentu" subtitle="Najwazniejsze ustawienia od razu widoczne">
|
<Card title="Deployment configuration" subtitle="Most important settings visible at a glance">
|
||||||
<dl className="grid gap-4 text-sm">
|
<dl className="grid gap-4 text-sm">
|
||||||
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
|
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
|
||||||
<dt className="text-slate-500">Site name</dt>
|
<dt className="text-slate-500">Site name</dt>
|
||||||
@@ -41,7 +41,7 @@ export function ConfigPanel({ config }: ConfigPanelProps) {
|
|||||||
<dd className="mt-2 text-white">{config.app.timezone}</dd>
|
<dd className="mt-2 text-white">{config.app.timezone}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
|
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
|
||||||
<dt className="text-slate-500">Moduly live / analytics / history</dt>
|
<dt className="text-slate-500">Live / analytics / history modules</dt>
|
||||||
<dd className="mt-2 text-white">
|
<dd className="mt-2 text-white">
|
||||||
live: {String(config.capabilities.realtime_enabled)} / analytics: {String(config.capabilities.analytics_enabled)} / history: {String(config.capabilities.historical_import_enabled)}
|
live: {String(config.capabilities.realtime_enabled)} / analytics: {String(config.capabilities.analytics_enabled)} / history: {String(config.capabilities.historical_import_enabled)}
|
||||||
</dd>
|
</dd>
|
||||||
@@ -51,7 +51,7 @@ export function ConfigPanel({ config }: ConfigPanelProps) {
|
|||||||
|
|
||||||
{config.capabilities.historical_import_enabled ? <HistoricalImportPanel config={config} /> : null}
|
{config.capabilities.historical_import_enabled ? <HistoricalImportPanel config={config} /> : null}
|
||||||
|
|
||||||
<Card title="Mapowanie encji" subtitle="Tabela pomocnicza do dalszego doprecyzowania integracji" className="xl:col-span-2">
|
<Card title="Entity mapping" subtitle="Helper table for further integration tuning" className="xl:col-span-2">
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="min-w-full text-left text-sm text-slate-300">
|
<table className="min-w-full text-left text-sm text-slate-300">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -7,27 +7,77 @@ import { formatDate, formatDateTime, formatDurationShort, formatPercent, formatV
|
|||||||
|
|
||||||
interface HistoricalImportPanelProps { config: DashboardConfig; }
|
interface HistoricalImportPanelProps { config: DashboardConfig; }
|
||||||
|
|
||||||
function eventTone(level?: string): "ok" | "warn" | "critical" | "neutral" { if (level === "success") return "ok"; if (level === "warn") return "warn"; if (level === "error") return "critical"; return "neutral"; }
|
function eventTone(level?: string): "ok" | "warn" | "critical" | "neutral" {
|
||||||
function chunkTone(state?: string): "ok" | "warn" | "critical" | "neutral" { if (state === "completed") return "ok"; if (state === "running") return "warn"; if (state === "failed") return "critical"; return "neutral"; }
|
if (level === "success") return "ok";
|
||||||
|
if (level === "warn") return "warn";
|
||||||
|
if (level === "error") return "critical";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunkTone(state?: string): "ok" | "warn" | "critical" | "neutral" {
|
||||||
|
if (state === "completed") return "ok";
|
||||||
|
if (state === "running") return "warn";
|
||||||
|
if (state === "failed") return "critical";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
function StatCard({ label, value, helper }: { label: string; value: string; helper: string }) {
|
function StatCard({ label, value, helper }: { label: string; value: string; helper: string }) {
|
||||||
return <div className="rounded-2xl border border-white/10 bg-slate-950/35 p-4"><div className="text-xs uppercase tracking-[0.2em] text-slate-500">{label}</div><div className="mt-3 text-2xl font-semibold text-white">{value}</div><div className="mt-2 text-xs text-slate-500">{helper}</div></div>;
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-slate-950/35 p-4">
|
||||||
|
<div className="text-xs uppercase tracking-[0.2em] text-slate-500">{label}</div>
|
||||||
|
<div className="mt-3 text-2xl font-semibold text-white">{value}</div>
|
||||||
|
<div className="mt-2 text-xs text-slate-500">{helper}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChunkRow({ chunk, activeChunkIndex }: { chunk: HistoricalChunkProgress; activeChunkIndex: number }) {
|
function ChunkRow({ chunk, activeChunkIndex }: { chunk: HistoricalChunkProgress; activeChunkIndex: number }) {
|
||||||
const isActive = chunk.chunk_index === activeChunkIndex || chunk.state === "running";
|
const isActive = chunk.chunk_index === activeChunkIndex || chunk.state === "running";
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-white/10 bg-slate-950/35 p-4">
|
<div className="rounded-2xl border border-white/10 bg-slate-950/35 p-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3"><div><div className="text-sm font-medium text-white">Chunk {chunk.chunk_index}/{chunk.total_chunks}</div><div className="mt-1 text-xs text-slate-500">{formatDate(chunk.start_date)} - {formatDate(chunk.end_date)}</div></div><div className="flex items-center gap-2">{isActive ? <Badge tone="warn">aktywny</Badge> : null}<Badge tone={chunkTone(chunk.state)}>{chunk.state}</Badge></div></div>
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="mt-4 grid gap-3 text-sm sm:grid-cols-4"><div><div className="text-slate-500">Przetworzone</div><div className="mt-1 text-white">{chunk.processed_days}</div></div><div><div className="text-slate-500">Import</div><div className="mt-1 text-white">{chunk.imported_days}</div></div><div><div className="text-slate-500">Pominiete</div><div className="mt-1 text-white">{chunk.skipped_days}</div></div><div><div className="text-slate-500">Energia</div><div className="mt-1 text-white">{formatValue(chunk.energy_kwh, "kWh", 2)}</div></div></div>
|
<div>
|
||||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 text-xs text-slate-500"><span>{chunk.note}</span><span>{chunk.duration_seconds ? `czas ${formatDurationShort(chunk.duration_seconds)}` : "w toku"}</span></div>
|
<div className="text-sm font-medium text-white">Chunk {chunk.chunk_index}/{chunk.total_chunks}</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-500">{formatDate(chunk.start_date)} - {formatDate(chunk.end_date)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isActive ? <Badge tone="warn">active</Badge> : null}
|
||||||
|
<Badge tone={chunkTone(chunk.state)}>{chunk.state}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-3 text-sm sm:grid-cols-4">
|
||||||
|
<div><div className="text-slate-500">Processed</div><div className="mt-1 text-white">{chunk.processed_days}</div></div>
|
||||||
|
<div><div className="text-slate-500">Imported</div><div className="mt-1 text-white">{chunk.imported_days}</div></div>
|
||||||
|
<div><div className="text-slate-500">Skipped</div><div className="mt-1 text-white">{chunk.skipped_days}</div></div>
|
||||||
|
<div><div className="text-slate-500">Energy</div><div className="mt-1 text-white">{formatValue(chunk.energy_kwh, "kWh", 2)}</div></div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 text-xs text-slate-500">
|
||||||
|
<span>{chunk.note}</span>
|
||||||
|
<span>{chunk.duration_seconds ? `time ${formatDurationShort(chunk.duration_seconds)}` : "in progress"}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventRow({ event }: { event: HistoricalActivityEvent }) {
|
function EventRow({ event }: { event: HistoricalActivityEvent }) {
|
||||||
return (
|
return (
|
||||||
<div className="relative pl-5"><div className="absolute left-0 top-2 h-2.5 w-2.5 rounded-full bg-white/30" /><div className="rounded-2xl border border-white/10 bg-slate-950/35 p-4"><div className="flex flex-wrap items-center justify-between gap-3"><div className="flex items-center gap-2"><Badge tone={eventTone(event.level)}>{event.level}</Badge><div className="text-sm font-medium text-white">{event.title}</div></div><div className="text-xs text-slate-500">{formatDateTime(event.timestamp)}</div></div><div className="mt-2 text-sm text-slate-300">{event.message}</div><div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-500">{event.day ? <span>Dzien: {formatDate(event.day)}</span> : null}{event.chunk_index ? <span>Chunk: #{event.chunk_index}</span> : null}</div></div></div>
|
<div className="relative pl-5">
|
||||||
|
<div className="absolute left-0 top-2 h-2.5 w-2.5 rounded-full bg-white/30" />
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-slate-950/35 p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge tone={eventTone(event.level)}>{event.level}</Badge>
|
||||||
|
<div className="text-sm font-medium text-white">{event.title}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">{formatDateTime(event.timestamp)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-slate-300">{event.message}</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-500">
|
||||||
|
{event.day ? <span>Day: {formatDate(event.day)}</span> : null}
|
||||||
|
{event.chunk_index ? <span>Chunk: #{event.chunk_index}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,9 +89,14 @@ export function HistoricalImportPanel({ config }: HistoricalImportPanelProps) {
|
|||||||
const [chunkDays, setChunkDays] = useState(String(config.capabilities.history.default_chunk_days || 7));
|
const [chunkDays, setChunkDays] = useState(String(config.capabilities.history.default_chunk_days || 7));
|
||||||
const [force, setForce] = useState(false);
|
const [force, setForce] = useState(false);
|
||||||
|
|
||||||
useEffect(() => { setChunkDays(String(config.capabilities.history.default_chunk_days || 7)); }, [config.capabilities.history.default_chunk_days]);
|
useEffect(() => {
|
||||||
|
setChunkDays(String(config.capabilities.history.default_chunk_days || 7));
|
||||||
|
}, [config.capabilities.history.default_chunk_days]);
|
||||||
|
|
||||||
const progress = useMemo(() => { if (!payload || payload.total_days <= 0) return 0; return Math.min(100, Math.round((payload.processed_days / payload.total_days) * 100)); }, [payload]);
|
const progress = useMemo(() => {
|
||||||
|
if (!payload || payload.total_days <= 0) return 0;
|
||||||
|
return Math.min(100, Math.round((payload.processed_days / payload.total_days) * 100));
|
||||||
|
}, [payload]);
|
||||||
const visibleChunks = useMemo(() => [...(payload?.recent_chunks ?? [])].sort((l, r) => r.chunk_index - l.chunk_index), [payload?.recent_chunks]);
|
const visibleChunks = useMemo(() => [...(payload?.recent_chunks ?? [])].sort((l, r) => r.chunk_index - l.chunk_index), [payload?.recent_chunks]);
|
||||||
const visibleEvents = useMemo(() => [...(payload?.recent_events ?? [])].sort((l, r) => new Date(r.timestamp).getTime() - new Date(l.timestamp).getTime()), [payload?.recent_events]);
|
const visibleEvents = useMemo(() => [...(payload?.recent_events ?? [])].sort((l, r) => new Date(r.timestamp).getTime() - new Date(l.timestamp).getTime()), [payload?.recent_events]);
|
||||||
|
|
||||||
@@ -50,25 +105,76 @@ export function HistoricalImportPanel({ config }: HistoricalImportPanelProps) {
|
|||||||
const availableRangeReady = Boolean(payload?.available_start_date && payload?.available_end_date);
|
const availableRangeReady = Boolean(payload?.available_start_date && payload?.available_end_date);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="Import archiwalny z InfluxDB" subtitle="Mechanizm backfillu dzien po dniu z lokalnym cache SQLite, kontrola chunkow, ETA i lista ostatnich operacji" className="xl:col-span-2">
|
<Card title="Historical import from InfluxDB" subtitle="Day-by-day backfill with local SQLite cache, chunk control, ETA and recent activity list" className="xl:col-span-2">
|
||||||
<div className="grid gap-6 2xl:grid-cols-[1.15fr_0.85fr]"><div className="space-y-6">
|
<div className="grid gap-6 2xl:grid-cols-[1.15fr_0.85fr]">
|
||||||
|
<div className="space-y-6">
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<StatCard label="Postep" value={`${progress}%`} helper={`${payload?.processed_days ?? 0} / ${payload?.total_days ?? 0} dni`} />
|
<StatCard label="Progress" value={`${progress}%`} helper={`${payload?.processed_days ?? 0} / ${payload?.total_days ?? 0} days`} />
|
||||||
<StatCard label="Przepustowosc" value={formatValue(payload?.avg_days_per_minute ?? null, "dni/min", 1)} helper="Srednia szybkosc importu" />
|
<StatCard label="Throughput" value={formatValue(payload?.avg_days_per_minute ?? null, "days/min", 1)} helper="Average import speed" />
|
||||||
<StatCard label="ETA" value={formatDurationShort(payload?.estimated_remaining_seconds)} helper="Szacowany czas do konca" />
|
<StatCard label="ETA" value={formatDurationShort(payload?.estimated_remaining_seconds)} helper="Estimated time to completion" />
|
||||||
<StatCard label="Pokrycie" value={formatPercent(payload?.coverage?.coverage_pct ?? null)} helper={`${payload?.coverage?.missing_days ?? 0} brakujacych dni`} />
|
<StatCard label="Coverage" value={formatPercent(payload?.coverage?.coverage_pct ?? null)} helper={`${payload?.coverage?.missing_days ?? 0} missing days`} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[0.9fr_1.1fr]">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<label className="block rounded-2xl border border-white/10 bg-slate-950/30 p-4 text-sm text-slate-300">
|
||||||
|
<span className="mb-2 block text-slate-500">Start date</span>
|
||||||
|
<input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950 px-3 py-2 text-white outline-none" />
|
||||||
|
</label>
|
||||||
|
<label className="block rounded-2xl border border-white/10 bg-slate-950/30 p-4 text-sm text-slate-300">
|
||||||
|
<span className="mb-2 block text-slate-500">End date</span>
|
||||||
|
<input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950 px-3 py-2 text-white outline-none" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
|
||||||
|
<label className="block rounded-2xl border border-white/10 bg-slate-950/30 p-4 text-sm text-slate-300">
|
||||||
|
<span className="mb-2 block text-slate-500">Chunk (days)</span>
|
||||||
|
<input type="number" min={1} max={31} value={chunkDays} onChange={(e) => setChunkDays(e.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950 px-3 py-2 text-white outline-none" />
|
||||||
|
</label>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4 text-sm text-slate-300">
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} />
|
||||||
|
<span>Overwrite days already present in the historical cache</span>
|
||||||
|
</label>
|
||||||
|
<p className="mt-3 text-slate-500">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.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button type="button" disabled={busy} onClick={() => start.mutate({ start_date: startDate || undefined, end_date: endDate || undefined, chunk_days: Number(chunkDays || 7), force })} className="rounded-full bg-white px-5 py-2.5 text-sm font-medium text-slate-950 transition hover:bg-slate-200 disabled:cursor-not-allowed disabled:opacity-50">Start import</button>
|
||||||
|
<button type="button" disabled={busy} onClick={() => syncNow.mutate()} className="rounded-full bg-emerald-500/20 px-5 py-2.5 text-sm font-medium text-emerald-200 transition hover:bg-emerald-500/30 disabled:cursor-not-allowed disabled:opacity-50">Sync missing days</button>
|
||||||
|
<button type="button" disabled={!payload?.running || busy} onClick={() => cancel.mutate()} className="rounded-full bg-rose-500/20 px-5 py-2.5 text-sm font-medium text-rose-200 transition hover:bg-rose-500/30 disabled:cursor-not-allowed disabled:opacity-50">Cancel</button>
|
||||||
|
{availableRangeReady ? <button type="button" disabled={busy} onClick={() => { setStartDate(payload?.available_start_date ?? ""); setEndDate(payload?.available_end_date ?? ""); }} className="rounded-full bg-sky-500/15 px-5 py-2.5 text-sm font-medium text-sky-200 transition hover:bg-sky-500/25 disabled:cursor-not-allowed disabled:opacity-50">Use full history</button> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 lg:grid-cols-[0.9fr_1.1fr]"><div className="space-y-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2"><label className="block rounded-2xl border border-white/10 bg-slate-950/30 p-4 text-sm text-slate-300"><span className="mb-2 block text-slate-500">Data od</span><input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950 px-3 py-2 text-white outline-none" /></label><label className="block rounded-2xl border border-white/10 bg-slate-950/30 p-4 text-sm text-slate-300"><span className="mb-2 block text-slate-500">Data do</span><input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950 px-3 py-2 text-white outline-none" /></label></div>
|
|
||||||
<div className="grid gap-4 md:grid-cols-[220px_1fr]"><label className="block rounded-2xl border border-white/10 bg-slate-950/30 p-4 text-sm text-slate-300"><span className="mb-2 block text-slate-500">Chunk (dni)</span><input type="number" min={1} max={31} value={chunkDays} onChange={(e) => setChunkDays(e.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950 px-3 py-2 text-white outline-none" /></label><div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4 text-sm text-slate-300"><label className="flex items-center gap-3"><input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} /><span>Nadpisz dni juz obecne w cache historycznym</span></label><p className="mt-3 text-slate-500">Gdy pola dat sa puste, backend sam wykryje zakres do importu na podstawie pierwszej probki w InfluxDB i ostatniego dnia juz zapisanego w SQLite.</p></div></div>
|
|
||||||
<div className="flex flex-wrap gap-3"><button type="button" disabled={busy} onClick={() => start.mutate({ start_date: startDate || undefined, end_date: endDate || undefined, chunk_days: Number(chunkDays || 7), force })} className="rounded-full bg-white px-5 py-2.5 text-sm font-medium text-slate-950 transition hover:bg-slate-200 disabled:cursor-not-allowed disabled:opacity-50">Start importu</button><button type="button" disabled={busy} onClick={() => syncNow.mutate()} className="rounded-full bg-emerald-500/20 px-5 py-2.5 text-sm font-medium text-emerald-200 transition hover:bg-emerald-500/30 disabled:cursor-not-allowed disabled:opacity-50">Synchronizuj brakujace dni</button><button type="button" disabled={!payload?.running || busy} onClick={() => cancel.mutate()} className="rounded-full bg-rose-500/20 px-5 py-2.5 text-sm font-medium text-rose-200 transition hover:bg-rose-500/30 disabled:cursor-not-allowed disabled:opacity-50">Anuluj</button>{availableRangeReady ? <button type="button" disabled={busy} onClick={() => { setStartDate(payload?.available_start_date ?? ""); setEndDate(payload?.available_end_date ?? ""); }} className="rounded-full bg-sky-500/15 px-5 py-2.5 text-sm font-medium text-sky-200 transition hover:bg-sky-500/25 disabled:cursor-not-allowed disabled:opacity-50">Ustaw pelna historie</button> : null}</div>
|
|
||||||
{mutationError ? <div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 p-4 text-sm text-rose-200">{mutationError}</div> : null}
|
{mutationError ? <div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 p-4 text-sm text-rose-200">{mutationError}</div> : null}
|
||||||
{status.error ? <div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 p-4 text-sm text-rose-200">{status.error.message}</div> : null}
|
{status.error ? <div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 p-4 text-sm text-rose-200">{status.error.message}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-3xl border border-white/10 bg-slate-950/35 p-5"><div className="flex flex-wrap items-start justify-between gap-3"><div><div className="text-sm font-medium text-white">Operacyjny status zadania</div><div className="mt-1 text-xs text-slate-500">{payload?.message || "Brak aktywnego zadania"}</div></div><div className="flex items-center gap-2">{payload?.job_id ? <Badge tone="neutral">job {payload.job_id}</Badge> : null}<Badge tone={payload?.running ? "warn" : payload?.state === "failed" ? "critical" : "neutral"}>{payload?.running ? "w trakcie" : payload?.state || "idle"}</Badge></div></div><div className="mt-5 h-3 overflow-hidden rounded-full bg-white/8"><div className="h-full rounded-full bg-gradient-to-r from-emerald-300 via-sky-400 to-cyan-400 transition-all" style={{ width: `${progress}%` }} /></div><div className="mt-2 flex items-center justify-between text-xs text-slate-500"><span>Chunk {payload?.active_chunk_index ?? 0} / {payload?.total_chunks ?? 0}</span><span>{progress}%</span></div>
|
<div className="rounded-3xl border border-white/10 bg-slate-950/35 p-5">
|
||||||
<div className="mt-5 grid gap-4 text-sm text-slate-300 md:grid-cols-2"><div className="rounded-2xl border border-white/10 bg-slate-900/70 p-4"><div className="text-slate-500">Dostepny zakres w InfluxDB</div><div className="mt-2 text-white">{formatDate(payload?.available_start_date)} - {formatDate(payload?.available_end_date)}</div><div className="mt-2 text-xs text-slate-500">{payload?.coverage?.available_days ?? 0} dni wykrytego archiwum</div></div><div className="rounded-2xl border border-white/10 bg-slate-900/70 p-4"><div className="text-slate-500">Zakres zapisany lokalnie</div><div className="mt-2 text-white">{formatDate(payload?.coverage?.first_day)} - {formatDate(payload?.coverage?.last_day)}</div><div className="mt-2 text-xs text-slate-500">{payload?.coverage?.imported_days ?? 0} dni w cache, {formatValue(payload?.coverage?.total_energy_kwh ?? 0, "kWh", 1)}</div></div><div className="rounded-2xl border border-white/10 bg-slate-900/70 p-4"><div className="text-slate-500">Aktualny chunk</div><div className="mt-2 text-white">{formatDate(payload?.current_chunk_start)} - {formatDate(payload?.current_chunk_end)}</div><div className="mt-2 text-xs text-slate-500">Ostatni dzien: {formatDate(payload?.current_date)}</div></div><div className="rounded-2xl border border-white/10 bg-slate-900/70 p-4"><div className="text-slate-500">Czasy i opoznienia</div><div className="mt-2 text-white">elapsed {formatDurationShort(payload?.elapsed_seconds)}</div><div className="mt-2 text-xs text-slate-500">start {formatDateTime(payload?.started_at)} / koniec {formatDateTime(payload?.finished_at)}</div>{payload?.last_error ? <div className="mt-3 text-xs text-rose-200">Blad: {payload.last_error}</div> : null}</div></div></div></div>
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"><Card title="Lista chunkow" subtitle="Najswiezsze zakresy z liczba zaimportowanych dni i energia na chunk"><div className="space-y-3">{visibleChunks.length ? visibleChunks.map((chunk) => <ChunkRow key={chunk.chunk_index} chunk={chunk} activeChunkIndex={payload?.active_chunk_index ?? 0} />) : <div className="rounded-2xl border border-dashed border-white/10 bg-slate-950/20 p-6 text-sm text-slate-400">Lista chunkow pojawi sie po uruchomieniu pierwszego backfillu.</div>}</div></Card><Card title="Ostatnie operacje" subtitle="Operator widzi ostatnie dni, chunki i ewentualne ostrzezenia bez wchodzenia do logow backendu"><div className="space-y-3 border-l border-white/10 pl-4">{visibleEvents.length ? visibleEvents.map((event, index) => <EventRow key={`${event.timestamp}-${index}`} event={event} />) : <div className="rounded-2xl border border-dashed border-white/10 bg-slate-950/20 p-6 text-sm text-slate-400">Historia operacji pojawi sie po starcie zadania.</div>}</div></Card></div>
|
<div>
|
||||||
</div></div>
|
<div className="text-sm font-medium text-white">Operational task status</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-500">{payload?.message || "No active task"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{payload?.job_id ? <Badge tone="neutral">job {payload.job_id}</Badge> : null}
|
||||||
|
<Badge tone={payload?.running ? "warn" : payload?.state === "failed" ? "critical" : "neutral"}>{payload?.running ? "running" : payload?.state || "idle"}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 h-3 overflow-hidden rounded-full bg-white/8"><div className="h-full rounded-full bg-gradient-to-r from-emerald-300 via-sky-400 to-cyan-400 transition-all" style={{ width: `${progress}%` }} /></div>
|
||||||
|
<div className="mt-2 flex items-center justify-between text-xs text-slate-500"><span>Chunk {payload?.active_chunk_index ?? 0} / {payload?.total_chunks ?? 0}</span><span>{progress}%</span></div>
|
||||||
|
<div className="mt-5 grid gap-4 text-sm text-slate-300 md:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-slate-900/70 p-4"><div className="text-slate-500">Available range in InfluxDB</div><div className="mt-2 text-white">{formatDate(payload?.available_start_date)} - {formatDate(payload?.available_end_date)}</div><div className="mt-2 text-xs text-slate-500">{payload?.coverage?.available_days ?? 0} detected archive days</div></div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-slate-900/70 p-4"><div className="text-slate-500">Range stored locally</div><div className="mt-2 text-white">{formatDate(payload?.coverage?.first_day)} - {formatDate(payload?.coverage?.last_day)}</div><div className="mt-2 text-xs text-slate-500">{payload?.coverage?.imported_days ?? 0} days in cache, {formatValue(payload?.coverage?.total_energy_kwh ?? 0, "kWh", 1)}</div></div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-slate-900/70 p-4"><div className="text-slate-500">Current chunk</div><div className="mt-2 text-white">{formatDate(payload?.current_chunk_start)} - {formatDate(payload?.current_chunk_end)}</div><div className="mt-2 text-xs text-slate-500">Last day: {formatDate(payload?.current_date)}</div></div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-slate-900/70 p-4"><div className="text-slate-500">Timings and delays</div><div className="mt-2 text-white">elapsed {formatDurationShort(payload?.elapsed_seconds)}</div><div className="mt-2 text-xs text-slate-500">start {formatDateTime(payload?.started_at)} / finish {formatDateTime(payload?.finished_at)}</div>{payload?.last_error ? <div className="mt-3 text-xs text-rose-200">Error: {payload.last_error}</div> : null}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
||||||
|
<Card title="Chunk list" subtitle="Latest ranges with imported days count and energy per chunk"><div className="space-y-3">{visibleChunks.length ? visibleChunks.map((chunk) => <ChunkRow key={chunk.chunk_index} chunk={chunk} activeChunkIndex={payload?.active_chunk_index ?? 0} />) : <div className="rounded-2xl border border-dashed border-white/10 bg-slate-950/20 p-6 text-sm text-slate-400">The chunk list will appear after the first backfill starts.</div>}</div></Card>
|
||||||
|
<Card title="Recent operations" subtitle="Recent days, chunks and warnings without opening backend logs"><div className="space-y-3 border-l border-white/10 pl-4">{visibleEvents.length ? visibleEvents.map((event, index) => <EventRow key={`${event.timestamp}-${index}`} event={event} />) : <div className="rounded-2xl border border-dashed border-white/10 bg-slate-950/20 p-6 text-sm text-slate-400">Operation history will appear after the task starts.</div>}</div></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+21
-21
@@ -53,21 +53,21 @@ export const demoConfig: DashboardConfig = {
|
|||||||
{ key: "ytd", label: "YTD" },
|
{ key: "ytd", label: "YTD" },
|
||||||
],
|
],
|
||||||
buckets: [
|
buckets: [
|
||||||
{ key: "day", label: "Dzien" },
|
{ key: "day", label: "Dzień" },
|
||||||
{ key: "week", label: "Tydzien" },
|
{ key: "week", label: "Tydzień" },
|
||||||
{ key: "month", label: "Miesiac" },
|
{ key: "month", label: "Miesiąc" },
|
||||||
{ key: "year", label: "Rok" },
|
{ key: "year", label: "Rok" },
|
||||||
],
|
],
|
||||||
historical_import_enabled: true,
|
historical_import_enabled: true,
|
||||||
history: { enabled: true, default_chunk_days: 7, auto_sync_enabled: true, auto_sync_interval_minutes: 30 },
|
history: { enabled: true, default_chunk_days: 7, auto_sync_enabled: true, auto_sync_interval_minutes: 30 },
|
||||||
},
|
},
|
||||||
visible_entities: [
|
visible_entities: [
|
||||||
{ metric_id: "ac_power", label: "Moc AC calkowita", entity_id: "sofarsolar_ac_power", measurement: "W", unit: "W", kind: "gauge" },
|
{ 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 total", entity_id: "sofarsolar_energy_total", measurement: "kWh", unit: "kWh", kind: "counter" },
|
{ 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_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_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" },
|
{ 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" },
|
{ metric_id: "inverter_temp", label: "Temp. falownika", value: 47.3, unit: "°C", accent: "rose", subtitle: "Live status termiczny" },
|
||||||
],
|
],
|
||||||
kpis: {
|
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" },
|
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" },
|
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 total", unit: "W", value: 6760, 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 total", unit: "kWh", value: 18264.3, precision: 1, kind: "counter", status: "ok" },
|
energy_total: { metric_id: "energy_total", label: "Energia całkowita", unit: "kWh", value: 18264.3, precision: 1, kind: "counter", status: "ok" },
|
||||||
},
|
},
|
||||||
strings: [
|
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: "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 / 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: "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: [],
|
phases: [],
|
||||||
status: [
|
status: [
|
||||||
{ metric_id: "inverter_temp", label: "Temperatura falownika", unit: "°C", value: 47.3, precision: 1, kind: "gauge", status: "ok" },
|
{ metric_id: "inverter_temp", label: "Temperatura falownika", unit: "°C", value: 47.3, precision: 1, kind: "gauge", status: "ok" },
|
||||||
{ metric_id: "data_freshness", label: "Swiezosc danych", unit: "", value: "3 s temu", precision: 0, kind: "text", status: "ok" },
|
{ metric_id: "data_freshness", label: "Świeżość danych", unit: "", value: "3 s temu", precision: 0, kind: "text", status: "ok" },
|
||||||
],
|
],
|
||||||
faults: [],
|
faults: [],
|
||||||
});
|
});
|
||||||
@@ -110,8 +110,8 @@ export const demoHistory: HistoryPayload = {
|
|||||||
end: isoAt(0),
|
end: isoAt(0),
|
||||||
series: [
|
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: "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_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", unit: "W", points: historyPoints([0, 40, 240, 520, 880, 1260, 1930, 2530, 3310, 3200, 2790, 2410]) },
|
{ 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]) },
|
{ 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",
|
unit: "kWh",
|
||||||
bucket: "day",
|
bucket: "day",
|
||||||
total: 289.5,
|
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 = {
|
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" },
|
{ 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: [
|
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.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 dzien", message: "Zaimportowano 2025-01-07 (12.84 kWh). Energia: 12.84 kWh.", day: "2025-01-07", 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 dzien", message: "Zaimportowano 2025-01-06 (12.56 kWh). Energia: 12.56 kWh.", day: "2025-01-06", 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 dzien", message: "Zaimportowano 2025-01-05 (12.90 kWh). Energia: 12.90 kWh.", day: "2025-01-05", 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(-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 },
|
{ 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 },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ import { api } from "../api/client";
|
|||||||
export function useRealtimeHistory(
|
export function useRealtimeHistory(
|
||||||
rangeKey: string,
|
rangeKey: string,
|
||||||
enabled = true,
|
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({
|
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),
|
queryFn: () => api.getRealtimeHistory(rangeKey, options),
|
||||||
staleTime: 20 * 1000,
|
staleTime: autoRefreshEnabled ? 20 * 1000 : Infinity,
|
||||||
refetchInterval: options?.start || options?.end ? false : 30 * 1000,
|
refetchInterval: autoRefreshEnabled ? 30 * 1000 : false,
|
||||||
|
refetchOnWindowFocus: autoRefreshEnabled,
|
||||||
|
refetchOnReconnect: autoRefreshEnabled,
|
||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -38,6 +38,8 @@ const messages = {
|
|||||||
chartProduction: "Produkcja",
|
chartProduction: "Produkcja",
|
||||||
chartProductionSubtitle: "Agregacja w wybranym bucketcie",
|
chartProductionSubtitle: "Agregacja w wybranym bucketcie",
|
||||||
chartComparison: "Porównanie okresów",
|
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",
|
chartDistribution: "Rozkład produkcji",
|
||||||
currentPeriod: "Bieżący okres",
|
currentPeriod: "Bieżący okres",
|
||||||
comparisonPeriod: "Porównanie",
|
comparisonPeriod: "Porównanie",
|
||||||
@@ -145,6 +147,8 @@ const messages = {
|
|||||||
chartProduction: "Production",
|
chartProduction: "Production",
|
||||||
chartProductionSubtitle: "Aggregated by selected bucket",
|
chartProductionSubtitle: "Aggregated by selected bucket",
|
||||||
chartComparison: "Period comparison",
|
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",
|
chartDistribution: "Production distribution",
|
||||||
currentPeriod: "Current period",
|
currentPeriod: "Current period",
|
||||||
comparisonPeriod: "Comparison",
|
comparisonPeriod: "Comparison",
|
||||||
@@ -227,6 +231,14 @@ const metricLabels: Record<string, { pl: string; en: string }> = {
|
|||||||
today_vs_yesterday: { pl: "Dziś vs wczoraj", en: "Today vs yesterday" },
|
today_vs_yesterday: { pl: "Dziś vs wczoraj", en: "Today vs yesterday" },
|
||||||
dc_power_total: { pl: "Moc DC łącznie", en: "Total DC power" },
|
dc_power_total: { pl: "Moc DC łącznie", en: "Total DC power" },
|
||||||
inverter_temp: { pl: "Temperatura falownika", en: "Inverter temperature" },
|
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 {
|
export function t(language: Language, key: MessageKey): string {
|
||||||
|
|||||||
@@ -165,6 +165,10 @@ body {
|
|||||||
align-items: end;
|
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 {
|
.pv-filter-grid-archive {
|
||||||
grid-template-columns: minmax(240px, 1.35fr) minmax(180px, 0.9fr) repeat(2, minmax(160px, 1fr));
|
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) {
|
@media (max-width: 992px) {
|
||||||
.pv-filter-grid,
|
.pv-filter-grid,
|
||||||
|
.pv-filter-grid-live,
|
||||||
.pv-filter-grid-archive {
|
.pv-filter-grid-archive {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -247,6 +252,25 @@ body {
|
|||||||
min-width: 1.1rem;
|
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 {
|
.pv-menu-meta {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -254,11 +278,354 @@ body {
|
|||||||
color: var(--tblr-secondary);
|
color: var(--tblr-secondary);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.pv-subnav-side {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
@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 {
|
.pv-menu-meta {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
white-space: normal;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-3
@@ -2,14 +2,22 @@ import { defineConfig } from "vite";
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
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({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
port: 5173
|
port: 5173,
|
||||||
|
headers: noStoreHeaders,
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
port: 4173
|
port: 4173,
|
||||||
}
|
headers: noStoreHeaders,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user