first commit
This commit is contained in:
73
.env.example
Normal file
73
.env.example
Normal 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
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.venv
|
||||||
|
node_modules
|
||||||
|
*.sqlite3
|
||||||
|
*.zip
|
||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
.env
|
||||||
|
frontend/dist/*
|
||||||
169
README.md
Normal file
169
README.md
Normal 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
13
backend/.dockerignore
Normal 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
15
backend/Dockerfile
Normal 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
12
backend/Dockerfile.dev
Normal 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
3
backend/app/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from app.main import app
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
193
backend/app/app_factory.py
Normal file
193
backend/app/app_factory.py
Normal 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
|
||||||
71
backend/app/core_settings.py
Normal file
71
backend/app/core_settings.py
Normal 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
5
backend/app/main.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.app_factory import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
33
backend/app/models/__init__.py
Normal file
33
backend/app/models/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
174
backend/app/models/definitions.py
Normal file
174
backend/app/models/definitions.py
Normal 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)
|
||||||
15
backend/app/routes/__init__.py
Normal file
15
backend/app/routes/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
63
backend/app/routes/analytics.py
Normal file
63
backend/app/routes/analytics.py
Normal 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
|
||||||
80
backend/app/routes/auth.py
Normal file
80
backend/app/routes/auth.py
Normal 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
|
||||||
80
backend/app/routes/dashboard.py
Normal file
80
backend/app/routes/dashboard.py
Normal 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
|
||||||
17
backend/app/routes/health.py
Normal file
17
backend/app/routes/health.py
Normal 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,
|
||||||
|
})
|
||||||
54
backend/app/routes/historical.py
Normal file
54
backend/app/routes/historical.py
Normal 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)
|
||||||
26
backend/app/routes/realtime.py
Normal file
26
backend/app/routes/realtime.py
Normal 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
|
||||||
5
backend/app/services/__init__.py
Normal file
5
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from .analytics import AnalyticsService
|
||||||
|
from .historical_sync import HistoricalSyncService
|
||||||
|
from .realtime import RealtimeService
|
||||||
|
|
||||||
|
__all__ = ["AnalyticsService", "HistoricalSyncService", "RealtimeService"]
|
||||||
140
backend/app/services/analytics.py
Normal file
140
backend/app/services/analytics.py
Normal 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"],
|
||||||
|
}
|
||||||
179
backend/app/services/auth.py
Normal file
179
backend/app/services/auth.py
Normal 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
|
||||||
43
backend/app/services/capabilities.py
Normal file
43
backend/app/services/capabilities.py
Normal 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),
|
||||||
|
},
|
||||||
|
}
|
||||||
27
backend/app/services/catalog.py
Normal file
27
backend/app/services/catalog.py
Normal 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())
|
||||||
220
backend/app/services/energy.py
Normal file
220
backend/app/services/energy.py
Normal 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
|
||||||
605
backend/app/services/historical_sync.py
Normal file
605
backend/app/services/historical_sync.py
Normal 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()
|
||||||
241
backend/app/services/influx_http.py
Normal file
241
backend/app/services/influx_http.py
Normal 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
|
||||||
123
backend/app/services/kiosk_settings.py
Normal file
123
backend/app/services/kiosk_settings.py
Normal 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
|
||||||
99
backend/app/services/metrics.py
Normal file
99
backend/app/services/metrics.py
Normal 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,
|
||||||
|
)
|
||||||
231
backend/app/services/realtime.py
Normal file
231
backend/app/services/realtime.py
Normal 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
|
||||||
4
backend/app/storage/__init__.py
Normal file
4
backend/app/storage/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .sqlite_repository import SQLiteEnergyRepository
|
||||||
|
from .auth_users import AuthUser, SQLiteAuthUserRepository
|
||||||
|
|
||||||
|
__all__ = ["SQLiteEnergyRepository", "AuthUser", "SQLiteAuthUserRepository"]
|
||||||
132
backend/app/storage/auth_users.py
Normal file
132
backend/app/storage/auth_users.py
Normal 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
|
||||||
|
]
|
||||||
87
backend/app/storage/kiosk_settings.py
Normal file
87
backend/app/storage/kiosk_settings.py
Normal 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}
|
||||||
131
backend/app/storage/sqlite_repository.py
Normal file
131
backend/app/storage/sqlite_repository.py
Normal 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
|
||||||
25
backend/app/utils/__init__.py
Normal file
25
backend/app/utils/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
19
backend/app/utils/serialization.py
Normal file
19
backend/app/utils/serialization.py
Normal 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
156
backend/app/utils/time.py
Normal 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
24
backend/backfill.py
Normal 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
289
backend/config.py
Normal 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
2
backend/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Flask>=3.1,<4
|
||||||
|
waitress>=3.0.2,<4
|
||||||
15
backend/run.py
Normal file
15
backend/run.py
Normal 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
18
backend/run_prod.py
Normal 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
23
deploy/nginx/default.conf
Normal 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
27
docker-compose.dev.yml
Normal 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
36
docker-compose.prod.yml
Normal 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
25
docker-compose.yml
Normal 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
4
frontend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal 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
13
frontend/Dockerfile.dev
Normal 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
14
frontend/index.html
Normal 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
30
frontend/nginx.conf
Normal 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
2553
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
183
frontend/public/vendor/tabler/LICENSE.txt
vendored
Normal file
183
frontend/public/vendor/tabler/LICENSE.txt
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
frontend/public/vendor/tabler/tabler.min.css
vendored
Normal file
9
frontend/public/vendor/tabler/tabler.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
13
frontend/public/vendor/tabler/tabler.min.js
vendored
Normal file
13
frontend/public/vendor/tabler/tabler.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
501
frontend/src/App.tsx
Normal file
501
frontend/src/App.tsx
Normal 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
433
frontend/src/App.tsx.bak
Normal 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
145
frontend/src/api/client.ts
Normal 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;
|
||||||
|
};
|
||||||
66
frontend/src/components/analytics/ComparisonChart.tsx
Normal file
66
frontend/src/components/analytics/ComparisonChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/components/analytics/DistributionPieChart.tsx
Normal file
51
frontend/src/components/analytics/DistributionPieChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
frontend/src/components/analytics/PeriodControls.tsx
Normal file
76
frontend/src/components/analytics/PeriodControls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend/src/components/analytics/ProductionBarChart.tsx
Normal file
56
frontend/src/components/analytics/ProductionBarChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
frontend/src/components/analytics/SummaryCards.tsx
Normal file
34
frontend/src/components/analytics/SummaryCards.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/src/components/common/Badge.tsx
Normal file
21
frontend/src/components/common/Badge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
frontend/src/components/common/Card.tsx
Normal file
31
frontend/src/components/common/Card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
frontend/src/components/common/EChart.tsx
Normal file
31
frontend/src/components/common/EChart.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
13
frontend/src/components/common/EmptyState.tsx
Normal file
13
frontend/src/components/common/EmptyState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
frontend/src/components/common/Icons.tsx
Normal file
84
frontend/src/components/common/Icons.tsx
Normal 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>;
|
||||||
|
}
|
||||||
17
frontend/src/components/common/ValuePair.tsx
Normal file
17
frontend/src/components/common/ValuePair.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/src/components/layout/AppShell.tsx
Normal file
16
frontend/src/components/layout/AppShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend/src/components/layout/TopNav.tsx
Normal file
56
frontend/src/components/layout/TopNav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/components/realtime/HeroKpiGrid.tsx
Normal file
32
frontend/src/components/realtime/HeroKpiGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/components/realtime/KpiStrip.tsx
Normal file
35
frontend/src/components/realtime/KpiStrip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
frontend/src/components/realtime/LiveHistoryChart.tsx
Normal file
64
frontend/src/components/realtime/LiveHistoryChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/components/realtime/LiveStatusBoard.tsx
Normal file
30
frontend/src/components/realtime/LiveStatusBoard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/src/components/realtime/PhaseGrid.tsx
Normal file
26
frontend/src/components/realtime/PhaseGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
frontend/src/components/realtime/StringGrid.tsx
Normal file
34
frontend/src/components/realtime/StringGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
frontend/src/components/settings/ConfigPanel.tsx
Normal file
82
frontend/src/components/settings/ConfigPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
frontend/src/components/settings/HistoricalImportPanel.tsx
Normal file
74
frontend/src/components/settings/HistoricalImportPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
frontend/src/components/status/FaultBanner.tsx
Normal file
24
frontend/src/components/status/FaultBanner.tsx
Normal 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
187
frontend/src/demo/data.ts
Normal 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",
|
||||||
|
};
|
||||||
5
frontend/src/hooks/index.ts
Normal file
5
frontend/src/hooks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./useAnalytics";
|
||||||
|
export * from "./useDashboardConfig";
|
||||||
|
export * from "./useHistoricalImport";
|
||||||
|
export * from "./useRealtimeHistory";
|
||||||
|
export * from "./useRealtimeSocket";
|
||||||
26
frontend/src/hooks/useAnalytics.ts
Normal file
26
frontend/src/hooks/useAnalytics.ts
Normal 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 };
|
||||||
|
}
|
||||||
11
frontend/src/hooks/useDashboardConfig.ts
Normal file
11
frontend/src/hooks/useDashboardConfig.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
40
frontend/src/hooks/useHistoricalImport.ts
Normal file
40
frontend/src/hooks/useHistoricalImport.ts
Normal 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 };
|
||||||
|
}
|
||||||
16
frontend/src/hooks/useRealtimeHistory.ts
Normal file
16
frontend/src/hooks/useRealtimeHistory.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
66
frontend/src/hooks/useRealtimeSocket.ts
Normal file
66
frontend/src/hooks/useRealtimeSocket.ts
Normal 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
270
frontend/src/i18n.ts
Normal 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
148
frontend/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
81
frontend/src/lib/format.ts
Normal file
81
frontend/src/lib/format.ts
Normal 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
15
frontend/src/main.tsx
Normal 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
275
frontend/src/types.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal 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
15
frontend/vite.config.ts
Normal 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
18
scripts/dev.sh
Executable 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
21
scripts/dev_backend.sh
Executable 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
17
scripts/dev_frontend.sh
Executable 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
11
scripts/dev_frontend_demo.sh
Executable 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
Reference in New Issue
Block a user