From 340815f00fba0d409a88119969f72d7aeb84d60a Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Sat, 5 Sep 2026 12:34:08 +0100 Subject: [PATCH 1/2] Improve dashboard filtering, controls, and local demo data --- LOCAL_DEVELOPMENT.md | 22 +++ dashboard/batch_stats.py | 14 +- dashboard/components/batch.py | 2 +- dashboard/components/common.py | 3 +- dashboard/components/search.py | 17 +- dashboard/components/toolbar.py | 5 + dashboard/metric_results.py | 78 +++++++++ dashboard/routes/batch.py | 29 +--- dashboard/routes/index.py | 2 + dashboard/routes/search.py | 209 +++---------------------- dashboard/static/styles.css | 6 + scripts/development/seed_stack_data.py | 116 ++++++++++++++ tests/test_dashboard.py | 44 ++++++ 13 files changed, 320 insertions(+), 227 deletions(-) create mode 100644 dashboard/metric_results.py create mode 100644 scripts/development/seed_stack_data.py diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md index b909c843..3012e955 100644 --- a/LOCAL_DEVELOPMENT.md +++ b/LOCAL_DEVELOPMENT.md @@ -21,6 +21,28 @@ Dashboard code is loaded from the working tree. Restart `make stack` after chang For the existing environment and full metric configuration, the older `make local` and dashboard targets remain available. +## More dashboard data + +Stop `make stack`, then seed additional isolated fixtures and restart: + +```sh +.venv/bin/python scripts/development/seed_stack_data.py --public +make stack +``` + +This adds `demo_netdata` (51 metrics) and `demo_currency` (85 metrics), each +with seven days of hourly synthetic values, simulated scores, and alerts. +The `--public` option also adds `public_earthquake`: seven days of hourly, +rolling 24-hour earthquake counts and magnitudes reconstructed from the USGS +monthly event feed. Those are real observations with no fabricated anomaly +scores. Omit `--public` for an entirely offline seed. + +Rerunning replaces only these fixture tables. The original example and Dagster +history are preserved. Fixture configs and the database stay under ignored +`tmpdata/local-stack/`; they are dashboard fixtures, not scheduled Dagster jobs. +Public data requires an internet connection but no API key. Source information +is stored in each row's metadata. + ## Validation and dependency updates ```sh diff --git a/dashboard/batch_stats.py b/dashboard/batch_stats.py index 8ad7cc43..c4cc0b10 100644 --- a/dashboard/batch_stats.py +++ b/dashboard/batch_stats.py @@ -68,11 +68,9 @@ def format_time_ago(timestamp: str) -> str: now = datetime.now(timezone.utc) delta = now - dt - if delta.days > 0: - return f"{delta.days} days ago" - elif delta.seconds > 3600: - return f"{delta.seconds // 3600} hours ago" - elif delta.seconds > 60: - return f"{delta.seconds // 60} minutes ago" - else: - return "just now" + seconds = max(0, int(delta.total_seconds())) + for unit, duration in (("day", 86400), ("hour", 3600), ("minute", 60)): + if seconds >= duration: + count = seconds // duration + return f"{count} {unit}{'s' if count != 1 else ''} ago" + return "just now" diff --git a/dashboard/components/batch.py b/dashboard/components/batch.py index dc6f9ca3..33f3d9ee 100644 --- a/dashboard/components/batch.py +++ b/dashboard/components/batch.py @@ -68,7 +68,7 @@ def create_batch_card(batch_name: str, stats: dict) -> Card: UkIcon("bar-chart", cls="text-purple-500"), f"Avg Score: {stats['avg_score']:.1%}", ), - (UkIcon("alert-circle", cls="text-red-500"), f"{stats['alert_count']} alerts"), + (UkIcon("alert-circle", cls="text-red-500"), f"{int(stats['alert_count'])} alerts"), ] metric_divs = [ diff --git a/dashboard/components/common.py b/dashboard/components/common.py index 0bf4998f..137c8ad4 100644 --- a/dashboard/components/common.py +++ b/dashboard/components/common.py @@ -7,7 +7,7 @@ """ -from fasthtml.common import Div +from fasthtml.common import H4, Div from monsterui.all import Card, DivFullySpaced from .search import create_last_n_form, create_search_form @@ -24,6 +24,7 @@ def create_controls(batch_name: str) -> Card: Card: The card. """ return Card( + H4(batch_name, cls="text-lg font-semibold mb-2 break-all"), DivFullySpaced( Div( Div( diff --git a/dashboard/components/search.py b/dashboard/components/search.py index 660e95ad..4bcb61a8 100644 --- a/dashboard/components/search.py +++ b/dashboard/components/search.py @@ -7,7 +7,7 @@ """ -# from fasthtml.common import Input +from fasthtml.common import Div, Label, P from monsterui.all import DivLAligned, Form, Input from dashboard.app import app @@ -25,13 +25,15 @@ def create_search_form(batch_name: str) -> Form: current_search = app.state.search_term.get(batch_name, "") return Form( + Label("Search metrics", fr="metric-search", cls="text-sm font-medium"), Input( + id="metric-search", type="search", name="search", - placeholder="Search metrics...", + placeholder="Name or regular expression…", value=current_search, cls="uk-input uk-form-small rounded-md border-gray-200 w-full md:w-[220px]", - uk_tooltip="Filter metrics by name", + uk_tooltip="Filter metric names with a case-insensitive regular expression", autocomplete="off", aria_label="Search metrics", hx_get=f"/batch/{batch_name}/search", @@ -60,8 +62,11 @@ def create_last_n_form(batch_name: str) -> Form: current_last_n = app.state.last_n.get(batch_name, "90n") return Form( + Label("Time window", fr="time-window", cls="text-sm font-medium"), DivLAligned( Input( + id="time-window", + aria_describedby="time-window-help", type="text", name="last_n", value=current_last_n, @@ -78,6 +83,12 @@ def create_last_n_form(batch_name: str) -> Form: ), cls="space-x-2", ), + P( + "90n = 90 points · 24h = 24 hours · 7d = 7 days", + id="time-window-help", + cls="text-xs text-muted-foreground mt-1", + ), + Div(id="window-error"), id=f"last-n-form-{batch_name}", onsubmit="return false;", ) diff --git a/dashboard/components/toolbar.py b/dashboard/components/toolbar.py index f30c1087..23923088 100644 --- a/dashboard/components/toolbar.py +++ b/dashboard/components/toolbar.py @@ -31,17 +31,20 @@ def create_toolbar_buttons(batch_name: str) -> Div: hx_target="#main-content", cls=ButtonT.secondary, uk_tooltip="Return to homepage", + aria_label="Home", ), Button( DivLAligned(UkIcon("menu")), cls=ButtonT.secondary, uk_tooltip="Select metric batch to display", + aria_label="Metric batches", ), create_batches_dropdown(batch_name), Button( DivLAligned(UkIcon("settings")), cls=ButtonT.secondary, uk_tooltip="Customize chart display settings", + aria_label="Chart settings", ), create_settings_dropdown(batch_name), Button( @@ -50,6 +53,7 @@ def create_toolbar_buttons(batch_name: str) -> Div: hx_target="#main-content", cls=ButtonT.secondary, uk_tooltip="Refresh metrics data from source", + aria_label="Refresh metrics", ), Button( DivLAligned(UkIcon("alert-circle")), @@ -58,6 +62,7 @@ def create_toolbar_buttons(batch_name: str) -> Div: hx_target="#main-content", cls=ButtonT.secondary, uk_tooltip="View anomaly list", + aria_label="View anomalies", ), cls="flex items-center space-x-2 flex-wrap", ) diff --git a/dashboard/metric_results.py b/dashboard/metric_results.py new file mode 100644 index 00000000..9cd37298 --- /dev/null +++ b/dashboard/metric_results.py @@ -0,0 +1,78 @@ +"""Consistent filtering and pagination for every chart-list refresh.""" + +import re + +from fasthtml.common import Div, P +from monsterui.all import Button, ButtonT + +from dashboard.charts import ChartManager +from dashboard.constants import DEFAULT_LOAD_N_CHARTS + + +def filter_metrics(stats, search): + """Retain source indices so lazy chart requests resolve the correct metric.""" + pattern = re.compile(search, re.IGNORECASE) if search else None + return [ + (i, stat) + for i, stat in enumerate(stats) + if not pattern or pattern.search(stat["metric_name"]) + ] + + +def metric_results(state, batch_name, start=0, limit=DEFAULT_LOAD_N_CHARTS, append=False, oob=True): + """Render a filtered page and its matching pagination control.""" + message = None + try: + matches = filter_metrics( + state.stats_cache[batch_name], state.search_term.get(batch_name, "") + ) + except re.error: + matches = [] + message = "Invalid search pattern. Check your regular expression or clear the search." + start = max(0, start) + page = matches[start : start + limit] + remaining = max(0, len(matches) - (start + limit)) + charts = [ + ChartManager.create_chart_placeholder(stat["metric_name"], i, batch_name) + for i, stat in page + ] + if not matches: + charts = [ + P( + message or "No matching metrics. Clear the search or try a wider time window.", + role="status", + cls="text-muted-foreground p-4 text-center col-span-full", + ) + ] + pagination = Div( + Button( + f"Load next {min(limit, remaining)} of {remaining}", + hx_get=f"/batch/{batch_name}/load-more/{start + limit}", + hx_target="#charts-container", + hx_swap="beforeend", + hx_indicator="#loading", + hx_disabled_elt="this", + cls=ButtonT.secondary, + style="width: 100%; margin-top: 1rem;", + ) + if remaining + else P( + f"{len(matches)} matching metrics" + if state.search_term.get(batch_name) + else f"{len(matches)} metrics", + cls="text-sm text-muted-foreground text-center p-2", + role="status", + ), + id="load-more-container", + hx_swap_oob="true" if oob else None, + ) + if append: + return [*charts, pagination] + return [ + Div( + *charts, + id="charts-container", + cls=f"grid grid-cols-{2 if state.two_columns else 1} gap-4", + ), + pagination, + ] diff --git a/dashboard/routes/batch.py b/dashboard/routes/batch.py index 9689b9f2..b210abb6 100644 --- a/dashboard/routes/batch.py +++ b/dashboard/routes/batch.py @@ -17,6 +17,7 @@ from dashboard.components import create_controls from dashboard.constants import DEFAULT_LAST_N, DEFAULT_LOAD_N_CHARTS from dashboard.data import get_data +from dashboard.metric_results import metric_results def get_batch_data(batch_name: str) -> pd.DataFrame: @@ -54,9 +55,6 @@ def get_batch_view(batch_name: str, initial_load: int = DEFAULT_LOAD_N_CHARTS): app.state.df_cache[batch_name] = get_batch_data(batch_name) app.state.calculate_metric_stats(batch_name) - metric_stats = app.state.stats_cache[batch_name] - remaining_metrics = len(metric_stats) - initial_load - script = Script( f""" document.querySelectorAll('.top-nav li').forEach(li => {{ @@ -85,32 +83,9 @@ def get_batch_view(batch_name: str, initial_load: int = DEFAULT_LOAD_N_CHARTS): """ ) - load_next = min(DEFAULT_LOAD_N_CHARTS, remaining_metrics) return Div( create_controls(batch_name), - Div( - *[ - ChartManager.create_chart_placeholder(stat["metric_name"], i, batch_name) - for i, stat in enumerate(metric_stats[:initial_load]) - ], - id="charts-container", - cls=f"grid grid-cols-{2 if app.state.two_columns else 1} gap-4", - ), - Div( - Button( - f"Load next {load_next} of {remaining_metrics}" - if remaining_metrics > 0 - else "No more metrics", - hx_get=f"/batch/{batch_name}/load-more/{initial_load}", - hx_target="#charts-container", - hx_swap="beforeend", - hx_indicator="#loading", - cls=ButtonT.secondary, - style="width: 100%; margin-top: 1rem;", - disabled=remaining_metrics <= 0, - ), - id="load-more-container", - ), + *metric_results(app.state, batch_name, limit=initial_load, oob=False), script, ) diff --git a/dashboard/routes/index.py b/dashboard/routes/index.py index de5c0cfb..72709673 100644 --- a/dashboard/routes/index.py +++ b/dashboard/routes/index.py @@ -104,12 +104,14 @@ def create_main_content(batch_stats: dict, sorted_batch_names: list) -> Div: hx_target="#main-content", hx_indicator="#loading", uk_tooltip="Refresh all", + aria_label="Refresh all metrics", ), A( UkIcon("github"), href="https://github.com/andrewm4894/anomstack", cls=ButtonT.ghost, uk_tooltip="View the source code on GitHub", + aria_label="View source on GitHub", target="_blank", ), cls="space-x-2", diff --git a/dashboard/routes/search.py b/dashboard/routes/search.py index 279c2b9b..cc903a9f 100644 --- a/dashboard/routes/search.py +++ b/dashboard/routes/search.py @@ -1,208 +1,43 @@ -""" -dashboard/routes/search.py +"""Search, pagination and time-window updates for metric charts.""" -Routes for search and load more functionality. - -This module contains the routes for the search and load more functionality. - -""" - -from fasthtml.common import FT, Div, P -from monsterui.all import Button, ButtonT +from fasthtml.common import Div, P from dashboard.app import app, rt -from dashboard.data import get_data - -from .batch import DEFAULT_LOAD_N_CHARTS, ChartManager +from dashboard.data import get_data, parse_time_spec +from dashboard.metric_results import metric_results @rt("/batch/{batch_name}/search") -def get(batch_name: str, search: str = "") -> FT: - """Search metrics. - - Args: - batch_name (str): The name of the batch. - search (str): The search term. - - Returns: - FT: The search results container with out-of-band load more button. - """ - import re - +def search_metrics(batch_name: str, search: str = ""): app.state.search_term[batch_name] = search - if batch_name not in app.state.stats_cache: app.state.calculate_metric_stats(batch_name) - - try: - pattern = re.compile(search, re.IGNORECASE) if search else None - filtered_stats_with_indices = [ - (i, stat) - for i, stat in enumerate(app.state.stats_cache[batch_name]) - if not pattern or pattern.search(stat["metric_name"]) - ] - - if not filtered_stats_with_indices: - return Div( - P("No matching metrics found", cls="text-muted-foreground p-4 text-center"), - # Add out-of-band load more container - Div( - id="load-more-container", - hx_swap_oob="true", - ), - id="charts-container", - cls=f"grid grid-cols-{2 if app.state.two_columns else 1} gap-4", - ) - - remaining_metrics = len(filtered_stats_with_indices) - DEFAULT_LOAD_N_CHARTS - load_next = min(DEFAULT_LOAD_N_CHARTS, remaining_metrics) - - # Create chart placeholders - chart_placeholders = [ - ChartManager.create_chart_placeholder(stat["metric_name"], original_index, batch_name) - for original_index, stat in filtered_stats_with_indices[:DEFAULT_LOAD_N_CHARTS] - ] - - # Create load more button if needed - load_more_button = Div( - Button( - f"Load next {load_next} of {remaining_metrics}" - if remaining_metrics > 0 - else "No more metrics", - hx_get=f"/batch/{batch_name}/load-more/{DEFAULT_LOAD_N_CHARTS}", - hx_target="#charts-container", - hx_swap="beforeend", - hx_indicator="#loading", - cls=ButtonT.secondary, - style="width: 100%; margin-top: 1rem;", - disabled=remaining_metrics <= 0, - ) - if remaining_metrics > 0 - else "", - id="load-more-container", - hx_swap_oob="true", - ) - - return Div( - *chart_placeholders, - load_more_button, - id="charts-container", - cls=f"grid grid-cols-{2 if app.state.two_columns else 1} gap-4", - ) - - except re.error: - return Div( - P("Invalid search pattern", cls="text-red-500 p-4 text-center"), - # Add out-of-band load more container - Div( - id="load-more-container", - hx_swap_oob="true", - ), - id="charts-container", - cls=f"grid grid-cols-{2 if app.state.two_columns else 1} gap-4", - ) + return metric_results(app.state, batch_name) @rt("/batch/{batch_name}/load-more/{start_index}") -def get(batch_name: str, start_index: int): - """Load more charts. - - Args: - batch_name (str): The name of the batch. - start_index (int): The index of the first chart to load. - - Returns: - list: The list of charts. - """ - metric_stats = app.state.stats_cache[batch_name] - remaining_metrics = len(metric_stats) - (start_index + 10) - load_next = min(10, remaining_metrics) - - return [ - *[ - ChartManager.create_chart_placeholder(stat["metric_name"], i, batch_name) - for i, stat in enumerate( - metric_stats[start_index : start_index + 10], - start=start_index, - ) - ], - Div( - Button( - ( - f"Load next {load_next} of {remaining_metrics}" - if remaining_metrics > 0 - else "No more metrics" - ), - hx_get=f"/batch/{batch_name}/load-more/{start_index + 10}", - hx_target="#charts-container", - hx_swap="beforeend", - hx_indicator="#loading", - cls=ButtonT.secondary, - style="width: 100%; margin-top: 1rem;", - disabled=remaining_metrics <= 0, - ), - id="load-more-container", - hx_swap_oob="true", - ), - ] +def load_more(batch_name: str, start_index: int): + return metric_results(app.state, batch_name, start=start_index, append=True) @rt("/batch/{batch_name}/update-n") -def post(batch_name: str, last_n: str = "90n"): - """Update time window.""" +def update_window(batch_name: str, last_n: str = "90n"): try: - # Update the last_n value - app.state.last_n[batch_name] = last_n - - # Clear all caches for this batch - if batch_name in app.state.df_cache: - del app.state.df_cache[batch_name] - if batch_name in app.state.stats_cache: - del app.state.stats_cache[batch_name] - if batch_name in app.state.chart_cache: - del app.state.chart_cache[batch_name] - - # Get fresh data with new time window - app.state.df_cache[batch_name] = get_data( - app.state.specs_enabled[batch_name], last_n=last_n, ensure_timestamp=True - ) - - # Recalculate stats with new data - app.state.calculate_metric_stats(batch_name) - - # Return only the charts container content - metric_stats = app.state.stats_cache[batch_name] - remaining_metrics = len(metric_stats) - DEFAULT_LOAD_N_CHARTS - load_next = min(DEFAULT_LOAD_N_CHARTS, remaining_metrics) - + # Validate and fetch before changing the selected window or clearing caches. + parse_time_spec(last_n) + df = get_data(app.state.specs_enabled[batch_name], last_n=last_n, ensure_timestamp=True) + except ValueError as exc: return [ + *metric_results(app.state, batch_name), Div( - *[ - ChartManager.create_chart_placeholder(stat["metric_name"], i, batch_name) - for i, stat in enumerate(metric_stats[:DEFAULT_LOAD_N_CHARTS]) - ], - id="charts-container", - cls=f"grid grid-cols-{2 if app.state.two_columns else 1} gap-4", - ), - Div( - Button( - f"Load next {load_next} of {remaining_metrics}" - if remaining_metrics > 0 - else "No more metrics", - hx_get=f"/batch/{batch_name}/load-more/{DEFAULT_LOAD_N_CHARTS}", - hx_target="#charts-container", - hx_swap="beforeend", - hx_indicator="#loading", - cls=ButtonT.secondary, - style="width: 100%; margin-top: 1rem;", - disabled=remaining_metrics <= 0, - ), - id="load-more-container", + P(str(exc), role="alert", cls="text-red-500 p-4"), + id="window-error", hx_swap_oob="true", ), ] - except ValueError as e: - return Div( - P(str(e), cls="text-red-500 p-4 text-center"), - id="charts-container", - ) + + app.state.last_n[batch_name] = last_n + app.state.clear_batch_cache(batch_name) + app.state.df_cache[batch_name] = df + app.state.calculate_metric_stats(batch_name) + return [*metric_results(app.state, batch_name), Div(id="window-error", hx_swap_oob="true")] diff --git a/dashboard/static/styles.css b/dashboard/static/styles.css index feef814d..37104f31 100644 --- a/dashboard/static/styles.css +++ b/dashboard/static/styles.css @@ -360,3 +360,9 @@ body.dark-mode .uk-toggle-switch::before { align-items: center; justify-content: center; } + +/* Keep chart columns readable on phones, including after settings changes. */ +@media (max-width: 640px) { + #charts-container { grid-template-columns: minmax(0, 1fr); } + .homepage-grid { grid-template-columns: minmax(0, 1fr); } +} diff --git a/scripts/development/seed_stack_data.py b/scripts/development/seed_stack_data.py new file mode 100644 index 00000000..0834040e --- /dev/null +++ b/scripts/development/seed_stack_data.py @@ -0,0 +1,116 @@ +"""Seed isolated demo batches; optionally backfill public USGS observations.""" + +import argparse +from datetime import datetime, timedelta, timezone +import json +from pathlib import Path + +import duckdb +import pandas as pd +import requests +from seed_local_db import ( + generate_alerts_data, + generate_scores_data, + load_ingest_function, + wrangle_df, +) +import yaml + +ROOT = Path(__file__).resolve().parents[2] +LOCAL = ROOT / "tmpdata/local-stack" + + +def save_batch(connection, name, frame, source): + """Replace only this dedicated local fixture table.""" + frame = frame.copy() + frame["metric_batch"] = name + frame["metadata"] = json.dumps({"source": source}) + table = f"metrics_{name}" + connection.register("seed_frame", frame) + connection.execute(f'CREATE OR REPLACE TABLE "{table}" AS SELECT * FROM seed_frame') + config = { + "metric_batch": name, + "table_key": table, + "alert_methods": "", + "tholdalert_methods": "", + "disable_llmalert": True, + "disable_ingest": True, + "dashboard_stale_metric_max_days_ago": 90, + } + folder = LOCAL / "metrics/fixtures" + folder.mkdir(parents=True, exist_ok=True) + (folder / f"{name}.yaml").write_text(yaml.safe_dump(config)) + print(f"{name}: {frame.metric_name.nunique()} metrics, {len(frame):,} rows ({source})") + + +def seed_synthetic(connection): + for source in ("netdata", "currency"): + name = f"demo_{source}" + ingest = load_ingest_function(source) + frames = [] + now = datetime.now(timezone.utc).replace(tzinfo=None) + for hour in range(7 * 24): + frame = ingest() + frame["metric_timestamp"] = now - timedelta(hours=hour) + frames.append(frame) + metrics = wrangle_df(pd.concat(frames, ignore_index=True), name) + scores = generate_scores_data(metrics) + save_batch( + connection, + name, + pd.concat([metrics, scores, generate_alerts_data(scores)], ignore_index=True), + "Synthetic demo data; scores and alerts are simulated", + ) + + +def seed_public(connection): + # A month of real events supports seven days of rolling 24-hour aggregates. + url = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_month.geojson" + response = requests.get(url, timeout=30) + response.raise_for_status() + payload = response.json() + events = pd.DataFrame( + [ + {"time": feature["properties"]["time"], "mag": feature["properties"]["mag"]} + for feature in payload["features"] + if feature["properties"].get("mag") is not None + ] + ) + events["time"] = pd.to_datetime(events["time"], unit="ms", utc=True) + end = pd.to_datetime(payload["metadata"]["generated"], unit="ms", utc=True).floor("h") + rows = [] + for timestamp in pd.date_range(end=end, periods=7 * 24, freq="h"): + magnitudes = events.loc[ + (events.time > timestamp - pd.Timedelta(days=1)) & (events.time <= timestamp), "mag" + ] + for metric, value in { + "earthquake_count_last_day": len(magnitudes), + "earthquake_max_magnitude_last_day": magnitudes.max() if len(magnitudes) else 0, + "earthquake_avg_magnitude_last_day": magnitudes.mean() if len(magnitudes) else 0, + }.items(): + rows.append( + { + "metric_timestamp": timestamp.tz_localize(None), + "metric_name": metric, + "metric_value": value, + } + ) + frame = wrangle_df(pd.DataFrame(rows), "public_earthquake") + save_batch(connection, "public_earthquake", frame, url) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--public", action="store_true", help="Fetch real USGS history (requires internet)" + ) + args = parser.parse_args() + LOCAL.mkdir(parents=True, exist_ok=True) + with duckdb.connect(str(LOCAL / "metrics.db")) as connection: + seed_synthetic(connection) + if args.public: + seed_public(connection) + + +if __name__ == "__main__": + main() diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 50037c2f..33dda9e9 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -85,3 +85,47 @@ def test_app_state_cache_management_and_stats(): assert batch not in state.df_cache assert batch not in state.chart_cache assert batch not in state.stats_cache + + +def test_time_ago_singular_and_future(): + now = datetime.now(timezone.utc) + assert format_time_ago(now - timedelta(hours=1)) == "1 hour ago" + assert format_time_ago(now - timedelta(minutes=1)) == "1 minute ago" + assert format_time_ago(now + timedelta(minutes=1)) == "just now" + + +def test_filtered_pagination_retains_chart_indices(): + from fasthtml.common import to_xml + + from dashboard.metric_results import metric_results + + state = AppState() + state.stats_cache["demo"] = [ + {"metric_name": f"{'london' if i % 2 else 'paris'}_{i}"} for i in range(50) + ] + state.search_term["demo"] = "LONDON" + html = to_xml(metric_results(state, "demo", start=10, append=True)) + assert "london_21" in html + assert "london_39" in html + assert "/chart/21" in html + assert "paris_" not in html + assert "london_1<" not in html + assert "Load next 5 of 5" in html + final = to_xml(metric_results(state, "demo", start=20, append=True)) + assert "25 matching metrics" in final + assert "load-more/30" not in final + + +def test_search_empty_and_invalid_patterns(): + from fasthtml.common import to_xml + + from dashboard.metric_results import metric_results + + state = AppState() + state.stats_cache["demo"] = [{"metric_name": "cpu"}] + state.search_term["demo"] = "missing" + assert "No matching metrics" in to_xml(metric_results(state, "demo")) + state.search_term["demo"] = "[" + assert "Invalid search pattern" in to_xml(metric_results(state, "demo")) + state.search_term["demo"] = "" + assert "/chart/0" in to_xml(metric_results(state, "demo")) From 8fbe23a45e44e63b6839266baf638a03d671cf8a Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Sat, 5 Sep 2026 12:34:57 +0100 Subject: [PATCH 2/2] Clarify empty anomaly lists and align filter labels --- dashboard/components/search.py | 4 ++-- dashboard/routes/batch.py | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/dashboard/components/search.py b/dashboard/components/search.py index 4bcb61a8..1fa0beff 100644 --- a/dashboard/components/search.py +++ b/dashboard/components/search.py @@ -25,7 +25,7 @@ def create_search_form(batch_name: str) -> Form: current_search = app.state.search_term.get(batch_name, "") return Form( - Label("Search metrics", fr="metric-search", cls="text-sm font-medium"), + Label("Search metrics", fr="metric-search", cls="block text-sm font-medium mb-1"), Input( id="metric-search", type="search", @@ -62,7 +62,7 @@ def create_last_n_form(batch_name: str) -> Form: current_last_n = app.state.last_n.get(batch_name, "90n") return Form( - Label("Time window", fr="time-window", cls="text-sm font-medium"), + Label("Time window", fr="time-window", cls="block text-sm font-medium mb-1"), DivLAligned( Input( id="time-window", diff --git a/dashboard/routes/batch.py b/dashboard/routes/batch.py index b210abb6..1452e9e5 100644 --- a/dashboard/routes/batch.py +++ b/dashboard/routes/batch.py @@ -577,6 +577,22 @@ def get_anomaly_list(batch_name: str, page: int = 1, per_page: int = 50): cls="w-full divide-y min-w-full table-fixed", ), cls="overflow-x-auto -mx-4 sm:mx-0", + ) + if total_anomalies + else Div( + P( + "No anomaly records in this time window.", + role="status", + cls="text-muted-foreground mb-4", + ), + Button( + "Return to metrics", + hx_get=f"/batch/{batch_name}", + hx_push_url=f"/batch/{batch_name}", + hx_target="#main-content", + cls=ButtonT.secondary, + ), + cls="p-4 text-center", ), header=Div( H4("Anomalies", cls="mb-1"), @@ -587,7 +603,7 @@ def get_anomaly_list(batch_name: str, page: int = 1, per_page: int = 50): ), cls="mb-4", ), - pagination, + pagination if total_anomalies else None, # Add all the modals *modals, id="anomaly-list",