idxwze/SentinelOps
0
1from __future__ import annotations2 3import json4import time5from pathlib import Path6from typing import Dict, List, Tuple7 8import gradio as gr9import plotly.graph_objects as go10from plotly.subplots import make_subplots11 12 13BASE_DIR = Path(__file__).parent14DATA_DIR = BASE_DIR / "data"15 16 17TEXT = {18 "en": {19 "tagline": "AI incident copilot for reliability and security triage",20 "subtitle": "Review live signals, isolate likely causes, and decide the next safe action faster.",21 "workspace_status": "Workspace status",22 "language": "Language",23 "text_size": "Text size",24 "contrast": "High contrast",25 "compact": "Compact",26 "comfortable": "Comfortable",27 "large": "Large",28 "nav_dashboard": "Dashboard",29 "nav_incidents": "Incidents",30 "nav_copilot": "Copilot",31 "nav_profile": "Profile",32 "queue_title": "Incident queue",33 "queue_subtitle": "Active incidents and current response state",34 "incident_selector": "Selected incident",35 "summary_title": "Incident summary",36 "summary_subtitle": "Current condition and recent operational context",37 "signals": "Core signals",38 "signals_subtitle": "Latency and error behavior for the selected service",39 "load": "Infrastructure and traffic",40 "load_subtitle": "Capacity pressure and request shape",41 "dependencies": "Service dependencies",42 "dependencies_subtitle": "Connected systems affected by this incident",43 "hypotheses": "Ranked hypotheses",44 "hypotheses_subtitle": "Most likely explanations based on current evidence",45 "actions": "Recommended actions",46 "actions_subtitle": "Practical next checks for the on-call engineer",47 "profile_title": "Analyst profile",48 "profile_subtitle": "Local prototype profile and working preferences",49 "profile_name": "Name",50 "profile_role": "Role",51 "profile_team": "Team",52 "profile_language": "Preferred language",53 "profile_focus": "Alert focus",54 "profile_status": "On-call status",55 "profile_name_value": "Maya Chen",56 "profile_role_value": "Junior SRE",57 "profile_team_value": "Platform Reliability",58 "profile_focus_value": "Payments and authentication",59 "profile_status_value": "Primary on-call",60 "copilot": "AI copilot",61 "copilot_subtitle": "Incident-aware assistant for triage and explanation",62 "copilot_placeholder": "Ask about likely cause, blast radius, recent changes, or the best next check...",63 "copilot_empty": "Choose an incident to start a focused triage conversation.",64 "copilot_closed": "Copilot minimized",65 "copilot_open": "Copilot ready",66 "launcher": "Open Copilot",67 "close_chat": "Close",68 "suggested_prompts": "Suggested prompts",69 "ask_button": "Send",70 "why_checkout": "Why is checkout failing?",71 "recent_changes": "What changed recently?",72 "root_cause": "What is the most likely root cause?",73 "check_first": "What should I check first?",74 "security_concern": "Is there a security concern?",75 "current_state": "Current state",76 "recent_change": "Recent change",77 "affected_service": "Affected service",78 "latency": "Latency",79 "error_rate": "Error rate",80 "cpu": "CPU",81 "requests": "Request volume",82 "confidence": "Confidence",83 "next_check": "Next check",84 "footer_note": "SentinelOps prototype for incident triage and reliability UX",85 "status_loading": "Loading incident context",86 "status_loaded": "Incident context loaded",87 "status_no_incident": "Select an incident to unlock the workspace",88 "status_chat_blocked": "Select an incident before using the copilot",89 "status_no_question": "Enter a question or choose a prompt",90 "assistant_intro": "Copilot ready. I can summarize likely causes, recent changes, dependency impact, and recommended next checks.",91 "empty_hypotheses": "No hypotheses are available for this incident yet.",92 "empty_dependencies": "No dependency impact data available.",93 "empty_overview": "Select an incident from the queue to inspect service health, signals, and likely causes.",94 "unavailable": "Unavailable",95 },96 "fr": {97 "tagline": "Copilote IA d'incident pour le triage fiabilite et securite",98 "subtitle": "Analysez les signaux, isolez les causes probables et choisissez plus vite la prochaine action sure.",99 "workspace_status": "Etat de l'espace",100 "language": "Langue",101 "text_size": "Taille du texte",102 "contrast": "Contraste eleve",103 "compact": "Compact",104 "comfortable": "Confortable",105 "large": "Grand",106 "nav_dashboard": "Tableau",107 "nav_incidents": "Incidents",108 "nav_copilot": "Copilote",109 "nav_profile": "Profil",110 "queue_title": "File d'incidents",111 "queue_subtitle": "Incidents actifs et etat actuel de reponse",112 "incident_selector": "Incident selectionne",113 "summary_title": "Resume de l'incident",114 "summary_subtitle": "Etat actuel et contexte operationnel recent",115 "signals": "Signaux principaux",116 "signals_subtitle": "Comportement de latence et d'erreur pour le service selectionne",117 "load": "Infrastructure et trafic",118 "load_subtitle": "Pression de capacite et forme du trafic",119 "dependencies": "Dependances du service",120 "dependencies_subtitle": "Systemes connectes touches par cet incident",121 "hypotheses": "Hypotheses classees",122 "hypotheses_subtitle": "Explications les plus probables selon les preuves actuelles",123 "actions": "Actions recommandees",124 "actions_subtitle": "Prochains controles concrets pour l'ingenieur d'astreinte",125 "profile_title": "Profil analyste",126 "profile_subtitle": "Profil local du prototype et preferences de travail",127 "profile_name": "Nom",128 "profile_role": "Role",129 "profile_team": "Equipe",130 "profile_language": "Langue preferee",131 "profile_focus": "Focus alertes",132 "profile_status": "Statut d'astreinte",133 "profile_name_value": "Maya Chen",134 "profile_role_value": "SRE junior",135 "profile_team_value": "Fiabilite plateforme",136 "profile_focus_value": "Paiements et authentification",137 "profile_status_value": "Astreinte principale",138 "copilot": "Copilote IA",139 "copilot_subtitle": "Assistant contextuel pour le triage et l'explication",140 "copilot_placeholder": "Posez une question sur la cause probable, la portee, les changements recents ou le meilleur prochain controle...",141 "copilot_empty": "Choisissez un incident pour commencer une conversation de triage ciblee.",142 "copilot_closed": "Copilote reduit",143 "copilot_open": "Copilote pret",144 "launcher": "Ouvrir le copilote",145 "close_chat": "Fermer",146 "suggested_prompts": "Invites suggerees",147 "ask_button": "Envoyer",148 "why_checkout": "Pourquoi le checkout echoue-t-il ?",149 "recent_changes": "Qu'est-ce qui a change recemment ?",150 "root_cause": "Quelle est la cause la plus probable ?",151 "check_first": "Que dois-je verifier en premier ?",152 "security_concern": "Y a-t-il un risque de securite ?",153 "current_state": "Etat actuel",154 "recent_change": "Changement recent",155 "affected_service": "Service affecte",156 "latency": "Latence",157 "error_rate": "Taux d'erreur",158 "cpu": "CPU",159 "requests": "Volume de requetes",160 "confidence": "Confiance",161 "next_check": "Prochain controle",162 "footer_note": "Prototype SentinelOps pour le triage d'incident et l'UX fiabilite",163 "status_loading": "Chargement du contexte d'incident",164 "status_loaded": "Contexte d'incident charge",165 "status_no_incident": "Selectionnez un incident pour activer l'espace de travail",166 "status_chat_blocked": "Selectionnez un incident avant d'utiliser le copilote",167 "status_no_question": "Saisissez une question ou choisissez une invite",168 "assistant_intro": "Copilote pret. Je peux resumer les causes probables, les changements recents, l'impact sur les dependances et les prochains controles recommandes.",169 "empty_hypotheses": "Aucune hypothese n'est disponible pour cet incident.",170 "empty_dependencies": "Aucune information sur l'impact des dependances.",171 "empty_overview": "Selectionnez un incident dans la file pour inspecter la sante du service, les signaux et les causes probables.",172 "unavailable": "Indisponible",173 },174}175 176 177SEVERITY_COLORS = {178 "Critical": "#dc2626",179 "High": "#ea580c",180 "Medium": "#ca8a04",181 "Low": "#16a34a",182}183 184STATUS_COLORS = {185 "Investigating": "#b45309",186 "Mitigating": "#2563eb",187 "Escalated": "#be123c",188 "Monitoring": "#0f766e",189 "Contained": "#7c3aed",190}191 192 193def load_json(path: Path):194 with path.open("r", encoding="utf-8") as file:195 return json.load(file)196 197 198def load_datasets():199 incidents = load_json(DATA_DIR / "incidents.json")200 metrics = load_json(DATA_DIR / "metrics.json")201 hypotheses = load_json(DATA_DIR / "hypotheses.json")202 return incidents, metrics, hypotheses203 204 205INCIDENTS, METRICS, HYPOTHESES = load_datasets()206INCIDENT_MAP = {incident["id"]: incident for incident in INCIDENTS}207 208 209def t(lang: str, key: str) -> str:210 return TEXT.get(lang, TEXT["en"]).get(key, key)211 212 213def format_number(value: float, unit: str = "") -> str:214 if unit == "%":215 return f"{value:.1f}%"216 if unit == "ms":217 return f"{int(value)} ms"218 if unit == "rpm":219 return f"{int(value):,} rpm"220 return f"{value}"221 222 223def get_incident_label(incident: Dict, lang: str) -> str:224 return f"{incident[f'title_{lang}']} | {incident['severity']} | {incident['service']}"225 226 227def get_incident_choices(lang: str) -> List[Tuple[str, str]]:228 return [(get_incident_label(incident, lang), incident["id"]) for incident in INCIDENTS]229 230 231def get_accessibility_classes(text_size: str, contrast: bool) -> str:232 contrast_class = "contrast-on" if contrast else "contrast-off"233 size_class = f"size-{text_size}"234 return f"{contrast_class} {size_class}"235 236 237def build_header_html(lang: str, text_size: str, contrast: bool) -> str:238 classes = get_accessibility_classes(text_size, contrast)239 return f"""240 <div class="topbar-shell {classes}">241 <div class="brand-cluster">242 <div class="brand-mark">SO</div>243 <div class="brand-copy">244 <div class="brand-title">SentinelOps</div>245 <div class="brand-subtitle">{t(lang, 'subtitle')}</div>246 </div>247 </div>248 <div class="nav-cluster">249 <div class="nav-item active">{t(lang, 'nav_dashboard')}</div>250 <div class="nav-item">{t(lang, 'nav_incidents')}</div>251 <div class="nav-item">{t(lang, 'nav_copilot')}</div>252 <div class="nav-item">{t(lang, 'nav_profile')}</div>253 </div>254 <div class="account-cluster">255 <div class="account-meta">256 <div class="account-label">{t(lang, 'workspace_status')}</div>257 <div class="account-value">SentinelOps / MVP</div>258 </div>259 <div class="account-avatar">MC</div>260 </div>261 </div>262 """263 264 265def build_status_html(message: str, lang: str, text_size: str, contrast: bool, mode: str = "neutral") -> str:266 classes = get_accessibility_classes(text_size, contrast)267 return f"""268 <div class="status-shell {classes} mode-{mode}">269 <div class="status-label">{t(lang, 'workspace_status')}</div>270 <div class="status-value">{message}</div>271 </div>272 """273 274 275def build_section_heading(title: str, subtitle: str) -> str:276 return f"""277 <div class="section-heading">278 <div class="section-title">{title}</div>279 <div class="section-subtitle">{subtitle}</div>280 </div>281 """282 283 284def build_footer_html(lang: str) -> str:285 return f"""286 <div class="footer-shell">287 <div class="footer-left">SentinelOps</div>288 <div class="footer-right">{t(lang, 'footer_note')}</div>289 </div>290 """291 292 293def build_incident_cards(selected_id: str | None, lang: str, text_size: str, contrast: bool) -> str:294 classes = get_accessibility_classes(text_size, contrast)295 cards = []296 for incident in INCIDENTS:297 selected_class = "selected" if incident["id"] == selected_id else ""298 severity_color = SEVERITY_COLORS.get(incident["severity"], "#64748b")299 status_color = STATUS_COLORS.get(incident["status"], "#64748b")300 cards.append(301 f"""302 <div class="incident-card {classes} {selected_class}">303 <div class="incident-card-top">304 <span class="severity-pill" style="background:{severity_color};">{incident['severity']}</span>305 <span class="status-pill" style="color:{status_color}; border-color:{status_color};">{incident['status']}</span>306 </div>307 <div class="incident-title">{incident[f'title_{lang}']}</div>308 <div class="incident-meta">{incident['service']} • {incident['timestamp']}</div>309 <div class="incident-description">{incident[f'short_status_{lang}']}</div>310 </div>311 """312 )313 return "\n".join(cards)314 315 316def build_profile_html(lang: str, text_size: str, contrast: bool) -> str:317 classes = get_accessibility_classes(text_size, contrast)318 items = [319 (t(lang, "profile_name"), t(lang, "profile_name_value")),320 (t(lang, "profile_role"), t(lang, "profile_role_value")),321 (t(lang, "profile_team"), t(lang, "profile_team_value")),322 (t(lang, "profile_language"), "EN / FR"),323 (t(lang, "profile_focus"), t(lang, "profile_focus_value")),324 (t(lang, "profile_status"), t(lang, "profile_status_value")),325 ]326 rows = "".join(327 [328 f"""329 <div class="profile-row">330 <div class="profile-key">{label}</div>331 <div class="profile-value">{value}</div>332 </div>333 """334 for label, value in items335 ]336 )337 return f"""338 <div class="panel-card profile-card {classes}">339 <div class="profile-top">340 <div>341 <div class="profile-avatar">MC</div>342 </div>343 <div class="profile-summary">344 <h3>{t(lang, 'profile_title')}</h3>345 <p>{t(lang, 'profile_subtitle')}</p>346 </div>347 </div>348 <div class="profile-grid">349 {rows}350 </div>351 </div>352 """353 354 355def build_overview_html(incident_id: str | None, lang: str, text_size: str, contrast: bool) -> str:356 classes = get_accessibility_classes(text_size, contrast)357 if not incident_id or incident_id not in INCIDENT_MAP:358 return f"""359 <div class="panel-card workspace-card {classes}">360 <h3>{t(lang, 'summary_title')}</h3>361 <p class="empty-state">{t(lang, 'empty_overview')}</p>362 </div>363 """364 365 incident = INCIDENT_MAP[incident_id]366 metric_payload = METRICS.get(incident_id, {})367 stats = metric_payload.get("current_stats", {})368 stat_items = [369 (t(lang, "latency"), format_number(stats.get("latency_ms", 0), "ms")),370 (t(lang, "error_rate"), format_number(stats.get("error_rate_pct", 0), "%")),371 (t(lang, "cpu"), format_number(stats.get("cpu_pct", 0), "%")),372 (t(lang, "requests"), format_number(stats.get("request_volume_rpm", 0), "rpm")),373 ]374 stat_html = "".join(375 [376 f"""377 <div class="stat-card">378 <div class="stat-label">{label}</div>379 <div class="stat-value">{value}</div>380 </div>381 """382 for label, value in stat_items383 ]384 )385 severity_color = SEVERITY_COLORS.get(incident["severity"], "#64748b")386 return f"""387 <div class="panel-card workspace-card {classes}">388 <div class="workspace-top">389 <div>390 <div class="eyebrow">{t(lang, 'affected_service')}</div>391 <h3>{incident['service']}</h3>392 </div>393 <span class="severity-pill" style="background:{severity_color};">{incident['severity']}</span>394 </div>395 <div class="info-grid">396 <div class="info-card">397 <div class="info-label">{t(lang, 'current_state')}</div>398 <div class="info-body">{incident[f'health_summary_{lang}']}</div>399 </div>400 <div class="info-card">401 <div class="info-label">{t(lang, 'recent_change')}</div>402 <div class="info-body">{incident[f'recent_change_{lang}']}</div>403 </div>404 </div>405 <div class="summary-block">406 <div class="info-label">{t(lang, 'summary_title')}</div>407 <p>{incident[f'summary_{lang}']}</p>408 </div>409 <div class="stat-grid">410 {stat_html}411 </div>412 </div>413 """414 415 416def build_dependencies_html(incident_id: str | None, lang: str, text_size: str, contrast: bool) -> str:417 classes = get_accessibility_classes(text_size, contrast)418 if not incident_id or incident_id not in METRICS:419 return f"""420 <div class="panel-card workspace-card {classes}">421 <h3>{t(lang, 'dependencies')}</h3>422 <p class="empty-state">{t(lang, 'empty_dependencies')}</p>423 </div>424 """425 426 dependencies = METRICS[incident_id].get("dependency_health", [])427 dependency_html = "".join(428 [429 f"""430 <div class="dependency-node">431 <div class="dependency-name">{item['name']}</div>432 <div class="dependency-status status-{item['status'].lower()}">{item['status']}</div>433 <div class="dependency-impact">{item[f'impact_{lang}']}</div>434 </div>435 """436 for item in dependencies437 ]438 )439 return f"""440 <div class="panel-card workspace-card {classes}">441 <div class="section-inline-title">{t(lang, 'dependencies')}</div>442 <div class="dependency-grid">{dependency_html}</div>443 </div>444 """445 446 447def build_hypotheses_html(incident_id: str | None, lang: str, text_size: str, contrast: bool) -> str:448 classes = get_accessibility_classes(text_size, contrast)449 items = HYPOTHESES.get(incident_id, [])450 if not items:451 return f"""452 <div class="panel-card workspace-card {classes}">453 <h3>{t(lang, 'hypotheses')}</h3>454 <p class="empty-state">{t(lang, 'empty_hypotheses')}</p>455 </div>456 """457 458 cards = []459 for item in items:460 risk_color = item.get("risk_color", "#64748b")461 cards.append(462 f"""463 <div class="hypothesis-card">464 <div class="hypothesis-top">465 <div class="hypothesis-title">{item[f'title_{lang}']}</div>466 <span class="confidence-pill" style="color:{risk_color}; border-color:{risk_color};">467 {t(lang, 'confidence')}: {item['confidence_score']}%468 </span>469 </div>470 <p>{item[f'explanation_{lang}']}</p>471 <div class="next-check"><strong>{t(lang, 'next_check')}:</strong> {item[f'recommended_action_{lang}']}</div>472 </div>473 """474 )475 return f"""476 <div class="panel-card workspace-card {classes}">477 <div class="hypothesis-grid">{''.join(cards)}</div>478 </div>479 """480 481 482def build_actions_html(incident_id: str | None, lang: str, text_size: str, contrast: bool) -> str:483 classes = get_accessibility_classes(text_size, contrast)484 items = HYPOTHESES.get(incident_id, [])485 if not items:486 return f"""487 <div class="panel-card workspace-card {classes}">488 <p class="empty-state">{t(lang, 'empty_hypotheses')}</p>489 </div>490 """491 492 actions = "".join(493 [494 f"""495 <div class="action-item">496 <div class="action-rank">{index + 1}</div>497 <div class="action-copy">498 <div class="action-title">{item[f'title_{lang}']}</div>499 <div class="action-text">{item[f'recommended_action_{lang}']}</div>500 </div>501 </div>502 """503 for index, item in enumerate(items[:3])504 ]505 )506 return f"""507 <div class="panel-card workspace-card {classes}">508 <div class="action-list">{actions}</div>509 </div>510 """511 512 513def build_empty_figure(title: str, lang: str, contrast: bool) -> go.Figure:514 font_color = "#0f172a"515 paper_bg = "#ffffff"516 plot_bg = "#ffffff"517 if contrast:518 font_color = "#020617"519 fig = go.Figure()520 fig.update_layout(521 template="plotly_white",522 title=title,523 paper_bgcolor=paper_bg,524 plot_bgcolor=plot_bg,525 font=dict(color=font_color, size=13),526 margin=dict(l=24, r=18, t=54, b=24),527 xaxis=dict(visible=False),528 yaxis=dict(visible=False),529 annotations=[530 dict(531 text=t(lang, "status_no_incident"),532 x=0.5,533 y=0.5,534 xref="paper",535 yref="paper",536 showarrow=False,537 font=dict(size=14, color="#64748b"),538 )539 ],540 )541 return fig542 543 544def build_signal_figure(incident_id: str | None, lang: str, contrast: bool) -> go.Figure:545 if not incident_id or incident_id not in METRICS:546 return build_empty_figure(t(lang, "signals"), lang, contrast)547 payload = METRICS[incident_id]548 timestamps = payload["timestamps"]549 series = payload["series"]550 fig = make_subplots(rows=1, cols=2, subplot_titles=(t(lang, "latency"), t(lang, "error_rate")))551 fig.add_trace(552 go.Scatter(553 x=timestamps,554 y=series["latency_ms"],555 mode="lines+markers",556 name=t(lang, "latency"),557 line=dict(color="#2563eb", width=2.5),558 fill="tozeroy",559 fillcolor="rgba(37,99,235,0.10)",560 ),561 row=1,562 col=1,563 )564 fig.add_trace(565 go.Scatter(566 x=timestamps,567 y=series["error_rate_pct"],568 mode="lines+markers",569 name=t(lang, "error_rate"),570 line=dict(color="#ea580c", width=2.5),571 fill="tozeroy",572 fillcolor="rgba(234,88,12,0.10)",573 ),574 row=1,575 col=2,576 )577 fig.update_layout(578 template="plotly_white",579 title=t(lang, "signals"),580 paper_bgcolor="#ffffff",581 plot_bgcolor="#ffffff",582 font=dict(color="#0f172a", size=13),583 margin=dict(l=24, r=18, t=56, b=24),584 legend=dict(orientation="h", yanchor="bottom", y=1.08, xanchor="right", x=1.0),585 height=320,586 )587 fig.update_xaxes(showgrid=False, tickangle=-35, linecolor="#e2e8f0")588 fig.update_yaxes(showgrid=True, gridcolor="#eef2f7", zerolinecolor="#eef2f7")589 return fig590 591 592def build_load_figure(incident_id: str | None, lang: str, contrast: bool) -> go.Figure:593 if not incident_id or incident_id not in METRICS:594 return build_empty_figure(t(lang, "load"), lang, contrast)595 payload = METRICS[incident_id]596 timestamps = payload["timestamps"]597 series = payload["series"]598 fig = make_subplots(rows=1, cols=2, subplot_titles=(t(lang, "cpu"), t(lang, "requests")))599 fig.add_trace(600 go.Scatter(601 x=timestamps,602 y=series["cpu_pct"],603 mode="lines+markers",604 name=t(lang, "cpu"),605 line=dict(color="#0f766e", width=2.5),606 fill="tozeroy",607 fillcolor="rgba(15,118,110,0.10)",608 ),609 row=1,610 col=1,611 )612 fig.add_trace(613 go.Bar(614 x=timestamps,615 y=series["request_volume_rpm"],616 name=t(lang, "requests"),617 marker=dict(color="#7c3aed", opacity=0.82),618 ),619 row=1,620 col=2,621 )622 fig.update_layout(623 template="plotly_white",624 title=t(lang, "load"),625 paper_bgcolor="#ffffff",626 plot_bgcolor="#ffffff",627 font=dict(color="#0f172a", size=13),628 margin=dict(l=24, r=18, t=56, b=24),629 legend=dict(orientation="h", yanchor="bottom", y=1.08, xanchor="right", x=1.0),630 height=320,631 )632 fig.update_xaxes(showgrid=False, tickangle=-35, linecolor="#e2e8f0")633 fig.update_yaxes(showgrid=True, gridcolor="#eef2f7", zerolinecolor="#eef2f7")634 return fig635 636 637def get_suggested_prompts(lang: str) -> List[str]:638 return [639 t(lang, "why_checkout"),640 t(lang, "recent_changes"),641 t(lang, "root_cause"),642 t(lang, "check_first"),643 t(lang, "security_concern"),644 ]645 646 647def get_copilot_intro(lang: str) -> List[Dict[str, str]]:648 return [{"role": "assistant", "content": t(lang, "assistant_intro")}]649 650 651def build_copilot_response(incident_id: str, lang: str, question: str) -> str:652 incident = INCIDENT_MAP[incident_id]653 top_hypothesis = HYPOTHESES.get(incident_id, [{}])[0]654 responses = incident["copilot_responses"]655 normalized = question.lower()656 if any(keyword in normalized for keyword in ["change", "changed", "recent", "modification", "change rec", "changement"]):657 return responses[f"changes_{lang}"]658 if any(keyword in normalized for keyword in ["root", "cause", "likely", "probable", "racine"]):659 return responses[f"root_cause_{lang}"]660 if any(keyword in normalized for keyword in ["check", "first", "verify", "verifier", "premier"]):661 return responses[f"check_first_{lang}"]662 if any(keyword in normalized for keyword in ["security", "abuse", "credential", "securite", "attaque"]):663 return responses[f"security_{lang}"]664 if any(keyword in normalized for keyword in ["why", "failing", "down", "latency", "timeout", "pourquoi", "echec"]):665 return responses[f"summary_{lang}"]666 fallback_title = top_hypothesis.get(f"title_{lang}", t(lang, "unavailable"))667 fallback_action = top_hypothesis.get(f"recommended_action_{lang}", t(lang, "unavailable"))668 return (669 f"{responses[f'summary_{lang}']} "670 f"{t(lang, 'next_check')}: {fallback_action}. "671 f"{t(lang, 'confidence')}: {top_hypothesis.get('confidence_score', 0)}% on {fallback_title}."672 )673 674 675def sync_ui(lang: str, selected_incident: str | None, text_size: str, contrast: bool, chat_open: bool):676 enabled = selected_incident is not None677 status_message = t(lang, "status_loaded" if enabled else "status_no_incident")678 status_mode = "healthy" if enabled else "neutral"679 prompts = get_suggested_prompts(lang)680 chat_value = get_copilot_intro(lang)681 launcher = f"● {t(lang, 'copilot')}" if chat_open or enabled else f"○ {t(lang, 'launcher')}"682 return (683 build_header_html(lang, text_size, contrast),684 build_status_html(status_message, lang, text_size, contrast, status_mode),685 build_section_heading(t(lang, "queue_title"), t(lang, "queue_subtitle")),686 build_incident_cards(selected_incident, lang, text_size, contrast),687 gr.update(choices=get_incident_choices(lang), label=t(lang, "incident_selector"), value=selected_incident),688 gr.update(label=t(lang, "language")),689 gr.update(label=t(lang, "text_size")),690 gr.update(label=t(lang, "contrast")),691 build_profile_html(lang, text_size, contrast),692 build_section_heading(t(lang, "summary_title"), t(lang, "summary_subtitle")),693 build_overview_html(selected_incident, lang, text_size, contrast),694 build_section_heading(t(lang, "signals"), t(lang, "signals_subtitle")),695 build_signal_figure(selected_incident, lang, contrast),696 build_section_heading(t(lang, "load"), t(lang, "load_subtitle")),697 build_load_figure(selected_incident, lang, contrast),698 build_section_heading(t(lang, "dependencies"), t(lang, "dependencies_subtitle")),699 build_dependencies_html(selected_incident, lang, text_size, contrast),700 build_section_heading(t(lang, "hypotheses"), t(lang, "hypotheses_subtitle")),701 build_hypotheses_html(selected_incident, lang, text_size, contrast),702 build_section_heading(t(lang, "actions"), t(lang, "actions_subtitle")),703 build_actions_html(selected_incident, lang, text_size, contrast),704 build_section_heading(t(lang, "copilot"), t(lang, "copilot_subtitle")),705 gr.update(value=chat_value, label=t(lang, "copilot")),706 gr.update(placeholder=t(lang, "copilot_placeholder"), interactive=enabled),707 gr.update(value=t(lang, "ask_button"), interactive=enabled),708 gr.update(value=prompts[0], interactive=enabled),709 gr.update(value=prompts[1], interactive=enabled),710 gr.update(value=prompts[2], interactive=enabled),711 gr.update(value=prompts[3], interactive=enabled),712 gr.update(value=prompts[4], interactive=enabled),713 gr.update(value=launcher),714 gr.update(visible=chat_open),715 build_footer_html(lang),716 chat_value,717 )718 719 720def update_incident_view(selected_incident: str | None, lang: str, text_size: str, contrast: bool):721 cards = build_incident_cards(selected_incident, lang, text_size, contrast)722 intro_history = get_copilot_intro(lang)723 if not selected_incident:724 yield (725 cards,726 build_overview_html(None, lang, text_size, contrast),727 build_signal_figure(None, lang, contrast),728 build_load_figure(None, lang, contrast),729 build_dependencies_html(None, lang, text_size, contrast),730 build_hypotheses_html(None, lang, text_size, contrast),731 build_actions_html(None, lang, text_size, contrast),732 intro_history,733 intro_history,734 build_status_html(t(lang, "status_no_incident"), lang, text_size, contrast, "neutral"),735 gr.update(interactive=False, placeholder=t(lang, "copilot_placeholder")),736 gr.update(interactive=False),737 gr.update(interactive=False),738 gr.update(interactive=False),739 gr.update(interactive=False),740 gr.update(interactive=False),741 gr.update(interactive=False),742 gr.update(interactive=False),743 gr.update(value=f"○ {t(lang, 'launcher')}"),744 )745 return746 yield (747 cards,748 build_overview_html(None, lang, text_size, contrast),749 build_empty_figure(t(lang, "signals"), lang, contrast),750 build_empty_figure(t(lang, "load"), lang, contrast),751 build_dependencies_html(None, lang, text_size, contrast),752 build_hypotheses_html(None, lang, text_size, contrast),753 build_actions_html(None, lang, text_size, contrast),754 intro_history,755 intro_history,756 build_status_html(t(lang, "status_loading"), lang, text_size, contrast, "loading"),757 gr.update(interactive=False, placeholder=t(lang, "copilot_placeholder")),758 gr.update(interactive=False),759 gr.update(interactive=False),760 gr.update(interactive=False),761 gr.update(interactive=False),762 gr.update(interactive=False),763 gr.update(interactive=False),764 gr.update(interactive=False),765 gr.update(value=f"● {t(lang, 'copilot')}"),766 )767 time.sleep(0.25)768 yield (769 cards,770 build_overview_html(selected_incident, lang, text_size, contrast),771 build_signal_figure(selected_incident, lang, contrast),772 build_load_figure(selected_incident, lang, contrast),773 build_dependencies_html(selected_incident, lang, text_size, contrast),774 build_hypotheses_html(selected_incident, lang, text_size, contrast),775 build_actions_html(selected_incident, lang, text_size, contrast),776 intro_history,777 intro_history,778 build_status_html(t(lang, "status_loaded"), lang, text_size, contrast, "healthy"),779 gr.update(interactive=True, placeholder=t(lang, "copilot_placeholder")),780 gr.update(interactive=True),781 gr.update(interactive=True),782 gr.update(interactive=True),783 gr.update(interactive=True),784 gr.update(interactive=True),785 gr.update(interactive=True),786 gr.update(interactive=True),787 gr.update(value=f"● {t(lang, 'copilot')}"),788 )789 790 791def toggle_chat(chat_open: bool, lang: str):792 next_state = not chat_open793 label = t(lang, "copilot") if next_state else t(lang, "launcher")794 prefix = "●" if next_state else "○"795 return next_state, gr.update(visible=next_state), gr.update(value=f"{prefix} {label}")796 797 798def submit_chat(799 message: str,800 history: List[Dict[str, str]] | None,801 selected_incident: str | None,802 lang: str,803 text_size: str,804 contrast: bool,805):806 history = history or []807 if not selected_incident:808 history.append({"role": "assistant", "content": t(lang, "status_chat_blocked")})809 return history, history, "", build_status_html(t(lang, "status_chat_blocked"), lang, text_size, contrast, "warning")810 if not message or not message.strip():811 return history, history, "", build_status_html(t(lang, "status_no_question"), lang, text_size, contrast, "warning")812 answer = build_copilot_response(selected_incident, lang, message.strip())813 history.append({"role": "user", "content": message.strip()})814 history.append({"role": "assistant", "content": answer})815 return history, history, "", build_status_html(t(lang, "status_loaded"), lang, text_size, contrast, "healthy")816 817 818def use_prompt(819 prompt: str,820 history: List[Dict[str, str]] | None,821 selected_incident: str | None,822 lang: str,823 text_size: str,824 contrast: bool,825):826 return submit_chat(prompt, history, selected_incident, lang, text_size, contrast)827 828 829CUSTOM_CSS = """830:root {831 --bg: #f5f7fb;832 --bg-soft: #eef2f7;833 --panel: #ffffff;834 --panel-soft: #fbfcfe;835 --panel-accent: #f2f7ff;836 --text: #0f172a;837 --text-soft: #475569;838 --text-faint: #64748b;839 --border: #e6ebf2;840 --border-strong: #d4dce8;841 --accent: #2563eb;842 --accent-soft: #dbeafe;843 --success: #15803d;844 --warning: #b45309;845 --danger: #dc2626;846 --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);847 --shadow-md: 0 12px 32px rgba(15, 23, 42, 0.06);848 --radius-xl: 18px;849 --radius-lg: 16px;850 --radius-md: 12px;851}852 853body, .gradio-container {854 font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;855 background:856 radial-gradient(circle at top left, rgba(37, 99, 235, 0.035), transparent 26%),857 linear-gradient(180deg, #f8fafc 0%, #f5f7fb 100%);858 color: var(--text);859}860 861.gradio-container {862 max-width: 1560px !important;863 padding: 20px 18px 28px !important;864}865 866.gradio-container .block,867.gradio-container .gr-box,868.gradio-container .gr-group {869 border: none !important;870 box-shadow: none !important;871 background: transparent !important;872}873 874.gradio-container button,875.gradio-container input,876.gradio-container textarea,877.gradio-container select,878.gradio-container label,879.gradio-container div,880.gradio-container span,881.gradio-container p {882 font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;883}884 885.app-shell {886 gap: 16px !important;887}888 889.topbar-shell {890 background: rgba(255, 255, 255, 0.88);891 backdrop-filter: blur(10px);892 border: 1px solid var(--border);893 border-radius: 18px;894 box-shadow: var(--shadow-sm);895 padding: 14px 18px;896 display: grid;897 grid-template-columns: 1.3fr 1fr 0.9fr;898 align-items: center;899 gap: 18px;900}901 902.brand-cluster,903.account-cluster {904 display: flex;905 align-items: center;906 gap: 12px;907}908 909.brand-mark {910 width: 40px;911 height: 40px;912 border-radius: 12px;913 background: linear-gradient(180deg, #eff6ff, #dbeafe);914 color: var(--accent);915 display: flex;916 align-items: center;917 justify-content: center;918 font-weight: 800;919 font-size: 14px;920}921 922.brand-title {923 font-size: 16px;924 font-weight: 700;925 color: var(--text);926}927 928.brand-subtitle {929 margin-top: 2px;930 font-size: 12px;931 color: var(--text-faint);932}933 934.nav-cluster {935 display: flex;936 align-items: center;937 justify-content: center;938 gap: 8px;939}940 941.nav-item {942 padding: 8px 12px;943 border-radius: 10px;944 color: var(--text-faint);945 font-size: 13px;946 font-weight: 600;947 border: 1px solid transparent;948}949 950.nav-item.active {951 background: var(--panel-accent);952 color: var(--accent);953 border-color: #bfdbfe;954}955 956.account-meta {957 margin-left: auto;958 text-align: right;959}960 961.account-label {962 font-size: 11px;963 color: var(--text-faint);964 text-transform: uppercase;965 letter-spacing: 0.06em;966 font-weight: 700;967}968 969.account-value {970 margin-top: 3px;971 font-size: 13px;972 color: var(--text-soft);973}974 975.account-avatar,976.profile-avatar {977 width: 38px;978 height: 38px;979 border-radius: 999px;980 background: #eff6ff;981 color: var(--accent);982 display: flex;983 align-items: center;984 justify-content: center;985 font-weight: 700;986}987 988.settings-shell,989.status-shell,990.pane-shell,991.panel-card,992.chat-popup {993 background: var(--panel);994 border: 1px solid var(--border);995 box-shadow: var(--shadow-sm);996}997 998.settings-shell {999 border-radius: 16px;1000 padding: 14px 16px 8px;1001}1002 1003.status-shell {1004 border-radius: 16px;1005 padding: 16px 18px;1006}1007 1008.status-label,1009.eyebrow,1010.info-label,1011.stat-label,1012.profile-key,1013.dependency-impact {1014 font-size: 11px;1015 color: var(--text-faint);1016 text-transform: uppercase;1017 letter-spacing: 0.06em;1018 font-weight: 700;1019}1020 1021.status-value {1022 margin-top: 8px;1023 font-size: 15px;1024 color: var(--text);1025 font-weight: 700;1026}1027 1028.mode-healthy .status-value { color: var(--success); }1029.mode-warning .status-value { color: var(--warning); }1030.mode-loading .status-value { color: var(--accent); }1031 1032.section-heading {1033 padding: 2px 2px 10px;1034}1035 1036.section-title {1037 font-size: 15px;1038 font-weight: 700;1039 color: var(--text);1040}1041 1042.section-subtitle {1043 margin-top: 3px;1044 font-size: 12px;1045 color: var(--text-faint);1046}1047 1048.master-detail {1049 gap: 16px !important;1050 align-items: start !important;1051}1052 1053.pane-shell {1054 border-radius: 18px;1055 padding: 14px;1056}1057 1058.incident-card {1059 background: var(--panel);1060 border: 1px solid var(--border);1061 border-radius: 14px;1062 padding: 14px;1063 margin-bottom: 10px;1064 transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;1065}1066 1067.incident-card:hover {1068 border-color: var(--border-strong);1069 box-shadow: var(--shadow-sm);1070}1071 1072.incident-card.selected {1073 background: var(--panel-accent);1074 border-color: #93c5fd;1075 box-shadow: inset 3px 0 0 var(--accent);1076}1077 1078.incident-card-top,1079.workspace-top,1080.hypothesis-top,1081.profile-top {1082 display: flex;1083 align-items: flex-start;1084 justify-content: space-between;1085 gap: 10px;1086}1087 1088.incident-title,1089.hypothesis-title,1090.action-title {1091 margin-top: 10px;1092 color: var(--text);1093 font-weight: 700;1094 font-size: 15px;1095 line-height: 1.35;1096}1097 1098.incident-meta,1099.incident-description,1100.info-body,1101.summary-block p,1102.hypothesis-card p,1103.next-check,1104.action-text,1105.profile-summary p,1106.empty-state {1107 color: var(--text-soft);1108 font-size: 13px;1109 line-height: 1.55;1110}1111 1112.incident-meta,1113.incident-description {1114 margin-top: 7px;1115 color: var(--text-faint);1116 font-size: 12px;1117}1118 1119.severity-pill,1120.status-pill,1121.confidence-pill {1122 display: inline-flex;1123 align-items: center;1124 justify-content: center;1125 border-radius: 999px;1126 padding: 4px 9px;1127 font-size: 11px;1128 font-weight: 700;1129}1130 1131.severity-pill {1132 color: white;1133}1134 1135.status-pill,1136.confidence-pill {1137 background: #ffffff;1138 border: 1px solid var(--border-strong);1139}1140 1141.workspace-card,1142.profile-card {1143 border-radius: 16px;1144 padding: 18px;1145}1146 1147.workspace-card h3,1148.profile-summary h3 {1149 margin: 0;1150 font-size: 16px;1151 color: var(--text);1152}1153 1154.info-grid,1155.stat-grid,1156.dependency-grid,1157.hypothesis-grid,1158.workspace-two-up {1159 display: grid;1160 gap: 14px;1161}1162 1163.info-grid {1164 grid-template-columns: repeat(2, minmax(0, 1fr));1165 margin: 18px 0;1166}1167 1168.stat-grid {1169 grid-template-columns: repeat(4, minmax(0, 1fr));1170 margin-top: 18px;1171}1172 1173.dependency-grid,1174.hypothesis-grid {1175 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));1176}1177 1178.info-card,1179.stat-card,1180.dependency-node,1181.hypothesis-card,1182.action-item,1183.profile-row {1184 background: var(--panel-soft);1185 border: 1px solid var(--border);1186 border-radius: 14px;1187 padding: 14px;1188}1189 1190.stat-value {1191 margin-top: 8px;1192 font-size: 21px;1193 font-weight: 700;1194 color: var(--text);1195}1196 1197.summary-block {1198 margin-top: 2px;1199}1200 