Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c1f86b4ce |
@@ -19,7 +19,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
|
|||||||
| Domaine | Technologie | Emplacement | Etat |
|
| Domaine | Technologie | Emplacement | Etat |
|
||||||
|------------|-------------------------------------|---------------------|---------------|
|
|------------|-------------------------------------|---------------------|---------------|
|
||||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
|
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
|
||||||
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord |
|
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Squelette |
|
||||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
|
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
|
||||||
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
|
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
|
||||||
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
||||||
@@ -27,9 +27,8 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
|
|||||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
|
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
|
||||||
|
|
||||||
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
|
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
|
||||||
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
|
porte le squelette Angular, sans code metier : aucune route, aucun appel d'API. Les autres dossiers
|
||||||
correspondants restent à écrire côté API. Les autres dossiers portent l'arborescence et un README
|
portent l'arborescence et un README de cadrage, leur contenu fait l'objet d'un ticket dedie.
|
||||||
de cadrage, leur contenu fait l'objet d'un ticket dedie.
|
|
||||||
|
|
||||||
L'etat detaille de chaque brique et les vues d'architecture sont dans
|
L'etat detaille de chaque brique et les vues d'architecture sont dans
|
||||||
[docs/architecture](docs/architecture/README.md).
|
[docs/architecture](docs/architecture/README.md).
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ config = context.config
|
|||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
config.set_main_option("sqlalchemy.url", get_settings().database_url.replace("%", "%%"))
|
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
"""Création des six tables Data et de l'hypertable reading.
|
|
||||||
|
|
||||||
Revision ID: e6d2026091501
|
|
||||||
Revises: 821f71be74c0
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
revision = "e6d2026091501"
|
|
||||||
down_revision = "821f71be74c0"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table(
|
|
||||||
"dataset",
|
|
||||||
sa.Column("dataset_id", sa.BigInteger(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column("dataset_name", sa.Text(), nullable=False),
|
|
||||||
sa.Column("archive_sha256", sa.String(length=64), nullable=False),
|
|
||||||
sa.Column("storage_uri", sa.Text(), nullable=False),
|
|
||||||
sa.Column("source_timezone", sa.Text(), nullable=True),
|
|
||||||
sa.Column(
|
|
||||||
"metadata", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False
|
|
||||||
),
|
|
||||||
sa.CheckConstraint("dataset_id > 0", name="ck_dataset_positive_id"),
|
|
||||||
sa.PrimaryKeyConstraint("dataset_id"),
|
|
||||||
sa.UniqueConstraint("archive_sha256", name="uq_dataset_archive_sha256"),
|
|
||||||
)
|
|
||||||
op.create_table(
|
|
||||||
"site",
|
|
||||||
sa.Column("site_id", sa.Text(), nullable=False),
|
|
||||||
sa.Column("site_name", sa.Text(), nullable=False),
|
|
||||||
sa.Column("site_type", sa.Text(), nullable=False),
|
|
||||||
sa.Column("location", sa.Text(), nullable=True),
|
|
||||||
sa.Column("capacity_kw", sa.Double(), nullable=True),
|
|
||||||
sa.Column("status", sa.Text(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint("site_id"),
|
|
||||||
)
|
|
||||||
op.create_table(
|
|
||||||
"prediction",
|
|
||||||
sa.Column("prediction_id", sa.BigInteger(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column("site_id", sa.Text(), nullable=False),
|
|
||||||
sa.Column(
|
|
||||||
"created_at",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
server_default=sa.text("now()"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.Column("target_at", sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column("target_metric", sa.Text(), nullable=False),
|
|
||||||
sa.Column("period_minutes", sa.Integer(), nullable=True),
|
|
||||||
sa.Column("predicted_value", sa.Double(), nullable=True),
|
|
||||||
sa.Column("model_reference", sa.Text(), nullable=False),
|
|
||||||
sa.Column("status", sa.Text(), nullable=False),
|
|
||||||
sa.Column("failure_reason", sa.Text(), nullable=True),
|
|
||||||
sa.CheckConstraint(
|
|
||||||
"(status = 'available' AND predicted_value IS NOT NULL AND failure_reason IS NULL) OR (status IN ('insufficient_data', 'error') AND predicted_value IS NULL AND failure_reason IS NOT NULL)",
|
|
||||||
name="ck_prediction_status",
|
|
||||||
),
|
|
||||||
sa.CheckConstraint(
|
|
||||||
"target_metric <> 'consumption_kwh' OR period_minutes IS NOT NULL",
|
|
||||||
name="ck_prediction_energy_period",
|
|
||||||
),
|
|
||||||
sa.CheckConstraint(
|
|
||||||
"target_metric IN ('consumption_kwh', 'consumption_kw')", name="ck_prediction_metric"
|
|
||||||
),
|
|
||||||
sa.CheckConstraint(
|
|
||||||
"period_minutes IS NULL OR period_minutes > 0", name="ck_prediction_period"
|
|
||||||
),
|
|
||||||
sa.ForeignKeyConstraint(
|
|
||||||
["site_id"], ["site.site_id"], name="fk_prediction_site", ondelete="RESTRICT"
|
|
||||||
),
|
|
||||||
sa.PrimaryKeyConstraint("prediction_id"),
|
|
||||||
sa.UniqueConstraint("prediction_id", "site_id", name="uq_prediction_id_site"),
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_prediction_site_target", "prediction", ["site_id", "target_at"], unique=False
|
|
||||||
)
|
|
||||||
op.create_table(
|
|
||||||
"reading",
|
|
||||||
sa.Column("reading_id", sa.BigInteger(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column("site_id", sa.Text(), nullable=False),
|
|
||||||
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column("source", sa.Text(), nullable=False),
|
|
||||||
sa.Column("dataset_id", sa.BigInteger(), nullable=True),
|
|
||||||
sa.Column("consumption_kw", sa.Double(), nullable=True),
|
|
||||||
sa.Column("consumption_kwh", sa.Double(), nullable=True),
|
|
||||||
sa.Column("consumption_euros", sa.Numeric(precision=14, scale=2), nullable=True),
|
|
||||||
sa.Column("voltage_v", sa.Double(), nullable=True),
|
|
||||||
sa.Column("current_a", sa.Double(), nullable=True),
|
|
||||||
sa.Column("power_factor", sa.Double(), nullable=True),
|
|
||||||
sa.Column("temperature_celsius", sa.Double(), nullable=True),
|
|
||||||
sa.Column("humidity_percent", sa.Double(), nullable=True),
|
|
||||||
sa.Column("solar_irradiance_wm2", sa.Double(), nullable=True),
|
|
||||||
sa.Column("is_working_hours", sa.Boolean(), nullable=True),
|
|
||||||
sa.Column("data_quality", sa.Text(), nullable=True),
|
|
||||||
sa.Column("null_reasons", postgresql.ARRAY(sa.Text()), nullable=True),
|
|
||||||
sa.Column(
|
|
||||||
"imputed_values", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=True
|
|
||||||
),
|
|
||||||
sa.Column("imputation_method", sa.Text(), nullable=True),
|
|
||||||
sa.Column(
|
|
||||||
"ingested_at",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
server_default=sa.text("now()"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"raw_data", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False
|
|
||||||
),
|
|
||||||
sa.CheckConstraint(
|
|
||||||
"(source = 'csv' AND dataset_id IS NOT NULL) OR (source IN ('api_current', 'api_history') AND dataset_id IS NULL)",
|
|
||||||
name="ck_reading_dataset_source",
|
|
||||||
),
|
|
||||||
sa.CheckConstraint(
|
|
||||||
"data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')",
|
|
||||||
name="ck_reading_quality",
|
|
||||||
),
|
|
||||||
sa.CheckConstraint(
|
|
||||||
"source IN ('csv', 'api_current', 'api_history')", name="ck_reading_source"
|
|
||||||
),
|
|
||||||
sa.CheckConstraint(
|
|
||||||
"(imputed_values IS NULL AND imputation_method IS NULL) OR (imputed_values IS NOT NULL AND imputation_method IS NOT NULL)",
|
|
||||||
name="ck_reading_imputation",
|
|
||||||
),
|
|
||||||
sa.ForeignKeyConstraint(
|
|
||||||
["dataset_id"], ["dataset.dataset_id"], name="fk_reading_dataset", ondelete="RESTRICT"
|
|
||||||
),
|
|
||||||
sa.ForeignKeyConstraint(
|
|
||||||
["site_id"], ["site.site_id"], name="fk_reading_site", ondelete="RESTRICT"
|
|
||||||
),
|
|
||||||
sa.PrimaryKeyConstraint("reading_id", "timestamp"),
|
|
||||||
)
|
|
||||||
op.create_index("ix_reading_dataset_id", "reading", ["dataset_id"], unique=False)
|
|
||||||
op.create_index(
|
|
||||||
"ix_reading_site_timestamp", "reading", ["site_id", "timestamp"], unique=False
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"uq_reading_source",
|
|
||||||
"reading",
|
|
||||||
["site_id", "timestamp", "source", sa.literal_column("coalesce(dataset_id, 0)")],
|
|
||||||
unique=True,
|
|
||||||
)
|
|
||||||
op.execute(
|
|
||||||
"SELECT create_hypertable('reading', by_range('timestamp'), create_default_indexes => FALSE)"
|
|
||||||
)
|
|
||||||
op.create_table(
|
|
||||||
"alert",
|
|
||||||
sa.Column("alert_id", sa.BigInteger(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column("source_alert_id", sa.Text(), nullable=False),
|
|
||||||
sa.Column("site_id", sa.Text(), nullable=False),
|
|
||||||
sa.Column("source", sa.Text(), nullable=False),
|
|
||||||
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column("type", sa.Text(), nullable=False),
|
|
||||||
sa.Column("severity", sa.Text(), nullable=False),
|
|
||||||
sa.Column("message", sa.Text(), nullable=False),
|
|
||||||
sa.Column("value", sa.Double(), nullable=True),
|
|
||||||
sa.Column("threshold", sa.Double(), nullable=True),
|
|
||||||
sa.Column("metric", sa.Text(), nullable=True),
|
|
||||||
sa.Column("prediction_id", sa.BigInteger(), nullable=True),
|
|
||||||
sa.Column(
|
|
||||||
"raw_data", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False
|
|
||||||
),
|
|
||||||
sa.CheckConstraint(
|
|
||||||
"severity IN ('low', 'medium', 'high', 'critical')", name="ck_alert_severity"
|
|
||||||
),
|
|
||||||
sa.CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alert_source"),
|
|
||||||
sa.CheckConstraint(
|
|
||||||
"type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alert_type"
|
|
||||||
),
|
|
||||||
sa.ForeignKeyConstraint(
|
|
||||||
["prediction_id", "site_id"],
|
|
||||||
["prediction.prediction_id", "prediction.site_id"],
|
|
||||||
name="fk_alert_prediction_site",
|
|
||||||
ondelete="RESTRICT",
|
|
||||||
),
|
|
||||||
sa.ForeignKeyConstraint(
|
|
||||||
["site_id"], ["site.site_id"], name="fk_alert_site", ondelete="RESTRICT"
|
|
||||||
),
|
|
||||||
sa.PrimaryKeyConstraint("alert_id"),
|
|
||||||
sa.UniqueConstraint(
|
|
||||||
"source", "site_id", "source_alert_id", name="uq_alert_source_reference"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.create_index("ix_alert_site_timestamp", "alert", ["site_id", "timestamp"], unique=False)
|
|
||||||
op.create_table(
|
|
||||||
"recommendation",
|
|
||||||
sa.Column("recommendation_id", sa.BigInteger(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column("alert_id", sa.BigInteger(), nullable=False),
|
|
||||||
sa.Column("action", sa.Text(), nullable=False),
|
|
||||||
sa.Column("explanation", sa.Text(), nullable=False),
|
|
||||||
sa.Column("rule_reference", sa.Text(), nullable=False),
|
|
||||||
sa.Column(
|
|
||||||
"created_at",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
server_default=sa.text("now()"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.ForeignKeyConstraint(
|
|
||||||
["alert_id"], ["alert.alert_id"], name="fk_recommendation_alert", ondelete="RESTRICT"
|
|
||||||
),
|
|
||||||
sa.PrimaryKeyConstraint("recommendation_id"),
|
|
||||||
sa.UniqueConstraint("alert_id", "rule_reference", name="uq_recommendation_alert_rule"),
|
|
||||||
)
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_table("recommendation")
|
|
||||||
op.drop_table("alert")
|
|
||||||
op.drop_table("reading")
|
|
||||||
op.drop_table("prediction")
|
|
||||||
op.drop_table("site")
|
|
||||||
op.drop_table("dataset")
|
|
||||||
@@ -2,20 +2,8 @@
|
|||||||
# --autogenerate`, qui générerait alors un drop de sa table.
|
# --autogenerate`, qui générerait alors un drop de sa table.
|
||||||
|
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site
|
|
||||||
from app.models.login_attempt import LoginAttempt
|
from app.models.login_attempt import LoginAttempt
|
||||||
from app.models.refresh_token import RefreshToken
|
from app.models.refresh_token import RefreshToken
|
||||||
from app.models.user import AppUser
|
from app.models.user import AppUser
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["AppUser", "AuditLog", "LoginAttempt", "RefreshToken"]
|
||||||
"Alert",
|
|
||||||
"AppUser",
|
|
||||||
"AuditLog",
|
|
||||||
"Dataset",
|
|
||||||
"LoginAttempt",
|
|
||||||
"Prediction",
|
|
||||||
"Reading",
|
|
||||||
"Recommendation",
|
|
||||||
"RefreshToken",
|
|
||||||
"Site",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
"""Tables du modèle de données EnerVision (CSV, API Mock et résultats ML)."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
|
||||||
BigInteger,
|
|
||||||
Boolean,
|
|
||||||
CheckConstraint,
|
|
||||||
DateTime,
|
|
||||||
Double,
|
|
||||||
ForeignKey,
|
|
||||||
ForeignKeyConstraint,
|
|
||||||
Index,
|
|
||||||
Integer,
|
|
||||||
Numeric,
|
|
||||||
String,
|
|
||||||
Text,
|
|
||||||
UniqueConstraint,
|
|
||||||
func,
|
|
||||||
text,
|
|
||||||
)
|
|
||||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Dataset(Base):
|
|
||||||
__tablename__ = "dataset"
|
|
||||||
__table_args__ = (
|
|
||||||
CheckConstraint("dataset_id > 0", name="ck_dataset_positive_id"),
|
|
||||||
UniqueConstraint("archive_sha256", name="uq_dataset_archive_sha256"),
|
|
||||||
)
|
|
||||||
|
|
||||||
dataset_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
||||||
dataset_name: Mapped[str] = mapped_column(Text)
|
|
||||||
archive_sha256: Mapped[str] = mapped_column(String(64))
|
|
||||||
storage_uri: Mapped[str] = mapped_column(Text)
|
|
||||||
source_timezone: Mapped[str | None] = mapped_column(Text)
|
|
||||||
# "metadata" est réservé par SQLAlchemy ; le nom SQL reste inchangé.
|
|
||||||
dataset_metadata: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB(none_as_null=True))
|
|
||||||
|
|
||||||
|
|
||||||
class Site(Base):
|
|
||||||
__tablename__ = "site"
|
|
||||||
|
|
||||||
site_id: Mapped[str] = mapped_column(Text, primary_key=True)
|
|
||||||
site_name: Mapped[str] = mapped_column(Text)
|
|
||||||
site_type: Mapped[str] = mapped_column(Text)
|
|
||||||
location: Mapped[str | None] = mapped_column(Text)
|
|
||||||
capacity_kw: Mapped[float | None] = mapped_column(Double)
|
|
||||||
status: Mapped[str | None] = mapped_column(Text)
|
|
||||||
|
|
||||||
|
|
||||||
class Reading(Base):
|
|
||||||
__tablename__ = "reading"
|
|
||||||
__table_args__ = (
|
|
||||||
CheckConstraint(
|
|
||||||
"source IN ('csv', 'api_current', 'api_history')", name="ck_reading_source"
|
|
||||||
),
|
|
||||||
CheckConstraint(
|
|
||||||
"(source = 'csv' AND dataset_id IS NOT NULL) OR "
|
|
||||||
"(source IN ('api_current', 'api_history') AND dataset_id IS NULL)",
|
|
||||||
name="ck_reading_dataset_source",
|
|
||||||
),
|
|
||||||
CheckConstraint(
|
|
||||||
"data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')",
|
|
||||||
name="ck_reading_quality",
|
|
||||||
),
|
|
||||||
CheckConstraint(
|
|
||||||
"(imputed_values IS NULL AND imputation_method IS NULL) OR "
|
|
||||||
"(imputed_values IS NOT NULL AND imputation_method IS NOT NULL)",
|
|
||||||
name="ck_reading_imputation",
|
|
||||||
),
|
|
||||||
Index("ix_reading_site_timestamp", "site_id", "timestamp"),
|
|
||||||
Index("ix_reading_dataset_id", "dataset_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
reading_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
||||||
site_id: Mapped[str] = mapped_column(
|
|
||||||
Text, ForeignKey("site.site_id", name="fk_reading_site", ondelete="RESTRICT")
|
|
||||||
)
|
|
||||||
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), primary_key=True)
|
|
||||||
source: Mapped[str] = mapped_column(Text)
|
|
||||||
dataset_id: Mapped[int | None] = mapped_column(
|
|
||||||
BigInteger,
|
|
||||||
ForeignKey("dataset.dataset_id", name="fk_reading_dataset", ondelete="RESTRICT"),
|
|
||||||
)
|
|
||||||
consumption_kw: Mapped[float | None] = mapped_column(Double)
|
|
||||||
consumption_kwh: Mapped[float | None] = mapped_column(Double)
|
|
||||||
consumption_euros: Mapped[Decimal | None] = mapped_column(Numeric(14, 2))
|
|
||||||
voltage_v: Mapped[float | None] = mapped_column(Double)
|
|
||||||
current_a: Mapped[float | None] = mapped_column(Double)
|
|
||||||
power_factor: Mapped[float | None] = mapped_column(Double)
|
|
||||||
temperature_celsius: Mapped[float | None] = mapped_column(Double)
|
|
||||||
humidity_percent: Mapped[float | None] = mapped_column(Double)
|
|
||||||
solar_irradiance_wm2: Mapped[float | None] = mapped_column(Double)
|
|
||||||
is_working_hours: Mapped[bool | None] = mapped_column(Boolean)
|
|
||||||
data_quality: Mapped[str | None] = mapped_column(Text)
|
|
||||||
null_reasons: Mapped[list[str] | None] = mapped_column(ARRAY(Text))
|
|
||||||
imputed_values: Mapped[dict[str, Any] | None] = mapped_column(JSONB(none_as_null=True))
|
|
||||||
imputation_method: Mapped[str | None] = mapped_column(Text)
|
|
||||||
ingested_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
raw_data: Mapped[dict[str, Any]] = mapped_column(JSONB(none_as_null=True))
|
|
||||||
|
|
||||||
|
|
||||||
Index(
|
|
||||||
"uq_reading_source",
|
|
||||||
Reading.site_id,
|
|
||||||
Reading.timestamp,
|
|
||||||
Reading.source,
|
|
||||||
func.coalesce(Reading.dataset_id, text("0")),
|
|
||||||
unique=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Prediction(Base):
|
|
||||||
__tablename__ = "prediction"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("prediction_id", "site_id", name="uq_prediction_id_site"),
|
|
||||||
Index("ix_prediction_site_target", "site_id", "target_at"),
|
|
||||||
CheckConstraint(
|
|
||||||
"target_metric IN ('consumption_kwh', 'consumption_kw')",
|
|
||||||
name="ck_prediction_metric",
|
|
||||||
),
|
|
||||||
CheckConstraint(
|
|
||||||
"period_minutes IS NULL OR period_minutes > 0", name="ck_prediction_period"
|
|
||||||
),
|
|
||||||
CheckConstraint(
|
|
||||||
"target_metric <> 'consumption_kwh' OR period_minutes IS NOT NULL",
|
|
||||||
name="ck_prediction_energy_period",
|
|
||||||
),
|
|
||||||
CheckConstraint(
|
|
||||||
"(status = 'available' AND predicted_value IS NOT NULL AND failure_reason IS NULL) OR "
|
|
||||||
"(status IN ('insufficient_data', 'error') AND predicted_value IS NULL "
|
|
||||||
"AND failure_reason IS NOT NULL)",
|
|
||||||
name="ck_prediction_status",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
prediction_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
||||||
site_id: Mapped[str] = mapped_column(
|
|
||||||
Text, ForeignKey("site.site_id", name="fk_prediction_site", ondelete="RESTRICT")
|
|
||||||
)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
target_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
||||||
target_metric: Mapped[str] = mapped_column(Text)
|
|
||||||
period_minutes: Mapped[int | None] = mapped_column(Integer)
|
|
||||||
predicted_value: Mapped[float | None] = mapped_column(Double)
|
|
||||||
model_reference: Mapped[str] = mapped_column(Text)
|
|
||||||
status: Mapped[str] = mapped_column(Text)
|
|
||||||
failure_reason: Mapped[str | None] = mapped_column(Text)
|
|
||||||
|
|
||||||
|
|
||||||
class Alert(Base):
|
|
||||||
__tablename__ = "alert"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("source", "site_id", "source_alert_id", name="uq_alert_source_reference"),
|
|
||||||
Index("ix_alert_site_timestamp", "site_id", "timestamp"),
|
|
||||||
ForeignKeyConstraint(
|
|
||||||
["prediction_id", "site_id"],
|
|
||||||
["prediction.prediction_id", "prediction.site_id"],
|
|
||||||
name="fk_alert_prediction_site",
|
|
||||||
ondelete="RESTRICT",
|
|
||||||
),
|
|
||||||
CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alert_source"),
|
|
||||||
CheckConstraint(
|
|
||||||
"type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alert_type"
|
|
||||||
),
|
|
||||||
CheckConstraint(
|
|
||||||
"severity IN ('low', 'medium', 'high', 'critical')", name="ck_alert_severity"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
alert_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
||||||
source_alert_id: Mapped[str] = mapped_column(Text)
|
|
||||||
site_id: Mapped[str] = mapped_column(
|
|
||||||
Text, ForeignKey("site.site_id", name="fk_alert_site", ondelete="RESTRICT")
|
|
||||||
)
|
|
||||||
source: Mapped[str] = mapped_column(Text)
|
|
||||||
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
||||||
type: Mapped[str] = mapped_column(Text)
|
|
||||||
severity: Mapped[str] = mapped_column(Text)
|
|
||||||
message: Mapped[str] = mapped_column(Text)
|
|
||||||
value: Mapped[float | None] = mapped_column(Double)
|
|
||||||
threshold: Mapped[float | None] = mapped_column(Double)
|
|
||||||
metric: Mapped[str | None] = mapped_column(Text)
|
|
||||||
prediction_id: Mapped[int | None] = mapped_column(BigInteger)
|
|
||||||
raw_data: Mapped[dict[str, Any]] = mapped_column(JSONB(none_as_null=True))
|
|
||||||
|
|
||||||
|
|
||||||
class Recommendation(Base):
|
|
||||||
__tablename__ = "recommendation"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("alert_id", "rule_reference", name="uq_recommendation_alert_rule"),
|
|
||||||
)
|
|
||||||
|
|
||||||
recommendation_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
||||||
alert_id: Mapped[int] = mapped_column(
|
|
||||||
BigInteger,
|
|
||||||
ForeignKey("alert.alert_id", name="fk_recommendation_alert", ondelete="RESTRICT"),
|
|
||||||
)
|
|
||||||
action: Mapped[str] = mapped_column(Text)
|
|
||||||
explanation: Mapped[str] = mapped_column(Text)
|
|
||||||
rule_reference: Mapped[str] = mapped_column(Text)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
from collections.abc import AsyncIterator
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy import insert, select, text
|
|
||||||
from sqlalchemy.engine import make_url
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
|
|
||||||
|
|
||||||
from app.core.config import get_settings
|
|
||||||
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
|
||||||
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def data_connection() -> AsyncIterator[AsyncConnection]:
|
|
||||||
url = make_url(get_settings().database_url)
|
|
||||||
if url.database != "enervision_test":
|
|
||||||
pytest.fail("Ces tests exigent DATABASE_URL vers enervision_test.")
|
|
||||||
engine = create_async_engine(url)
|
|
||||||
try:
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
transaction = await connection.begin()
|
|
||||||
try:
|
|
||||||
yield connection
|
|
||||||
finally:
|
|
||||||
await transaction.rollback()
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def data_site(data_connection: AsyncConnection) -> str:
|
|
||||||
site_id = f"TEST-{uuid4()}"
|
|
||||||
await data_connection.execute(
|
|
||||||
insert(Site).values(site_id=site_id, site_name="Site de test", site_type="office")
|
|
||||||
)
|
|
||||||
return site_id
|
|
||||||
|
|
||||||
|
|
||||||
async def test_reading_is_a_time_hypertable_when_migrated(
|
|
||||||
data_connection: AsyncConnection,
|
|
||||||
) -> None:
|
|
||||||
query = text(
|
|
||||||
"SELECT column_name FROM timescaledb_information.dimensions "
|
|
||||||
"WHERE hypertable_schema = 'public' AND hypertable_name = 'reading'"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await data_connection.execute(query)
|
|
||||||
|
|
||||||
assert result.scalars().all() == ["timestamp"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_reading_preserves_null_and_zero_when_inserted(
|
|
||||||
data_connection: AsyncConnection, data_site: str
|
|
||||||
) -> None:
|
|
||||||
statement = insert(Reading).values(
|
|
||||||
site_id=data_site,
|
|
||||||
timestamp=MOMENT,
|
|
||||||
source="api_current",
|
|
||||||
consumption_kw=None,
|
|
||||||
consumption_kwh=0,
|
|
||||||
data_quality="partial",
|
|
||||||
null_reasons=["sensor_failure"],
|
|
||||||
raw_data={"consumption_kw": None},
|
|
||||||
imputed_values=None,
|
|
||||||
imputation_method=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
await data_connection.execute(statement)
|
|
||||||
result = (
|
|
||||||
await data_connection.execute(
|
|
||||||
select(
|
|
||||||
Reading.consumption_kw,
|
|
||||||
Reading.consumption_kwh,
|
|
||||||
Reading.raw_data,
|
|
||||||
Reading.imputed_values,
|
|
||||||
).where(Reading.site_id == data_site)
|
|
||||||
)
|
|
||||||
).one()
|
|
||||||
|
|
||||||
assert tuple(result) == (None, 0, {"consumption_kw": None}, None)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("source", ["csv", "api_current", "api_history"])
|
|
||||||
async def test_duplicate_reading_is_rejected_when_key_matches(
|
|
||||||
data_connection: AsyncConnection, data_site: str, source: str
|
|
||||||
) -> None:
|
|
||||||
dataset_id = None
|
|
||||||
if source == "csv":
|
|
||||||
dataset_id = (
|
|
||||||
await data_connection.execute(
|
|
||||||
insert(Dataset.__table__)
|
|
||||||
.values(
|
|
||||||
dataset_name="Archive de test",
|
|
||||||
archive_sha256=uuid4().hex + uuid4().hex,
|
|
||||||
storage_uri="test://archive",
|
|
||||||
metadata={},
|
|
||||||
)
|
|
||||||
.returning(Dataset.dataset_id)
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
statement = insert(Reading).values(
|
|
||||||
site_id=data_site,
|
|
||||||
timestamp=MOMENT,
|
|
||||||
source=source,
|
|
||||||
dataset_id=dataset_id,
|
|
||||||
raw_data={},
|
|
||||||
)
|
|
||||||
await data_connection.execute(statement)
|
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
async with data_connection.begin_nested():
|
|
||||||
await data_connection.execute(statement)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"changes",
|
|
||||||
[
|
|
||||||
{"source": "csv"},
|
|
||||||
{"source": "unknown"},
|
|
||||||
{"site_id": "UNKNOWN-SITE"},
|
|
||||||
{"data_quality": "unknown"},
|
|
||||||
{"imputed_values": {"consumption_kw": 12}},
|
|
||||||
{"imputation_method": "mean-v1"},
|
|
||||||
],
|
|
||||||
ids=[
|
|
||||||
"csv_sans_dataset",
|
|
||||||
"source_inconnue",
|
|
||||||
"site_absent",
|
|
||||||
"qualite_inconnue",
|
|
||||||
"imputation_sans_methode",
|
|
||||||
"methode_sans_imputation",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_invalid_reading_is_rejected_when_constraints_fail(
|
|
||||||
data_connection: AsyncConnection, data_site: str, changes: dict[str, object]
|
|
||||||
) -> None:
|
|
||||||
values: dict[str, object] = {
|
|
||||||
"site_id": data_site,
|
|
||||||
"timestamp": MOMENT,
|
|
||||||
"source": "api_current",
|
|
||||||
"raw_data": {},
|
|
||||||
}
|
|
||||||
values.update(changes)
|
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
async with data_connection.begin_nested():
|
|
||||||
await data_connection.execute(insert(Reading).values(**values))
|
|
||||||
|
|
||||||
|
|
||||||
async def test_prediction_requires_period_when_energy_is_predicted(
|
|
||||||
data_connection: AsyncConnection, data_site: str
|
|
||||||
) -> None:
|
|
||||||
statement = insert(Prediction).values(
|
|
||||||
site_id=data_site,
|
|
||||||
target_at=MOMENT,
|
|
||||||
target_metric="consumption_kwh",
|
|
||||||
predicted_value=12,
|
|
||||||
status="available",
|
|
||||||
model_reference="test-model/1",
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
async with data_connection.begin_nested():
|
|
||||||
await data_connection.execute(statement)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_unavailable_prediction_preserves_null_when_inserted(
|
|
||||||
data_connection: AsyncConnection, data_site: str
|
|
||||||
) -> None:
|
|
||||||
statement = (
|
|
||||||
insert(Prediction)
|
|
||||||
.values(
|
|
||||||
site_id=data_site,
|
|
||||||
target_at=MOMENT,
|
|
||||||
target_metric="consumption_kw",
|
|
||||||
status="insufficient_data",
|
|
||||||
failure_reason="Historique trop court",
|
|
||||||
model_reference="test-model/1",
|
|
||||||
)
|
|
||||||
.returning(Prediction.predicted_value)
|
|
||||||
)
|
|
||||||
|
|
||||||
value = (await data_connection.execute(statement)).scalar_one()
|
|
||||||
|
|
||||||
assert value is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_alert_rejects_prediction_when_site_differs(
|
|
||||||
data_connection: AsyncConnection, data_site: str
|
|
||||||
) -> None:
|
|
||||||
other_site = f"TEST-{uuid4()}"
|
|
||||||
await data_connection.execute(
|
|
||||||
insert(Site).values(site_id=other_site, site_name="Autre site", site_type="office")
|
|
||||||
)
|
|
||||||
prediction_id = (
|
|
||||||
await data_connection.execute(
|
|
||||||
insert(Prediction)
|
|
||||||
.values(
|
|
||||||
site_id=data_site,
|
|
||||||
target_at=MOMENT,
|
|
||||||
target_metric="consumption_kw",
|
|
||||||
predicted_value=12,
|
|
||||||
status="available",
|
|
||||||
model_reference="test-model/1",
|
|
||||||
)
|
|
||||||
.returning(Prediction.prediction_id)
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
async with data_connection.begin_nested():
|
|
||||||
await data_connection.execute(
|
|
||||||
insert(Alert).values(
|
|
||||||
source_alert_id=str(uuid4()),
|
|
||||||
site_id=other_site,
|
|
||||||
source="enervision",
|
|
||||||
timestamp=MOMENT,
|
|
||||||
type="spike",
|
|
||||||
severity="high",
|
|
||||||
message="Test",
|
|
||||||
prediction_id=prediction_id,
|
|
||||||
raw_data={},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_recommendation_is_unique_when_alert_and_rule_match(
|
|
||||||
data_connection: AsyncConnection, data_site: str
|
|
||||||
) -> None:
|
|
||||||
alert_id = (
|
|
||||||
await data_connection.execute(
|
|
||||||
insert(Alert)
|
|
||||||
.values(
|
|
||||||
source_alert_id=str(uuid4()),
|
|
||||||
site_id=data_site,
|
|
||||||
source="api_mock",
|
|
||||||
timestamp=MOMENT,
|
|
||||||
type="spike",
|
|
||||||
severity="high",
|
|
||||||
message="Test",
|
|
||||||
raw_data={},
|
|
||||||
)
|
|
||||||
.returning(Alert.alert_id)
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
statement = insert(Recommendation).values(
|
|
||||||
alert_id=alert_id,
|
|
||||||
action="Vérifier la consommation",
|
|
||||||
explanation="Pic détecté",
|
|
||||||
rule_reference="spike-v1",
|
|
||||||
)
|
|
||||||
await data_connection.execute(statement)
|
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
async with data_connection.begin_nested():
|
|
||||||
await data_connection.execute(statement)
|
|
||||||
@@ -34,7 +34,6 @@ yarn-error.log
|
|||||||
.sass-cache/
|
.sass-cache/
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
/test-results
|
|
||||||
/libpeerconnection.log
|
/libpeerconnection.log
|
||||||
testem.log
|
testem.log
|
||||||
/typings
|
/typings
|
||||||
|
|||||||
Generated
-19
@@ -14,7 +14,6 @@
|
|||||||
"@angular/forms": "^22.1.0",
|
"@angular/forms": "^22.1.0",
|
||||||
"@angular/platform-browser": "^22.1.0",
|
"@angular/platform-browser": "^22.1.0",
|
||||||
"@angular/router": "^22.1.0",
|
"@angular/router": "^22.1.0",
|
||||||
"chart.js": "^4.5.1",
|
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -2039,12 +2038,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@kurkle/color": {
|
|
||||||
"version": "0.3.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
|
||||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@listr2/prompt-adapter-inquirer": {
|
"node_modules/@listr2/prompt-adapter-inquirer": {
|
||||||
"version": "4.2.5",
|
"version": "4.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.5.tgz",
|
||||||
@@ -4227,18 +4220,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/chart.js": {
|
|
||||||
"version": "4.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
|
||||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@kurkle/color": "^0.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"pnpm": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
"@angular/forms": "^22.1.0",
|
"@angular/forms": "^22.1.0",
|
||||||
"@angular/platform-browser": "^22.1.0",
|
"@angular/platform-browser": "^22.1.0",
|
||||||
"@angular/router": "^22.1.0",
|
"@angular/router": "^22.1.0",
|
||||||
"chart.js": "^4.5.1",
|
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { mockApiInterceptor } from './core/interceptors/mock-api-interceptor';
|
|
||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)],
|
||||||
provideBrowserGlobalErrorListeners(),
|
|
||||||
provideRouter(routes),
|
|
||||||
provideHttpClient(withInterceptors([mockApiInterceptor])),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1 +1,353 @@
|
|||||||
<router-outlet></router-outlet>
|
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||||
|
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
|
||||||
|
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
|
||||||
|
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
|
||||||
|
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||||
|
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
|
||||||
|
<!-- * * * * * * * to get started with your project! * * * * * * * -->
|
||||||
|
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
--bright-blue: oklch(51.01% 0.274 263.83);
|
||||||
|
--electric-violet: oklch(53.18% 0.28 296.97);
|
||||||
|
--french-violet: oklch(47.66% 0.246 305.88);
|
||||||
|
--vivid-pink: oklch(69.02% 0.277 332.77);
|
||||||
|
--hot-red: oklch(61.42% 0.238 15.34);
|
||||||
|
--orange-red: oklch(63.32% 0.24 31.68);
|
||||||
|
|
||||||
|
--gray-900: oklch(19.37% 0.006 300.98);
|
||||||
|
--gray-700: oklch(36.98% 0.014 302.71);
|
||||||
|
--gray-400: oklch(70.9% 0.015 304.04);
|
||||||
|
|
||||||
|
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--orange-red) 0%,
|
||||||
|
var(--vivid-pink) 50%,
|
||||||
|
var(--electric-violet) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--orange-red) 0%,
|
||||||
|
var(--vivid-pink) 50%,
|
||||||
|
var(--electric-violet) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
--pill-accent: var(--bright-blue);
|
||||||
|
|
||||||
|
font-family:
|
||||||
|
'Inter',
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
Helvetica,
|
||||||
|
Arial,
|
||||||
|
sans-serif,
|
||||||
|
'Apple Color Emoji',
|
||||||
|
'Segoe UI Emoji',
|
||||||
|
'Segoe UI Symbol';
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
display: block;
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3.125rem;
|
||||||
|
color: var(--gray-900);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 100%;
|
||||||
|
letter-spacing: -0.125rem;
|
||||||
|
margin: 0;
|
||||||
|
font-family:
|
||||||
|
'Inter Tight',
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
Helvetica,
|
||||||
|
Arial,
|
||||||
|
sans-serif,
|
||||||
|
'Apple Color Emoji',
|
||||||
|
'Segoe UI Emoji',
|
||||||
|
'Segoe UI Symbol';
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
box-sizing: inherit;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.angular-logo {
|
||||||
|
max-width: 9.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 700px;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 1px;
|
||||||
|
background: var(--red-to-pink-to-purple-vertical-gradient);
|
||||||
|
margin-inline: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
--pill-accent: var(--bright-blue);
|
||||||
|
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
|
||||||
|
color: var(--pill-accent);
|
||||||
|
padding-inline: 0.75rem;
|
||||||
|
padding-block: 0.375rem;
|
||||||
|
border-radius: 2.75rem;
|
||||||
|
border: 0;
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
font-family: var(--inter-font);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4rem;
|
||||||
|
letter-spacing: -0.00875rem;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill:hover {
|
||||||
|
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-group .pill:nth-child(6n + 1) {
|
||||||
|
--pill-accent: var(--bright-blue);
|
||||||
|
}
|
||||||
|
.pill-group .pill:nth-child(6n + 2) {
|
||||||
|
--pill-accent: var(--electric-violet);
|
||||||
|
}
|
||||||
|
.pill-group .pill:nth-child(6n + 3) {
|
||||||
|
--pill-accent: var(--french-violet);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-group .pill:nth-child(6n + 4),
|
||||||
|
.pill-group .pill:nth-child(6n + 5),
|
||||||
|
.pill-group .pill:nth-child(6n + 6) {
|
||||||
|
--pill-accent: var(--hot-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-group svg {
|
||||||
|
margin-inline-start: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.73rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-links path {
|
||||||
|
transition: fill 0.3s ease;
|
||||||
|
fill: var(--gray-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-links a:hover svg path {
|
||||||
|
fill: var(--gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 650px) {
|
||||||
|
.content {
|
||||||
|
flex-direction: column;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--red-to-pink-to-purple-horizontal-gradient);
|
||||||
|
margin-block: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="content">
|
||||||
|
<div class="left-side">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 982 239"
|
||||||
|
fill="none"
|
||||||
|
class="angular-logo"
|
||||||
|
>
|
||||||
|
<g clip-path="url(#a)">
|
||||||
|
<path
|
||||||
|
fill="url(#b)"
|
||||||
|
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="url(#c)"
|
||||||
|
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<radialGradient
|
||||||
|
id="c"
|
||||||
|
cx="0"
|
||||||
|
cy="0"
|
||||||
|
r="1"
|
||||||
|
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<stop stop-color="#FF41F8" />
|
||||||
|
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
|
||||||
|
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="b" x1="0" x2="982" y1="192" y2="192" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#F0060B" />
|
||||||
|
<stop offset="0" stop-color="#F0070C" />
|
||||||
|
<stop offset=".526" stop-color="#CC26D5" />
|
||||||
|
<stop offset="1" stop-color="#7702FF" />
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<h1>Hello, {{ title() }}</h1>
|
||||||
|
<p>Congratulations! Your app is running. 🎉</p>
|
||||||
|
</div>
|
||||||
|
<div class="divider" role="separator" aria-label="Divider"></div>
|
||||||
|
<div class="right-side">
|
||||||
|
<div class="pill-group">
|
||||||
|
@for (
|
||||||
|
item of [
|
||||||
|
{ title: 'Explore the Docs', link: 'https://angular.dev' },
|
||||||
|
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
|
||||||
|
{
|
||||||
|
title: 'Prompt and best practices for AI',
|
||||||
|
link: 'https://angular.dev/ai/develop-with-ai',
|
||||||
|
},
|
||||||
|
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
|
||||||
|
{
|
||||||
|
title: 'Angular Language Service',
|
||||||
|
link: 'https://angular.dev/tools/language-service',
|
||||||
|
},
|
||||||
|
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
|
||||||
|
];
|
||||||
|
track item.title
|
||||||
|
) {
|
||||||
|
<a class="pill" [href]="item.link" target="_blank" rel="noopener">
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 -960 960 960"
|
||||||
|
width="14"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="social-links">
|
||||||
|
<a
|
||||||
|
href="https://github.com/angular/angular"
|
||||||
|
aria-label="Github"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="25"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 25 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
alt="Github"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="https://x.com/angular" aria-label="X" target="_blank" rel="noopener">
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
alt="X"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
|
||||||
|
aria-label="Youtube"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="29"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 29 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
alt="Youtube"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||||
|
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
|
||||||
|
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
|
||||||
|
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
|
||||||
|
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||||
|
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
|
||||||
|
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||||
|
|
||||||
|
<router-outlet />
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [];
|
||||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
|
||||||
{
|
|
||||||
path: 'dashboard',
|
|
||||||
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -13,4 +13,11 @@ describe('App', () => {
|
|||||||
const app = fixture.componentInstance;
|
const app = fixture.componentInstance;
|
||||||
expect(app).toBeTruthy();
|
expect(app).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render title', async () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
await fixture.whenStable();
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
||||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
|
||||||
import { mockApiInterceptor } from './mock-api-interceptor';
|
|
||||||
import { environment } from '../../../environments/environment';
|
|
||||||
import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture';
|
|
||||||
|
|
||||||
describe('mockApiInterceptor', () => {
|
|
||||||
let http: HttpClient;
|
|
||||||
let httpMock: HttpTestingController;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
providers: [
|
|
||||||
provideHttpClient(withInterceptors([mockApiInterceptor])),
|
|
||||||
provideHttpClientTesting(),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
http = TestBed.inject(HttpClient);
|
|
||||||
httpMock = TestBed.inject(HttpTestingController);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
environment.useMockFixtures = true;
|
|
||||||
httpMock.verify();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renvoie la fixture sans appel réseau quand useMockFixtures est activé', () => {
|
|
||||||
environment.useMockFixtures = true;
|
|
||||||
let result: unknown;
|
|
||||||
|
|
||||||
http.get(`${environment.apiUrl}/stats/summary`).subscribe((r) => (result = r));
|
|
||||||
|
|
||||||
httpMock.expectNone(`${environment.apiUrl}/stats/summary`);
|
|
||||||
expect((result as typeof STATS_SUMMARY_FIXTURE).total_sites).toBe(
|
|
||||||
STATS_SUMMARY_FIXTURE.total_sites,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('laisse passer la vraie requête quand useMockFixtures est désactivé', () => {
|
|
||||||
environment.useMockFixtures = false;
|
|
||||||
|
|
||||||
http.get(`${environment.apiUrl}/stats/summary`).subscribe();
|
|
||||||
|
|
||||||
const req = httpMock.expectOne(`${environment.apiUrl}/stats/summary`);
|
|
||||||
req.flush({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("laisse passer une requête qui ne correspond à aucune route connue de l'interceptor", () => {
|
|
||||||
environment.useMockFixtures = true;
|
|
||||||
|
|
||||||
http.get('/api/v1/autre-chose').subscribe();
|
|
||||||
|
|
||||||
const req = httpMock.expectOne('/api/v1/autre-chose');
|
|
||||||
req.flush({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renvoie la fixture des alertes sans appel réseau quand useMockFixtures est activé', () => {
|
|
||||||
environment.useMockFixtures = true;
|
|
||||||
let result: unknown;
|
|
||||||
|
|
||||||
http.get(`${environment.apiUrl}/alerts`).subscribe((r) => (result = r));
|
|
||||||
|
|
||||||
httpMock.expectNone(`${environment.apiUrl}/alerts`);
|
|
||||||
expect((result as unknown[]).length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
|
|
||||||
import { of } from 'rxjs';
|
|
||||||
import { environment } from '../../../environments/environment';
|
|
||||||
import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture';
|
|
||||||
import { ALERTS_FIXTURE } from '../mocks/alerts.fixture';
|
|
||||||
|
|
||||||
function withJitter(base: typeof STATS_SUMMARY_FIXTURE) {
|
|
||||||
const jitter = () => (Math.random() - 0.5) * 40;
|
|
||||||
const totalConsumption = Math.max(0, base.total_consumption_kw + jitter());
|
|
||||||
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
total_consumption_kw: Math.round(totalConsumption * 100) / 100,
|
|
||||||
average_load_percent: Math.round((totalConsumption / base.total_capacity_kw) * 1000) / 10,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const mockApiInterceptor: HttpInterceptorFn = (req, next) => {
|
|
||||||
if (!environment.useMockFixtures) {
|
|
||||||
return next(req);
|
|
||||||
}
|
|
||||||
if (req.url.endsWith(`${environment.apiUrl}/stats/summary`)) {
|
|
||||||
return of(new HttpResponse({ status: 200, body: withJitter(STATS_SUMMARY_FIXTURE) }));
|
|
||||||
}
|
|
||||||
if (req.url.endsWith(`${environment.apiUrl}/alerts`)) {
|
|
||||||
return of(new HttpResponse({ status: 200, body: ALERTS_FIXTURE }));
|
|
||||||
}
|
|
||||||
return next(req);
|
|
||||||
};
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { Alert } from '../../shared/models/alert.model';
|
|
||||||
|
|
||||||
export const ALERTS_FIXTURE: Alert[] = [
|
|
||||||
{
|
|
||||||
alert_id: 'ALR-SITE002-1718458320',
|
|
||||||
timestamp: '2026-09-15T11:12:00',
|
|
||||||
site_id: 'SITE002',
|
|
||||||
severity: 'critical',
|
|
||||||
type: 'outage',
|
|
||||||
message: 'Risque de surcharge sur Usine Lyon Vénissieux',
|
|
||||||
value: 812.5,
|
|
||||||
threshold: 720.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
alert_id: 'ALR-SITE003-1718458321',
|
|
||||||
timestamp: '2026-09-15T11:05:00',
|
|
||||||
site_id: 'SITE003',
|
|
||||||
severity: 'critical',
|
|
||||||
type: 'sensor',
|
|
||||||
message: 'Perte réseau totale sur Data Center Marseille',
|
|
||||||
value: 0,
|
|
||||||
threshold: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
alert_id: 'ALR-SITE005-1718458322',
|
|
||||||
timestamp: '2026-09-15T10:47:00',
|
|
||||||
site_id: 'SITE005',
|
|
||||||
severity: 'high',
|
|
||||||
type: 'threshold',
|
|
||||||
message: 'Usine Toulouse approche de son seuil de capacité',
|
|
||||||
value: 410.0,
|
|
||||||
threshold: 480.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
alert_id: 'ALR-SITE006-1718458323',
|
|
||||||
timestamp: '2026-09-15T10:30:00',
|
|
||||||
site_id: 'SITE006',
|
|
||||||
severity: 'medium',
|
|
||||||
type: 'sensor',
|
|
||||||
message: 'Capteur de température défaillant sur Bureau Lille',
|
|
||||||
value: 0,
|
|
||||||
threshold: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
alert_id: 'ALR-SITE004-1718458324',
|
|
||||||
timestamp: '2026-09-15T09:58:00',
|
|
||||||
site_id: 'SITE004',
|
|
||||||
severity: 'low',
|
|
||||||
type: 'anomaly',
|
|
||||||
message: 'Comportement de consommation inhabituel sur Bureau Bordeaux',
|
|
||||||
value: 62.0,
|
|
||||||
threshold: 55.0,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { StatsSummary } from '../../shared/models/stats.model';
|
|
||||||
|
|
||||||
export const STATS_SUMMARY_FIXTURE: StatsSummary = {
|
|
||||||
timestamp: '2026-09-15T11:32:00',
|
|
||||||
total_sites: 7,
|
|
||||||
total_consumption_kw: 1826.44,
|
|
||||||
total_capacity_kw: 3830,
|
|
||||||
average_load_percent: 55.1,
|
|
||||||
sites: [
|
|
||||||
{
|
|
||||||
site_id: 'SITE001',
|
|
||||||
site_name: 'Bureau Paris La Défense',
|
|
||||||
current_consumption_kw: 87.34,
|
|
||||||
capacity_kw: 200,
|
|
||||||
load_percent: 43.7,
|
|
||||||
data_quality: 'good',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
site_id: 'SITE002',
|
|
||||||
site_name: 'Usine Lyon Vénissieux',
|
|
||||||
current_consumption_kw: 542.1,
|
|
||||||
capacity_kw: 1000,
|
|
||||||
load_percent: 54.2,
|
|
||||||
data_quality: 'good',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
site_id: 'SITE003',
|
|
||||||
site_name: 'Data Center Marseille',
|
|
||||||
current_consumption_kw: null,
|
|
||||||
capacity_kw: 800,
|
|
||||||
load_percent: null,
|
|
||||||
data_quality: 'critical',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
site_id: 'SITE004',
|
|
||||||
site_name: 'Bureau Bordeaux',
|
|
||||||
current_consumption_kw: 62.0,
|
|
||||||
capacity_kw: 150,
|
|
||||||
load_percent: 41.3,
|
|
||||||
data_quality: 'partial',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
site_id: 'SITE005',
|
|
||||||
site_name: 'Usine Toulouse',
|
|
||||||
current_consumption_kw: 410.0,
|
|
||||||
capacity_kw: 600,
|
|
||||||
load_percent: 68.3,
|
|
||||||
data_quality: 'good',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
site_id: 'SITE006',
|
|
||||||
site_name: 'Bureau Lille',
|
|
||||||
current_consumption_kw: 95.0,
|
|
||||||
capacity_kw: 180,
|
|
||||||
load_percent: 52.8,
|
|
||||||
data_quality: 'degraded',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
site_id: 'SITE007',
|
|
||||||
site_name: 'Data Center Nantes',
|
|
||||||
current_consumption_kw: 630.0,
|
|
||||||
capacity_kw: 900,
|
|
||||||
load_percent: 70.0,
|
|
||||||
data_quality: 'good',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
|
||||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
|
||||||
import { AlertsService } from './alerts.service';
|
|
||||||
import { environment } from '../../../environments/environment';
|
|
||||||
|
|
||||||
describe('AlertsService', () => {
|
|
||||||
let service: AlertsService;
|
|
||||||
let httpMock: HttpTestingController;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
|
||||||
});
|
|
||||||
service = TestBed.inject(AlertsService);
|
|
||||||
httpMock = TestBed.inject(HttpTestingController);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => httpMock.verify());
|
|
||||||
|
|
||||||
it("appelle le bon endpoint et retourne un tableau d'alertes", () => {
|
|
||||||
let result: unknown;
|
|
||||||
service.getAlerts().subscribe((r) => (result = r));
|
|
||||||
|
|
||||||
const req = httpMock.expectOne(`${environment.apiUrl}/alerts`);
|
|
||||||
expect(req.request.method).toBe('GET');
|
|
||||||
|
|
||||||
req.flush([
|
|
||||||
{
|
|
||||||
alert_id: 'ALR-TEST-1',
|
|
||||||
timestamp: '2026-09-15T12:00:00',
|
|
||||||
site_id: 'SITE001',
|
|
||||||
severity: 'high',
|
|
||||||
type: 'threshold',
|
|
||||||
message: 'Test',
|
|
||||||
value: 100,
|
|
||||||
threshold: 90,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect((result as unknown[]).length).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Service, inject } from '@angular/core';
|
|
||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
import { environment } from '../../../environments/environment';
|
|
||||||
import { Alert } from '../../shared/models/alert.model';
|
|
||||||
|
|
||||||
@Service()
|
|
||||||
export class AlertsService {
|
|
||||||
private http = inject(HttpClient);
|
|
||||||
|
|
||||||
getAlerts() {
|
|
||||||
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
|
||||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
|
||||||
import { StatsService } from './stats.service';
|
|
||||||
import { environment } from '../../../environments/environment';
|
|
||||||
|
|
||||||
describe('StatsService', () => {
|
|
||||||
let service: StatsService;
|
|
||||||
let httpMock: HttpTestingController;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
|
||||||
});
|
|
||||||
service = TestBed.inject(StatsService);
|
|
||||||
httpMock = TestBed.inject(HttpTestingController);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => httpMock.verify());
|
|
||||||
|
|
||||||
it('appelle le bon endpoint et retourne le résumé', () => {
|
|
||||||
let result: unknown;
|
|
||||||
service.getSummary().subscribe((r) => (result = r));
|
|
||||||
|
|
||||||
const req = httpMock.expectOne(`${environment.apiUrl}/stats/summary`);
|
|
||||||
expect(req.request.method).toBe('GET');
|
|
||||||
|
|
||||||
req.flush({
|
|
||||||
timestamp: '2026-09-15T12:00:00',
|
|
||||||
total_sites: 7,
|
|
||||||
total_consumption_kw: 1800,
|
|
||||||
total_capacity_kw: 3800,
|
|
||||||
average_load_percent: 47.4,
|
|
||||||
sites: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect((result as { total_sites: number }).total_sites).toBe(7);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Service, inject } from '@angular/core';
|
|
||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
import { environment } from '../../../environments/environment';
|
|
||||||
import { StatsSummary } from '../../shared/models/stats.model';
|
|
||||||
|
|
||||||
@Service()
|
|
||||||
export class StatsService {
|
|
||||||
private http = inject(HttpClient);
|
|
||||||
|
|
||||||
getSummary() {
|
|
||||||
return this.http.get<StatsSummary>(`${environment.apiUrl}/stats/summary`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<div class="dashboard">
|
|
||||||
<header class="dashboard__header">
|
|
||||||
<h1>Vue d'ensemble</h1>
|
|
||||||
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
@if (error(); as message) {
|
|
||||||
<p class="banner-error" role="alert">{{ message }}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (stats(); as s) {
|
|
||||||
<section class="overview">
|
|
||||||
<div class="card card--gauge">
|
|
||||||
<span class="card__label">Consommation vs capacité</span>
|
|
||||||
<app-consumption-gauge
|
|
||||||
[consumption]="s.total_consumption_kw"
|
|
||||||
[capacity]="s.total_capacity_kw"
|
|
||||||
/>
|
|
||||||
<span class="card__value"
|
|
||||||
>{{ s.total_consumption_kw | number: '1.0-1' }} /
|
|
||||||
{{ s.total_capacity_kw | number }} kW</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<span class="card__label">Charge moyenne du parc</span>
|
|
||||||
<span class="card__value">{{ s.average_load_percent }} %</span>
|
|
||||||
<div class="progress-bar">
|
|
||||||
<div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<span class="card__label">Sites suivis</span>
|
|
||||||
<span class="card__value">{{ s.total_sites }}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="chart-section">
|
|
||||||
<h2>Charge et alerte visuelle par site</h2>
|
|
||||||
<app-site-load-chart [sites]="s.sites" />
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (alerts().length > 0) {
|
|
||||||
<section class="alerts-section">
|
|
||||||
<h2>Alertes actives</h2>
|
|
||||||
<ul class="alerts-list">
|
|
||||||
@for (alert of alerts(); track alert.alert_id) {
|
|
||||||
<li class="alert-item" [class]="'alert-item--' + alert.severity">
|
|
||||||
<span class="alert-item__badge">{{ alert.severity }}</span>
|
|
||||||
<span class="alert-item__message">{{ alert.message }}</span>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
:host {
|
|
||||||
--color-good: #2e7d32;
|
|
||||||
--color-partial: #f9a825;
|
|
||||||
--color-degraded: #ef6c00;
|
|
||||||
--color-critical: #c62828;
|
|
||||||
--color-bg-card: #ffffff;
|
|
||||||
--color-border: #e5e7eb;
|
|
||||||
--color-text-muted: #6b7280;
|
|
||||||
--radius: 10px;
|
|
||||||
|
|
||||||
display: block;
|
|
||||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
|
||||||
color: #1f2937;
|
|
||||||
padding: 2rem;
|
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard__header {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard__subtitle {
|
|
||||||
margin: 0.25rem 0 0;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-error {
|
|
||||||
margin: 0 0 1.5rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border: 1px solid var(--color-critical);
|
|
||||||
border-left-width: 4px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: #fdecea;
|
|
||||||
color: var(--color-critical);
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card--gauge {
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card__label {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card__value {
|
|
||||||
font-size: 1.6rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
height: 6px;
|
|
||||||
background: #e5e7eb;
|
|
||||||
border-radius: 999px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar__fill {
|
|
||||||
height: 100%;
|
|
||||||
background: #3b82f6;
|
|
||||||
border-radius: 999px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-section {
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alerts-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.7rem 1rem;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: #fef2f2;
|
|
||||||
border: 1px solid #fecaca;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-item__badge {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 0.2rem 0.55rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
color: #fff;
|
|
||||||
background: var(--color-critical);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-item--high .alert-item__badge {
|
|
||||||
background: var(--color-degraded);
|
|
||||||
}
|
|
||||||
.alert-item--medium .alert-item__badge {
|
|
||||||
background: var(--color-partial);
|
|
||||||
}
|
|
||||||
.alert-item--low .alert-item__badge {
|
|
||||||
background: var(--color-good);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-item__message {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { vi } from 'vitest';
|
|
||||||
import { of, throwError } from 'rxjs';
|
|
||||||
import { Dashboard } from './dashboard';
|
|
||||||
import { StatsService } from '../../core/services/stats.service';
|
|
||||||
import { AlertsService } from '../../core/services/alerts.service';
|
|
||||||
|
|
||||||
vi.mock('chart.js', () => {
|
|
||||||
class ChartMock {
|
|
||||||
update = vi.fn();
|
|
||||||
destroy = vi.fn();
|
|
||||||
data = { datasets: [{}] };
|
|
||||||
static register = vi.fn();
|
|
||||||
}
|
|
||||||
return { Chart: ChartMock, registerables: [] };
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Dashboard', () => {
|
|
||||||
afterEach(() => vi.useRealTimers());
|
|
||||||
|
|
||||||
it('charge les stats et les alertes au démarrage', async () => {
|
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) };
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
// laisse le timer(0, ...) se déclencher avant de vérifier
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(statsMock.getSummary).toHaveBeenCalled();
|
|
||||||
expect(alertsMock.getAlerts).toHaveBeenCalled();
|
|
||||||
expect(fixture.componentInstance.alerts().length).toBe(1);
|
|
||||||
expect(fixture.componentInstance.error()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const statsMock = {
|
|
||||||
getSummary: vi
|
|
||||||
.fn()
|
|
||||||
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
|
|
||||||
.mockReturnValue(of({ total_sites: 7, sites: [] })),
|
|
||||||
};
|
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(1);
|
|
||||||
expect(statsMock.getSummary).toHaveBeenCalledTimes(1);
|
|
||||||
expect(fixture.componentInstance.error()).not.toBeNull();
|
|
||||||
expect(fixture.componentInstance.stats()).toBeNull();
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(10000);
|
|
||||||
expect(statsMock.getSummary).toHaveBeenCalledTimes(2);
|
|
||||||
expect(fixture.componentInstance.stats()).not.toBeNull();
|
|
||||||
expect(fixture.componentInstance.error()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("n'interrompt pas la page quand le chargement des alertes échoue", () => {
|
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) };
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(fixture.componentInstance.alerts().length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core';
|
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
||||||
import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs';
|
|
||||||
import { DecimalPipe } from '@angular/common';
|
|
||||||
import { StatsService } from '../../core/services/stats.service';
|
|
||||||
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
|
|
||||||
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
|
|
||||||
import { AlertsService } from '../../core/services/alerts.service';
|
|
||||||
import { StatsSummary } from '../../shared/models/stats.model';
|
|
||||||
import { Alert } from '../../shared/models/alert.model';
|
|
||||||
|
|
||||||
const REFRESH_INTERVAL_MS = 10000;
|
|
||||||
const UNAVAILABLE_MESSAGE =
|
|
||||||
'Données indisponibles, les valeurs affichées datent du dernier relevé.';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-dashboard',
|
|
||||||
standalone: true,
|
|
||||||
imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart],
|
|
||||||
templateUrl: './dashboard.html',
|
|
||||||
styleUrl: './dashboard.scss',
|
|
||||||
})
|
|
||||||
export class Dashboard implements OnInit {
|
|
||||||
private statsService = inject(StatsService);
|
|
||||||
private alertsService = inject(AlertsService);
|
|
||||||
private destroyRef = inject(DestroyRef);
|
|
||||||
|
|
||||||
stats = signal<StatsSummary | null>(null);
|
|
||||||
alerts = signal<Alert[]>([]);
|
|
||||||
error = signal<string | null>(null);
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.alertsService
|
|
||||||
.getAlerts()
|
|
||||||
.pipe(catchError(() => this.reportUnavailable()))
|
|
||||||
.subscribe((alerts) => this.alerts.set(alerts));
|
|
||||||
|
|
||||||
// Piège : le catchError porte sur l'observable interne. Sur le flux externe il
|
|
||||||
// terminerait le timer, et le rafraîchissement ne repartirait jamais.
|
|
||||||
timer(0, REFRESH_INTERVAL_MS)
|
|
||||||
.pipe(
|
|
||||||
switchMap(() =>
|
|
||||||
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())),
|
|
||||||
),
|
|
||||||
takeUntilDestroyed(this.destroyRef),
|
|
||||||
)
|
|
||||||
.subscribe((stats) => {
|
|
||||||
this.error.set(null);
|
|
||||||
this.stats.set(stats);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private reportUnavailable(): Observable<never> {
|
|
||||||
this.error.set(UNAVAILABLE_MESSAGE);
|
|
||||||
return EMPTY;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<canvas #canvas></canvas>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
height: 200px;
|
|
||||||
width: 200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { vi } from 'vitest';
|
|
||||||
import { Chart } from 'chart.js';
|
|
||||||
import { ConsumptionGauge } from './consumption-gauge';
|
|
||||||
|
|
||||||
vi.mock('chart.js', () => {
|
|
||||||
class ChartMock {
|
|
||||||
static instances: ChartMock[] = [];
|
|
||||||
static register = vi.fn();
|
|
||||||
update = vi.fn();
|
|
||||||
destroy = vi.fn();
|
|
||||||
data = { datasets: [{}] };
|
|
||||||
constructor() {
|
|
||||||
ChartMock.instances.push(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { Chart: ChartMock, registerables: [] };
|
|
||||||
});
|
|
||||||
|
|
||||||
type ChartDouble = { destroy: ReturnType<typeof vi.fn> };
|
|
||||||
|
|
||||||
function lastChart(): ChartDouble | undefined {
|
|
||||||
return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ConsumptionGauge', () => {
|
|
||||||
it('se crée sans erreur avec des entrées valides', () => {
|
|
||||||
TestBed.configureTestingModule({ imports: [ConsumptionGauge] });
|
|
||||||
const fixture = TestBed.createComponent(ConsumptionGauge);
|
|
||||||
fixture.componentRef.setInput('consumption', 300);
|
|
||||||
fixture.componentRef.setInput('capacity', 1000);
|
|
||||||
expect(() => fixture.detectChanges()).not.toThrow();
|
|
||||||
});
|
|
||||||
it('met à jour le graphique quand les valeurs changent après initialisation', () => {
|
|
||||||
TestBed.configureTestingModule({ imports: [ConsumptionGauge] });
|
|
||||||
const fixture = TestBed.createComponent(ConsumptionGauge);
|
|
||||||
fixture.componentRef.setInput('consumption', 300);
|
|
||||||
fixture.componentRef.setInput('capacity', 1000);
|
|
||||||
fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais
|
|
||||||
|
|
||||||
fixture.componentRef.setInput('consumption', 500);
|
|
||||||
fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini
|
|
||||||
|
|
||||||
expect(() => fixture.detectChanges()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('détruit le graphique quand le composant est détruit', () => {
|
|
||||||
TestBed.configureTestingModule({ imports: [ConsumptionGauge] });
|
|
||||||
const fixture = TestBed.createComponent(ConsumptionGauge);
|
|
||||||
fixture.componentRef.setInput('consumption', 300);
|
|
||||||
fixture.componentRef.setInput('capacity', 1000);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
const chart = lastChart();
|
|
||||||
fixture.destroy();
|
|
||||||
|
|
||||||
expect(chart?.destroy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
ElementRef,
|
|
||||||
ViewChild,
|
|
||||||
input,
|
|
||||||
effect,
|
|
||||||
AfterViewInit,
|
|
||||||
OnDestroy,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { Chart, registerables } from 'chart.js';
|
|
||||||
|
|
||||||
Chart.register(...registerables);
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-consumption-gauge',
|
|
||||||
standalone: true,
|
|
||||||
templateUrl: './consumption-gauge.html',
|
|
||||||
styleUrl: './consumption-gauge.scss',
|
|
||||||
})
|
|
||||||
export class ConsumptionGauge implements AfterViewInit, OnDestroy {
|
|
||||||
consumption = input.required<number>();
|
|
||||||
capacity = input.required<number>();
|
|
||||||
|
|
||||||
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
|
|
||||||
private chart?: Chart;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
const used = this.consumption();
|
|
||||||
const remaining = Math.max(0, this.capacity() - used);
|
|
||||||
if (this.chart) {
|
|
||||||
this.chart.data.datasets[0].data = [used, remaining];
|
|
||||||
this.chart.update('none');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
|
||||||
const used = this.consumption();
|
|
||||||
const remaining = Math.max(0, this.capacity() - used);
|
|
||||||
|
|
||||||
this.chart = new Chart(this.canvasRef.nativeElement, {
|
|
||||||
type: 'doughnut',
|
|
||||||
data: {
|
|
||||||
labels: ['Utilisé', 'Disponible'],
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
data: [used, remaining],
|
|
||||||
backgroundColor: ['#3b82f6', '#e5e7eb'],
|
|
||||||
borderWidth: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
cutout: '70%',
|
|
||||||
animation: { duration: 300 },
|
|
||||||
plugins: { legend: { display: false } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.chart?.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<canvas #canvas></canvas>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
height: 260px;
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { vi } from 'vitest';
|
|
||||||
import { Chart } from 'chart.js';
|
|
||||||
import { SiteLoadChart } from './site-load-chart';
|
|
||||||
|
|
||||||
vi.mock('chart.js', () => {
|
|
||||||
class ChartMock {
|
|
||||||
static instances: ChartMock[] = [];
|
|
||||||
static register = vi.fn();
|
|
||||||
update = vi.fn();
|
|
||||||
destroy = vi.fn();
|
|
||||||
data = { datasets: [{}] };
|
|
||||||
constructor() {
|
|
||||||
ChartMock.instances.push(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { Chart: ChartMock, registerables: [] };
|
|
||||||
});
|
|
||||||
|
|
||||||
type ChartDouble = { destroy: ReturnType<typeof vi.fn> };
|
|
||||||
|
|
||||||
function lastChart(): ChartDouble | undefined {
|
|
||||||
return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('SiteLoadChart', () => {
|
|
||||||
it('se crée sans erreur avec une liste de sites valide', () => {
|
|
||||||
TestBed.configureTestingModule({ imports: [SiteLoadChart] });
|
|
||||||
const fixture = TestBed.createComponent(SiteLoadChart);
|
|
||||||
fixture.componentRef.setInput('sites', [
|
|
||||||
{
|
|
||||||
site_id: 'S1',
|
|
||||||
site_name: 'Test',
|
|
||||||
current_consumption_kw: 50,
|
|
||||||
capacity_kw: 100,
|
|
||||||
load_percent: 50,
|
|
||||||
data_quality: 'good',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(() => fixture.detectChanges()).not.toThrow();
|
|
||||||
});
|
|
||||||
it('met à jour le graphique quand les sites changent après initialisation', () => {
|
|
||||||
TestBed.configureTestingModule({ imports: [SiteLoadChart] });
|
|
||||||
const fixture = TestBed.createComponent(SiteLoadChart);
|
|
||||||
fixture.componentRef.setInput('sites', [
|
|
||||||
{
|
|
||||||
site_id: 'S1',
|
|
||||||
site_name: 'A',
|
|
||||||
current_consumption_kw: 50,
|
|
||||||
capacity_kw: 100,
|
|
||||||
load_percent: 50,
|
|
||||||
data_quality: 'good',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais
|
|
||||||
|
|
||||||
fixture.componentRef.setInput('sites', [
|
|
||||||
{
|
|
||||||
site_id: 'S2',
|
|
||||||
site_name: 'B',
|
|
||||||
current_consumption_kw: 80,
|
|
||||||
capacity_kw: 100,
|
|
||||||
load_percent: 80,
|
|
||||||
data_quality: 'critical',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini
|
|
||||||
|
|
||||||
expect(() => fixture.detectChanges()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('détruit le graphique quand le composant est détruit', () => {
|
|
||||||
TestBed.configureTestingModule({ imports: [SiteLoadChart] });
|
|
||||||
const fixture = TestBed.createComponent(SiteLoadChart);
|
|
||||||
fixture.componentRef.setInput('sites', [
|
|
||||||
{
|
|
||||||
site_id: 'S1',
|
|
||||||
site_name: 'A',
|
|
||||||
current_consumption_kw: 50,
|
|
||||||
capacity_kw: 100,
|
|
||||||
load_percent: 50,
|
|
||||||
data_quality: 'good',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
const chart = lastChart();
|
|
||||||
fixture.destroy();
|
|
||||||
|
|
||||||
expect(chart?.destroy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
ElementRef,
|
|
||||||
ViewChild,
|
|
||||||
input,
|
|
||||||
effect,
|
|
||||||
AfterViewInit,
|
|
||||||
OnDestroy,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { Chart, registerables } from 'chart.js';
|
|
||||||
import { SiteSummary } from '../../models/stats.model';
|
|
||||||
|
|
||||||
Chart.register(...registerables);
|
|
||||||
|
|
||||||
const QUALITY_COLORS: Record<SiteSummary['data_quality'], string> = {
|
|
||||||
good: '#2e7d32',
|
|
||||||
partial: '#f9a825',
|
|
||||||
degraded: '#ef6c00',
|
|
||||||
critical: '#c62828',
|
|
||||||
};
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-site-load-chart',
|
|
||||||
standalone: true,
|
|
||||||
templateUrl: './site-load-chart.html',
|
|
||||||
styleUrl: './site-load-chart.scss',
|
|
||||||
})
|
|
||||||
export class SiteLoadChart implements AfterViewInit, OnDestroy {
|
|
||||||
sites = input.required<SiteSummary[]>();
|
|
||||||
|
|
||||||
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
|
|
||||||
private chart?: Chart;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
const sites = this.sites();
|
|
||||||
if (this.chart) {
|
|
||||||
this.chart.data.labels = sites.map((s) => s.site_name);
|
|
||||||
this.chart.data.datasets[0].data = sites.map((s) => s.load_percent ?? 0);
|
|
||||||
this.chart.data.datasets[0].backgroundColor = sites.map(
|
|
||||||
(s) => QUALITY_COLORS[s.data_quality],
|
|
||||||
);
|
|
||||||
this.chart.update('none');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
|
||||||
const sites = this.sites();
|
|
||||||
this.chart = new Chart(this.canvasRef.nativeElement, {
|
|
||||||
type: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: sites.map((s) => s.site_name),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
data: sites.map((s) => s.load_percent ?? 0),
|
|
||||||
backgroundColor: sites.map((s) => QUALITY_COLORS[s.data_quality]),
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: { legend: { display: false } },
|
|
||||||
scales: {
|
|
||||||
y: { beginAtZero: true, max: 100, title: { display: true, text: 'Charge (%)' } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.chart?.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical';
|
|
||||||
export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
|
|
||||||
|
|
||||||
export interface Alert {
|
|
||||||
alert_id: string;
|
|
||||||
timestamp: string;
|
|
||||||
site_id: string;
|
|
||||||
severity: AlertSeverity;
|
|
||||||
type: AlertType;
|
|
||||||
message: string;
|
|
||||||
value: number;
|
|
||||||
threshold: number;
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export interface SiteSummary {
|
|
||||||
site_id: string;
|
|
||||||
site_name: string;
|
|
||||||
current_consumption_kw: number | null;
|
|
||||||
capacity_kw: number;
|
|
||||||
load_percent: number | null;
|
|
||||||
data_quality: 'good' | 'partial' | 'degraded' | 'critical';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatsSummary {
|
|
||||||
timestamp: string;
|
|
||||||
total_sites: number;
|
|
||||||
total_consumption_kw: number;
|
|
||||||
total_capacity_kw: number;
|
|
||||||
average_load_percent: number;
|
|
||||||
sites: SiteSummary[];
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
apiUrl: '/api/v1',
|
apiUrl: '/api/v1'
|
||||||
useMockFixtures: true, // a passer a false une fois le backend prêt
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
apiUrl: 'http://localhost:8000/api/v1',
|
apiUrl: 'http://localhost:8000/api/v1'
|
||||||
useMockFixtures: false,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<testsuites name="vitest tests" tests="2" failures="0" errors="0" time="0.0699261">
|
||||||
|
<testsuite name="src/app/app.spec.ts" timestamp="2026-09-14T14:27:28.895Z" hostname="76SE37-GL5HHZ3" tests="2" failures="0" errors="0" skipped="0" time="0.0699261">
|
||||||
|
<testcase classname="src/app/app.spec.ts" name="App > should create the app" time="0.0527847">
|
||||||
|
</testcase>
|
||||||
|
<testcase classname="src/app/app.spec.ts" name="App > should render title" time="0.015831">
|
||||||
|
</testcase>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
@@ -63,9 +63,8 @@ flowchart TB
|
|||||||
grafana -.-> prom
|
grafana -.-> prom
|
||||||
```
|
```
|
||||||
|
|
||||||
Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API, mais un
|
Le lien `front -.-> api` est en pointillé à dessein : le frontend n'appelle aujourd'hui aucune
|
||||||
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
|
API, `provideHttpClient` n'est pas encore installé. Voir [30-frontend.md](30-frontend.md).
|
||||||
[30-frontend.md](30-frontend.md).
|
|
||||||
|
|
||||||
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
|
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
|
||||||
collecteur ne vient le lire.
|
collecteur ne vient le lire.
|
||||||
@@ -75,7 +74,7 @@ collecteur ne vient le lire.
|
|||||||
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement |
|
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`. Aucune couche métier |
|
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`. Aucune couche métier |
|
||||||
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures |
|
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Squelette `ng new` standalone, routes vides, aucun service HTTP |
|
||||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Aucune table applicative |
|
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Aucune table applicative |
|
||||||
| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
|
| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
|
||||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
||||||
|
|||||||
@@ -4,36 +4,30 @@ Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps
|
|||||||
|
|
||||||
## État actuel
|
## État actuel
|
||||||
|
|
||||||
Statut : `En cours`. L'application sert une première page métier, le tableau de bord, alimentée
|
Statut : `En cours`. Le projet est un `ng new` intact. Le tableau de la
|
||||||
par des fixtures : les endpoints qu'elle appelle n'existent pas encore côté API.
|
[vue d'ensemble](00-vue-ensemble.md) le classe désormais correctement, le `README.md` racine le
|
||||||
|
disait encore « à initialiser » alors que le squelette existe depuis `49f4697`.
|
||||||
|
|
||||||
Ce qui est en place :
|
Ce qui est en place :
|
||||||
|
|
||||||
- Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt.
|
- Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt.
|
||||||
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
|
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()` et `provideRouter(routes)`.
|
||||||
`provideHttpClient(withInterceptors([mockApiInterceptor]))`.
|
- Vitest via le builder `@angular/build:unit-test`, couverture activée, un fichier de test.
|
||||||
- Une route `/dashboard` en composant différé, et une redirection depuis la racine.
|
|
||||||
- `core/services` porte `StatsService` et `AlertsService`, `core/interceptors` l'intercepteur de
|
|
||||||
fixtures, `features/dashboard` la page, `shared/components` la jauge de consommation et le
|
|
||||||
graphique de charge par site, tous deux construits sur Chart.js.
|
|
||||||
- L'état vit dans des signaux, sans bibliothèque dédiée.
|
|
||||||
- Vitest via le builder `@angular/build:unit-test`, couverture activée, sept fichiers de test.
|
|
||||||
- Prettier configuré, parser `angular` pour les gabarits HTML.
|
- Prettier configuré, parser `angular` pour les gabarits HTML.
|
||||||
|
|
||||||
Ce qui n'existe pas encore :
|
Ce qui n'existe pas encore :
|
||||||
|
|
||||||
- **Aucun endpoint réel derrière l'écran.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts`
|
- `routes` est un tableau vide. Aucune page, aucune navigation.
|
||||||
sont servis par l'intercepteur ; l'API expose `/health`, `/auth` et `/users`, rien d'autre.
|
- **`provideHttpClient` n'est pas fourni** et `@angular/common/http` n'est importé nulle part :
|
||||||
- Aucune authentification côté interface : ni garde de route, ni intercepteur de jeton, alors que
|
l'application n'appelle aucune API.
|
||||||
les routes métier de l'API en exigent un. Voir
|
- `app.html` est la page d'accueil Angular par défaut, commentaires de remplacement compris.
|
||||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
- Aucune bibliothèque de graphiques, aucun kit d'interface, aucune gestion d'état.
|
||||||
- Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide.
|
|
||||||
- Aucun lint : ESLint n'est pas installé.
|
- Aucun lint : ESLint n'est pas installé.
|
||||||
|
|
||||||
## Arborescence
|
## Arborescence cible
|
||||||
|
|
||||||
Statut : `Fait`. Elle suit ce que [`TESTING.md`](../../apps/frontend/TESTING.md) prescrit dans ses
|
Statut : `Cible`. Elle n'est pas inventée ici : [`TESTING.md`](../../apps/frontend/TESTING.md) la
|
||||||
gabarits de tests.
|
prescrit déjà dans ses gabarits de tests.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
@@ -54,33 +48,22 @@ directement : ils passent par un service, ce qui rend le double de test trivial.
|
|||||||
|
|
||||||
## Flux HTTP
|
## Flux HTTP
|
||||||
|
|
||||||
Statut : `En cours`. Le chemin complet est câblé, mais un intercepteur se place devant et répond
|
Statut : `Cible`. Le chemin est câblé, rien ne l'emprunte encore.
|
||||||
lui-même tant que les endpoints n'existent pas.
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant C as Composant
|
participant C as Composant
|
||||||
participant S as Service Angular
|
participant S as Service Angular
|
||||||
participant I as mockApiInterceptor
|
|
||||||
participant P as ng serve, proxy
|
participant P as ng serve, proxy
|
||||||
participant A as FastAPI
|
participant A as FastAPI
|
||||||
|
|
||||||
C->>S: appel de méthode
|
C->>S: appel de méthode
|
||||||
S->>I: GET /api/v1/...
|
S->>P: GET /api/v1/...
|
||||||
alt useMockFixtures actif et route connue
|
|
||||||
I-->>S: fixture locale
|
|
||||||
else
|
|
||||||
I->>P: la requête poursuit
|
|
||||||
P->>A: http://localhost:8000/api/v1/...
|
P->>A: http://localhost:8000/api/v1/...
|
||||||
A-->>S: JSON
|
A-->>S: JSON
|
||||||
end
|
|
||||||
S-->>C: modèle typé
|
S-->>C: modèle typé
|
||||||
```
|
```
|
||||||
|
|
||||||
`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si
|
|
||||||
`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en
|
|
||||||
production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel.
|
|
||||||
|
|
||||||
En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce
|
En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce
|
||||||
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
|
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
|
||||||
`apiUrl` relatif, `/api/v1`.
|
`apiUrl` relatif, `/api/v1`.
|
||||||
@@ -104,10 +87,6 @@ déploiement, en même temps que sera tranchée la question de l'ingress dans
|
|||||||
| `npm run test` | Vitest en mode observateur |
|
| `npm run test` | Vitest en mode observateur |
|
||||||
| `npm run test:ci` | Vitest en une passe |
|
| `npm run test:ci` | Vitest en une passe |
|
||||||
|
|
||||||
**Version de Node.** L'Angular CLI refuse de démarrer en dessous de 22.22.3, 24.15.0 ou 26.0.0, et
|
|
||||||
le message d'erreur arrive avant toute compilation. Un poste en 22.21 ou en 24.12 ne peut donc ni
|
|
||||||
tester ni construire le frontend.
|
|
||||||
|
|
||||||
Le frontend **n'a pas de cible dans le `Makefile` racine** et **aucun service dans
|
Le frontend **n'a pas de cible dans le `Makefile` racine** et **aucun service dans
|
||||||
`docker-compose.yml`** : il se pilote uniquement par `npm`, depuis `apps/frontend`. Le port 4200
|
`docker-compose.yml`** : il se pilote uniquement par `npm`, depuis `apps/frontend`. Le port 4200
|
||||||
n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend.
|
n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend.
|
||||||
@@ -119,9 +98,8 @@ avec un service statique, il reste à écrire.
|
|||||||
## Sécurité
|
## Sécurité
|
||||||
|
|
||||||
- Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL.
|
- Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL.
|
||||||
- L'authentification existe côté API mais pas côté interface : aucune garde de route, aucun
|
- L'authentification n'existe pas côté API, donc pas de garde ni d'intercepteur de jeton à ce
|
||||||
intercepteur de jeton. `core/guards` reste à créer, `core/interceptors` n'héberge aujourd'hui
|
stade. `core/guards` et `core/interceptors` sont prévus pour cela.
|
||||||
que les fixtures.
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -129,7 +107,8 @@ Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTI
|
|||||||
|
|
||||||
## Questions ouvertes
|
## Questions ouvertes
|
||||||
|
|
||||||
- **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs
|
- **Quelle bibliothèque de graphiques** pour les séries temporelles, et si Grafana en couvre déjà
|
||||||
pages partageront le même état.
|
une partie du besoin.
|
||||||
|
- **Gestion d'état** : signaux seuls, ou une bibliothèque dédiée.
|
||||||
- **Comment `apiUrl` est injecté en production** : build par environnement, ou configuration lue
|
- **Comment `apiUrl` est injecté en production** : build par environnement, ou configuration lue
|
||||||
au démarrage.
|
au démarrage.
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ PostgreSQL 17 avec l'extension TimescaleDB. Le choix, ses alternatives et ses co
|
|||||||
dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md), qui fait foi. Ce document décrit le
|
dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md), qui fait foi. Ce document décrit le
|
||||||
système qui en découle.
|
système qui en découle.
|
||||||
|
|
||||||
## Avertissement
|
## Ce que couvre ce document
|
||||||
|
|
||||||
**Aucune table applicative n'existe à ce jour.** `Base.metadata` est vide, `app/models/` ne
|
**Dix tables applicatives existent** : quatre pour l'authentification, six pour les données
|
||||||
contient qu'un commentaire, l'unique révision Alembic ne crée aucune table, et aucune hypertable
|
d'énergie, dont l'hypertable `reading`. Les sections marquées `Fait` relèvent le code. Celles
|
||||||
n'a été déclarée. Tout ce qui suit sous le statut `Cible` est une proposition de structure, pas un
|
marquées `Cible` décrivent ce qui n'est pas écrit, au premier rang desquelles la chaîne
|
||||||
relevé du code. Le modèle sera arrêté au jalon J2.
|
d'ingestion, les agrégats continus, la compression et la rétention.
|
||||||
|
|
||||||
## Trois emplacements, trois rôles
|
## Trois emplacements, trois rôles
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ Statut : `Fait`.
|
|||||||
- `db/init/100-extensions.sql` crée l'extension `timescaledb`.
|
- `db/init/100-extensions.sql` crée l'extension `timescaledb`.
|
||||||
- `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par
|
- `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par
|
||||||
`apps/backend/tests/conftest.py`.
|
`apps/backend/tests/conftest.py`.
|
||||||
- Quatre révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle
|
- Cinq révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle
|
||||||
établit `alembic_version` et refuse de s'appliquer si l'extension manque :
|
établit `alembic_version` et refuse de s'appliquer si l'extension manque :
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
@@ -48,16 +48,18 @@ Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap saut
|
|||||||
au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés.
|
au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés.
|
||||||
|
|
||||||
Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`,
|
Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`,
|
||||||
puis `login_attempt` et `audit_log`, puis `refresh_token`.
|
puis `login_attempt` et `audit_log`, puis `refresh_token`. La cinquième, `e6d2026091501`, crée
|
||||||
|
les six tables de données décrites en fin de document et déclare l'hypertable `reading`.
|
||||||
|
|
||||||
## Cycle de vie d'une mesure
|
## Cycle de vie d'une mesure
|
||||||
|
|
||||||
Statut : `Cible`. Aucun de ces maillons n'existe.
|
Statut : `Cible`, sauf l'hypertable `reading` qui existe. Ni l'ingestion, ni les agrégats
|
||||||
|
continus, ni la compression, ni la rétention ne sont écrits.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
src["Source de mesures"] -.-> ing["Ingestion Airflow"]
|
src["Source de mesures"] -.-> ing["Ingestion Airflow"]
|
||||||
ing -.-> hy[("Hypertable mesure")]
|
ing -.-> hy[("Hypertable reading")]
|
||||||
hy -.-> agg[("Agrégat continu")]
|
hy -.-> agg[("Agrégat continu")]
|
||||||
hy -.-> comp["Compression"]
|
hy -.-> comp["Compression"]
|
||||||
hy -.-> ret["Rétention"]
|
hy -.-> ret["Rétention"]
|
||||||
@@ -133,67 +135,46 @@ donc **pas** une hypertable : une politique de rétention émettrait des `DELETE
|
|||||||
refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est
|
refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est
|
||||||
piloté par l'attaquant.
|
piloté par l'attaquant.
|
||||||
|
|
||||||
## Modèle métier
|
|
||||||
|
|
||||||
Statut : `Cible`. Les entités ci-dessous sont des **candidates**, à valider en J2. Elles
|
|
||||||
s'appuient sur les gabarits de [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md), qui
|
|
||||||
évoquent déjà un modèle `Site`, un `SiteRepository` et un `ConsumptionService` exposant un
|
|
||||||
`total_kwh(site_id)`.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
erDiagram
|
|
||||||
SITE ||--o{ POINT_DE_MESURE : porte
|
|
||||||
POINT_DE_MESURE ||--o{ MESURE : produit
|
|
||||||
|
|
||||||
SITE {
|
|
||||||
int id PK
|
|
||||||
string nom
|
|
||||||
}
|
|
||||||
POINT_DE_MESURE {
|
|
||||||
int id PK
|
|
||||||
int site_id FK
|
|
||||||
string libelle
|
|
||||||
string unite
|
|
||||||
}
|
|
||||||
MESURE {
|
|
||||||
timestamptz horodatage PK
|
|
||||||
int point_id PK
|
|
||||||
double valeur
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`MESURE` est la table destinée à devenir une hypertable, partitionnée sur `horodatage`. Sa clé
|
|
||||||
primaire doit inclure la colonne de temps : TimescaleDB l'exige, une clé sur le seul identifiant
|
|
||||||
de point serait refusée.
|
|
||||||
|
|
||||||
## Gabarit de révision créant une hypertable
|
## Gabarit de révision créant une hypertable
|
||||||
|
|
||||||
Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision.
|
Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. La révision
|
||||||
|
`e6d2026091501` en est l'exemple réel, réduit ici à l'essentiel.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"mesure",
|
"reading",
|
||||||
sa.Column("horodatage", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("reading_id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
sa.Column("point_id", sa.Integer(), sa.ForeignKey("point_de_mesure.id"), nullable=False),
|
sa.Column("site_id", sa.Text(), nullable=False),
|
||||||
sa.Column("valeur", sa.Float(), nullable=False),
|
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.PrimaryKeyConstraint("horodatage", "point_id"),
|
sa.PrimaryKeyConstraint("reading_id", "timestamp"),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"SELECT create_hypertable('reading', by_range('timestamp'), "
|
||||||
|
"create_default_indexes => FALSE)"
|
||||||
)
|
)
|
||||||
op.execute("SELECT create_hypertable('mesure', by_range('horodatage'))")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.drop_table("mesure")
|
op.drop_table("reading")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
La clé primaire inclut la colonne de temps parce que TimescaleDB l'exige : toute contrainte
|
||||||
|
unique d'une hypertable doit porter la colonne de partitionnement, et une clé sur le seul
|
||||||
|
`reading_id` serait refusée par `create_hypertable`.
|
||||||
|
|
||||||
|
`create_default_indexes => FALSE` écarte l'index que TimescaleDB pose d'office sur la seule
|
||||||
|
colonne de temps : les index déclarés dans la révision le couvrent déjà.
|
||||||
|
|
||||||
`drop_table` suffit au retour arrière : supprimer la table supprime l'hypertable et ses partitions.
|
`drop_table` suffit au retour arrière : supprimer la table supprime l'hypertable et ses partitions.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Noms au singulier**, en minuscules, sans préfixe de table.
|
- **Noms au singulier**, en minuscules, sans préfixe de table : `app_user`, `reading`.
|
||||||
- **Toute colonne de temps en `timestamptz`.** Jamais de `timestamp` nu : une mesure sans fuseau
|
- **Toute colonne de temps en `timestamptz`.** Jamais de `timestamp` nu : une mesure sans fuseau
|
||||||
devient ininterprétable dès le premier changement d'heure.
|
devient ininterprétable dès le premier changement d'heure.
|
||||||
- **La colonne de partitionnement s'appelle `horodatage`** et entre dans la clé primaire.
|
- **La colonne de partitionnement entre dans la clé primaire.** Dans `reading` elle s'appelle
|
||||||
|
`timestamp` : c'est un nom de colonne, son type reste `timestamptz`.
|
||||||
- **Les politiques de rétention et de compression** vont dans `db/migrations/`, pas dans Alembic :
|
- **Les politiques de rétention et de compression** vont dans `db/migrations/`, pas dans Alembic :
|
||||||
elles ne découlent pas du schéma applicatif.
|
elles ne découlent pas du schéma applicatif.
|
||||||
- **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi
|
- **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi
|
||||||
@@ -201,13 +182,12 @@ def downgrade() -> None:
|
|||||||
|
|
||||||
## Questions ouvertes
|
## Questions ouvertes
|
||||||
|
|
||||||
Elles relèvent du jalon J2, « valider le périmètre retenu », et bloquent le modèle définitif.
|
Elles relèvent du jalon J2, « valider le périmètre retenu ». Le schéma est livré : ce qui suit
|
||||||
|
porte sur son exploitation, plus sur sa forme.
|
||||||
|
|
||||||
- **Quelles sources de mesures**, et selon quel protocole elles sont collectées.
|
|
||||||
- **Quelle granularité** à l'ingestion : la seconde, la minute, le quart d'heure.
|
- **Quelle granularité** à l'ingestion : la seconde, la minute, le quart d'heure.
|
||||||
- **Quels agrégats continus**, et sur quelles fenêtres.
|
- **Quels agrégats continus**, et sur quelles fenêtres.
|
||||||
- **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse.
|
- **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse.
|
||||||
- **Quelles unités** sont manipulées, et si une même table les mélange.
|
|
||||||
- **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures.
|
- **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures.
|
||||||
|
|
||||||
## Modélisation détaillée des données
|
## Modélisation détaillée des données
|
||||||
@@ -220,12 +200,11 @@ jusqu’aux recommandations proposées à l’utilisateur.
|
|||||||
### Schéma de données
|
### Schéma de données
|
||||||
|
|
||||||
Le diagramme ci-dessous présente les tables et leurs relations.
|
Le diagramme ci-dessous présente les tables et leurs relations.
|
||||||
Il décrit une structure de conception ; les migrations correspondantes
|
La révision `e6d2026091501` les crée.
|
||||||
restent à implémenter.
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
*Figure — Modélisation des données EnerVision.*
|
*Figure : Modélisation des données EnerVision.*
|
||||||
|
|
||||||
### Description des tables
|
### Description des tables
|
||||||
|
|
||||||
@@ -234,15 +213,15 @@ des données.
|
|||||||
|
|
||||||
| Table | Rôle | Origine des informations |
|
| Table | Rôle | Origine des informations |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `datasets` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import |
|
| `dataset` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import |
|
||||||
| `sites` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` |
|
| `site` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` |
|
||||||
| `readings` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` |
|
| `reading` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` |
|
||||||
| `predictions` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision |
|
| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision |
|
||||||
| `alerts` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision |
|
| `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision |
|
||||||
| `recommendations` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision |
|
| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision |
|
||||||
|
|
||||||
Les anomalies historiques décrites dans les JSON sont conservées
|
Les anomalies historiques décrites dans les JSON sont conservées
|
||||||
dans `datasets.metadata`. Elles servent à l’analyse des données
|
dans `dataset.metadata`. Elles servent à l’analyse des données
|
||||||
et ne sont pas considérées comme des alertes actuelles.
|
et ne sont pas considérées comme des alertes actuelles.
|
||||||
|
|
||||||
### Relations entre les tables
|
### Relations entre les tables
|
||||||
|
|||||||
Reference in New Issue
Block a user