Spaces:
Sleeping
Sleeping
| """ | |
| Reusable UI components for CSH2 Web Dashboard — HUD Theme | |
| """ | |
| import streamlit as st | |
| import pandas as pd | |
| from typing import List, Dict, Optional | |
| def page_header(title: str, subtitle: str = ""): | |
| """Render a HUD-style page header with gradient accent lines""" | |
| subtitle_html = f'<div class="hud-subtitle">{subtitle}</div>' if subtitle else '' | |
| st.markdown(f""" | |
| <div class="hud-page-header"> | |
| <div class="hud-header-line"></div> | |
| <h1>{title}</h1> | |
| {subtitle_html} | |
| <div class="hud-header-line"></div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def metric_row(metrics: List[Dict]): | |
| """ | |
| Render a row of HUD-style metric cards. | |
| metrics: list of dicts with keys: label, value, unit, delta (optional), status (optional) | |
| status can be: 'nominal', 'warning', 'alarm' (defaults to no special styling) | |
| """ | |
| cols = st.columns(len(metrics)) | |
| for col, m in zip(cols, metrics): | |
| with col: | |
| display_value = f"{m['value']}" | |
| if m.get('unit'): | |
| display_value = f"{m['value']} {m['unit']}" | |
| st.metric( | |
| label=m['label'], | |
| value=display_value, | |
| delta=m.get('delta'), | |
| ) | |
| def analysis_card(title: str, content: str): | |
| """Render an LLM analysis result in a HUD terminal-style card""" | |
| st.markdown(f""" | |
| <div class="analysis-card"> | |
| <h4>// {title.upper()}</h4> | |
| <div> | |
| {content.replace(chr(10), '<br>')} | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def system_status_footer(sensor_count: int, data_start: str, data_end: str, connected: bool = True): | |
| """Render a HUD-style system status panel in the sidebar""" | |
| conn_color = 'var(--status-nominal)' if connected else 'var(--status-alarm)' | |
| conn_text = 'CONNECTED' if connected else 'OFFLINE' | |
| st.markdown(f""" | |
| <div style=" | |
| border-top: 1px solid var(--border); | |
| padding-top: var(--space-md); | |
| margin-top: var(--space-md); | |
| font-family: var(--font-mono); | |
| font-size: 0.7rem; | |
| color: var(--text-muted); | |
| letter-spacing: 0.5px; | |
| "> | |
| <div style="margin-bottom: var(--space-xs);"> | |
| <span class="hud-indicator" style="background-color: {conn_color}; box-shadow: 0 0 6px {conn_color};"></span> | |
| <span style="color: {conn_color};">{conn_text}</span> | |
| </div> | |
| <div>SENSORS: {sensor_count}</div> | |
| <div>DATA: {data_start} — {data_end}</div> | |
| <div style="margin-top: 6px; color: var(--text-dim);">CSH2 DELPHI v1.0</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def cycle_selector(cycles: List[Dict], key_prefix: str = "cycle") -> Optional[Dict]: | |
| """ | |
| Render a cycle selector dropdown. | |
| cycles: list of dicts with keys: cycle_id, start_time, end_time, duration_minutes, peak_pressure | |
| Returns selected cycle dict or None | |
| """ | |
| if not cycles: | |
| st.info("No testing cycles detected for this period.") | |
| return None | |
| options = [] | |
| for c in cycles: | |
| # Use Eastern display time if available, fall back to start_time | |
| display_time = c.get('start_time_et') or c['start_time'] | |
| start_str = display_time.strftime('%b %d %H:%M') + " ET" | |
| duration = f"{c['duration_minutes']:.0f} min" | |
| peak = f"{c.get('peak_pressure', 0):.0f} bar" if c.get('peak_pressure') else "N/A" | |
| options.append(f"Cycle {c['cycle_id']} | {start_str} | {duration} | Peak (PT130): {peak}") | |
| selected_idx = st.selectbox( | |
| "Select a testing cycle", | |
| range(len(options)), | |
| format_func=lambda i: options[i], | |
| key=f"{key_prefix}_selector", | |
| ) | |
| return cycles[selected_idx] if selected_idx is not None else None | |
| def tag_multiselect(db_connector, key: str = "tags", default_group: str = None) -> List[str]: | |
| """ | |
| Render a tag multi-select with group filtering. | |
| Selecting a sensor group filters the available options to only show | |
| sensors in that group. "All Sensors" shows every tag in the database. | |
| Returns list of selected tag names. | |
| """ | |
| from core.config import SENSOR_GROUPS | |
| group_names = ["All Sensors"] + list(SENSOR_GROUPS.keys()) | |
| def _clear_selection(): | |
| """Reset the multiselect when group changes to avoid stale selections.""" | |
| st.session_state.pop(f"{key}_select", None) | |
| selected_group = st.selectbox( | |
| "Sensor Group", | |
| group_names, | |
| key=f"{key}_group", | |
| on_change=_clear_selection, | |
| ) | |
| all_tags = db_connector.get_all_tag_names() | |
| if selected_group == "All Sensors": | |
| available_tags = all_tags | |
| default_tags = [] | |
| else: | |
| group_tags = SENSOR_GROUPS.get(selected_group, []) | |
| available_tags = [t for t in group_tags if t in all_tags] | |
| default_tags = available_tags # Pre-select all sensors in the chosen group | |
| selected_tags = st.multiselect( | |
| "Select Sensors", | |
| options=available_tags, | |
| default=default_tags, | |
| key=f"{key}_select", | |
| ) | |
| return selected_tags | |
| def date_range_picker(key: str = "dates"): | |
| """Render start/end date and time pickers. Returns (start_datetime, end_datetime).""" | |
| from datetime import datetime, time, timedelta | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| start_date = st.date_input( | |
| "Start Date", | |
| value=datetime(2025, 9, 25), | |
| key=f"{key}_start_date", | |
| ) | |
| start_time = st.time_input("Start Time", value=time(0, 0), key=f"{key}_start_time") | |
| with col2: | |
| end_date = st.date_input( | |
| "End Date", | |
| value=datetime(2025, 9, 25), | |
| key=f"{key}_end_date", | |
| ) | |
| end_time = st.time_input("End Time", value=time(23, 59), key=f"{key}_end_time") | |
| start_dt = datetime.combine(start_date, start_time) | |
| end_dt = datetime.combine(end_date, end_time) | |
| return start_dt, end_dt | |
| def no_data_message(): | |
| """Show a styled no-data message""" | |
| st.warning("No data found for the selected sensors and time range. Try adjusting your filters.") | |
| def follow_up_section( | |
| session_key: str, | |
| llm_analyzer, | |
| original_prompt: str, | |
| original_analysis: str, | |
| max_turns: int = 3, | |
| ): | |
| """Render a follow-up Q&A section below an analysis card. | |
| Maintains a multi-turn conversation thread stored in | |
| ``st.session_state[session_key]`` as a list of ``{role, content}`` dicts. | |
| Args: | |
| session_key: Unique session state key for this conversation thread. | |
| llm_analyzer: An ``LLMCycleAnalyzer`` instance with ``follow_up()`` method. | |
| original_prompt: The original user prompt that generated the analysis. | |
| original_analysis: The original assistant analysis text. | |
| max_turns: Maximum follow-up exchanges (default 3). | |
| """ | |
| # Initialize conversation history if needed | |
| if session_key not in st.session_state: | |
| st.session_state[session_key] = [] | |
| followups = st.session_state[session_key] | |
| # Display existing follow-up thread | |
| if followups: | |
| for msg in followups: | |
| if msg["role"] == "user": | |
| st.markdown(f""" | |
| <div style="background: var(--bg-elevated); border-left: 3px solid var(--info); | |
| padding: var(--space-sm) var(--space-md); margin: var(--space-sm) 0; | |
| border-radius: var(--radius); | |
| font-family: var(--font-body); font-size: 0.85rem; color: var(--text-secondary);"> | |
| <strong style="color: var(--info); font-family: var(--font-mono);">You:</strong> {msg['content']} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| analysis_card("Murphy Follow-up", msg["content"]) | |
| # Show input if under max turns | |
| turn_count = len([m for m in followups if m["role"] == "user"]) | |
| if turn_count >= max_turns: | |
| st.caption(f"Maximum {max_turns} follow-up questions reached.") | |
| return | |
| # Follow-up input | |
| input_col, btn_col = st.columns([5, 1]) | |
| with input_col: | |
| followup_q = st.text_input( | |
| "Ask a follow-up question", | |
| placeholder="e.g. 'What about the temperature trend?' or 'Explain the plateau at 370 bar'", | |
| key=f"{session_key}_input", | |
| label_visibility="collapsed", | |
| ) | |
| with btn_col: | |
| followup_btn = st.button( | |
| "Ask", | |
| type="primary", | |
| use_container_width=True, | |
| key=f"{session_key}_btn", | |
| ) | |
| if followup_btn and followup_q: | |
| # Build full conversation history for the API call | |
| conversation = [ | |
| {"role": "user", "content": original_prompt}, | |
| {"role": "assistant", "content": original_analysis}, | |
| ] + followups | |
| with st.spinner("Murphy is thinking..."): | |
| response = llm_analyzer.follow_up(conversation, followup_q) | |
| # Append to thread | |
| followups.append({"role": "user", "content": followup_q}) | |
| followups.append({"role": "assistant", "content": response}) | |
| st.session_state[session_key] = followups | |
| st.rerun() | |