first commit

This commit is contained in:
Mateusz Gruszczyński
2026-03-23 15:56:18 +01:00
commit c5cc2efbac
106 changed files with 10254 additions and 0 deletions

73
.env.example Normal file
View File

@@ -0,0 +1,73 @@
# Backend application
APP_NAME=PV Insight
APP_VERSION=1.3.0
APP_TIMEZONE=Europe/Warsaw
APP_HOST=0.0.0.0
APP_PORT=8105
APP_SECRET_KEY=change-me
APP_SESSION_COOKIE_NAME=pv_insight_session
APP_SQLITE_PATH=./data/pv_insight.sqlite3
# Site
SITE_NAME=Domowa instalacja PV
PV_INSTALLED_POWER_KWP=9.99
CO2_FACTOR_KG_PER_KWH=0.72
# InfluxDB
INFLUXDB_SCHEME=http
INFLUXDB_HOST=127.0.0.1
INFLUXDB_PORT=8086
INFLUXDB_DATABASE=ha
INFLUXDB_USER=
INFLUXDB_PASSWORD=
INFLUXDB_VERIFY_SSL=false
INFLUXDB_TIMEOUT_SECONDS=15
# Auth
AUTH_ENABLED=true
AUTH_USERNAME=admin
AUTH_PASSWORD=change-me
AUTH_DISPLAY_NAME=Operator
AUTH_SESSION_MAX_AGE_SECONDS=43200
AUTH_COOKIE_SECURE=false
AUTH_COOKIE_SAMESITE=Lax
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:4173,http://127.0.0.1:4173
# Frontend defaults
FRONTEND_DEFAULT_TAB=realtime
FRONTEND_THEME=dark
FRONTEND_LANGUAGE=pl
# PV metric entities used by backend
PV_AC_POWER_ENTITY=sofarsolar_ac_power
PV_AC_POWER_MEASUREMENT=W
PV_TOTAL_ENERGY_ENTITY=sofarsolar_energy_total
PV_TOTAL_ENERGY_MEASUREMENT=kWh
PV_INVERTER_TEMP_ENTITY=sofarsolar_temprature_inverter
PV_INVERTER_TEMP_MEASUREMENT=°C
# DC strings
PV_STRING_1_LABEL=DC1
PV_STRING_1_POWER_ENTITY=sofarsolar_dc1_power
PV_STRING_1_POWER_MEASUREMENT=W
PV_STRING_1_VOLTAGE_ENTITY=sofarsolar_dc1_voltage
PV_STRING_1_VOLTAGE_MEASUREMENT=V
PV_STRING_2_LABEL=DC2
PV_STRING_2_POWER_ENTITY=sofarsolar_dc2_power
PV_STRING_2_POWER_MEASUREMENT=W
PV_STRING_2_VOLTAGE_ENTITY=sofarsolar_dc2_voltage
PV_STRING_2_VOLTAGE_MEASUREMENT=V
PV_STRING_3_LABEL=DC3
PV_STRING_3_POWER_ENTITY=
PV_STRING_3_POWER_MEASUREMENT=W
PV_STRING_3_VOLTAGE_ENTITY=
PV_STRING_3_VOLTAGE_MEASUREMENT=V
PV_STRING_4_LABEL=DC4
PV_STRING_4_POWER_ENTITY=
PV_STRING_4_POWER_MEASUREMENT=W
PV_STRING_4_VOLTAGE_ENTITY=
PV_STRING_4_VOLTAGE_MEASUREMENT=V

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.venv
node_modules
*.sqlite3
*.zip
venv
__pycache__
.env
frontend/dist/*

169
README.md Normal file
View File

@@ -0,0 +1,169 @@
# PV Insight
Dashboard fotowoltaiki pod InfluxDB, z naciskiem na prosty dev na Pythonie 3.14 i gotowy deploy produkcyjny bez zewnetrznego CDN.
## Najwazniejsze cechy
- dev uruchamiany zwyklym `python run.py`
- prod uruchamiany przez `waitress`, bez serwera developerskiego Flask
- frontend `React + TypeScript + Vite + ECharts`
- stylowanie w oparciu o **Tabler 1.4.0**, zwendorowane **offline** w repo
- tryb jasny / ciemny
- interfejs PL / EN
- logowanie sesyjne
- tryb kiosku z recznym doborem widgetow
- lokalny cache historii w SQLite
- import archiwum z InfluxDB chunkami
## Co aplikacja liczy sama
Aplikacja nie wymaga encji typu `energy_today`, `energy_monthly` czy `energy_yearly`.
Najpierw liczy energie z `PV_TOTAL_ENERGY_ENTITY` jako licznika calkowitego, a jesli go nie ma, moze estymowac dane z `PV_AC_POWER_ENTITY`.
## Struktura projektu
- `backend/config.py` - glowna konfiguracja, encje i moduly
- `backend/run.py` - start developerski
- `backend/run_prod.py` - start produkcyjny przez Waitress
- `backend/backfill.py` - reczny import historii
- `backend/app/routes` - endpointy API
- `backend/app/services` - logika Influx, energii, auth i historii
- `backend/app/storage/sqlite_repository.py` - lokalny cache historii
- `frontend/src` - UI operatora, kiosk, logowanie, i18n
- `frontend/public/vendor/tabler` - lokalny, offline Tabler
- `frontend/nginx.conf` - serwowanie SPA i proxy `/api` w prod
- `scripts/dev_*.sh` - start developerski
- `scripts/prod_*.sh` - build / start produkcyjny
## Konfiguracja
1. Skopiuj `.env.example` do `.env`
2. Uzupelnij polaczenie do InfluxDB
3. Ustaw encje PV
4. Ustaw login i haslo do panelu
Najwazniejsze pola:
- `INFLUXDB_*`
- `PV_AC_POWER_ENTITY`
- `PV_TOTAL_ENERGY_ENTITY`
- `PV_STRING_1_*`, `PV_STRING_2_*`, `PV_STRING_3_*`, `PV_STRING_4_*`
- `PV_INVERTER_TEMP_ENTITY` opcjonalnie
- `APP_SQLITE_PATH`
- `HISTORY_*`
- `AUTH_USERNAME`
- `AUTH_PASSWORD` lub `AUTH_PASSWORD_HASH`
- `APP_SECRET_KEY`
- `AUTH_COOKIE_SECURE`
Dla HTTPS w produkcji ustaw `AUTH_COOKIE_SECURE=true`. Flask wyraznie zaznacza, ze w produkcji nie nalezy uzywac wbudowanego dev servera, tylko dedykowany serwer WSGI, a Waitress dobrze pasuje do tego projektu jako prosty, czysto pythonowy serwer WSGI.
## Dev bez Dockera
### Backend
```bash
cp .env.example .env
./scripts/dev_backend.sh
```
### Frontend
W drugim terminalu:
```bash
./scripts/dev_frontend.sh
```
### Razem
```bash
./scripts/dev.sh
```
### Demo UI bez backendu
```bash
./scripts/dev_frontend_demo.sh
```
## Lokalny smoke-test builda bez Dockera
### Backend przez Waitress
```bash
./scripts/prod_backend.sh
```
### Build frontendu
```bash
./scripts/prod_frontend_build.sh
```
### Podglad zbudowanego frontendu
```bash
./scripts/prod_frontend_preview.sh
```
Po buildzie frontend statyczny bedzie w `frontend/dist`. To jest dobre do lokalnego sprawdzenia builda. Docelowy deploy produkcyjny jest opisany nizej w sekcji Docker / deploy produkcyjny.
## Docker / deploy produkcyjny
Domyslny `docker-compose.yml` jest przygotowany pod prosty deploy produkcyjny:
- backend: `Flask + Waitress`
- frontend: `nginx` serwujacy build Vite
- `nginx` proxuje `/api/*` do backendu
- Tabler jest lokalny, wiec nie ma zaleznosci od CDN
Start:
```bash
docker compose up -d --build
```
UI bedzie pod adresem:
```text
http://localhost:8080
```
Zatrzymanie:
```bash
./scripts/prod_down.sh
```
## Docker dev
Jesli chcesz kontenery developerskie:
```bash
docker compose -f docker-compose.dev.yml up --build
```
## Import historii
Z UI: zakladka `Ustawienia` -> `Import archiwalny z InfluxDB`
Z CLI:
```bash
./scripts/import_history.sh --start-date 2022-01-01 --end-date 2025-12-31 --chunk-days 7
```
## Uwagi
- jesli masz mniej stringow, ustaw tylko istniejace encje
- gdy temperatura falownika nie istnieje, modul temperatury schowa sie automatycznie
- kiosk zapisuje widok lokalnie w `localStorage`
- backend trzyma cache dziennych agregatow w SQLite
- frontend w dev na porcie `5173` automatycznie gada z backendem na tym samym hoscie, port `8105`
- frontend w prod korzysta domyslnie z `/api/v1`, czyli przez proxy nginx
##
cd backend
export FLASK_APP=app.main:app
python -m flask create-admin --username admin2 --password 'SuperHaslo123'
python -m flask create-user --username user1 --password 'SuperHaslo123'
python -m flask reset-password --username user1 --password 'NoweHaslo123'
## Produkcja
Uruchomienie produkcyjne przez reverse proxy:
```bash
docker compose -f docker-compose.prod.yml up -d --build
```

13
backend/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.venv
venv
__pycache__
*.pyc
*.pyo
*.pyd
*.log
.env
data
*.sqlite3
*.db
*.db-shm
*.db-wal

15
backend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r /app/requirements.txt
COPY . /app
EXPOSE 8105
CMD ["python", "run_prod.py"]

12
backend/Dockerfile.dev Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r /app/requirements.txt
COPY . /app
EXPOSE 8105
CMD ["python", "run.py"]

3
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from app.main import app
__all__ = ["app"]

193
backend/app/app_factory.py Normal file
View File

@@ -0,0 +1,193 @@
from __future__ import annotations
import logging
import os
from logging.config import dictConfig
import click
from flask import Flask, jsonify, make_response, request, session
from werkzeug.exceptions import HTTPException
from app.core_settings import get_settings
from app.routes import (
analytics_blueprint,
auth_blueprint,
dashboard_blueprint,
health_blueprint,
historical_blueprint,
realtime_blueprint,
)
from app.services.auth import get_auth_service
from app.services.historical_sync import get_historical_sync_service
def configure_logging(debug: bool) -> None:
level = "DEBUG" if debug else "INFO"
dictConfig(
{
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {"format": "%(asctime)s | %(levelname)s | %(name)s | %(message)s"}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
"level": level,
}
},
"root": {"handlers": ["console"], "level": level},
}
)
def create_app() -> Flask:
settings = get_settings()
configure_logging(settings.debug)
app = Flask(__name__)
app.config["JSON_SORT_KEYS"] = False
get_auth_service().configure_app(app)
app.register_blueprint(health_blueprint)
app.register_blueprint(auth_blueprint, url_prefix=settings.api_prefix)
app.register_blueprint(dashboard_blueprint, url_prefix=settings.api_prefix)
app.register_blueprint(realtime_blueprint, url_prefix=settings.api_prefix)
app.register_blueprint(analytics_blueprint, url_prefix=settings.api_prefix)
app.register_blueprint(historical_blueprint, url_prefix=settings.api_prefix)
@app.get("/")
def index():
return {
"app": settings.app_name,
"version": settings.version,
"api_prefix": settings.api_prefix,
"message": "PV Insight backend is running",
}
@app.before_request
def handle_preflight_and_auth():
if request.method == "OPTIONS":
response = make_response("", 204)
return _apply_cors(response)
if not settings.auth["enabled"]:
return None
if request.path in {"/", "/health", "/favicon.ico"}:
return None
if request.path.startswith(f"{settings.api_prefix}/auth/"):
return None
public_kiosk = request.args.get("publicKiosk") == "1"
public_kiosk_allowed_paths = {
f"{settings.api_prefix}/dashboard/config",
f"{settings.api_prefix}/dashboard/kiosk-settings",
f"{settings.api_prefix}/realtime/snapshot",
f"{settings.api_prefix}/realtime/history",
f"{settings.api_prefix}/analytics/production",
f"{settings.api_prefix}/analytics/distribution",
}
if public_kiosk and request.method == "GET" and request.path in public_kiosk_allowed_paths:
return None
if request.path.startswith(settings.api_prefix) and "auth_user" not in session:
return _apply_cors(make_response(jsonify({"detail": "Authentication required"}), 401))
return None
@app.after_request
def append_cors_headers(response):
return _apply_cors(response)
@app.errorhandler(HTTPException)
def handle_http_exception(exc: HTTPException):
response = jsonify({"detail": exc.description})
return _apply_cors(make_response(response, exc.code or 500))
@app.errorhandler(Exception)
def handle_exception(exc: Exception):
logging.getLogger(__name__).exception("Unhandled application error")
response = {"detail": str(exc) if settings.debug else "Internal server error"}
return _apply_cors(make_response(response, 500))
_register_cli_commands(app)
_bootstrap_background_services(settings.debug)
return app
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("--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):
try:
user = auth_service.create_user(
username=username,
password=password,
role="admin",
display_name=display_name,
)
click.echo(f"Utworzono konto admina: {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
@app.cli.command("create-user")
@click.option("--username", required=True, help="Login")
@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):
try:
user = auth_service.create_user(
username=username,
password=password,
role="user",
display_name=display_name,
)
click.echo(f"Admin 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
@app.cli.command("reset-password")
@click.option("--username", required=True, help="Login")
@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}")
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
def _bootstrap_background_services(debug: bool) -> None:
should_run = (not debug) or os.environ.get("WERKZEUG_RUN_MAIN") == "true"
if not should_run:
return
get_historical_sync_service().start_scheduler_if_enabled()
def _apply_cors(response):
settings = get_settings()
origin = request.headers.get("Origin")
allowed = settings.cors_origins
if origin and (origin in allowed or "*" in allowed):
response.headers["Access-Control-Allow-Origin"] = origin
response.headers.add("Vary", "Origin")
elif "*" in allowed:
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, PUT"
response.headers["Access-Control-Allow-Credentials"] = "true"
return response

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
import config
from app.models.definitions import MetricDefinition
@dataclass(frozen=True)
class AppSettings:
app_name: str
version: str
debug: bool
api_prefix: str
timezone: str
host: str
port: int
site_name: str
installed_power_kwp: float
co2_factor: float
influx: dict
storage: dict
cors_origins: list[str]
modules: dict
realtime: dict
time_ranges: dict
analytics: dict
history: dict
strings: list[dict]
status_metrics: list[str]
visible_entity_table: list[str]
frontend_defaults: dict
auth: dict
i18n: dict
metrics: dict[str, MetricDefinition]
@lru_cache(maxsize=1)
def get_settings() -> AppSettings:
metric_catalog = {
metric_id: MetricDefinition(id=metric_id, **payload)
for metric_id, payload in config.METRICS.items()
}
return AppSettings(
app_name=config.APP_CONFIG["name"],
version=config.APP_CONFIG["version"],
debug=config.APP_CONFIG["debug"],
api_prefix=config.APP_CONFIG["api_prefix"],
timezone=config.APP_CONFIG["timezone"],
host=config.APP_CONFIG["host"],
port=config.APP_CONFIG["port"],
site_name=config.SITE_CONFIG["site_name"],
installed_power_kwp=config.SITE_CONFIG["installed_power_kwp"],
co2_factor=config.SITE_CONFIG["co2_factor_kg_per_kwh"],
influx=config.INFLUXDB_CONFIG,
storage=config.STORAGE_CONFIG,
cors_origins=config.CORS_ORIGINS,
modules=config.MODULES,
realtime=config.REALTIME,
time_ranges=config.TIME_RANGES,
analytics=config.ANALYTICS,
history=config.HISTORY,
strings=config.STRINGS,
status_metrics=config.STATUS_METRICS,
visible_entity_table=config.VISIBLE_ENTITY_TABLE,
frontend_defaults=config.FRONTEND_DEFAULTS,
auth=config.AUTH_CONFIG,
i18n=config.I18N,
metrics=metric_catalog,
)

5
backend/app/main.py Normal file
View File

@@ -0,0 +1,5 @@
from __future__ import annotations
from app.app_factory import create_app
app = create_app()

View File

@@ -0,0 +1,33 @@
from .definitions import (
AnalyticsSummary,
BucketPoint,
DailyEnergyRecord,
HeroCard,
HistoricalActivityEvent,
HistoricalChunkProgress,
HistoricalCoverage,
HistoricalImportStatus,
MetricDefinition,
MetricValue,
SeriesPayload,
SeriesPoint,
SnapshotGroupRow,
SnapshotPayload,
)
__all__ = [
"AnalyticsSummary",
"BucketPoint",
"DailyEnergyRecord",
"HeroCard",
"HistoricalActivityEvent",
"HistoricalChunkProgress",
"HistoricalCoverage",
"HistoricalImportStatus",
"MetricDefinition",
"MetricValue",
"SeriesPayload",
"SeriesPoint",
"SnapshotGroupRow",
"SnapshotPayload",
]

View File

@@ -0,0 +1,174 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Any, Literal
MetricKind = Literal["gauge", "counter", "text"]
@dataclass(frozen=True)
class MetricDefinition:
id: str
entity_id: str
measurement: str
unit: str = ""
label: str = ""
kind: MetricKind = "gauge"
precision: int = 2
enabled: bool = True
@dataclass
class MetricValue:
metric_id: str
label: str
unit: str = ""
value: float | str | None = None
timestamp: datetime | None = None
precision: int = 2
kind: MetricKind = "gauge"
status: str = "neutral"
@dataclass
class SeriesPoint:
timestamp: datetime
value: float | None = None
@dataclass
class SeriesPayload:
metric_id: str
label: str
unit: str
points: list[SeriesPoint] = field(default_factory=list)
@dataclass
class SnapshotGroupRow:
id: str
label: str
values: dict[str, MetricValue] = field(default_factory=dict)
meta: dict[str, Any] = field(default_factory=dict)
@dataclass
class HeroCard:
metric_id: str
label: str
value: float | str | None = None
unit: str = ""
accent: str = "neutral"
subtitle: str = ""
@dataclass
class SnapshotPayload:
updated_at: datetime | None = None
hero_cards: list[HeroCard] = field(default_factory=list)
kpis: dict[str, MetricValue] = field(default_factory=dict)
strings: list[SnapshotGroupRow] = field(default_factory=list)
phases: list[SnapshotGroupRow] = field(default_factory=list)
status: list[MetricValue] = field(default_factory=list)
faults: list[str] = field(default_factory=list)
@dataclass
class BucketPoint:
label: str
start: datetime
end: datetime
value: float
@dataclass
class AnalyticsSummary:
total: float = 0.0
unit: str = "kWh"
average_bucket: float = 0.0
best_bucket_label: str = ""
best_bucket_value: float = 0.0
co2_saved_kg: float = 0.0
comparison_total: float | None = None
comparison_delta_pct: float | None = None
@dataclass
class DailyEnergyRecord:
day: date
energy_kwh: float
source: str
samples_count: int
imported_at: datetime | None = None
@dataclass
class HistoricalCoverage:
imported_days: int = 0
first_day: date | None = None
last_day: date | None = None
total_energy_kwh: float = 0.0
available_days: int = 0
missing_days: int = 0
coverage_pct: float | None = None
@dataclass
class HistoricalChunkProgress:
chunk_index: int
total_chunks: int
start_date: date
end_date: date
processed_days: int = 0
imported_days: int = 0
skipped_days: int = 0
energy_kwh: float = 0.0
state: str = "pending"
started_at: datetime | None = None
finished_at: datetime | None = None
duration_seconds: float | None = None
note: str = ""
@dataclass
class HistoricalActivityEvent:
timestamp: datetime
level: str = "info"
title: str = ""
message: str = ""
day: date | None = None
chunk_index: int | None = None
@dataclass
class HistoricalImportStatus:
enabled: bool = True
running: bool = False
state: str = "idle"
job_id: str | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
requested_start_date: date | None = None
requested_end_date: date | None = None
total_days: int = 0
processed_days: int = 0
imported_days: int = 0
skipped_days: int = 0
chunk_days: int = 1
total_chunks: int = 0
active_chunk_index: int = 0
current_date: date | None = None
current_chunk_start: date | None = None
current_chunk_end: date | None = None
elapsed_seconds: float | None = None
estimated_remaining_seconds: float | None = None
avg_days_per_minute: float | None = None
last_error: str | None = None
message: str = ""
coverage: HistoricalCoverage = field(default_factory=HistoricalCoverage)
available_start_date: date | None = None
available_end_date: date | None = None
default_chunk_days: int = 1
recent_chunks: list[HistoricalChunkProgress] = field(default_factory=list)
recent_events: list[HistoricalActivityEvent] = field(default_factory=list)

View File

@@ -0,0 +1,15 @@
from .analytics import analytics_blueprint
from .auth import auth_blueprint
from .dashboard import dashboard_blueprint
from .health import health_blueprint
from .historical import historical_blueprint
from .realtime import realtime_blueprint
__all__ = [
"auth_blueprint",
"analytics_blueprint",
"dashboard_blueprint",
"health_blueprint",
"historical_blueprint",
"realtime_blueprint",
]

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import json
from flask import Blueprint, jsonify, request
from app.services.analytics import AnalyticsService
from app.utils.serialization import to_plain
analytics_blueprint = Blueprint("analytics", __name__)
service = AnalyticsService()
@analytics_blueprint.get("/analytics/production")
def production_analytics():
range_key = request.args.get("range", "30d")
bucket = request.args.get("bucket", "day")
compare = request.args.get("compare", "none")
start = request.args.get("start")
end = request.args.get("end")
compare_ranges_raw = request.args.get("compare_ranges", "")
compare_ranges = []
if compare_ranges_raw:
try:
compare_ranges = json.loads(compare_ranges_raw)
except json.JSONDecodeError as exc:
return jsonify({"detail": f"Invalid compare_ranges payload: {exc}"}), 400
try:
return jsonify(
to_plain(
service.production(
range_key=range_key,
bucket=bucket,
compare_mode=compare,
start=start,
end=end,
compare_ranges=compare_ranges,
)
)
)
except ValueError as exc:
return jsonify({"detail": str(exc)}), 400
@analytics_blueprint.get("/analytics/distribution")
def production_distribution():
range_key = request.args.get("range", "30d")
bucket = request.args.get("bucket", "day")
start = request.args.get("start")
end = request.args.get("end")
try:
return jsonify(
to_plain(
service.distribution(
range_key=range_key,
bucket=bucket,
start=start,
end=end,
)
)
)
except ValueError as exc:
return jsonify({"detail": str(exc)}), 400

View File

@@ -0,0 +1,80 @@
from __future__ import annotations
from flask import Blueprint, jsonify, request
from app.services.auth import get_auth_service
from app.utils.serialization import to_plain
auth_blueprint = Blueprint("auth", __name__)
service = get_auth_service()
@auth_blueprint.get("/auth/status")
def auth_status():
return jsonify(to_plain(service.status()))
@auth_blueprint.post("/auth/login")
def auth_login():
payload = request.get_json(silent=True) or {}
try:
status = service.login(payload.get("username", ""), payload.get("password", ""))
return jsonify(to_plain(status))
except ValueError as exc:
return jsonify({"detail": str(exc)}), 401
@auth_blueprint.post("/auth/logout")
def auth_logout():
return jsonify(to_plain(service.logout()))
@auth_blueprint.get("/auth/users")
def list_users():
try:
service.require_admin()
return jsonify(to_plain({"items": service.list_users()}))
except PermissionError as exc:
return jsonify({"detail": str(exc)}), 403
@auth_blueprint.post("/auth/users")
def create_user():
payload = request.get_json(silent=True) or {}
try:
service.require_admin()
user = service.create_user(
username=payload.get("username", ""),
password=payload.get("password", ""),
role=payload.get("role", "user"),
display_name=payload.get("display_name") or payload.get("username") or "",
)
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
@auth_blueprint.post("/auth/users/<username>/reset-password")
def reset_password(username: str):
payload = request.get_json(silent=True) or {}
try:
service.require_admin()
user = service.reset_password(username=username, new_password=payload.get("password", ""))
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

@@ -0,0 +1,80 @@
from __future__ import annotations
from flask import Blueprint, jsonify, request
from app.core_settings import get_settings
from app.services.capabilities import build_capabilities
from app.services.catalog import get_catalog
from app.services.kiosk_settings import get_kiosk_settings_service
from app.services.auth import get_auth_service
from app.utils.serialization import to_plain
dashboard_blueprint = Blueprint("dashboard", __name__)
@dashboard_blueprint.get("/dashboard/config")
def dashboard_config():
settings = get_settings()
catalog = get_catalog()
capabilities = build_capabilities(catalog)
payload = {
"app": {
"name": settings.app_name,
"version": settings.version,
"site_name": settings.site_name,
"timezone": settings.timezone,
"installed_power_kwp": settings.installed_power_kwp,
},
"defaults": {
"realtime_range": settings.realtime["history_default_range"],
"analytics_range": settings.analytics["default_range"],
"analytics_bucket": settings.analytics["default_bucket"],
"tab": settings.frontend_defaults["tab"],
"theme": settings.frontend_defaults["theme"],
"language": settings.frontend_defaults["language"],
},
"auth": {
"enabled": settings.auth["enabled"],
},
"i18n": settings.i18n,
"capabilities": capabilities,
"visible_entities": [
{
"metric_id": metric.id,
"label": metric.label,
"entity_id": metric.entity_id,
"measurement": metric.measurement,
"unit": metric.unit,
"kind": metric.kind,
}
for metric in catalog.visible_entities()
],
}
return jsonify(to_plain(payload))
@dashboard_blueprint.get("/dashboard/kiosk-settings")
def dashboard_kiosk_settings():
requested_mode = request.args.get("mode") or ("public" if request.args.get("publicKiosk") == "1" else "private")
try:
payload = get_kiosk_settings_service().get(requested_mode)
return jsonify(to_plain(payload))
except ValueError as exc:
return jsonify({"detail": str(exc)}), 400
@dashboard_blueprint.put("/dashboard/kiosk-settings")
def update_dashboard_kiosk_settings():
payload = request.get_json(silent=True) or {}
mode = payload.get("mode", "private")
auth_service = get_auth_service()
try:
auth_service.require_admin()
updated = get_kiosk_settings_service().update_from_session(mode, payload)
return jsonify(to_plain(updated))
except PermissionError as exc:
return jsonify({"detail": str(exc)}), 403
except ValueError as exc:
return jsonify({"detail": str(exc)}), 400

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from flask import Blueprint, jsonify
from app.core_settings import get_settings
health_blueprint = Blueprint("health", __name__)
@health_blueprint.get("/health")
def health():
settings = get_settings()
return jsonify({
"status": "ok",
"app": settings.app_name,
"version": settings.version,
})

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from datetime import date
from flask import Blueprint, jsonify, request
from app.services.historical_sync import get_historical_sync_service
from app.utils.serialization import to_plain
historical_blueprint = Blueprint("historical", __name__)
service = get_historical_sync_service()
@historical_blueprint.get("/historical/status")
def historical_status():
return jsonify(to_plain(service.status()))
@historical_blueprint.post("/historical/start")
def historical_start():
payload = request.get_json(silent=True) or {}
try:
status = service.start(
start_date=_parse_date(payload.get("start_date")),
end_date=_parse_date(payload.get("end_date")),
chunk_days=payload.get("chunk_days"),
force=bool(payload.get("force", False)),
)
return jsonify(to_plain(status))
except ValueError as exc:
return jsonify({"detail": str(exc)}), 400
except RuntimeError as exc:
return jsonify({"detail": str(exc)}), 400
@historical_blueprint.post("/historical/sync-now")
def historical_sync_now():
try:
status = service.start(auto=True)
return jsonify(to_plain(status))
except RuntimeError as exc:
return jsonify({"detail": str(exc)}), 400
@historical_blueprint.post("/historical/cancel")
def historical_cancel():
return jsonify(to_plain(service.cancel()))
def _parse_date(value: str | None) -> date | None:
if not value:
return None
return date.fromisoformat(value)

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from flask import Blueprint, jsonify, request
from app.services.realtime import RealtimeService
from app.utils.serialization import to_plain
realtime_blueprint = Blueprint("realtime", __name__)
service = RealtimeService()
@realtime_blueprint.get("/realtime/snapshot")
def realtime_snapshot():
return jsonify(to_plain(service.snapshot()))
@realtime_blueprint.get("/realtime/history")
def realtime_history():
range_key = request.args.get("range", "6h")
start = request.args.get("start")
end = request.args.get("end")
metrics = [item.strip() for item in request.args.get("metrics", "").split(",") if item.strip()]
try:
return jsonify(to_plain(service.history(range_key=range_key, start=start, end=end, metric_ids=metrics or None)))
except ValueError as exc:
return jsonify({"detail": str(exc)}), 400

View File

@@ -0,0 +1,5 @@
from .analytics import AnalyticsService
from .historical_sync import HistoricalSyncService
from .realtime import RealtimeService
__all__ = ["AnalyticsService", "HistoricalSyncService", "RealtimeService"]

View File

@@ -0,0 +1,140 @@
from __future__ import annotations
from app.core_settings import AppSettings, get_settings
from app.services.catalog import MetricCatalog, get_catalog
from app.services.energy import EnergyService
from app.services.influx_http import InfluxHTTPService
from app.services.metrics import compare_delta_pct
from app.utils.time import resolve_window, shift_window
class AnalyticsService:
def __init__(
self,
settings: AppSettings | None = None,
catalog: MetricCatalog | None = None,
influx: InfluxHTTPService | None = None,
energy: EnergyService | None = None,
) -> None:
self.settings = settings or get_settings()
self.catalog = catalog or get_catalog()
self.influx = influx or InfluxHTTPService(self.settings)
self.energy = energy or EnergyService(self.settings, self.catalog, self.influx)
def production(
self,
range_key: str | None = None,
bucket: str | None = None,
compare_mode: str = "none",
start: str | None = None,
end: str | None = None,
compare_ranges: list[dict] | None = None,
) -> dict:
bucket = bucket or self.settings.analytics["default_bucket"]
if bucket not in self.settings.analytics["bucket_labels"]:
raise ValueError(f"Unsupported bucket: {bucket}")
window = resolve_window(range_key=range_key, start=start, end=end)
current_days = self.energy.daily_records_for_window(window.start, window.end, persist_missing=True)
current = self.energy.bucketize_daily(current_days, bucket)
total = round(sum(item.value for item in current), 2)
comparison = []
comparison_total = None
comparison_delta_pct = None
comparisons = []
if compare_mode == "custom_multi":
for index, item in enumerate(compare_ranges or []):
compare_start = item.get("start")
compare_end = item.get("end")
if not compare_start or not compare_end:
continue
compare_window = resolve_window(start=compare_start, end=compare_end)
comparison_days = self.energy.daily_records_for_window(compare_window.start, compare_window.end, persist_missing=True)
comparison_series = self.energy.bucketize_daily(comparison_days, bucket)
comparison_total_value = round(sum(point.value for point in comparison_series), 2)
comparisons.append({
"key": item.get("key") or f"custom_{index + 1}",
"label": item.get("label") or f"Custom {index + 1}",
"start": compare_window.start,
"end": compare_window.end,
"total": comparison_total_value,
"delta_pct": compare_delta_pct(total, comparison_total_value),
"points": comparison_series,
})
if comparisons:
comparison = comparisons[0]["points"]
comparison_total = comparisons[0]["total"]
comparison_delta_pct = comparisons[0]["delta_pct"]
elif compare_mode != "none":
compare_window = shift_window(window, compare_mode)
comparison_days = self.energy.daily_records_for_window(compare_window.start, compare_window.end, persist_missing=True)
comparison = self.energy.bucketize_daily(comparison_days, bucket)
comparison_total = round(sum(item.value for item in comparison), 2)
comparison_delta_pct = compare_delta_pct(total, comparison_total)
comparisons.append({
"key": compare_mode,
"label": compare_mode,
"start": compare_window.start,
"end": compare_window.end,
"total": comparison_total,
"delta_pct": comparison_delta_pct,
"points": comparison,
})
average_bucket = round(total / len(current), 2) if current else 0.0
best_bucket = max(current, key=lambda item: item.value, default=None)
return {
"unit": "kWh",
"bucket": bucket,
"compare_mode": compare_mode,
"current": current,
"comparison": comparison,
"comparisons": comparisons,
"summary": {
"total": total,
"unit": "kWh",
"average_bucket": average_bucket,
"best_bucket_label": best_bucket.label if best_bucket else "",
"best_bucket_value": best_bucket.value if best_bucket else 0.0,
"co2_saved_kg": round(total * self.settings.co2_factor, 2),
"comparison_total": comparison_total,
"comparison_delta_pct": comparison_delta_pct,
},
"meta": {
"window": {
"start": window.start,
"end": window.end,
"range_key": window.key,
},
"source": "sqlite_cache_plus_live_influx",
},
}
def distribution(
self,
range_key: str | None = None,
bucket: str | None = None,
start: str | None = None,
end: str | None = None,
) -> dict:
payload = self.production(range_key=range_key, bucket=bucket, compare_mode="none", start=start, end=end)
current = payload["current"]
total = round(sum(item.value for item in current), 2)
denominator = total or 1.0
return {
"unit": payload["unit"],
"bucket": payload["bucket"],
"total": total,
"slices": [
{
"label": item.label,
"value": item.value,
"share": round((item.value / denominator) * 100.0, 2),
}
for item in current
if item.value > 0
],
"meta": payload["meta"],
}

View File

@@ -0,0 +1,179 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any
from flask import session
from werkzeug.security import check_password_hash, generate_password_hash
from app.core_settings import AppSettings, get_settings
from app.storage.auth_users import AuthUser, SQLiteAuthUserRepository
SESSION_USER_KEY = "auth_user"
SESSION_DISPLAY_NAME_KEY = "auth_display_name"
SESSION_ROLE_KEY = "auth_role"
VALID_ROLES = {"admin", "user"}
class AuthService:
def __init__(self, settings: AppSettings | None = None) -> None:
self.settings = settings or get_settings()
self.user_repository = SQLiteAuthUserRepository(self.settings.storage["sqlite_path"])
@property
def enabled(self) -> bool:
return bool(self.settings.auth["enabled"])
def status(self) -> dict[str, Any]:
if not self.enabled:
return {
"enabled": False,
"authenticated": True,
"user": None,
"display_name": None,
"role": None,
}
return {
"enabled": True,
"authenticated": SESSION_USER_KEY in session,
"user": session.get(SESSION_USER_KEY),
"display_name": session.get(SESSION_DISPLAY_NAME_KEY),
"role": session.get(SESSION_ROLE_KEY),
}
def login(self, username: str, password: str) -> dict[str, Any]:
if not self.enabled:
return self.status()
username = (username or "").strip()
password = password or ""
user = self.user_repository.get_by_username(username)
if user is None:
self._login_legacy_user(username, password)
else:
if not user.is_active:
raise ValueError("Konto jest nieaktywne")
if not check_password_hash(user.password_hash, password):
raise ValueError("Niepoprawny login lub haslo")
self._set_session(user.username, user.display_name, user.role)
return self.status()
def logout(self) -> dict[str, Any]:
session.clear()
return self.status()
def list_users(self) -> list[dict[str, Any]]:
users = self.user_repository.list_users()
return [
{
"username": user.username,
"display_name": user.display_name,
"role": user.role,
"is_active": user.is_active,
"created_at": user.created_at,
"updated_at": user.updated_at,
}
for user in users
]
def require_admin(self) -> None:
if not self.enabled:
return
if session.get(SESSION_ROLE_KEY) != "admin":
raise PermissionError("Brak uprawnien administratora")
def configure_app(self, app) -> None:
max_age = int(self.settings.auth["session_max_age_seconds"])
app.secret_key = self.settings.auth["secret_key"]
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(seconds=max_age)
app.config["SESSION_COOKIE_NAME"] = self.settings.auth["session_cookie_name"]
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = self.settings.auth.get("cookie_samesite", "Lax")
app.config["SESSION_COOKIE_SECURE"] = bool(self.settings.auth.get("cookie_secure", False))
def create_user(self, *, username: str, password: str, role: str, display_name: str | None = None) -> AuthUser:
normalized_username = self._normalize_username(username)
normalized_role = self._normalize_role(role)
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")
return self.user_repository.upsert_user(
username=normalized_username,
password_hash=generate_password_hash(clean_password),
role=normalized_role,
display_name=resolved_display_name,
is_active=True,
)
def reset_password(self, *, username: str, new_password: str) -> AuthUser:
normalized_username = self._normalize_username(username)
clean_password = self._validate_password(new_password)
user = self.user_repository.update_password(
normalized_username,
generate_password_hash(clean_password),
)
if user is None:
raise ValueError(f"Uzytkownik '{normalized_username}' nie istnieje")
return user
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")
if expected_password_hash:
password_ok = check_password_hash(expected_password_hash, password)
else:
password_ok = password == expected_password
if not password_ok:
raise ValueError("Niepoprawny login lub haslo")
self._set_session(
expected_username,
self.settings.auth.get("display_name") or expected_username,
self.settings.auth.get("role", "admin"),
)
def _set_session(self, username: str, display_name: str, role: str) -> None:
session.clear()
session.permanent = True
session[SESSION_USER_KEY] = username
session[SESSION_DISPLAY_NAME_KEY] = display_name
session[SESSION_ROLE_KEY] = role
def _normalize_username(self, username: str) -> str:
normalized = (username or "").strip()
if not normalized:
raise ValueError("Username nie moze byc pusty")
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")
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")
return clean_password
_auth_service: AuthService | None = None
def get_auth_service() -> AuthService:
global _auth_service
if _auth_service is None:
_auth_service = AuthService()
return _auth_service

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from app.services.catalog import MetricCatalog, get_catalog
def build_capabilities(catalog: MetricCatalog | None = None) -> dict:
catalog = catalog or get_catalog()
settings = catalog.settings
string_rows = []
for item in settings.strings:
metric_ids = list(item.get("metrics", {}).values())
if any(catalog.safe_get(metric_id) for metric_id in metric_ids):
string_rows.append(item)
analytics_enabled = settings.modules.get("analytics", False)
return {
"modules": settings.modules,
"strings_enabled": settings.modules.get("strings", False) and len(string_rows) > 0,
"strings_count": len(string_rows),
"phases_enabled": False,
"phases_count": 0,
"analytics_enabled": analytics_enabled,
"realtime_enabled": settings.modules.get("realtime_overview", False),
"comparison_modes": list(settings.analytics["compare_modes"].keys()),
"ranges": [
{"key": key, "label": definition["label"]}
for key, definition in settings.time_ranges.items()
],
"buckets": [
{"key": key, "label": label}
for key, label in settings.analytics["bucket_labels"].items()
],
"historical_import_enabled": settings.modules.get("historical_import", False),
"history": {
"enabled": settings.history.get("enabled", True),
"default_chunk_days": settings.history.get("default_chunk_days", 7),
"auto_sync_enabled": settings.history.get("auto_sync_enabled", False),
"auto_sync_interval_minutes": settings.history.get("auto_sync_interval_minutes", 30),
},
}

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from dataclasses import dataclass
from app.core_settings import AppSettings, get_settings
from app.models.definitions import MetricDefinition
@dataclass
class MetricCatalog:
settings: AppSettings
def get(self, metric_id: str) -> MetricDefinition:
if metric_id not in self.settings.metrics:
raise KeyError(f"Unknown metric: {metric_id}")
return self.settings.metrics[metric_id]
def safe_get(self, metric_id: str) -> MetricDefinition | None:
return self.settings.metrics.get(metric_id)
def visible_entities(self) -> list[MetricDefinition]:
return [self.get(metric_id) for metric_id in self.settings.visible_entity_table if metric_id in self.settings.metrics]
def get_catalog() -> MetricCatalog:
return MetricCatalog(get_settings())

View File

@@ -0,0 +1,220 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta
from zoneinfo import ZoneInfo
from app.core_settings import AppSettings, get_settings
from app.models.definitions import BucketPoint, DailyEnergyRecord, MetricDefinition, SeriesPoint
from app.services.catalog import MetricCatalog, get_catalog
from app.services.influx_http import InfluxHTTPService
from app.services.metrics import to_float
from app.storage import SQLiteEnergyRepository
from app.utils.time import (
choose_counter_interval,
choose_power_interval,
duration_to_seconds,
now_local,
)
@dataclass
class EnergySample:
timestamp: datetime
delta_kwh: float
class EnergyService:
def __init__(
self,
settings: AppSettings | None = None,
catalog: MetricCatalog | None = None,
influx: InfluxHTTPService | None = None,
repository: SQLiteEnergyRepository | None = None,
) -> None:
self.settings = settings or get_settings()
self.catalog = catalog or get_catalog()
self.influx = influx or InfluxHTTPService(self.settings)
self.repository = repository or SQLiteEnergyRepository(self.settings.storage["sqlite_path"])
self.tz = ZoneInfo(self.settings.timezone)
def total_for_window(self, start: datetime, end: datetime) -> float:
total, _, _ = self.window_total_with_meta(start, end)
return total
def window_total_with_meta(self, start: datetime, end: datetime) -> tuple[float, str, int]:
samples, source, observations_count = self._samples_for_window(start, end)
return round(sum(sample.delta_kwh for sample in samples), 2), source, observations_count
def total_for_full_day(self, day: date) -> tuple[float, str, int]:
start = datetime.combine(day, time.min, tzinfo=self.tz)
end = start + timedelta(days=1)
return self.window_total_with_meta(start, end)
def samples(self, start: datetime, end: datetime) -> list[EnergySample]:
samples, _, _ = self._samples_for_window(start, end)
return samples
def daily_records_for_window(
self,
start: datetime,
end: datetime,
*,
persist_missing: bool = True,
) -> list[DailyEnergyRecord]:
start_local = start.astimezone(self.tz)
end_local = end.astimezone(self.tz)
if end_local <= start_local:
return []
start_day = start_local.date()
end_day = end_local.date()
cached = self.repository.fetch_daily_energy(start_day, end_day)
today_local = now_local().date()
rows: list[DailyEnergyRecord] = []
current = start_day
while current <= end_day:
day_start = datetime.combine(current, time.min, tzinfo=self.tz)
day_end = day_start + timedelta(days=1)
segment_start = max(start_local, day_start)
segment_end = min(end_local, day_end)
if segment_end <= segment_start:
current = current + timedelta(days=1)
continue
is_full_day = segment_start == day_start and segment_end == day_end
cached_row = cached.get(current)
if is_full_day and cached_row is not None:
rows.append(cached_row)
else:
total, source, observations_count = self.window_total_with_meta(segment_start, segment_end)
record = DailyEnergyRecord(
day=current,
energy_kwh=total,
source=source,
samples_count=observations_count,
)
rows.append(record)
if is_full_day and persist_missing and current < today_local and observations_count > 0:
self.repository.upsert_daily_energy(record)
current = current + timedelta(days=1)
return rows
def bucketize_daily(self, records: list[DailyEnergyRecord], bucket: str) -> list[BucketPoint]:
grouped: dict[str, dict] = defaultdict(lambda: {"value": 0.0, "start": None, "end": None, "label": ""})
for record in records:
start = datetime.combine(record.day, time.min, tzinfo=self.tz)
if bucket == "day":
bucket_start = start
bucket_end = bucket_start + timedelta(days=1)
key = bucket_start.strftime("%Y-%m-%d")
label = bucket_start.strftime("%d.%m")
elif bucket == "week":
bucket_start = start - timedelta(days=start.weekday())
bucket_end = bucket_start + timedelta(days=7)
iso = bucket_start.isocalendar()
key = f"{iso.year}-W{iso.week:02d}"
label = key
elif bucket == "month":
bucket_start = start.replace(day=1)
if bucket_start.month == 12:
bucket_end = bucket_start.replace(year=bucket_start.year + 1, month=1)
else:
bucket_end = bucket_start.replace(month=bucket_start.month + 1)
key = bucket_start.strftime("%Y-%m")
label = key
elif bucket == "year":
bucket_start = start.replace(month=1, day=1)
bucket_end = bucket_start.replace(year=bucket_start.year + 1)
key = bucket_start.strftime("%Y")
label = key
else:
raise ValueError(f"Unsupported bucket: {bucket}")
current = grouped[key]
current["label"] = label
current["value"] += record.energy_kwh
current["start"] = bucket_start if current["start"] is None else min(current["start"], bucket_start)
current["end"] = bucket_end if current["end"] is None else max(current["end"], bucket_end)
rows = []
for key in sorted(grouped.keys()):
item = grouped[key]
rows.append(
BucketPoint(
label=item["label"],
start=item["start"],
end=item["end"],
value=round(item["value"], 2),
)
)
return rows
def _samples_for_window(self, start: datetime, end: datetime) -> tuple[list[EnergySample], str, int]:
counter_metric = self.catalog.safe_get(self.settings.analytics["production_metric_id"])
if counter_metric is not None:
samples, observations_count = self._samples_from_counter(counter_metric, start, end)
return samples, "counter", observations_count
power_metric = self.catalog.safe_get(self.settings.analytics["fallback_power_metric_id"])
if power_metric is not None:
samples, observations_count = self._samples_from_power(power_metric, start, end)
return samples, "power_estimated", observations_count
return [], "unavailable", 0
def _samples_from_counter(self, metric: MetricDefinition, start: datetime, end: datetime) -> tuple[list[EnergySample], int]:
interval = choose_counter_interval(start, end)
baseline = self.influx.last_before(metric, start)
series = self.influx.grouped_last_series(metric, start, end, interval)
points: list[SeriesPoint] = []
if baseline and baseline.value is not None:
points.append(SeriesPoint(timestamp=start, value=baseline.value))
else:
first_value = next((point.value for point in series if point.value is not None), None)
if first_value is not None:
points.append(SeriesPoint(timestamp=start, value=first_value))
points.extend(series)
samples: list[EnergySample] = []
previous_value = None
for point in points:
current_value = to_float(point.value)
if current_value is None:
continue
if previous_value is None:
previous_value = current_value
continue
delta = current_value - previous_value
previous_value = current_value
if delta <= 0:
continue
if point.timestamp < start or point.timestamp > end:
continue
samples.append(EnergySample(timestamp=point.timestamp, delta_kwh=round(delta, 6)))
observations_count = sum(1 for point in series if to_float(point.value) is not None)
return samples, observations_count
def _samples_from_power(self, metric: MetricDefinition, start: datetime, end: datetime) -> tuple[list[EnergySample], int]:
interval = choose_power_interval(start, end)
interval_seconds = duration_to_seconds(interval)
points = self.influx.gauge_history(metric, start, end, interval, aggregate="mean")
samples: list[EnergySample] = []
observations_count = 0
for point in points:
watts = to_float(point.value)
if watts is None:
continue
observations_count += 1
if watts <= 0:
continue
delta_kwh = watts * (interval_seconds / 3600.0) / 1000.0
samples.append(EnergySample(timestamp=point.timestamp, delta_kwh=round(delta_kwh, 6)))
return samples, observations_count

View File

@@ -0,0 +1,605 @@
from __future__ import annotations
import copy
import logging
import threading
import uuid
from datetime import date, datetime, timedelta
from functools import lru_cache
from math import ceil
from typing import Iterable
from app.core_settings import AppSettings, get_settings
from app.models import (
DailyEnergyRecord,
HistoricalActivityEvent,
HistoricalChunkProgress,
HistoricalImportStatus,
)
from app.services.catalog import MetricCatalog, get_catalog
from app.services.energy import EnergyService
from app.services.influx_http import InfluxHTTPService
from app.storage import SQLiteEnergyRepository
from app.utils.time import now_local
logger = logging.getLogger(__name__)
class HistoricalSyncService:
MAX_RECENT_CHUNKS = 18
MAX_RECENT_EVENTS = 40
def __init__(
self,
settings: AppSettings | None = None,
catalog: MetricCatalog | None = None,
influx: InfluxHTTPService | None = None,
energy: EnergyService | None = None,
repository: SQLiteEnergyRepository | None = None,
) -> None:
self.settings = settings or get_settings()
self.catalog = catalog or get_catalog()
self.influx = influx or InfluxHTTPService(self.settings)
self.energy = energy or EnergyService(self.settings, self.catalog, self.influx)
self.repository = repository or SQLiteEnergyRepository(self.settings.storage["sqlite_path"])
self._state_lock = threading.Lock()
self._worker: threading.Thread | None = None
self._cancel_event = threading.Event()
self._scheduler_stop = threading.Event()
self._scheduler: threading.Thread | None = None
self._available_bounds_cache: tuple[datetime, date | None, date | None] | None = None
self._state = HistoricalImportStatus(
enabled=self.settings.history.get("enabled", True),
state="idle",
default_chunk_days=self.settings.history.get("default_chunk_days", 7),
)
self._refresh_coverage()
self._refresh_available_bounds()
self._refresh_runtime_metrics()
def status(self) -> HistoricalImportStatus:
with self._state_lock:
self._refresh_coverage(lock_held=True)
self._refresh_available_bounds(lock_held=True)
self._refresh_runtime_metrics(lock_held=True)
return copy.deepcopy(self._state)
def start(
self,
*,
start_date: date | None = None,
end_date: date | None = None,
chunk_days: int | None = None,
force: bool = False,
auto: bool = False,
) -> HistoricalImportStatus:
if not self.settings.history.get("enabled", True):
raise RuntimeError("Historical import is disabled")
chunk_days = max(int(chunk_days or self.settings.history.get("default_chunk_days", 7)), 1)
resolved = self._resolve_range(start_date=start_date, end_date=end_date)
if resolved is None:
with self._state_lock:
self._state.running = False
self._state.state = "idle"
self._state.message = "Brak brakujacych dni do importu."
self._state.finished_at = datetime.utcnow()
self._refresh_coverage(lock_held=True)
self._refresh_available_bounds(lock_held=True)
self._refresh_runtime_metrics(lock_held=True)
return copy.deepcopy(self._state)
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"
with self._state_lock:
if self._worker and self._worker.is_alive():
return copy.deepcopy(self._state)
self._cancel_event = threading.Event()
self._state = HistoricalImportStatus(
enabled=True,
running=True,
state="running",
job_id=uuid.uuid4().hex[:12],
started_at=datetime.utcnow(),
requested_start_date=resolved_start,
requested_end_date=resolved_end,
total_days=total_days,
chunk_days=chunk_days,
total_chunks=total_chunks,
active_chunk_index=1,
current_chunk_start=resolved_start,
current_chunk_end=min(resolved_start + timedelta(days=chunk_days - 1), resolved_end),
message=start_message,
default_chunk_days=self.settings.history.get("default_chunk_days", 7),
recent_chunks=[],
recent_events=[],
)
self._refresh_coverage(lock_held=True)
self._refresh_available_bounds(lock_held=True)
self._refresh_runtime_metrics(lock_held=True)
self._worker = threading.Thread(
target=self._run_worker,
kwargs={
"start_date": resolved_start,
"end_date": resolved_end,
"chunk_days": chunk_days,
"force": force,
"auto": auto,
},
name="pv-historical-backfill",
daemon=True,
)
self._worker.start()
self._record_event(
level="info",
title="Uruchomiono zadanie",
message=f"Zakres {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk {chunk_days} dni",
)
return self.status()
def cancel(self) -> HistoricalImportStatus:
self._cancel_event.set()
with self._state_lock:
self._state.message = "Anulowanie zadania..."
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.")
return snapshot
def run_blocking(
self,
*,
start_date: date | None = None,
end_date: date | None = None,
chunk_days: int | None = None,
force: bool = False,
) -> HistoricalImportStatus:
resolved = self._resolve_range(start_date=start_date, end_date=end_date)
if resolved is None:
return self.status()
resolved_start, resolved_end = resolved
chunk_days = max(int(chunk_days or self.settings.history.get("default_chunk_days", 7)), 1)
total_days = (resolved_end - resolved_start).days + 1
total_chunks = max(ceil(total_days / chunk_days), 1)
with self._state_lock:
self._state = HistoricalImportStatus(
enabled=True,
running=True,
state="running",
job_id=uuid.uuid4().hex[:12],
started_at=datetime.utcnow(),
requested_start_date=resolved_start,
requested_end_date=resolved_end,
total_days=total_days,
chunk_days=chunk_days,
total_chunks=total_chunks,
default_chunk_days=self.settings.history.get("default_chunk_days", 7),
recent_chunks=[],
recent_events=[],
)
self._record_event(
level="info",
title="Uruchomiono zadanie",
message=f"Zakres {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk {chunk_days} dni",
)
self._run_worker(
start_date=resolved_start,
end_date=resolved_end,
chunk_days=chunk_days,
force=force,
auto=False,
)
return self.status()
def start_scheduler_if_enabled(self) -> None:
if not self.settings.history.get("enabled", True):
return
if not self.settings.history.get("auto_sync_enabled", False):
return
if self._scheduler and self._scheduler.is_alive():
return
self._scheduler_stop.clear()
self._scheduler = threading.Thread(target=self._scheduler_loop, name="pv-history-scheduler", daemon=True)
self._scheduler.start()
def _scheduler_loop(self) -> None:
interval_seconds = max(int(self.settings.history.get("auto_sync_interval_minutes", 30)), 1) * 60
if self.settings.history.get("auto_sync_on_start", False):
try:
self.start(auto=True)
except Exception as exc:
logger.warning("Unable to auto-start historical sync: %s", exc)
while not self._scheduler_stop.wait(interval_seconds):
try:
if self._worker and self._worker.is_alive():
continue
self.start(auto=True)
except Exception as exc:
logger.warning("Historical scheduler cycle failed: %s", exc)
def _run_worker(
self,
*,
start_date: date,
end_date: date,
chunk_days: int,
force: bool,
auto: bool,
) -> None:
total_chunks = max(ceil(((end_date - start_date).days + 1) / chunk_days), 1)
try:
chunk_index = 0
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.")
return
chunk_index += 1
chunk_end = min(chunk_start + timedelta(days=chunk_days - 1), end_date)
self._update_chunk(chunk_index, total_chunks, chunk_start, chunk_end)
imported, skipped, energy_kwh, cancelled = self._process_chunk(
chunk_index=chunk_index,
start_day=chunk_start,
end_day=chunk_end,
force=force,
)
if cancelled:
self._close_chunk(
chunk_index,
imported_days=imported,
skipped_days=skipped,
energy_kwh=energy_kwh,
state="cancelled",
note="Chunk zatrzymany podczas przetwarzania",
)
self._record_event(level="warn", title="Anulowano", message="Import archiwalny anulowany przez uzytkownika.")
self._finish("cancelled", running=False, message="Import archiwalny anulowany przez uzytkownika.")
return
self._close_chunk(
chunk_index,
imported_days=imported,
skipped_days=skipped,
energy_kwh=energy_kwh,
state="completed",
note=f"Chunk zakonczony: import {imported}, pominiete {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",
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)
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))
def _process_chunk(self, *, chunk_index: int, start_day: date, end_day: date, force: bool) -> tuple[int, int, float, bool]:
imported_days = 0
skipped_days = 0
energy_kwh = 0.0
for day in self._date_range(start_day, end_day):
if self._cancel_event.is_set():
return imported_days, skipped_days, energy_kwh, True
if not force and self.repository.has_day(day):
skipped_days += 1
self._advance_day(
day,
imported=False,
message=f"Pominieto {day.isoformat()} - dzien juz istnieje w cache",
level="warn",
title="Pominieto dzien",
chunk_index=chunk_index,
)
continue
total, source, samples_count = self.energy.total_for_full_day(day)
if samples_count <= 0:
skipped_days += 1
self._advance_day(
day,
imported=False,
message=f"Pominieto {day.isoformat()} - brak probek w InfluxDB",
level="warn",
title="Brak probek",
chunk_index=chunk_index,
)
continue
self.repository.upsert_daily_energy(
DailyEnergyRecord(
day=day,
energy_kwh=total,
source=source,
samples_count=samples_count,
)
)
imported_days += 1
energy_kwh += total
self._advance_day(
day,
imported=True,
message=f"Zaimportowano {day.isoformat()} ({total:.2f} kWh)",
level="success",
title="Zaimportowano dzien",
chunk_index=chunk_index,
energy_kwh=total,
)
return imported_days, skipped_days, round(energy_kwh, 3), False
def _advance_day(
self,
day: date,
*,
imported: bool,
message: str,
level: str,
title: str,
chunk_index: int,
energy_kwh: float | None = None,
) -> None:
with self._state_lock:
self._state.processed_days += 1
if imported:
self._state.imported_days += 1
else:
self._state.skipped_days += 1
self._state.current_date = day
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 ""
self._record_event(
level=level,
title=title,
message=f"{message}.{suffix}" if not message.endswith(".") else f"{message}{suffix}",
day=day,
chunk_index=chunk_index,
)
def _update_chunk(self, chunk_index: int, total_chunks: int, chunk_start: date, chunk_end: date) -> None:
chunk = HistoricalChunkProgress(
chunk_index=chunk_index,
total_chunks=total_chunks,
start_date=chunk_start,
end_date=chunk_end,
state="running",
started_at=datetime.utcnow(),
note=f"Aktywny 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._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()}",
chunk_index=chunk_index,
)
def _close_chunk(
self,
chunk_index: int,
*,
imported_days: int,
skipped_days: int,
energy_kwh: float,
state: str,
note: str,
) -> None:
with self._state_lock:
existing = self._find_chunk_locked(chunk_index)
started_at = existing.started_at if existing and existing.started_at else datetime.utcnow()
finished_at = datetime.utcnow()
processed_days = imported_days + skipped_days
duration_seconds = max((finished_at - started_at).total_seconds(), 0.0)
chunk = HistoricalChunkProgress(
chunk_index=chunk_index,
total_chunks=self._state.total_chunks,
start_date=existing.start_date if existing else self._state.current_chunk_start or self._state.requested_start_date or date.today(),
end_date=existing.end_date if existing else self._state.current_chunk_end or self._state.requested_end_date or date.today(),
processed_days=processed_days,
imported_days=imported_days,
skipped_days=skipped_days,
energy_kwh=round(energy_kwh, 3),
state=state,
started_at=started_at,
finished_at=finished_at,
duration_seconds=round(duration_seconds, 2),
note=note,
)
self._upsert_chunk_locked(chunk)
if state != "running":
self._state.message = note
self._refresh_runtime_metrics(lock_held=True)
def _finish(
self,
state: str,
*,
running: bool,
message: str,
last_error: str | None = None,
) -> None:
with self._state_lock:
self._state.running = running
self._state.state = state
self._state.finished_at = datetime.utcnow()
self._state.last_error = last_error
self._state.message = message
self._state.active_chunk_index = 0
self._refresh_coverage(lock_held=True)
self._refresh_available_bounds(lock_held=True)
self._refresh_runtime_metrics(lock_held=True)
def _resolve_range(self, *, start_date: date | None, end_date: date | None) -> tuple[date, date] | None:
today = now_local().date()
include_today = self.settings.history.get("include_today_in_sync", False)
default_end = today if include_today else today - timedelta(days=1)
resolved_end = end_date or default_end
if start_date is None:
coverage = self.repository.coverage()
if coverage.last_day:
resolved_start = coverage.last_day + timedelta(days=1)
else:
bootstrap_start = self.settings.history.get("bootstrap_start_date")
if bootstrap_start:
resolved_start = date.fromisoformat(bootstrap_start)
else:
available_start, _ = self._available_bounds()
resolved_start = available_start or resolved_end
else:
resolved_start = start_date
if resolved_start > resolved_end:
return None
return resolved_start, resolved_end
def _available_bounds(self) -> tuple[date | None, date | None]:
now_utc = datetime.utcnow()
cached = self._available_bounds_cache
if cached and (now_utc - cached[0]).total_seconds() < 300:
return cached[1], cached[2]
available_start: date | None = None
available_end: date | None = None
metric = self.catalog.safe_get(self.settings.analytics.get("production_metric_id", "energy_total"))
fallback = self.catalog.safe_get(self.settings.analytics.get("fallback_power_metric_id", "ac_power"))
source_metric = metric or fallback
if source_metric is not None:
first_point = self.influx.first_value(source_metric)
last_point = self.influx.last_value(source_metric)
available_start = first_point.timestamp.astimezone(self.energy.tz).date() if first_point else None
available_end = last_point.timestamp.astimezone(self.energy.tz).date() if last_point else None
self._available_bounds_cache = (now_utc, available_start, available_end)
return available_start, available_end
def _refresh_coverage(self, *, lock_held: bool = False) -> None:
coverage = self.repository.coverage()
available_start, available_end = self._available_bounds()
if available_start and available_end and available_start <= available_end:
available_days = (available_end - available_start).days + 1
missing_days = self.repository.count_missing_days(available_start, available_end)
coverage.available_days = available_days
coverage.missing_days = missing_days
imported_in_range = max(available_days - missing_days, 0)
coverage.coverage_pct = round((imported_in_range / available_days) * 100, 1) if available_days > 0 else None
else:
coverage.available_days = 0
coverage.missing_days = 0
coverage.coverage_pct = None
if lock_held:
self._state.coverage = coverage
else:
with self._state_lock:
self._state.coverage = coverage
def _refresh_available_bounds(self, *, lock_held: bool = False) -> None:
available_start, available_end = self._available_bounds()
if lock_held:
self._state.available_start_date = available_start
self._state.available_end_date = available_end
else:
with self._state_lock:
self._state.available_start_date = available_start
self._state.available_end_date = available_end
def _refresh_runtime_metrics(self, *, lock_held: bool = False) -> None:
def apply() -> None:
if self._state.started_at is None:
self._state.elapsed_seconds = None
self._state.estimated_remaining_seconds = None
self._state.avg_days_per_minute = None
return
end_reference = datetime.utcnow() if self._state.running or self._state.finished_at is None else self._state.finished_at
elapsed_seconds = max((end_reference - self._state.started_at).total_seconds(), 0.0)
self._state.elapsed_seconds = round(elapsed_seconds, 1)
if self._state.processed_days > 0 and elapsed_seconds > 0:
avg_days_per_minute = (self._state.processed_days / elapsed_seconds) * 60
remaining_days = max(self._state.total_days - self._state.processed_days, 0)
estimated_remaining = (remaining_days / self._state.processed_days) * elapsed_seconds
self._state.avg_days_per_minute = round(avg_days_per_minute, 2)
self._state.estimated_remaining_seconds = round(estimated_remaining, 1) if self._state.running else 0.0
else:
self._state.avg_days_per_minute = None
self._state.estimated_remaining_seconds = None if self._state.running else 0.0
if lock_held:
apply()
else:
with self._state_lock:
apply()
def _record_event(
self,
*,
level: str,
title: str,
message: str,
day: date | None = None,
chunk_index: int | None = None,
) -> None:
event = HistoricalActivityEvent(
timestamp=datetime.utcnow(),
level=level,
title=title,
message=message,
day=day,
chunk_index=chunk_index,
)
with self._state_lock:
self._state.recent_events.append(event)
self._state.recent_events = self._state.recent_events[-self.MAX_RECENT_EVENTS :]
def _find_chunk_locked(self, chunk_index: int) -> HistoricalChunkProgress | None:
for chunk in self._state.recent_chunks:
if chunk.chunk_index == chunk_index:
return chunk
return None
def _upsert_chunk_locked(self, chunk: HistoricalChunkProgress) -> None:
for index, existing in enumerate(self._state.recent_chunks):
if existing.chunk_index == chunk.chunk_index:
self._state.recent_chunks[index] = chunk
break
else:
self._state.recent_chunks.append(chunk)
self._state.recent_chunks = self._state.recent_chunks[-self.MAX_RECENT_CHUNKS :]
@staticmethod
def _date_range(start_day: date, end_day: date) -> Iterable[date]:
current = start_day
while current <= end_day:
yield current
current = current + timedelta(days=1)
@lru_cache(maxsize=1)
def get_historical_sync_service() -> HistoricalSyncService:
return HistoricalSyncService()

View File

@@ -0,0 +1,241 @@
from __future__ import annotations
import base64
import json
import logging
import ssl
import urllib.error
import urllib.parse
import urllib.request
from collections import defaultdict
from datetime import datetime
from typing import Iterable
from app.core_settings import AppSettings, get_settings
from app.models.definitions import MetricDefinition, SeriesPoint
from app.services.metrics import to_float
from app.utils.time import to_utc_iso
logger = logging.getLogger(__name__)
def _quote_identifier(value: str) -> str:
return '"' + value.replace('"', '\\"') + '"'
def _quote_literal(value: str) -> str:
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
class InfluxHTTPService:
def __init__(self, settings: AppSettings | None = None) -> None:
self.settings = settings or get_settings()
@property
def base_url(self) -> str:
config = self.settings.influx
return f"{config['scheme']}://{config['host']}:{config['port']}"
def latest_values(self, metrics: Iterable[MetricDefinition]) -> dict[str, dict]:
grouped: dict[str, list[MetricDefinition]] = defaultdict(list)
for metric in metrics:
grouped[metric.measurement].append(metric)
payload: dict[str, dict] = {}
for measurement, measurement_metrics in grouped.items():
conditions = " OR ".join(
f'("entity_id" = {_quote_literal(metric.entity_id)})'
for metric in measurement_metrics
)
query = (
f'SELECT LAST("value") AS value '
f'FROM {_quote_identifier(measurement)} '
f'WHERE {conditions} '
f'GROUP BY "entity_id"'
)
try:
for series in self._execute(query):
entity_id = (series.get("tags") or {}).get("entity_id")
if not entity_id:
continue
metric = next((item for item in measurement_metrics if item.entity_id == entity_id), None)
if metric is None:
continue
row = self._row_from_series(series)
payload[metric.id] = {
"value": row.get("value"),
"timestamp": _parse_time(row.get("time")),
}
except Exception as exc:
logger.warning("Influx latest_values error for %s: %s", measurement, exc)
return payload
def latest_value(self, metric: MetricDefinition) -> SeriesPoint | None:
return self._single_value(
f'SELECT LAST("value") AS value '
f'FROM {_quote_identifier(metric.measurement)} '
f'WHERE "entity_id" = {_quote_literal(metric.entity_id)}'
)
def first_value(self, metric: MetricDefinition) -> SeriesPoint | None:
return self._single_value(
f'SELECT FIRST("value") AS value '
f'FROM {_quote_identifier(metric.measurement)} '
f'WHERE "entity_id" = {_quote_literal(metric.entity_id)}'
)
def last_value(self, metric: MetricDefinition) -> SeriesPoint | None:
return self.latest_value(metric)
def gauge_history(
self,
metric: MetricDefinition,
start: datetime,
end: datetime,
interval: str,
aggregate: str = "mean",
) -> list[SeriesPoint]:
query = (
f'SELECT {aggregate}("value") AS value '
f'FROM {_quote_identifier(metric.measurement)} '
f'WHERE "entity_id" = {_quote_literal(metric.entity_id)} '
f'AND time >= {_quote_literal(to_utc_iso(start))} '
f'AND time <= {_quote_literal(to_utc_iso(end))} '
f'GROUP BY time({interval}) fill(null)'
)
points: list[SeriesPoint] = []
try:
for series in self._execute(query):
for row in self._rows_from_series(series):
timestamp = _parse_time(row.get("time"))
if timestamp is None:
continue
points.append(SeriesPoint(timestamp=timestamp, value=to_float(row.get("value"))))
except Exception as exc:
logger.warning("Influx gauge_history error for %s: %s", metric.id, exc)
return points
def grouped_last_series(
self,
metric: MetricDefinition,
start: datetime,
end: datetime,
interval: str,
) -> list[SeriesPoint]:
query = (
f'SELECT LAST("value") AS value '
f'FROM {_quote_identifier(metric.measurement)} '
f'WHERE "entity_id" = {_quote_literal(metric.entity_id)} '
f'AND time >= {_quote_literal(to_utc_iso(start))} '
f'AND time <= {_quote_literal(to_utc_iso(end))} '
f'GROUP BY time({interval}) fill(null)'
)
points: list[SeriesPoint] = []
try:
for series in self._execute(query):
for row in self._rows_from_series(series):
timestamp = _parse_time(row.get("time"))
if timestamp is None:
continue
points.append(SeriesPoint(timestamp=timestamp, value=to_float(row.get("value"))))
except Exception as exc:
logger.warning("Influx grouped_last_series error for %s: %s", metric.id, exc)
return points
def last_before(self, metric: MetricDefinition, moment: datetime) -> SeriesPoint | None:
query = (
f'SELECT LAST("value") AS value '
f'FROM {_quote_identifier(metric.measurement)} '
f'WHERE "entity_id" = {_quote_literal(metric.entity_id)} '
f'AND time < {_quote_literal(to_utc_iso(moment))}'
)
try:
series = self._execute(query)
if not series:
return None
row = self._row_from_series(series[0])
timestamp = _parse_time(row.get("time"))
value = to_float(row.get("value"))
if timestamp is None or value is None:
return None
return SeriesPoint(timestamp=timestamp, value=value)
except Exception as exc:
logger.warning("Influx last_before error for %s: %s", metric.id, exc)
return None
def _single_value(self, query: str) -> SeriesPoint | None:
try:
series = self._execute(query)
if not series:
return None
row = self._row_from_series(series[0])
timestamp = _parse_time(row.get("time"))
value = to_float(row.get("value"))
if timestamp is None or value is None:
return None
return SeriesPoint(timestamp=timestamp, value=value)
except Exception as exc:
logger.warning("Influx single value query error: %s", exc)
return None
def _execute(self, query: str) -> list[dict]:
params = {
"db": self.settings.influx["database"],
"q": query,
}
url = f"{self.base_url}/query?{urllib.parse.urlencode(params)}"
headers = {"Accept": "application/json"}
username = self.settings.influx.get("username") or ""
password = self.settings.influx.get("password") or ""
if username:
token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
headers["Authorization"] = f"Basic {token}"
request = urllib.request.Request(url, headers=headers, method="GET")
verify_ssl = self.settings.influx.get("verify_ssl", False)
timeout = self.settings.influx.get("timeout_seconds", 15)
context = None
if self.settings.influx.get("scheme") == "https" and not verify_ssl:
context = ssl._create_unverified_context()
try:
with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="ignore")
raise RuntimeError(f"Influx HTTP {exc.code}: {body}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"Influx connection error: {exc}") from exc
results = payload.get("results") or []
if not results:
return []
result = results[0]
if "error" in result:
raise RuntimeError(result["error"])
return result.get("series") or []
@staticmethod
def _rows_from_series(series: dict) -> list[dict]:
columns = series.get("columns") or []
rows = []
for values in series.get("values") or []:
rows.append(dict(zip(columns, values)))
return rows
@classmethod
def _row_from_series(cls, series: dict) -> dict:
rows = cls._rows_from_series(series)
return rows[0] if rows else {}
def _parse_time(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from typing import Any
from flask import session
from app.core_settings import AppSettings, get_settings
from app.storage.kiosk_settings import SQLiteKioskSettingsRepository
VALID_MODES = {"public", "private"}
DEFAULT_WIDGETS = ["hero", "history", "strings", "status", "production", "comparison", "importStatus"]
VALID_WIDGETS = {"hero", "quickMetrics", "history", "status", "strings", "production", "comparison", "distribution", "importStatus"}
VALID_REALTIME_RANGES = {"today", "yesterday", "6h", "12h", "24h", "48h", "7d"}
VALID_ANALYTICS_RANGES = {"today", "yesterday", "7d", "30d", "90d", "365d", "custom"}
class KioskSettingsService:
def __init__(self, settings: AppSettings | None = None) -> None:
self.settings = settings or get_settings()
self.repository = SQLiteKioskSettingsRepository(self.settings.storage["sqlite_path"])
def get(self, mode: str) -> dict[str, Any]:
normalized_mode = self._normalize_mode(mode)
stored = self.repository.get(normalized_mode)
if stored is None:
return self._default_payload(normalized_mode)
return self._sanitize_payload(normalized_mode, stored, persist_if_changed=False)
def update(self, mode: str, payload: dict[str, Any], updated_by: str | None = None) -> dict[str, Any]:
normalized_mode = self._normalize_mode(mode)
merged = {**self.get(normalized_mode), **(payload or {})}
cleaned = self._sanitize_payload(normalized_mode, merged, persist_if_changed=False)
return self.repository.upsert(normalized_mode, cleaned, updated_by=updated_by)
def update_from_session(self, mode: str, payload: dict[str, Any]) -> dict[str, Any]:
updated_by = session.get("auth_user")
return self.update(mode, payload, updated_by=updated_by)
def _default_payload(self, mode: str) -> dict[str, Any]:
return {
"mode": mode,
"widgets": list(DEFAULT_WIDGETS),
"realtime_range": self._default_realtime_range(),
"analytics_range": self._default_analytics_range(),
"analytics_bucket": self._default_analytics_bucket(),
"compare_mode": self._default_compare_mode(),
"updated_at": None,
"updated_by": None,
}
def _sanitize_payload(self, mode: str, payload: dict[str, Any], persist_if_changed: bool = False) -> dict[str, Any]:
cleaned = {
"mode": mode,
"widgets": self._normalize_widgets(payload.get("widgets")),
"realtime_range": self._normalize_realtime_range(payload.get("realtime_range")),
"analytics_range": self._normalize_analytics_range(payload.get("analytics_range")),
"analytics_bucket": self._normalize_bucket(payload.get("analytics_bucket")),
"compare_mode": self._normalize_compare_mode(payload.get("compare_mode")),
"updated_at": payload.get("updated_at"),
"updated_by": payload.get("updated_by"),
}
if persist_if_changed:
return self.repository.upsert(mode, cleaned, updated_by=cleaned.get("updated_by"))
return cleaned
def _normalize_mode(self, mode: str) -> str:
normalized = (mode or "").strip().lower()
if normalized not in VALID_MODES:
raise ValueError("Mode musi byc jednym z: public, private")
return normalized
def _normalize_widgets(self, widgets: Any) -> list[str]:
if not isinstance(widgets, list):
return list(DEFAULT_WIDGETS)
normalized: list[str] = []
for item in widgets:
widget = str(item or "").strip()
if widget in VALID_WIDGETS and widget not in normalized:
normalized.append(widget)
return normalized or list(DEFAULT_WIDGETS)
def _normalize_realtime_range(self, value: Any) -> str:
normalized = str(value or self._default_realtime_range()).strip()
return normalized if normalized in VALID_REALTIME_RANGES else self._default_realtime_range()
def _normalize_analytics_range(self, value: Any) -> str:
normalized = str(value or self._default_analytics_range()).strip()
return normalized if normalized in VALID_ANALYTICS_RANGES else self._default_analytics_range()
def _normalize_bucket(self, value: Any) -> str:
normalized = str(value or self._default_analytics_bucket()).strip()
return normalized if normalized in self.settings.analytics["bucket_labels"] else self._default_analytics_bucket()
def _normalize_compare_mode(self, value: Any) -> str:
normalized = str(value or self._default_compare_mode()).strip()
return normalized if normalized in self.settings.analytics["compare_modes"] else self._default_compare_mode()
def _default_realtime_range(self) -> str:
raw = str(self.settings.realtime.get("history_default_range", "12h"))
return raw if raw in VALID_REALTIME_RANGES else "12h"
def _default_analytics_range(self) -> str:
raw = str(self.settings.analytics.get("default_range", "30d"))
return raw if raw in VALID_ANALYTICS_RANGES else "30d"
def _default_analytics_bucket(self) -> str:
raw = str(self.settings.analytics.get("default_bucket", "day"))
return raw if raw in self.settings.analytics["bucket_labels"] else "day"
def _default_compare_mode(self) -> str:
raw = str(self.settings.analytics.get("default_compare", "none"))
return raw if raw in self.settings.analytics["compare_modes"] else "none"
_kiosk_settings_service: KioskSettingsService | None = None
def get_kiosk_settings_service() -> KioskSettingsService:
global _kiosk_settings_service
if _kiosk_settings_service is None:
_kiosk_settings_service = KioskSettingsService()
return _kiosk_settings_service

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
from datetime import datetime
from app.models.definitions import MetricDefinition, MetricValue
def to_float(value: float | str | None) -> float | None:
if value is None:
return None
if isinstance(value, (float, int)):
return float(value)
try:
return float(str(value).replace(",", "."))
except (TypeError, ValueError):
return None
def round_value(value: float | None, precision: int) -> float | None:
if value is None:
return None
return round(value, precision)
def compare_delta_pct(current: float | None, previous: float | None) -> float | None:
if current is None or previous in (None, 0):
return None
return round(((current - previous) / previous) * 100.0, 2)
def build_status(metric_id: str, numeric: float | None) -> str:
if numeric is None:
return "neutral"
if metric_id == "inverter_temp":
if numeric < 55:
return "ok"
if numeric < 70:
return "warn"
return "critical"
return "ok"
def metric_value(
metric: MetricDefinition,
value: float | str | None,
*,
timestamp: datetime | None = None,
) -> MetricValue:
rendered = value
numeric = None
if metric.kind != "text":
numeric = to_float(value)
rendered = round_value(numeric, metric.precision)
return MetricValue(
metric_id=metric.id,
label=metric.label,
unit=metric.unit,
value=rendered,
timestamp=timestamp,
precision=metric.precision,
kind=metric.kind,
status=build_status(metric.id, numeric),
)
def custom_metric_value(
metric_id: str,
label: str,
value: float | str | None,
*,
unit: str = "",
precision: int = 2,
timestamp: datetime | None = None,
status: str = "neutral",
kind: str = "gauge",
) -> MetricValue:
rendered = value
if kind != "text":
numeric = to_float(value)
rendered = round_value(numeric, precision)
return MetricValue(
metric_id=metric_id,
label=label,
unit=unit,
value=rendered,
timestamp=timestamp,
precision=precision,
kind=kind,
status=status,
)

View File

@@ -0,0 +1,231 @@
from __future__ import annotations
from datetime import datetime, timedelta
from app.core_settings import AppSettings, get_settings
from app.models.definitions import HeroCard, SnapshotGroupRow, SnapshotPayload
from app.services.catalog import MetricCatalog, get_catalog
from app.services.energy import EnergyService
from app.services.influx_http import InfluxHTTPService
from app.services.metrics import compare_delta_pct, custom_metric_value, metric_value, to_float
from app.utils.time import choose_power_interval, now_local, resolve_window, start_of_local_day
class RealtimeService:
def __init__(
self,
settings: AppSettings | None = None,
catalog: MetricCatalog | None = None,
influx: InfluxHTTPService | None = None,
energy: EnergyService | None = None,
) -> None:
self.settings = settings or get_settings()
self.catalog = catalog or get_catalog()
self.influx = influx or InfluxHTTPService(self.settings)
self.energy = energy or EnergyService(self.settings, self.catalog, self.influx)
def snapshot(self) -> SnapshotPayload:
now = now_local()
today_start = start_of_local_day(now)
yesterday_start = today_start - timedelta(days=1)
metric_ids = {"ac_power", "energy_total", "inverter_temp"}
for group in self.settings.strings:
metric_ids.update(group.get("metrics", {}).values())
metrics = [self.catalog.get(metric_id) for metric_id in metric_ids if self.catalog.safe_get(metric_id)]
latest = self.influx.latest_values(metrics)
ac_power = to_float(_value(latest, "ac_power"))
total_dc_power = round(
sum(
to_float(_value(latest, group.get("metrics", {}).get("power", ""))) or 0.0
for group in self.settings.strings
),
0,
)
energy_today = self.energy.total_for_window(today_start, now)
energy_yesterday = self.energy.total_for_window(yesterday_start, today_start)
total_energy = to_float(_value(latest, "energy_total"))
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"),
]
if inverter_temp is not None:
hero_cards.append(self._hero_card("inverter_temp", inverter_temp, label="Temp. falownika", unit="°C", subtitle="Sensor opcjonalny"))
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_total": custom_metric_value(
"energy_total",
"Energia laczna",
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"),
}
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",
comparison,
unit="%",
precision=2,
status="ok" if comparison >= 0 else "warn",
)
strings = self._build_string_rows(latest)
status = []
if self.catalog.safe_get("inverter_temp"):
status.append(
metric_value(
self.catalog.get("inverter_temp"),
inverter_temp,
timestamp=_timestamp(latest, "inverter_temp"),
)
)
status.append(
custom_metric_value(
"data_refresh",
"Ostatni odczyt energii",
_timestamp(latest, "energy_total").isoformat() if _timestamp(latest, "energy_total") else None,
status="ok" if _timestamp(latest, "energy_total") else "neutral",
kind="text",
)
)
updated_at = _max_timestamp(latest.values())
return SnapshotPayload(
updated_at=updated_at,
hero_cards=hero_cards,
kpis=kpis,
strings=strings,
phases=[],
status=status,
faults=[],
)
def history(self, range_key: str | None = None, start: str | None = None, end: str | None = None, metric_ids: list[str] | None = None) -> dict:
window = resolve_window(range_key=range_key or self.settings.realtime["history_default_range"], start=start, end=end)
interval = choose_power_interval(window.start, window.end)
series = []
selected = set(metric_ids or [])
def include(metric_id: str) -> bool:
return not selected or metric_id in selected
ac_metric = self.catalog.safe_get("ac_power")
if ac_metric is not None and include("ac_power"):
series.append(
{
"metric_id": ac_metric.id,
"label": ac_metric.label,
"unit": ac_metric.unit,
"points": self.influx.gauge_history(ac_metric, window.start, window.end, interval=interval, aggregate="mean"),
}
)
for group in self.settings.strings:
for slot, metric_id in group.get("metrics", {}).items():
if not metric_id or not self.catalog.safe_get(metric_id) or not include(metric_id):
continue
metric = self.catalog.get(metric_id)
series.append(
{
"metric_id": metric.id,
"label": metric.label if slot != "power" else group["label"],
"unit": metric.unit,
"points": self.influx.gauge_history(metric, window.start, window.end, interval=interval, aggregate="mean"),
}
)
temp_metric = self.catalog.safe_get("inverter_temp")
if temp_metric is not None and include("inverter_temp"):
temp_points = self.influx.gauge_history(temp_metric, window.start, window.end, interval=interval, aggregate="mean")
last_value = None
filled = []
for point in temp_points:
value = point.value if point.value is not None else last_value
if point.value is not None:
last_value = point.value
filled.append({"timestamp": point.timestamp, "value": value})
series.append(
{
"metric_id": temp_metric.id,
"label": temp_metric.label,
"unit": temp_metric.unit,
"points": filled,
}
)
return {
"range_key": window.key,
"start": window.start,
"end": window.end,
"series": series,
}
def _hero_card(self, metric_id: str, value, *, label: str | None = None, unit: str | None = None, subtitle: str = "") -> HeroCard:
accent = "slate"
numeric = to_float(value)
if metric_id == "inverter_temp":
if numeric is not None and numeric < 55:
accent = "emerald"
elif numeric is not None and numeric < 70:
accent = "amber"
elif numeric is not None:
accent = "rose"
else:
accent = "emerald" if numeric not in (None, 0) else "slate"
resolved_label = label or (self.catalog.get(metric_id).label if self.catalog.safe_get(metric_id) else metric_id)
resolved_unit = unit or (self.catalog.get(metric_id).unit if self.catalog.safe_get(metric_id) else "")
return HeroCard(
metric_id=metric_id,
label=resolved_label,
value=value,
unit=resolved_unit,
accent=accent,
subtitle=subtitle,
)
def _build_string_rows(self, latest: dict) -> list[SnapshotGroupRow]:
rows = []
for group in self.settings.strings:
values = {}
for slot, metric_id in group.get("metrics", {}).items():
metric = self.catalog.safe_get(metric_id)
if metric is None:
continue
values[slot] = metric_value(metric, _value(latest, metric_id), timestamp=_timestamp(latest, metric_id))
rows.append(SnapshotGroupRow(id=group["id"], label=group["label"], values=values, meta={}))
return rows
def _value(latest: dict, metric_id: str):
payload = latest.get(metric_id) or {}
return payload.get("value")
def _timestamp(latest: dict, metric_id: str):
payload = latest.get(metric_id) or {}
return payload.get("timestamp")
def _max_timestamp(items) -> datetime | None:
timestamps = [item.get("timestamp") for item in items if item.get("timestamp") is not None]
return max(timestamps) if timestamps else None

View File

@@ -0,0 +1,4 @@
from .sqlite_repository import SQLiteEnergyRepository
from .auth_users import AuthUser, SQLiteAuthUserRepository
__all__ = ["SQLiteEnergyRepository", "AuthUser", "SQLiteAuthUserRepository"]

View File

@@ -0,0 +1,132 @@
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterator
@dataclass(frozen=True)
class AuthUser:
username: str
password_hash: str
role: str
display_name: str
is_active: bool = True
created_at: datetime | None = None
updated_at: datetime | None = None
class SQLiteAuthUserRepository:
def __init__(self, db_path: str) -> None:
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self.ensure_schema()
@contextmanager
def connect(self) -> Iterator[sqlite3.Connection]:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
yield conn
conn.commit()
finally:
conn.close()
def ensure_schema(self) -> None:
with self.connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS auth_users (
username TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
display_name TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_auth_users_role ON auth_users(role)"
)
def get_by_username(self, username: str) -> AuthUser | None:
with self.connect() as conn:
row = conn.execute(
"""
SELECT username, password_hash, role, display_name, is_active, created_at, updated_at
FROM auth_users
WHERE username = ?
LIMIT 1
""",
(username,),
).fetchone()
if row is None:
return None
return AuthUser(
username=row["username"],
password_hash=row["password_hash"],
role=row["role"],
display_name=row["display_name"],
is_active=bool(row["is_active"]),
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
def upsert_user(self, *, username: str, password_hash: str, role: str, display_name: str, is_active: bool = True) -> AuthUser:
now = datetime.utcnow().isoformat()
with self.connect() as conn:
conn.execute(
"""
INSERT INTO auth_users (username, password_hash, role, display_name, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(username) DO UPDATE SET
password_hash = excluded.password_hash,
role = excluded.role,
display_name = excluded.display_name,
is_active = excluded.is_active,
updated_at = excluded.updated_at
""",
(username, password_hash, role, display_name, 1 if is_active else 0, now, now),
)
return self.get_by_username(username) # type: ignore[return-value]
def update_password(self, username: str, password_hash: str) -> AuthUser | None:
now = datetime.utcnow().isoformat()
with self.connect() as conn:
cursor = conn.execute(
"UPDATE auth_users SET password_hash = ?, updated_at = ? WHERE username = ?",
(password_hash, now, username),
)
if cursor.rowcount == 0:
return None
return self.get_by_username(username)
def list_users(self) -> list[AuthUser]:
with self.connect() as conn:
rows = conn.execute(
"""
SELECT username, password_hash, role, display_name, is_active, created_at, updated_at
FROM auth_users
ORDER BY role DESC, username ASC
"""
).fetchall()
return [
AuthUser(
username=row["username"],
password_hash=row["password_hash"],
role=row["role"],
display_name=row["display_name"],
is_active=bool(row["is_active"]),
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
for row in rows
]

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
import json
import sqlite3
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Iterator
@dataclass
class KioskSettingsRecord:
mode: str
widgets: list[str]
realtime_range: str
analytics_range: str
analytics_bucket: str
compare_mode: str
updated_at: datetime | None = None
updated_by: str | None = None
class SQLiteKioskSettingsRepository:
def __init__(self, db_path: str) -> None:
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self.ensure_schema()
@contextmanager
def connect(self) -> Iterator[sqlite3.Connection]:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
yield conn
conn.commit()
finally:
conn.close()
def ensure_schema(self) -> None:
with self.connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS kiosk_settings (
mode TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by TEXT
)
"""
)
def get(self, mode: str) -> dict[str, Any] | None:
with self.connect() as conn:
row = conn.execute(
"SELECT mode, payload_json, updated_at, updated_by FROM kiosk_settings WHERE mode = ? LIMIT 1",
(mode,),
).fetchone()
if row is None:
return None
payload = json.loads(row["payload_json"])
payload["mode"] = row["mode"]
payload["updated_at"] = row["updated_at"]
payload["updated_by"] = row["updated_by"]
return payload
def upsert(self, mode: str, payload: dict[str, Any], updated_by: str | None = None) -> dict[str, Any]:
now = datetime.utcnow().isoformat()
stored_payload = dict(payload)
stored_payload.pop("mode", None)
stored_payload.pop("updated_at", None)
stored_payload.pop("updated_by", None)
with self.connect() as conn:
conn.execute(
"""
INSERT INTO kiosk_settings (mode, payload_json, updated_at, updated_by)
VALUES (?, ?, ?, ?)
ON CONFLICT(mode) DO UPDATE SET
payload_json = excluded.payload_json,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by
""",
(mode, json.dumps(stored_payload, ensure_ascii=False), now, updated_by),
)
return self.get(mode) or {"mode": mode, **stored_payload, "updated_at": now, "updated_by": updated_by}

View File

@@ -0,0 +1,131 @@
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from datetime import datetime, date
from pathlib import Path
from typing import Iterator
from app.models import DailyEnergyRecord, HistoricalCoverage
class SQLiteEnergyRepository:
def __init__(self, db_path: str) -> None:
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self.ensure_schema()
@contextmanager
def connect(self) -> Iterator[sqlite3.Connection]:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
yield conn
conn.commit()
finally:
conn.close()
def ensure_schema(self) -> None:
with self.connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS daily_energy (
day TEXT PRIMARY KEY,
energy_kwh REAL NOT NULL,
source TEXT NOT NULL,
samples_count INTEGER NOT NULL DEFAULT 0,
imported_at TEXT NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_daily_energy_imported_at ON daily_energy(imported_at)"
)
def has_day(self, day: date) -> bool:
with self.connect() as conn:
row = conn.execute("SELECT 1 FROM daily_energy WHERE day = ? LIMIT 1", (day.isoformat(),)).fetchone()
return row is not None
def upsert_daily_energy(self, record: DailyEnergyRecord) -> None:
imported_at = record.imported_at or datetime.utcnow()
with self.connect() as conn:
conn.execute(
"""
INSERT INTO daily_energy (day, energy_kwh, source, samples_count, imported_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(day) DO UPDATE SET
energy_kwh = excluded.energy_kwh,
source = excluded.source,
samples_count = excluded.samples_count,
imported_at = excluded.imported_at
""",
(
record.day.isoformat(),
float(record.energy_kwh),
record.source,
int(record.samples_count),
imported_at.isoformat(),
),
)
def fetch_daily_energy(self, start_day: date, end_day: date) -> dict[date, DailyEnergyRecord]:
with self.connect() as conn:
rows = conn.execute(
"""
SELECT day, energy_kwh, source, samples_count, imported_at
FROM daily_energy
WHERE day >= ? AND day <= ?
ORDER BY day ASC
""",
(start_day.isoformat(), end_day.isoformat()),
).fetchall()
payload: dict[date, DailyEnergyRecord] = {}
for row in rows:
payload[date.fromisoformat(row["day"])] = DailyEnergyRecord(
day=date.fromisoformat(row["day"]),
energy_kwh=float(row["energy_kwh"]),
source=row["source"],
samples_count=int(row["samples_count"]),
imported_at=datetime.fromisoformat(row["imported_at"]),
)
return payload
def coverage(self) -> HistoricalCoverage:
with self.connect() as conn:
row = conn.execute(
"""
SELECT
COUNT(*) AS imported_days,
MIN(day) AS first_day,
MAX(day) AS last_day,
COALESCE(SUM(energy_kwh), 0) AS total_energy_kwh
FROM daily_energy
"""
).fetchone()
if row is None:
return HistoricalCoverage()
return HistoricalCoverage(
imported_days=int(row["imported_days"] or 0),
first_day=date.fromisoformat(row["first_day"]) if row["first_day"] else None,
last_day=date.fromisoformat(row["last_day"]) if row["last_day"] else None,
total_energy_kwh=round(float(row["total_energy_kwh"] or 0.0), 2),
)
def latest_day(self) -> date | None:
return self.coverage().last_day
def count_missing_days(self, start_day: date, end_day: date) -> int:
existing = self.fetch_daily_energy(start_day, end_day)
current = start_day
missing = 0
while current <= end_day:
if current not in existing:
missing += 1
current = current.fromordinal(current.toordinal() + 1)
return missing

View File

@@ -0,0 +1,25 @@
from .serialization import to_plain
from .time import (
TimeWindow,
choose_counter_interval,
choose_power_interval,
duration_to_seconds,
now_local,
resolve_window,
shift_window,
start_of_local_day,
to_utc_iso,
)
__all__ = [
"TimeWindow",
"choose_counter_interval",
"choose_power_interval",
"duration_to_seconds",
"now_local",
"resolve_window",
"shift_window",
"start_of_local_day",
"to_plain",
"to_utc_iso",
]

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from dataclasses import asdict, is_dataclass
from datetime import date, datetime
from typing import Any
def to_plain(value: Any) -> Any:
if is_dataclass(value):
return to_plain(asdict(value))
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, date):
return value.isoformat()
if isinstance(value, dict):
return {key: to_plain(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [to_plain(item) for item in value]
return value

156
backend/app/utils/time.py Normal file
View File

@@ -0,0 +1,156 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from config import TIME_RANGES
from app.core_settings import get_settings
@dataclass
class TimeWindow:
start: datetime
end: datetime
label: str
key: str
def now_local() -> datetime:
settings = get_settings()
return datetime.now(ZoneInfo(settings.timezone))
def start_of_local_day(moment: datetime | None = None) -> datetime:
current = moment or now_local()
return current.replace(hour=0, minute=0, second=0, microsecond=0)
def resolve_window(range_key: str | None = None, start: str | None = None, end: str | None = None) -> TimeWindow:
settings = get_settings()
tz = ZoneInfo(settings.timezone)
if (start and not end) or (end and not start):
raise ValueError("Provide both start and end for custom range")
if start and end:
start_dt = _parse_iso(start, tz)
end_dt = _parse_iso(end, tz)
return TimeWindow(start=start_dt, end=end_dt, label="Custom", key="custom")
key = range_key or settings.analytics["default_range"]
definition = settings.time_ranges.get(key)
if not definition:
raise ValueError(f"Unsupported range: {key}")
now_dt = datetime.now(tz)
end_dt = now_dt
special = definition.get("special")
if special == "ytd":
start_dt = now_dt.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
elif special == "today":
start_dt = now_dt.replace(hour=0, minute=0, second=0, microsecond=0)
end_dt = now_dt
elif special == "yesterday":
end_dt = now_dt.replace(hour=0, minute=0, second=0, microsecond=0)
start_dt = end_dt - timedelta(days=1)
else:
start_dt = now_dt - timedelta(seconds=int(definition["seconds"]))
return TimeWindow(start=start_dt, end=end_dt, label=definition["label"], key=key)
def shift_window(window: TimeWindow, mode: str) -> TimeWindow:
if mode == "previous_period":
span = window.end - window.start
return TimeWindow(
start=window.start - span,
end=window.start,
label="Previous period",
key=f"{window.key}:previous_period",
)
if mode in {"previous_year", "previous_year_2", "previous_year_3"}:
years = {"previous_year": 1, "previous_year_2": 2, "previous_year_3": 3}[mode]
return TimeWindow(
start=_safe_replace_year(window.start, window.start.year - years),
end=_safe_replace_year(window.end, window.end.year - years),
label=f"Previous {years} year",
key=f"{window.key}:{mode}",
)
if mode in {"previous_month_12", "previous_month_24"}:
months = {"previous_month_12": 12, "previous_month_24": 24}[mode]
return TimeWindow(
start=_shift_months(window.start, -months),
end=_shift_months(window.end, -months),
label=f"Previous {months} months",
key=f"{window.key}:{mode}",
)
raise ValueError(f"Unsupported compare mode: {mode}")
def choose_counter_interval(start: datetime, end: datetime) -> str:
span_seconds = max((end - start).total_seconds(), 0)
if span_seconds <= 3 * 86400:
return "5m"
if span_seconds <= 14 * 86400:
return "15m"
if span_seconds <= 93 * 86400:
return "30m"
if span_seconds <= 366 * 86400:
return "1h"
return "3h"
def choose_power_interval(start: datetime, end: datetime) -> str:
span_seconds = max((end - start).total_seconds(), 0)
if span_seconds <= 24 * 3600:
return "5m"
if span_seconds <= 7 * 86400:
return "15m"
if span_seconds <= 31 * 86400:
return "30m"
if span_seconds <= 366 * 86400:
return "1h"
return "3h"
def duration_to_seconds(interval: str) -> int:
suffix = interval[-1]
amount = int(interval[:-1])
if suffix == "s":
return amount
if suffix == "m":
return amount * 60
if suffix == "h":
return amount * 3600
if suffix == "d":
return amount * 86400
raise ValueError(f"Unsupported duration format: {interval}")
def to_utc_iso(dt: datetime) -> str:
return dt.astimezone(ZoneInfo("UTC")).isoformat()
def _parse_iso(value: str, tz: ZoneInfo) -> datetime:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
return parsed.replace(tzinfo=tz)
return parsed.astimezone(tz)
def _safe_replace_year(value: datetime, year: int) -> datetime:
try:
return value.replace(year=year)
except ValueError:
return value.replace(year=year, day=28)
def _shift_months(value: datetime, months: int) -> datetime:
year = value.year + ((value.month - 1 + months) // 12)
month = ((value.month - 1 + months) % 12) + 1
day = min(value.day, [31, 29 if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1])
return value.replace(year=year, month=month, day=day)

24
backend/backfill.py Normal file
View File

@@ -0,0 +1,24 @@
from __future__ import annotations
import argparse
from datetime import date
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")
args = parser.parse_args()
service = get_historical_sync_service()
status = service.run_blocking(
start_date=date.fromisoformat(args.start_date) if args.start_date else None,
end_date=date.fromisoformat(args.end_date) if args.end_date else None,
chunk_days=args.chunk_days,
force=args.force,
)
print(to_plain(status))

289
backend/config.py Normal file
View File

@@ -0,0 +1,289 @@
from __future__ import annotations
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
PROJECT_DIR = BASE_DIR.parent
DATA_DIR = PROJECT_DIR / "data"
DATA_DIR.mkdir(exist_ok=True)
def _load_dotenv(path: Path) -> None:
if not path.exists():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if not key or key in os.environ:
continue
cleaned = value.strip().strip('"').strip("'")
os.environ[key] = cleaned
_load_dotenv(PROJECT_DIR / ".env")
def env_bool(name: str, default: bool = False) -> bool:
return os.getenv(name, str(default)).strip().lower() in {"1", "true", "yes", "on"}
def env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, str(default)))
except (TypeError, ValueError):
return default
def env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name, str(default)).replace(",", "."))
except (TypeError, ValueError):
return default
APP_CONFIG = {
"name": os.getenv("APP_NAME", "PV Insight"),
"version": os.getenv("APP_VERSION", "1.3.0"),
"debug": env_bool("APP_DEBUG", False),
"api_prefix": "/api/v1",
"timezone": os.getenv("APP_TIMEZONE", "Europe/Warsaw"),
"host": os.getenv("APP_HOST", "0.0.0.0"),
"port": env_int("APP_PORT", 8105),
}
SITE_CONFIG = {
"site_name": os.getenv("SITE_NAME", "Domowa instalacja PV"),
"timezone": APP_CONFIG["timezone"],
"installed_power_kwp": env_float("PV_INSTALLED_POWER_KWP", 9.99),
"currency": os.getenv("SITE_CURRENCY", "PLN"),
"co2_factor_kg_per_kwh": env_float("CO2_FACTOR_KG_PER_KWH", 0.72),
}
INFLUXDB_CONFIG = {
"scheme": os.getenv("INFLUXDB_SCHEME", "http"),
"host": os.getenv("INFLUXDB_HOST", "127.0.0.1"),
"port": env_int("INFLUXDB_PORT", 8086),
"database": os.getenv("INFLUXDB_DATABASE", "ha"),
"username": os.getenv("INFLUXDB_USER", ""),
"password": os.getenv("INFLUXDB_PASSWORD", ""),
"verify_ssl": env_bool("INFLUXDB_VERIFY_SSL", False),
"timeout_seconds": env_int("INFLUXDB_TIMEOUT_SECONDS", 15),
}
STORAGE_CONFIG = {
"sqlite_path": os.getenv("APP_SQLITE_PATH", str(DATA_DIR / "pv_insight.sqlite3")),
}
CORS_ORIGINS = [
value.strip()
for value in os.getenv(
"CORS_ORIGINS",
"http://localhost:5173,http://127.0.0.1:5173,http://localhost:4173,http://127.0.0.1:4173",
).split(",")
if value.strip()
]
TIME_RANGES = {
"today": {"label": "Dzis", "special": "today"},
"yesterday": {"label": "Wczoraj", "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},
"ytd": {"label": "YTD", "special": "ytd"},
}
REALTIME = {
"refresh_seconds": env_int("REALTIME_REFRESH_SECONDS", 8),
"history_default_range": os.getenv("REALTIME_HISTORY_DEFAULT_RANGE", "6h"),
}
ANALYTICS = {
"production_metric_id": "energy_total",
"fallback_power_metric_id": "ac_power",
"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",
},
"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",
},
}
HISTORY = {
"enabled": env_bool("HISTORY_ENABLED", True),
"chunk_days": env_int("HISTORY_CHUNK_DAYS", 7),
"default_chunk_days": env_int("HISTORY_DEFAULT_CHUNK_DAYS", 7),
"auto_sync_enabled": env_bool("HISTORY_AUTO_SYNC_ENABLED", True),
"auto_sync_on_start": env_bool("HISTORY_AUTO_SYNC_ON_START", False),
"auto_sync_interval_minutes": env_int("HISTORY_AUTO_SYNC_INTERVAL_MINUTES", 30),
"include_today_in_sync": env_bool("HISTORY_INCLUDE_TODAY_IN_SYNC", False),
"bootstrap_start_date": os.getenv("HISTORY_BOOTSTRAP_START_DATE", "").strip(),
}
AUTH_CONFIG = {
"enabled": env_bool("AUTH_ENABLED", True),
"username": os.getenv("AUTH_USERNAME", "admin"),
"password": os.getenv("AUTH_PASSWORD", "change-me"),
"password_hash": os.getenv("AUTH_PASSWORD_HASH", "").strip(),
"display_name": os.getenv("AUTH_DISPLAY_NAME", "Operator"),
"secret_key": os.getenv("APP_SECRET_KEY", "pv-insight-dev-secret-change-me"),
"session_cookie_name": os.getenv("APP_SESSION_COOKIE_NAME", "pv_insight_session"),
"session_max_age_seconds": env_int("AUTH_SESSION_MAX_AGE_SECONDS", 60 * 60 * 12),
"cookie_secure": env_bool("AUTH_COOKIE_SECURE", False),
"cookie_samesite": os.getenv("AUTH_COOKIE_SAMESITE", "Lax"),
}
I18N = {
"default_language": os.getenv("APP_DEFAULT_LANGUAGE", "pl"),
"supported_languages": ["pl", "en"],
}
FRONTEND_DEFAULTS = {
"tab": os.getenv("FRONTEND_DEFAULT_TAB", "realtime"),
"theme": os.getenv("FRONTEND_THEME", "dark"),
"language": os.getenv("FRONTEND_LANGUAGE", I18N["default_language"]),
}
METRICS: dict[str, dict] = {}
STRINGS: list[dict] = []
def register_metric(
metric_id: str,
*,
entity_id: str,
measurement: str,
unit: str,
label: str,
kind: str = "gauge",
precision: int = 2,
) -> str | None:
entity_id = (entity_id or "").strip()
measurement = (measurement or "").strip()
if not entity_id or not measurement:
return None
METRICS[metric_id] = {
"entity_id": entity_id,
"measurement": measurement,
"unit": unit,
"label": label,
"kind": kind,
"precision": precision,
"enabled": True,
}
return metric_id
register_metric(
"ac_power",
entity_id=os.getenv("PV_AC_POWER_ENTITY", "sofarsolar_ac_power"),
measurement=os.getenv("PV_AC_POWER_MEASUREMENT", "W"),
unit="W",
label="Moc AC",
precision=0,
)
register_metric(
"energy_total",
entity_id=os.getenv("PV_TOTAL_ENERGY_ENTITY", "sofarsolar_energy_total"),
measurement=os.getenv("PV_TOTAL_ENERGY_MEASUREMENT", "kWh"),
unit="kWh",
label="Energia laczna",
kind="counter",
precision=2,
)
register_metric(
"inverter_temp",
entity_id=os.getenv("PV_INVERTER_TEMP_ENTITY", "sofarsolar_temprature_inverter"),
measurement=os.getenv("PV_INVERTER_TEMP_MEASUREMENT", "°C"),
unit="°C",
label="Temperatura falownika",
precision=1,
)
STRING_DEFAULTS = {
1: {"label": "DC1", "power": "sofarsolar_dc1_power", "voltage": "sofarsolar_dc1_voltage"},
2: {"label": "DC2", "power": "sofarsolar_dc2_power", "voltage": "sofarsolar_dc2_voltage"},
3: {"label": "DC3", "power": "", "voltage": ""},
4: {"label": "DC4", "power": "", "voltage": ""},
}
for index, defaults in STRING_DEFAULTS.items():
label = os.getenv(f"PV_STRING_{index}_LABEL", defaults["label"]).strip() or defaults["label"]
power_metric_id = register_metric(
f"string_{index}_power",
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",
precision=0,
)
voltage_metric_id = register_metric(
f"string_{index}_voltage",
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",
precision=1,
)
if power_metric_id or voltage_metric_id:
STRINGS.append(
{
"id": f"string_{index}",
"label": label,
"metrics": {
key: value
for key, value in {
"power": power_metric_id,
"voltage": voltage_metric_id,
}.items()
if value
},
}
)
MODULES = {
"realtime_overview": True,
"realtime_history": True,
"analytics": "energy_total" in METRICS or "ac_power" in METRICS,
"comparison": "energy_total" in METRICS or "ac_power" in METRICS,
"distribution_pie": "energy_total" in METRICS or "ac_power" in METRICS,
"strings": len(STRINGS) > 0,
"temperatures": "inverter_temp" in METRICS,
"historical_import": HISTORY["enabled"],
"phases": False,
"faults": False,
"settings_panel": True,
}
STATUS_METRICS = [metric_id for metric_id in ["inverter_temp"] if metric_id in METRICS]
VISIBLE_ENTITY_TABLE = list(METRICS.keys())

2
backend/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
Flask>=3.1,<4
waitress>=3.0.2,<4

15
backend/run.py Normal file
View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from app.main import app
from app.core_settings import get_settings
if __name__ == "__main__":
settings = get_settings()
app.run(
host=settings.host,
port=settings.port,
debug=settings.debug,
use_reloader=settings.debug,
threaded=True,
)

18
backend/run_prod.py Normal file
View File

@@ -0,0 +1,18 @@
from __future__ import annotations
import os
from waitress import serve
from app.main import app
from app.core_settings import get_settings
if __name__ == "__main__":
settings = get_settings()
try:
threads = int(os.getenv("WAITRESS_THREADS", "8"))
except (TypeError, ValueError):
threads = 8
serve(app, host=settings.host, port=settings.port, threads=threads)

23
deploy/nginx/default.conf Normal file
View File

@@ -0,0 +1,23 @@
server {
listen 80;
server_name _;
server_tokens off;
location /api/ {
proxy_pass http://backend:8105;
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;
}
location / {
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;
}
}

27
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,27 @@
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile.dev
env_file:
- ./.env
environment:
APP_HOST: 0.0.0.0
APP_PORT: 8105
APP_DEBUG: "true"
CORS_ORIGINS: http://localhost:5173,http://127.0.0.1:5173,http://localhost:4173,http://127.0.0.1:4173
volumes:
- ./data:/app/data
ports:
- "8105:8105"
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
environment:
VITE_DEMO_MODE: "false"
ports:
- "5173:5173"
depends_on:
- backend

36
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,36 @@
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
env_file:
- ./.env
environment:
APP_HOST: 0.0.0.0
APP_PORT: 8105
volumes:
- ./data:/app/data
expose:
- "8105"
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
depends_on:
- backend
expose:
- "80"
restart: unless-stopped
reverse-proxy:
image: nginx:1.28.2-alpine
depends_on:
- frontend
- backend
volumes:
- ./deploy/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "8787:80"
restart: unless-stopped

25
docker-compose.yml Normal file
View File

@@ -0,0 +1,25 @@
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
env_file:
- ./.env
environment:
APP_HOST: 0.0.0.0
APP_PORT: 8105
volumes:
- ./data:/app/data
ports:
- "8105:8105"
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "8080:80"
restart: unless-stopped
depends_on:
- backend

4
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
*.log
.env

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.28-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

13
frontend/Dockerfile.dev Normal file
View File

@@ -0,0 +1,13 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html /app/
COPY public /app/public
COPY src /app/src
RUN npm ci
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="pl" data-bs-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/vendor/tabler/tabler.min.css" />
<title>PV Insight</title>
</head>
<body class="bg-body-tertiary">
<div id="root"></div>
<script src="/vendor/tabler/tabler.min.js" defer></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

30
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,30 @@
server {
listen 80;
server_name _;
server_tokens off;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8105;
proxy_http_version 1.1;
proxy_set_header Host $host;
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;
}
location = /health {
proxy_pass http://backend:8105/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
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;
}
location / {
try_files $uri $uri/ /index.html;
}
}

2553
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
frontend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "pv-insight-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0 --port 4173",
"dev:demo": "VITE_DEMO_MODE=true vite",
"build:demo": "VITE_DEMO_MODE=true tsc --noEmit && vite build"
},
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"clsx": "^2.1.1",
"echarts": "^6.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"tailwindcss": "^4.2.0",
"typescript": "^5.0.0",
"vite": "^7.0.0"
}
}

View File

@@ -0,0 +1,183 @@
Tabler core 1.4.0
Source package: @tabler/core
License metadata: MIT
Upstream package.json follows:
{
"name": "@tabler/core",
"version": "1.4.0",
"description": "Premium and Open Source dashboard template with responsive and high quality UI.",
"homepage": "https://tabler.io",
"repository": {
"type": "git",
"url": "git+https://github.com/tabler/tabler.git"
},
"keywords": [
"css",
"sass",
"mobile-first",
"responsive",
"front-end",
"framework",
"web"
],
"author": "codecalm",
"license": "MIT",
"bugs": {
"url": "https://github.com/tabler/tabler/issues"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"engines": {
"node": ">=20"
},
"files": [
"docs/**/*",
"dist/**/*",
"js/**/*.{js,map}",
"img/**/*.{svg}",
"scss/**/*.scss",
"libs.json"
],
"style": "dist/css/tabler.css",
"sass": "scss/tabler.scss",
"unpkg": "dist/js/tabler.min.js",
"umd:main": "dist/js/tabler.min.js",
"module": "dist/js/tabler.esm.js",
"main": "dist/js/tabler.js",
"bundlewatch": {
"files": [
{
"path": "./dist/css/tabler.css",
"maxSize": "75 kB"
},
{
"path": "./dist/css/tabler.min.css",
"maxSize": "70 kB"
},
{
"path": "./dist/css/tabler.rtl.css",
"maxSize": "75 kB"
},
{
"path": "./dist/css/tabler.rtl.min.css",
"maxSize": "70 kB"
},
{
"path": "./dist/css/tabler-flags.css",
"maxSize": "2.5 kB"
},
{
"path": "./dist/css/tabler-flags.min.css",
"maxSize": "2 kB"
},
{
"path": "./dist/css/tabler-payments.css",
"maxSize": "2.2 kB"
},
{
"path": "./dist/css/tabler-payments.min.css",
"maxSize": "2 kB"
},
{
"path": "./dist/css/tabler-socials.css",
"maxSize": "2 kB"
},
{
"path": "./dist/css/tabler-socials.min.css",
"maxSize": "2 kB"
},
{
"path": "./dist/css/tabler-vendors.css",
"maxSize": "7.5 kB"
},
{
"path": "./dist/css/tabler-vendors.min.css",
"maxSize": "6.5 kB"
},
{
"path": "./dist/js/tabler.js",
"maxSize": "60 kB"
},
{
"path": "./dist/js/tabler.min.js",
"maxSize": "45 kB"
},
{
"path": "./dist/js/tabler.esm.js",
"maxSize": "60 kB"
},
{
"path": "./dist/js/tabler.esm.min.js",
"maxSize": "45 kB"
}
]
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "5.3.7"
},
"devDependencies": {
"@hotwired/turbo": "^8.0.13",
"@melloware/coloris": "^0.25.0",
"apexcharts": "3.54.1",
"autosize": "^6.0.1",
"choices.js": "^11.1.0",
"clipboard": "^2.0.11",
"countup.js": "^2.9.0",
"dropzone": "^6.0.0-beta.2",
"flatpickr": "^4.6.13",
"fslightbox": "^3.6.1",
"fullcalendar": "^6.1.18",
"hugerte": "^1.0.9",
"imask": "^7.6.1",
"jsvectormap": "^1.7.0",
"list.js": "^2.3.1",
"litepicker": "^2.0.12",
"nouislider": "^15.8.1",
"plyr": "^3.7.8",
"signature_pad": "^5.0.10",
"star-rating.js": "^4.3.1",
"tom-select": "^2.4.3",
"typed.js": "^2.1.0"
},
"directories": {
"doc": "docs"
},
"scripts": {
"dev": "pnpm run clean && pnpm run copy && pnpm run watch",
"build": "pnpm run clean && pnpm run css && pnpm run js && pnpm run copy && pnpm run generate-sri",
"clean": "shx rm -rf dist demo",
"css": "pnpm run css-compile && pnpm run css-prefix && pnpm run css-rtl && pnpm run css-minify && pnpm run css-banner",
"css-compile": "sass --no-source-map --load-path=node_modules --style expanded scss/:dist/css/",
"css-banner": "node .build/add-banner.mjs",
"css-prefix": "postcss --config .build/postcss.config.mjs --replace \"dist/css/*.css\" \"!dist/css/*.rtl*.css\" \"!dist/css/*.min.css\"",
"css-rtl": "cross-env NODE_ENV=RTL postcss --config .build/postcss.config.mjs --dir \"dist/css\" --ext \".rtl.css\" \"dist/css/*.css\" \"!dist/css/*.min.css\" \"!dist/css/*.rtl.css\"",
"css-minify": "pnpm run css-minify-main && pnpm run css-minify-rtl",
"css-minify-main": "cleancss -O1 --format breakWith=lf --with-rebase --source-map --source-map-inline-sources --output dist/css/ --batch --batch-suffix \".min\" \"dist/css/*.css\" \"!dist/css/*.min.css\" \"!dist/css/*rtl*.css\"",
"css-minify-rtl": "cleancss -O1 --format breakWith=lf --with-rebase --source-map --source-map-inline-sources --output dist/css/ --batch --batch-suffix \".min\" \"dist/css/*rtl.css\" \"!dist/css/*.min.css\"",
"js": "pnpm run js-compile && pnpm run js-minify",
"js-compile": "pnpm run js-compile-standalone && pnpm run js-compile-standalone-esm && pnpm run js-compile-theme && pnpm run js-compile-theme-esm",
"js-compile-theme-esm": "rollup --environment THEME:true --environment ESM:true --config .build/rollup.config.mjs --sourcemap",
"js-compile-theme": "rollup --environment THEME:true --config .build/rollup.config.mjs --sourcemap",
"js-compile-standalone": "rollup --config .build/rollup.config.mjs --sourcemap",
"js-compile-standalone-esm": "rollup --environment ESM:true --config .build/rollup.config.mjs --sourcemap",
"js-minify": "pnpm run js-minify-standalone && pnpm run js-minify-standalone-esm && pnpm run js-minify-theme && pnpm run js-minify-theme-esm",
"js-minify-standalone": "terser --compress passes=2 --mangle --comments \"/^!/\" --source-map \"content=dist/js/tabler.js.map,includeSources,url=tabler.min.js.map\" --output dist/js/tabler.min.js dist/js/tabler.js",
"js-minify-standalone-esm": "terser --compress passes=2 --mangle --comments \"/^!/\" --source-map \"content=dist/js/tabler.esm.js.map,includeSources,url=tabler.esm.min.js.map\" --output dist/js/tabler.esm.min.js dist/js/tabler.esm.js",
"js-minify-theme": "terser --compress passes=2 --mangle --comments \"/^!/\" --source-map \"content=dist/js/tabler-theme.js.map,includeSources,url=tabler-theme.min.js.map\" --output dist/js/tabler-theme.min.js dist/js/tabler-theme.js",
"js-minify-theme-esm": "terser --compress passes=2 --mangle --comments \"/^!/\" --source-map \"content=dist/js/tabler-theme.esm.js.map,includeSources,url=tabler-theme.esm.min.js.map\" --output dist/js/tabler-theme.esm.min.js dist/js/tabler-theme.esm.js",
"copy": "pnpm run copy-img && pnpm run copy-libs",
"copy-img": "shx mkdir -p dist/img && shx cp -rf img/* dist/img",
"copy-libs": "node .build/copy-libs.mjs",
"watch": "concurrently \"pnpm run watch-css\" \"pnpm run watch-js\"",
"watch-css": "nodemon --watch scss/ --ext scss --exec \"pnpm run css-compile && pnpm run css-prefix\"",
"watch-js": "nodemon --watch js/ --ext js --exec \"pnpm run js-compile\"",
"bundlewatch": "bundlewatch",
"generate-sri": "node .build/generate-sri.js",
"format:check": "prettier --check src/**/*.{js,scss} --cache",
"format:write": "prettier --write src/**/*.{js,scss} --cache"
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

501
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,501 @@
import { useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { EChartsOption } from "echarts";
import {
IconArrowsMove,
IconBolt,
IconChartBar,
IconChecklist,
IconClockHour4,
IconDatabaseImport,
IconDeviceDesktop,
IconHistory,
IconLanguage,
IconLayoutDashboard,
IconLock,
IconLogin2,
IconLogout,
IconMoon,
IconPlayerPlay,
IconRefresh,
IconSettings,
IconSun,
IconTemperature,
IconX,
} from "./components/common/Icons";
import { api } from "./api/client";
import { EChart } from "./components/common/EChart";
import { labelForMetric, localeForLanguage, normalizeLanguage, t, translateCompareMode, type Language } from "./i18n";
import { useAnalytics, useDashboardConfig, useHistoricalImport, useRealtimeHistory, useRealtimeSocket } from "./hooks";
import { formatDateTime, formatDurationShort, formatPercent, formatShortTime, formatValue } from "./lib/format";
import type {
AnalyticsPayload,
AuthStatus,
AuthUsersPayload,
BucketPoint,
DashboardConfig,
DistributionPayload,
HistoryPayload,
HistoricalStatus,
KioskSettingsPayload,
MetricValue,
SnapshotGroupRow,
SnapshotPayload,
} from "./types";
type ThemeMode = "light" | "dark";
type TabKey = "realtime" | "archive" | "analytics" | "warehouse" | "kiosk" | "settings";
type ViewMode = "normal" | "kiosk";
type WidgetId = "hero" | "quickMetrics" | "history" | "status" | "strings" | "production" | "comparison" | "distribution" | "importStatus";
type BlockTarget = "hero" | "quick";
const STORAGE_KEYS = {
theme: "pv-theme-v4",
language: "pv-language-v4",
kioskWidgets: "pv-kiosk-widgets-v4",
viewMode: "pv-view-mode-v4",
blockConfig: "pv-block-config-v4",
liveMetrics: "pv-live-metrics-v4",
archiveMetrics: "pv-archive-metrics-v4",
};
const DEFAULT_KIOSK_WIDGETS: WidgetId[] = ["hero", "history", "strings", "status", "production", "comparison", "importStatus"];
const DEFAULT_BLOCK_CONFIG: Record<BlockTarget, string[]> = {
hero: ["ac_power", "dc_power_total", "energy_today", "energy_total"],
quick: ["energy_today", "energy_yesterday", "energy_total", "dc_power_total", "today_vs_yesterday"],
};
const DEFAULT_LIVE_METRICS = ["ac_power", "string_1_power", "string_2_power"];
function getKioskRouteMode(): "public" | "private" | null {
const pathname = window.location.pathname.replace(/\/+$/, "") || "/";
if (pathname.endsWith("/kiosk/public")) return "public";
if (pathname.endsWith("/kiosk/private")) return "private";
const url = new URL(window.location.href);
if (url.searchParams.get("publicKiosk") === "1") return "public";
if (url.searchParams.get("privateKiosk") === "1") return "private";
return null;
}
const KIOSK_ROUTE_MODE = getKioskRouteMode();
const PUBLIC_KIOSK = KIOSK_ROUTE_MODE === "public";
const PRIVATE_KIOSK_ROUTE = KIOSK_ROUTE_MODE === "private";
const widgetOrder: Array<{ id: WidgetId; tab: TabKey; icon: typeof IconLayoutDashboard }> = [
{ id: "hero", tab: "realtime", icon: IconLayoutDashboard },
{ id: "quickMetrics", tab: "realtime", icon: IconChecklist },
{ id: "history", tab: "realtime", icon: IconHistory },
{ id: "status", tab: "realtime", icon: IconBolt },
{ id: "strings", tab: "realtime", icon: IconArrowsMove },
{ id: "production", tab: "analytics", icon: IconChartBar },
{ id: "comparison", tab: "analytics", icon: IconRefresh },
{ id: "distribution", tab: "analytics", icon: IconChartBar },
{ id: "importStatus", tab: "warehouse", icon: IconDatabaseImport },
];
function readStorage<T>(key: string, fallback: T, parser?: (raw: string) => T): T {
try {
const raw = window.localStorage.getItem(key);
if (!raw) return fallback;
return parser ? parser(raw) : (JSON.parse(raw) as T);
} catch {
return fallback;
}
}
function writeStorage<T>(key: string, value: T): void {
try { window.localStorage.setItem(key, typeof value === "string" ? value : JSON.stringify(value)); } catch {}
}
function parseViewModeFromLocation(): ViewMode {
if (KIOSK_ROUTE_MODE) return "kiosk";
const url = new URL(window.location.href);
return url.searchParams.get("mode") === "kiosk" ? "kiosk" : "normal";
}
function syncViewModeToLocation(mode: ViewMode): void {
if (KIOSK_ROUTE_MODE) return;
const url = new URL(window.location.href);
if (mode === "kiosk") url.searchParams.set("mode", "kiosk"); else url.searchParams.delete("mode");
window.history.replaceState({}, "", url.toString());
}
function iconForMetric(metricId: string) {
if (metricId.includes("temp")) return <IconTemperature size={18} />;
if (metricId.includes("energy")) return <IconChartBar size={18} />;
return <IconBolt size={18} />;
}
function buildWidgetLabel(language: Language, widgetId: WidgetId): string {
const labels: Record<WidgetId, string> = {
hero: language === "en" ? "Hero metrics" : "Karty hero",
quickMetrics: t(language, "quickMetrics"),
history: t(language, "chartPowerHistory"),
status: t(language, "systemStatus"),
strings: t(language, "strings"),
production: t(language, "chartProduction"),
comparison: t(language, "chartComparison"),
distribution: t(language, "chartDistribution"),
importStatus: language === "en" ? "Data warehouse" : "Hurtownia danych",
};
return labels[widgetId];
}
function buildTablerChartTheme(theme: ThemeMode) {
return theme === "dark"
? { text: "#cbd5e1", grid: "rgba(255,255,255,0.08)", tooltip: "rgba(15, 23, 42, 0.96)", series: ["#4dabf7", "#20c997", "#f59f00", "#e64980", "#9775fa", "#ff922b", "#66d9e8", "#adb5bd", "#94d82d", "#ffa8a8"] }
: { text: "#334155", grid: "rgba(15,23,42,0.12)", tooltip: "rgba(255,255,255,0.98)", series: ["#206bc4", "#2fb344", "#f59f00", "#d63384", "#7950f2", "#fd7e14", "#1098ad", "#868e96", "#74b816", "#fa5252"] };
}
function buildLiveHistoryOption(history: HistoryPayload | undefined, theme: ThemeMode, language: Language): EChartsOption {
const palette = buildTablerChartTheme(theme);
const series = history?.series ?? [];
return {
color: palette.series,
tooltip: { trigger: "axis", backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text } },
legend: { top: 0, textStyle: { color: palette.text }, itemGap: 16 },
grid: { left: 12, right: 16, top: 48, bottom: 12, containLabel: true },
xAxis: { type: "category", boundaryGap: false, axisLabel: { color: palette.text }, axisLine: { lineStyle: { color: palette.grid } }, data: (series[0]?.points ?? []).map((point: any) => formatShortTime(point.timestamp, localeForLanguage(language))) },
yAxis: { type: "value", axisLabel: { color: palette.text }, splitLine: { lineStyle: { color: palette.grid } } },
series: series.map((item, index) => ({ name: item.label, type: "line", smooth: true, connectNulls: true, showSymbol: false, lineStyle: { width: index === 0 ? 3 : 2 }, emphasis: { focus: "series" }, data: item.points.map((point: any) => point.value) })),
};
}
function buildBarOption(points: BucketPoint[], unit: string, theme: ThemeMode, language: Language): EChartsOption {
const palette = buildTablerChartTheme(theme);
return {
color: [palette.series[0]],
tooltip: { trigger: "axis", backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text }, valueFormatter: (value) => formatValue(Number(value), unit, 2, localeForLanguage(language)) },
grid: { left: 12, right: 16, top: 16, bottom: 40, containLabel: true },
xAxis: { type: "category", axisLabel: { color: palette.text, rotate: points.length > 12 ? 32 : 0 }, axisLine: { lineStyle: { color: palette.grid } }, data: points.map((point) => point.label) },
yAxis: { type: "value", name: unit, nameTextStyle: { color: palette.text }, axisLabel: { color: palette.text }, splitLine: { lineStyle: { color: palette.grid } } },
series: [{ type: "bar", barMaxWidth: 24, itemStyle: { borderRadius: [6, 6, 0, 0] }, data: points.map((point) => point.value) }],
};
}
function buildComparisonOption(data: AnalyticsPayload | undefined, theme: ThemeMode, language: Language): EChartsOption {
const palette = buildTablerChartTheme(theme);
const current = data?.current ?? [];
const comparisonSeries = (data?.comparisons?.length ? data.comparisons : [{ key: data?.compare_mode ?? "comparison", label: t(language, "comparisonPeriod"), points: data?.comparison ?? [] }]).filter((item) => item.points?.length).map((item) => ({ ...item, label: translateCompareMode(language, item.label || item.key) }));
return {
color: palette.series,
tooltip: { trigger: "axis", axisPointer: { type: "shadow" }, backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text } },
legend: { top: 0, textStyle: { color: palette.text } },
grid: { left: 16, right: 20, top: 42, bottom: 18, containLabel: true },
xAxis: { type: "category", axisLabel: { color: palette.text, interval: 0, rotate: current.length > 12 ? 35 : 0 }, axisLine: { lineStyle: { color: palette.grid } }, data: current.map((point) => point.label) },
yAxis: { type: "value", axisLabel: { color: palette.text }, splitLine: { lineStyle: { color: palette.grid } } },
series: [
{ name: t(language, "currentPeriod"), type: "bar", barMaxWidth: 18, data: current.map((point) => point.value) },
...comparisonSeries.map((seriesItem, index) => ({ name: seriesItem.label, type: "bar" as const, barMaxWidth: 18, data: current.map((_, pointIndex) => seriesItem.points[pointIndex]?.value ?? 0), itemStyle: { opacity: index === 0 ? 0.9 : 0.75 } })),
],
};
}
function buildPieOption(data: DistributionPayload | undefined, theme: ThemeMode): EChartsOption {
const palette = buildTablerChartTheme(theme);
const slices = [...(data?.slices ?? [])].sort((a, b) => b.value - a.value).slice(0, 12);
return {
color: palette.series,
tooltip: { trigger: "axis", axisPointer: { type: "shadow" }, backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text } },
grid: { left: 16, right: 28, top: 8, bottom: 8, containLabel: true },
xAxis: { type: "value", axisLabel: { color: palette.text }, splitLine: { lineStyle: { color: palette.grid } } },
yAxis: { type: "category", axisLabel: { color: palette.text }, data: slices.map((item) => item.label) },
series: [{ type: "bar", data: slices.map((item) => ({ value: item.value, label: { show: true, position: "right", formatter: `${item.share}%`, color: palette.text } })), barMaxWidth: 22, itemStyle: { borderRadius: [0, 6, 6, 0] } }],
};
}
function liveRangeOptions(language: Language) {
return [
{ key: "today", label: language === "en" ? "Today" : "Dziś" },
{ key: "yesterday", label: language === "en" ? "Yesterday" : "Wczoraj" },
{ key: "6h", label: "6h" },
{ key: "12h", label: "12h" },
{ key: "24h", label: "24h" },
{ key: "48h", label: "48h" },
{ key: "7d", label: "7d" },
];
}
function analyticsRangeOptions(language: Language) {
return [
{ key: "today", label: language === "en" ? "Today" : "Dziś" },
{ key: "yesterday", label: language === "en" ? "Yesterday" : "Wczoraj" },
{ key: "7d", label: "7d" },
{ key: "30d", label: "30d" },
{ key: "90d", label: "90d" },
{ key: "365d", label: language === "en" ? "365 days" : "365 dni" },
];
}
function archiveRangeOptions(language: Language) {
return [
{ key: "1d", label: language === "en" ? "1 day" : "1 dzień" },
{ key: "3d", label: "3d" },
{ key: "7d", label: "7d" },
{ key: "14d", label: "14d" },
{ key: "30d", label: "30d" },
{ key: "60d", label: "60d" },
{ key: "custom", label: language === "en" ? "Custom" : "Ręczny" },
];
}
function getInitialTheme(config?: DashboardConfig): ThemeMode {
return readStorage<ThemeMode>(STORAGE_KEYS.theme, (config?.defaults.theme as ThemeMode) ?? "dark", (raw) => (raw === "light" ? "light" : "dark"));
}
function getInitialLanguage(config?: DashboardConfig): Language {
return normalizeLanguage(readStorage<string>(STORAGE_KEYS.language, config?.defaults.language ?? "pl", (raw) => raw));
}
function getVisibleWidgets(ids: WidgetId[]): WidgetId[] { const base = ids.filter((id, index) => ids.indexOf(id) === index); return base.length > 0 ? base : DEFAULT_KIOSK_WIDGETS; }
function toWidgetIds(ids: string[]): WidgetId[] { return getVisibleWidgets(ids.filter((id): id is WidgetId => widgetOrder.some((item) => item.id === id as WidgetId))); }
function getMetricCandidates(snapshot: SnapshotPayload, config?: DashboardConfig) {
const fromConfig = (config?.visible_entities ?? [])
.filter((item) => item.kind === "gauge")
.map((item) => ({ metric_id: item.metric_id, label: item.label, unit: item.unit }));
const map = new Map<string, { metric_id: string; label: string; unit: string }>();
fromConfig.forEach((item) => map.set(item.metric_id, item));
return [...map.values()];
}
export default function App() {
const queryClient = useQueryClient();
const publicMode = PUBLIC_KIOSK;
const privateKioskRoute = PRIVATE_KIOSK_ROUTE;
const authQuery = useQuery<AuthStatus>({ queryKey: ["auth-status", publicMode], queryFn: api.getAuthStatus, staleTime: 20_000, retry: false, enabled: !publicMode });
const authEnabled = publicMode ? false : (authQuery.data?.enabled ?? true);
const authenticated = publicMode ? true : (authQuery.data ? (!authEnabled || authQuery.data.authenticated) : false);
const configQuery = useDashboardConfig(authenticated || authEnabled === false);
const config = configQuery.data;
const privateKioskSettingsQuery = useQuery<KioskSettingsPayload>({ queryKey: ["kiosk-settings", "private"], queryFn: () => api.getKioskSettings("private"), enabled: !publicMode && (authenticated || authEnabled === false), staleTime: 30_000 });
const publicKioskSettingsQuery = useQuery<KioskSettingsPayload>({ queryKey: ["kiosk-settings", "public"], queryFn: () => api.getKioskSettings("public"), enabled: publicMode || authenticated || authEnabled === false, staleTime: 30_000 });
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme(undefined));
const [language, setLanguage] = useState<Language>(() => getInitialLanguage(undefined));
const [activeTab, setActiveTab] = useState<TabKey>(publicMode ? "kiosk" : "realtime");
const [realtimeRange, setRealtimeRange] = useState("6h");
const [analyticsRange, setAnalyticsRange] = useState("30d");
const [bucket, setBucket] = useState("day");
const [compare, setCompare] = useState("previous_year");
const [analyticsStart, setAnalyticsStart] = 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 [archiveStart, setArchiveStart] = useState("");
const [archiveEnd, setArchiveEnd] = useState("");
const [archiveRange, setArchiveRange] = useState("1d");
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 [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 [kioskEditorMode, setKioskEditorMode] = useState<"private" | "public">("private");
const [privateKioskDraft, setPrivateKioskDraft] = useState<KioskSettingsPayload>({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" });
const [publicKioskDraft, setPublicKioskDraft] = useState<KioskSettingsPayload>({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" });
const [blockConfig, setBlockConfig] = useState<Record<BlockTarget, string[]>>(() => readStorage(STORAGE_KEYS.blockConfig, DEFAULT_BLOCK_CONFIG));
const [loginForm, setLoginForm] = useState({ username: "", password: "" });
const [loginError, setLoginError] = useState<string | null>(null);
const [newUser, setNewUser] = useState({ username: "", display_name: "", password: "", role: "user" });
const [passwordReset, setPasswordReset] = useState<{ username: string; password: string }>({ username: "", password: "" });
const [kioskSaveNotice, setKioskSaveNotice] = useState<Record<"public" | "private", string | null>>({ public: null, private: null });
const initializedRef = useRef(false);
const defaultKioskSerializedRef = useRef<Record<"public" | "private", string>>({
public: JSON.stringify({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }),
private: JSON.stringify({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }),
});
const lastSyncedKioskRef = useRef<Record<"public" | "private", string>>({
public: defaultKioskSerializedRef.current.public,
private: defaultKioskSerializedRef.current.private,
});
useEffect(() => {
if (!config || initializedRef.current) return;
initializedRef.current = true;
setActiveTab((config.defaults.tab as TabKey) || (publicMode ? "kiosk" : "realtime"));
setRealtimeRange(config.defaults.realtime_range);
setAnalyticsRange(config.defaults.analytics_range);
setBucket(config.defaults.analytics_bucket);
setTheme((current) => current || ((config.defaults.theme as ThemeMode) ?? "dark"));
setLanguage((current) => current || normalizeLanguage(config.defaults.language));
}, [config, publicMode]);
useEffect(() => {
if (!privateKioskSettingsQuery.data) return;
const normalized = { ...privateKioskSettingsQuery.data, mode: "private" as const };
lastSyncedKioskRef.current.private = JSON.stringify(normalized);
applyKioskDraftChange("private", normalized);
}, [privateKioskSettingsQuery.data]);
useEffect(() => {
if (!publicKioskSettingsQuery.data) return;
const normalized = { ...publicKioskSettingsQuery.data, mode: "public" as const };
lastSyncedKioskRef.current.public = JSON.stringify(normalized);
applyKioskDraftChange("public", normalized);
}, [publicKioskSettingsQuery.data]);
useEffect(() => { document.documentElement.setAttribute("data-bs-theme", theme); document.body.setAttribute("data-bs-theme", theme); writeStorage(STORAGE_KEYS.theme, theme); }, [theme]);
useEffect(() => { writeStorage(STORAGE_KEYS.language, language); }, [language]);
useEffect(() => { syncViewModeToLocation(viewMode); writeStorage(STORAGE_KEYS.viewMode, viewMode); }, [viewMode]);
useEffect(() => { writeStorage(STORAGE_KEYS.kioskWidgets, kioskWidgets); }, [kioskWidgets]);
useEffect(() => { writeStorage(STORAGE_KEYS.blockConfig, blockConfig); }, [blockConfig]);
useEffect(() => { writeStorage(STORAGE_KEYS.liveMetrics, liveMetrics); }, [liveMetrics]);
useEffect(() => { writeStorage(STORAGE_KEYS.archiveMetrics, archiveMetrics); }, [archiveMetrics]);
const dataEnabled = authenticated || authEnabled === false;
const { snapshot, connected, lastUpdated } = useRealtimeSocket(dataEnabled);
const metricCandidates = useMemo(() => getMetricCandidates(snapshot, config), [snapshot, config]);
useEffect(() => {
if (!metricCandidates.length) return;
const allowed = new Set(metricCandidates.map((item) => item.metric_id));
setLiveMetrics((current) => {
const filtered = current.filter((item) => allowed.has(item));
return filtered.length ? filtered : metricCandidates.slice(0, 3).map((item) => item.metric_id);
});
setArchiveMetrics((current) => {
const filtered = current.filter((item) => allowed.has(item));
return filtered.length ? filtered : metricCandidates.slice(0, 3).map((item) => item.metric_id);
});
}, [metricCandidates]);
const liveHistoryMetrics = useMemo(() => liveMetrics.filter((item) => item !== "inverter_temp"), [liveMetrics]);
const effectiveKioskSettings = publicMode ? publicKioskDraft : privateKioskDraft;
const kioskActive = publicMode || privateKioskRoute || viewMode === "kiosk";
const effectiveKioskWidgets = toWidgetIds(kioskActive ? effectiveKioskSettings.widgets : kioskWidgets);
const effectiveRealtimeRange = kioskActive ? effectiveKioskSettings.realtime_range : realtimeRange;
const effectiveAnalyticsRange = kioskActive ? effectiveKioskSettings.analytics_range : analyticsRange;
const effectiveBucket = kioskActive ? effectiveKioskSettings.analytics_bucket : bucket;
const effectiveCompare = kioskActive ? effectiveKioskSettings.compare_mode : compare;
const historyQuery = useRealtimeHistory(effectiveRealtimeRange, dataEnabled, { metrics: liveHistoryMetrics, publicKiosk: publicMode });
const sanitizedCompareRanges = compareRanges.filter((item) => item.start && item.end);
const analyticsOptions = analyticsStart && analyticsEnd && !kioskActive ? { start: analyticsStart, end: analyticsEnd, publicKiosk: publicMode, compareRanges: effectiveCompare === "custom_multi" ? sanitizedCompareRanges : undefined } : { publicKiosk: publicMode, compareRanges: effectiveCompare === "custom_multi" ? sanitizedCompareRanges : undefined };
const analyticsQuery = useAnalytics(analyticsStart && analyticsEnd && !kioskActive ? "custom" : effectiveAnalyticsRange, effectiveBucket, effectiveCompare, dataEnabled, analyticsOptions);
const historical = useHistoricalImport(dataEnabled && !publicMode);
const archiveQuery = useRealtimeHistory(archiveStart && archiveEnd ? "custom" : archiveRange, dataEnabled, { start: archiveStart || undefined, end: archiveEnd || undefined, metrics: archiveMetrics, publicKiosk: publicMode });
const usersQuery = useQuery<AuthUsersPayload>({ queryKey: ["auth-users"], queryFn: api.getUsers, enabled: dataEnabled && (authQuery.data?.role === "admin"), staleTime: 15_000 });
const loginMutation = useMutation({ mutationFn: () => api.login(loginForm.username, loginForm.password), onSuccess: async () => { setLoginError(null); setLoginForm((value) => ({ ...value, password: "" })); await queryClient.invalidateQueries({ queryKey: ["auth-status"] }); await queryClient.invalidateQueries({ queryKey: ["dashboard-config"] }); }, onError: (error: Error) => setLoginError(parseError(error) || t(language, "loginError")) });
const logoutMutation = useMutation({ mutationFn: api.logout, onSuccess: async () => { await queryClient.clear(); await queryClient.invalidateQueries({ queryKey: ["auth-status"] }); } });
const createUserMutation = useMutation({ mutationFn: () => api.createUser(newUser), onSuccess: async () => { setNewUser({ username: "", display_name: "", password: "", role: "user" }); await usersQuery.refetch(); } });
const resetPasswordMutation = useMutation({ mutationFn: () => api.resetUserPassword(passwordReset.username, passwordReset.password), onSuccess: async () => { setPasswordReset({ username: "", password: "" }); await usersQuery.refetch(); } });
const saveKioskSettingsMutation = useMutation({
mutationFn: (payload: KioskSettingsPayload) => api.saveKioskSettings(payload),
onSuccess: async (saved, payload) => {
const normalized = { ...saved, mode: payload.mode };
lastSyncedKioskRef.current[payload.mode] = JSON.stringify(normalized);
if (payload.mode === "public") setPublicKioskDraft(normalized); else setPrivateKioskDraft(normalized);
setKioskSaveNotice((current) => ({ ...current, [payload.mode]: language === "en" ? "Saved." : "Zapisano." }));
await queryClient.invalidateQueries({ queryKey: ["kiosk-settings", payload.mode] });
},
onError: (error: Error, payload) => {
const message = parseError(error) || (language === "en" ? "Save failed." : "Nie udało się zapisać.");
setKioskSaveNotice((current) => ({ ...current, [payload.mode]: message }));
},
});
const applyKioskDraftChange = (mode: "public" | "private", next: KioskSettingsPayload) => {
const normalized: KioskSettingsPayload = { ...next, mode };
if (mode === "public") setPublicKioskDraft(normalized); else setPrivateKioskDraft(normalized);
setKioskSaveNotice((current) => ({ ...current, [mode]: null }));
};
const canPersistKioskSettings = !publicMode && (authEnabled === false || authQuery.data?.role === "admin");
const privateKioskDirty = JSON.stringify(privateKioskDraft) !== lastSyncedKioskRef.current.private;
const publicKioskDirty = JSON.stringify(publicKioskDraft) !== lastSyncedKioskRef.current.public;
const currentKioskDirty = kioskEditorMode === "public" ? publicKioskDirty : privateKioskDirty;
const resetKioskDraft = (mode: "public" | "private") => {
const serialized = lastSyncedKioskRef.current[mode] || defaultKioskSerializedRef.current[mode];
const parsed = JSON.parse(serialized) as KioskSettingsPayload;
applyKioskDraftChange(mode, { ...parsed, mode });
};
const saveCurrentKioskSettings = () => {
if (!canPersistKioskSettings || saveKioskSettingsMutation.isPending) return;
const payload = kioskEditorMode === "public" ? publicKioskDraft : privateKioskDraft;
setKioskSaveNotice((current) => ({ ...current, [payload.mode]: null }));
saveKioskSettingsMutation.mutate(payload);
};
const locale = localeForLanguage(language);
const widgetLabels = useMemo(() => { const map = new Map<WidgetId, string>(); for (const item of widgetOrder) map.set(item.id, buildWidgetLabel(language, item.id)); return map; }, [language]);
const summary = analyticsQuery.production.data?.summary;
const topStatus = snapshot.status ?? [];
const heroCards = snapshot.hero_cards.filter((card) => blockConfig.hero.includes(card.metric_id));
const quickMetrics = Object.values(snapshot.kpis ?? {}).filter((metric) => blockConfig.quick.includes(metric.metric_id));
const isAdmin = authQuery.data?.role === "admin";
const publicKioskUrl = `${window.location.origin}/kiosk/public`;
const privateKioskUrl = `${window.location.origin}/kiosk/private`;
const allWidgets: Record<WidgetId, ReactElement | null> = {
hero: <HeroCards cards={heroCards} locale={locale} language={language} />,
quickMetrics: <QuickMetrics metrics={quickMetrics} locale={locale} language={language} />,
history: <LiveHistoryPanel data={historyQuery.data} language={language} theme={theme} title={t(language, "chartPowerHistory")} subtitle={t(language, "realtimeSubtitle")} />,
status: <StatusPanel metrics={topStatus} locale={locale} language={language} />,
strings: <StringsPanel rows={snapshot.strings} locale={locale} language={language} />,
production: <ProductionPanel data={analyticsQuery.production.data} language={language} theme={theme} />,
comparison: effectiveCompare !== "none" ? <ComparisonPanel data={analyticsQuery.production.data} language={language} theme={theme} /> : null,
distribution: <DistributionPanel data={analyticsQuery.distribution.data} language={language} theme={theme} locale={locale} />,
importStatus: <HistoricalPanel status={historical.status.data} language={language} locale={locale} compact />,
};
const renderWidget = (widgetId: WidgetId) => { const content = allWidgets[widgetId]; if (!content) return null; return <div key={widgetId} className={widgetId === "hero" ? "col-12" : widgetId === "history" ? "col-12 col-xxl-8" : "col-12 col-xxl-4"}>{content}</div>; };
if ((!publicMode && authQuery.isLoading) || (authEnabled && !authenticated && loginMutation.isPending)) return <LoadingScreen language={language} />;
if (authEnabled && !authenticated) return <LoginPage language={language} theme={theme} form={loginForm} onChange={setLoginForm} onSubmit={() => loginMutation.mutate()} onThemeToggle={() => setTheme((current) => (current === "dark" ? "light" : "dark"))} onLanguageToggle={() => setLanguage((current) => (current === "pl" ? "en" : "pl"))} loading={loginMutation.isPending} error={loginError} />;
if (configQuery.isLoading || !config) return <LoadingScreen language={language} />;
const navbar = (
<header className="navbar navbar-expand-md d-print-none pv-navbar">
<div className="container-xl">
<div className="navbar-brand navbar-brand-autodark d-flex align-items-center gap-2"><span className="avatar avatar-sm bg-primary-lt text-primary border-0"><IconBolt size={18} /></span><div><div className="fw-bold">{config.app.site_name}</div><div className="text-secondary small">{t(language, "operatorPanel")}</div></div></div>
<div className="navbar-nav flex-row order-md-last align-items-center gap-2">
<span className={`badge ${connected ? "bg-green-lt text-green" : "bg-yellow-lt text-yellow"}`}>{connected ? t(language, "connected") : t(language, "disconnected")}</span>
<button className="btn btn-icon btn-ghost-secondary" onClick={() => setTheme((current) => (current === "dark" ? "light" : "dark"))} title={t(language, "theme")}>{theme === "dark" ? <IconSun size={18} /> : <IconMoon size={18} />}</button>
<button className="btn btn-icon btn-ghost-secondary" onClick={() => setLanguage((current) => (current === "pl" ? "en" : "pl"))} title={t(language, "language")}><IconLanguage size={18} /></button>
{!publicMode ? <button className="btn btn-outline-primary" onClick={() => setViewMode((current) => (current === "normal" ? "kiosk" : "normal"))}><IconDeviceDesktop size={18} className="me-1" />{viewMode === "normal" ? t(language, "openKiosk") : t(language, "exitKiosk")}</button> : null}
{!publicMode ? <button className="btn btn-outline-secondary" onClick={() => logoutMutation.mutate()}><IconLogout size={18} className="me-1" />{t(language, "signOut")}</button> : null}
</div>
</div>
</header>
);
const menu = (
<div className="navbar-expand-md pv-subnav border-bottom"><div className="container-xl"><div className="navbar-collapse"><ul className="navbar-nav">
<NavItem icon={<IconLayoutDashboard size={18} />} active={activeTab === "realtime"} onClick={() => setActiveTab("realtime")} label={language === "en" ? "Live" : "Live"} />
<NavItem icon={<IconHistory size={18} />} active={activeTab === "archive"} onClick={() => setActiveTab("archive")} label={language === "en" ? "Historical live" : "Dane chwilowe"} />
<NavItem icon={<IconChartBar size={18} />} active={activeTab === "analytics"} onClick={() => setActiveTab("analytics")} label={t(language, "analytics")} />
<NavItem icon={<IconDatabaseImport size={18} />} active={activeTab === "warehouse"} onClick={() => setActiveTab("warehouse")} label={language === "en" ? "Data warehouse" : "Hurtownia danych"} />
<NavItem icon={<IconDeviceDesktop size={18} />} active={activeTab === "kiosk"} onClick={() => setActiveTab("kiosk")} label={t(language, "kiosk")} />
<NavItem icon={<IconSettings size={18} />} active={activeTab === "settings"} onClick={() => setActiveTab("settings")} label={t(language, "settings")} />
</ul><div className="ms-auto text-secondary small d-none d-md-flex align-items-center gap-2"><IconClockHour4 size={16} />{t(language, "updatedAt")}: {formatDateTime(lastUpdated, locale)}</div></div></div></div>
);
if (viewMode === "kiosk" || publicMode) {
return <div className="page kiosk-shell"><div className="container-fluid py-3 px-3 px-xl-4"><div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3"><div><div className="h2 mb-0">{config.app.site_name}</div><div className="text-secondary">{t(language, "kioskHint")}</div></div><div className="d-flex gap-2"><button className="btn btn-primary" onClick={() => requestFullscreen()}><IconDeviceDesktop size={18} className="me-1" />{t(language, "fullscreen")}</button>{!publicMode ? <button className="btn btn-outline-secondary" onClick={() => setViewMode("normal")}><IconX size={18} className="me-1" />{t(language, "exitKiosk")}</button> : null}</div></div><div className="row row-cards g-3">{effectiveKioskWidgets.map((widgetId) => renderWidget(widgetId))}</div></div></div>;
}
return (
<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">{renderWidget("hero")}<div className="col-12 col-xl-4">{allWidgets.quickMetrics}</div><div className="col-12 col-xl-8">{allWidgets.history}</div><div className="col-12 col-xl-4">{allWidgets.status}</div><div className="col-12 col-xl-8">{allWidgets.strings}</div></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="d-flex flex-wrap gap-2 align-items-end"><SegmentedSelect label={language === "en" ? "Range" : "Zakres"} value={archiveStart && archiveEnd ? "custom" : archiveRange} onChange={(value) => { setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={archiveRangeOptions(language)} /><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={metricCandidates.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={archiveQuery.data} language={language} theme={theme} title={language === "en" ? "Historical chart" : "Wykres historyczny"} subtitle={language === "en" ? "Raw instant metrics from InfluxDB only." : "Tylko surowe metryki chwilowe z InfluxDB."} /></div></div></>}
{activeTab === "analytics" && <><PageHeader title={t(language, "analyticsOverview")} subtitle={t(language, "analyticsSubtitle")}><div className="d-flex flex-wrap gap-2 align-items-end"><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"].includes(item.key)).map((item) => ({ key: item.key, label: 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) }))} /><SegmentedSelect 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 col-xxl-8">{allWidgets.production}</div><div className="col-12 col-xxl-4">{allWidgets.distribution}</div>{compare !== "none" ? <div className="col-12">{allWidgets.comparison}</div> : null}</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" ? "Kiosk mode configuration" : "Konfiguracja trybu kiosk"} /><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={canPersistKioskSettings} saveNotice={kioskSaveNotice[kioskEditorMode]} onSave={saveCurrentKioskSettings} onReset={() => resetKioskDraft(kioskEditorMode)} /></div><div className="col-12 col-xxl-4"><KioskLinkPanel language={language} publicKioskUrl={publicKioskUrl} privateKioskUrl={privateKioskUrl} publicSettings={publicKioskDraft} privateSettings={privateKioskDraft} /></div></div></>}
{activeTab === "settings" && <><PageHeader title={t(language, "settings")} subtitle={language === "en" ? "Appearance, metric blocks and admin users." : "Wygląd, bloki metryk i użytkownicy."} /><div className="row row-cards g-3"><div className="col-12 col-xxl-4"><AppearanceSecurityPanel language={language} theme={theme} setTheme={setTheme} viewMode={viewMode} setViewMode={setViewMode} authEnabled={config.auth?.enabled ?? false} userName={authQuery.data?.display_name ?? authQuery.data?.user ?? ""} /></div><div className="col-12 col-xxl-8"><div className="row g-3"><div className="col-12"><LiveChartMetricsPanel language={language} items={metricCandidates.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={metricCandidates} config={blockConfig} onChange={setBlockConfig} /></div></div></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></>}
</div></div></div></div>
);
}
function parseError(error: Error): string | null { const match = error.message.match(/"detail"\s*:\s*"([^"]+)"/); return match?.[1] ?? error.message; }
function requestFullscreen() { const element = document.documentElement as HTMLElement & { webkitRequestFullscreen?: () => Promise<void> | void; msRequestFullscreen?: () => Promise<void> | void; }; if (element.requestFullscreen) { void element.requestFullscreen(); return; } if (element.webkitRequestFullscreen) { void element.webkitRequestFullscreen(); return; } if (element.msRequestFullscreen) void element.msRequestFullscreen(); }
function translateBucket(language: Language, key: string): string { const map: Record<string, { pl: string; en: string }> = { day: { pl: "Dzień", en: "Day" }, week: { pl: "Tydzień", en: "Week" }, month: { pl: "Miesiąc", en: "Month" }, year: { pl: "Rok", en: "Year" } }; return map[key]?.[language] ?? key; }
function comparisonOptions(language: Language) { return [{ key: "none", label: translateCompareMode(language, "none") }, { key: "previous_period", label: translateCompareMode(language, "previous_period") }, { key: "previous_year", label: translateCompareMode(language, "previous_year") }, { key: "previous_year_2", label: translateCompareMode(language, "previous_year_2") }, { key: "previous_year_3", label: translateCompareMode(language, "previous_year_3") }, { key: "previous_month_12", label: translateCompareMode(language, "previous_month_12") }, { key: "previous_month_24", label: translateCompareMode(language, "previous_month_24") }, { key: "custom_multi", label: translateCompareMode(language, "custom_multi") }]; }
function applyArchivePreset(rangeKey: string, setStart: (value: string) => void, setEnd: (value: string) => void) {
if (rangeKey !== "custom") {
setStart("");
setEnd("");
}
}
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 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 SegmentedSelect({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return <div className="btn-list align-items-center flex-nowrap"><span className="text-secondary small me-2 d-none d-md-inline">{label}</span><div className="btn-group">{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 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, card.unit === "kWh" ? 2 : 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="list-group list-group-flush list-group-hoverable">{metrics.map((metric) => <div className="list-group-item" key={metric.metric_id}><div className="row align-items-center"><div className="col-auto text-primary">{iconForMetric(metric.metric_id)}</div><div className="col text-truncate"><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || t(language, "status")}</div></div><div className="col-auto fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div></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 StatusPanel({ 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, "systemStatus")}</h3></div><div className="card-body d-flex flex-column gap-3">{metrics.map((metric) => <div key={metric.metric_id} className="d-flex justify-content-between align-items-center border rounded-3 px-3 py-2 status-row"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || t(language, "status")}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div></div>; }
function StringsPanel({ 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.length === 0 ? <div className="col-12 text-secondary">{t(language, "noDataDescription")}</div> : rows.map((row) => <div className="col-12 col-md-6" key={row.id}><div className="border rounded-3 p-3 h-100 string-panel"><div className="d-flex align-items-center justify-content-between mb-3"><div className="fw-semibold">{row.label}</div><span className="badge bg-azure-lt text-azure">DC</span></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 small"><span className="text-secondary">{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 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 ComparisonPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "chartComparison")}</h3></div><div className="card-body"><EChart option={buildComparisonOption(data, theme, language)} className="pv-chart" /></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)} 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 StatusStat({ label, value }: { label: string; value: string }) { return <div className="col-6 col-md-4 col-xl-2"><div className="border rounded-3 px-3 py-2 h-100 bg-body-tertiary"><div className="text-secondary small mb-1">{label}</div><div className="fw-semibold">{value}</div></div></div>; }
function ImportControls({ status, language, onStart, onSyncNow, onCancel }: { status?: HistoricalStatus; language: Language; onStart: (payload: { start_date?: string; end_date?: string; chunk_days?: number; force?: boolean }) => void; onSyncNow: () => void; onCancel: () => void; }) { const [startDate, setStartDate] = useState(status?.available_start_date ?? ""); const [endDate, setEndDate] = useState(status?.available_end_date ?? ""); const [chunkDays, setChunkDays] = useState(String(status?.default_chunk_days ?? 7)); useEffect(() => { if (!status) return; setStartDate((current) => current || status.available_start_date || ""); setEndDate((current) => current || status.available_end_date || ""); setChunkDays((current) => current || String(status.default_chunk_days || 7)); }, [status]); return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{language === "en" ? "Import controls" : "Sterowanie importem"}</h3></div><div className="card-body d-flex flex-column gap-3"><div><label className="form-label">{t(language, "startDate")}</label><input className="form-control" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} /></div><div><label className="form-label">{t(language, "endDate")}</label><input className="form-control" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} /></div><div><label className="form-label">{t(language, "chunkDays")}</label><input className="form-control" type="number" min={1} max={31} value={chunkDays} onChange={(event) => setChunkDays(event.target.value)} /></div><div className="d-grid gap-2"><button className="btn btn-primary" onClick={() => onStart({ start_date: startDate || undefined, end_date: endDate || undefined, chunk_days: Number(chunkDays) || undefined, force: true })}><IconPlayerPlay size={18} className="me-1" />{t(language, "startImport")}</button><button className="btn btn-outline-secondary" onClick={onSyncNow}><IconRefresh size={18} className="me-1" />{t(language, "syncMissing")}</button><button className="btn btn-outline-danger" onClick={onCancel} disabled={!status?.running}><IconX size={18} className="me-1" />{t(language, "cancel")}</button></div></div></div>; }
function KioskLayoutPanel({ language, widgets, onChange, labels }: { language: Language; widgets: WidgetId[]; onChange: (value: WidgetId[]) => void; labels: Map<WidgetId, string>; }) { const available = widgetOrder.map((item) => item.id); const selected = widgets; const unselected = available.filter((item) => !selected.includes(item)); const move = (id: WidgetId, direction: -1 | 1) => { const index = selected.indexOf(id); if (index === -1) return; const target = index + direction; if (target < 0 || target >= selected.length) return; const next = [...selected]; [next[index], next[target]] = [next[target], next[index]]; onChange(next); }; const toggle = (id: WidgetId) => { if (selected.includes(id)) { const next = selected.filter((item) => item !== id); onChange(next.length ? next : selected); return; } onChange([...selected, id]); }; return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{t(language, "kioskLayout")}</h3><div className="text-secondary small">{t(language, "kioskLayoutSubtitle")}</div></div></div><div className="card-body"><div className="alert alert-info py-2">{t(language, "saveLayout")}</div><div className="row g-3"><div className="col-12 col-lg-7"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{t(language, "selected")}</div><div className="d-flex flex-column gap-2">{selected.map((id) => <div key={id} className="d-flex align-items-center justify-content-between gap-2 border rounded-3 px-3 py-2 bg-body-tertiary"><span>{labels.get(id)}</span><div className="btn-list"><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, -1)}>{t(language, "moveUp")}</button><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, 1)}>{t(language, "moveDown")}</button><button className="btn btn-sm btn-outline-danger" onClick={() => toggle(id)}><IconX size={16} /></button></div></div>)}</div></div></div><div className="col-12 col-lg-5"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{t(language, "available")}</div><div className="d-flex flex-wrap gap-2">{unselected.map((id) => <button key={id} className="btn btn-outline-primary" onClick={() => toggle(id)}>{labels.get(id)}</button>)}</div></div></div></div></div></div>; }
function KioskSettingsEditorPanel({ language, value, onChange, onSave, onReset, selectedMode, onModeChange, labels, buckets, compareModes, saving, dirty, canSave, saveNotice }: { language: Language; value: KioskSettingsPayload; onChange: (value: KioskSettingsPayload) => void; onSave: () => void; onReset: () => void; selectedMode: "public" | "private"; onModeChange: (value: "public" | "private") => void; labels: Map<WidgetId, string>; buckets: Array<{ key: string; label: string }>; compareModes: string[]; saving: boolean; dirty: boolean; canSave: boolean; saveNotice: string | null; }) { const widgets = toWidgetIds(value.widgets); return <div className="d-flex flex-column gap-3"><div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Kiosk settings" : "Ustawienia kiosku"}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="btn-group w-100"><button className={`btn ${selectedMode === "private" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => onModeChange("private")}>{language === "en" ? "Logged-in kiosk" : "Kiosk prywatny"}</button><button className={`btn ${selectedMode === "public" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => onModeChange("public")}>{language === "en" ? "Public kiosk" : "Kiosk publiczny"}</button></div><div className={`alert py-2 mb-0 ${dirty ? "alert-warning" : "alert-success"}`}>{dirty ? (language === "en" ? "You have local changes." : "Masz lokalne zmiany.") : (language === "en" ? "No unsaved changes." : "Brak niezapisanych zmian.")}{saveNotice ? <span className="d-block mt-1">{saveNotice}</span> : null}</div><div className="row g-3"><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Live chart range" : "Zakres wykresu live"}</label><select className="form-select" value={value.realtime_range} onChange={(e) => onChange({ ...value, realtime_range: e.target.value })}>{liveRangeOptions(language).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Analytics range" : "Zakres analityki"}</label><select className="form-select" value={value.analytics_range} onChange={(e) => onChange({ ...value, analytics_range: e.target.value })}>{analyticsRangeOptions(language).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">Bucket</label><select className="form-select" value={value.analytics_bucket} onChange={(e) => onChange({ ...value, analytics_bucket: e.target.value })}>{buckets.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Comparison" : "Porównanie"}</label><select className="form-select" value={value.compare_mode} onChange={(e) => onChange({ ...value, compare_mode: e.target.value })}><option value="none">{translateCompareMode(language, "none")}</option>{compareModes.filter((item) => item !== "none").map((item) => <option key={item} value={item}>{translateCompareMode(language, item)}</option>)}</select></div></div><div className="d-flex justify-content-end gap-2"><button className="btn btn-outline-secondary" onClick={onReset} disabled={saving || !dirty}>{language === "en" ? "Discard changes" : "Odrzuć zmiany"}</button><button className="btn btn-primary" onClick={onSave} disabled={saving || !dirty || !canSave}>{saving ? (language === "en" ? "Saving..." : "Zapisywanie...") : (language === "en" ? "Save kiosk settings" : "Zapisz ustawienia kiosku")}</button></div></div></div><KioskLayoutPanel language={language} widgets={widgets} onChange={(widgetsValue) => onChange({ ...value, widgets: widgetsValue })} labels={labels} /></div>; }
function KioskLinkPanel({ language, publicKioskUrl, privateKioskUrl, publicSettings, privateSettings }: { language: Language; publicKioskUrl: string; privateKioskUrl: string; publicSettings: KioskSettingsPayload; privateSettings: KioskSettingsPayload }) { 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="card pv-card h-100"><div className="card-header"><h3 className="card-title">{language === "en" ? "Kiosk links" : "Linki kiosku"}</h3></div><div className="card-body d-flex flex-column gap-4"><div className="d-flex flex-column gap-2"><div className="fw-semibold">{language === "en" ? "Public kiosk" : "Kiosk publiczny"}</div><div className="text-secondary small">{language === "en" ? "Read-only access without login." : "Podgląd bez logowania, tylko odczyt."}</div><input className="form-control" value={publicKioskUrl} readOnly /><div className="small text-secondary">{language === "en" ? "Ranges:" : "Zakresy:"} live {publicSettings.realtime_range}, analytics {publicSettings.analytics_range}</div><button className="btn btn-primary" onClick={() => copy(publicKioskUrl, "public")}>{copied === "public" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy public link" : "Kopiuj link publiczny")}</button></div><div className="d-flex flex-column gap-2 border-top pt-3"><div className="fw-semibold">{language === "en" ? "Private kiosk" : "Kiosk prywatny"}</div><div className="text-secondary small">{language === "en" ? "Requires login and uses private kiosk settings." : "Wymaga logowania i używa prywatnych ustawień kiosku."}</div><input className="form-control" value={privateKioskUrl} readOnly /><div className="small text-secondary">{language === "en" ? "Ranges:" : "Zakresy:"} live {privateSettings.realtime_range}, analytics {privateSettings.analytics_range}</div><button className="btn btn-outline-primary" onClick={() => copy(privateKioskUrl, "private")}>{copied === "private" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy private link" : "Kopiuj link prywatny")}</button></div></div></div>; }
function AppearanceSecurityPanel({ language, theme, setTheme, viewMode, setViewMode, authEnabled, userName }: { language: Language; theme: ThemeMode; setTheme: (value: ThemeMode) => void; viewMode: ViewMode; setViewMode: (value: ViewMode) => void; authEnabled: boolean; userName: string; }) { return <div className="d-flex flex-column gap-3"><div className="card pv-card"><div className="card-header"><h3 className="card-title">{t(language, "theme")}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="btn-group w-100"><button className={`btn ${theme === "light" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setTheme("light")}>{t(language, "light")}</button><button className={`btn ${theme === "dark" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setTheme("dark")}>{t(language, "dark")}</button></div></div></div><div className="card pv-card"><div className="card-header"><h3 className="card-title">{t(language, "viewMode")}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="btn-group w-100"><button className={`btn ${viewMode === "normal" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setViewMode("normal")}>{t(language, "normalMode")}</button><button className={`btn ${viewMode === "kiosk" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setViewMode("kiosk")}>{t(language, "kioskMode")}</button></div></div></div><div className="card pv-card"><div className="card-header"><h3 className="card-title">{t(language, "security")}</h3></div><div className="card-body d-flex flex-column gap-2"><div className="d-flex align-items-center gap-2 fw-medium"><IconLock size={18} /> {authEnabled ? t(language, "authEnabled") : t(language, "authDisabled")}</div><div className="text-secondary small">{language === "en" ? "Admin user management is available below." : "Zarządzanie użytkownikami admina jest dostępne niżej."}</div>{userName ? <div className="badge bg-primary-lt text-primary align-self-start">{userName}</div> : null}</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"><h3 className="card-title">{title}</h3></div><div className="card-body d-flex flex-column gap-2">{items.map((item) => <label key={item.metric_id} className="form-check"><input className="form-check-input" type="checkbox" checked={selected.includes(item.metric_id)} onChange={() => toggle(item.metric_id)} /><span className="form-check-label">{item.label} <span className="text-secondary small">{item.unit}</span></span></label>)}</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 wykresu live"} items={items} selected={selected} onChange={onChange} />; }
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] }); }; return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{language === "en" ? "Metric visibility in blocks" : "Widoczność metryk w blokach"}</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">Hero metrics</div>{items.map((item) => <label key={`hero-${item.metric_id}`} className="form-check d-block mb-2"><input className="form-check-input" type="checkbox" checked={config.hero.includes(item.metric_id)} onChange={() => toggle("hero", item.metric_id)} /><span className="form-check-label">{item.label}</span></label>)}</div></div><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">Quick metrics</div>{items.map((item) => <label key={`quick-${item.metric_id}`} className="form-check d-block mb-2"><input className="form-check-input" type="checkbox" checked={config.quick.includes(item.metric_id)} onChange={() => toggle("quick", item.metric_id)} /><span className="form-check-label">{item.label}</span></label>)}</div></div></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>; }

433
frontend/src/App.tsx.bak Normal file
View File

@@ -0,0 +1,433 @@
import { useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { EChartsOption } from "echarts";
import {
IconArrowsMove,
IconBolt,
IconChartBar,
IconChecklist,
IconClockHour4,
IconDatabaseImport,
IconDeviceDesktop,
IconHistory,
IconLanguage,
IconLayoutDashboard,
IconLock,
IconLogin2,
IconLogout,
IconMoon,
IconPlayerPlay,
IconRefresh,
IconSettings,
IconSun,
IconTemperature,
IconX,
} from "./components/common/Icons";
import { api } from "./api/client";
import { EChart } from "./components/common/EChart";
import { labelForMetric, localeForLanguage, normalizeLanguage, t, translateCompareMode, type Language } from "./i18n";
import { useAnalytics, useDashboardConfig, useHistoricalImport, useRealtimeHistory, useRealtimeSocket } from "./hooks";
import { formatDateTime, formatDurationShort, formatPercent, formatShortTime, formatValue } from "./lib/format";
import type {
AnalyticsPayload,
AuthStatus,
AuthUsersPayload,
BucketPoint,
DashboardConfig,
DistributionPayload,
HistoryPayload,
HistoricalStatus,
KioskSettingsPayload,
MetricValue,
SnapshotGroupRow,
SnapshotPayload,
} from "./types";
type ThemeMode = "light" | "dark";
type TabKey = "realtime" | "archive" | "analytics" | "warehouse" | "kiosk" | "settings";
type ViewMode = "normal" | "kiosk";
type WidgetId = "hero" | "quickMetrics" | "history" | "status" | "strings" | "production" | "comparison" | "distribution" | "importStatus";
type BlockTarget = "hero" | "quick";
const STORAGE_KEYS = {
theme: "pv-theme-v4",
language: "pv-language-v4",
kioskWidgets: "pv-kiosk-widgets-v4",
viewMode: "pv-view-mode-v4",
blockConfig: "pv-block-config-v4",
liveMetrics: "pv-live-metrics-v4",
archiveMetrics: "pv-archive-metrics-v4",
};
const DEFAULT_KIOSK_WIDGETS: WidgetId[] = ["hero", "history", "strings", "status", "production", "comparison", "importStatus"];
const DEFAULT_BLOCK_CONFIG: Record<BlockTarget, string[]> = {
hero: ["ac_power", "dc_power_total", "energy_today", "energy_total"],
quick: ["energy_today", "energy_yesterday", "energy_total", "dc_power_total", "today_vs_yesterday"],
};
const DEFAULT_LIVE_METRICS = ["ac_power", "string_1_power", "string_2_power"];
const PUBLIC_KIOSK = new URL(window.location.href).searchParams.get("publicKiosk") === "1";
const widgetOrder: Array<{ id: WidgetId; tab: TabKey; icon: typeof IconLayoutDashboard }> = [
{ id: "hero", tab: "realtime", icon: IconLayoutDashboard },
{ id: "quickMetrics", tab: "realtime", icon: IconChecklist },
{ id: "history", tab: "realtime", icon: IconHistory },
{ id: "status", tab: "realtime", icon: IconBolt },
{ id: "strings", tab: "realtime", icon: IconArrowsMove },
{ id: "production", tab: "analytics", icon: IconChartBar },
{ id: "comparison", tab: "analytics", icon: IconRefresh },
{ id: "distribution", tab: "analytics", icon: IconChartBar },
{ id: "importStatus", tab: "warehouse", icon: IconDatabaseImport },
];
function readStorage<T>(key: string, fallback: T, parser?: (raw: string) => T): T {
try {
const raw = window.localStorage.getItem(key);
if (!raw) return fallback;
return parser ? parser(raw) : (JSON.parse(raw) as T);
} catch {
return fallback;
}
}
function writeStorage<T>(key: string, value: T): void {
try { window.localStorage.setItem(key, typeof value === "string" ? value : JSON.stringify(value)); } catch {}
}
function parseViewModeFromLocation(): ViewMode {
const url = new URL(window.location.href);
return url.searchParams.get("mode") === "kiosk" ? "kiosk" : "normal";
}
function syncViewModeToLocation(mode: ViewMode): void {
const url = new URL(window.location.href);
if (mode === "kiosk") url.searchParams.set("mode", "kiosk"); else url.searchParams.delete("mode");
window.history.replaceState({}, "", url.toString());
}
function iconForMetric(metricId: string) {
if (metricId.includes("temp")) return <IconTemperature size={18} />;
if (metricId.includes("energy")) return <IconChartBar size={18} />;
return <IconBolt size={18} />;
}
function buildWidgetLabel(language: Language, widgetId: WidgetId): string {
const labels: Record<WidgetId, string> = {
hero: language === "en" ? "Hero metrics" : "Karty hero",
quickMetrics: t(language, "quickMetrics"),
history: t(language, "chartPowerHistory"),
status: t(language, "systemStatus"),
strings: t(language, "strings"),
production: t(language, "chartProduction"),
comparison: t(language, "chartComparison"),
distribution: t(language, "chartDistribution"),
importStatus: language === "en" ? "Data warehouse" : "Hurtownia danych",
};
return labels[widgetId];
}
function buildTablerChartTheme(theme: ThemeMode) {
return theme === "dark"
? { text: "#cbd5e1", grid: "rgba(255,255,255,0.08)", tooltip: "rgba(15, 23, 42, 0.96)", series: ["#4dabf7", "#20c997", "#f59f00", "#e64980", "#9775fa", "#ff922b", "#66d9e8", "#adb5bd", "#94d82d", "#ffa8a8"] }
: { text: "#334155", grid: "rgba(15,23,42,0.12)", tooltip: "rgba(255,255,255,0.98)", series: ["#206bc4", "#2fb344", "#f59f00", "#d63384", "#7950f2", "#fd7e14", "#1098ad", "#868e96", "#74b816", "#fa5252"] };
}
function buildLiveHistoryOption(history: HistoryPayload | undefined, theme: ThemeMode, language: Language): EChartsOption {
const palette = buildTablerChartTheme(theme);
const series = history?.series ?? [];
return {
color: palette.series,
tooltip: { trigger: "axis", backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text } },
legend: { top: 0, textStyle: { color: palette.text }, itemGap: 16 },
grid: { left: 12, right: 16, top: 48, bottom: 12, containLabel: true },
xAxis: { type: "category", boundaryGap: false, axisLabel: { color: palette.text }, axisLine: { lineStyle: { color: palette.grid } }, data: (series[0]?.points ?? []).map((point: any) => formatShortTime(point.timestamp, localeForLanguage(language))) },
yAxis: { type: "value", axisLabel: { color: palette.text }, splitLine: { lineStyle: { color: palette.grid } } },
series: series.map((item, index) => ({ name: item.label, type: "line", smooth: true, connectNulls: true, showSymbol: false, lineStyle: { width: index === 0 ? 3 : 2 }, emphasis: { focus: "series" }, data: item.points.map((point: any) => point.value) })),
};
}
function buildBarOption(points: BucketPoint[], unit: string, theme: ThemeMode, language: Language): EChartsOption {
const palette = buildTablerChartTheme(theme);
return {
color: [palette.series[0]],
tooltip: { trigger: "axis", backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text }, valueFormatter: (value) => formatValue(Number(value), unit, 2, localeForLanguage(language)) },
grid: { left: 12, right: 16, top: 16, bottom: 40, containLabel: true },
xAxis: { type: "category", axisLabel: { color: palette.text, rotate: points.length > 12 ? 32 : 0 }, axisLine: { lineStyle: { color: palette.grid } }, data: points.map((point) => point.label) },
yAxis: { type: "value", name: unit, nameTextStyle: { color: palette.text }, axisLabel: { color: palette.text }, splitLine: { lineStyle: { color: palette.grid } } },
series: [{ type: "bar", barMaxWidth: 24, itemStyle: { borderRadius: [6, 6, 0, 0] }, data: points.map((point) => point.value) }],
};
}
function buildComparisonOption(data: AnalyticsPayload | undefined, theme: ThemeMode, language: Language): EChartsOption {
const palette = buildTablerChartTheme(theme);
const current = data?.current ?? [];
const comparisonSeries = (data?.comparisons?.length ? data.comparisons : [{ key: data?.compare_mode ?? "comparison", label: t(language, "comparisonPeriod"), points: data?.comparison ?? [] }]).filter((item) => item.points?.length).map((item) => ({ ...item, label: translateCompareMode(language, item.label || item.key) }));
return {
color: palette.series,
tooltip: { trigger: "axis", axisPointer: { type: "shadow" }, backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text } },
legend: { top: 0, textStyle: { color: palette.text } },
grid: { left: 16, right: 20, top: 42, bottom: 18, containLabel: true },
xAxis: { type: "category", axisLabel: { color: palette.text, interval: 0, rotate: current.length > 12 ? 35 : 0 }, axisLine: { lineStyle: { color: palette.grid } }, data: current.map((point) => point.label) },
yAxis: { type: "value", axisLabel: { color: palette.text }, splitLine: { lineStyle: { color: palette.grid } } },
series: [
{ name: t(language, "currentPeriod"), type: "bar", barMaxWidth: 18, data: current.map((point) => point.value) },
...comparisonSeries.map((seriesItem, index) => ({ name: seriesItem.label, type: "bar" as const, barMaxWidth: 18, data: current.map((_, pointIndex) => seriesItem.points[pointIndex]?.value ?? 0), itemStyle: { opacity: index === 0 ? 0.9 : 0.75 } })),
],
};
}
function buildPieOption(data: DistributionPayload | undefined, theme: ThemeMode): EChartsOption {
const palette = buildTablerChartTheme(theme);
const slices = [...(data?.slices ?? [])].sort((a, b) => b.value - a.value).slice(0, 12);
return {
color: palette.series,
tooltip: { trigger: "axis", axisPointer: { type: "shadow" }, backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text } },
grid: { left: 16, right: 28, top: 8, bottom: 8, containLabel: true },
xAxis: { type: "value", axisLabel: { color: palette.text }, splitLine: { lineStyle: { color: palette.grid } } },
yAxis: { type: "category", axisLabel: { color: palette.text }, data: slices.map((item) => item.label) },
series: [{ type: "bar", data: slices.map((item) => ({ value: item.value, label: { show: true, position: "right", formatter: `${item.share}%`, color: palette.text } })), barMaxWidth: 22, itemStyle: { borderRadius: [0, 6, 6, 0] } }],
};
}
function liveRangeOptions(language: Language) {
return [
{ key: "today", label: language === "en" ? "Today" : "Dziś" },
{ key: "yesterday", label: language === "en" ? "Yesterday" : "Wczoraj" },
{ key: "6h", label: "6h" },
{ key: "12h", label: "12h" },
{ key: "24h", label: "24h" },
{ key: "48h", label: "48h" },
{ key: "7d", label: "7d" },
];
}
function analyticsRangeOptions(language: Language) {
return [
{ key: "today", label: language === "en" ? "Today" : "Dziś" },
{ key: "yesterday", label: language === "en" ? "Yesterday" : "Wczoraj" },
{ key: "7d", label: "7d" },
{ key: "30d", label: "30d" },
{ key: "90d", label: "90d" },
{ key: "365d", label: language === "en" ? "365 days" : "365 dni" },
];
}
function archiveRangeOptions(language: Language) {
return [
{ key: "1d", label: language === "en" ? "1 day" : "1 dzień" },
{ key: "3d", label: "3d" },
{ key: "7d", label: "7d" },
{ key: "14d", label: "14d" },
{ key: "30d", label: "30d" },
{ key: "60d", label: "60d" },
{ key: "custom", label: language === "en" ? "Custom" : "Ręczny" },
];
}
function getInitialTheme(config?: DashboardConfig): ThemeMode {
return readStorage<ThemeMode>(STORAGE_KEYS.theme, (config?.defaults.theme as ThemeMode) ?? "dark", (raw) => (raw === "light" ? "light" : "dark"));
}
function getInitialLanguage(config?: DashboardConfig): Language {
return normalizeLanguage(readStorage<string>(STORAGE_KEYS.language, config?.defaults.language ?? "pl", (raw) => raw));
}
function getVisibleWidgets(ids: WidgetId[]): WidgetId[] { const base = ids.filter((id, index) => ids.indexOf(id) === index); return base.length > 0 ? base : DEFAULT_KIOSK_WIDGETS; }
function toWidgetIds(ids: string[]): WidgetId[] { return getVisibleWidgets(ids.filter((id): id is WidgetId => widgetOrder.some((item) => item.id === id as WidgetId))); }
function getMetricCandidates(snapshot: SnapshotPayload, config?: DashboardConfig) {
const fromConfig = (config?.visible_entities ?? [])
.filter((item) => item.kind === "gauge")
.map((item) => ({ metric_id: item.metric_id, label: item.label, unit: item.unit }));
const map = new Map<string, { metric_id: string; label: string; unit: string }>();
fromConfig.forEach((item) => map.set(item.metric_id, item));
return [...map.values()];
}
export default function App() {
const queryClient = useQueryClient();
const publicMode = PUBLIC_KIOSK;
const authQuery = useQuery<AuthStatus>({ queryKey: ["auth-status", publicMode], queryFn: api.getAuthStatus, staleTime: 20_000, retry: false, enabled: !publicMode });
const authEnabled = publicMode ? false : (authQuery.data?.enabled ?? true);
const authenticated = publicMode ? true : (authQuery.data ? (!authEnabled || authQuery.data.authenticated) : false);
const configQuery = useDashboardConfig(authenticated || authEnabled === false);
const config = configQuery.data;
const privateKioskSettingsQuery = useQuery<KioskSettingsPayload>({ queryKey: ["kiosk-settings", "private"], queryFn: () => api.getKioskSettings("private"), enabled: authenticated || authEnabled === false, staleTime: 30_000 });
const publicKioskSettingsQuery = useQuery<KioskSettingsPayload>({ queryKey: ["kiosk-settings", "public"], queryFn: () => api.getKioskSettings("public"), enabled: authenticated || authEnabled === false || publicMode, staleTime: 30_000 });
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme(undefined));
const [language, setLanguage] = useState<Language>(() => getInitialLanguage(undefined));
const [activeTab, setActiveTab] = useState<TabKey>(publicMode ? "kiosk" : "realtime");
const [realtimeRange, setRealtimeRange] = useState("6h");
const [analyticsRange, setAnalyticsRange] = useState("30d");
const [bucket, setBucket] = useState("day");
const [compare, setCompare] = useState("previous_year");
const [analyticsStart, setAnalyticsStart] = 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 [archiveStart, setArchiveStart] = useState("");
const [archiveEnd, setArchiveEnd] = useState("");
const [archiveRange, setArchiveRange] = useState("1d");
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 [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 [kioskEditorMode, setKioskEditorMode] = useState<"private" | "public">("private");
const [privateKioskDraft, setPrivateKioskDraft] = useState<KioskSettingsPayload>({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" });
const [publicKioskDraft, setPublicKioskDraft] = useState<KioskSettingsPayload>({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" });
const [blockConfig, setBlockConfig] = useState<Record<BlockTarget, string[]>>(() => readStorage(STORAGE_KEYS.blockConfig, DEFAULT_BLOCK_CONFIG));
const [loginForm, setLoginForm] = useState({ username: "", password: "" });
const [loginError, setLoginError] = useState<string | null>(null);
const [newUser, setNewUser] = useState({ username: "", display_name: "", password: "", role: "user" });
const [passwordReset, setPasswordReset] = useState<{ username: string; password: string }>({ username: "", password: "" });
const initializedRef = useRef(false);
useEffect(() => {
if (!config || initializedRef.current) return;
initializedRef.current = true;
setActiveTab((config.defaults.tab as TabKey) || (publicMode ? "kiosk" : "realtime"));
setRealtimeRange(config.defaults.realtime_range);
setAnalyticsRange(config.defaults.analytics_range);
setBucket(config.defaults.analytics_bucket);
setTheme((current) => current || ((config.defaults.theme as ThemeMode) ?? "dark"));
setLanguage((current) => current || normalizeLanguage(config.defaults.language));
}, [config, publicMode]);
useEffect(() => { if (privateKioskSettingsQuery.data) setPrivateKioskDraft(privateKioskSettingsQuery.data); }, [privateKioskSettingsQuery.data]);
useEffect(() => { if (publicKioskSettingsQuery.data) setPublicKioskDraft(publicKioskSettingsQuery.data); }, [publicKioskSettingsQuery.data]);
useEffect(() => { document.documentElement.setAttribute("data-bs-theme", theme); document.body.setAttribute("data-bs-theme", theme); writeStorage(STORAGE_KEYS.theme, theme); }, [theme]);
useEffect(() => { writeStorage(STORAGE_KEYS.language, language); }, [language]);
useEffect(() => { syncViewModeToLocation(viewMode); writeStorage(STORAGE_KEYS.viewMode, viewMode); }, [viewMode]);
useEffect(() => { writeStorage(STORAGE_KEYS.kioskWidgets, kioskWidgets); }, [kioskWidgets]);
useEffect(() => { writeStorage(STORAGE_KEYS.blockConfig, blockConfig); }, [blockConfig]);
useEffect(() => { writeStorage(STORAGE_KEYS.liveMetrics, liveMetrics); }, [liveMetrics]);
useEffect(() => { writeStorage(STORAGE_KEYS.archiveMetrics, archiveMetrics); }, [archiveMetrics]);
const dataEnabled = authenticated || authEnabled === false;
const { snapshot, connected, lastUpdated } = useRealtimeSocket(dataEnabled);
const metricCandidates = useMemo(() => getMetricCandidates(snapshot, config), [snapshot, config]);
useEffect(() => {
if (!metricCandidates.length) return;
const allowed = new Set(metricCandidates.map((item) => item.metric_id));
setLiveMetrics((current) => {
const filtered = current.filter((item) => allowed.has(item));
return filtered.length ? filtered : metricCandidates.slice(0, 3).map((item) => item.metric_id);
});
setArchiveMetrics((current) => {
const filtered = current.filter((item) => allowed.has(item));
return filtered.length ? filtered : metricCandidates.slice(0, 3).map((item) => item.metric_id);
});
}, [metricCandidates]);
const liveHistoryMetrics = useMemo(() => liveMetrics.filter((item) => item !== "inverter_temp"), [liveMetrics]);
const effectiveKioskSettings = publicMode ? publicKioskDraft : privateKioskDraft;
const kioskActive = publicMode || viewMode === "kiosk";
const effectiveKioskWidgets = toWidgetIds(kioskActive ? effectiveKioskSettings.widgets : kioskWidgets);
const effectiveRealtimeRange = kioskActive ? effectiveKioskSettings.realtime_range : realtimeRange;
const effectiveAnalyticsRange = kioskActive ? effectiveKioskSettings.analytics_range : analyticsRange;
const effectiveBucket = kioskActive ? effectiveKioskSettings.analytics_bucket : bucket;
const effectiveCompare = kioskActive ? effectiveKioskSettings.compare_mode : compare;
const historyQuery = useRealtimeHistory(effectiveRealtimeRange, dataEnabled, { metrics: liveHistoryMetrics, publicKiosk: publicMode });
const sanitizedCompareRanges = compareRanges.filter((item) => item.start && item.end);
const analyticsOptions = analyticsStart && analyticsEnd && !kioskActive ? { start: analyticsStart, end: analyticsEnd, publicKiosk: publicMode, compareRanges: effectiveCompare === "custom_multi" ? sanitizedCompareRanges : undefined } : { publicKiosk: publicMode, compareRanges: effectiveCompare === "custom_multi" ? sanitizedCompareRanges : undefined };
const analyticsQuery = useAnalytics(analyticsStart && analyticsEnd && !kioskActive ? "custom" : effectiveAnalyticsRange, effectiveBucket, effectiveCompare, dataEnabled, analyticsOptions);
const historical = useHistoricalImport(dataEnabled && !publicMode);
const archiveQuery = useRealtimeHistory(archiveStart && archiveEnd ? "custom" : archiveRange, dataEnabled, { start: archiveStart || undefined, end: archiveEnd || undefined, metrics: archiveMetrics, publicKiosk: publicMode });
const usersQuery = useQuery<AuthUsersPayload>({ queryKey: ["auth-users"], queryFn: api.getUsers, enabled: dataEnabled && (authQuery.data?.role === "admin"), staleTime: 15_000 });
const loginMutation = useMutation({ mutationFn: () => api.login(loginForm.username, loginForm.password), onSuccess: async () => { setLoginError(null); setLoginForm((value) => ({ ...value, password: "" })); await queryClient.invalidateQueries({ queryKey: ["auth-status"] }); await queryClient.invalidateQueries({ queryKey: ["dashboard-config"] }); }, onError: (error: Error) => setLoginError(parseError(error) || t(language, "loginError")) });
const logoutMutation = useMutation({ mutationFn: api.logout, onSuccess: async () => { await queryClient.clear(); await queryClient.invalidateQueries({ queryKey: ["auth-status"] }); } });
const createUserMutation = useMutation({ mutationFn: () => api.createUser(newUser), onSuccess: async () => { setNewUser({ username: "", display_name: "", password: "", role: "user" }); await usersQuery.refetch(); } });
const resetPasswordMutation = useMutation({ mutationFn: () => api.resetUserPassword(passwordReset.username, passwordReset.password), onSuccess: async () => { setPasswordReset({ username: "", password: "" }); await usersQuery.refetch(); } });
const saveKioskSettingsMutation = useMutation({ mutationFn: (payload: KioskSettingsPayload) => api.saveKioskSettings(payload), onSuccess: async (_, payload) => { await queryClient.invalidateQueries({ queryKey: ["kiosk-settings", payload.mode] }); } });
const locale = localeForLanguage(language);
const widgetLabels = useMemo(() => { const map = new Map<WidgetId, string>(); for (const item of widgetOrder) map.set(item.id, buildWidgetLabel(language, item.id)); return map; }, [language]);
const summary = analyticsQuery.production.data?.summary;
const topStatus = snapshot.status ?? [];
const heroCards = snapshot.hero_cards.filter((card) => blockConfig.hero.includes(card.metric_id));
const quickMetrics = Object.values(snapshot.kpis ?? {}).filter((metric) => blockConfig.quick.includes(metric.metric_id));
const isAdmin = authQuery.data?.role === "admin";
const kioskUrl = `${window.location.origin}${window.location.pathname}?mode=kiosk&publicKiosk=1`;
const allWidgets: Record<WidgetId, ReactElement | null> = {
hero: <HeroCards cards={heroCards} locale={locale} language={language} />,
quickMetrics: <QuickMetrics metrics={quickMetrics} locale={locale} language={language} />,
history: <LiveHistoryPanel data={historyQuery.data} language={language} theme={theme} title={t(language, "chartPowerHistory")} subtitle={t(language, "realtimeSubtitle")} />,
status: <StatusPanel metrics={topStatus} locale={locale} language={language} />,
strings: <StringsPanel rows={snapshot.strings} locale={locale} language={language} />,
production: <ProductionPanel data={analyticsQuery.production.data} language={language} theme={theme} />,
comparison: compare !== "none" ? <ComparisonPanel data={analyticsQuery.production.data} language={language} theme={theme} /> : null,
distribution: <DistributionPanel data={analyticsQuery.distribution.data} language={language} theme={theme} locale={locale} />,
importStatus: <HistoricalPanel status={historical.status.data} language={language} locale={locale} compact />,
};
const renderWidget = (widgetId: WidgetId) => { const content = allWidgets[widgetId]; if (!content) return null; return <div key={widgetId} className={widgetId === "hero" ? "col-12" : widgetId === "history" ? "col-12 col-xxl-8" : "col-12 col-xxl-4"}>{content}</div>; };
if ((!publicMode && authQuery.isLoading) || (authEnabled && !authenticated && loginMutation.isPending)) return <LoadingScreen language={language} />;
if (authEnabled && !authenticated) return <LoginPage language={language} theme={theme} form={loginForm} onChange={setLoginForm} onSubmit={() => loginMutation.mutate()} onThemeToggle={() => setTheme((current) => (current === "dark" ? "light" : "dark"))} onLanguageToggle={() => setLanguage((current) => (current === "pl" ? "en" : "pl"))} loading={loginMutation.isPending} error={loginError} />;
if (configQuery.isLoading || !config) return <LoadingScreen language={language} />;
const navbar = (
<header className="navbar navbar-expand-md d-print-none pv-navbar">
<div className="container-xl">
<div className="navbar-brand navbar-brand-autodark d-flex align-items-center gap-2"><span className="avatar avatar-sm bg-primary-lt text-primary border-0"><IconBolt size={18} /></span><div><div className="fw-bold">{config.app.site_name}</div><div className="text-secondary small">{t(language, "operatorPanel")}</div></div></div>
<div className="navbar-nav flex-row order-md-last align-items-center gap-2">
<span className={`badge ${connected ? "bg-green-lt text-green" : "bg-yellow-lt text-yellow"}`}>{connected ? t(language, "connected") : t(language, "disconnected")}</span>
<button className="btn btn-icon btn-ghost-secondary" onClick={() => setTheme((current) => (current === "dark" ? "light" : "dark"))} title={t(language, "theme")}>{theme === "dark" ? <IconSun size={18} /> : <IconMoon size={18} />}</button>
<button className="btn btn-icon btn-ghost-secondary" onClick={() => setLanguage((current) => (current === "pl" ? "en" : "pl"))} title={t(language, "language")}><IconLanguage size={18} /></button>
{!publicMode ? <button className="btn btn-outline-primary" onClick={() => setViewMode((current) => (current === "normal" ? "kiosk" : "normal"))}><IconDeviceDesktop size={18} className="me-1" />{viewMode === "normal" ? t(language, "openKiosk") : t(language, "exitKiosk")}</button> : null}
{!publicMode ? <button className="btn btn-outline-secondary" onClick={() => logoutMutation.mutate()}><IconLogout size={18} className="me-1" />{t(language, "signOut")}</button> : null}
</div>
</div>
</header>
);
const menu = (
<div className="navbar-expand-md pv-subnav border-bottom"><div className="container-xl"><div className="navbar-collapse"><ul className="navbar-nav">
<NavItem icon={<IconLayoutDashboard size={18} />} active={activeTab === "realtime"} onClick={() => setActiveTab("realtime")} label={language === "en" ? "Live" : "Live"} />
<NavItem icon={<IconHistory size={18} />} active={activeTab === "archive"} onClick={() => setActiveTab("archive")} label={language === "en" ? "Historical live" : "Dane chwilowe"} />
<NavItem icon={<IconChartBar size={18} />} active={activeTab === "analytics"} onClick={() => setActiveTab("analytics")} label={t(language, "analytics")} />
<NavItem icon={<IconDatabaseImport size={18} />} active={activeTab === "warehouse"} onClick={() => setActiveTab("warehouse")} label={language === "en" ? "Data warehouse" : "Hurtownia danych"} />
<NavItem icon={<IconDeviceDesktop size={18} />} active={activeTab === "kiosk"} onClick={() => setActiveTab("kiosk")} label={t(language, "kiosk")} />
<NavItem icon={<IconSettings size={18} />} active={activeTab === "settings"} onClick={() => setActiveTab("settings")} label={t(language, "settings")} />
</ul><div className="ms-auto text-secondary small d-none d-md-flex align-items-center gap-2"><IconClockHour4 size={16} />{t(language, "updatedAt")}: {formatDateTime(lastUpdated, locale)}</div></div></div></div>
);
if (viewMode === "kiosk" || publicMode) {
return <div className="page kiosk-shell"><div className="container-fluid py-3 px-3 px-xl-4"><div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3"><div><div className="h2 mb-0">{config.app.site_name}</div><div className="text-secondary">{t(language, "kioskHint")}</div></div><div className="d-flex gap-2"><button className="btn btn-primary" onClick={() => requestFullscreen()}><IconDeviceDesktop size={18} className="me-1" />{t(language, "fullscreen")}</button>{!publicMode ? <button className="btn btn-outline-secondary" onClick={() => setViewMode("normal")}><IconX size={18} className="me-1" />{t(language, "exitKiosk")}</button> : null}</div></div><div className="row row-cards g-3">{effectiveKioskWidgets.map((widgetId) => renderWidget(widgetId))}</div></div></div>;
}
return (
<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">{renderWidget("hero")}<div className="col-12 col-xl-4">{allWidgets.quickMetrics}</div><div className="col-12 col-xl-8">{allWidgets.history}</div><div className="col-12 col-xl-4">{allWidgets.status}</div><div className="col-12 col-xl-8">{allWidgets.strings}</div></div></>}
{activeTab === "archive" && <><PageHeader title={language === "en" ? "Historical live data" : "Dane chwilowe z historii"} subtitle={language === "en" ? "Browse all instant metrics for any past period." : "Podgląd metryk chwilowych dla dowolnego okresu, nie tylko live."}><div className="d-flex flex-wrap gap-2 align-items-end"><SegmentedSelect label={language === "en" ? "Range" : "Zakres"} value={archiveStart && archiveEnd ? "custom" : archiveRange} onChange={(value) => { setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={archiveRangeOptions(language)} /><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={metricCandidates.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={archiveQuery.data} language={language} theme={theme} title={language === "en" ? "Historical chart" : "Wykres historyczny"} subtitle={language === "en" ? "Raw instant metrics from InfluxDB only." : "Tylko surowe metryki chwilowe z InfluxDB."} /></div></div></>}
{activeTab === "analytics" && <><PageHeader title={t(language, "analyticsOverview")} subtitle={t(language, "analyticsSubtitle")}><div className="d-flex flex-wrap gap-2 align-items-end"><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"].includes(item.key)).map((item) => ({ key: item.key, label: 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) }))} /><SegmentedSelect 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 col-xxl-8">{allWidgets.production}</div><div className="col-12 col-xxl-4">{allWidgets.distribution}</div>{compare !== "none" ? <div className="col-12">{allWidgets.comparison}</div> : null}</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" ? "Kiosk layout and public access." : "Układ kiosku i dostęp publiczny."} /><div className="row row-cards g-3"><div className="col-12 col-xxl-8"><KioskSettingsEditorPanel language={language} value={kioskEditorMode === "public" ? publicKioskDraft : privateKioskDraft} onChange={(value) => kioskEditorMode === "public" ? setPublicKioskDraft(value) : setPrivateKioskDraft(value)} onModeChange={setKioskEditorMode} selectedMode={kioskEditorMode} labels={widgetLabels} buckets={config.capabilities.buckets} compareModes={config.capabilities.comparison_modes} saving={saveKioskSettingsMutation.isPending && saveKioskSettingsMutation.variables?.mode === kioskEditorMode} onSave={() => saveKioskSettingsMutation.mutate(kioskEditorMode === "public" ? publicKioskDraft : privateKioskDraft)} /></div><div className="col-12 col-xxl-4"><KioskLinkPanel language={language} kioskUrl={kioskUrl} settings={publicKioskDraft} /></div></div></>}
{activeTab === "settings" && <><PageHeader title={t(language, "settings")} subtitle={language === "en" ? "Appearance, metric blocks and admin users." : "Wygląd, bloki metryk i użytkownicy."} /><div className="row row-cards g-3"><div className="col-12 col-xxl-4"><AppearanceSecurityPanel language={language} theme={theme} setTheme={setTheme} viewMode={viewMode} setViewMode={setViewMode} authEnabled={config.auth?.enabled ?? false} userName={authQuery.data?.display_name ?? authQuery.data?.user ?? ""} /></div><div className="col-12 col-xxl-8"><div className="row g-3"><div className="col-12"><LiveChartMetricsPanel language={language} items={metricCandidates.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={metricCandidates} config={blockConfig} onChange={setBlockConfig} /></div></div></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></>}
</div></div></div></div>
);
}
function parseError(error: Error): string | null { const match = error.message.match(/"detail"\s*:\s*"([^"]+)"/); return match?.[1] ?? error.message; }
function requestFullscreen() { const element = document.documentElement as HTMLElement & { webkitRequestFullscreen?: () => Promise<void> | void; msRequestFullscreen?: () => Promise<void> | void; }; if (element.requestFullscreen) { void element.requestFullscreen(); return; } if (element.webkitRequestFullscreen) { void element.webkitRequestFullscreen(); return; } if (element.msRequestFullscreen) void element.msRequestFullscreen(); }
function translateBucket(language: Language, key: string): string { const map: Record<string, { pl: string; en: string }> = { day: { pl: "Dzień", en: "Day" }, week: { pl: "Tydzień", en: "Week" }, month: { pl: "Miesiąc", en: "Month" }, year: { pl: "Rok", en: "Year" } }; return map[key]?.[language] ?? key; }
function comparisonOptions(language: Language) { return [{ key: "none", label: translateCompareMode(language, "none") }, { key: "previous_period", label: translateCompareMode(language, "previous_period") }, { key: "previous_year", label: translateCompareMode(language, "previous_year") }, { key: "previous_year_2", label: translateCompareMode(language, "previous_year_2") }, { key: "previous_year_3", label: translateCompareMode(language, "previous_year_3") }, { key: "previous_month_12", label: translateCompareMode(language, "previous_month_12") }, { key: "previous_month_24", label: translateCompareMode(language, "previous_month_24") }, { key: "custom_multi", label: translateCompareMode(language, "custom_multi") }]; }
function applyArchivePreset(rangeKey: string, setStart: (value: string) => void, setEnd: (value: string) => void) {
if (rangeKey !== "custom") {
setStart("");
setEnd("");
}
}
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 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 SegmentedSelect({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return <div className="btn-list align-items-center flex-nowrap"><span className="text-secondary small me-2 d-none d-md-inline">{label}</span><div className="btn-group">{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 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, card.unit === "kWh" ? 2 : 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="list-group list-group-flush list-group-hoverable">{metrics.map((metric) => <div className="list-group-item" key={metric.metric_id}><div className="row align-items-center"><div className="col-auto text-primary">{iconForMetric(metric.metric_id)}</div><div className="col text-truncate"><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || t(language, "status")}</div></div><div className="col-auto fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div></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 StatusPanel({ 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, "systemStatus")}</h3></div><div className="card-body d-flex flex-column gap-3">{metrics.map((metric) => <div key={metric.metric_id} className="d-flex justify-content-between align-items-center border rounded-3 px-3 py-2 status-row"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || t(language, "status")}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div></div>; }
function StringsPanel({ 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.length === 0 ? <div className="col-12 text-secondary">{t(language, "noDataDescription")}</div> : rows.map((row) => <div className="col-12 col-md-6" key={row.id}><div className="border rounded-3 p-3 h-100 string-panel"><div className="d-flex align-items-center justify-content-between mb-3"><div className="fw-semibold">{row.label}</div><span className="badge bg-azure-lt text-azure">DC</span></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 small"><span className="text-secondary">{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 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 ComparisonPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "chartComparison")}</h3></div><div className="card-body"><EChart option={buildComparisonOption(data, theme, language)} className="pv-chart" /></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)} 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 StatusStat({ label, value }: { label: string; value: string }) { return <div className="col-6 col-md-4 col-xl-2"><div className="border rounded-3 px-3 py-2 h-100 bg-body-tertiary"><div className="text-secondary small mb-1">{label}</div><div className="fw-semibold">{value}</div></div></div>; }
function ImportControls({ status, language, onStart, onSyncNow, onCancel }: { status?: HistoricalStatus; language: Language; onStart: (payload: { start_date?: string; end_date?: string; chunk_days?: number; force?: boolean }) => void; onSyncNow: () => void; onCancel: () => void; }) { const [startDate, setStartDate] = useState(status?.available_start_date ?? ""); const [endDate, setEndDate] = useState(status?.available_end_date ?? ""); const [chunkDays, setChunkDays] = useState(String(status?.default_chunk_days ?? 7)); useEffect(() => { if (!status) return; setStartDate((current) => current || status.available_start_date || ""); setEndDate((current) => current || status.available_end_date || ""); setChunkDays((current) => current || String(status.default_chunk_days || 7)); }, [status]); return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{language === "en" ? "Import controls" : "Sterowanie importem"}</h3></div><div className="card-body d-flex flex-column gap-3"><div><label className="form-label">{t(language, "startDate")}</label><input className="form-control" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} /></div><div><label className="form-label">{t(language, "endDate")}</label><input className="form-control" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} /></div><div><label className="form-label">{t(language, "chunkDays")}</label><input className="form-control" type="number" min={1} max={31} value={chunkDays} onChange={(event) => setChunkDays(event.target.value)} /></div><div className="d-grid gap-2"><button className="btn btn-primary" onClick={() => onStart({ start_date: startDate || undefined, end_date: endDate || undefined, chunk_days: Number(chunkDays) || undefined, force: true })}><IconPlayerPlay size={18} className="me-1" />{t(language, "startImport")}</button><button className="btn btn-outline-secondary" onClick={onSyncNow}><IconRefresh size={18} className="me-1" />{t(language, "syncMissing")}</button><button className="btn btn-outline-danger" onClick={onCancel} disabled={!status?.running}><IconX size={18} className="me-1" />{t(language, "cancel")}</button></div></div></div>; }
function KioskLayoutPanel({ language, widgets, onChange, labels }: { language: Language; widgets: WidgetId[]; onChange: (value: WidgetId[]) => void; labels: Map<WidgetId, string>; }) { const available = widgetOrder.map((item) => item.id); const selected = widgets; const unselected = available.filter((item) => !selected.includes(item)); const move = (id: WidgetId, direction: -1 | 1) => { const index = selected.indexOf(id); if (index === -1) return; const target = index + direction; if (target < 0 || target >= selected.length) return; const next = [...selected]; [next[index], next[target]] = [next[target], next[index]]; onChange(next); }; const toggle = (id: WidgetId) => { if (selected.includes(id)) { const next = selected.filter((item) => item !== id); onChange(next.length ? next : selected); return; } onChange([...selected, id]); }; return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{t(language, "kioskLayout")}</h3><div className="text-secondary small">{t(language, "kioskLayoutSubtitle")}</div></div></div><div className="card-body"><div className="alert alert-info py-2">{t(language, "saveLayout")}</div><div className="row g-3"><div className="col-12 col-lg-7"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{t(language, "selected")}</div><div className="d-flex flex-column gap-2">{selected.map((id) => <div key={id} className="d-flex align-items-center justify-content-between gap-2 border rounded-3 px-3 py-2 bg-body-tertiary"><span>{labels.get(id)}</span><div className="btn-list"><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, -1)}>{t(language, "moveUp")}</button><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, 1)}>{t(language, "moveDown")}</button><button className="btn btn-sm btn-outline-danger" onClick={() => toggle(id)}><IconX size={16} /></button></div></div>)}</div></div></div><div className="col-12 col-lg-5"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{t(language, "available")}</div><div className="d-flex flex-wrap gap-2">{unselected.map((id) => <button key={id} className="btn btn-outline-primary" onClick={() => toggle(id)}>{labels.get(id)}</button>)}</div></div></div></div></div></div>; }
function KioskSettingsEditorPanel({ language, value, onChange, onSave, selectedMode, onModeChange, labels, buckets, compareModes, saving }: { language: Language; value: KioskSettingsPayload; onChange: (value: KioskSettingsPayload) => void; onSave: () => void; selectedMode: "public" | "private"; onModeChange: (value: "public" | "private") => void; labels: Map<WidgetId, string>; buckets: Array<{ key: string; label: string }>; compareModes: string[]; saving: boolean; }) { const widgets = toWidgetIds(value.widgets); return <div className="d-flex flex-column gap-3"><div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Kiosk settings" : "Ustawienia kiosku"}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="btn-group w-100"><button className={`btn ${selectedMode === "private" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => onModeChange("private")}>{language === "en" ? "Logged-in kiosk" : "Kiosk prywatny"}</button><button className={`btn ${selectedMode === "public" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => onModeChange("public")}>{language === "en" ? "Public kiosk" : "Kiosk publiczny"}</button></div><div className="row g-3"><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Live chart range" : "Zakres wykresu live"}</label><select className="form-select" value={value.realtime_range} onChange={(e) => onChange({ ...value, realtime_range: e.target.value })}>{liveRangeOptions(language).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Analytics range" : "Zakres analityki"}</label><select className="form-select" value={value.analytics_range} onChange={(e) => onChange({ ...value, analytics_range: e.target.value })}>{analyticsRangeOptions(language).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">Bucket</label><select className="form-select" value={value.analytics_bucket} onChange={(e) => onChange({ ...value, analytics_bucket: e.target.value })}>{buckets.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Comparison" : "Porównanie"}</label><select className="form-select" value={value.compare_mode} onChange={(e) => onChange({ ...value, compare_mode: e.target.value })}><option value="none">{translateCompareMode(language, "none")}</option>{compareModes.filter((item) => item !== "none").map((item) => <option key={item} value={item}>{translateCompareMode(language, item)}</option>)}</select></div></div><div className="d-flex justify-content-end"><button className="btn btn-primary" onClick={onSave} disabled={saving}>{saving ? (language === "en" ? "Saving..." : "Zapisywanie...") : (language === "en" ? "Save kiosk settings" : "Zapisz ustawienia kiosku")}</button></div></div></div><KioskLayoutPanel language={language} widgets={widgets} onChange={(widgetsValue) => onChange({ ...value, widgets: widgetsValue })} labels={labels} /></div>; }
function KioskLinkPanel({ language, kioskUrl, settings }: { language: Language; kioskUrl: string; settings: KioskSettingsPayload }) { const [copied, setCopied] = useState(false); return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{language === "en" ? "Public kiosk link" : "Publiczny link kiosku"}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="text-secondary small">{language === "en" ? "This link opens kiosk mode without login for read-only public display." : "Ten link otwiera kiosk bez logowania, tylko do publicznego podglądu."}</div><input className="form-control" value={kioskUrl} readOnly /><div className="small text-secondary">{language === "en" ? "Current public ranges:" : "Aktualne zakresy publiczne:"} live {settings.realtime_range}, analytics {settings.analytics_range}</div><button className="btn btn-primary" onClick={async () => { await navigator.clipboard.writeText(kioskUrl); setCopied(true); window.setTimeout(() => setCopied(false), 1500); }}>{copied ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy link" : "Kopiuj link")}</button></div></div>; }
function AppearanceSecurityPanel({ language, theme, setTheme, viewMode, setViewMode, authEnabled, userName }: { language: Language; theme: ThemeMode; setTheme: (value: ThemeMode) => void; viewMode: ViewMode; setViewMode: (value: ViewMode) => void; authEnabled: boolean; userName: string; }) { return <div className="d-flex flex-column gap-3"><div className="card pv-card"><div className="card-header"><h3 className="card-title">{t(language, "theme")}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="btn-group w-100"><button className={`btn ${theme === "light" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setTheme("light")}>{t(language, "light")}</button><button className={`btn ${theme === "dark" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setTheme("dark")}>{t(language, "dark")}</button></div></div></div><div className="card pv-card"><div className="card-header"><h3 className="card-title">{t(language, "viewMode")}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="btn-group w-100"><button className={`btn ${viewMode === "normal" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setViewMode("normal")}>{t(language, "normalMode")}</button><button className={`btn ${viewMode === "kiosk" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setViewMode("kiosk")}>{t(language, "kioskMode")}</button></div></div></div><div className="card pv-card"><div className="card-header"><h3 className="card-title">{t(language, "security")}</h3></div><div className="card-body d-flex flex-column gap-2"><div className="d-flex align-items-center gap-2 fw-medium"><IconLock size={18} /> {authEnabled ? t(language, "authEnabled") : t(language, "authDisabled")}</div><div className="text-secondary small">{language === "en" ? "Admin user management is available below." : "Zarządzanie użytkownikami admina jest dostępne niżej."}</div>{userName ? <div className="badge bg-primary-lt text-primary align-self-start">{userName}</div> : null}</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"><h3 className="card-title">{title}</h3></div><div className="card-body d-flex flex-column gap-2">{items.map((item) => <label key={item.metric_id} className="form-check"><input className="form-check-input" type="checkbox" checked={selected.includes(item.metric_id)} onChange={() => toggle(item.metric_id)} /><span className="form-check-label">{item.label} <span className="text-secondary small">{item.unit}</span></span></label>)}</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 wykresu live"} items={items} selected={selected} onChange={onChange} />; }
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] }); }; return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{language === "en" ? "Metric visibility in blocks" : "Widoczność metryk w blokach"}</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">Hero metrics</div>{items.map((item) => <label key={`hero-${item.metric_id}`} className="form-check d-block mb-2"><input className="form-check-input" type="checkbox" checked={config.hero.includes(item.metric_id)} onChange={() => toggle("hero", item.metric_id)} /><span className="form-check-label">{item.label}</span></label>)}</div></div><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">Quick metrics</div>{items.map((item) => <label key={`quick-${item.metric_id}`} className="form-check d-block mb-2"><input className="form-check-input" type="checkbox" checked={config.quick.includes(item.metric_id)} onChange={() => toggle("quick", item.metric_id)} /><span className="form-check-label">{item.label}</span></label>)}</div></div></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>; }

145
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,145 @@
import type {
AnalyticsPayload,
AuthStatus,
AuthUsersPayload,
DashboardConfig,
KioskSettingsPayload,
DistributionPayload,
HistoryPayload,
HistoricalStartPayload,
HistoricalStatus,
SnapshotPayload,
} from "../types";
import {
demoAnalytics,
demoAuthStatus,
demoConfig,
demoDistribution,
demoHistory,
demoHistoricalStatus,
demoSnapshot,
} from "../demo/data";
function defaultApiBase(): string {
const explicit = import.meta.env.VITE_API_BASE_URL;
if (explicit) return explicit;
const { protocol, hostname, port } = window.location;
if (port === "5173" || port === "4173") {
return `${protocol}//${hostname}:8105/api/v1`;
}
return "/api/v1";
}
const API_BASE = defaultApiBase();
const DEMO_MODE = String(import.meta.env.VITE_DEMO_MODE ?? "").toLowerCase() === "true";
const locationUrl = new URL(window.location.href);
const PUBLIC_KIOSK = locationUrl.searchParams.get("publicKiosk") === "1" || locationUrl.pathname.endsWith("/kiosk/public");
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const method = (init?.method || "GET").toUpperCase();
const resolvedPath = PUBLIC_KIOSK && method === "GET" ? `${path}${path.includes("?") ? "&" : "?"}publicKiosk=1` : path;
const response = await fetch(`${API_BASE}${resolvedPath}`, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return {} as T;
}
return response.json() as Promise<T>;
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
async function demoResponse<T>(factory: () => T): Promise<T> {
await new Promise((resolve) => window.setTimeout(resolve, 120));
return clone(factory());
}
export const api = {
getConfig: () => (DEMO_MODE ? demoResponse(() => demoConfig) : request<DashboardConfig>("/dashboard/config")),
getKioskSettings: (mode: "public" | "private") => (DEMO_MODE ? demoResponse(() => ({ mode, widgets: ["hero", "history", "strings", "status", "production", "comparison", "importStatus"], realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" })) : request<KioskSettingsPayload>(`/dashboard/kiosk-settings?mode=${mode}`)),
saveKioskSettings: (payload: KioskSettingsPayload) => (DEMO_MODE ? demoResponse(() => payload) : request<KioskSettingsPayload>("/dashboard/kiosk-settings", { method: "PUT", body: JSON.stringify(payload) })),
getAuthStatus: () => (DEMO_MODE ? demoResponse(() => demoAuthStatus) : request<AuthStatus>("/auth/status")),
login: (username: string, password: string) =>
DEMO_MODE
? demoResponse(() => ({ ...demoAuthStatus, authenticated: true, user: username || "demo", display_name: username || "demo" }))
: request<AuthStatus>("/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }),
logout: () => (DEMO_MODE ? demoResponse(() => ({ ...demoAuthStatus, authenticated: false })) : request<AuthStatus>("/auth/logout", { method: "POST", body: JSON.stringify({}) })),
getRealtimeSnapshot: () => (DEMO_MODE ? demoResponse(() => demoSnapshot()) : request<SnapshotPayload>("/realtime/snapshot")),
getRealtimeHistory: (range: string, options?: { start?: string; end?: string; metrics?: string[]; publicKiosk?: boolean }) => {
const params = new URLSearchParams();
params.set("range", range);
if (options?.start) params.set("start", options.start);
if (options?.end) params.set("end", options.end);
if (options?.metrics?.length) params.set("metrics", options.metrics.join(","));
if (options?.publicKiosk) params.set("publicKiosk", "1");
return DEMO_MODE ? demoResponse(() => ({ ...demoHistory, range_key: range })) : request<HistoryPayload>(`/realtime/history?${params.toString()}`);
},
getAnalytics: (range: string, bucket: string, compare: string, options?: { start?: string; end?: string; publicKiosk?: boolean; compareRanges?: Array<{ start: string; end: string; label: string; key?: string }> }) => {
const params = new URLSearchParams();
params.set("range", range);
params.set("bucket", bucket);
params.set("compare", compare);
if (options?.start) params.set("start", options.start);
if (options?.end) params.set("end", options.end);
if (options?.publicKiosk) params.set("publicKiosk", "1");
if (options?.compareRanges?.length) params.set("compare_ranges", JSON.stringify(options.compareRanges));
return DEMO_MODE
? demoResponse(() => ({
...demoAnalytics,
bucket,
compare_mode: compare,
meta: { ...demoAnalytics.meta, window: { ...demoAnalytics.meta.window, range_key: range } },
}))
: request<AnalyticsPayload>(`/analytics/production?${params.toString()}`);
},
getDistribution: (range: string, bucket: string, options?: { start?: string; end?: string; publicKiosk?: boolean }) => {
const params = new URLSearchParams();
params.set("range", range);
params.set("bucket", bucket);
if (options?.start) params.set("start", options.start);
if (options?.end) params.set("end", options.end);
if (options?.publicKiosk) params.set("publicKiosk", "1");
return DEMO_MODE ? demoResponse(() => ({ ...demoDistribution, bucket })) : request<DistributionPayload>(`/analytics/distribution?${params.toString()}`);
},
getHistoricalStatus: () => (DEMO_MODE ? demoResponse(() => demoHistoricalStatus) : request<HistoricalStatus>("/historical/status")),
startHistoricalImport: (payload: HistoricalStartPayload) =>
DEMO_MODE
? demoResponse(() => ({ ...demoHistoricalStatus, ...payload, message: "Tryb demo: import uruchomiony", running: true }))
: request<HistoricalStatus>("/historical/start", { method: "POST", body: JSON.stringify(payload) }),
syncHistoricalNow: () =>
DEMO_MODE
? demoResponse(() => ({ ...demoHistoricalStatus, message: "Tryb demo: synchronizacja brakujacych dni" }))
: request<HistoricalStatus>("/historical/sync-now", { method: "POST", body: JSON.stringify({}) }),
cancelHistoricalImport: () =>
DEMO_MODE
? demoResponse(() => ({ ...demoHistoricalStatus, running: false, state: "cancelled", message: "Tryb demo: anulowano" }))
: request<HistoricalStatus>("/historical/cancel", { method: "POST", body: JSON.stringify({}) }),
getUsers: () => (DEMO_MODE ? demoResponse(() => ({ items: [] })) : request<AuthUsersPayload>("/auth/users")),
createUser: (payload: { username: string; password: string; role: string; display_name?: string }) =>
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 }) }),
};
export const wsBaseUrl = (): string => {
const explicit = import.meta.env.VITE_WS_BASE_URL;
if (explicit) return explicit.endsWith("/") ? explicit.slice(0, -1) : explicit;
const { protocol, hostname, port } = window.location;
const wsProtocol = protocol === "https:" ? "wss:" : "ws:";
const raw = (port === "5173" || port === "4173") ? `${wsProtocol}//${hostname}:8105` : `${wsProtocol}//${window.location.host}`;
return raw.endsWith("/") ? raw.slice(0, -1) : raw;
};

View File

@@ -0,0 +1,66 @@
import type { EChartsOption } from "echarts";
import { Card } from "../common/Card";
import { EChart } from "../common/EChart";
import type { BucketPoint } from "../../types";
interface ComparisonChartProps {
current: BucketPoint[];
comparison: BucketPoint[];
unit: string;
compareMode: string;
}
export function ComparisonChart({ current, comparison, unit, compareMode }: ComparisonChartProps) {
const option: EChartsOption = {
tooltip: {
trigger: "axis",
backgroundColor: "rgba(2, 6, 23, 0.95)",
borderColor: "rgba(255,255,255,0.08)",
textStyle: { color: "#e2e8f0" },
},
legend: {
top: 0,
textStyle: { color: "#cbd5e1" },
},
grid: {
left: 18,
right: 16,
top: 46,
bottom: 24,
containLabel: true,
},
xAxis: {
type: "category",
axisLabel: { color: "#94a3b8", rotate: current.length > 12 ? 35 : 0 },
axisLine: { lineStyle: { color: "rgba(255,255,255,0.08)" } },
data: current.map((item) => item.label),
},
yAxis: {
type: "value",
name: unit,
axisLabel: { color: "#94a3b8" },
splitLine: { lineStyle: { color: "rgba(255,255,255,0.06)" } },
},
series: [
{
name: "Aktualny okres",
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",
type: "bar",
itemStyle: { color: "#f59e0b", borderRadius: [10, 10, 0, 0] },
data: current.map((_, index) => comparison[index]?.value ?? 0),
},
],
};
return (
<Card title="Porownanie okresow" subtitle="Wspolne slupki dla aktualnego i porownawczego okresu">
<EChart option={option} className="h-[340px] w-full" />
</Card>
);
}

View File

@@ -0,0 +1,51 @@
import type { EChartsOption } from "echarts";
import { Card } from "../common/Card";
import { EChart } from "../common/EChart";
import type { DistributionPayload } from "../../types";
interface DistributionPieChartProps {
distribution?: DistributionPayload;
}
export function DistributionPieChart({ distribution }: DistributionPieChartProps) {
const option: EChartsOption = {
tooltip: {
trigger: "item",
backgroundColor: "rgba(2, 6, 23, 0.95)",
borderColor: "rgba(255,255,255,0.08)",
textStyle: { color: "#e2e8f0" },
},
legend: {
orient: "vertical",
right: 0,
top: "center",
textStyle: { color: "#cbd5e1" },
},
series: [
{
type: "pie",
radius: ["42%", "68%"],
center: ["38%", "50%"],
padAngle: 2,
itemStyle: {
borderColor: "#020617",
borderWidth: 4,
},
label: {
color: "#e2e8f0",
formatter: "{b}: {d}%",
},
data: distribution?.slices.map((item) => ({
name: item.label,
value: item.value,
})) ?? [],
},
],
};
return (
<Card title="Udzial produkcji" subtitle="Wykres kolowy z rozkladem produkcji w wybranym okresie">
<EChart option={option} className="h-[340px] w-full" />
</Card>
);
}

View File

@@ -0,0 +1,76 @@
import { Card } from "../common/Card";
interface PeriodControlsProps {
rangeKey: string;
bucket: string;
compare: string;
ranges: Array<{ key: string; label: string }>;
buckets: Array<{ key: string; label: string }>;
compareModes: Array<{ key: string; label: string }>;
onRangeChange: (value: string) => void;
onBucketChange: (value: string) => void;
onCompareChange: (value: string) => void;
}
export function PeriodControls({
rangeKey,
bucket,
compare,
ranges,
buckets,
compareModes,
onRangeChange,
onBucketChange,
onCompareChange,
}: PeriodControlsProps) {
return (
<Card title="Porownanie okresow" subtitle="Dzien / tydzien / miesiac + poprzedni okres lub poprzedni rok">
<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>
<select
value={rangeKey}
onChange={(event) => onRangeChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3 text-sm text-white outline-none"
>
{ranges.map((item) => (
<option key={item.key} value={item.key}>
{item.label}
</option>
))}
</select>
</label>
<label className="space-y-2">
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">Bucket</span>
<select
value={bucket}
onChange={(event) => onBucketChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3 text-sm text-white outline-none"
>
{buckets.map((item) => (
<option key={item.key} value={item.key}>
{item.label}
</option>
))}
</select>
</label>
<label className="space-y-2">
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">Porownanie</span>
<select
value={compare}
onChange={(event) => onCompareChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3 text-sm text-white outline-none"
>
{compareModes.map((item) => (
<option key={item.key} value={item.key}>
{item.label}
</option>
))}
</select>
</label>
</div>
</Card>
);
}

View File

@@ -0,0 +1,56 @@
import type { EChartsOption } from "echarts";
import { Card } from "../common/Card";
import { EChart } from "../common/EChart";
import type { BucketPoint } from "../../types";
interface ProductionBarChartProps {
current: BucketPoint[];
unit: string;
}
export function ProductionBarChart({ current, unit }: ProductionBarChartProps) {
const option: EChartsOption = {
tooltip: {
trigger: "axis",
backgroundColor: "rgba(2, 6, 23, 0.95)",
borderColor: "rgba(255,255,255,0.08)",
textStyle: { color: "#e2e8f0" },
},
grid: {
left: 18,
right: 16,
top: 30,
bottom: 24,
containLabel: true,
},
xAxis: {
type: "category",
axisLabel: { color: "#94a3b8", rotate: current.length > 12 ? 35 : 0 },
axisLine: { lineStyle: { color: "rgba(255,255,255,0.08)" } },
data: current.map((item) => item.label),
},
yAxis: {
type: "value",
name: unit,
axisLabel: { color: "#94a3b8" },
splitLine: { lineStyle: { color: "rgba(255,255,255,0.06)" } },
},
series: [
{
type: "bar",
barMaxWidth: 26,
itemStyle: {
borderRadius: [10, 10, 0, 0],
color: "#34d399",
},
data: current.map((item) => item.value),
},
],
};
return (
<Card title="Produkcja dlugoterminowa" subtitle="Wykres slupkowy agregowany wedlug wybranego bucketu">
<EChart option={option} className="h-[340px] w-full" />
</Card>
);
}

View File

@@ -0,0 +1,34 @@
import { Card } from "../common/Card";
import { formatValue } from "../../lib/format";
import type { AnalyticsSummary } from "../../types";
interface SummaryCardsProps {
summary: AnalyticsSummary;
}
export function SummaryCards({ summary }: SummaryCardsProps) {
const tiles = [
{ label: "Produkcja", value: formatValue(summary.total, summary.unit, 2) },
{ label: "Srednio / bucket", value: formatValue(summary.average_bucket, summary.unit, 2) },
{ label: "Najlepszy bucket", value: `${summary.best_bucket_label || "--"} / ${formatValue(summary.best_bucket_value, summary.unit, 2)}` },
{ label: "CO2 mniej", value: formatValue(summary.co2_saved_kg, "kg", 2) },
{
label: "Delta vs porownanie",
value:
summary.comparison_delta_pct === null || summary.comparison_delta_pct === undefined
? "--"
: formatValue(summary.comparison_delta_pct, "%", 2),
},
];
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
{tiles.map((tile) => (
<Card key={tile.label} className="bg-slate-950/35">
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">{tile.label}</div>
<div className="mt-3 text-2xl font-semibold text-white">{tile.value}</div>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import clsx from "clsx";
import type { PropsWithChildren } from "react";
interface BadgeProps extends PropsWithChildren {
tone?: "ok" | "warn" | "critical" | "neutral";
}
export function Badge({ tone = "neutral", children }: BadgeProps) {
const palette = {
ok: "border-emerald-400/30 bg-emerald-500/10 text-emerald-200",
warn: "border-amber-400/30 bg-amber-500/10 text-amber-200",
critical: "border-rose-400/30 bg-rose-500/10 text-rose-200",
neutral: "border-white/10 bg-white/5 text-slate-200",
};
return (
<span className={clsx("inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium", palette[tone])}>
{children}
</span>
);
}

View File

@@ -0,0 +1,31 @@
import clsx from "clsx";
import type { PropsWithChildren, ReactNode } from "react";
interface CardProps extends PropsWithChildren {
title?: string;
subtitle?: string;
action?: ReactNode;
className?: string;
}
export function Card({ title, subtitle, action, className, children }: CardProps) {
return (
<section
className={clsx(
"rounded-3xl border border-white/10 bg-white/5 p-5 shadow-[0_24px_80px_rgba(15,23,42,0.35)] backdrop-blur",
className
)}
>
{(title || subtitle || action) && (
<header className="mb-4 flex items-start justify-between gap-4">
<div>
{title && <h3 className="text-base font-semibold text-white">{title}</h3>}
{subtitle && <p className="mt-1 text-sm text-slate-400">{subtitle}</p>}
</div>
{action}
</header>
)}
{children}
</section>
);
}

View File

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

View File

@@ -0,0 +1,13 @@
interface EmptyStateProps {
title: string;
description: string;
}
export function EmptyState({ title, description }: EmptyStateProps) {
return (
<div className="rounded-3xl border border-dashed border-white/10 bg-white/3 p-10 text-center">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<p className="mt-2 text-sm text-slate-400">{description}</p>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import type { SVGProps } from "react";
type IconProps = SVGProps<SVGSVGElement> & { size?: number };
function BaseIcon({ size = 18, children, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
{children}
</svg>
);
}
export function IconBolt(props: IconProps) {
return <BaseIcon {...props}><path d="M13 2L5 14h6l-1 8 8-12h-6l1-8z" /></BaseIcon>;
}
export function IconChartBar(props: IconProps) {
return <BaseIcon {...props}><path d="M4 20h16" /><path d="M7 16V8" /><path d="M12 16V4" /><path d="M17 16v-6" /></BaseIcon>;
}
export function IconChecklist(props: IconProps) {
return <BaseIcon {...props}><path d="M9 6h11" /><path d="M9 12h11" /><path d="M9 18h11" /><path d="M4 6l1.5 1.5L7.5 5" /><path d="M4 12l1.5 1.5L7.5 11" /><path d="M4 18l1.5 1.5L7.5 17" /></BaseIcon>;
}
export function IconClockHour4(props: IconProps) {
return <BaseIcon {...props}><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></BaseIcon>;
}
export function IconDatabaseImport(props: IconProps) {
return <BaseIcon {...props}><ellipse cx="12" cy="5" rx="7" ry="3" /><path d="M5 5v6c0 1.7 3.1 3 7 3s7-1.3 7-3V5" /><path d="M12 14v8" /><path d="M9 19l3 3 3-3" /></BaseIcon>;
}
export function IconDeviceDesktop(props: IconProps) {
return <BaseIcon {...props}><rect x="3" y="4" width="18" height="12" rx="2" /><path d="M8 20h8" /><path d="M12 16v4" /></BaseIcon>;
}
export function IconHistory(props: IconProps) {
return <BaseIcon {...props}><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v5h5" /><path d="M12 7v5l3 2" /></BaseIcon>;
}
export function IconLanguage(props: IconProps) {
return <BaseIcon {...props}><path d="M4 5h10" /><path d="M9 3c0 6-2 10-5 12" /><path d="M7 13c1.5 2.5 3.5 4.5 6 6" /><path d="M14 10h6" /><path d="M17 7l3 10" /><path d="M14 17h6" /></BaseIcon>;
}
export function IconLayoutDashboard(props: IconProps) {
return <BaseIcon {...props}><rect x="3" y="3" width="8" height="8" rx="1" /><rect x="13" y="3" width="8" height="5" rx="1" /><rect x="13" y="10" width="8" height="11" rx="1" /><rect x="3" y="13" width="8" height="8" rx="1" /></BaseIcon>;
}
export function IconLock(props: IconProps) {
return <BaseIcon {...props}><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V8a4 4 0 0 1 8 0v3" /></BaseIcon>;
}
export function IconLogin2(props: IconProps) {
return <BaseIcon {...props}><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><path d="M10 17l5-5-5-5" /><path d="M15 12H3" /></BaseIcon>;
}
export function IconLogout(props: IconProps) {
return <BaseIcon {...props}><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><path d="M16 17l5-5-5-5" /><path d="M21 12H9" /></BaseIcon>;
}
export function IconMoon(props: IconProps) {
return <BaseIcon {...props}><path d="M12 3a7 7 0 1 0 9 9 9 9 0 1 1-9-9z" /></BaseIcon>;
}
export function IconPlayerPlay(props: IconProps) {
return <BaseIcon {...props}><path d="M8 5v14l11-7z" /></BaseIcon>;
}
export function IconRefresh(props: IconProps) {
return <BaseIcon {...props}><path d="M20 11a8 8 0 0 0-14-5l-2 2" /><path d="M4 3v5h5" /><path d="M4 13a8 8 0 0 0 14 5l2-2" /><path d="M20 21v-5h-5" /></BaseIcon>;
}
export function IconSettings(props: IconProps) {
return <BaseIcon {...props}><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-1.8-.3 1.6 1.6 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.2a1.6 1.6 0 0 0-1-1.5 1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0 .3-1.8 1.6 1.6 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.2a1.6 1.6 0 0 0 1.5-1 1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 1.8.3h0A1.6 1.6 0 0 0 10 3.2V3a2 2 0 1 1 4 0v.2a1.6 1.6 0 0 0 1 1.5h0a1.6 1.6 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8v0a1.6 1.6 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.2a1.6 1.6 0 0 0-1.4 1z" /></BaseIcon>;
}
export function IconSun(props: IconProps) {
return <BaseIcon {...props}><circle cx="12" cy="12" r="4" /><path d="M12 2v2" /><path d="M12 20v2" /><path d="M4.9 4.9l1.4 1.4" /><path d="M17.7 17.7l1.4 1.4" /><path d="M2 12h2" /><path d="M20 12h2" /><path d="M4.9 19.1l1.4-1.4" /><path d="M17.7 6.3l1.4-1.4" /></BaseIcon>;
}
export function IconTemperature(props: IconProps) {
return <BaseIcon {...props}><path d="M14 14.76V5a2 2 0 0 0-4 0v9.76a4 4 0 1 0 4 0z" /></BaseIcon>;
}
export function IconX(props: IconProps) {
return <BaseIcon {...props}><path d="M18 6L6 18" /><path d="M6 6l12 12" /></BaseIcon>;
}
export function IconArrowsMove(props: IconProps) {
return <BaseIcon {...props}><path d="M12 2v20" /><path d="M2 12h20" /><path d="M7 7l5-5 5 5" /><path d="M7 17l5 5 5-5" /></BaseIcon>;
}

View File

@@ -0,0 +1,17 @@
import { formatValue } from "../../lib/format";
import type { MetricValue } from "../../types";
interface ValuePairProps {
metric?: MetricValue;
}
export function ValuePair({ metric }: ValuePairProps) {
return (
<div className="rounded-2xl border border-white/8 bg-slate-950/40 p-3">
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">{metric?.label ?? "--"}</div>
<div className="mt-2 text-lg font-semibold text-white">
{metric ? formatValue(metric.value, metric.unit, metric.precision) : "--"}
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import type { PropsWithChildren, ReactNode } from "react";
interface AppShellProps extends PropsWithChildren {
header: ReactNode;
}
export function AppShell({ header, children }: AppShellProps) {
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(52,211,153,0.14),_transparent_30%),radial-gradient(circle_at_top_right,_rgba(96,165,250,0.12),_transparent_28%),linear-gradient(180deg,_#020617,_#0f172a_55%,_#020617)] text-slate-100">
<div className="mx-auto flex min-h-screen w-full max-w-7xl flex-col px-4 pb-10 pt-4 sm:px-6 lg:px-8">
{header}
<main className="mt-6 flex-1">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import clsx from "clsx";
import { Badge } from "../common/Badge";
import { formatDateTime } from "../../lib/format";
interface TopNavProps {
siteName: string;
connected: boolean;
lastUpdated?: string | null;
activeTab: string;
onTabChange: (tab: "realtime" | "analytics" | "settings") => void;
}
const tabs = [
{ id: "realtime", label: "Na zywo" },
{ id: "analytics", label: "Analityka" },
{ id: "settings", label: "Konfiguracja" },
] as const;
export function TopNav({ siteName, connected, lastUpdated, activeTab, onTabChange }: TopNavProps) {
return (
<header className="sticky top-4 z-20 rounded-[28px] border border-white/10 bg-slate-950/70 p-4 shadow-2xl backdrop-blur">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.28em] text-emerald-300/80">PV Insight</p>
<h1 className="mt-2 text-2xl font-semibold text-white">{siteName}</h1>
<p className="mt-1 text-sm text-slate-400">Panel live + analityka liczona z surowych danych InfluxDB</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Badge tone={connected ? "ok" : "warn"}>{connected ? "Live polling" : "Brak odpowiedzi API"}</Badge>
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-300">
Ostatnia aktualizacja: <span className="text-white">{formatDateTime(lastUpdated)}</span>
</div>
</div>
</div>
<nav className="mt-4 flex flex-wrap gap-2">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onTabChange(tab.id)}
className={clsx(
"rounded-full px-4 py-2 text-sm font-medium transition",
activeTab === tab.id
? "bg-white text-slate-950 shadow-lg"
: "bg-white/5 text-slate-300 hover:bg-white/10 hover:text-white"
)}
>
{tab.label}
</button>
))}
</nav>
</header>
);
}

View File

@@ -0,0 +1,32 @@
import clsx from "clsx";
import { Card } from "../common/Card";
import { formatValue } from "../../lib/format";
import type { HeroCard } from "../../types";
interface HeroKpiGridProps {
cards: HeroCard[];
}
const accents: Record<string, string> = {
emerald: "from-emerald-400/15 to-emerald-500/5 ring-emerald-300/20",
amber: "from-amber-400/15 to-amber-500/5 ring-amber-300/20",
rose: "from-rose-400/15 to-rose-500/5 ring-rose-300/20",
slate: "from-white/10 to-white/5 ring-white/10",
};
export function HeroKpiGrid({ cards }: HeroKpiGridProps) {
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
{cards.map((card) => (
<Card
key={card.metric_id}
className={clsx("overflow-hidden bg-gradient-to-br", accents[card.accent] ?? accents.slate)}
>
<div className="text-xs uppercase tracking-[0.22em] text-slate-400">{card.label}</div>
<div className="mt-3 text-3xl font-semibold text-white">{formatValue(card.value, card.unit, 2)}</div>
<div className="mt-3 text-sm text-slate-400">{card.subtitle}</div>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { Card } from "../common/Card";
import { formatValue } from "../../lib/format";
import type { MetricValue } from "../../types";
interface KpiStripProps {
items: Record<string, MetricValue>;
}
const order = [
"energy_today",
"energy_yesterday",
"today_vs_yesterday",
"dc_power_total",
"energy_total",
];
export function KpiStrip({ items }: KpiStripProps) {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
{order
.filter((metricId) => items[metricId])
.map((metricId) => {
const metric = items[metricId];
return (
<Card key={metric.metric_id} className="bg-slate-950/35">
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">{metric.label}</div>
<div className="mt-3 text-2xl font-semibold text-white">
{formatValue(metric.value, metric.unit, metric.precision)}
</div>
</Card>
);
})}
</div>
);
}

View File

@@ -0,0 +1,64 @@
import type { EChartsOption } from "echarts";
import { Card } from "../common/Card";
import { EChart } from "../common/EChart";
import type { HistoryPayload } from "../../types";
interface LiveHistoryChartProps {
history?: HistoryPayload;
title?: string;
}
export function LiveHistoryChart({ history, title = "Dane chwilowe" }: LiveHistoryChartProps) {
const option: EChartsOption = {
tooltip: {
trigger: "axis",
backgroundColor: "rgba(2, 6, 23, 0.95)",
borderColor: "rgba(255,255,255,0.08)",
textStyle: { color: "#e2e8f0" },
},
legend: {
top: 0,
textStyle: { color: "#cbd5e1" },
},
grid: {
left: 18,
right: 16,
top: 46,
bottom: 24,
containLabel: true,
},
xAxis: {
type: "category",
boundaryGap: false,
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" })
) ?? [],
},
yAxis: {
type: "value",
axisLabel: { color: "#94a3b8" },
splitLine: { lineStyle: { color: "rgba(255,255,255,0.06)" } },
},
series:
history?.series.map((item) => ({
name: `${item.label} (${item.unit})`,
type: "line",
smooth: true,
showSymbol: false,
lineStyle: { width: 3 },
areaStyle: { opacity: 0.08 },
data: item.points.map((point) => point.value),
})) ?? [],
};
return (
<Card
title={title}
subtitle="Moc AC, moce stringow DC i opcjonalnie temperatura falownika w jednym widoku live"
>
<EChart option={option} className="h-[340px] w-full" />
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import { Badge } from "../common/Badge";
import { Card } from "../common/Card";
import { formatValue } from "../../lib/format";
import type { MetricValue } from "../../types";
interface LiveStatusBoardProps {
status: MetricValue[];
}
export function LiveStatusBoard({ status }: LiveStatusBoardProps) {
if (!status.length) {
return null;
}
return (
<Card title="Stan systemu" subtitle="Temperatura falownika i kontrola swiezosci odczytu">
<div className="grid gap-3 sm:grid-cols-2">
{status.map((metric) => (
<div key={metric.metric_id} className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-medium text-white">{metric.label}</div>
<Badge tone={metric.status}>{metric.status}</Badge>
</div>
<div className="text-sm text-slate-300">{formatValue(metric.value, metric.unit, metric.precision)}</div>
</div>
))}
</div>
</Card>
);
}

View File

@@ -0,0 +1,26 @@
import { Card } from "../common/Card";
import { ValuePair } from "../common/ValuePair";
import type { SnapshotGroupRow } from "../../types";
interface PhaseGridProps {
rows: SnapshotGroupRow[];
}
export function PhaseGrid({ rows }: PhaseGridProps) {
return (
<Card title="Fazy AC" subtitle="Napiece, prady 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">
<div className="mb-4 text-sm font-semibold text-white">{row.label}</div>
<div className="grid gap-3">
<ValuePair metric={row.values.voltage} />
<ValuePair metric={row.values.current} />
<ValuePair metric={row.values.apparent_power} />
</div>
</div>
))}
</div>
</Card>
);
}

View File

@@ -0,0 +1,34 @@
import { Card } from "../common/Card";
import { ValuePair } from "../common/ValuePair";
import type { SnapshotGroupRow } from "../../types";
interface StringGridProps {
rows: SnapshotGroupRow[];
}
const slotOrder = ["power", "voltage"] as const;
export function StringGrid({ rows }: StringGridProps) {
return (
<Card title="Stringi DC" subtitle="Widok automatycznie skaluje sie do liczby stringow i dostepnych 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]);
return (
<div key={row.id} className="rounded-3xl border border-white/10 bg-slate-950/40 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="text-sm font-semibold text-white">{row.label}</div>
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">{row.id}</div>
</div>
<div className={`grid gap-3 ${visibleSlots.length > 1 ? "sm:grid-cols-2" : "sm:grid-cols-1"}`}>
{visibleSlots.map((slot) => (
<ValuePair key={slot} metric={row.values[slot]} />
))}
</div>
</div>
);
})}
</div>
</Card>
);
}

View File

@@ -0,0 +1,82 @@
import { Card } from "../common/Card";
import type { DashboardConfig } from "../../types";
import { HistoricalImportPanel } from "./HistoricalImportPanel";
interface ConfigPanelProps {
config: DashboardConfig;
}
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">
<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>
</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>
</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>
</div>
</Card>
<Card title="Konfiguracja deploymentu" subtitle="Najwazniejsze ustawienia od razu widoczne">
<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>
<dd className="mt-2 text-white">{config.app.site_name}</dd>
</div>
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<dt className="text-slate-500">Installed power</dt>
<dd className="mt-2 text-white">{config.app.installed_power_kwp} kWp</dd>
</div>
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<dt className="text-slate-500">Timezone</dt>
<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>
<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>
</div>
</dl>
</Card>
{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">
<div className="overflow-auto">
<table className="min-w-full text-left text-sm text-slate-300">
<thead>
<tr className="border-b border-white/10 text-slate-500">
<th className="px-3 py-3 font-medium">Metric</th>
<th className="px-3 py-3 font-medium">Label</th>
<th className="px-3 py-3 font-medium">Entity ID</th>
<th className="px-3 py-3 font-medium">Measurement</th>
<th className="px-3 py-3 font-medium">Unit</th>
</tr>
</thead>
<tbody>
{config.visible_entities.map((item) => (
<tr key={item.metric_id} className="border-b border-white/6 last:border-none">
<td className="px-3 py-3 text-white">{item.metric_id}</td>
<td className="px-3 py-3">{item.label}</td>
<td className="px-3 py-3 font-mono text-xs text-emerald-200">{item.entity_id}</td>
<td className="px-3 py-3">{item.measurement}</td>
<td className="px-3 py-3">{item.unit}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { useEffect, useMemo, useState } from "react";
import { useHistoricalImport } from "../../hooks";
import type { DashboardConfig, HistoricalActivityEvent, HistoricalChunkProgress } from "../../types";
import { Badge } from "../common/Badge";
import { Card } from "../common/Card";
import { formatDate, formatDateTime, formatDurationShort, formatPercent, formatValue } from "../../lib/format";
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 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>;
}
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>
);
}
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>
);
}
export function HistoricalImportPanel({ config }: HistoricalImportPanelProps) {
const { status, start, syncNow, cancel } = useHistoricalImport();
const payload = status.data;
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
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]);
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]);
const busy = start.isPending || syncNow.isPending || cancel.isPending;
const mutationError = start.error?.message || syncNow.error?.message || cancel.error?.message || null;
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`} />
</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>
</Card>
);
}

View File

@@ -0,0 +1,24 @@
import { Card } from "../common/Card";
interface FaultBannerProps {
faults: string[];
}
export function FaultBanner({ faults }: FaultBannerProps) {
if (!faults.length) {
return null;
}
return (
<Card className="border-rose-400/30 bg-rose-500/10">
<div className="flex flex-col gap-2">
<div className="text-xs uppercase tracking-[0.22em] text-rose-200">Alarm falownika</div>
{faults.map((fault) => (
<div key={fault} className="text-sm text-rose-100">
{fault}
</div>
))}
</div>
</Card>
);
}

187
frontend/src/demo/data.ts Normal file
View File

@@ -0,0 +1,187 @@
import type {
AnalyticsPayload,
DashboardConfig,
DistributionPayload,
HistoryPayload,
HistoricalStatus,
SnapshotPayload,
} from "../types";
function isoAt(offsetMinutes: number): string {
return new Date(Date.now() + offsetMinutes * 60 * 1000).toISOString();
}
export const demoConfig: DashboardConfig = {
app: {
name: "pv-insight",
version: "1.3.0",
site_name: "PV Insight / Sofar Demo",
timezone: "Europe/Warsaw",
installed_power_kwp: 9.86,
},
defaults: {
realtime_range: "6h",
analytics_range: "30d",
analytics_bucket: "day",
tab: "realtime",
theme: "dark",
language: "pl",
},
auth: { enabled: false },
i18n: { default_language: "pl", supported_languages: ["pl", "en"] },
capabilities: {
modules: { realtime: true, analytics: true, history: true },
strings_enabled: true,
strings_count: 2,
phases_enabled: false,
phases_count: 0,
analytics_enabled: true,
realtime_enabled: true,
comparison_modes: ["none", "previous_period", "previous_year"],
ranges: [
{ key: "6h", label: "6h" },
{ key: "24h", label: "24h" },
{ key: "1d", label: "1 dzień" },
{ key: "3d", label: "3 dni" },
{ key: "7d", label: "7 dni" },
{ key: "14d", label: "14 dni" },
{ key: "30d", label: "30 dni" },
{ key: "60d", label: "60 dni" },
{ key: "365d", label: "365 dni" },
{ key: "ytd", label: "YTD" },
],
buckets: [
{ key: "day", label: "Dzien" },
{ key: "week", label: "Tydzien" },
{ key: "month", label: "Miesiac" },
{ 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: "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_2_power", label: "Moc stringu DC2", entity_id: "sofarsolar_dc2_power", measurement: "W", unit: "W", kind: "gauge" },
{ metric_id: "string_2_voltage", label: "Napiecie stringu DC2", entity_id: "sofarsolar_dc2_voltage", measurement: "V", unit: "V", kind: "gauge" },
{ metric_id: "inverter_temperature", label: "Temperatura falownika", entity_id: "sofarsolar_temprature_inverter", measurement: "°C", unit: "°C", kind: "gauge" },
],
};
export const demoSnapshot = (): SnapshotPayload => ({
updated_at: new Date().toISOString(),
hero_cards: [
{ metric_id: "ac_power", label: "Produkcja AC", value: 6840, unit: "W", accent: "emerald", subtitle: "Aktualna moc oddawana przez falownik" },
{ metric_id: "energy_today", label: "Dzisiaj", value: 31.8, unit: "kWh", accent: "amber", subtitle: "Liczone z energy_total / fallback z AC power" },
{ metric_id: "dc1_power", label: "String DC1", value: 3450, unit: "W", accent: "emerald", subtitle: "Wschod" },
{ metric_id: "dc2_power", label: "String DC2", value: 3310, unit: "W", accent: "emerald", subtitle: "Zachod" },
{ metric_id: "inverter_temperature", label: "Temp. falownika", value: 47.3, unit: "°C", accent: "rose", subtitle: "Live status termiczny" },
],
kpis: {
energy_today: { metric_id: "energy_today", label: "Energia dzis", 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" },
},
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" } } },
],
phases: [],
status: [
{ metric_id: "inverter_temperature", label: "Temperatura falownika", unit: "°C", value: 47.3, precision: 1, kind: "gauge", status: "ok" },
{ metric_id: "data_freshness", label: "Swiezosc danych", unit: "", value: "3 s temu", precision: 0, kind: "text", status: "ok" },
],
faults: [],
});
function historyPoints(values: number[]) {
return values.map((value, index) => ({ timestamp: isoAt(-(values.length - index) * 5), value }));
}
export const demoHistory: HistoryPayload = {
range_key: "6h",
start: isoAt(-360),
end: isoAt(0),
series: [
{ metric_id: "ac_power", label: "Moc AC", unit: "W", points: historyPoints([0, 120, 860, 1840, 2760, 3920, 5180, 6020, 6840, 6500, 5710, 4980]) },
{ metric_id: "dc1_power", label: "DC1", unit: "W", points: historyPoints([0, 80, 620, 1320, 2140, 2860, 3250, 3490, 3450, 3300, 2920, 2480]) },
{ metric_id: "dc2_power", label: "DC2", unit: "W", points: historyPoints([0, 40, 240, 520, 880, 1260, 1930, 2530, 3310, 3200, 2790, 2410]) },
{ metric_id: "inverter_temperature", label: "Temp. falownika", unit: "°C", points: historyPoints([22, 24, 27, 31, 35, 39, 42, 45, 47.3, 46.8, 44.1, 41.2]) },
],
};
const currentBars = [18.3, 22.1, 25.7, 21.9, 23.2, 27.6, 30.1, 28.4, 19.8, 16.2, 24.4, 31.8].map((value, index) => ({ label: `${index + 1} mar`, start: isoAt(-(12 - index) * 1440), end: isoAt(-(11 - index) * 1440), value }));
const comparisonBars = [16.1, 19.2, 22.4, 19.5, 20.2, 23.4, 26.8, 25.1, 17.9, 15.4, 21.6, 27.3].map((value, index) => ({ label: `${index + 1} mar`, start: isoAt(-(377 - index) * 1440), end: isoAt(-(376 - index) * 1440), value }));
export const demoAnalytics: AnalyticsPayload = {
unit: "kWh",
bucket: "day",
compare_mode: "previous_year",
current: currentBars,
comparison: comparisonBars,
summary: { total: 289.5, unit: "kWh", average_bucket: 24.1, best_bucket_label: "12 mar", best_bucket_value: 31.8, co2_saved_kg: 230.1, comparison_total: 255, comparison_delta_pct: 13.5 },
meta: { window: { start: isoAt(-30 * 1440), end: isoAt(0), range_key: "30d" }, source: "sqlite+influx" },
};
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 } ],
};
export const demoHistoricalStatus: HistoricalStatus = {
enabled: true,
running: true,
state: "running",
job_id: "hist-9f31ab",
started_at: isoAt(-18),
finished_at: null,
requested_start_date: "2022-01-01",
requested_end_date: "2025-12-31",
total_days: 1461,
processed_days: 1096,
imported_days: 1028,
skipped_days: 68,
chunk_days: 7,
total_chunks: 209,
active_chunk_index: 157,
current_date: "2025-01-08",
current_chunk_start: "2025-01-05",
current_chunk_end: "2025-01-11",
elapsed_seconds: 1080,
estimated_remaining_seconds: 360,
avg_days_per_minute: 60.89,
last_error: null,
message: "Przetwarzanie zakresu 2025-01-05 -> 2025-01-11",
coverage: { imported_days: 1028, first_day: "2022-01-01", last_day: "2025-01-04", total_energy_kwh: 18264.3, available_days: 1461, missing_days: 433, coverage_pct: 70.4 },
available_start_date: "2022-01-01",
available_end_date: "2025-12-31",
default_chunk_days: 7,
recent_chunks: [
{ chunk_index: 154, total_chunks: 209, start_date: "2024-12-15", end_date: "2024-12-21", processed_days: 7, imported_days: 7, skipped_days: 0, energy_kwh: 96.3, state: "completed", started_at: isoAt(-9.8), finished_at: isoAt(-9.1), duration_seconds: 42, note: "Chunk zakonczony: import 7, pominiete 0" },
{ chunk_index: 155, total_chunks: 209, start_date: "2024-12-22", end_date: "2024-12-28", processed_days: 7, imported_days: 7, skipped_days: 0, energy_kwh: 88.7, state: "completed", started_at: isoAt(-9.0), finished_at: isoAt(-8.2), duration_seconds: 46, note: "Chunk zakonczony: import 7, pominiete 0" },
{ chunk_index: 156, total_chunks: 209, start_date: "2024-12-29", end_date: "2025-01-04", processed_days: 7, imported_days: 7, skipped_days: 0, energy_kwh: 92.6, state: "completed", started_at: isoAt(-8.1), finished_at: isoAt(-7.4), duration_seconds: 44, note: "Chunk zakonczony: import 7, pominiete 0" },
{ 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(-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 },
],
};
export const demoAuthStatus = {
enabled: false,
authenticated: true,
user: "demo",
display_name: "Demo Operator",
};

View File

@@ -0,0 +1,5 @@
export * from "./useAnalytics";
export * from "./useDashboardConfig";
export * from "./useHistoricalImport";
export * from "./useRealtimeHistory";
export * from "./useRealtimeSocket";

View File

@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../api/client";
export function useAnalytics(
rangeKey: string,
bucket: string,
compare: string,
enabled = true,
options?: { start?: string; end?: string; publicKiosk?: boolean; compareRanges?: Array<{ start: string; end: string; label: string; key?: string }> },
) {
const production = useQuery({
queryKey: ["analytics", rangeKey, bucket, compare, options?.start, options?.end, options?.publicKiosk, JSON.stringify(options?.compareRanges ?? [])],
queryFn: () => api.getAnalytics(rangeKey, bucket, compare, options),
staleTime: 60 * 1000,
enabled,
});
const distribution = useQuery({
queryKey: ["distribution", rangeKey, bucket, options?.start, options?.end, options?.publicKiosk],
queryFn: () => api.getDistribution(rangeKey, bucket, options),
staleTime: 60 * 1000,
enabled,
});
return { production, distribution };
}

View File

@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../api/client";
export function useDashboardConfig(enabled = true) {
return useQuery({
queryKey: ["dashboard-config"],
queryFn: api.getConfig,
staleTime: 5 * 60 * 1000,
enabled,
});
}

View File

@@ -0,0 +1,40 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/client";
import type { HistoricalStartPayload } from "../types";
export function useHistoricalImport(enabled = true) {
const queryClient = useQueryClient();
const status = useQuery({
queryKey: ["historical-status"],
queryFn: api.getHistoricalStatus,
staleTime: 5 * 1000,
enabled,
refetchInterval: (query) => (query.state.data?.running ? 3000 : 15000),
});
const start = useMutation({
mutationFn: (payload: HistoricalStartPayload) => api.startHistoricalImport(payload),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["historical-status"] });
await queryClient.invalidateQueries({ queryKey: ["analytics"] });
await queryClient.invalidateQueries({ queryKey: ["distribution"] });
},
});
const syncNow = useMutation({
mutationFn: api.syncHistoricalNow,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["historical-status"] });
},
});
const cancel = useMutation({
mutationFn: api.cancelHistoricalImport,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["historical-status"] });
},
});
return { status, start, syncNow, cancel };
}

View File

@@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../api/client";
export function useRealtimeHistory(
rangeKey: string,
enabled = true,
options?: { start?: string; end?: string; metrics?: string[]; publicKiosk?: boolean },
) {
return useQuery({
queryKey: ["realtime-history", rangeKey, options?.start, options?.end, options?.metrics?.join(","), options?.publicKiosk],
queryFn: () => api.getRealtimeHistory(rangeKey, options),
staleTime: 20 * 1000,
refetchInterval: options?.start || options?.end ? false : 30 * 1000,
enabled,
});
}

View File

@@ -0,0 +1,66 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "../api/client";
import type { SnapshotPayload } from "../types";
const EMPTY_SNAPSHOT: SnapshotPayload = {
hero_cards: [],
kpis: {},
strings: [],
phases: [],
status: [],
faults: [],
};
const POLL_MS = Number(import.meta.env.VITE_LIVE_POLL_MS ?? 8000);
export function useRealtimeSocket(enabled = true) {
const [snapshot, setSnapshot] = useState<SnapshotPayload>(EMPTY_SNAPSHOT);
const [connected, setConnected] = useState(false);
useEffect(() => {
if (!enabled) {
setSnapshot(EMPTY_SNAPSHOT);
setConnected(false);
return;
}
let isActive = true;
let timer: number | null = null;
const poll = async () => {
try {
const data = await api.getRealtimeSnapshot();
if (!isActive) {
return;
}
setSnapshot(data);
setConnected(true);
} catch {
if (isActive) {
setConnected(false);
}
} finally {
if (isActive) {
timer = window.setTimeout(poll, POLL_MS);
}
}
};
void poll();
return () => {
isActive = false;
if (timer !== null) {
window.clearTimeout(timer);
}
};
}, [enabled]);
const lastUpdated = useMemo(() => snapshot.updated_at ?? null, [snapshot.updated_at]);
return {
snapshot,
connected,
lastUpdated,
};
}

270
frontend/src/i18n.ts Normal file
View File

@@ -0,0 +1,270 @@
export type Language = "pl" | "en";
const messages = {
pl: {
live: "Live",
analytics: "Analityka",
settings: "Ustawienia",
kiosk: "Kiosk",
operatorPanel: "Panel operatora",
poweredBy: "Tabler UI",
connected: "Połączono",
disconnected: "Brak połączenia",
updatedAt: "Aktualizacja",
loginTitle: "Logowanie",
loginSubtitle: "Minitoring fotowoltaiki",
username: "Login",
password: "Hasło",
signIn: "Zaloguj",
signOut: "Wyloguj",
theme: "Motyw",
light: "Jasny",
dark: "Ciemny",
language: "Język",
polish: "Polski",
english: "English",
openKiosk: "Otwórz kiosk",
exitKiosk: "Wyjdź z kiosku",
fullscreen: "Pełny ekran",
demoMode: "Tryb demo",
realtimeOverview: "Przegląd na żywo",
realtimeSubtitle: "Najważniejsze parametry falownika i stringów",
analyticsOverview: "Produkcja długoterminowa",
analyticsSubtitle: "Dzień / tydzień / miesiąc / rok i porównania okresów",
settingsSubtitle: "Import archiwum, wygląd, kiosk, bezpieczeństwo",
noData: "Brak danych",
noDataDescription: "Brak odpowiedzi z backendu lub InfluxDB.",
chartPowerHistory: "Historia mocy i temperatury",
chartProduction: "Produkcja",
chartProductionSubtitle: "Agregacja w wybranym bucketcie",
chartComparison: "Porównanie okresów",
chartDistribution: "Rozkład produkcji",
currentPeriod: "Bieżący okres",
comparisonPeriod: "Porównanie",
summaryTotal: "Suma",
summaryAverage: "Średnia / bucket",
summaryBest: "Najlepszy bucket",
summaryCo2: "Oszczędzone CO₂",
compareNone: "Bez porównania",
comparePreviousPeriod: "Poprzedni okres",
comparePreviousYear: "Poprzedni rok",
comparePreviousYear2: "2 lata wstecz",
comparePreviousYear3: "3 lata wstecz",
comparePreviousMonth12: "12 miesięcy wstecz",
comparePreviousMonth24: "24 miesiące wstecz",
compareCustomMulti: "Własne zakresy",
range: "Zakres",
bucket: "Bucket",
liveRange: "Zakres live",
systemStatus: "Status systemu",
strings: "Stringi DC",
quickMetrics: "Szybkie metryki",
importArchive: "Import archiwalny z InfluxDB",
importArchiveSubtitle: "Backfill chunkami i auto-sync brakujących dni",
startDate: "Data od",
endDate: "Data do",
chunkDays: "Chunk (dni)",
startImport: "Start importu",
syncMissing: "Synchronizuj brakujące",
cancel: "Anuluj",
status: "Status",
coverage: "Pokrycie",
importedDays: "Zaimportowane dni",
missingDays: "Brakujące dni",
throughput: "Przepustowość",
eta: "ETA",
activeChunk: "Aktywny chunk",
recentChunks: "Ostatnie chunki",
recentEvents: "Ostatnie zdarzenia",
kioskLayout: "Układ kiosku",
kioskLayoutSubtitle: "Wybierz widżety i kolejność widoku kiosku",
saveLayout: "Układ zapisuje się automatycznie",
kioskHint: "Tryb kiosku.",
widgetSelect: "Widżety",
moveUp: "W górę",
moveDown: "W dół",
selected: "Wybrane",
available: "Dostępne",
security: "Bezpieczeństwo",
authEnabled: "Logowanie aktywne",
authDisabled: "Logowanie wyłączone",
changePasswordHint: "Zmiana loginu i hasła odbywa się przez .env backendu.",
authRequired: "Wymagane logowanie",
loginError: "Nie udało się zalogować.",
loading: "Ładowanie",
inverterTemp: "Temperatura falownika",
acPower: "Moc AC",
energyTotal: "Energia łączna",
energyToday: "Energia dziś",
energyYesterday: "Energia wczoraj",
todayVsYesterday: "Dziś vs wczoraj",
dcPowerTotal: "Moc DC łącznie",
unknown: "Nieznane",
yes: "Tak",
no: "Nie",
setFullHistory: "Ustaw pełną historię",
viewMode: "Tryb widoku",
normalMode: "Normalny",
kioskMode: "Kiosk",
simplifiedCards: "Karty uproszczone",
},
en: {
live: "Live",
analytics: "Analytics",
settings: "Settings",
kiosk: "Kiosk",
operatorPanel: "Operator panel",
poweredBy: "Tabler UI",
connected: "Connected",
disconnected: "Disconnected",
updatedAt: "Updated",
loginTitle: "Sign in",
loginSubtitle: "PV monitoring",
username: "Username",
password: "Password",
signIn: "Sign in",
signOut: "Sign out",
theme: "Theme",
light: "Light",
dark: "Dark",
language: "Language",
polish: "Polski",
english: "English",
openKiosk: "Open kiosk",
exitKiosk: "Exit kiosk",
fullscreen: "Fullscreen",
demoMode: "Demo mode",
realtimeOverview: "Live overview",
realtimeSubtitle: "Key inverter and string metrics",
analyticsOverview: "Long-term production",
analyticsSubtitle: "Day / week / month / year and period comparisons",
settingsSubtitle: "Archive import, appearance, kiosk, security",
noData: "No data",
noDataDescription: "No response from backend or InfluxDB.",
chartPowerHistory: "Power and temperature history",
chartProduction: "Production",
chartProductionSubtitle: "Aggregated by selected bucket",
chartComparison: "Period comparison",
chartDistribution: "Production distribution",
currentPeriod: "Current period",
comparisonPeriod: "Comparison",
summaryTotal: "Total",
summaryAverage: "Average / bucket",
summaryBest: "Best bucket",
summaryCo2: "CO₂ saved",
compareNone: "No comparison",
comparePreviousPeriod: "Previous period",
comparePreviousYear: "Previous year",
comparePreviousYear2: "2 years back",
comparePreviousYear3: "3 years back",
comparePreviousMonth12: "12 months back",
comparePreviousMonth24: "24 months back",
compareCustomMulti: "Custom ranges",
range: "Range",
bucket: "Bucket",
liveRange: "Live range",
systemStatus: "System status",
strings: "DC strings",
quickMetrics: "Quick metrics",
importArchive: "Historical import from InfluxDB",
importArchiveSubtitle: "Chunked backfill and auto-sync for missing days",
startDate: "Start date",
endDate: "End date",
chunkDays: "Chunk (days)",
startImport: "Start import",
syncMissing: "Sync missing",
cancel: "Cancel",
status: "Status",
coverage: "Coverage",
importedDays: "Imported days",
missingDays: "Missing days",
throughput: "Throughput",
eta: "ETA",
activeChunk: "Active chunk",
recentChunks: "Recent chunks",
recentEvents: "Recent events",
kioskLayout: "Kiosk layout",
kioskLayoutSubtitle: "Choose widgets and their order for kiosk view",
saveLayout: "Layout is saved automatically",
kioskHint: "Kiosk mode.",
widgetSelect: "Widgets",
moveUp: "Move up",
moveDown: "Move down",
selected: "Selected",
available: "Available",
security: "Security",
authEnabled: "Login enabled",
authDisabled: "Login disabled",
changePasswordHint: "Change username and password in backend .env.",
authRequired: "Authentication required",
loginError: "Sign in failed.",
loading: "Loading",
inverterTemp: "Inverter temperature",
acPower: "AC power",
energyTotal: "Total energy",
energyToday: "Energy today",
energyYesterday: "Energy yesterday",
todayVsYesterday: "Today vs yesterday",
dcPowerTotal: "Total DC power",
unknown: "Unknown",
yes: "Yes",
no: "No",
setFullHistory: "Use full history",
viewMode: "View mode",
normalMode: "Normal",
kioskMode: "Kiosk",
simplifiedCards: "Simplified cards",
},
} as const;
export type MessageKey = keyof typeof messages.pl;
const metricLabels: Record<string, { pl: string; en: string }> = {
ac_power: { pl: "Moc AC", en: "AC power" },
energy_total: { pl: "Energia łączna", en: "Total energy" },
energy_today: { pl: "Energia dziś", en: "Energy today" },
energy_yesterday: { pl: "Energia wczoraj", en: "Energy yesterday" },
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" },
};
export function t(language: Language, key: MessageKey): string {
return messages[language][key] ?? messages.pl[key];
}
export function labelForMetric(language: Language, metricId: string, fallback: string): string {
return metricLabels[metricId]?.[language] ?? fallback;
}
export function localeForLanguage(language: Language): string {
return language === "en" ? "en-GB" : "pl-PL";
}
export function normalizeLanguage(value: string | null | undefined): Language {
return value === "en" ? "en" : "pl";
}
export function translateCompareMode(language: Language, mode: string): string {
switch (mode) {
case "none":
return language === "en" ? "Comparison" : "Porównanie";
case "previous_period":
return t(language, "comparePreviousPeriod");
case "previous_year":
return t(language, "comparePreviousYear");
case "previous_year_2":
return t(language, "comparePreviousYear2");
case "previous_year_3":
return t(language, "comparePreviousYear3");
case "previous_month_12":
return t(language, "comparePreviousMonth12");
case "previous_month_24":
return t(language, "comparePreviousMonth24");
case "custom_multi":
return t(language, "compareCustomMulti");
default:
return mode;
}
}

148
frontend/src/index.css Normal file
View File

@@ -0,0 +1,148 @@
:root {
--tblr-border-radius: 0.55rem;
--tblr-border-radius-lg: 0.7rem;
--tblr-border-radius-sm: 0.4rem;
--tblr-card-border-radius: 0.7rem;
--tblr-shadow-sm: 0 0.125rem 0.25rem rgba(15, 23, 42, 0.05);
--pv-shell-bg: #f4f6f9;
--pv-card-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 12px 24px rgba(15, 23, 42, 0.04);
}
[data-bs-theme="dark"] {
--pv-shell-bg: #0b1220;
--pv-card-shadow: 0 1px 2px rgba(0, 0, 0, 0.28), 0 14px 30px rgba(0, 0, 0, 0.24);
}
html,
body,
#root {
min-height: 100%;
}
body {
background: var(--pv-shell-bg);
}
.page {
min-height: 100vh;
}
.page-body {
padding-top: 1rem;
padding-bottom: 1.5rem;
}
.pv-navbar,
.pv-subnav {
backdrop-filter: blur(14px);
}
.pv-card,
.login-card {
border-width: 1px;
box-shadow: var(--pv-card-shadow);
}
.pv-card .card-header,
.pv-card .card-body,
.login-card .card-body {
padding: 1rem 1rem;
}
.pv-hero-card .display-6 {
letter-spacing: -0.03em;
}
.pv-chart {
width: 100%;
height: 340px;
}
.pv-chart-sm {
width: 100%;
height: 280px;
}
.status-row,
.string-panel {
background: rgba(127, 127, 127, 0.03);
}
.kiosk-shell {
background:
radial-gradient(circle at top right, rgba(32, 107, 196, 0.08), transparent 30%),
var(--pv-shell-bg);
}
.login-page-shell {
background:
radial-gradient(circle at top, rgba(32, 107, 196, 0.12), transparent 28%),
var(--pv-shell-bg);
}
.btn-group > .btn,
.form-control,
.card,
.border,
.progress,
.badge,
.alert {
border-radius: 0.65rem !important;
}
.card-table tbody tr:last-child td {
border-bottom-width: 0;
}
.nav-link.active {
font-weight: 600;
}
.table-responsive {
overflow-x: auto;
}
@media (max-width: 768px) {
.pv-chart,
.pv-chart-sm {
height: 260px;
}
.page-body {
padding-top: 0.75rem;
}
}
.pv-nav-link {
display: inline-flex !important;
align-items: center;
justify-content: center;
gap: 0.6rem;
min-height: 2.75rem;
padding: 0.7rem 1rem !important;
}
.pv-nav-icon {
width: 1.25rem;
min-width: 1.25rem;
height: 1.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 1.25rem;
line-height: 1;
}
.pv-nav-icon svg {
display: block;
width: 1.1rem;
height: 1.1rem;
}
.pv-nav-title {
display: inline-flex;
align-items: center;
line-height: 1.1;
white-space: nowrap;
}

View File

@@ -0,0 +1,81 @@
export function formatValue(
value: number | string | null | undefined,
unit = "",
precision = 2,
locale = "pl-PL",
): string {
if (value === null || value === undefined || value === "") {
return "--";
}
if (typeof value === "number") {
return `${value.toLocaleString(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: precision,
})}${unit ? ` ${unit}` : ""}`;
}
return unit ? `${value} ${unit}` : String(value);
}
export function formatDateTime(value?: string | null, locale = "pl-PL"): string {
if (!value) {
return "--";
}
return new Date(value).toLocaleString(locale, {
dateStyle: "short",
timeStyle: "short",
});
}
export function formatDate(value?: string | null, locale = "pl-PL"): string {
if (!value) {
return "--";
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return parsed.toLocaleDateString(locale);
}
export function formatShortTime(value?: string | null, locale = "pl-PL"): string {
if (!value) {
return "--";
}
return new Date(value).toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
});
}
export function formatDurationShort(value?: number | null, locale = "pl-PL"): string {
if (value === null || value === undefined || Number.isNaN(value)) {
return "--";
}
const totalSeconds = Math.max(Math.round(value), 0);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (locale.startsWith("pl")) {
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
export function formatPercent(value?: number | null, precision = 1, locale = "pl-PL"): string {
if (value === null || value === undefined || Number.isNaN(value)) {
return "--";
}
return `${value.toLocaleString(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: precision,
})}%`;
}

15
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);

275
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,275 @@
export type StatusTone = "ok" | "warn" | "critical" | "neutral";
export interface HeroCard {
metric_id: string;
label: string;
value: number | string | null;
unit: string;
accent: string;
subtitle: string;
}
export interface MetricValue {
metric_id: string;
label: string;
unit: string;
value: number | string | null;
timestamp?: string | null;
precision: number;
kind: "gauge" | "counter" | "text";
status: StatusTone;
}
export interface SnapshotGroupRow {
id: string;
label: string;
values: Record<string, MetricValue>;
meta: Record<string, number | string>;
}
export interface SnapshotPayload {
updated_at?: string | null;
hero_cards: HeroCard[];
kpis: Record<string, MetricValue>;
strings: SnapshotGroupRow[];
phases: SnapshotGroupRow[];
status: MetricValue[];
faults: string[];
}
export interface SeriesPoint {
timestamp: string;
value: number | null;
}
export interface SeriesPayload {
metric_id: string;
label: string;
unit: string;
color?: string | null;
points: SeriesPoint[];
}
export interface HistoryPayload {
range_key: string;
start: string;
end: string;
series: SeriesPayload[];
}
export interface BucketPoint {
label: string;
start: string;
end: string;
value: number;
}
export interface AnalyticsSummary {
total: number;
unit: string;
average_bucket: number;
best_bucket_label: string;
best_bucket_value: number;
co2_saved_kg: number;
comparison_total?: number | null;
comparison_delta_pct?: number | null;
}
export interface AnalyticsPayload {
unit: string;
bucket: string;
compare_mode: string;
current: BucketPoint[];
comparison: BucketPoint[];
comparisons?: Array<{
key: string;
label: string;
start: string;
end: string;
total: number;
delta_pct?: number | null;
points: BucketPoint[];
}>;
summary: AnalyticsSummary;
meta: {
window: {
start: string;
end: string;
range_key: string;
};
source?: string;
};
}
export interface DistributionSlice {
label: string;
value: number;
share: number;
}
export interface DistributionPayload {
unit: string;
bucket: string;
total: number;
slices: DistributionSlice[];
}
export interface HistoricalCoverage {
imported_days: number;
first_day?: string | null;
last_day?: string | null;
total_energy_kwh: number;
available_days: number;
missing_days: number;
coverage_pct?: number | null;
}
export interface HistoricalChunkProgress {
chunk_index: number;
total_chunks: number;
start_date: string;
end_date: string;
processed_days: number;
imported_days: number;
skipped_days: number;
energy_kwh: number;
state: string;
started_at?: string | null;
finished_at?: string | null;
duration_seconds?: number | null;
note: string;
}
export interface HistoricalActivityEvent {
timestamp: string;
level: string;
title: string;
message: string;
day?: string | null;
chunk_index?: number | null;
}
export interface HistoricalStatus {
enabled: boolean;
running: boolean;
state: string;
job_id?: string | null;
started_at?: string | null;
finished_at?: string | null;
requested_start_date?: string | null;
requested_end_date?: string | null;
total_days: number;
processed_days: number;
imported_days: number;
skipped_days: number;
chunk_days: number;
total_chunks: number;
active_chunk_index: number;
current_date?: string | null;
current_chunk_start?: string | null;
current_chunk_end?: string | null;
elapsed_seconds?: number | null;
estimated_remaining_seconds?: number | null;
avg_days_per_minute?: number | null;
last_error?: string | null;
message: string;
coverage: HistoricalCoverage;
available_start_date?: string | null;
available_end_date?: string | null;
default_chunk_days: number;
recent_chunks: HistoricalChunkProgress[];
recent_events: HistoricalActivityEvent[];
}
export interface HistoricalStartPayload {
start_date?: string;
end_date?: string;
chunk_days?: number;
force?: boolean;
}
export interface AuthStatus {
enabled: boolean;
authenticated: boolean;
user?: string | null;
display_name?: string | null;
role?: string | null;
}
export interface DashboardConfig {
app: {
name: string;
version: string;
site_name: string;
timezone: string;
installed_power_kwp: number;
};
defaults: {
realtime_range: string;
analytics_range: string;
analytics_bucket: string;
tab: string;
theme: string;
language: string;
};
auth?: {
enabled: boolean;
};
i18n?: {
default_language: string;
supported_languages: string[];
};
capabilities: {
modules: Record<string, boolean>;
strings_enabled: boolean;
strings_count: number;
phases_enabled: boolean;
phases_count: number;
analytics_enabled: boolean;
realtime_enabled: boolean;
comparison_modes: string[];
ranges: Array<{ key: string; label: string }>;
buckets: Array<{ key: string; label: string }>;
historical_import_enabled: boolean;
history: {
enabled: boolean;
default_chunk_days: number;
auto_sync_enabled: boolean;
auto_sync_interval_minutes: number;
};
};
visible_entities: Array<{
metric_id: string;
label: string;
entity_id: string;
measurement: string;
unit: string;
kind: string;
}>;
}
export interface AuthUserItem {
username: string;
display_name: string;
role: string;
is_active: boolean;
created_at?: string | null;
updated_at?: string | null;
}
export interface AuthUsersPayload {
items: AuthUserItem[];
}
export interface KioskSettingsPayload {
mode: "public" | "private";
widgets: string[];
realtime_range: string;
analytics_range: string;
analytics_bucket: string;
compare_mode: string;
updated_at?: string | null;
updated_by?: string | null;
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

19
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"noEmit": true
},
"include": ["src"]
}

15
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
host: "0.0.0.0",
port: 5173
},
preview: {
host: "0.0.0.0",
port: 4173
}
});

18
scripts/dev.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cleanup() {
if [ -n "${BACKEND_PID:-}" ]; then
kill "$BACKEND_PID" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT INT TERM
"$ROOT_DIR/scripts/dev_backend.sh" &
BACKEND_PID=$!
sleep 2
"$ROOT_DIR/scripts/dev_frontend.sh"

21
scripts/dev_backend.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BACKEND_DIR="$ROOT_DIR/backend"
PYTHON_BIN="${PYTHON_BIN:-python3.14}"
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
PYTHON_BIN="${PYTHON_FALLBACK_BIN:-python3}"
fi
cd "$BACKEND_DIR"
if [ ! -d ".venv" ]; then
"$PYTHON_BIN" -m venv .venv
fi
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python run.py

17
scripts/dev_frontend.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FRONTEND_DIR="$ROOT_DIR/frontend"
cd "$FRONTEND_DIR"
if [ ! -d "node_modules" ]; then
if [ -f "package-lock.json" ]; then
npm ci
else
npm install
fi
fi
npm run dev -- --host 0.0.0.0

11
scripts/dev_frontend_demo.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/../frontend"
if [ ! -d "node_modules" ]; then
if [ -f "package-lock.json" ]; then
npm ci
else
npm install
fi
fi
VITE_DEMO_MODE=true npm run dev -- --host 0.0.0.0

Some files were not shown because too many files have changed in this diff Show More