diff --git a/streamlit_app/.streamlit/config.toml b/streamlit_app/.streamlit/config.toml index 6bbaabe..49f7885 100644 --- a/streamlit_app/.streamlit/config.toml +++ b/streamlit_app/.streamlit/config.toml @@ -1,7 +1,2 @@ [theme] -base = "dark" -primaryColor = "#7C83FD" -backgroundColor = "#0E1117" -secondaryBackgroundColor = "#1A1F2E" -textColor = "#FAFAFA" -font = "sans serif" +base = "light" diff --git a/streamlit_app/app.py b/streamlit_app/app.py index 1b31a56..9d4a9eb 100644 --- a/streamlit_app/app.py +++ b/streamlit_app/app.py @@ -2,7 +2,11 @@ Page d'accueil et login — PredictML Admin Dashboard """ +import json import os +import tempfile +import time +import uuid from urllib.parse import urlparse import streamlit as st @@ -10,6 +14,55 @@ from utils.auth import logout from utils.ui_helpers import show_token_with_copy +# Sessions stockées dans /tmp — survive aux hot-reloads Streamlit. +_SESSION_DIR = os.path.join(tempfile.gettempdir(), "predictml_sessions") +os.makedirs(_SESSION_DIR, exist_ok=True) +_SESSION_TTL = 8 * 3600 # 8 heures + + +def _session_path(sid: str) -> str: + return os.path.join(_SESSION_DIR, f"{sid}.json") + + +def _save_session(token: str, api_url: str, is_admin: bool) -> str: + sid = str(uuid.uuid4()) + with open(_session_path(sid), "w") as f: + json.dump({ + "token": token, + "api_url": api_url, + "is_admin": is_admin, + "expires_at": time.time() + _SESSION_TTL, + }, f) + return sid + + +def _restore_session(sid: str) -> bool: + path = _session_path(sid) + if not os.path.exists(path): + return False + try: + with open(path) as f: + data = json.load(f) + except Exception: + return False + if data.get("expires_at", 0) < time.time(): + os.remove(path) + return False + st.session_state["api_token"] = data["token"] + st.session_state["api_url"] = data["api_url"] + st.session_state["is_admin"] = data["is_admin"] + st.session_state["_sid"] = sid + return True + + +def _clear_session() -> None: + sid = st.session_state.pop("_sid", None) or st.query_params.get("sid") + if sid: + path = _session_path(sid) + if os.path.exists(path): + os.remove(path) + st.query_params.clear() + def _is_valid_api_url(url: str) -> bool: """Vérifie que l'URL est http/https et pointe vers un hôte non vide.""" @@ -53,6 +106,9 @@ def show_login(): st.session_state["api_token"] = token st.session_state["api_url"] = api_url st.session_state["is_admin"] = is_admin + sid = _save_session(token, api_url, is_admin) + st.session_state["_sid"] = sid + st.query_params["sid"] = sid st.rerun() st.divider() @@ -61,25 +117,14 @@ def show_login(): st.markdown(""" **Premier accès admin** -Le compte admin initial est créé au démarrage de l'API. Récupérez le token via : - -```bash -# Vérifier les logs de démarrage -docker-compose logs api | grep -i "token\|admin" - -# Ou lancer manuellement l'initialisation -docker exec predictml-api python init_data/init_db.py -``` - -La variable d'environnement `ADMIN_TOKEN` permet aussi de forcer le token à l'init. +Le token admin est défini par la variable d'environnement **`ADMIN_TOKEN`**. --- **Nouvel utilisateur** -Demandez un accès à votre administrateur, ou soumettez une demande via la page -**"Demande d'accès"** dans le menu latéral — un admin vous communiquera votre token -une fois approuvé. +Soumettez une demande via la page **"Demande d'accès"** dans le menu — un admin vous +communiquera votre token une fois approuvé. """) st.markdown("📝 [Soumettre une demande d'accès](/Demande_Acces)") @@ -95,39 +140,6 @@ def show_home(): token=st.session_state["api_token"], ) - with st.sidebar: - st.subheader("Mon compte") - try: - quota = client.get_my_quota() - used = quota["used_today"] - limit = quota["rate_limit_per_day"] - remaining = quota["remaining_today"] - st.progress(used / limit if limit > 0 else 0) - st.caption(f"{used} / {limit} aujourd'hui") - if remaining == 0: - st.warning("Quota épuisé pour aujourd'hui.") - except Exception: - pass - - with st.expander("🔑 Mon token API"): - try: - me = client.get_me() - show_token_with_copy(me["api_token"]) - except Exception: - st.error("Impossible de charger le token.") - - # Badge demandes en attente (admin uniquement) - if st.session_state.get("is_admin"): - try: - n_pending = client.get_pending_account_requests_count() - if n_pending > 0: - st.warning(f"🔔 {n_pending} demande(s) d'accès en attente") - st.page_link("pages/1_Users.py", label="Gérer les demandes →") - except Exception: - pass - - st.divider() - # Statut API try: health = client.get_health() @@ -153,40 +165,118 @@ def show_home(): except Exception: pass - st.divider() - st.subheader("Navigation") - st.markdown(""" -| Page | Description | -|------|-------------| -| **1 - Users** | Gérer les utilisateurs, créer des comptes, renouveler les tokens *(admin)* | -| **2 - Models** | Consulter et administrer les modèles ML | -| **3 - Predictions** | Historique des prédictions avec filtres | -| **4 - Stats** | Statistiques et graphiques d'utilisation | -| **5 - Code Example** | Exemple de code MLflow + API | -| **6 - A/B Testing** | Configurer les tests A/B, déploiement shadow, comparer les métriques par version | -""") - - st.divider() - if st.button("Se déconnecter", type="secondary"): - logout() # Router principal — navigation conditionnelle selon l'état de connexion +# Restauration de session après F5 : sid dans session_state ou dans l'URL +if not st.session_state.get("api_token"): + _sid = st.session_state.get("_sid") or st.query_params.get("sid") + if _sid: + if not _restore_session(_sid): + st.query_params.clear() + _logged_in = bool(st.session_state.get("api_token")) +# Ré-écrire le sid dans l'URL à chaque render (la navigation le supprime) +if _logged_in and st.session_state.get("_sid"): + if st.query_params.get("sid") != st.session_state["_sid"]: + st.query_params["sid"] = st.session_state["_sid"] + +_DARK_CSS = """ + +""" + if _logged_in: + # Sidebar "Mon compte" — affiché sur toutes les pages + _client = APIClient( + base_url=st.session_state["api_url"], + token=st.session_state["api_token"], + ) + with st.sidebar: + st.subheader("Mon compte") + try: + quota = _client.get_my_quota() + used = quota["used_today"] + limit = quota["rate_limit_per_day"] + remaining = quota["remaining_today"] + st.progress(used / limit if limit > 0 else 0) + st.caption(f"{used} / {limit} aujourd'hui") + if remaining == 0: + st.warning("Quota épuisé pour aujourd'hui.") + except Exception: + pass + + with st.expander("🔑 Mon token API"): + try: + me = _client.get_me() + show_token_with_copy(me["api_token"]) + except Exception: + st.error("Impossible de charger le token.") + + if st.button("Se déconnecter", type="secondary", use_container_width=True): + _clear_session() + logout() + + if st.session_state.get("is_admin"): + try: + n_pending = _client.get_pending_account_requests_count() + if n_pending > 0: + st.warning(f"🔔 {n_pending} demande(s) d'accès en attente") + st.page_link("pages/1_Users.py", label="Gérer les demandes →") + except Exception: + pass + + st.divider() + _pg = st.navigation([ st.Page(show_home, title="Accueil", default=True), st.Page("pages/1_Users.py", title="Users"), st.Page("pages/2_Models.py", title="Models"), st.Page("pages/3_Predictions.py", title="Predictions"), st.Page("pages/4_Stats.py", title="Stats"), - st.Page("pages/5_Code_Example.py", title="Code Example"), st.Page("pages/6_AB_Testing.py", title="AB Testing"), st.Page("pages/7_Supervision.py", title="Supervision"), st.Page("pages/8_Retrain.py", title="Retrain"), st.Page("pages/9_Golden_Tests.py", title="Golden Tests"), st.Page("pages/10_Aide.py", title="Aide"), + st.Page("pages/5_Code_Example.py", title="Code Example"), ]) else: _pg = st.navigation([ @@ -194,4 +284,8 @@ def show_home(): st.Page("pages/0_Demande_Acces.py", title="Demande d'accès"), ]) +# Dark mode toggle — visible sur toutes les pages +if st.sidebar.toggle("Mode sombre", key="dark_mode"): + st.markdown(_DARK_CSS, unsafe_allow_html=True) + _pg.run() diff --git a/streamlit_app/pages/10_Aide.py b/streamlit_app/pages/10_Aide.py index c4b282c..27bf00f 100644 --- a/streamlit_app/pages/10_Aide.py +++ b/streamlit_app/pages/10_Aide.py @@ -22,6 +22,20 @@ st.set_page_config(page_title="Aide & Assistant IA — PredictML", page_icon="💬", layout="wide") require_auth() + +@st.dialog("📚 Documentation", width="large") +def _doc_popup(content: str, label: str) -> None: + st.caption(label) + st.divider() + st.markdown(content) + + +@st.dialog("🔧 Code source", width="large") +def _src_popup(filename: str, content: str) -> None: + st.caption(filename) + st.divider() + st.code(content, language="python") + # ── Constantes ──────────────────────────────────────────────────────────────── MODEL_ID = "claude-sonnet-4-6" @@ -270,17 +284,13 @@ def _sync_raw_from_display() -> list: st.divider() -# ── Layout principal ────────────────────────────────────────────────────────── - -col_docs, col_chat = st.columns([4, 6], gap="large") +# ── Layout principal — 3 sections pleine largeur ───────────────────────────── # ═══════════════════════════════════════════════════════════ -# COLONNE GAUCHE — Visualiseur de documentation +# SECTION 1 — Documentation # ═══════════════════════════════════════════════════════════ -with col_docs: - st.subheader("📚 Documentation") - +with st.expander("📚 Documentation", expanded=True): if not docs: st.warning( "Les fichiers de documentation ne sont pas accessibles.\n\n" @@ -305,53 +315,67 @@ def _sync_raw_from_display() -> list: display_names = [labels.get(n, n) for n in doc_names] name_map = dict(zip(display_names, doc_names)) - selected_label = st.selectbox("Choisir un document", display_names, key="help_doc_select") + col_sel, col_btn, col_dl = st.columns([6, 1, 1], vertical_alignment="bottom") + selected_label = col_sel.selectbox("Choisir un document", display_names, key="help_doc_select") selected_key = name_map[selected_label] - with st.container(height=620, border=True): - st.markdown(docs[selected_key]) + if col_btn.button("⛶ Agrandir", key="open_doc_popup", use_container_width=True): + _doc_popup(docs[selected_key], selected_label) - if snippets: - with st.expander(f"🔧 Code source ({len(snippets)} fichiers)", expanded=False): - src_names = list(snippets.keys()) - selected_src = st.selectbox("Fichier", src_names, key="help_src_select") - st.code(snippets[selected_src], language="python") + col_dl.download_button( + "⬇ .md", + data=docs[selected_key].encode("utf-8"), + file_name=f"{selected_key.lower()}.md", + mime="text/markdown", + use_container_width=True, + key="dl_doc", + ) + + with st.container(height=500, border=True): + st.markdown(docs[selected_key]) # ═══════════════════════════════════════════════════════════ -# COLONNE DROITE — Chatbot LLM avec tools +# SECTION 2 — Code source # ═══════════════════════════════════════════════════════════ -with col_chat: - st.subheader("💬 Assistant IA") +if snippets: + with st.expander(f"🔧 Code source ({len(snippets)} fichiers)", expanded=False): + col_src, col_src_btn, col_src_dl = st.columns([6, 1, 1], vertical_alignment="bottom") + selected_src = col_src.selectbox("Fichier", list(snippets.keys()), key="help_src_select") + if col_src_btn.button("⛶ Agrandir", key="open_src_popup", use_container_width=True): + _src_popup(selected_src, snippets[selected_src]) + col_src_dl.download_button( + "⬇ .py", + data=snippets[selected_src].encode("utf-8"), + file_name=selected_src.split("/")[-1], + mime="text/x-python", + use_container_width=True, + key="dl_src", + ) + st.code(snippets[selected_src], language="python") + +# ═══════════════════════════════════════════════════════════ +# SECTION 3 — Chatbot LLM +# ═══════════════════════════════════════════════════════════ - # ── Avertissement si clé absente ────────────────────── +with st.expander("💬 Assistant IA", expanded=True): if anthropic_client is None: st.warning( "**La clé `ANTHROPIC_API_KEY` n'est pas configurée.**\n\n" - "Pour activer le chatbot :\n\n" - "1. Éditez le fichier **`.env`** à la racine du projet :\n" - " ```\n" - " ANTHROPIC_API_KEY=sk-ant-...\n" - " ```\n" - "2. Relancez le container Streamlit :\n" - " ```bash\n" - " docker-compose up -d streamlit\n" - " ```\n\n" - "En attendant, la documentation est disponible dans la colonne de gauche." + "Ajoutez `ANTHROPIC_API_KEY=sk-ant-...` dans le fichier `.env` " + "puis relancez le container Streamlit." ) - # ── Sujets rapides ──────────────────────────────────── with st.expander( "⚡ Sujets rapides", expanded=(len(st.session_state["help_messages"]) == 0), ): - cols = st.columns(2) + cols = st.columns(3) for i, (label, prompt) in enumerate(QUICK_TOPICS): - if cols[i % 2].button(label, key=f"quick_{i}", use_container_width=True): + if cols[i % 3].button(label, key=f"quick_{i}", use_container_width=True): st.session_state["help_pending_prompt"] = prompt - # ── Actions ─────────────────────────────────────────── - btn_col1, btn_col2 = st.columns([2, 3]) + btn_col1, btn_col2 = st.columns([2, 5]) if btn_col1.button("🗑️ Nouvelle conversation", key="help_clear"): st.session_state["help_messages"] = [] st.session_state["help_pending_prompt"] = None @@ -363,7 +387,6 @@ def _sync_raw_from_display() -> list: st.divider() - # ── Historique ──────────────────────────────────────── render_chat_history() # ── Traitement du message (prompt rapide ou saisie) ─── diff --git a/streamlit_app/pages/1_Users.py b/streamlit_app/pages/1_Users.py index edc0815..24de41e 100644 --- a/streamlit_app/pages/1_Users.py +++ b/streamlit_app/pages/1_Users.py @@ -222,14 +222,17 @@ def reload(): # Modifier rôle new_roles = [r for r in ["user", "admin", "readonly"] if r != selected["role"]] - new_role = col_c.selectbox("Changer rôle", new_roles, key="role_select") + _role_val = st.session_state.get("role_select", new_roles[0] if new_roles else None) + if _role_val not in new_roles and new_roles: + _role_val = new_roles[0] if col_c.button("✏️ Appliquer rôle", use_container_width=True): try: - client.update_user(selected["id"], {"role": new_role}) - st.toast(f"Rôle mis à jour → {new_role}", icon="✅") + client.update_user(selected["id"], {"role": _role_val}) + st.toast(f"Rôle mis à jour → {_role_val}", icon="✅") reload() except Exception as e: st.toast(f"Erreur : {e}", icon="❌") + col_c.selectbox("Changer rôle", new_roles, key="role_select") # Supprimer with col_d: