Spaces:
Running
Running
Mathias Heider
Claude Fable 5.1
Keep the agent running: cycle watchdog, Retry-After cap, boot at container start, self keep-alive
73849a1 unverified Download agent/ui_theme.py from aim4composites/AutonomousAgent: direct link, hf CLI and curl.
- Browser
- Download file 26.3 kB
-
https://huggingface.co/spaces/aim4composites/AutonomousAgent/resolve/main/agent/ui_theme.py
- Command line
-
hf download hf://spaces/aim4composites/AutonomousAgent/agent/ui_theme.py
-
curl -L -o ui_theme.py https://huggingface.co/spaces/aim4composites/AutonomousAgent/resolve/main/agent/ui_theme.py
26.3 kB
| """Design system for the agent Space — tokens, CSS, altair theme, components. | |
| Everything visual that is shared between pages lives here so a page only | |
| composes: ``page_header``, ``status_pill``, ``kpi_row``, ``empty_state``, | |
| ``chip``, ``feed`` and the altair theme. Nothing in this module talks to the | |
| database or changes behavior; it is presentation only. | |
| Palette / chrome follow the project design system (colorblind-validated | |
| categorical order, status colors reserved for state, one custom light theme | |
| with a serviceable dark variant picked from ``st.context.theme``). | |
| """ | |
| from __future__ import annotations | |
| import html as _html | |
| from datetime import datetime, timezone | |
| from typing import Iterable, Optional | |
| import streamlit as st | |
| # --------------------------------------------------------------------------- | |
| # tokens | |
| # --------------------------------------------------------------------------- | |
| CATEGORICAL_LIGHT = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", | |
| "#e87ba4", "#008300", "#4a3aa7", "#e34948"] | |
| CATEGORICAL_DARK = ["#3987e5", "#d95926", "#199e70", "#c98500", | |
| "#d55181", "#008300", "#9085e9", "#e66767"] | |
| SEQUENTIAL = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", | |
| "#256abf", "#184f95", "#0d366b"] | |
| STATUS = { # reserved for state — never used as series colors | |
| "good": "#0ca30c", | |
| "warning": "#fab219", | |
| "serious": "#ec835a", | |
| "critical": "#d03b3b", | |
| } | |
| LIGHT = dict( | |
| ink="#0b0b0b", ink2="#52514e", muted="#898781", | |
| grid="#e1e0d9", baseline="#c3c2b7", hairline="rgba(11,11,11,0.10)", | |
| surface="#fcfcfb", plane="#f9f9f7", success_text="#006300", | |
| tint="rgba(11,11,11,0.035)", primary="#2a78d6", | |
| ) | |
| DARK = dict( | |
| ink="#f3f3f1", ink2="#c2c1bb", muted="#8f8e88", | |
| grid="#33332f", baseline="#4a4a45", hairline="rgba(255,255,255,0.12)", | |
| surface="#151515", plane="#0e0e0e", success_text="#5fd35f", | |
| tint="rgba(255,255,255,0.05)", primary="#3987e5", | |
| ) | |
| FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' | |
| MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace' | |
| def is_dark() -> bool: | |
| """True only when the *configured* Streamlit theme is dark. | |
| Deliberately NOT st.context.theme.type: with a single custom light theme | |
| that call reports the viewer's OS preference ("dark" on a dark desktop) | |
| while Streamlit still renders the light theme — injecting dark tokens on | |
| a light page. The config is the one source of truth for the whole app. | |
| """ | |
| try: | |
| return (st.get_option("theme.base") or "light") == "dark" | |
| except Exception: | |
| return False | |
| def tokens() -> dict: | |
| return DARK if is_dark() else LIGHT | |
| def series() -> list[str]: | |
| """Categorical series colors in fixed order (color follows the entity).""" | |
| return CATEGORICAL_DARK if is_dark() else CATEGORICAL_LIGHT | |
| def esc(x) -> str: | |
| """HTML-escape anything (None/NaN-proof).""" | |
| if x is None: | |
| return "" | |
| try: | |
| import pandas as pd | |
| if isinstance(x, float) and pd.isna(x): | |
| return "" | |
| except Exception: | |
| pass | |
| return _html.escape(str(x), quote=True) | |
| def fmt_int(n) -> str: | |
| """Thousands-separated integer, '—' for None/NaN.""" | |
| try: | |
| if n is None: | |
| return "—" | |
| import pandas as pd | |
| if pd.isna(n): | |
| return "—" | |
| return f"{int(n):,}" | |
| except Exception: | |
| return "—" | |
| def rel_time(ts) -> str: | |
| """'3 min ago' style relative time; '' if unparsable.""" | |
| if ts is None: | |
| return "" | |
| try: | |
| import pandas as pd | |
| if pd.isna(ts): | |
| return "" | |
| t = pd.Timestamp(ts) | |
| if t.tzinfo is None: | |
| t = t.tz_localize("UTC") | |
| now = pd.Timestamp.now(tz="UTC") | |
| s = int((now - t).total_seconds()) | |
| except Exception: | |
| return "" | |
| if s < 0: | |
| s = 0 | |
| if s < 45: | |
| return "just now" | |
| if s < 3600: | |
| return f"{s // 60} min ago" | |
| if s < 86400: | |
| h = s // 3600 | |
| return f"{h} h ago" | |
| d = s // 86400 | |
| return f"{d} day{'s' if d != 1 else ''} ago" | |
| def fmt_ts(ts, fmt: str = "%b %d, %H:%M") -> str: | |
| if ts is None: | |
| return "" | |
| try: | |
| import pandas as pd | |
| if pd.isna(ts): | |
| return "" | |
| return pd.Timestamp(ts).strftime(fmt) | |
| except Exception: | |
| return str(ts) | |
| # --------------------------------------------------------------------------- | |
| # CSS (single place) | |
| # --------------------------------------------------------------------------- | |
| def _css(t: dict) -> str: | |
| good, warn, serious, crit = (STATUS["good"], STATUS["warning"], | |
| STATUS["serious"], STATUS["critical"]) | |
| return f""" | |
| <style> | |
| :root {{ | |
| --aim-ink: {t['ink']}; --aim-ink2: {t['ink2']}; --aim-muted: {t['muted']}; | |
| --aim-grid: {t['grid']}; --aim-baseline: {t['baseline']}; | |
| --aim-hairline: {t['hairline']}; --aim-surface: {t['surface']}; | |
| --aim-plane: {t['plane']}; --aim-tint: {t['tint']}; --aim-primary: {t['primary']}; | |
| --aim-success-text: {t['success_text']}; | |
| --aim-good: {good}; --aim-warn: {warn}; --aim-serious: {serious}; --aim-crit: {crit}; | |
| --aim-font: {FONT}; --aim-mono: {MONO}; | |
| }} | |
| /* ---- typography scale ------------------------------------------------ */ | |
| .stApp, .stApp * {{ font-variant-numeric: proportional-nums; }} | |
| .stApp h1, .stApp h2, .stApp h3 {{ letter-spacing: -0.01em; }} | |
| [data-testid="stMainBlockContainer"] {{ padding-top: 4.4rem; padding-bottom: 3rem; max-width: 1360px; }} | |
| [data-testid="stHeading"] h2 {{ font-size: 1.15rem; font-weight: 650; padding-top: .9rem; padding-bottom: .2rem; }} | |
| [data-testid="stHeading"] h3 {{ font-size: 1.0rem; font-weight: 650; }} | |
| [data-testid="stCaptionContainer"] p {{ color: var(--aim-muted); }} | |
| [data-testid="stExpander"] details {{ border: 1px solid var(--aim-hairline); border-radius: 10px; background: var(--aim-surface); }} | |
| [data-testid="stExpander"] summary {{ padding: .55rem .9rem; }} | |
| [data-testid="stExpander"] summary p {{ margin: 0; }} | |
| [data-testid="stTabs"] [data-baseweb="tab-list"] {{ gap: 6px; }} | |
| [data-testid="stTabs"] [data-baseweb="tab-highlight"] {{ background-color: var(--aim-primary); height: 2px; }} | |
| [data-testid="stTabs"] [data-baseweb="tab-border"] {{ background-color: var(--aim-hairline); }} | |
| [data-testid="stTabs"] [data-baseweb="tab"] p {{ font-weight: 600; }} | |
| [data-testid="stDataFrame"] {{ border-radius: 8px; overflow: hidden; }} | |
| [data-testid="stDataFrame"], [data-testid="stDataFrame"] * {{ font-variant-numeric: tabular-nums; }} | |
| [data-testid="stSidebar"] {{ border-right: 1px solid var(--aim-hairline); }} | |
| [data-testid="stHeader"] {{ border-bottom: 1px solid var(--aim-hairline); background: var(--aim-plane); }} | |
| [data-testid="stTopNavLink"] p {{ font-weight: 560; }} | |
| [data-testid="stTopNavLink"][aria-current="page"] p, | |
| [data-testid="stTopNavLinkContainer"] a[aria-current="page"] p {{ font-weight: 700; }} | |
| [data-testid="stAppDeployButton"] {{ display: none; }} | |
| code, kbd, pre {{ font-family: var(--aim-mono) !important; }} | |
| /* cards: every bordered container is a card */ | |
| [data-testid="stVerticalBlockBorderWrapper"] > div[data-testid="stVerticalBlock"] {{ gap: .6rem; }} | |
| div[data-testid="stVerticalBlockBorderWrapper"]:has(> div > div[data-testid="stVerticalBlock"]) {{ | |
| border-color: var(--aim-hairline) !important; border-radius: 12px; background: var(--aim-surface); | |
| padding: 1rem 1.15rem 1.05rem 1.15rem; | |
| }} | |
| .aim-card-title {{ font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; | |
| color: var(--aim-muted); margin: 0 0 .15rem 0; display: flex; align-items: center; justify-content: space-between; gap: 8px; }} | |
| .aim-card-title .aim-card-sub {{ font-weight: 500; letter-spacing: 0; text-transform: none; font-size: 12px; }} | |
| /* ---- brand row / footer ---------------------------------------------- */ | |
| .aim-brand {{ display: flex; align-items: baseline; justify-content: space-between; gap: 16px; | |
| padding: 2px 0 10px 0; margin-bottom: 4px; border-bottom: 1px solid var(--aim-hairline); }} | |
| .aim-brand .aim-wordmark {{ font-size: 13px; font-weight: 700; letter-spacing: .02em; color: var(--aim-ink); }} | |
| .aim-brand .aim-wordmark span {{ font-weight: 500; color: var(--aim-ink2); }} | |
| .aim-brand .aim-env {{ font-size: 12px; color: var(--aim-muted); white-space: nowrap; }} | |
| .aim-brand .aim-env code {{ font-size: 11.5px; background: var(--aim-tint); padding: 1px 6px; border-radius: 5px; color: var(--aim-ink2); }} | |
| [data-testid="stLayoutWrapper"]:has(> .st-key-aim_footer), .st-key-aim_footer {{ order: 9999; }} | |
| .st-key-aim_footer {{ margin-top: 2.5rem; }} | |
| .aim-footer {{ border-top: 1px solid var(--aim-hairline); padding-top: 10px; font-size: 12px; color: var(--aim-muted); | |
| display: flex; gap: 18px; flex-wrap: wrap; align-items: center; }} | |
| .aim-footer a {{ color: var(--aim-ink2); text-decoration: none; border-bottom: 1px solid var(--aim-baseline); }} | |
| .aim-footer a:hover {{ color: var(--aim-primary); border-color: var(--aim-primary); }} | |
| /* ---- page header ------------------------------------------------------- */ | |
| .aim-ph {{ display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin: .35rem 0 1.1rem 0; }} | |
| .aim-ph h1 {{ font-size: 1.85rem; font-weight: 700; margin: 0; padding: 0; line-height: 1.15; color: var(--aim-ink); }} | |
| .aim-ph .aim-sub {{ font-size: 14px; color: var(--aim-ink2); margin-top: 4px; }} | |
| .aim-ph .aim-right {{ display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }} | |
| /* ---- pills / chips ----------------------------------------------------- */ | |
| .aim-pill {{ display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; | |
| padding: 3px 10px 3px 8px; border-radius: 999px; border: 1px solid transparent; white-space: nowrap; line-height: 1.5; }} | |
| .aim-pill .dot {{ width: 8px; height: 8px; border-radius: 50%; background: currentColor; flex: none; }} | |
| .aim-pill-ok {{ color: #0a6a0a; background: rgba(12,163,12,.12); border-color: rgba(12,163,12,.28); }} | |
| .aim-pill-running {{ color: #1b5cab; background: rgba(42,120,214,.12); border-color: rgba(42,120,214,.30); }} | |
| .aim-pill-running .dot {{ animation: aim-pulse 1.4s ease-in-out infinite; }} | |
| .aim-pill-warn, .aim-pill-stale {{ color: #7a5200; background: rgba(250,178,25,.18); border-color: rgba(250,178,25,.45); }} | |
| .aim-pill-crashed, .aim-pill-error {{ color: #9c1f1f; background: rgba(208,59,59,.12); border-color: rgba(208,59,59,.32); }} | |
| .aim-pill-idle, .aim-pill-off {{ color: var(--aim-ink2); background: var(--aim-tint); border-color: var(--aim-hairline); }} | |
| .aim-pill-info {{ color: var(--aim-ink2); background: var(--aim-tint); border-color: var(--aim-hairline); }} | |
| .aim-pill-new {{ color: #1b5cab; background: rgba(42,120,214,.12); border-color: rgba(42,120,214,.30); }} | |
| @keyframes aim-pulse {{ 0%,100% {{ opacity: 1; transform: scale(1);}} 50% {{ opacity: .35; transform: scale(.75);}} }} | |
| .aim-chip {{ display: inline-flex; align-items: center; gap: 4px; font-size: 11.5px; font-weight: 600; padding: 1px 8px; | |
| border-radius: 6px; border: 1px solid var(--aim-hairline); background: var(--aim-tint); color: var(--aim-ink2); white-space: nowrap; }} | |
| .aim-chip-muted {{ font-weight: 500; }} | |
| .aim-chip-info {{ color: var(--aim-ink2); }} | |
| .aim-chip-warn {{ color: #7a5200; background: rgba(250,178,25,.18); border-color: rgba(250,178,25,.45); }} | |
| .aim-chip-error, .aim-chip-critical {{ color: #9c1f1f; background: rgba(208,59,59,.12); border-color: rgba(208,59,59,.32); }} | |
| .aim-chip-good {{ color: #0a6a0a; background: rgba(12,163,12,.12); border-color: rgba(12,163,12,.28); }} | |
| .aim-chip-blue {{ color: #1b5cab; background: rgba(42,120,214,.12); border-color: rgba(42,120,214,.30); }} | |
| .aim-chips {{ display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 2px 0 8px 0; }} | |
| .aim-chips .k {{ color: var(--aim-muted); font-weight: 500; margin-right: 2px; }} | |
| /* ---- KPI tiles --------------------------------------------------------- */ | |
| .aim-kpis {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin: .2rem 0 1rem 0; }} | |
| .aim-kpi {{ border: 1px solid var(--aim-hairline); border-radius: 12px; background: var(--aim-surface); padding: 12px 14px 11px 14px; min-width: 0; }} | |
| .aim-kpi-label {{ font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--aim-muted); | |
| display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }} | |
| .aim-kpi-label .help {{ font-weight: 600; text-transform: none; letter-spacing: 0; cursor: help; color: var(--aim-baseline); | |
| border: 1px solid var(--aim-baseline); border-radius: 50%; width: 13px; height: 13px; font-size: 9px; display: inline-flex; align-items: center; justify-content: center; }} | |
| .aim-kpi-value {{ font-size: 30px; font-weight: 650; line-height: 1.15; color: var(--aim-ink); margin-top: 6px; letter-spacing: -0.015em; }} | |
| .aim-kpi-value.small {{ font-size: 22px; }} | |
| .aim-kpi-delta {{ font-size: 12px; margin-top: 4px; color: var(--aim-ink2); font-weight: 500; }} | |
| .aim-kpi-delta.up {{ color: var(--aim-success-text); }} | |
| .aim-kpi-delta.down {{ color: var(--aim-crit); }} | |
| .aim-kpi-delta.warn {{ color: #7a5200; }} | |
| /* ---- empty state ------------------------------------------------------- */ | |
| .aim-empty {{ text-align: center; padding: 34px 20px; border: 1px dashed var(--aim-baseline); border-radius: 12px; background: var(--aim-surface); margin: .5rem 0 1rem 0; }} | |
| .aim-empty .ico {{ font-size: 28px; line-height: 1; margin-bottom: 8px; }} | |
| .aim-empty .ttl {{ font-size: 15px; font-weight: 650; color: var(--aim-ink); }} | |
| .aim-empty .hint {{ font-size: 13px; color: var(--aim-muted); margin-top: 4px; max-width: 520px; margin-left: auto; margin-right: auto; }} | |
| /* ---- feed (events) ----------------------------------------------------- */ | |
| .aim-feed {{ border: 1px solid var(--aim-hairline); border-radius: 12px; background: var(--aim-surface); overflow: hidden; }} | |
| .aim-feed-row {{ display: grid; grid-template-columns: 62px 78px minmax(0, 1fr) 90px; gap: 10px; align-items: start; | |
| padding: 8px 14px; border-top: 1px solid var(--aim-hairline); font-size: 13px; color: var(--aim-ink); }} | |
| .aim-feed-row:first-child {{ border-top: 0; }} | |
| .aim-feed-row .lvl {{ padding-top: 1px; }} | |
| .aim-feed-row .node {{ color: var(--aim-muted); font-size: 12px; padding-top: 2px; font-family: var(--aim-mono); }} | |
| .aim-feed-row .msg {{ min-width: 0; overflow-wrap: anywhere; }} | |
| .aim-feed-row .msg pre {{ margin: 6px 0 0 0; font-size: 11.5px; line-height: 1.4; padding: 8px 10px; background: var(--aim-tint); | |
| border-radius: 8px; white-space: pre-wrap; color: var(--aim-ink2); }} | |
| .aim-feed-row .ts {{ color: var(--aim-muted); font-size: 12px; text-align: right; white-space: nowrap; padding-top: 2px; font-variant-numeric: tabular-nums; }} | |
| .aim-feed-row .ts .abs {{ display: block; font-size: 10.5px; color: var(--aim-baseline); }} | |
| .aim-feed-empty {{ padding: 16px; font-size: 13px; color: var(--aim-muted); text-align: center; }} | |
| /* ---- material expander label (Database page) --------------------------- */ | |
| .aim-mat {{ display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 14px; }} | |
| .aim-mat .name {{ font-weight: 700; color: var(--aim-ink); }} | |
| .aim-mat .meta {{ color: var(--aim-muted); font-size: 12.5px; }} | |
| .aim-mat .meta b {{ color: var(--aim-ink2); font-weight: 600; }} | |
| .aim-note {{ font-size: 12px; color: var(--aim-muted); margin: 6px 0 4px 0; }} | |
| .aim-inline-count {{ font-size: 13px; color: var(--aim-ink2); }} | |
| .aim-inline-count b {{ color: var(--aim-ink); }} | |
| </style> | |
| """ | |
| def inject_css() -> None: | |
| """Inject the design-system CSS once per run (call from app.py).""" | |
| st.html(_css(tokens())) | |
| # --------------------------------------------------------------------------- | |
| # altair theme | |
| # --------------------------------------------------------------------------- | |
| def _altair_config(t: dict, cats: list[str]) -> dict: | |
| axis = dict(labelColor=t["muted"], titleColor=t["ink2"], gridColor=t["grid"], | |
| domainColor=t["baseline"], tickColor=t["baseline"], labelFont=FONT, | |
| titleFont=FONT, labelFontSize=11, titleFontSize=11, titleFontWeight=600, | |
| labelPadding=6, titlePadding=8, gridDash=[1, 0], gridWidth=1, ticks=False, | |
| labelFlush=True) | |
| return { | |
| "config": { | |
| "background": t["surface"], | |
| "view": {"stroke": None, "continuousWidth": 400, "continuousHeight": 220}, | |
| "font": FONT, | |
| "padding": {"left": 4, "top": 6, "right": 8, "bottom": 4}, | |
| "title": {"font": FONT, "fontSize": 13, "fontWeight": 650, "color": t["ink"], | |
| "anchor": "start", "offset": 10, "subtitleColor": t["muted"], | |
| "subtitleFontSize": 11, "subtitleFont": FONT}, | |
| "axis": axis, | |
| "axisX": {**axis, "grid": False, "domain": True}, | |
| "axisY": {**axis, "grid": True, "domain": False}, | |
| "legend": {"labelColor": t["ink2"], "titleColor": t["ink2"], "labelFont": FONT, | |
| "titleFont": FONT, "labelFontSize": 11, "titleFontSize": 11, | |
| "symbolSize": 70, "symbolType": "circle", "orient": "top", | |
| "direction": "horizontal", "offset": 6, "padding": 0}, | |
| "range": {"category": cats, "ordinal": SEQUENTIAL, "ramp": SEQUENTIAL, | |
| "heatmap": SEQUENTIAL}, | |
| "bar": {"color": cats[0], "cornerRadiusTopLeft": 4, "cornerRadiusTopRight": 4, | |
| "binSpacing": 2, "stroke": None}, | |
| "line": {"color": cats[0], "strokeWidth": 2, "interpolate": "linear"}, | |
| "area": {"color": cats[0], "opacity": 0.18, "line": True}, | |
| "point": {"color": cats[0], "size": 46, "filled": True}, | |
| "rule": {"color": t["baseline"]}, | |
| "text": {"color": t["ink2"], "font": FONT, "fontSize": 11}, | |
| "mark": {"tooltip": True}, | |
| } | |
| } | |
| def register_altair_theme() -> None: | |
| """Register + enable the 'aim' altair theme (works with altair 4/5/6).""" | |
| try: | |
| import altair as alt | |
| except Exception: | |
| return | |
| t = tokens() | |
| cats = series() | |
| cfg = _altair_config(t, cats) | |
| def _aim_theme(): | |
| return cfg | |
| try: # altair >= 5.5 / 6 | |
| alt.theme.register("aim", enable=True)(_aim_theme) | |
| return | |
| except Exception: | |
| pass | |
| try: # altair 4 / 5.0-5.4 | |
| alt.themes.register("aim", _aim_theme) | |
| alt.themes.enable("aim") | |
| except Exception: | |
| pass | |
| def chart(c, key: Optional[str] = None, height: Optional[int] = None) -> None: | |
| """Render an altair chart with the AIM theme (not Streamlit's built-in one).""" | |
| kwargs = {"theme": None} | |
| if key: | |
| kwargs["key"] = key | |
| try: | |
| st.altair_chart(c, width="stretch", **kwargs) | |
| except TypeError: # older Streamlit: use_container_width | |
| st.altair_chart(c, use_container_width=True, **kwargs) | |
| # --------------------------------------------------------------------------- | |
| # components | |
| # --------------------------------------------------------------------------- | |
| _PILL_ICON = {"ok": "●", "running": None, "stale": "▲", "warn": "▲", "crashed": "■", | |
| "error": "■", "idle": "○", "off": "○", "info": "○", "new": "✦"} | |
| def pill_html(kind: str, text: str) -> str: | |
| kind = (kind or "idle").lower() | |
| if kind not in _PILL_ICON: | |
| kind = {"ok": "ok", "success": "ok", "warning": "warn", "critical": "crashed", | |
| "failed": "crashed", "on": "ok"}.get(kind, "idle") | |
| icon = _PILL_ICON.get(kind) | |
| lead = '<span class="dot"></span>' if icon is None else f'<span aria-hidden="true">{icon}</span>' | |
| return f'<span class="aim-pill aim-pill-{kind}">{lead}<span>{esc(text)}</span></span>' | |
| def status_pill(kind: str, text: str) -> None: | |
| st.html(pill_html(kind, text)) | |
| def chip_html(text: str, kind: str = "muted", title: str = "") -> str: | |
| kind = (kind or "muted").lower() | |
| t = f' title="{esc(title)}"' if title else "" | |
| return f'<span class="aim-chip aim-chip-{kind}"{t}>{esc(text)}</span>' | |
| def chips_html(items: Iterable[tuple], gap_label: bool = True) -> str: | |
| """items: (text, kind) or (label, text, kind) → one flex row of chips.""" | |
| parts = [] | |
| for it in items: | |
| if len(it) == 2: | |
| text, kind = it | |
| parts.append(chip_html(text, kind)) | |
| else: | |
| label, text, kind = it | |
| parts.append(f'<span class="aim-chip aim-chip-{esc(kind)}"><span class="k">{esc(label)}</span>{esc(text)}</span>') | |
| return '<div class="aim-chips">' + "".join(parts) + "</div>" | |
| def level_chip_html(level: str) -> str: | |
| lv = (level or "info").lower() | |
| kind = {"info": "info", "warn": "warn", "warning": "warn", "error": "error", | |
| "critical": "error"}.get(lv, "info") | |
| return chip_html(lv, kind) | |
| def page_header(title: str, subtitle: str = "", right: Optional[str] = None) -> None: | |
| """H1 + one-line muted subtitle + optional right-aligned pill HTML.""" | |
| right_html = f'<div class="aim-right">{right}</div>' if right else "" | |
| st.html( | |
| f'<div class="aim-ph"><div><h1>{esc(title)}</h1>' | |
| f'<div class="aim-sub">{esc(subtitle)}</div></div>{right_html}</div>') | |
| def kpi_row(items: list[dict], small: bool = False) -> None: | |
| """items: [{label, value, delta?, delta_kind?('up'|'down'|'warn'|'flat'), help?}]""" | |
| tiles = [] | |
| for it in items: | |
| label = esc(it.get("label", "")) | |
| val = it.get("value", "—") | |
| val = fmt_int(val) if isinstance(val, (int, float)) and not isinstance(val, bool) else esc(val) | |
| help_ = it.get("help") | |
| help_html = f'<span class="help" title="{esc(help_)}">?</span>' if help_ else "" | |
| delta = it.get("delta") | |
| dk = it.get("delta_kind", "flat") | |
| delta_html = f'<div class="aim-kpi-delta {esc(dk)}">{esc(delta)}</div>' if delta else "" | |
| cls = "aim-kpi-value small" if small else "aim-kpi-value" | |
| tiles.append(f'<div class="aim-kpi"><div class="aim-kpi-label"><span>{label}</span>{help_html}</div>' | |
| f'<div class="{cls}">{val}</div>{delta_html}</div>') | |
| st.html('<div class="aim-kpis">' + "".join(tiles) + "</div>") | |
| def empty_state(icon: str, title: str, hint: str = "") -> None: | |
| st.html(f'<div class="aim-empty"><div class="ico">{esc(icon)}</div>' | |
| f'<div class="ttl">{esc(title)}</div><div class="hint">{esc(hint)}</div></div>') | |
| def card_title(title: str, sub: str = "", right_html: str = "") -> None: | |
| """Small uppercase card heading (use inside st.container(border=True)).""" | |
| sub_html = f'<span class="aim-card-sub">{esc(sub)}</span>' if sub else "" | |
| st.html(f'<div class="aim-card-title"><span>{esc(title)} {sub_html}</span>{right_html}</div>') | |
| def _is_traceback(msg: str) -> bool: | |
| return ("Traceback" in msg) or ("\n" in msg.strip()) | |
| def feed_html(rows: Iterable[dict], empty: str = "No events yet.") -> str: | |
| """rows: dicts with ts, level, node, message → compact event feed.""" | |
| out = [] | |
| for r in rows: | |
| msg = str(r.get("message") or "") | |
| if _is_traceback(msg): | |
| head, _, tail = msg.partition("\n") | |
| body = esc(head) + (f"<pre>{esc(tail)}</pre>" if tail.strip() else "") | |
| else: | |
| body = esc(msg) | |
| ts = r.get("ts") | |
| out.append( | |
| f'<div class="aim-feed-row"><div class="lvl">{level_chip_html(r.get("level"))}</div>' | |
| f'<div class="node">{esc(r.get("node") or "")}</div>' | |
| f'<div class="msg">{body}</div>' | |
| f'<div class="ts" title="{esc(fmt_ts(ts, "%Y-%m-%d %H:%M:%S UTC"))}">{esc(rel_time(ts))}' | |
| f'<span class="abs">{esc(fmt_ts(ts, "%b %d, %H:%M"))}</span></div></div>') | |
| if not out: | |
| return f'<div class="aim-feed"><div class="aim-feed-empty">{esc(empty)}</div></div>' | |
| return '<div class="aim-feed">' + "".join(out) + "</div>" | |
| def feed(rows: Iterable[dict], empty: str = "No events yet.") -> None: | |
| st.html(feed_html(rows, empty)) | |
| def run_status_kind(status) -> str: | |
| s = str(status or "").lower() | |
| return {"ok": "ok", "running": "running", "stale": "stale", "crashed": "crashed", | |
| "error": "error", "timeout": "crashed"}.get(s, "idle") | |
| def status_cell_style(status) -> str: | |
| """pandas Styler CSS for a run/level status cell (word + color, never color alone).""" | |
| s = str(status or "").lower() | |
| if s in ("ok", "ingested", "info", "good"): | |
| return "background-color: rgba(12,163,12,.14); color: #0a6a0a; font-weight: 600;" | |
| if s in ("running",): | |
| return "background-color: rgba(42,120,214,.14); color: #1b5cab; font-weight: 600;" | |
| if s in ("stale", "warn", "warning") or s.startswith("skipped"): | |
| return "background-color: rgba(250,178,25,.22); color: #7a5200; font-weight: 600;" | |
| if s in ("crashed", "error", "failed", "critical", "timeout"): | |
| return "background-color: rgba(208,59,59,.14); color: #9c1f1f; font-weight: 600;" | |
| if s.startswith("flag"): | |
| return "background-color: rgba(250,178,25,.22); color: #7a5200; font-weight: 600;" | |
| return "" | |
| def brand_row(db_name: str, extra: str = "") -> None: | |
| st.html( | |
| '<div class="aim-brand"><div class="aim-wordmark">AIM for Composites ' | |
| '<span>· Autonomous Ingestion Agent</span></div>' | |
| f'<div class="aim-env">database: <code>{esc(db_name or "?")}</code> · read-only for visitors{esc(extra)}</div></div>') | |
| def footer() -> None: | |
| st.html( | |
| '<div class="aim-footer">' | |
| '<span>AIM for Composites · Autonomous Ingestion Agent</span>' | |
| '<a href="https://huggingface.co/spaces/aim4composites/MaterialsDatabase" target="_blank" rel="noopener">MaterialsDatabase Space</a>' | |
| '<a href="https://github.com/AIM-for-Composites/InDesDatabaseTool" target="_blank" rel="noopener">GitHub · AIM-for-Composites/InDesDatabaseTool</a>' | |
| '<span>Postgres · Streamlit · LangGraph · Gemini</span>' | |
| '</div>') | |
| def now_utc() -> datetime: | |
| return datetime.now(timezone.utc) | |