# -*- coding: utf-8 -*-
import os
import random
import traceback
from datetime import datetime, date, time
import bcrypt
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import mysql.connector
import pandas as pd
pd.set_option('future.no_silent_downcasting', True)
import streamlit as st
from contextlib import closing
from dotenv import find_dotenv, load_dotenv
env_file = find_dotenv(usecwd=True)
if env_file:
load_dotenv(env_file)
from fpdf import FPDF
# =========================================================
# Config de page
# =========================================================
st.set_page_config(page_title="Domo91 - Surveillance", layout="wide")
st.title("đ Domo91 - Surveillance des sondes")
st.write("Bienvenue sur lâapplication de supervision.")
# =========================================================
# ENV & DB
# =========================================================
load_dotenv()
db_config = {
"host": os.getenv("DB_HOST"),
"user": os.getenv("DB_USER"),
"password": os.getenv("DB_PASS"),
"database": os.getenv("DB_NAME"),
"autocommit": False,
}
SITES_AUTORISES = {"Saclay", "Meudon", "Roissy"} # anti-injection sur noms de tables
def get_connection():
return mysql.connector.connect(**db_config)
# --- Gyro: lecture + badge (auto) ---
def fetch_gyro(site: str):
"""Retourne (etat, ts) depuis la vue v_gyro_last pour le site donné."""
q = """
SELECT Etat, `Date`
FROM Sondes.v_gyro_last
WHERE Lieu = %s AND Sonde = 'Gyro'
ORDER BY `Date` DESC
LIMIT 1
"""
cnx = get_connection()
try:
cur = cnx.cursor(dictionary=True)
cur.execute(q, (site,))
row = cur.fetchone()
if not row:
return None, None
etat = (row.get("Etat") or "").strip().upper()
ts = row.get("Date")
return etat, ts
finally:
try:
cur.close()
except Exception:
pass
try:
cnx.close()
except Exception:
pass
def render_gyro_badge(site: str, stale_after_min: int = 10):
"""Affiche un voyant Gyro (vert/rouge/orange) + fraßcheur des données."""
etat, ts = fetch_gyro(site)
# Etat â couleur/label
if etat in ("ON", "1"):
color, label = "#22c55e", "GYRO ON"
elif etat in ("OFF", "0"):
color, label = "#ef4444", "GYRO OFF"
elif etat in ("ALERTE", "ALARM", "ALARMED"):
color, label = "#f59e0b", "GYRO ALERTE"
else:
color, label = "#9E9E9E", "GYRO INCONNU"
# FraĂźcheur
stale = True
age_txt = "â"
if ts is not None:
try:
# ts provient normalement de MySQL déjà en datetime
from datetime import datetime as _dt
now = _dt.now(ts.tzinfo) if hasattr(ts, "tzinfo") and ts.tzinfo else _dt.now()
mins = int((now - ts).total_seconds() // 60)
stale = mins >= stale_after_min
age_txt = f"MAJ: {ts:%d/%m/%Y %H:%M} ({mins} min)"
except Exception:
pass
border = "4px dashed" if stale else "4px solid"
opacity = "0.6" if stale else "1"
st.markdown(f"""
{label}
{age_txt}{' â donnĂ©es anciennes' if stale else ''}
""", unsafe_allow_html=True)
def get_conn():
return mysql.connector.connect(**db_config)
# =========================================================
# Session state
# =========================================================
for key, default in {
"authenticated": False,
"role": None,
"site_autorise": None,
"onglet_actif": "Accueil",
"selected_date": date.today(),
"selected_site": "Saclay",
"selected_periode": "Toute la journée",
}.items():
st.session_state.setdefault(key, default)
# =========================================================
# Sécurité mots de passe
# =========================================================
def hash_password(plain_password):
return bcrypt.hashpw(plain_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verifier_password(input_password, hash_en_base):
return bcrypt.checkpw(input_password.encode("utf-8"), hash_en_base.encode("utf-8"))
# =========================================================
# Bootstrap schéma : crée Journal_Erreurs si absent
# GĂšre 2 cas :
# - VERSION A : colonne générée (recommandée)
# - VERSION B (fallback) : colonne simple + triggers pour normaliser Source_Id_norm
# =========================================================
def ensure_schema():
ddl_generated = """
CREATE TABLE IF NOT EXISTS Journal_Erreurs (
Id INT AUTO_INCREMENT PRIMARY KEY,
Site VARCHAR(50) NOT NULL,
Sonde VARCHAR(100) NOT NULL,
DateJour DATE NOT NULL,
Type VARCHAR(30) NOT NULL,
Source_Id INT NULL,
Source_Id_norm INT GENERATED ALWAYS AS (COALESCE(Source_Id, 0)) STORED,
Resume TEXT NOT NULL,
Statut VARCHAR(20) NOT NULL DEFAULT 'Nouveau',
Priorite TINYINT NOT NULL DEFAULT 3,
Assignation VARCHAR(100) NULL,
Commentaire TEXT NULL,
Tag VARCHAR(50) NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_site_sonde_date_type (Site, Sonde, DateJour, Type, Source_Id_norm),
KEY idx_journal_date_site (DateJour, Site),
KEY idx_journal_statut (Statut)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
"""
ddl_fallback = """
CREATE TABLE IF NOT EXISTS Journal_Erreurs (
Id INT AUTO_INCREMENT PRIMARY KEY,
Site VARCHAR(50) NOT NULL,
Sonde VARCHAR(100) NOT NULL,
DateJour DATE NOT NULL,
Type VARCHAR(30) NOT NULL,
Source_Id INT NULL,
Source_Id_norm INT NULL DEFAULT 0,
Resume TEXT NOT NULL,
Statut VARCHAR(20) NOT NULL DEFAULT 'Nouveau',
Priorite TINYINT NOT NULL DEFAULT 3,
Assignation VARCHAR(100) NULL,
Commentaire TEXT NULL,
Tag VARCHAR(50) NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
"""
idxs_fallback = [
"CREATE UNIQUE INDEX IF NOT EXISTS uk_site_sonde_date_type ON Sondes.Journal_Erreurs (Site, Sonde, DateJour, Type, Source_Id_norm)",
"CREATE INDEX IF NOT EXISTS idx_journal_date_site ON Sondes.Journal_Erreurs (DateJour, Site)",
"CREATE INDEX IF NOT EXISTS idx_journal_statut ON Sondes.Journal_Erreurs (Statut)",
]
triggers_fallback = [
"""
CREATE TRIGGER IF NOT EXISTS trg_je_bi
BEFORE INSERT ON Sondes.Journal_Erreurs
FOR EACH ROW
BEGIN
SET NEW.Source_Id_norm = IFNULL(NEW.Source_Id, 0);
END
""",
"""
CREATE TRIGGER IF NOT EXISTS trg_je_bu
BEFORE UPDATE ON Sondes.Journal_Erreurs
FOR EACH ROW
BEGIN
SET NEW.Source_Id_norm = IFNULL(NEW.Source_Id, 0);
END
""",
]
try:
with closing(get_conn()) as cnx, closing(cnx.cursor()) as cur:
cur.execute(ddl_generated)
cnx.commit()
except Exception:
# fallback si la colonne générée n'est pas supportée
with closing(get_conn()) as cnx, closing(cnx.cursor()) as cur:
cur.execute(ddl_fallback)
for q in idxs_fallback:
try:
cur.execute(q)
except Exception:
pass
# MariaDB/MySQL n'ont pas tous IF NOT EXISTS sur triggers â on tente drop/try
for name in ("trg_je_bi", "trg_je_bu"):
try:
cur.execute(f"DROP TRIGGER IF EXISTS {name}")
except Exception:
pass
for q in triggers_fallback:
try:
cur.execute(q)
except Exception:
pass
cnx.commit()
# ExĂ©cution Ă lâimport
try:
ensure_schema()
except Exception as e:
st.warning(f"Init schéma Journal_Erreurs : {e}")
# =========================================================
# Connexion utilisateur
# =========================================================
if not st.session_state.get("authenticated", False):
login = st.sidebar.text_input("Nom d'utilisateur")
password = st.sidebar.text_input("Mot de passe", type="password")
if st.sidebar.button("Se connecter"):
try:
conn = get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute(
"""
SELECT NomUtilisateur, role, MotDePasseHash, Site, DateExpiration
FROM Acces.Utilisateurs
WHERE NomUtilisateur = %s
LIMIT 1
""",
(login,),
)
result = cursor.fetchone()
if not result:
st.sidebar.error("Identifiants invalides")
elif result["DateExpiration"] and result["DateExpiration"] < date.today():
st.sidebar.error("â AccĂšs expirĂ©.")
elif not verifier_password(password, result["MotDePasseHash"]):
st.sidebar.error("Identifiants invalides")
else:
st.session_state.update({
"authenticated": True,
"role": result["role"],
"site_autorise": result["Site"],
"onglet_actif": "Accueil", # đ reset
})
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
cursor.execute(
"""
INSERT INTO Sondes.Connexion_Log (Utilisateur, Lieu, Date_Connexion)
VALUES (%s, %s, %s)
""",
(result["NomUtilisateur"], result["Site"], now_str),
)
conn.commit()
st.rerun()
cursor.close()
conn.close()
except Exception as e:
st.sidebar.error(f"Erreur connexion : {e}")
else:
st.sidebar.success(f"Connecté ({st.session_state.get('role')})")
if st.sidebar.button("đ DĂ©connexion"):
for key in ["authenticated", "role", "site_autorise"]:
st.session_state[key] = False if key == "authenticated" else None
st.session_state["onglet_actif"] = "Accueil" # đ reset
st.rerun()
# =========================================================
# PDF
# =========================================================
def generer_pdf(site, date_str, periode):
st.info(f"Génération du rapport PDF pour {site} à la date {date_str} ({periode})")
try:
conn = get_connection()
pdf_cursor = conn.cursor(dictionary=True)
# Relevés
pdf_cursor.execute(
f"SELECT Sonde, Date, Temperature FROM `{site}` WHERE DATE(Date) = %s ORDER BY Sonde, Date",
(date_str,),
)
rows = pdf_cursor.fetchall()
df = pd.DataFrame(rows)
if df.empty:
st.warning("Aucune donnée ce jour.")
return
df["Heure"] = pd.to_datetime(df["Date"]).dt.strftime("%H:%M")
df["Heure_obj"] = pd.to_datetime(df["Date"]).dt.time
# Périodes
plages = {
"Toute la journée": (time(0, 0), time(23, 59)),
"Matin (6h-12h)": (time(6, 0), time(12, 0)),
"AprĂšs-midi (12h-18h)": (time(12, 0), time(18, 0)),
"Nuit (18h-6h)": (time(18, 0), time(6, 0)),
}
heure_debut, heure_fin = plages.get(periode, (time(0, 0), time(23, 59)))
if heure_debut < heure_fin:
df = df[(df["Heure_obj"] >= heure_debut) & (df["Heure_obj"] <= heure_fin)]
else:
df = df[(df["Heure_obj"] >= heure_debut) | (df["Heure_obj"] <= heure_fin)]
releves = {}
for sonde in sorted(df["Sonde"].unique()):
df_sonde = df[df["Sonde"] == sonde]
releves[sonde] = list(zip(df_sonde["Heure"], df_sonde["Temperature"]))
# Alertes
table_alertes = f"Alertes_{site}"
pdf_cursor.execute(
f"SELECT Sonde, Debut_defaut, Etat FROM `{table_alertes}` WHERE DATE(Debut_defaut) = %s",
(date_str,),
)
alertes = pdf_cursor.fetchall()
pdf_cursor.close()
conn.close()
class RapportPDF(FPDF):
def header(self):
self.set_font("Arial", "B", 14)
self.cell(0, 10, "Rapport de surveillance des sondes", ln=1, align="C")
self.set_font("Arial", "", 12)
self.cell(0, 8, f"Date : {date_str}", ln=1, align="C")
self.cell(0, 8, f"Période : {getattr(self, 'periode', '')}", ln=1, align="C")
self.ln(4)
def site_info(self, site_name):
self.set_font("Arial", "B", 12)
self.cell(0, 8, f"Site : {site_name}", ln=1)
self.ln(2)
def releves_section(self, data):
self.set_font("Arial", "B", 12)
self.cell(0, 8, "Relevés de température", ln=1)
for sonde, mesures in data.items():
self.set_font("Arial", "B", 11)
self.cell(0, 7, f"Sonde : {sonde}", ln=1)
col1 = mesures[::2]
col2 = mesures[1::2]
self.set_font("Arial", "B", 10)
self.cell(40, 6, "Heure", border=1)
self.cell(30, 6, "Temp (°C)", border=1)
self.cell(20, 6, "", border=0)
self.cell(40, 6, "Heure", border=1)
self.cell(30, 6, "Temp (°C)", border=1)
self.ln()
self.set_font("Arial", "", 10)
for i in range(max(len(col1), len(col2))):
if i < len(col1):
h1, t1 = col1[i]
self.cell(40, 6, h1, border=1)
self.cell(30, 6, f"{t1:.2f}", border=1)
else:
self.cell(70, 6, "", border=0)
self.cell(20, 6, "", border=0)
if i < len(col2):
h2, t2 = col2[i]
self.cell(40, 6, h2, border=1)
self.cell(30, 6, f"{t2:.2f}", border=1)
self.ln()
self.ln(3)
def alertes_section(self, data):
self.set_font("Arial", "B", 12)
self.cell(0, 8, "Alertes enregistrées", ln=1)
self.set_font("Arial", "", 10)
if not data:
self.cell(0, 6, "Aucune alerte ce jour.", ln=1)
else:
for a in data:
self.cell(0, 6, f"{a['Sonde']} - {a['Debut_defaut']} - {a['Etat']}", ln=1)
pdf = RapportPDF()
pdf.periode = periode
pdf.add_page()
pdf.site_info(site)
pdf.releves_section(releves)
pdf.alertes_section(alertes)
output_dir = "../PDF"
os.makedirs(output_dir, exist_ok=True)
file_name = f"rapport_{site}_{date_str}.pdf"
output_path = os.path.join(output_dir, file_name)
pdf.output(output_path)
with open(output_path, "rb") as f:
st.download_button(
label="đ„ TĂ©lĂ©charger le rapport PDF",
data=f,
file_name=file_name,
mime="application/pdf",
)
except Exception as err1:
st.error(f"Erreur lors de la génération du PDF : {err1}")
# =========================================================
# Fonctions Journal erreurs (SQL)
# =========================================================
def _get_site_courant():
role = st.session_state.get("role")
if role != "superviseur":
return st.session_state.get("site_autorise")
return st.session_state.get("selected_site", "Saclay")
def load_alertes(site: str, jour: date):
table_alertes = f"Alertes_{site}"
q = f"""
SELECT
a.Id AS Source_Id,
%s AS Site,
a.Sonde AS Sonde,
DATE(a.Debut_defaut) AS DateJour,
'Alerte' AS Type,
CONCAT('Alerte ', a.Etat, ' depuis ', DATE_FORMAT(a.Debut_defaut,'%H:%i')) AS Resume,
a.Etat AS Etat
FROM `{table_alertes}` a
WHERE DATE(a.Debut_defaut) = %s
OR (a.Etat <> 'Acquitté' AND DATE(a.Debut_defaut) <= %s);
"""
with closing(get_conn()) as cnx, closing(cnx.cursor(dictionary=True)) as cur:
cur.execute(q, (site, jour, jour))
rows = cur.fetchall()
cols = ["Source_Id","Site","Sonde","DateJour","Type","Resume","Etat"]
return pd.DataFrame(rows, columns=cols) if rows else pd.DataFrame(columns=cols)
def load_anomalies_auto(site: str, jour: date):
"""
Détection d'anomalies "Auto" sur la date choisie :
- GAPS : vrais trous = diff entre 2 mesures consécutives >= gap_threshold_min
- JUMPS : saut de température > jump_deg entre 2 mesures consécutives
- BOUNDS : valeurs hors bornes physiques
"""
table_mesures = site
gap_threshold_min = 20 # seuil "trou" (ex. mesures toutes 5 min â 20 min = 4 crĂ©neaux manquĂ©s)
jump_deg = 10 # saut suspect
min_phys, max_phys = -60, 120
q = f"""
WITH J AS (
SELECT Sonde, `Date`, Temperature
FROM `{table_mesures}`
WHERE DATE(`Date`) = %s
),
L AS (
SELECT Sonde,
`Date`,
Temperature,
LAG(`Date`) OVER (PARTITION BY Sonde ORDER BY `Date`) AS prev_date,
LAG(Temperature) OVER (PARTITION BY Sonde ORDER BY `Date`) AS prev_temp
FROM J
),
gaps AS (
SELECT Sonde,
COUNT(*) AS nb_gaps,
MAX(TIMESTAMPDIFF(MINUTE, prev_date, `Date`)) AS max_gap_min
FROM L
WHERE prev_date IS NOT NULL
AND TIMESTAMPDIFF(MINUTE, prev_date, `Date`) >= {gap_threshold_min}
GROUP BY Sonde
),
jumps AS (
SELECT Sonde,
COUNT(*) AS nb_jumps,
MAX(ABS(Temperature - prev_temp)) AS max_jump
FROM L
WHERE prev_temp IS NOT NULL
AND ABS(Temperature - prev_temp) > {jump_deg}
GROUP BY Sonde
),
bounds AS (
SELECT Sonde,
MIN(Temperature) AS tmin,
MAX(Temperature) AS tmax
FROM J
GROUP BY Sonde
HAVING MIN(Temperature) < {min_phys} OR MAX(Temperature) > {max_phys}
)
SELECT NULL AS Source_Id, %s AS Site, g.Sonde AS Sonde, %s AS DateJour, 'Auto' AS Type,
CONCAT('Trous de mesures : ', nb_gaps, ' fois (max ', max_gap_min, ' min)') AS Resume
FROM gaps g
UNION ALL
SELECT NULL, %s, j.Sonde, %s, 'Auto',
CONCAT('Sauts de température suspects : ', nb_jumps, ' (max ', ROUND(max_jump,1), '°C)')
FROM jumps j
UNION ALL
SELECT NULL, %s, b.Sonde, %s, 'Auto',
CONCAT('Valeurs hors bornes physiques (min=', ROUND(tmin,1), '°C, max=', ROUND(tmax,1), '°C)')
FROM bounds b
ORDER BY Sonde;
"""
params = (jour, site, jour, site, jour, site, jour)
with closing(get_conn()) as cnx, closing(cnx.cursor(dictionary=True)) as cur:
cur.execute(q, params)
rows = cur.fetchall()
cols = ["Source_Id","Site","Sonde","DateJour","Type","Resume"]
return pd.DataFrame(rows, columns=cols) if rows else pd.DataFrame(columns=cols)
def load_journal_existants(site: str, jour: date):
q = """
SELECT Id, Site, Sonde, DateJour, Type, Source_Id, Resume,
Statut, Priorite, Assignation, Commentaire, Tag
FROM Sondes.Journal_Erreurs
WHERE Site=%s AND DateJour=%s;
"""
with closing(get_conn()) as cnx, closing(cnx.cursor(dictionary=True)) as cur:
cur.execute(q, (site, jour))
rows = cur.fetchall()
cols = ["Id","Site","Sonde","DateJour","Type","Source_Id","Resume","Statut","Priorite","Assignation","Commentaire","Tag"]
return pd.DataFrame(rows, columns=cols) if rows else pd.DataFrame(columns=cols)
def upsert_journal(rows: list[dict]):
if not rows:
return
q_insert = """
INSERT INTO Sondes.Journal_Erreurs
(Site, Sonde, DateJour, Type, Source_Id, Resume, Statut, Priorite, Assignation, Commentaire, Tag)
VALUES (%(Site)s, %(Sonde)s, %(DateJour)s, %(Type)s, %(Source_Id)s, %(Resume)s,
%(Statut)s, %(Priorite)s, %(Assignation)s, %(Commentaire)s, %(Tag)s)
ON DUPLICATE KEY UPDATE
Resume=VALUES(Resume),
Statut=VALUES(Statut),
Priorite=VALUES(Priorite),
Assignation=VALUES(Assignation),
Commentaire=VALUES(Commentaire),
Tag=VALUES(Tag),
UpdatedAt=CURRENT_TIMESTAMP;
"""
with closing(get_conn()) as cnx, closing(cnx.cursor()) as cur:
cur.executemany(q_insert, rows)
cnx.commit()
# =========================================================
# Page Journal erreurs
# =========================================================
def page_journal_erreurs():
st.header("đ Journal des erreurs")
site = _get_site_courant()
if not site:
st.warning("Veuillez dâabord choisir un site sur la page dâaccueil.")
return
if site not in SITES_AUTORISES:
st.error("Site invalide.")
return
jour = st.date_input("Date de vision", value=st.session_state.get("selected_date", date.today()))
st.caption(f"Site : {site} â Date : {jour.strftime('%d/%m/%Y')}")
# --- Chargement des sources
df_alertes = load_alertes(site, jour)
df_auto = load_anomalies_auto(site, jour)
df_saved = load_journal_existants(site, jour)
base = pd.concat([df_auto, df_alertes], ignore_index=True)
if base.empty and df_saved.empty:
st.info("Aucune anomalie détectée ni alerte pour cette date.")
return
# Colonnes attendues
for col in ["Statut","Priorite","Assignation","Commentaire","Tag","Id","Source_Id"]:
if col not in base.columns:
base[col] = pd.NA
key_join = ["Site","Sonde","DateJour","Type","Source_Id","Resume"]
df = base.merge(df_saved, on=key_join, how="left", suffixes=("","_saved"))
# Garantir l'existence des *_saved si df_saved est vide
for c in ["Statut_saved","Priorite_saved","Assignation_saved","Commentaire_saved",
"Tag_saved","Id_saved","Source_Id_saved"]:
if c not in df.columns:
df[c] = pd.NA
# Valeurs par défaut depuis sauvegarde
df["Statut"] = df["Statut"].fillna(df["Statut_saved"]).fillna("Nouveau")
df["Priorite"] = df["Priorite"].fillna(df["Priorite_saved"]).fillna(3)
df["Assignation"] = df["Assignation"].fillna(df["Assignation_saved"])
df["Commentaire"] = df["Commentaire"].fillna(df["Commentaire_saved"])
df["Tag"] = df["Tag"].fillna(df["Tag_saved"])
df["Id"] = df["Id"].fillna(df["Id_saved"])
df["Source_Id"] = df["Source_Id"].fillna(df["Source_Id_saved"])
# --- Types compatibles pour l'éditeur ---
text_cols = ["Sonde", "Type", "Resume", "Statut", "Assignation", "Tag", "Commentaire"]
for c in text_cols:
if c not in df.columns:
df[c] = pd.Series(dtype="string") # colonne vide typée texte
else:
df[c] = df[c].astype("string").fillna("") # tout en string, pas de NaN
# Priorité = entier nullable (évite 3.0)
df["Priorite"] = pd.to_numeric(df["Priorite"], errors="coerce").fillna(3).astype("Int64")
# --- Ăditeur
st.subheader("SynthÚse (éditable)")
edit_cols = ["Sonde","Type","Resume","Statut","Priorite","Assignation","Tag","Commentaire"]
disabled_cols = ["Sonde","Type","Resume"]
st.subheader("SynthÚse (éditable)")
edit_cols = ["Sonde", "Type", "Resume", "Statut", "Priorite", "Assignation", "Tag", "Commentaire"]
disabled_cols = ["Sonde", "Type", "Resume"]
editable = st.data_editor(
df[edit_cols].copy(), # <<< important
disabled=disabled_cols,
use_container_width=True,
hide_index=True,
column_config={
"Statut": st.column_config.SelectboxColumn(
"Statut", options=["Nouveau", "En cours", "PlanifiĂ©", "Clos"], help="Ătat de la tĂąche"
),
"Priorite": st.column_config.NumberColumn(
"Priorité", min_value=1, max_value=3, step=1, format="%d",
help="1=Haute, 2=Moyenne, 3=Basse"
),
"Assignation": st.column_config.TextColumn("Assignation", help="Technicien / ticket"),
"Tag": st.column_config.TextColumn("Tag", help="Intermittent, Réseau, Capteur, etc."),
"Commentaire": st.column_config.TextColumn(
"Commentaire", help="Notes (SHIFT+Entrée pour un saut de ligne)", max_chars=2000
),
}
)
# --- Sauvegarde
def _none_if_empty(x):
if x is None: return None
if isinstance(x, float) and pd.isna(x): return None
if isinstance(x, str) and x.strip() == "": return None
return x
if st.session_state.get("role") == "superviseur":
if st.button("đŸ Enregistrer les modifications"):
# On rattache les clés (Site/Sonde/DateJour/Type/Source_Id/Resume) aux lignes éditées
df_keys = df[["Site","Sonde","DateJour","Type","Source_Id","Resume"]]
df_to_save = editable.merge(df_keys, on=["Sonde","Type","Resume"], how="left")
payload = []
for _, r in df_to_save.iterrows():
payload.append({
"Site": r["Site"],
"Sonde": r["Sonde"],
"DateJour": r["DateJour"],
"Type": r["Type"],
"Source_Id": int(r["Source_Id"]) if pd.notna(r.get("Source_Id")) else None,
"Resume": r["Resume"],
"Statut": _none_if_empty(r.get("Statut")) or "Nouveau",
"Priorite": int(r.get("Priorite", 3)) if pd.notna(r.get("Priorite", 3)) else 3,
"Assignation": _none_if_empty(r.get("Assignation")),
"Commentaire": _none_if_empty(r.get("Commentaire")),
"Tag": _none_if_empty(r.get("Tag")),
})
upsert_journal(payload)
st.success("Journal mis Ă jour.")
else:
st.info("Lecture seule (rĂ©servĂ© aux superviseurs pour lâĂ©dition).")
# =========================================================
# Affichage ALERTES non acquittées (global)
# =========================================================
if st.session_state.get("authenticated"):
try:
role = st.session_state.get("role")
site_selectionne = (
st.session_state.get("site_autorise")
if role != "superviseur"
else st.session_state.get("selected_site", "Saclay")
)
if not site_selectionne:
st.info("Connectez-vous et choisissez un site pour afficher les alertes.")
else:
if site_selectionne not in SITES_AUTORISES:
raise ValueError(f"Site invalide: {site_selectionne}")
conn = get_connection()
cursor = conn.cursor(dictionary=True)
table_alertes = f"Alertes_{site_selectionne}"
cursor.execute(
f"SELECT Sonde, Debut_defaut, Etat "
f"FROM `{table_alertes}` "
f"WHERE Etat != 'Acquitté' "
f"ORDER BY Debut_defaut DESC"
)
alertes = cursor.fetchall()
if alertes:
df_alertes = pd.DataFrame(alertes)
st.subheader("đš Alertes non acquittĂ©es")
st.dataframe(df_alertes, use_container_width=True)
else:
st.success("â
Aucune alerte en cours.")
cursor.close()
conn.close()
except Exception as e:
st.error(f"Erreur lors de la récupération des alertes : {e}")
else:
st.info("Connectez-vous pour voir les alertes en cours.")
# =========================================================
# Navigation
# =========================================================
if st.session_state["authenticated"]:
onglets = (
["Accueil", "Entretien"]
if st.session_state["role"] != "superviseur"
else ["Accueil", "Statistiques", "Entretien", "Traffic", "Journal erreurs"]
)
onglets = (
["Accueil", "Entretien"]
if st.session_state["role"] != "superviseur"
else ["Accueil", "Statistiques", "Entretien", "Traffic", "Journal erreurs"]
)
# đ Normaliser l'onglet actif si absent de la liste (ex. compte non-admin)
if st.session_state.get("onglet_actif") not in onglets:
st.session_state["onglet_actif"] = onglets[0]
onglet_selectionne = st.sidebar.radio(
"đ Navigation", onglets, index=onglets.index(st.session_state["onglet_actif"])
)
site_actuel = (
st.session_state.get("site_autorise")
if st.session_state["role"] != "superviseur"
else st.session_state.get("selected_site", "Saclay")
)
date_selectionnee = st.session_state.get("selected_date", date.today())
# ------------------ Accueil ------------------
if onglet_selectionne == "Accueil":
try:
# --- Voyant Gyro pour le site courant ---
st.subheader(f"đš Statut Gyro â {site_actuel}")
try:
st.autorefresh(interval=30000, key="gyro_autorefresh")
except Exception:
pass
render_gyro_badge(site_actuel)
# ----------------------------------------
conn = get_connection()
cursor = conn.cursor(dictionary=True)
if st.session_state["role"] == "superviseur":
site_actuel = st.selectbox("đ Choisissez un site :", ["Saclay", "Meudon"], index=0)
st.session_state["selected_site"] = site_actuel
else:
st.info(f"Site imposé : {site_actuel}")
date_selectionnee = st.date_input("đ
Date du relevé", value=date_selectionnee)
st.session_state["selected_date"] = date_selectionnee
cursor.execute(
f"SELECT * FROM `{site_actuel}` WHERE DATE(Date) = %s ORDER BY Sonde, Date DESC",
(date_selectionnee.strftime("%Y-%m-%d"),),
)
rows = cursor.fetchall()
if rows:
df = pd.DataFrame(rows)
df["Date"] = pd.to_datetime(df["Date"])
sondes = sorted(df["Sonde"].unique())
sonde_choisie = st.selectbox("đ§Ș Choisissez une sonde :", sondes)
df_sonde = df[df["Sonde"] == sonde_choisie].copy()
df_sonde["Heure"] = df_sonde["Date"].dt.hour
tranche = st.radio(
"đ Tranche horaire :",
["Toute la journée", "Matin (6h-12h)", "AprÚs-midi (12h-18h)", "Nuit (18h-6h)"],
)
st.session_state["selected_periode"] = tranche
# Génération PDF
if st.button("đ§Ÿ GĂ©nĂ©rer le PDF du jour"):
generer_pdf(
site_actuel,
date_selectionnee.strftime("%Y-%m-%d"),
st.session_state.get("selected_periode", "Toute la journée"),
)
if tranche == "Matin (6h-12h)":
df_sonde = df_sonde[(df_sonde["Heure"] >= 6) & (df_sonde["Heure"] < 12)]
elif tranche == "AprĂšs-midi (12h-18h)":
df_sonde = df_sonde[(df_sonde["Heure"] >= 12) & (df_sonde["Heure"] < 18)]
elif tranche == "Nuit (18h-6h)":
df_sonde = df_sonde[(df_sonde["Heure"] >= 18) | (df_sonde["Heure"] < 6)]
seuil_temp = 10
cursor.execute(
"SELECT Temp_Max FROM Sondes.Chambres_froides WHERE Lieu = %s AND Sonde = %s",
(site_actuel, sonde_choisie),
)
seuil = cursor.fetchone()
if seuil:
seuil_temp = seuil["Temp_Max"]
st.subheader("đ Tableau des relevĂ©s")
def surlignage_temp(val):
try:
if float(val) > seuil_temp:
return "color: red; font-weight: bold"
except Exception:
pass
return ""
styled_df = df_sonde.style.map(surlignage_temp, subset=["Temperature"])
st.dataframe(styled_df, use_container_width=True)
st.subheader("đ Ăvolution de la tempĂ©rature")
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(df_sonde["Date"], df_sonde["Temperature"], marker="o")
ax.axhline(seuil_temp, linestyle="--", label=f"Seuil {seuil_temp}°C")
ax.set_xlabel("Heure")
ax.set_ylabel("Température (°C)")
ax.set_title(f"{sonde_choisie} - {date_selectionnee.strftime('%d/%m/%Y')}")
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
ax.legend()
st.pyplot(fig)
cursor.close()
conn.close()
except Exception as e:
st.error(f"Erreur : {e}")
st.text(traceback.format_exc())
# ------------------ Statistiques ------------------
elif onglet_selectionne == "Statistiques":
st.markdown("## đ Statistiques de tempĂ©rature")
site = (
st.session_state["site_autorise"]
if st.session_state["role"] != "superviseur"
else st.session_state.get("selected_site", "Saclay")
)
date_val = st.session_state.get("selected_date", date.today())
try:
conn = get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute(
f"SELECT * FROM `{site}` WHERE DATE(Date) = %s ORDER BY Sonde, Date",
(date_val.strftime("%Y-%m-%d"),),
)
rows = cursor.fetchall()
df = pd.DataFrame(rows)
if df.empty:
st.info("Aucune donnée pour cette date.")
else:
df["Date"] = pd.to_datetime(df["Date"])
sondes = sorted(df["Sonde"].unique())
sonde = st.selectbox("Choisir une sonde :", sondes, key="selectbox_stats")
df_sonde = df[df["Sonde"] == sonde]
st.subheader("Ăvolution journaliĂšre")
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(df_sonde["Date"], df_sonde["Temperature"], marker="o")
ax.set_title(f"{sonde} - {date_val.strftime('%d/%m/%Y')}")
ax.set_xlabel("Heure")
ax.set_ylabel("Température (°C)")
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
st.pyplot(fig)
cursor.close()
conn.close()
except Exception as e:
st.error(f"Erreur chargement statistiques : {e}")
# Admin Chambres froides
if st.session_state["role"] == "superviseur":
with st.expander("đ ïž Gestion des chambres froides (administrateur)", expanded=True):
if st.button("đ Actualiser la liste"):
st.session_state["refresh_admin"] = random.randint(0, 9999)
try:
conn_admin = get_connection()
cursor_admin = conn_admin.cursor(dictionary=True)
cursor_admin.execute("SELECT * FROM Sondes.Chambres_froides WHERE Lieu = %s", (site,))
chambres = cursor_admin.fetchall()
if not chambres:
st.warning("Aucune chambre froide pour ce site.")
else:
for chambre in chambres:
col1, col2, col3 = st.columns([3, 1, 2])
with col1:
st.markdown(f"**{chambre['Sonde']}**")
with col2:
etat = st.checkbox(
"ON",
value=(chambre["Etat"] == "ON"),
key=f"etat_{chambre['Id']}_{st.session_state.get('refresh_admin', 0)}",
)
new_etat = "ON" if etat else "OFF"
with col3:
temp_max = chambre["Temp_Max"]
moins, temp_display, plus = st.columns([1, 2, 1])
with moins:
if st.button("âŒ", key=f"moins_{chambre['Id']}"):
temp_max -= 1
with temp_display:
st.markdown(
f"{temp_max}°C
",
unsafe_allow_html=True,
)
with plus:
if st.button("âČ", key=f"plus_{chambre['Id']}"):
temp_max += 1
if new_etat != chambre["Etat"] or temp_max != chambre["Temp_Max"]:
cursor_admin.execute(
"UPDATE Sondes.Chambres_froides SET Etat = %s, Temp_Max = %s WHERE Id = %s",
(new_etat, temp_max, chambre["Id"]),
)
conn_admin.commit()
st.success(f"{chambre['Sonde']} mise Ă jour")
cursor_admin.close()
conn_admin.close()
except Exception as e:
st.error(f"Erreur SQL (admin) : {e}")
# ------------------ Entretien ------------------
elif onglet_selectionne == "Entretien":
st.header("đ§° Gestion Entretien")
try:
conn = get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute(
"SELECT Id, Sonde, En_entretien FROM Sondes.Chambres_froides WHERE Lieu = %s",
(site_actuel,),
)
sondes = cursor.fetchall()
for sonde in sondes:
checked = st.checkbox(f"{sonde['Sonde']}", value=sonde["En_entretien"])
if checked != sonde["En_entretien"]:
cursor.execute(
"UPDATE Sondes.Chambres_froides SET En_entretien = %s WHERE Id = %s",
(checked, sonde["Id"]),
)
conn.commit()
st.success(f"{sonde['Sonde']} {'mise' if checked else 'retirée'} en entretien.")
cursor.close()
conn.close()
except Exception as e:
st.error(f"Erreur : {e}")
st.text(traceback.format_exc())
# ------------------ Traffic ------------------
elif onglet_selectionne == "Traffic":
st.header("đŠ Connexions rĂ©centes")
try:
conn = get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute(
"SELECT Utilisateur, Lieu, Date_Connexion FROM Sondes.Connexion_Log ORDER BY Date_Connexion DESC LIMIT 100"
)
logs = cursor.fetchall()
df_logs = pd.DataFrame(logs)
st.dataframe(df_logs, use_container_width=True)
cursor.close()
conn.close()
except Exception as e:
st.error(f"Erreur : {e}")
# ------------------ Journal erreurs ------------------
elif onglet_selectionne == "Journal erreurs":
page_journal_erreurs()