poprawki i zmiany ux

This commit is contained in:
Mateusz Gruszczyński
2026-03-26 09:30:39 +01:00
parent fd0f645251
commit 138059945e
28 changed files with 1000 additions and 225 deletions

53
ZMIANY_WDROZONE.txt Normal file
View File

@@ -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.

View File

@@ -101,7 +101,8 @@ def create_app() -> Flask:
@app.after_request
def append_cors_headers(response):
return _apply_cors(response)
response = _apply_cors(response)
return _apply_cache_headers(response)
@app.errorhandler(HTTPException)
def handle_http_exception(exc: HTTPException):
@@ -123,7 +124,7 @@ def _register_cli_commands(app: Flask) -> None:
auth_service = get_auth_service()
@app.cli.command("create-admin")
@click.option("--username", required=True, help="Login")
@click.option("--username", required=True, help="Username")
@click.option("--password", required=True, hide_input=True, confirmation_prompt=True, help="Password")
@click.option("--display-name", default=None, help="Name")
def create_admin_command(username: str, password: str, display_name: str | None):
@@ -134,14 +135,14 @@ def _register_cli_commands(app: Flask) -> None:
role="admin",
display_name=display_name,
)
click.echo(f"Utworzono konto admina: {user.username}")
click.echo(f"Admin account created: {user.username}")
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
except Exception as exc: # pragma: no cover
raise click.ClickException(f"Cant create admin account: {exc}") from exc
raise click.ClickException(f"Cannot create admin account: {exc}") from exc
@app.cli.command("create-user")
@click.option("--username", required=True, help="Login")
@click.option("--username", required=True, help="Username")
@click.option("--password", required=True, hide_input=True, confirmation_prompt=True, help="Password")
@click.option("--display-name", default=None, help="Name")
def create_user_command(username: str, password: str, display_name: str | None):
@@ -152,23 +153,23 @@ def _register_cli_commands(app: Flask) -> None:
role="user",
display_name=display_name,
)
click.echo(f"Admin created: {user.username}")
click.echo(f"User account created: {user.username}")
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
except Exception as exc: # pragma: no cover
raise click.ClickException(f"Cant create user account: {exc}") from exc
raise click.ClickException(f"Cannot create user account: {exc}") from exc
@app.cli.command("reset-password")
@click.option("--username", required=True, help="Login")
@click.option("--username", required=True, help="Username")
@click.option("--password", required=True, hide_input=True, confirmation_prompt=True, help="Password")
def reset_password_command(username: str, password: str):
try:
user = auth_service.reset_password(username=username, new_password=password)
click.echo(f"Passowrd reseted for: {user.username}")
click.echo(f"Password reset for: {user.username}")
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
except Exception as exc: # pragma: no cover
raise click.ClickException(f"Can't password reset: {exc}") from exc
raise click.ClickException(f"Cannot reset password: {exc}") from exc
def _bootstrap_background_services(debug: bool) -> None:
@@ -191,3 +192,11 @@ def _apply_cors(response):
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, PUT"
response.headers["Access-Control-Allow-Credentials"] = "true"
return response
def _apply_cache_headers(response):
if request.path == "/" or request.path.startswith("/api/"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, proxy-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response

View File

@@ -78,3 +78,21 @@ def reset_password(username: str):
return jsonify({"detail": str(exc)}), 403
except ValueError as exc:
return jsonify({"detail": str(exc)}), 400
@auth_blueprint.put("/auth/users/<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

View File

@@ -25,11 +25,11 @@ def _resolve_kiosk_mode(requested_mode: str, require_write_access: bool = False)
if normalized_mode == "public":
if require_write_access and auth_service.enabled and session.get("auth_role") != "admin":
raise PermissionError("Brak uprawnien do edycji publicznego kiosku")
raise PermissionError("You do not have permission to edit the public kiosk")
return "public", "public"
if normalized_mode != "private":
raise ValueError("Mode musi byc jednym z: public, private")
raise ValueError("Mode must be one of: public, private")
if not auth_service.enabled:
return "private", "private"

View File

@@ -55,9 +55,9 @@ class AuthService:
self._login_legacy_user(username, password)
else:
if not user.is_active:
raise ValueError("Konto jest nieaktywne")
raise ValueError("Account is inactive")
if not check_password_hash(user.password_hash, password):
raise ValueError("Niepoprawny login lub haslo")
raise ValueError("Invalid username or password")
self._set_session(user.username, user.display_name, user.role)
return self.status()
@@ -84,7 +84,7 @@ class AuthService:
if not self.enabled:
return
if session.get(SESSION_ROLE_KEY) != "admin":
raise PermissionError("Brak uprawnien administratora")
raise PermissionError("Administrator permissions are required")
def configure_app(self, app) -> None:
max_age = int(self.settings.auth["session_max_age_seconds"])
@@ -101,7 +101,7 @@ class AuthService:
clean_password = self._validate_password(password)
resolved_display_name = (display_name or normalized_username).strip()
if not resolved_display_name:
raise ValueError("Display name nie moze byc pusty")
raise ValueError("Display name cannot be empty")
return self.user_repository.upsert_user(
username=normalized_username,
password_hash=generate_password_hash(clean_password),
@@ -118,16 +118,33 @@ class AuthService:
generate_password_hash(clean_password),
)
if user is None:
raise ValueError(f"Uzytkownik '{normalized_username}' nie istnieje")
raise ValueError(f"User '{normalized_username}' does not exist")
return user
def update_role(self, *, username: str, role: str) -> AuthUser:
normalized_username = self._normalize_username(username)
normalized_role = self._normalize_role(role)
user = self.user_repository.get_by_username(normalized_username)
if user is None:
raise ValueError(f"User '{normalized_username}' does not exist")
if user.role == normalized_role:
return user
if user.role == 'admin' and normalized_role != 'admin' and self.user_repository.count_admin_users() <= 1:
raise ValueError('At least one active admin user must remain')
updated = self.user_repository.update_role(normalized_username, normalized_role)
if updated is None:
raise ValueError(f"User '{normalized_username}' does not exist")
if session.get(SESSION_USER_KEY) == updated.username:
session[SESSION_ROLE_KEY] = updated.role
return updated
def _login_legacy_user(self, username: str, password: str) -> None:
expected_username = self.settings.auth["username"]
expected_password = self.settings.auth["password"]
expected_password_hash = self.settings.auth.get("password_hash")
if username != expected_username:
raise ValueError("Niepoprawny login lub haslo")
raise ValueError("Invalid username or password")
if expected_password_hash:
password_ok = check_password_hash(expected_password_hash, password)
@@ -135,7 +152,7 @@ class AuthService:
password_ok = password == expected_password
if not password_ok:
raise ValueError("Niepoprawny login lub haslo")
raise ValueError("Invalid username or password")
self._set_session(
expected_username,
@@ -153,19 +170,19 @@ class AuthService:
def _normalize_username(self, username: str) -> str:
normalized = (username or "").strip()
if not normalized:
raise ValueError("Username nie moze byc pusty")
raise ValueError("Username cannot be empty")
return normalized
def _normalize_role(self, role: str) -> str:
normalized = (role or "").strip().lower()
if normalized not in VALID_ROLES:
raise ValueError("Rola musi byc jedna z: admin, user")
raise ValueError("Role must be one of: admin, user")
return normalized
def _validate_password(self, password: str) -> str:
clean_password = password or ""
if len(clean_password) < 8:
raise ValueError("Haslo musi miec co najmniej 8 znakow")
raise ValueError("Password must be at least 8 characters long")
return clean_password

View File

@@ -82,7 +82,7 @@ class HistoricalSyncService:
with self._state_lock:
self._state.running = False
self._state.state = "idle"
self._state.message = "Brak brakujacych dni do importu."
self._state.message = "There are no missing days to import."
self._state.finished_at = datetime.utcnow()
self._refresh_coverage(lock_held=True)
self._refresh_available_bounds(lock_held=True)
@@ -92,7 +92,7 @@ class HistoricalSyncService:
resolved_start, resolved_end = resolved
total_days = (resolved_end - resolved_start).days + 1
total_chunks = max(ceil(total_days / chunk_days), 1)
start_message = "Start importu archiwalnego" if not auto else "Start automatycznej synchronizacji archiwum"
start_message = "Historical import started" if not auto else "Automatic historical sync started"
with self._state_lock:
if self._worker and self._worker.is_alive():
@@ -138,18 +138,18 @@ class HistoricalSyncService:
self._record_event(
level="info",
title="Uruchomiono zadanie",
message=f"Zakres {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk {chunk_days} dni",
title="Job started",
message=f"Range {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk size {chunk_days} days",
)
return self.status()
def cancel(self) -> HistoricalImportStatus:
self._cancel_event.set()
with self._state_lock:
self._state.message = "Anulowanie zadania..."
self._state.message = "Cancelling job..."
self._refresh_runtime_metrics(lock_held=True)
snapshot = copy.deepcopy(self._state)
self._record_event(level="warn", title="Anulowanie", message="Uzytkownik poprosil o zatrzymanie zadania.")
self._record_event(level="warn", title="Cancellation requested", message="The user requested the job to stop.")
return snapshot
def run_blocking(
@@ -185,8 +185,8 @@ class HistoricalSyncService:
)
self._record_event(
level="info",
title="Uruchomiono zadanie",
message=f"Zakres {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk {chunk_days} dni",
title="Job started",
message=f"Range {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk size {chunk_days} days",
)
self._run_worker(
start_date=resolved_start,
@@ -239,8 +239,8 @@ class HistoricalSyncService:
chunk_start = start_date
while chunk_start <= end_date:
if self._cancel_event.is_set():
self._record_event(level="warn", title="Anulowano", message="Import archiwalny anulowany przez uzytkownika.")
self._finish("cancelled", running=False, message="Import archiwalny anulowany przez uzytkownika.")
self._record_event(level="warn", title="Cancelled", message="Historical import was cancelled by the user.")
self._finish("cancelled", running=False, message="Historical import was cancelled by the user.")
return
chunk_index += 1
@@ -259,10 +259,10 @@ class HistoricalSyncService:
skipped_days=skipped,
energy_kwh=energy_kwh,
state="cancelled",
note="Chunk zatrzymany podczas przetwarzania",
note="Chunk stopped during processing",
)
self._record_event(level="warn", title="Anulowano", message="Import archiwalny anulowany przez uzytkownika.")
self._finish("cancelled", running=False, message="Import archiwalny anulowany przez uzytkownika.")
self._record_event(level="warn", title="Cancelled", message="Historical import was cancelled by the user.")
self._finish("cancelled", running=False, message="Historical import was cancelled by the user.")
return
self._close_chunk(
@@ -271,23 +271,23 @@ class HistoricalSyncService:
skipped_days=skipped,
energy_kwh=energy_kwh,
state="completed",
note=f"Chunk zakonczony: import {imported}, pominiete {skipped}",
note=f"Chunk completed: imported {imported}, skipped {skipped}",
)
self._record_event(
level="success",
title=f"Chunk {chunk_index}/{total_chunks} zakonczony",
message=f"Zakres {chunk_start.isoformat()} -> {chunk_end.isoformat()}, import {imported}, pominiete {skipped}, energia {energy_kwh:.2f} kWh",
title=f"Chunk {chunk_index}/{total_chunks} completed",
message=f"Range {chunk_start.isoformat()} -> {chunk_end.isoformat()}, imported {imported}, skipped {skipped}, energy {energy_kwh:.2f} kWh",
chunk_index=chunk_index,
)
chunk_start = chunk_end + timedelta(days=1)
final_message = "Synchronizacja archiwalna zakonczona" if auto else "Import archiwalny zakonczony"
self._record_event(level="success", title="Zakonczono", message=final_message)
final_message = "Historical synchronization completed" if auto else "Historical import completed"
self._record_event(level="success", title="Completed", message=final_message)
self._finish("completed", running=False, message=final_message)
except Exception as exc:
logger.exception("Historical import failed")
self._record_event(level="error", title="Blad importu", message=str(exc))
self._finish("failed", running=False, message="Import archiwalny zakonczyl sie bledem.", last_error=str(exc))
self._record_event(level="error", title="Import error", message=str(exc))
self._finish("failed", running=False, message="Historical import finished with an error.", last_error=str(exc))
def _process_chunk(self, *, chunk_index: int, start_day: date, end_day: date, force: bool) -> tuple[int, int, float, bool]:
imported_days = 0
@@ -303,9 +303,9 @@ class HistoricalSyncService:
self._advance_day(
day,
imported=False,
message=f"Pominieto {day.isoformat()} - dzien juz istnieje w cache",
message=f"Skipped {day.isoformat()} - day already exists in cache",
level="warn",
title="Pominieto dzien",
title="Day skipped",
chunk_index=chunk_index,
)
continue
@@ -316,9 +316,9 @@ class HistoricalSyncService:
self._advance_day(
day,
imported=False,
message=f"Pominieto {day.isoformat()} - brak probek w InfluxDB",
message=f"Skipped {day.isoformat()} - no samples in InfluxDB",
level="warn",
title="Brak probek",
title="No samples",
chunk_index=chunk_index,
)
continue
@@ -336,9 +336,9 @@ class HistoricalSyncService:
self._advance_day(
day,
imported=True,
message=f"Zaimportowano {day.isoformat()} ({total:.2f} kWh)",
message=f"Imported {day.isoformat()} ({total:.2f} kWh)",
level="success",
title="Zaimportowano dzien",
title="Day imported",
chunk_index=chunk_index,
energy_kwh=total,
)
@@ -366,7 +366,7 @@ class HistoricalSyncService:
self._state.message = message
self._refresh_coverage(lock_held=True)
self._refresh_runtime_metrics(lock_held=True)
suffix = f" Energia: {energy_kwh:.2f} kWh." if imported and energy_kwh is not None else ""
suffix = f" Energy: {energy_kwh:.2f} kWh." if imported and energy_kwh is not None else ""
self._record_event(
level=level,
title=title,
@@ -383,19 +383,19 @@ class HistoricalSyncService:
end_date=chunk_end,
state="running",
started_at=datetime.utcnow(),
note=f"Aktywny chunk {chunk_start.isoformat()} -> {chunk_end.isoformat()}",
note=f"Active chunk {chunk_start.isoformat()} -> {chunk_end.isoformat()}",
)
with self._state_lock:
self._state.current_chunk_start = chunk_start
self._state.current_chunk_end = chunk_end
self._state.active_chunk_index = chunk_index
self._state.message = f"Przetwarzanie zakresu {chunk_start.isoformat()} -> {chunk_end.isoformat()}"
self._state.message = f"Processing range {chunk_start.isoformat()} -> {chunk_end.isoformat()}"
self._upsert_chunk_locked(chunk)
self._refresh_runtime_metrics(lock_held=True)
self._record_event(
level="info",
title=f"Chunk {chunk_index}/{total_chunks}",
message=f"Start zakresu {chunk_start.isoformat()} -> {chunk_end.isoformat()}",
message=f"Starting range {chunk_start.isoformat()} -> {chunk_end.isoformat()}",
chunk_index=chunk_index,
)

View File

@@ -77,7 +77,7 @@ class KioskSettingsService:
return normalized
if normalized.startswith(USER_MODE_PREFIX) and len(normalized) > len(USER_MODE_PREFIX):
return normalized
raise ValueError("Mode musi byc jednym z: public, private")
raise ValueError("Mode must be one of: public, private")
def _normalize_widgets(self, widgets: Any) -> list[str]:
if not isinstance(widgets, list):

View File

@@ -50,34 +50,34 @@ class RealtimeService:
inverter_temp = to_float(_value(latest, "inverter_temp"))
hero_cards = [
self._hero_card("ac_power", ac_power, subtitle="Aktualna moc AC"),
self._hero_card("dc_power_total", total_dc_power, label="Moc DC laczna", unit="W", subtitle="Suma stringow DC"),
self._hero_card("energy_today", energy_today, label="Energia dzis", unit="kWh", subtitle="Liczona z danych Influx"),
self._hero_card("energy_total", total_energy, label="Energia laczna", unit="kWh", subtitle="Licznik calkowity"),
self._hero_card("ac_power", ac_power, subtitle="Current AC power"),
self._hero_card("dc_power_total", total_dc_power, label="Total DC power", unit="W", subtitle="Sum of DC strings"),
self._hero_card("energy_today", energy_today, label="Energy today", unit="kWh", subtitle="Calculated from Influx data"),
self._hero_card("energy_total", total_energy, label="Total energy", unit="kWh", subtitle="Lifetime counter"),
]
if inverter_temp is not None:
hero_cards.append(self._hero_card("inverter_temp", inverter_temp, label="Temp. falownika", unit="°C", subtitle="Sensor opcjonalny"))
hero_cards.append(self._hero_card("inverter_temp", inverter_temp, label="Inverter temperature", unit="°C", subtitle="Optional sensor"))
kpis = {
"energy_today": custom_metric_value("energy_today", "Energia dzis", energy_today, unit="kWh", precision=2, status="ok"),
"energy_yesterday": custom_metric_value("energy_yesterday", "Energia wczoraj", energy_yesterday, unit="kWh", precision=2, status="ok"),
"energy_today": custom_metric_value("energy_today", "Energy today", energy_today, unit="kWh", precision=2, status="ok"),
"energy_yesterday": custom_metric_value("energy_yesterday", "Energy yesterday", energy_yesterday, unit="kWh", precision=2, status="ok"),
"energy_total": custom_metric_value(
"energy_total",
"Energia laczna",
"Total energy",
total_energy,
unit="kWh",
precision=2,
timestamp=_timestamp(latest, "energy_total"),
status="ok",
),
"dc_power_total": custom_metric_value("dc_power_total", "Moc DC laczna", total_dc_power, unit="W", precision=0, status="ok"),
"dc_power_total": custom_metric_value("dc_power_total", "Total DC power", total_dc_power, unit="W", precision=0, status="ok"),
}
comparison = compare_delta_pct(energy_today, energy_yesterday)
if comparison is not None:
kpis["today_vs_yesterday"] = custom_metric_value(
"today_vs_yesterday",
"Dzis vs wczoraj",
"Today vs yesterday",
comparison,
unit="%",
precision=2,
@@ -97,7 +97,7 @@ class RealtimeService:
status.append(
custom_metric_value(
"data_refresh",
"Ostatni odczyt energii",
"Last energy reading",
_timestamp(latest, "energy_total").isoformat() if _timestamp(latest, "energy_total") else None,
status="ok" if _timestamp(latest, "energy_total") else "neutral",
kind="text",
@@ -144,7 +144,7 @@ class RealtimeService:
series.append(
{
"metric_id": metric.id,
"label": metric.label if slot != "power" else group["label"],
"label": f"{group['label']} power" if slot == "power" else f"{group['label']} voltage" if slot == "voltage" else metric.label,
"unit": metric.unit,
"points": self.influx.gauge_history(metric, window.start, window.end, interval=interval, aggregate="mean"),
}

View File

@@ -108,6 +108,23 @@ class SQLiteAuthUserRepository:
return None
return self.get_by_username(username)
def update_role(self, username: str, role: str) -> AuthUser | None:
now = datetime.utcnow().isoformat()
with self.connect() as conn:
cursor = conn.execute(
"UPDATE auth_users SET role = ?, updated_at = ? WHERE username = ?",
(role, now, username),
)
if cursor.rowcount == 0:
return None
return self.get_by_username(username)
def count_admin_users(self) -> int:
with self.connect() as conn:
row = conn.execute(
"SELECT COUNT(*) AS count FROM auth_users WHERE role = 'admin' AND is_active = 1"
).fetchone()
return int(row['count']) if row is not None else 0
def list_users(self) -> list[AuthUser]:
with self.connect() as conn:

View File

@@ -7,11 +7,11 @@ from app.services.historical_sync import get_historical_sync_service
from app.utils.serialization import to_plain
parser = argparse.ArgumentParser(description="Import dziennych agregatow PV z InfluxDB do lokalnego cache SQLite")
parser.add_argument("--start-date", dest="start_date", help="Data startowa YYYY-MM-DD")
parser.add_argument("--end-date", dest="end_date", help="Data koncowa YYYY-MM-DD")
parser.add_argument("--chunk-days", dest="chunk_days", type=int, default=7, help="Liczba dni na chunk")
parser.add_argument("--force", action="store_true", help="Nadpisz dni juz zapisane w cache")
parser = argparse.ArgumentParser(description="Import daily PV aggregates from InfluxDB into the local SQLite cache")
parser.add_argument("--start-date", dest="start_date", help="Start date YYYY-MM-DD")
parser.add_argument("--end-date", dest="end_date", help="End date YYYY-MM-DD")
parser.add_argument("--chunk-days", dest="chunk_days", type=int, default=7, help="Number of days per chunk")
parser.add_argument("--force", action="store_true", help="Overwrite days already stored in the cache")
args = parser.parse_args()
service = get_historical_sync_service()

View File

@@ -59,7 +59,7 @@ APP_CONFIG = {
}
SITE_CONFIG = {
"site_name": os.getenv("SITE_NAME", "Domowa instalacja PV"),
"site_name": os.getenv("SITE_NAME", "Home PV installation"),
"timezone": APP_CONFIG["timezone"],
"installed_power_kwp": env_float("PV_INSTALLED_POWER_KWP", 9.99),
"currency": os.getenv("SITE_CURRENCY", "PLN"),
@@ -91,20 +91,20 @@ CORS_ORIGINS = [
]
TIME_RANGES = {
"today": {"label": "Dzis", "special": "today"},
"yesterday": {"label": "Wczoraj", "special": "yesterday"},
"today": {"label": "Today", "special": "today"},
"yesterday": {"label": "Yesterday", "special": "yesterday"},
"6h": {"label": "6h", "seconds": 6 * 3600},
"12h": {"label": "12h", "seconds": 12 * 3600},
"24h": {"label": "24h", "seconds": 24 * 3600},
"48h": {"label": "48h", "seconds": 48 * 3600},
"1d": {"label": "1 dzien", "seconds": 1 * 24 * 3600},
"3d": {"label": "3 dni", "seconds": 3 * 24 * 3600},
"7d": {"label": "7 dni", "seconds": 7 * 24 * 3600},
"14d": {"label": "14 dni", "seconds": 14 * 24 * 3600},
"30d": {"label": "30 dni", "seconds": 30 * 24 * 3600},
"60d": {"label": "60 dni", "seconds": 60 * 24 * 3600},
"90d": {"label": "90 dni", "seconds": 90 * 24 * 3600},
"365d": {"label": "365 dni", "seconds": 365 * 24 * 3600},
"1d": {"label": "1 day", "seconds": 1 * 24 * 3600},
"3d": {"label": "3 days", "seconds": 3 * 24 * 3600},
"7d": {"label": "7 days", "seconds": 7 * 24 * 3600},
"14d": {"label": "14 days", "seconds": 14 * 24 * 3600},
"30d": {"label": "30 days", "seconds": 30 * 24 * 3600},
"60d": {"label": "60 days", "seconds": 60 * 24 * 3600},
"90d": {"label": "90 days", "seconds": 90 * 24 * 3600},
"365d": {"label": "365 days", "seconds": 365 * 24 * 3600},
"ytd": {"label": "YTD", "special": "ytd"},
}
@@ -119,20 +119,20 @@ ANALYTICS = {
"default_range": os.getenv("ANALYTICS_DEFAULT_RANGE", "30d"),
"default_bucket": os.getenv("ANALYTICS_DEFAULT_BUCKET", "day"),
"bucket_labels": {
"day": "Dzien",
"week": "Tydzien",
"month": "Miesiac",
"year": "Rok",
"day": "Day",
"week": "Week",
"month": "Month",
"year": "Year",
},
"compare_modes": {
"none": "Porownanie",
"previous_period": "Poprzedni okres",
"previous_year": "Poprzedni rok",
"previous_year_2": "2 lata wstecz",
"previous_year_3": "3 lata wstecz",
"previous_month_12": "12 miesiecy wstecz",
"previous_month_24": "24 miesiace wstecz",
"custom_multi": "Wlasne zakresy",
"none": "Comparison",
"previous_period": "Previous period",
"previous_year": "Previous year",
"previous_year_2": "2 years back",
"previous_year_3": "3 years back",
"previous_month_12": "12 months back",
"previous_month_24": "24 months back",
"custom_multi": "Custom ranges",
},
}
@@ -208,7 +208,7 @@ register_metric(
entity_id=os.getenv("PV_AC_POWER_ENTITY", "sofarsolar_ac_power"),
measurement=os.getenv("PV_AC_POWER_MEASUREMENT", "W"),
unit="W",
label="Moc AC",
label="AC Power",
precision=0,
)
register_metric(
@@ -216,7 +216,7 @@ register_metric(
entity_id=os.getenv("PV_TOTAL_ENERGY_ENTITY", "sofarsolar_energy_total"),
measurement=os.getenv("PV_TOTAL_ENERGY_MEASUREMENT", "kWh"),
unit="kWh",
label="Energia laczna",
label="Total Energy",
kind="counter",
precision=2,
)
@@ -225,7 +225,7 @@ register_metric(
entity_id=os.getenv("PV_INVERTER_TEMP_ENTITY", "sofarsolar_temprature_inverter"),
measurement=os.getenv("PV_INVERTER_TEMP_MEASUREMENT", "°C"),
unit="°C",
label="Temperatura falownika",
label="Inverter Temperature",
precision=1,
)
@@ -243,7 +243,7 @@ for index, defaults in STRING_DEFAULTS.items():
entity_id=os.getenv(f"PV_STRING_{index}_POWER_ENTITY", defaults["power"]),
measurement=os.getenv(f"PV_STRING_{index}_POWER_MEASUREMENT", "W"),
unit="W",
label=f"{label} moc",
label=f"{label} power",
precision=0,
)
voltage_metric_id = register_metric(
@@ -251,7 +251,7 @@ for index, defaults in STRING_DEFAULTS.items():
entity_id=os.getenv(f"PV_STRING_{index}_VOLTAGE_ENTITY", defaults["voltage"]),
measurement=os.getenv(f"PV_STRING_{index}_VOLTAGE_MEASUREMENT", "V"),
unit="V",
label=f"{label} napiecie",
label=f"{label} voltage",
precision=1,
)

View File

@@ -10,6 +10,19 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
}
location /assets/ {
proxy_pass http://frontend:80;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location / {
@@ -19,5 +32,8 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
}
}

View File

@@ -6,6 +6,12 @@ server {
root /usr/share/nginx/html;
index index.html;
location /assets/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
location /api/ {
proxy_pass http://backend:8105;
proxy_http_version 1.1;
@@ -13,6 +19,9 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
}
location = /health {
@@ -22,9 +31,22 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
}
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
try_files $uri =404;
}
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
try_files $uri $uri/ /index.html;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -179,6 +179,8 @@ export const api = {
DEMO_MODE ? demoResponse(() => payload) : request("/auth/users", { method: "POST", body: JSON.stringify(payload) }),
resetUserPassword: (username: string, password: string) =>
DEMO_MODE ? demoResponse(() => ({ username })) : request(`/auth/users/${encodeURIComponent(username)}/reset-password`, { method: "POST", body: JSON.stringify({ password }) }),
updateUserRole: (username: string, role: string) =>
DEMO_MODE ? demoResponse(() => ({ username, role })) : request(`/auth/users/${encodeURIComponent(username)}/role`, { method: "PUT", body: JSON.stringify({ role }) }),
};
export const wsBaseUrl = (): string => {

View File

@@ -2,15 +2,17 @@ import type { EChartsOption } from "echarts";
import { Card } from "../common/Card";
import { EChart } from "../common/EChart";
import type { BucketPoint } from "../../types";
import { t, type Language } from "../../i18n";
interface ComparisonChartProps {
current: BucketPoint[];
comparison: BucketPoint[];
unit: string;
compareMode: string;
language?: Language;
}
export function ComparisonChart({ current, comparison, unit, compareMode }: ComparisonChartProps) {
export function ComparisonChart({ current, comparison, unit, compareMode, language = "en" }: ComparisonChartProps) {
const option: EChartsOption = {
tooltip: {
trigger: "axis",
@@ -43,14 +45,14 @@ export function ComparisonChart({ current, comparison, unit, compareMode }: Comp
},
series: [
{
name: "Aktualny okres",
name: t(language, "currentPeriod"),
type: "bar",
barGap: "20%",
itemStyle: { color: "#60a5fa", borderRadius: [10, 10, 0, 0] },
data: current.map((item) => item.value),
},
{
name: compareMode === "previous_year" ? "Poprzedni rok" : "Poprzedni okres",
name: compareMode === "previous_year" ? t(language, "comparePreviousYear") : t(language, "comparePreviousPeriod"),
type: "bar",
itemStyle: { color: "#f59e0b", borderRadius: [10, 10, 0, 0] },
data: current.map((_, index) => comparison[index]?.value ?? 0),
@@ -59,7 +61,7 @@ export function ComparisonChart({ current, comparison, unit, compareMode }: Comp
};
return (
<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" />
</Card>
);

View File

@@ -1,4 +1,5 @@
import { Card } from "../common/Card";
import { t, type Language } from "../../i18n";
interface PeriodControlsProps {
rangeKey: string;
@@ -10,6 +11,7 @@ interface PeriodControlsProps {
onRangeChange: (value: string) => void;
onBucketChange: (value: string) => void;
onCompareChange: (value: string) => void;
language?: Language;
}
export function PeriodControls({
@@ -22,12 +24,13 @@ export function PeriodControls({
onRangeChange,
onBucketChange,
onCompareChange,
language = "en",
}: PeriodControlsProps) {
return (
<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">
<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
value={rangeKey}
onChange={(event) => onRangeChange(event.target.value)}
@@ -42,7 +45,7 @@ export function PeriodControls({
</label>
<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
value={bucket}
onChange={(event) => onBucketChange(event.target.value)}
@@ -57,7 +60,7 @@ export function PeriodControls({
</label>
<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
value={compare}
onChange={(event) => onCompareChange(event.target.value)}

View File

@@ -9,23 +9,41 @@ interface EChartProps {
export function EChart({ option, className = "h-80 w-full" }: EChartProps) {
const ref = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<echarts.EChartsType | null>(null);
useEffect(() => {
if (!ref.current) {
if (!ref.current || chartRef.current) {
return;
}
const chart = echarts.init(ref.current);
chart.setOption(option);
chartRef.current = echarts.init(ref.current);
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);
return () => {
observer.disconnect();
chart.dispose();
};
}, [option]);
}, []);
return <div ref={ref} className={className} />;
}

View File

@@ -2,13 +2,15 @@ import type { EChartsOption } from "echarts";
import { Card } from "../common/Card";
import { EChart } from "../common/EChart";
import type { HistoryPayload } from "../../types";
import type { Language } from "../../i18n";
interface LiveHistoryChartProps {
history?: HistoryPayload;
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 = {
tooltip: {
trigger: "axis",
@@ -33,7 +35,7 @@ export function LiveHistoryChart({ history, title = "Dane chwilowe" }: LiveHisto
axisLabel: { color: "#94a3b8" },
axisLine: { lineStyle: { color: "rgba(255,255,255,0.08)" } },
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: {
@@ -56,7 +58,7 @@ export function LiveHistoryChart({ history, title = "Dane chwilowe" }: LiveHisto
return (
<Card
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" />
</Card>

View File

@@ -1,14 +1,16 @@
import { Card } from "../common/Card";
import { ValuePair } from "../common/ValuePair";
import type { SnapshotGroupRow } from "../../types";
import type { Language } from "../../i18n";
interface PhaseGridProps {
rows: SnapshotGroupRow[];
language?: Language;
}
export function PhaseGrid({ rows }: PhaseGridProps) {
export function PhaseGrid({ rows, language = "en" }: PhaseGridProps) {
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">
{rows.map((row) => (
<div key={row.id} className="rounded-3xl border border-white/10 bg-slate-950/40 p-4">

View File

@@ -1,16 +1,18 @@
import { Card } from "../common/Card";
import { ValuePair } from "../common/ValuePair";
import type { SnapshotGroupRow } from "../../types";
import type { Language } from "../../i18n";
interface StringGridProps {
rows: SnapshotGroupRow[];
language?: Language;
}
const slotOrder = ["power", "voltage"] as const;
export function StringGrid({ rows }: StringGridProps) {
export function StringGrid({ rows, language = "en" }: StringGridProps) {
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">
{rows.map((row) => {
const visibleSlots = slotOrder.filter((slot) => row.values[slot]);

View File

@@ -9,24 +9,24 @@ interface ConfigPanelProps {
export function ConfigPanel({ config }: ConfigPanelProps) {
return (
<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="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<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 className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<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 className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<div className="font-medium text-white">Logika danych</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>
<div className="font-medium text-white">Data logic</div>
<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>
</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">
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<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>
</div>
<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">
live: {String(config.capabilities.realtime_enabled)} / analytics: {String(config.capabilities.analytics_enabled)} / history: {String(config.capabilities.historical_import_enabled)}
</dd>
@@ -51,7 +51,7 @@ export function ConfigPanel({ config }: ConfigPanelProps) {
{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">
<table className="min-w-full text-left text-sm text-slate-300">
<thead>

View File

@@ -7,27 +7,77 @@ import { formatDate, formatDateTime, formatDurationShort, formatPercent, formatV
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 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 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 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 }) {
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 }) {
const isActive = chunk.chunk_index === activeChunkIndex || chunk.state === "running";
return (
<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="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 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="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">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>
);
}
function EventRow({ event }: { event: HistoricalActivityEvent }) {
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 [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 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);
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">
<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">
<StatCard label="Postep" value={`${progress}%`} helper={`${payload?.processed_days ?? 0} / ${payload?.total_days ?? 0} dni`} />
<StatCard label="Przepustowosc" value={formatValue(payload?.avg_days_per_minute ?? null, "dni/min", 1)} helper="Srednia szybkosc importu" />
<StatCard label="ETA" value={formatDurationShort(payload?.estimated_remaining_seconds)} helper="Szacowany czas do konca" />
<StatCard label="Pokrycie" value={formatPercent(payload?.coverage?.coverage_pct ?? null)} helper={`${payload?.coverage?.missing_days ?? 0} brakujacych dni`} />
<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-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard label="Progress" value={`${progress}%`} helper={`${payload?.processed_days ?? 0} / ${payload?.total_days ?? 0} days`} />
<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="Estimated time to completion" />
<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>
{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}
</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">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 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}
{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 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="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="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>
</Card>
);
}

View File

@@ -53,21 +53,21 @@ export const demoConfig: DashboardConfig = {
{ key: "ytd", label: "YTD" },
],
buckets: [
{ key: "day", label: "Dzien" },
{ key: "week", label: "Tydzien" },
{ key: "month", label: "Miesiac" },
{ key: "day", label: "Dzień" },
{ key: "week", label: "Tydzień" },
{ key: "month", label: "Miesiąc" },
{ key: "year", label: "Rok" },
],
historical_import_enabled: true,
history: { enabled: true, default_chunk_days: 7, auto_sync_enabled: true, auto_sync_interval_minutes: 30 },
},
visible_entities: [
{ metric_id: "ac_power", label: "Moc AC calkowita", entity_id: "sofarsolar_ac_power", measurement: "W", unit: "W", kind: "gauge" },
{ metric_id: "energy_total", label: "Energia total", entity_id: "sofarsolar_energy_total", measurement: "kWh", unit: "kWh", kind: "counter" },
{ metric_id: "ac_power", label: "Moc AC całkowita", entity_id: "sofarsolar_ac_power", measurement: "W", unit: "W", kind: "gauge" },
{ metric_id: "energy_total", label: "Energia całkowita", entity_id: "sofarsolar_energy_total", measurement: "kWh", unit: "kWh", kind: "counter" },
{ metric_id: "string_1_power", label: "Moc stringu DC1", entity_id: "sofarsolar_dc1_power", measurement: "W", unit: "W", kind: "gauge" },
{ metric_id: "string_1_voltage", label: "Napiecie stringu DC1", entity_id: "sofarsolar_dc1_voltage", measurement: "V", unit: "V", kind: "gauge" },
{ metric_id: "string_1_voltage", label: "Napięcie stringu DC1", entity_id: "sofarsolar_dc1_voltage", measurement: "V", unit: "V", kind: "gauge" },
{ metric_id: "string_2_power", label: "Moc stringu DC2", entity_id: "sofarsolar_dc2_power", measurement: "W", unit: "W", kind: "gauge" },
{ metric_id: "string_2_voltage", label: "Napiecie stringu DC2", entity_id: "sofarsolar_dc2_voltage", measurement: "V", unit: "V", kind: "gauge" },
{ metric_id: "string_2_voltage", label: "Napięcie stringu DC2", entity_id: "sofarsolar_dc2_voltage", measurement: "V", unit: "V", kind: "gauge" },
{ metric_id: "inverter_temp", label: "Temperatura falownika", entity_id: "sofarsolar_temprature_inverter", measurement: "°C", unit: "°C", kind: "gauge" },
],
};
@@ -82,20 +82,20 @@ export const demoSnapshot = (): SnapshotPayload => ({
{ metric_id: "inverter_temp", label: "Temp. falownika", value: 47.3, unit: "°C", accent: "rose", subtitle: "Live status termiczny" },
],
kpis: {
energy_today: { metric_id: "energy_today", label: "Energia dzis", unit: "kWh", value: 31.8, precision: 2, kind: "counter", status: "ok" },
energy_today: { metric_id: "energy_today", label: "Energia dziś", unit: "kWh", value: 31.8, precision: 2, kind: "counter", status: "ok" },
energy_yesterday: { metric_id: "energy_yesterday", label: "Energia wczoraj", unit: "kWh", value: 28.4, precision: 2, kind: "counter", status: "ok" },
today_vs_yesterday: { metric_id: "today_vs_yesterday", label: "Dzis vs wczoraj", unit: "%", value: 12, precision: 0, kind: "gauge", status: "ok" },
dc_power_total: { metric_id: "dc_power_total", label: "Moc DC total", unit: "W", value: 6760, precision: 0, kind: "gauge", status: "ok" },
energy_total: { metric_id: "energy_total", label: "Energia total", unit: "kWh", value: 18264.3, precision: 1, kind: "counter", status: "ok" },
today_vs_yesterday: { metric_id: "today_vs_yesterday", label: "Dziś vs wczoraj", unit: "%", value: 12, precision: 0, kind: "gauge", status: "ok" },
dc_power_total: { metric_id: "dc_power_total", label: "Moc DC łączna", unit: "W", value: 6760, precision: 0, kind: "gauge", status: "ok" },
energy_total: { metric_id: "energy_total", label: "Energia całkowita", unit: "kWh", value: 18264.3, precision: 1, kind: "counter", status: "ok" },
},
strings: [
{ id: "dc1", label: "String 1 / Wschod", meta: {}, values: { power: { metric_id: "string_1_power", label: "Moc", unit: "W", value: 3450, precision: 0, kind: "gauge", status: "ok" }, voltage: { metric_id: "string_1_voltage", label: "Napiecie", unit: "V", value: 382, precision: 0, kind: "gauge", status: "ok" } } },
{ id: "dc2", label: "String 2 / Zachod", meta: {}, values: { power: { metric_id: "string_2_power", label: "Moc", unit: "W", value: 3310, precision: 0, kind: "gauge", status: "ok" }, voltage: { metric_id: "string_2_voltage", label: "Napiecie", unit: "V", value: 375, precision: 0, kind: "gauge", status: "ok" } } },
{ id: "dc1", label: "String 1 / Wschód", meta: {}, values: { power: { metric_id: "string_1_power", label: "Moc", unit: "W", value: 3450, precision: 0, kind: "gauge", status: "ok" }, voltage: { metric_id: "string_1_voltage", label: "Napięcie", unit: "V", value: 382, precision: 0, kind: "gauge", status: "ok" } } },
{ id: "dc2", label: "String 2 / Zachód", meta: {}, values: { power: { metric_id: "string_2_power", label: "Moc", unit: "W", value: 3310, precision: 0, kind: "gauge", status: "ok" }, voltage: { metric_id: "string_2_voltage", label: "Napięcie", unit: "V", value: 375, precision: 0, kind: "gauge", status: "ok" } } },
],
phases: [],
status: [
{ metric_id: "inverter_temp", label: "Temperatura falownika", unit: "°C", value: 47.3, precision: 1, kind: "gauge", status: "ok" },
{ metric_id: "data_freshness", label: "Swiezosc danych", unit: "", value: "3 s temu", precision: 0, kind: "text", status: "ok" },
{ metric_id: "data_freshness", label: "Świeżość danych", unit: "", value: "3 s temu", precision: 0, kind: "text", status: "ok" },
],
faults: [],
});
@@ -110,8 +110,8 @@ export const demoHistory: HistoryPayload = {
end: isoAt(0),
series: [
{ metric_id: "ac_power", label: "Moc AC", unit: "W", points: historyPoints([0, 120, 860, 1840, 2760, 3920, 5180, 6020, 6840, 6500, 5710, 4980]) },
{ metric_id: "string_1_power", label: "DC1", unit: "W", points: historyPoints([0, 80, 620, 1320, 2140, 2860, 3250, 3490, 3450, 3300, 2920, 2480]) },
{ metric_id: "string_2_power", label: "DC2", unit: "W", points: historyPoints([0, 40, 240, 520, 880, 1260, 1930, 2530, 3310, 3200, 2790, 2410]) },
{ metric_id: "string_1_power", label: "DC1 moc", unit: "W", points: historyPoints([0, 80, 620, 1320, 2140, 2860, 3250, 3490, 3450, 3300, 2920, 2480]) },
{ metric_id: "string_2_power", label: "DC2 moc", unit: "W", points: historyPoints([0, 40, 240, 520, 880, 1260, 1930, 2530, 3310, 3200, 2790, 2410]) },
{ metric_id: "inverter_temp", label: "Temp. falownika", unit: "°C", points: historyPoints([22, 24, 27, 31, 35, 39, 42, 45, 47.3, 46.8, 44.1, 41.2]) },
],
};
@@ -133,7 +133,7 @@ export const demoDistribution: DistributionPayload = {
unit: "kWh",
bucket: "day",
total: 289.5,
slices: [ { label: "DC1 / Wschod", value: 154.2, share: 53.3 }, { label: "DC2 / Zachod", value: 135.3, share: 46.7 } ],
slices: [ { label: "DC1 / Wschód", value: 154.2, share: 53.3 }, { label: "DC2 / Zachód", value: 135.3, share: 46.7 } ],
};
export const demoHistoricalStatus: HistoricalStatus = {
@@ -171,10 +171,10 @@ export const demoHistoricalStatus: HistoricalStatus = {
{ chunk_index: 157, total_chunks: 209, start_date: "2025-01-05", end_date: "2025-01-11", processed_days: 4, imported_days: 4, skipped_days: 0, energy_kwh: 51.4, state: "running", started_at: isoAt(-6.5), finished_at: null, duration_seconds: null, note: "Aktywny chunk 2025-01-05 -> 2025-01-11" },
],
recent_events: [
{ timestamp: isoAt(-1.1), level: "success", title: "Zaimportowano dzien", message: "Zaimportowano 2025-01-08 (13.10 kWh). Energia: 13.10 kWh.", day: "2025-01-08", chunk_index: 157 },
{ timestamp: isoAt(-1.9), level: "success", title: "Zaimportowano dzien", message: "Zaimportowano 2025-01-07 (12.84 kWh). Energia: 12.84 kWh.", day: "2025-01-07", chunk_index: 157 },
{ timestamp: isoAt(-2.5), level: "success", title: "Zaimportowano dzien", message: "Zaimportowano 2025-01-06 (12.56 kWh). Energia: 12.56 kWh.", day: "2025-01-06", chunk_index: 157 },
{ timestamp: isoAt(-3.2), level: "success", title: "Zaimportowano dzien", message: "Zaimportowano 2025-01-05 (12.90 kWh). Energia: 12.90 kWh.", day: "2025-01-05", chunk_index: 157 },
{ timestamp: isoAt(-1.1), level: "success", title: "Zaimportowano dzień", message: "Zaimportowano 2025-01-08 (13.10 kWh). Energia: 13.10 kWh.", day: "2025-01-08", chunk_index: 157 },
{ timestamp: isoAt(-1.9), level: "success", title: "Zaimportowano dzień", message: "Zaimportowano 2025-01-07 (12.84 kWh). Energia: 12.84 kWh.", day: "2025-01-07", chunk_index: 157 },
{ timestamp: isoAt(-2.5), level: "success", title: "Zaimportowano dzień", message: "Zaimportowano 2025-01-06 (12.56 kWh). Energia: 12.56 kWh.", day: "2025-01-06", chunk_index: 157 },
{ timestamp: isoAt(-3.2), level: "success", title: "Zaimportowano dzień", message: "Zaimportowano 2025-01-05 (12.90 kWh). Energia: 12.90 kWh.", day: "2025-01-05", chunk_index: 157 },
{ timestamp: isoAt(-3.7), level: "info", title: "Chunk 157/209", message: "Start zakresu 2025-01-05 -> 2025-01-11", day: null, chunk_index: 157 },
{ timestamp: isoAt(-6.8), level: "success", title: "Chunk 156/209 zakonczony", message: "Zakres 2024-12-29 -> 2025-01-04, import 7, pominiete 0, energia 92.60 kWh", day: null, chunk_index: 156 },
],

View File

@@ -4,13 +4,18 @@ import { api } from "../api/client";
export function useRealtimeHistory(
rangeKey: string,
enabled = true,
options?: { start?: string; end?: string; metrics?: string[]; publicKiosk?: boolean },
options?: { start?: string; end?: string; metrics?: string[]; publicKiosk?: boolean; pauseAutoRefresh?: boolean },
) {
const manualWindow = Boolean(options?.start || options?.end);
const autoRefreshEnabled = !manualWindow && !options?.pauseAutoRefresh;
return useQuery({
queryKey: ["realtime-history", rangeKey, options?.start, options?.end, options?.metrics?.join(","), options?.publicKiosk],
queryKey: ["realtime-history", rangeKey, options?.start, options?.end, options?.metrics?.join(","), options?.publicKiosk, autoRefreshEnabled],
queryFn: () => api.getRealtimeHistory(rangeKey, options),
staleTime: 20 * 1000,
refetchInterval: options?.start || options?.end ? false : 30 * 1000,
staleTime: autoRefreshEnabled ? 20 * 1000 : Infinity,
refetchInterval: autoRefreshEnabled ? 30 * 1000 : false,
refetchOnWindowFocus: autoRefreshEnabled,
refetchOnReconnect: autoRefreshEnabled,
enabled,
});
}

View File

@@ -38,6 +38,8 @@ const messages = {
chartProduction: "Produkcja",
chartProductionSubtitle: "Agregacja w wybranym bucketcie",
chartComparison: "Porównanie okresów",
chartComparisonSubtitle: "Wspólne słupki dla aktualnego i porównawczego okresu",
chartComparisonControlSubtitle: "Dzień / tydzień / miesiąc z porównaniem do poprzedniego okresu lub roku",
chartDistribution: "Rozkład produkcji",
currentPeriod: "Bieżący okres",
comparisonPeriod: "Porównanie",
@@ -145,6 +147,8 @@ const messages = {
chartProduction: "Production",
chartProductionSubtitle: "Aggregated by selected bucket",
chartComparison: "Period comparison",
chartComparisonSubtitle: "Shared bars for the current and comparison periods",
chartComparisonControlSubtitle: "Day / week / month with previous period or previous year comparison",
chartDistribution: "Production distribution",
currentPeriod: "Current period",
comparisonPeriod: "Comparison",
@@ -227,6 +231,14 @@ const metricLabels: Record<string, { pl: string; en: string }> = {
today_vs_yesterday: { pl: "Dziś vs wczoraj", en: "Today vs yesterday" },
dc_power_total: { pl: "Moc DC łącznie", en: "Total DC power" },
inverter_temp: { pl: "Temperatura falownika", en: "Inverter temperature" },
string_1_power: { pl: "DC1 moc", en: "DC1 power" },
string_1_voltage: { pl: "DC1 napięcie", en: "DC1 voltage" },
string_2_power: { pl: "DC2 moc", en: "DC2 power" },
string_2_voltage: { pl: "DC2 napięcie", en: "DC2 voltage" },
string_3_power: { pl: "DC3 moc", en: "DC3 power" },
string_3_voltage: { pl: "DC3 napięcie", en: "DC3 voltage" },
string_4_power: { pl: "DC4 moc", en: "DC4 power" },
string_4_voltage: { pl: "DC4 napięcie", en: "DC4 voltage" },
};
export function t(language: Language, key: MessageKey): string {

View File

@@ -165,6 +165,10 @@ body {
align-items: end;
}
.pv-filter-grid-live {
grid-template-columns: minmax(260px, 1.45fr) minmax(240px, 0.92fr) minmax(210px, 0.95fr);
}
.pv-filter-grid-archive {
grid-template-columns: minmax(240px, 1.35fr) minmax(180px, 0.9fr) repeat(2, minmax(160px, 1fr));
}
@@ -196,6 +200,7 @@ body {
@media (max-width: 992px) {
.pv-filter-grid,
.pv-filter-grid-live,
.pv-filter-grid-archive {
grid-template-columns: 1fr;
}
@@ -247,6 +252,25 @@ body {
min-width: 1.1rem;
}
.pv-subnav-side {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.85rem;
min-width: 0;
margin-left: auto;
}
.pv-subnav-refresh-group {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
min-width: 0;
}
.pv-menu-meta {
display: inline-flex;
align-items: center;
@@ -254,11 +278,354 @@ body {
color: var(--tblr-secondary);
font-size: 0.875rem;
white-space: nowrap;
min-width: 0;
}
@media (max-width: 1200px) {
.pv-subnav-side {
width: 100%;
justify-content: space-between;
}
}
@media (max-width: 992px) {
.pv-subnav-side {
width: 100%;
flex-direction: column;
align-items: stretch;
}
.pv-subnav-refresh-group {
width: 100%;
flex-direction: column;
align-items: stretch;
}
.pv-menu-meta {
width: 100%;
justify-content: center;
white-space: normal;
}
}
.pv-date-field,
.pv-filter-field {
min-width: 0;
}
.pv-date-input {
min-height: 2.8rem;
font-size: 0.95rem;
}
.pv-action-tile,
.pv-chip-button {
border-radius: 0.9rem !important;
}
.pv-order-card {
border: 1px solid var(--tblr-border-color, rgba(15, 23, 42, 0.12));
border-radius: 1rem;
padding: 0.9rem 1rem;
background: color-mix(in srgb, var(--tblr-bg-surface, #fff) 90%, transparent);
}
.pv-order-index {
width: 2rem;
min-width: 2rem;
height: 2rem;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
background: rgba(32, 107, 196, 0.12);
color: var(--tblr-primary, #206bc4);
}
@media (max-width: 992px) {
.page-header .col-auto.ms-auto {
width: 100%;
}
}
@media (max-width: 576px) {
.pv-segmented-group {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pv-segmented-group > .btn,
.pv-chip-button,
.pv-action-tile,
.pv-order-card .btn {
width: 100%;
}
.pv-filter-grid-archive {
grid-template-columns: 1fr;
}
.pv-date-input {
min-height: 3rem;
}
.pv-order-card .btn-list {
width: 100%;
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem;
}
}
.login-layout {
min-height: 72vh;
}
.login-card-enhanced {
overflow: hidden;
border-width: 1px;
}
.login-card-enhanced::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 5px;
background: linear-gradient(90deg, rgba(32, 107, 196, 0.95), rgba(47, 179, 68, 0.9));
}
.login-card-enhanced .card-body {
position: relative;
}
.login-showcase-card {
border: 1px solid rgba(32, 107, 196, 0.14);
border-radius: 1rem;
padding: 2rem;
background:
radial-gradient(circle at top right, rgba(32, 107, 196, 0.18), transparent 30%),
rgba(255, 255, 255, 0.74);
box-shadow: var(--pv-card-shadow);
}
[data-bs-theme="dark"] .login-showcase-card {
background:
radial-gradient(circle at top right, rgba(32, 107, 196, 0.22), transparent 32%),
rgba(11, 18, 32, 0.82);
border-color: rgba(148, 163, 184, 0.18);
}
.login-showcase-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.75rem;
border-radius: 999px;
background: rgba(32, 107, 196, 0.12);
color: #206bc4;
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 1rem;
}
.login-showcase-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.login-stat-card {
padding: 1rem;
border-radius: 0.9rem;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.78);
}
[data-bs-theme="dark"] .login-stat-card {
background: rgba(15, 23, 42, 0.84);
border-color: rgba(148, 163, 184, 0.14);
}
.login-input-stack {
display: flex;
flex-direction: column;
gap: 1rem;
}
.login-submit-button {
margin-top: 0.25rem;
}
.login-footer-note {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(148, 163, 184, 0.18);
color: var(--tblr-secondary);
font-size: 0.88rem;
}
.pv-page-header-actions {
width: 100%;
display: flex;
justify-content: flex-end;
}
.pv-page-header-actions--wide {
width: min(100%, 920px);
margin-left: auto;
}
.pv-page-header-actions--right {
justify-items: end;
align-items: end;
}
.pv-page-header-actions--right > :first-child {
justify-self: stretch;
}
.pv-page-header-range-grid {
display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(220px, 0.92fr);
gap: 0.75rem;
width: min(100%, 620px);
}
.pv-page-header-range-grid--single {
grid-template-columns: minmax(280px, 1fr);
width: min(100%, 420px);
}
.pv-refresh-panel {
min-width: 240px;
}
.pv-refresh-panel--compact {
width: min(100%, 176px);
min-width: 0;
}
.pv-refresh-switch {
position: relative;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: center;
width: 100%;
min-height: 3rem;
padding: 0.28rem;
border: 1px solid color-mix(in srgb, var(--tblr-border-color, rgba(148, 163, 184, 0.2)) 90%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--tblr-bg-surface, #fff) 88%, rgba(32, 107, 196, 0.05));
color: var(--tblr-secondary);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
overflow: hidden;
transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
.pv-refresh-switch:hover,
.pv-refresh-switch:focus-visible {
border-color: color-mix(in srgb, var(--tblr-primary, #206bc4) 36%, var(--tblr-border-color, rgba(148, 163, 184, 0.18)));
box-shadow: 0 0 0 0.12rem rgba(32, 107, 196, 0.12);
}
.pv-refresh-panel--compact .pv-refresh-switch {
min-height: 2.5rem;
padding: 0.18rem;
}
.pv-refresh-switch-thumb {
position: absolute;
top: 0.28rem;
bottom: 0.28rem;
left: 0.28rem;
width: calc(50% - 0.28rem);
border-radius: 999px;
background: linear-gradient(135deg, rgba(32, 107, 196, 0.96), rgba(52, 118, 220, 0.86));
box-shadow: 0 10px 24px rgba(32, 107, 196, 0.2);
transition: transform 0.22s ease;
}
.pv-refresh-switch.is-paused .pv-refresh-switch-thumb {
transform: translateX(100%);
background: linear-gradient(135deg, rgba(100, 116, 139, 0.96), rgba(71, 85, 105, 0.86));
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18);
}
.pv-refresh-switch-option {
position: relative;
z-index: 1;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.35rem;
padding: 0 0.75rem;
font-size: 0.92rem;
font-weight: 700;
color: var(--tblr-secondary);
transition: color 0.18s ease, opacity 0.18s ease;
}
.pv-refresh-panel--compact .pv-refresh-switch-thumb {
top: 0.18rem;
bottom: 0.18rem;
left: 0.18rem;
width: calc(50% - 0.18rem);
}
.pv-refresh-panel--compact .pv-refresh-switch-option {
min-height: 1.95rem;
padding: 0 0.7rem;
font-size: 0.84rem;
}
.pv-refresh-switch.is-auto .pv-refresh-switch-option--auto,
.pv-refresh-switch.is-paused .pv-refresh-switch-option--paused {
color: #fff;
}
.pv-refresh-switch.is-auto .pv-refresh-switch-option--paused,
.pv-refresh-switch.is-paused .pv-refresh-switch-option--auto {
opacity: 0.82;
}
.pv-chart-footer-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)) minmax(220px, 1.1fr);
gap: 0.85rem;
align-items: end;
}
.pv-chart-footer-status {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@media (max-width: 992px) {
.pv-chart-footer-controls,
.pv-page-header-range-grid,
.pv-page-header-range-grid--single {
grid-template-columns: 1fr;
width: 100%;
}
.pv-page-header-actions,
.pv-page-header-actions--wide {
width: 100%;
}
}
@media (max-width: 576px) {
.pv-refresh-panel {
min-width: 0;
}
.pv-refresh-switch-option {
font-size: 0.88rem;
padding: 0 0.55rem;
}
}

View File

@@ -2,14 +2,22 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
const noStoreHeaders = {
"Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
Pragma: "no-cache",
Expires: "0",
};
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
host: "0.0.0.0",
port: 5173
port: 5173,
headers: noStoreHeaders,
},
preview: {
host: "0.0.0.0",
port: 4173
}
port: 4173,
headers: noStoreHeaders,
},
});