From 1ede1ad34e49e7de2042427bba95da151a5f39d2 Mon Sep 17 00:00:00 2001 From: Alan Date: Thu, 14 May 2026 20:39:39 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20am=C3=A9liorations=20UX=20dashboard?= =?UTF-8?q?=20Streamlit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mode sombre : toggle dans le bandeau latéral (visible toutes pages) avec injection CSS couvrant fond, textes, inputs, boutons - Page d'accueil : simplifie l'expander « Pas de token » (ADMIN_TOKEN uniquement, suppression des commandes docker) ; supprime le tableau Navigation et le bouton Se déconnecter du contenu principal - Bandeau gauche : bouton Se déconnecter déplacé sous « Mon token API » - Navigation : Code Example déplacé en dernier (après Aide) - Users : bouton « Appliquer rôle » affiché au-dessus de la liste déroulante « Changer rôle » Co-Authored-By: Claude Sonnet 4.6 --- streamlit_app/app.py | 66 ++++++++++++++++++---------------- streamlit_app/pages/1_Users.py | 9 +++-- 2 files changed, 41 insertions(+), 34 deletions(-) diff --git a/streamlit_app/app.py b/streamlit_app/app.py index 1b31a56..2befdd8 100644 --- a/streamlit_app/app.py +++ b/streamlit_app/app.py @@ -61,25 +61,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)") @@ -116,6 +105,9 @@ def show_home(): except Exception: st.error("Impossible de charger le token.") + if st.button("Se déconnecter", type="secondary", use_container_width=True): + logout() + # Badge demandes en attente (admin uniquement) if st.session_state.get("is_admin"): try: @@ -153,27 +145,35 @@ 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 _logged_in = bool(st.session_state.get("api_token")) +_DARK_CSS = """ + +""" + if _logged_in: _pg = st.navigation([ st.Page(show_home, title="Accueil", default=True), @@ -181,12 +181,12 @@ def show_home(): 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 +194,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/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: From bc371b881cfe8f9587aa017aaed0c33b6189d5fa Mon Sep 17 00:00:00 2001 From: Alan Date: Thu, 14 May 2026 20:42:14 +0200 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20forcer=20le=20th=C3=A8me=20clair=20p?= =?UTF-8?q?ar=20d=C3=A9faut=20(override=20pr=C3=A9f=C3=A9rence=20OS=20dark?= =?UTF-8?q?=20mode)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crée streamlit_app/.streamlit/config.toml avec base="light" pour que Streamlit n'hérite pas du dark mode de l'OS. Le toggle "Mode sombre" dans le bandeau reste fonctionnel pour basculer en dark. Co-Authored-By: Claude Sonnet 4.6 --- streamlit_app/.streamlit/config.toml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/streamlit_app/.streamlit/config.toml b/streamlit_app/.streamlit/config.toml index 6bbaabe..d581482 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" +[theme] +base = "light" From 5edc900238775a36898b408833ce759f76a8db4b Mon Sep 17 00:00:00 2001 From: Alan Date: Thu, 14 May 2026 20:45:06 +0200 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20corriger=20visibilit=C3=A9=20des=20b?= =?UTF-8?q?outons=20en=20mode=20sombre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le CSS dark mode forçait color:#fafafa sur tous les div, rendant le texte des boutons (blanc) invisible sur leur fond clair. Fix : - Suppression de la règle trop large `div { color }` - Ciblage direct de `button` avec fond sombre (#262730) et texte clair - Boutons primaires gardent leur couleur rouge distincte Co-Authored-By: Claude Sonnet 4.6 --- streamlit_app/app.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/streamlit_app/app.py b/streamlit_app/app.py index 2befdd8..d288ce0 100644 --- a/streamlit_app/app.py +++ b/streamlit_app/app.py @@ -155,8 +155,9 @@ def show_home(): [data-testid="stApp"] { background-color: #0e1117; } [data-testid="stHeader"] { background-color: #0e1117; } [data-testid="stSidebar"] { background-color: #262730; } -body, p, span, label, div { color: #fafafa; } +body, p, span, label { color: #fafafa; } h1, h2, h3, h4, h5, h6 { color: #fafafa; } +.stMarkdown, .stText, .stCaption { color: #fafafa; } input[type="text"], input[type="password"], textarea { background-color: #262730 !important; color: #fafafa !important; @@ -165,7 +166,19 @@ def show_home(): [data-baseweb="input"] { background-color: #262730 !important; } [data-baseweb="select"] > div { background-color: #262730 !important; color: #fafafa !important; } [data-testid="stForm"] { border-color: #555; } -[data-testid="baseButton-secondary"] { background-color: #262730; color: #fafafa; border-color: #555; } +button { + background-color: #262730 !important; + color: #fafafa !important; + border-color: #555 !important; +} +button:hover { + background-color: #3a3c4a !important; + border-color: #888 !important; +} +button[kind="primary"] { + background-color: #c0392b !important; + border-color: #c0392b !important; +} code { background-color: #262730; color: #e6e6e6; } pre { background-color: #1a1c23 !important; } hr { border-color: #555; } From 9ec74adb40e9e4cf0a9747cda0eeb107ae89e4ee Mon Sep 17 00:00:00 2001 From: Alan Date: Thu, 14 May 2026 20:56:07 +0200 Subject: [PATCH 4/9] feat: sidebar Mon compte global + popup agrandir doc/code sur Aide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app.py : bloc sidebar "Mon compte" (quota, token, déconnexion, badge admin) déplacé dans le scope global → visible sur toutes les pages - 10_Aide.py : boutons "⛶ Agrandir" sur la doc et le code source, ouvrent une popup pleine largeur via @st.dialog (Streamlit 1.36+) Co-Authored-By: Claude Sonnet 4.6 --- streamlit_app/app.py | 86 +++++++++++++++++++--------------- streamlit_app/pages/10_Aide.py | 25 +++++++++- 2 files changed, 70 insertions(+), 41 deletions(-) diff --git a/streamlit_app/app.py b/streamlit_app/app.py index d288ce0..d65eeaa 100644 --- a/streamlit_app/app.py +++ b/streamlit_app/app.py @@ -84,42 +84,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.") - - if st.button("Se déconnecter", type="secondary", use_container_width=True): - logout() - - # 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() @@ -166,19 +130,23 @@ def show_home(): [data-baseweb="input"] { background-color: #262730 !important; } [data-baseweb="select"] > div { background-color: #262730 !important; color: #fafafa !important; } [data-testid="stForm"] { border-color: #555; } -button { +/* Spécificité élevée pour battre les classes générées st-emotion-cache-xxx */ +html body [data-testid="stApp"] button { background-color: #262730 !important; color: #fafafa !important; border-color: #555 !important; } -button:hover { +html body [data-testid="stApp"] button:hover { background-color: #3a3c4a !important; border-color: #888 !important; } -button[kind="primary"] { +html body [data-testid="stApp"] button[kind="primary"] { background-color: #c0392b !important; border-color: #c0392b !important; } +html body [data-testid="stApp"] button p { + color: #fafafa !important; +} code { background-color: #262730; color: #e6e6e6; } pre { background-color: #1a1c23 !important; } hr { border-color: #555; } @@ -188,6 +156,46 @@ def show_home(): """ 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): + 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"), diff --git a/streamlit_app/pages/10_Aide.py b/streamlit_app/pages/10_Aide.py index c4b282c..5883cac 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" @@ -305,16 +319,23 @@ 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 = st.columns([4, 1]) + 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") + col_src, col_src_btn = st.columns([4, 1]) + selected_src = col_src.selectbox("Fichier", src_names, 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]) st.code(snippets[selected_src], language="python") # ═══════════════════════════════════════════════════════════ From 8fabca7e75bdd0eee5ba5b8bd6c1607ad6e1b869 Mon Sep 17 00:00:00 2001 From: Alan Date: Thu, 14 May 2026 21:01:28 +0200 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20mise=20en=20page=20Aide=20?= =?UTF-8?q?=E2=80=94=203=20sections=20pleine=20largeur=20d=C3=A9pliables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace le layout 2 colonnes (docs|chat) par 3 expanders verticaux pleine largeur, chacun repliable indépendamment : 1. Documentation — sélecteur + conteneur scrollable + bouton Agrandir 2. Code source — sélecteur + code + bouton Agrandir (replié par défaut) 3. Assistant IA — sujets rapides sur 3 colonnes + chat Les boutons "⛶ Agrandir" ouvrent toujours la popup @st.dialog. Co-Authored-By: Claude Sonnet 4.6 --- streamlit_app/pages/10_Aide.py | 63 +++++++++++++--------------------- 1 file changed, 24 insertions(+), 39 deletions(-) diff --git a/streamlit_app/pages/10_Aide.py b/streamlit_app/pages/10_Aide.py index 5883cac..336f09c 100644 --- a/streamlit_app/pages/10_Aide.py +++ b/streamlit_app/pages/10_Aide.py @@ -284,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" @@ -319,60 +315,50 @@ 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)) - col_sel, col_btn = st.columns([4, 1]) + col_sel, col_btn = st.columns([6, 1]) 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()) - col_src, col_src_btn = st.columns([4, 1]) - selected_src = col_src.selectbox("Fichier", src_names, 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]) - st.code(snippets[selected_src], language="python") + 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 = st.columns([6, 1]) + 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]) + 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 @@ -384,7 +370,6 @@ def _sync_raw_from_display() -> list: st.divider() - # ── Historique ──────────────────────────────────────── render_chat_history() # ── Traitement du message (prompt rapide ou saisie) ─── From 8a4721010d23700e136fe04909e6c8889c81d58f Mon Sep 17 00:00:00 2001 From: Alan Date: Thu, 14 May 2026 21:05:18 +0200 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20aligner=20bouton=20Agrandir=20+=20aj?= =?UTF-8?q?outer=20t=C3=A9l=C3=A9chargement=20doc/code=20sur=20Aide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vertical_alignment="bottom" sur les colonnes pour aligner boutons et selectbox sur la même ligne - Bouton ⬇ .md pour télécharger le document markdown sélectionné - Bouton ⬇ .py pour télécharger le fichier source sélectionné Co-Authored-By: Claude Sonnet 4.6 --- streamlit_app/pages/10_Aide.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/streamlit_app/pages/10_Aide.py b/streamlit_app/pages/10_Aide.py index 336f09c..27bf00f 100644 --- a/streamlit_app/pages/10_Aide.py +++ b/streamlit_app/pages/10_Aide.py @@ -315,13 +315,22 @@ 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)) - col_sel, col_btn = st.columns([6, 1]) + 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] if col_btn.button("⛶ Agrandir", key="open_doc_popup", use_container_width=True): _doc_popup(docs[selected_key], selected_label) + 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]) @@ -331,10 +340,18 @@ def _sync_raw_from_display() -> list: if snippets: with st.expander(f"🔧 Code source ({len(snippets)} fichiers)", expanded=False): - col_src, col_src_btn = st.columns([6, 1]) + 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") # ═══════════════════════════════════════════════════════════ From 4dc101031c1cd50c4c70aa956983a08a123ceb94 Mon Sep 17 00:00:00 2001 From: Alan Date: Thu, 14 May 2026 21:09:05 +0200 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20persistance=20de=20session=20apr?= =?UTF-8?q?=C3=A8s=20F5=20via=20query=5Fparams=20+=20store=20en=20m=C3=A9m?= =?UTF-8?q?oire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _SESSION_STORE : dict module-level qui survit aux rechargements Streamlit (F5 crée une nouvelle session WebSocket mais le processus Python reste) - À la connexion : génère un UUID session_id, stocke les credentials dans _SESSION_STORE, écrit sid= dans st.query_params (visible dans l'URL) - Au chargement : si api_token absent mais sid présent dans l'URL, restaure automatiquement la session depuis _SESSION_STORE - Déconnexion : supprime la session du store et nettoie les query_params - TTL 8h, nettoyage automatique des sessions expirées à chaque login Co-Authored-By: Claude Sonnet 4.6 --- streamlit_app/app.py | 47 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/streamlit_app/app.py b/streamlit_app/app.py index d65eeaa..75a02ae 100644 --- a/streamlit_app/app.py +++ b/streamlit_app/app.py @@ -3,6 +3,8 @@ """ import os +import time +import uuid from urllib.parse import urlparse import streamlit as st @@ -10,6 +12,43 @@ from utils.auth import logout from utils.ui_helpers import show_token_with_copy +# Stockage de session persistant entre les rechargements (F5). +# Clé = session_id (dans st.query_params), valeur = dict credentials + expiry. +_SESSION_STORE: dict[str, dict] = {} +_SESSION_TTL = 8 * 3600 # 8 heures + + +def _save_session(token: str, api_url: str, is_admin: bool) -> str: + sid = str(uuid.uuid4()) + _SESSION_STORE[sid] = { + "token": token, + "api_url": api_url, + "is_admin": is_admin, + "expires_at": time.time() + _SESSION_TTL, + } + # Nettoyage des sessions expirées + expired = [k for k, v in _SESSION_STORE.items() if v["expires_at"] < time.time()] + for k in expired: + del _SESSION_STORE[k] + return sid + + +def _restore_session(sid: str) -> bool: + data = _SESSION_STORE.get(sid) + if not data or data["expires_at"] < time.time(): + 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"] + return True + + +def _clear_session() -> None: + sid = st.query_params.get("sid") + if sid: + _SESSION_STORE.pop(sid, None) + 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 +92,7 @@ def show_login(): st.session_state["api_token"] = token st.session_state["api_url"] = api_url st.session_state["is_admin"] = is_admin + st.query_params["sid"] = _save_session(token, api_url, is_admin) st.rerun() st.divider() @@ -112,6 +152,12 @@ def show_home(): # Router principal — navigation conditionnelle selon l'état de connexion +# Restauration de session après F5 si un sid valide est dans l'URL +if not st.session_state.get("api_token"): + _sid = st.query_params.get("sid") + if _sid: + _restore_session(_sid) + _logged_in = bool(st.session_state.get("api_token")) _DARK_CSS = """ @@ -183,6 +229,7 @@ def show_home(): 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"): From 327ba096019bf2c9267168e7d2573e353d2ec3bb Mon Sep 17 00:00:00 2001 From: Alan Date: Thu, 14 May 2026 21:14:17 +0200 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20r=C3=A9-=C3=A9crire=20sid=20dans=20l?= =?UTF-8?q?'URL=20=C3=A0=20chaque=20render=20pour=20survivre=20=C3=A0=20la?= =?UTF-8?q?=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit st.navigation() supprime les query_params lors des changements de page. Fix : stocker le sid aussi dans st.session_state["_sid"] et le ré-injecter dans st.query_params à chaque render quand il est absent. Ainsi F5 sur n'importe quelle page (/Users, /Models…) restaure la session correctement. Co-Authored-By: Claude Sonnet 4.6 --- streamlit_app/app.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/streamlit_app/app.py b/streamlit_app/app.py index 75a02ae..31fa014 100644 --- a/streamlit_app/app.py +++ b/streamlit_app/app.py @@ -40,14 +40,15 @@ def _restore_session(sid: str) -> bool: 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.query_params.get("sid") + sid = st.session_state.pop("_sid", None) or st.query_params.get("sid") if sid: _SESSION_STORE.pop(sid, None) - st.query_params.clear() + st.query_params.clear() def _is_valid_api_url(url: str) -> bool: @@ -92,7 +93,9 @@ def show_login(): st.session_state["api_token"] = token st.session_state["api_url"] = api_url st.session_state["is_admin"] = is_admin - st.query_params["sid"] = _save_session(token, api_url, is_admin) + sid = _save_session(token, api_url, is_admin) + st.session_state["_sid"] = sid + st.query_params["sid"] = sid st.rerun() st.divider() @@ -152,14 +155,19 @@ def show_home(): # Router principal — navigation conditionnelle selon l'état de connexion -# Restauration de session après F5 si un sid valide est dans l'URL +# Restauration de session après F5 : sid dans session_state ou dans l'URL if not st.session_state.get("api_token"): - _sid = st.query_params.get("sid") + _sid = st.session_state.get("_sid") or st.query_params.get("sid") if _sid: _restore_session(_sid) _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 = """