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) => (
- -
-
+
+ {items.map((item) => (
+ -
+
+ {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}
+
+ ))}
+
+ {showPagination && (
+
-
- {item.channel}
- {item.success ? " · delivered" : " · failed"}
- {item.response_status != null ? ` · HTTP ${item.response_status}` : ""}
+ Prev
+
+
+ Page {page} of {totalPages}
-
- {item.error_message ? (
- {item.error_message}
- ) : null}
- {item.target ? (
- {item.target}
- ) : null}
-
- ))}
-
+
+
+ )}
+ >
)
}
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) => (
- -
- {item.status}
-
- {item.status_code != null ? `HTTP ${item.status_code}` : "no status"}
- {item.latency_ms != null ? ` · ${item.latency_ms}ms` : ""}
+ <>
+
+ {items.map((item) => (
+ -
+ {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}
+
+ ))}
+
+ {showPagination && (
+
- ))}
-
+
+
+ )}
+ >
)
}
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 (
+
+ )
+}
+
+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 (
+
+ )
+}
+
+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)