Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions LOCAL_DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 6 additions & 8 deletions dashboard/batch_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion dashboard/components/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
3 changes: 2 additions & 1 deletion dashboard/components/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
17 changes: 14 additions & 3 deletions dashboard/components/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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="block text-sm font-medium mb-1"),
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",
Expand Down Expand Up @@ -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="block text-sm font-medium mb-1"),
DivLAligned(
Input(
id="time-window",
aria_describedby="time-window-help",
type="text",
name="last_n",
value=current_last_n,
Expand All @@ -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;",
)
5 changes: 5 additions & 0 deletions dashboard/components/toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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")),
Expand All @@ -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",
)
78 changes: 78 additions & 0 deletions dashboard/metric_results.py
Original file line number Diff line number Diff line change
@@ -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,
]
47 changes: 19 additions & 28 deletions dashboard/routes/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 => {{
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -602,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"),
Expand All @@ -612,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",
Expand Down
2 changes: 2 additions & 0 deletions dashboard/routes/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading