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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
76 changes: 76 additions & 0 deletions app/api/routes/web/charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 9 additions & 2 deletions app/api/routes/web/hosts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -353,15 +353,22 @@ 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",
"host": host,
"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],
Expand Down
Binary file added docs/assets/longrange-chart.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)*

Expand Down
1 change: 1 addition & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down
21 changes: 21 additions & 0 deletions templates/hosts/item_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,27 @@ <h2>History</h2>
</section>
{% endif %}

{% if trend_chart and not trend_chart.empty %}
<section class="card" style="margin-top: 20px;">
<h2>Trends (long-range)</h2>
<p class="muted" style="margin-bottom: 12px;">
Hourly average with the min/max band — survives raw-sample retention.
</p>
<svg class="chart" viewBox="0 0 {{ trend_chart.width }} {{ trend_chart.height }}" role="img"
aria-label="{{ item.name }} long-range trend">
{% for y, label in trend_chart.y_ticks %}
<line class="chart-grid" x1="{{ trend_chart.plot_left }}" y1="{{ y }}" x2="{{ trend_chart.plot_right }}" y2="{{ y }}" />
<text class="chart-label" x="{{ trend_chart.plot_left - 6 }}" y="{{ y + 3 }}" text-anchor="end">{{ label }}</text>
{% endfor %}
{% for x, label in trend_chart.x_ticks %}
<text class="chart-label" x="{{ x }}" y="{{ trend_chart.plot_bottom + 16 }}" text-anchor="middle">{{ label }}</text>
{% endfor %}
<polygon class="chart-band" points="{{ trend_chart.band }}" />
<polyline class="chart-line" points="{{ trend_chart.line }}" fill="none" />
</svg>
</section>
{% endif %}

{% if trends %}
<section class="card" style="margin-top: 20px;">
<h2>Hourly trends <span class="count">{{ trends|length }}</span></h2>
Expand Down
12 changes: 12 additions & 0 deletions tests/test_charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,15 @@ async def test_item_detail_page_renders_chart(
assert "<h2>History</h2>" 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)
22 changes: 22 additions & 0 deletions tests/test_trends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading