diff --git a/README.md b/README.md index b8c02d9..398b5e1 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,11 @@ library) over an item's history: ![Server-rendered item history chart](docs/assets/item-history-chart.png) +**Long-range trends** — an average line with a min/max band over hourly rollups, so +long-term shape survives raw-sample retention: + +![Long-range trend chart with min/max band](docs/assets/longrange-chart.png) + **Hosts overview** — every host's health at a glance (item count, ongoing problems and worst severity), problem hosts first: diff --git a/app/api/routes/web/charts.py b/app/api/routes/web/charts.py index 62e7f3d..7bc848b 100644 --- a/app/api/routes/web/charts.py +++ b/app/api/routes/web/charts.py @@ -101,3 +101,79 @@ def _y(v: float) -> float: ] view.empty = False return view + + +@dataclass(slots=True) +class BandChartView: + """An average line with a min/max band — for long-range trend rollups.""" + + width: int + height: int + empty: bool = True + line: str = "" # avg polyline + band: str = "" # polygon: max edge L→R then min edge R→L + y_ticks: list[tuple[float, str]] = field(default_factory=list) + x_ticks: list[tuple[float, str]] = field(default_factory=list) + plot_left: float = 0.0 + plot_right: float = 0.0 + plot_top: float = 0.0 + plot_bottom: float = 0.0 + + +def band_chart( + series: list[tuple[datetime, float, float, float]], + *, + width: int = 720, + height: int = 200, + pad_left: int = 48, + pad_right: int = 12, + pad_top: int = 12, + pad_bottom: int = 24, +) -> BandChartView: + """Build a min/avg/max band chart from chronological (bucket, min, avg, max).""" + view = BandChartView(width=width, height=height) + if len(series) < 2: + return view + + left, right = float(pad_left), float(width - pad_right) + top, bottom = float(pad_top), float(height - pad_bottom) + view.plot_left, view.plot_right, view.plot_top, view.plot_bottom = left, right, top, bottom + + times = [t for t, _, _, _ in series] + mins = [mn for _, mn, _, _ in series] + avgs = [av for _, _, av, _ in series] + maxs = [mx for _, _, _, mx in series] + t0, t1 = times[0], times[-1] + span = (t1 - t0).total_seconds() + lo, hi = min(mins), max(maxs) + if hi == lo: + lo, hi = lo - 1.0, hi + 1.0 + vrange = hi - lo + last = len(series) - 1 + + def _x(i: int, t: datetime) -> float: + frac = ((t - t0).total_seconds() / span) if span > 0 else (i / last) + return left + (right - left) * frac + + def _y(v: float) -> float: + return top + (bottom - top) * (1 - (v - lo) / vrange) + + xs = [_x(i, t) for i, t in enumerate(times)] + view.line = " ".join(f"{xs[i]:.1f},{_y(avgs[i]):.1f}" for i in range(len(series))) + top_edge = [f"{xs[i]:.1f},{_y(maxs[i]):.1f}" for i in range(len(series))] + bottom_edge = [f"{xs[i]:.1f},{_y(mins[i]):.1f}" for i in reversed(range(len(series)))] + view.band = " ".join(top_edge + bottom_edge) + + view.y_ticks = [ + (_y(lo), _fmt_value(lo)), + (_y((lo + hi) / 2), _fmt_value((lo + hi) / 2)), + (_y(hi), _fmt_value(hi)), + ] + mid = series[len(series) // 2] + view.x_ticks = [ + (left, _fmt_time(t0, span)), + (_x(len(series) // 2, mid[0]), _fmt_time(mid[0], span)), + (right, _fmt_time(t1, span)), + ] + view.empty = False + return view diff --git a/app/api/routes/web/hosts.py b/app/api/routes/web/hosts.py index 1d43f52..c5fbb80 100644 --- a/app/api/routes/web/hosts.py +++ b/app/api/routes/web/hosts.py @@ -8,7 +8,7 @@ from app.api.deps.db import DBSession from app.api.routes.web._shared import login_redirect, resolve_current_user, templates -from app.api.routes.web.charts import ChartView, line_chart +from app.api.routes.web.charts import BandChartView, ChartView, band_chart, line_chart from app.core.models.host import ItemSource, ItemValueType from app.core.models.trigger import Severity, TriggerAggregation, TriggerOperator, TriggerState from app.core.models.user import User @@ -353,6 +353,12 @@ async def _item_detail_context( recent = list(await item_svc.list_values(item_id, limit=300)) # newest first series = [(v.collected_at, v.value_num) for v in reversed(recent) if v.value_num is not None] chart = line_chart(series) if item.value_type.is_numeric else ChartView(width=720, height=240) + + trends = list(await item_svc.list_trends(item_id, limit=720)) # newest first (~30 days) + trend_series = [(t.bucket, t.value_min, t.value_avg, t.value_max) for t in reversed(trends)] + trend_chart = ( + band_chart(trend_series) if item.value_type.is_numeric else BandChartView(720, 200) + ) return { "current_user": user, "active_nav": "hosts", @@ -360,8 +366,9 @@ async def _item_detail_context( "item": item, "triggers": list(await TriggerService(session).list_for_item(item_id)), "values": recent[:25], - "trends": list(await item_svc.list_trends(item_id, limit=24)), + "trends": trends[:24], "chart": chart, + "trend_chart": trend_chart, "trigger_operators": [{"value": o.value, "label": s} for o, s in _OPERATOR_SYMBOLS.items()], "trigger_aggregations": [a.value for a in TriggerAggregation], "severities": [s.value for s in Severity], diff --git a/docs/assets/longrange-chart.png b/docs/assets/longrange-chart.png new file mode 100644 index 0000000..13cbcfe Binary files /dev/null and b/docs/assets/longrange-chart.png differ diff --git a/docs/roadmap.md b/docs/roadmap.md index 98ad7c1..33511ff 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -122,7 +122,9 @@ GhostMonitor's reason to exist over a plain Zabbix clone is privacy (ghost-suite making it POST a structured remediation intent (command + problem context) to a **webhook** channel for an external runbook — the server never runs commands itself (webhook-only, validated on create and at fire time). -- *(next)* long-range charts backed by trends. +- ✅ **Long-range trend chart**: a server-rendered min/max band + average line over an + item's hourly trend rollups on the item page — long-term shape that survives + raw-sample retention. Pure, unit-tested geometry helper (`band_chart`). ## Phase 5 — Discovery & scale *(in progress)* diff --git a/static/style.css b/static/style.css index a52105a..050adb7 100644 --- a/static/style.css +++ b/static/style.css @@ -639,6 +639,7 @@ footer a:hover { color: var(--brand-primary); } .chart { display: block; width: 100%; height: auto; color: var(--accent); } .chart-line { stroke: currentColor; stroke-width: 1.5; } .chart-area { fill: currentColor; opacity: 0.08; } +.chart-band { fill: currentColor; opacity: 0.12; } .chart-grid { stroke: currentColor; stroke-width: 0.5; opacity: 0.25; } .chart-label { fill: currentColor; opacity: 0.6; font-size: 11px; } diff --git a/templates/hosts/item_detail.html b/templates/hosts/item_detail.html index 4edcec2..c96a89f 100644 --- a/templates/hosts/item_detail.html +++ b/templates/hosts/item_detail.html @@ -117,6 +117,27 @@

History

{% endif %} + {% if trend_chart and not trend_chart.empty %} +
+

Trends (long-range)

+

+ Hourly average with the min/max band — survives raw-sample retention. +

+ + {% for y, label in trend_chart.y_ticks %} + + {{ label }} + {% endfor %} + {% for x, label in trend_chart.x_ticks %} + {{ label }} + {% endfor %} + + + +
+ {% endif %} + {% if trends %}

Hourly trends {{ trends|length }}

diff --git a/tests/test_charts.py b/tests/test_charts.py index c3a8212..110ddf3 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -65,3 +65,15 @@ async def test_item_detail_page_renders_chart( assert "

History

" in page.text assert 'class="chart"' in page.text assert "chart-line" in page.text + + +def test_band_chart_empty_and_geometry() -> None: + from app.api.routes.web.charts import band_chart + + assert band_chart([]).empty + series = [(BASE + timedelta(hours=i), float(i), float(i) + 1, float(i) + 2) for i in range(4)] + cv = band_chart(series) + assert not cv.empty + assert len(cv.line.split()) == 4 # avg polyline + assert len(cv.band.split()) == 8 # max edge (4) + min edge (4) + assert [label for _, label in cv.y_ticks] == ["0", "2.50", "5"] # band spans min(0)..max(5) diff --git a/tests/test_trends.py b/tests/test_trends.py index 6dea2e2..89bf931 100644 --- a/tests/test_trends.py +++ b/tests/test_trends.py @@ -146,3 +146,25 @@ async def test_item_detail_page_shows_trends( page = await web_client.get(f"/hosts/{item.host_id}/items/{item.id}") assert page.status_code == 200 assert "Hourly trends" in page.text + + +async def test_item_detail_shows_long_range_trend_chart( + web_client: httpx.AsyncClient, session: Any, user: Any +) -> None: + item = await _item(session, user.id) + for i in range(5): + session.add( + MetricTrend( + item_id=item.id, + bucket=NOW.replace(hour=8 + i, minute=0), + value_min=float(10 + i), + value_avg=float(20 + i), + value_max=float(30 + i), + sample_count=3, + ) + ) + await session.commit() + page = await web_client.get(f"/hosts/{item.host_id}/items/{item.id}") + assert page.status_code == 200 + assert "Trends (long-range)" in page.text + assert "chart-band" in page.text