From e1df622b59d3cca659f8ba6cd68baab8421bc8cf Mon Sep 17 00:00:00 2001 From: Fede Date: Mon, 21 Sep 2026 17:55:53 -0300 Subject: [PATCH] Add dashboard charts, paginated history, CSV export, and check retention. Expose hourly uptime/latency series, page history/alerts, download CSV exports, and prune old check rows via CHECK_RETENTION_DAYS in the worker. Co-authored-by: Cursor --- .env.example | 2 + README.md | 7 +- config/settings.py | 1 + frontend/src/App.css | 60 ++++++ frontend/src/App.tsx | 39 +++- frontend/src/components/AlertHistory.tsx | 85 ++++++--- frontend/src/components/CheckHistory.tsx | 67 +++++-- frontend/src/components/DashboardPanel.tsx | 175 +++++++++++++++++- frontend/src/store/dashboardSlice.ts | 9 + frontend/src/store/endpointsSlice.ts | 106 +++++++++-- monitoring/dashboard.py | 10 +- monitoring/exports.py | 20 ++ .../management/commands/prune_checks.py | 46 +++++ .../management/commands/run_check_worker.py | 5 + monitoring/metrics.py | 87 +++++++++ monitoring/pagination_utils.py | 37 ++++ monitoring/retention.py | 25 +++ monitoring/tests.py | 82 +++++++- monitoring/urls.py | 2 + monitoring/views.py | 151 ++++++++++++++- 20 files changed, 939 insertions(+), 77 deletions(-) create mode 100644 monitoring/exports.py create mode 100644 monitoring/management/commands/prune_checks.py create mode 100644 monitoring/metrics.py create mode 100644 monitoring/pagination_utils.py create mode 100644 monitoring/retention.py diff --git a/.env.example b/.env.example index 9785408..827420e 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,8 @@ APOLLO_SEED_ON_STARTUP=false # Due-check worker loop (seconds) CHECK_INTERVAL_SECONDS=60 +# Delete check results older than N days (0 disables). Worker prunes periodically. +CHECK_RETENTION_DAYS=30 EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend DEFAULT_FROM_EMAIL=apollo@localhost diff --git a/README.md b/README.md index 4bf6237..6c52c1a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Django + React API Health Monitor (monolith). - **Frontend:** React + Vite + Redux Toolkit + React Router (pnpm) - **Data:** SQLite (local) or PostgreSQL (`DATABASE_URL`) - **Ops:** Docker Compose (`db` + `web` + `worker`), GitHub Actions CI -- **Product:** due checks, assertions, SSL, mute windows, probe auth, failure threshold, Discord/Slack, status branding, incidents, tags, dashboard +- **Product:** due checks, assertions, SSL, mute, probe auth, thresholds, Discord/Slack, status branding, charts, CSV export, retention, incidents, tags, dashboard ## Quick start (local) @@ -47,7 +47,8 @@ docker compose up --build | Alerts | Transition after N failures; muted while `mute_alerts_until` is future | | Incidents | Auto-open after threshold, auto-resolve on recovery | | Status page | Public `/status` + branding via `/api/status/config/` | -| Dashboard | Uptime, latency, due, open incidents | +| Dashboard | Uptime, latency, due, open incidents, hourly charts, CSV export | +| Retention | `prune_checks` / worker deletes checks older than `CHECK_RETENTION_DAYS` | ## Key API routes @@ -56,6 +57,7 @@ docker compose up --build | GET | `/api/health/` | public | | GET | `/api/status/public/` | public | | GET/PATCH | `/api/status/config/` | token | +| GET | `/api/exports/{checks\|alerts\|incidents}.csv` | token | | POST | `/api/auth/login/` | public | | GET | `/api/dashboard/` | token | | CRUD | `/api/endpoints/` | token | @@ -73,6 +75,7 @@ See `.env.example` for full list (`DATABASE_URL`, `CHECK_INTERVAL_SECONDS`, `EMA poetry run pytest poetry run python manage.py check_endpoints --due poetry run python manage.py run_check_worker --once +poetry run python manage.py prune_checks --dry-run pnpm --dir frontend build docker compose up --build ``` diff --git a/config/settings.py b/config/settings.py index 1d3e507..905e737 100644 --- a/config/settings.py +++ b/config/settings.py @@ -166,6 +166,7 @@ def env_list(name: str, default: str = "") -> list[str]: APOLLO_DEMO_PASSWORD = env("APOLLO_DEMO_PASSWORD", "apollo") or "apollo" APOLLO_SEED_ON_STARTUP = env_bool("APOLLO_SEED_ON_STARTUP", False) CHECK_INTERVAL_SECONDS = int(env("CHECK_INTERVAL_SECONDS", "60") or "60") +CHECK_RETENTION_DAYS = int(env("CHECK_RETENTION_DAYS", "30") or "30") _email_backend = ( env("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend") diff --git a/frontend/src/App.css b/frontend/src/App.css index ad11c62..fa0d719 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -382,6 +382,65 @@ margin-bottom: 1.25rem; } +.dashboard__charts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + margin-bottom: 1.25rem; +} + +.dashboard__chart-block h3 { + margin: 0 0 0.5rem; +} + +.dashboard__chart { + display: block; + width: 100%; + max-width: 20rem; + height: auto; + border: 1px solid rgba(26, 31, 36, 0.1); + background: rgba(255, 255, 255, 0.35); +} + +.dashboard__chart-bar { + fill: rgba(31, 107, 69, 0.75); +} + +.dashboard__chart-bar--uptime { + fill: rgba(31, 107, 69, 0.75); +} + +.dashboard__chart-bar--latency { + fill: rgba(74, 85, 96, 0.65); +} + +.dashboard__exports { + margin-bottom: 1.25rem; +} + +.dashboard__exports h3 { + margin: 0 0 0.5rem; +} + +.dashboard__export-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.pagination { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 0.85rem; +} + +.pagination__status { + color: #5a6670; + font-family: "IBM Plex Mono", ui-monospace, monospace; + font-size: 0.85rem; +} + .dashboard__label { margin: 0 0 0.2rem; color: #5a6670; @@ -552,6 +611,7 @@ } .dashboard__stats, + .dashboard__charts, .dashboard__tables { grid-template-columns: 1fr; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fd6234a..46a323d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -90,7 +90,8 @@ function AuthenticatedApp() { void dispatch(fetchDashboard(dashboard.hours)) void dispatch(fetchIncidents("open")) if (alertsOpenId === endpointId) { - void dispatch(fetchEndpointAlerts(endpointId)) + const alertsPage = endpoints.alertsById[endpointId]?.page ?? 1 + void dispatch(fetchEndpointAlerts({ endpointId, page: alertsPage })) } } @@ -100,7 +101,7 @@ function AuthenticatedApp() { return } setHistoryOpenId(endpointId) - void dispatch(fetchEndpointHistory(endpointId)) + void dispatch(fetchEndpointHistory({ endpointId })) } function toggleAlerts(endpointId: number) { @@ -109,7 +110,15 @@ function AuthenticatedApp() { return } setAlertsOpenId(endpointId) - void dispatch(fetchEndpointAlerts(endpointId)) + void dispatch(fetchEndpointAlerts({ endpointId })) + } + + function handleHistoryPageChange(endpointId: number, page: number) { + void dispatch(fetchEndpointHistory({ endpointId, page })) + } + + function handleAlertsPageChange(endpointId: number, page: number) { + void dispatch(fetchEndpointAlerts({ endpointId, page })) } const dueCount = endpoints.items.filter((item) => item.is_active && item.is_due).length @@ -346,7 +355,14 @@ function AuthenticatedApp() { type="button" className="app__button" disabled={history?.loading} - onClick={() => void dispatch(fetchEndpointHistory(endpoint.id))} + onClick={() => + void dispatch( + fetchEndpointHistory({ + endpointId: endpoint.id, + page: history?.page ?? 1, + }), + ) + } > {history?.loading ? "Refreshing…" : "Refresh history"} @@ -355,6 +371,9 @@ function AuthenticatedApp() { items={history?.items ?? []} loading={history?.loading ?? true} error={history?.error ?? null} + page={history?.page ?? 1} + totalPages={history?.total_pages ?? 1} + onPageChange={(page) => handleHistoryPageChange(endpoint.id, page)} /> )} @@ -367,7 +386,14 @@ function AuthenticatedApp() { type="button" className="app__button" disabled={alerts?.loading} - onClick={() => void dispatch(fetchEndpointAlerts(endpoint.id))} + onClick={() => + void dispatch( + fetchEndpointAlerts({ + endpointId: endpoint.id, + page: alerts?.page ?? 1, + }), + ) + } > {alerts?.loading ? "Refreshing…" : "Refresh alerts"} @@ -376,6 +402,9 @@ function AuthenticatedApp() { items={alerts?.items ?? []} loading={alerts?.loading ?? true} error={alerts?.error ?? null} + page={alerts?.page ?? 1} + totalPages={alerts?.total_pages ?? 1} + onPageChange={(page) => handleAlertsPageChange(endpoint.id, page)} /> )} diff --git a/frontend/src/components/AlertHistory.tsx b/frontend/src/components/AlertHistory.tsx index de7811b..041e81b 100644 --- a/frontend/src/components/AlertHistory.tsx +++ b/frontend/src/components/AlertHistory.tsx @@ -4,6 +4,9 @@ type AlertHistoryProps = { items: AlertEvent[] loading: boolean error: string | null + page?: number + totalPages?: number + onPageChange?: (page: number) => void } function formatCreatedAt(value: string) { @@ -12,7 +15,14 @@ function formatCreatedAt(value: string) { return date.toLocaleString() } -export function AlertHistory({ items, loading, error }: AlertHistoryProps) { +export function AlertHistory({ + items, + loading, + error, + page = 1, + totalPages = 1, + onPageChange, +}: AlertHistoryProps) { if (loading && items.length === 0) { return

Loading alerts…

} @@ -25,31 +35,58 @@ export function AlertHistory({ items, loading, error }: AlertHistoryProps) { return

No alerts sent yet.

} + const showPagination = totalPages > 1 && onPageChange + return ( -
    - {items.map((item) => ( -
  1. - +
      + {items.map((item) => ( +
    1. + + {item.event_type} + + + {item.channel} + {item.success ? " · delivered" : " · failed"} + {item.response_status != null ? ` · HTTP ${item.response_status}` : ""} + + + {item.error_message ? ( +

      {item.error_message}

      + ) : null} + {item.target ? ( +

      {item.target}

      + ) : null} +
    2. + ))} +
    + {showPagination && ( +
  2. - ))} -
+ + + )} + ) } diff --git a/frontend/src/components/CheckHistory.tsx b/frontend/src/components/CheckHistory.tsx index 095384c..48c78f5 100644 --- a/frontend/src/components/CheckHistory.tsx +++ b/frontend/src/components/CheckHistory.tsx @@ -4,6 +4,9 @@ type CheckHistoryProps = { items: HealthCheckResult[] loading: boolean error: string | null + page?: number + totalPages?: number + onPageChange?: (page: number) => void } function formatCheckedAt(value: string) { @@ -12,7 +15,14 @@ function formatCheckedAt(value: string) { return date.toLocaleString() } -export function CheckHistory({ items, loading, error }: CheckHistoryProps) { +export function CheckHistory({ + items, + loading, + error, + page = 1, + totalPages = 1, + onPageChange, +}: CheckHistoryProps) { if (loading && items.length === 0) { return

Loading history…

} @@ -25,21 +35,48 @@ export function CheckHistory({ items, loading, error }: CheckHistoryProps) { return

No checks recorded yet.

} + const showPagination = totalPages > 1 && onPageChange + return ( -
    - {items.map((item) => ( -
  1. - {item.status} - - {item.status_code != null ? `HTTP ${item.status_code}` : "no status"} - {item.latency_ms != null ? ` · ${item.latency_ms}ms` : ""} + <> +
      + {items.map((item) => ( +
    1. + {item.status} + + {item.status_code != null ? `HTTP ${item.status_code}` : "no status"} + {item.latency_ms != null ? ` · ${item.latency_ms}ms` : ""} + + + {item.error_message ? ( +

      {item.error_message}

      + ) : null} +
    2. + ))} +
    + {showPagination && ( +
  2. - ))} -
+ + + )} + ) } diff --git a/frontend/src/components/DashboardPanel.tsx b/frontend/src/components/DashboardPanel.tsx index 79d52ac..bfb8151 100644 --- a/frontend/src/components/DashboardPanel.tsx +++ b/frontend/src/components/DashboardPanel.tsx @@ -1,5 +1,12 @@ +import { useState } from "react" + +import { apiFetch, readError } from "../api/client" import { useAppDispatch, useAppSelector } from "../store/hooks" -import { fetchDashboard, setDashboardHours } from "../store/dashboardSlice" +import { + fetchDashboard, + setDashboardHours, + type DashboardSeriesBucket, +} from "../store/dashboardSlice" function formatPercent(value: number | null) { if (value == null) return "n/a" @@ -15,10 +22,118 @@ function formatTime(value: string) { return new Date(value).toLocaleString() } +const CHART_WIDTH = 320 +const CHART_HEIGHT = 72 + +function UptimeChart({ series }: { series: DashboardSeriesBucket[] }) { + if (series.length === 0) { + return

No check data in this window.

+ } + + const barWidth = CHART_WIDTH / series.length + + return ( + + {series.map((bucket, index) => { + const pct = bucket.uptime_percent ?? 0 + const barHeight = (pct / 100) * (CHART_HEIGHT - 4) + return ( + + + {formatTime(bucket.bucket_start)}: {formatPercent(bucket.uptime_percent)} + + + ) + })} + + ) +} + +function LatencyChart({ series }: { series: DashboardSeriesBucket[] }) { + const values = series + .map((bucket) => bucket.avg_latency_ms) + .filter((value): value is number => value != null) + + if (values.length === 0) { + return

No latency data in this window.

+ } + + const max = Math.max(...values, 1) + const barWidth = CHART_WIDTH / series.length + + return ( + + {series.map((bucket, index) => { + if (bucket.avg_latency_ms == null) return null + const barHeight = (bucket.avg_latency_ms / max) * (CHART_HEIGHT - 4) + return ( + + + {formatTime(bucket.bucket_start)}: {formatLatency(bucket.avg_latency_ms)} + + + ) + })} + + ) +} + +async function downloadExport(path: string, filename: string) { + const response = await apiFetch(path) + if (!response.ok) { + throw new Error(await readError(response, "Export failed")) + } + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = filename + anchor.click() + URL.revokeObjectURL(url) +} + export function DashboardPanel() { const dispatch = useAppDispatch() const { data, loading, error, hours } = useAppSelector((state) => state.dashboard) const summary = data?.summary + const [exportError, setExportError] = useState(null) + const [exporting, setExporting] = useState(null) + + async function handleExport(path: string, filename: string, key: string) { + setExportError(null) + setExporting(key) + try { + await downloadExport(path, filename) + } catch (err) { + setExportError(err instanceof Error ? err.message : "Export failed") + } finally { + setExporting(null) + } + } return (
@@ -91,6 +206,64 @@ export function DashboardPanel() { + {data?.series && data.series.length > 0 && ( +
+
+

Uptime trend

+ +
+
+

Latency trend

+ +
+
+ )} + +
+

Exports

+
+ + + +
+ {exportError &&

{exportError}

} +
+

Per-endpoint uptime

diff --git a/frontend/src/store/dashboardSlice.ts b/frontend/src/store/dashboardSlice.ts index 95e75a8..1493133 100644 --- a/frontend/src/store/dashboardSlice.ts +++ b/frontend/src/store/dashboardSlice.ts @@ -28,6 +28,14 @@ export type DashboardOpenIncident = { opened_at: string } +export type DashboardSeriesBucket = { + bucket_start: string + checks_total: number + checks_up: number + avg_latency_ms: number | null + uptime_percent: number | null +} + export type DashboardEndpointRow = { id: number name: string @@ -45,6 +53,7 @@ export type DashboardData = { window_hours: number generated_at: string summary: DashboardSummary + series: DashboardSeriesBucket[] endpoints: DashboardEndpointRow[] recent_failures: Array<{ id: number diff --git a/frontend/src/store/endpointsSlice.ts b/frontend/src/store/endpointsSlice.ts index bb9e3d3..b20375e 100644 --- a/frontend/src/store/endpointsSlice.ts +++ b/frontend/src/store/endpointsSlice.ts @@ -101,6 +101,10 @@ type ListEntry = { items: T[] loading: boolean error: string | null + count: number + page: number + page_size: number + total_pages: number } type EndpointsState = { @@ -214,27 +218,67 @@ export const checkDueEndpoints = createAsyncThunk( }, ) +const LIST_PAGE_SIZE = 20 + +type PaginatedListPayload = { + endpointId: number + items: T[] + count: number + page: number + page_size: number + total_pages: number +} + +type FetchListArg = { + endpointId: number + page?: number +} + export const fetchEndpointHistory = createAsyncThunk( "endpoints/fetchHistory", - async (endpointId: number): Promise<{ endpointId: number; items: HealthCheckResult[] }> => { - const response = await apiFetch(`/api/endpoints/${endpointId}/checks/`) + async ({ + endpointId, + page = 1, + }: FetchListArg): Promise> => { + const response = await apiFetch( + `/api/endpoints/${endpointId}/checks/?page=${page}&page_size=${LIST_PAGE_SIZE}`, + ) if (!response.ok) { throw new Error(await readError(response, "Failed to load history")) } - const items: HealthCheckResult[] = await response.json() - return { endpointId, items } + const body = await response.json() + return { + endpointId, + items: body.results as HealthCheckResult[], + count: body.count, + page: body.page, + page_size: body.page_size, + total_pages: body.total_pages, + } }, ) export const fetchEndpointAlerts = createAsyncThunk( "endpoints/fetchAlerts", - async (endpointId: number): Promise<{ endpointId: number; items: AlertEvent[] }> => { - const response = await apiFetch(`/api/endpoints/${endpointId}/alerts/`) + async ({ + endpointId, + page = 1, + }: FetchListArg): Promise> => { + const response = await apiFetch( + `/api/endpoints/${endpointId}/alerts/?page=${page}&page_size=${LIST_PAGE_SIZE}`, + ) if (!response.ok) { throw new Error(await readError(response, "Failed to load alerts")) } - const items: AlertEvent[] = await response.json() - return { endpointId, items } + const body = await response.json() + return { + endpointId, + items: body.results as AlertEvent[], + count: body.count, + page: body.page, + page_size: body.page_size, + total_pages: body.total_pages, + } }, ) @@ -374,11 +418,16 @@ const endpointsSlice = createSlice({ state.error = action.error.message ?? "Unknown error" }) .addCase(fetchEndpointHistory.pending, (state, action) => { - const endpointId = action.meta.arg + const { endpointId, page } = action.meta.arg + const existing = state.historyById[endpointId] state.historyById[endpointId] = { - items: state.historyById[endpointId]?.items ?? [], + items: existing?.items ?? [], loading: true, error: null, + count: existing?.count ?? 0, + page: page ?? existing?.page ?? 1, + page_size: existing?.page_size ?? LIST_PAGE_SIZE, + total_pages: existing?.total_pages ?? 1, } }) .addCase(fetchEndpointHistory.fulfilled, (state, action) => { @@ -386,22 +435,36 @@ const endpointsSlice = createSlice({ items: action.payload.items, loading: false, error: null, + count: action.payload.count, + page: action.payload.page, + page_size: action.payload.page_size, + total_pages: action.payload.total_pages, } }) .addCase(fetchEndpointHistory.rejected, (state, action) => { - const endpointId = action.meta.arg + const { endpointId } = action.meta.arg + const existing = state.historyById[endpointId] state.historyById[endpointId] = { - items: state.historyById[endpointId]?.items ?? [], + items: existing?.items ?? [], loading: false, error: action.error.message ?? "Unknown error", + count: existing?.count ?? 0, + page: existing?.page ?? 1, + page_size: existing?.page_size ?? LIST_PAGE_SIZE, + total_pages: existing?.total_pages ?? 1, } }) .addCase(fetchEndpointAlerts.pending, (state, action) => { - const endpointId = action.meta.arg + const { endpointId, page } = action.meta.arg + const existing = state.alertsById[endpointId] state.alertsById[endpointId] = { - items: state.alertsById[endpointId]?.items ?? [], + items: existing?.items ?? [], loading: true, error: null, + count: existing?.count ?? 0, + page: page ?? existing?.page ?? 1, + page_size: existing?.page_size ?? LIST_PAGE_SIZE, + total_pages: existing?.total_pages ?? 1, } }) .addCase(fetchEndpointAlerts.fulfilled, (state, action) => { @@ -409,18 +472,27 @@ const endpointsSlice = createSlice({ items: action.payload.items, loading: false, error: null, + count: action.payload.count, + page: action.payload.page, + page_size: action.payload.page_size, + total_pages: action.payload.total_pages, } const item = state.items.find((endpoint) => endpoint.id === action.payload.endpointId) - if (item) { + if (item && action.payload.page === 1) { item.last_alert = action.payload.items[0] ?? null } }) .addCase(fetchEndpointAlerts.rejected, (state, action) => { - const endpointId = action.meta.arg + const { endpointId } = action.meta.arg + const existing = state.alertsById[endpointId] state.alertsById[endpointId] = { - items: state.alertsById[endpointId]?.items ?? [], + items: existing?.items ?? [], loading: false, error: action.error.message ?? "Unknown error", + count: existing?.count ?? 0, + page: existing?.page ?? 1, + page_size: existing?.page_size ?? LIST_PAGE_SIZE, + total_pages: existing?.total_pages ?? 1, } }) .addCase(testEndpointWebhook.pending, (state, action) => { diff --git a/monitoring/dashboard.py b/monitoring/dashboard.py index 74bfb9e..bb59aae 100644 --- a/monitoring/dashboard.py +++ b/monitoring/dashboard.py @@ -6,6 +6,7 @@ from django.db.models import Avg, Count, Q from django.utils import timezone +from monitoring.metrics import build_check_series from monitoring.models import AlertEvent, HealthCheckResult, Incident, MonitoredEndpoint from monitoring.services import due_endpoints @@ -21,7 +22,13 @@ def build_dashboard(*, hours: int = 24) -> dict[str, Any]: alerting = [ endpoint for endpoint in endpoints - if endpoint.alert_on_failure and (endpoint.webhook_url or endpoint.alert_email) + if endpoint.alert_on_failure + and ( + endpoint.webhook_url + or endpoint.alert_email + or endpoint.discord_webhook_url + or endpoint.slack_webhook_url + ) ] open_incidents = Incident.objects.filter(status=Incident.Status.OPEN).count() @@ -156,4 +163,5 @@ def build_dashboard(*, hours: int = 24) -> dict[str, Any]: .select_related("endpoint") .order_by("-opened_at")[:10] ], + "series": build_check_series(hours=hours), } diff --git a/monitoring/exports.py b/monitoring/exports.py new file mode 100644 index 0000000..ec172da --- /dev/null +++ b/monitoring/exports.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import csv +from collections.abc import Iterable +from io import StringIO +from typing import Any + +from django.http import HttpResponse + + +def csv_response(filename: str, headers: list[str], rows: Iterable[Iterable[Any]]) -> HttpResponse: + """Build a downloadable CSV HttpResponse.""" + buffer = StringIO() + writer = csv.writer(buffer) + writer.writerow(headers) + for row in rows: + writer.writerow(list(row)) + response = HttpResponse(buffer.getvalue(), content_type="text/csv") + response["Content-Disposition"] = f'attachment; filename="{filename}"' + return response diff --git a/monitoring/management/commands/prune_checks.py b/monitoring/management/commands/prune_checks.py new file mode 100644 index 0000000..71bd5fb --- /dev/null +++ b/monitoring/management/commands/prune_checks.py @@ -0,0 +1,46 @@ +from django.core.management.base import BaseCommand + +from monitoring.retention import prune_old_checks + + +class Command(BaseCommand): + help = "Delete health check results older than CHECK_RETENTION_DAYS (or --days)." + + def add_arguments(self, parser) -> None: + parser.add_argument( + "--days", + type=int, + default=None, + help="Override retention window in days (0 disables).", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print how many rows would be deleted without deleting.", + ) + + def handle(self, *args, **options) -> None: + days = options["days"] + if options["dry_run"]: + from datetime import timedelta + + from django.conf import settings + from django.utils import timezone + + from monitoring.models import HealthCheckResult + + effective = days + if effective is None: + effective = int(getattr(settings, "CHECK_RETENTION_DAYS", 30) or 0) + if effective <= 0: + self.stdout.write("Retention disabled; nothing to prune.") + return + cutoff = timezone.now() - timedelta(days=effective) + count = HealthCheckResult.objects.filter(checked_at__lt=cutoff).count() + self.stdout.write( + self.style.WARNING(f"Would delete {count} check(s) older than {effective} day(s).") + ) + return + + deleted = prune_old_checks(days=days) + self.stdout.write(self.style.SUCCESS(f"Deleted {deleted} old check result(s).")) diff --git a/monitoring/management/commands/run_check_worker.py b/monitoring/management/commands/run_check_worker.py index dcddc56..4cce34f 100644 --- a/monitoring/management/commands/run_check_worker.py +++ b/monitoring/management/commands/run_check_worker.py @@ -38,6 +38,11 @@ def handle(self, *args, **options) -> None: except Exception as exc: # noqa: BLE001 - keep worker alive self.stderr.write(self.style.ERROR(f"Check pass failed: {exc}")) + try: + call_command("prune_checks") + except Exception as exc: # noqa: BLE001 - keep worker alive + self.stderr.write(self.style.ERROR(f"Prune pass failed: {exc}")) + if options["once"]: break diff --git a/monitoring/metrics.py b/monitoring/metrics.py new file mode 100644 index 0000000..3e9a3dc --- /dev/null +++ b/monitoring/metrics.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from django.db.models import Avg, Count, Q +from django.utils import timezone + +from monitoring.models import HealthCheckResult + + +def build_check_series(*, hours: int = 24, endpoint_id: int | None = None) -> list[dict[str, Any]]: + """ + Build hourly uptime/latency buckets for charts. + + Buckets cover [now - hours, now), one entry per hour (oldest first). + """ + hours = max(1, min(hours, 24 * 30)) + now = timezone.now().replace(minute=0, second=0, microsecond=0) + start = now - timedelta(hours=hours - 1) + + queryset = HealthCheckResult.objects.filter(checked_at__gte=start) + if endpoint_id is not None: + queryset = queryset.filter(endpoint_id=endpoint_id) + + # Prefetch into memory for sqlite-friendly bucketing. + rows = list(queryset.values("checked_at", "status", "latency_ms")) + buckets: dict[Any, dict[str, Any]] = {} + cursor = start + while cursor <= now: + buckets[cursor] = { + "bucket_start": cursor.isoformat(), + "checks_total": 0, + "checks_up": 0, + "avg_latency_ms": None, + "uptime_percent": None, + "_latency_sum": 0.0, + "_latency_count": 0, + } + cursor += timedelta(hours=1) + + for row in rows: + checked_at = row["checked_at"].replace(minute=0, second=0, microsecond=0) + bucket = buckets.get(checked_at) + if bucket is None: + continue + bucket["checks_total"] += 1 + if row["status"] == HealthCheckResult.Status.UP: + bucket["checks_up"] += 1 + if row["latency_ms"] is not None: + bucket["_latency_sum"] += float(row["latency_ms"]) + bucket["_latency_count"] += 1 + + series: list[dict[str, Any]] = [] + for key in sorted(buckets.keys()): + bucket = buckets[key] + total = bucket["checks_total"] + up = bucket["checks_up"] + latency_count = bucket.pop("_latency_count") + latency_sum = bucket.pop("_latency_sum") + bucket["uptime_percent"] = round((up / total) * 100, 2) if total else None + bucket["avg_latency_ms"] = ( + round(latency_sum / latency_count, 2) if latency_count else None + ) + series.append(bucket) + return series + + +def build_endpoint_uptime_summary(*, hours: int = 24) -> dict[str, Any]: + """Compact aggregate used by export/tests.""" + hours = max(1, min(hours, 24 * 30)) + since = timezone.now() - timedelta(hours=hours) + stats = HealthCheckResult.objects.filter(checked_at__gte=since).aggregate( + total=Count("id"), + up=Count("id", filter=Q(status=HealthCheckResult.Status.UP)), + avg_latency=Avg("latency_ms"), + ) + total = stats["total"] or 0 + up = stats["up"] or 0 + return { + "hours": hours, + "checks_total": total, + "uptime_percent": round((up / total) * 100, 2) if total else None, + "avg_latency_ms": ( + round(stats["avg_latency"], 2) if stats["avg_latency"] is not None else None + ), + } diff --git a/monitoring/pagination_utils.py b/monitoring/pagination_utils.py new file mode 100644 index 0000000..ea6d0dd --- /dev/null +++ b/monitoring/pagination_utils.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Any + +from django.core.paginator import Paginator +from rest_framework.request import Request + + +def paginate_queryset( + request: Request, + queryset, + *, + default_page_size: int = 20, + max_page_size: int = 100, +) -> dict[str, Any]: + """Return a page payload for list endpoints.""" + try: + page = int(request.query_params.get("page", 1)) + except (TypeError, ValueError): + page = 1 + try: + page_size = int(request.query_params.get("page_size", default_page_size)) + except (TypeError, ValueError): + page_size = default_page_size + + page = max(1, page) + page_size = max(1, min(page_size, max_page_size)) + + paginator = Paginator(queryset, page_size) + page_obj = paginator.get_page(page) + return { + "count": paginator.count, + "page": page_obj.number, + "page_size": page_size, + "total_pages": paginator.num_pages, + "results": list(page_obj.object_list), + } diff --git a/monitoring/retention.py b/monitoring/retention.py new file mode 100644 index 0000000..70eab8e --- /dev/null +++ b/monitoring/retention.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from datetime import timedelta + +from django.conf import settings +from django.utils import timezone + +from monitoring.models import HealthCheckResult + + +def prune_old_checks(*, days: int | None = None) -> int: + """ + Delete HealthCheckResult rows older than `days`. + + Returns the number of deleted rows. Uses CHECK_RETENTION_DAYS when days is None. + A value <= 0 disables pruning. + """ + if days is None: + days = int(getattr(settings, "CHECK_RETENTION_DAYS", 30) or 0) + if days <= 0: + return 0 + + cutoff = timezone.now() - timedelta(days=days) + deleted, _ = HealthCheckResult.objects.filter(checked_at__lt=cutoff).delete() + return int(deleted) diff --git a/monitoring/tests.py b/monitoring/tests.py index 7cd219f..4caf98e 100644 --- a/monitoring/tests.py +++ b/monitoring/tests.py @@ -284,9 +284,10 @@ def test_list_endpoint_checks(api_client) -> None: response = api_client.get(f"/api/endpoints/{endpoint.id}/checks/") assert response.status_code == status.HTTP_200_OK - assert len(response.data) == 2 - assert response.data[0]["id"] == newer.id - assert response.data[1]["id"] == older.id + assert response.data["count"] == 2 + assert len(response.data["results"]) == 2 + assert response.data["results"][0]["id"] == newer.id + assert response.data["results"][1]["id"] == older.id @pytest.mark.django_db @@ -439,8 +440,8 @@ def test_list_endpoint_alerts(api_client) -> None: response = api_client.get(f"/api/endpoints/{endpoint.id}/alerts/") assert response.status_code == status.HTTP_200_OK - assert len(response.data) == 2 - ids = {item["id"] for item in response.data} + assert response.data["count"] == 2 + ids = {item["id"] for item in response.data["results"]} assert newer.id in ids assert older.id in ids @@ -534,7 +535,10 @@ def test_run_check_worker_once(mock_call: MagicMock) -> None: from django.core.management import call_command call_command("run_check_worker", once=True, interval=5) - mock_call.assert_called_once_with("check_endpoints", due=True) + assert mock_call.call_args_list == [ + (("check_endpoints",), {"due": True}), + (("prune_checks",), {}), + ] @pytest.mark.django_db @@ -864,3 +868,69 @@ def test_status_page_config_update(api_client) -> None: assert public.data["title"] == "Acme Status" assert public.data["subtitle"] == "Platform health" assert public.data["support_url"] == "https://support.example.com" + + +@pytest.mark.django_db +def test_dashboard_includes_series(api_client) -> None: + endpoint = MonitoredEndpointFactory() + HealthCheckResultFactory(endpoint=endpoint, status=HealthCheckResult.Status.UP) + HealthCheckResultFactory( + endpoint=endpoint, status=HealthCheckResult.Status.DOWN, status_code=500 + ) + + response = api_client.get("/api/dashboard/?hours=24") + + assert response.status_code == status.HTTP_200_OK + assert "series" in response.data + assert isinstance(response.data["series"], list) + assert len(response.data["series"]) == 24 + + +@pytest.mark.django_db +def test_export_checks_csv(api_client) -> None: + endpoint = MonitoredEndpointFactory(name="CSV Target") + HealthCheckResultFactory(endpoint=endpoint, status=HealthCheckResult.Status.UP) + + response = api_client.get("/api/exports/checks.csv?hours=24") + + assert response.status_code == status.HTTP_200_OK + assert "text/csv" in response["Content-Type"] + body = response.content.decode() + assert "endpoint_name" in body + assert "CSV Target" in body + + +@pytest.mark.django_db +def test_prune_old_checks() -> None: + from datetime import timedelta + + from django.utils import timezone + + from monitoring.retention import prune_old_checks + + endpoint = MonitoredEndpointFactory() + old = HealthCheckResultFactory(endpoint=endpoint) + HealthCheckResult.objects.filter(pk=old.pk).update( + checked_at=timezone.now() - timedelta(days=40) + ) + fresh = HealthCheckResultFactory(endpoint=endpoint) + + deleted = prune_old_checks(days=30) + + assert deleted == 1 + assert not HealthCheckResult.objects.filter(pk=old.pk).exists() + assert HealthCheckResult.objects.filter(pk=fresh.pk).exists() + + +@pytest.mark.django_db +def test_checks_pagination(api_client) -> None: + endpoint = MonitoredEndpointFactory() + HealthCheckResultFactory.create_batch(5, endpoint=endpoint) + + response = api_client.get(f"/api/endpoints/{endpoint.id}/checks/?page=1&page_size=2") + + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 5 + assert response.data["page_size"] == 2 + assert len(response.data["results"]) == 2 + assert response.data["total_pages"] == 3 diff --git a/monitoring/urls.py b/monitoring/urls.py index 00e644d..de3458d 100644 --- a/monitoring/urls.py +++ b/monitoring/urls.py @@ -5,6 +5,7 @@ from monitoring.views import ( AlertEventViewSet, DashboardView, + ExportView, HealthCheckResultViewSet, HealthView, IncidentViewSet, @@ -26,6 +27,7 @@ path("status/public/", PublicStatusView.as_view(), name="public-status"), path("status/config/", StatusPageConfigView.as_view(), name="status-config"), path("dashboard/", DashboardView.as_view(), name="dashboard"), + path("exports/.csv", ExportView.as_view(), name="exports-csv"), path("auth/login/", LoginView.as_view(), name="auth-login"), path("auth/logout/", LogoutView.as_view(), name="auth-logout"), path("auth/me/", MeView.as_view(), name="auth-me"), diff --git a/monitoring/views.py b/monitoring/views.py index eeaee7f..2f111dc 100644 --- a/monitoring/views.py +++ b/monitoring/views.py @@ -1,3 +1,5 @@ +from datetime import timedelta + from django.utils import timezone from rest_framework import status, viewsets from rest_framework.decorators import action @@ -8,7 +10,9 @@ from monitoring.alerts import build_alert_payload, deliver_webhook from monitoring.dashboard import build_dashboard +from monitoring.exports import csv_response from monitoring.models import AlertEvent, HealthCheckResult, Incident, MonitoredEndpoint, StatusPageConfig, Tag +from monitoring.pagination_utils import paginate_queryset from monitoring.serializers import ( AlertEventSerializer, HealthCheckResultSerializer, @@ -114,20 +118,35 @@ def check_due(self, request: Request) -> Response: @action(detail=True, methods=["get"], url_path="checks") def checks(self, request: Request, pk: str | None = None) -> Response: endpoint = self.get_object() - results = endpoint.checks.all()[:50] - return Response(HealthCheckResultSerializer(results, many=True).data) + page = paginate_queryset(request, endpoint.checks.all()) + return Response( + { + **page, + "results": HealthCheckResultSerializer(page["results"], many=True).data, + } + ) @action(detail=True, methods=["get"], url_path="alerts") def alerts(self, request: Request, pk: str | None = None) -> Response: endpoint = self.get_object() - results = endpoint.alerts.all()[:50] - return Response(AlertEventSerializer(results, many=True).data) + page = paginate_queryset(request, endpoint.alerts.all()) + return Response( + { + **page, + "results": AlertEventSerializer(page["results"], many=True).data, + } + ) @action(detail=True, methods=["get"], url_path="incidents") def incidents(self, request: Request, pk: str | None = None) -> Response: endpoint = self.get_object() - results = endpoint.incidents.all()[:50] - return Response(IncidentSerializer(results, many=True).data) + page = paginate_queryset(request, endpoint.incidents.all()) + return Response( + { + **page, + "results": IncidentSerializer(page["results"], many=True).data, + } + ) @action(detail=True, methods=["post"], url_path="test-webhook") def test_webhook(self, request: Request, pk: str | None = None) -> Response: @@ -220,3 +239,123 @@ class TagViewSet(viewsets.ModelViewSet): queryset = Tag.objects.all() serializer_class = TagSerializer http_method_names = ["get", "post", "delete", "head", "options"] + + +class ExportView(APIView): + """CSV downloads for checks, alerts, and incidents.""" + + def get(self, request: Request, resource: str) -> Response: + try: + hours = int(request.query_params.get("hours", 24 * 7)) + except (TypeError, ValueError): + hours = 24 * 7 + hours = max(1, min(hours, 24 * 90)) + since = timezone.now() - timedelta(hours=hours) + endpoint_id = request.query_params.get("endpoint_id") + + if resource == "checks": + queryset = HealthCheckResult.objects.select_related("endpoint").filter( + checked_at__gte=since + ) + if endpoint_id: + queryset = queryset.filter(endpoint_id=endpoint_id) + rows = ( + ( + item.id, + item.endpoint_id, + item.endpoint.name, + item.status, + item.status_code if item.status_code is not None else "", + item.latency_ms if item.latency_ms is not None else "", + item.error_message, + item.checked_at.isoformat(), + ) + for item in queryset.order_by("-checked_at")[:5000] + ) + return csv_response( + "apollo-checks.csv", + [ + "id", + "endpoint_id", + "endpoint_name", + "status", + "status_code", + "latency_ms", + "error_message", + "checked_at", + ], + rows, + ) + + if resource == "alerts": + queryset = AlertEvent.objects.select_related("endpoint").filter( + created_at__gte=since + ) + if endpoint_id: + queryset = queryset.filter(endpoint_id=endpoint_id) + rows = ( + ( + item.id, + item.endpoint_id, + item.endpoint.name, + item.event_type, + item.channel, + item.target, + item.success, + item.response_status if item.response_status is not None else "", + item.error_message, + item.created_at.isoformat(), + ) + for item in queryset.order_by("-created_at")[:5000] + ) + return csv_response( + "apollo-alerts.csv", + [ + "id", + "endpoint_id", + "endpoint_name", + "event_type", + "channel", + "target", + "success", + "response_status", + "error_message", + "created_at", + ], + rows, + ) + + if resource == "incidents": + queryset = Incident.objects.select_related("endpoint").all() + if endpoint_id: + queryset = queryset.filter(endpoint_id=endpoint_id) + status_filter = request.query_params.get("status") + if status_filter: + queryset = queryset.filter(status=status_filter) + rows = ( + ( + item.id, + item.endpoint_id, + item.endpoint.name, + item.status, + item.summary, + item.opened_at.isoformat(), + item.resolved_at.isoformat() if item.resolved_at else "", + ) + for item in queryset.order_by("-opened_at")[:5000] + ) + return csv_response( + "apollo-incidents.csv", + [ + "id", + "endpoint_id", + "endpoint_name", + "status", + "summary", + "opened_at", + "resolved_at", + ], + rows, + ) + + return Response({"detail": "Unknown export resource."}, status=status.HTTP_404_NOT_FOUND)