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

Expand Down Expand Up @@ -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

Expand All @@ -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 |
Expand All @@ -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
```
1 change: 1 addition & 0 deletions config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -552,6 +611,7 @@
}

.dashboard__stats,
.dashboard__charts,
.dashboard__tables {
grid-template-columns: 1fr;
}
Expand Down
39 changes: 34 additions & 5 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
}
}

Expand All @@ -100,7 +101,7 @@ function AuthenticatedApp() {
return
}
setHistoryOpenId(endpointId)
void dispatch(fetchEndpointHistory(endpointId))
void dispatch(fetchEndpointHistory({ endpointId }))
}

function toggleAlerts(endpointId: number) {
Expand All @@ -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
Expand Down Expand Up @@ -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"}
</button>
Expand All @@ -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)}
/>
</div>
)}
Expand All @@ -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"}
</button>
Expand All @@ -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)}
/>
</div>
)}
Expand Down
85 changes: 61 additions & 24 deletions frontend/src/components/AlertHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 <p className="check-history__empty">Loading alerts…</p>
}
Expand All @@ -25,31 +35,58 @@ export function AlertHistory({ items, loading, error }: AlertHistoryProps) {
return <p className="check-history__empty">No alerts sent yet.</p>
}

const showPagination = totalPages > 1 && onPageChange

return (
<ol className="check-history">
{items.map((item) => (
<li key={item.id} className="check-history__item">
<span
className={`app__pill ${
item.event_type === "recovery" ? "app__pill--up" : "app__pill--down"
}`}
<>
<ol className="check-history">
{items.map((item) => (
<li key={item.id} className="check-history__item">
<span
className={`app__pill ${
item.event_type === "recovery" ? "app__pill--up" : "app__pill--down"
}`}
>
{item.event_type}
</span>
<span className="check-history__meta">
{item.channel}
{item.success ? " · delivered" : " · failed"}
{item.response_status != null ? ` · HTTP ${item.response_status}` : ""}
</span>
<time dateTime={item.created_at}>{formatCreatedAt(item.created_at)}</time>
{item.error_message ? (
<p className="check-history__error">{item.error_message}</p>
) : null}
{item.target ? (
<p className="check-history__target">{item.target}</p>
) : null}
</li>
))}
</ol>
{showPagination && (
<nav className="pagination" aria-label="Alert history pages">
<button
type="button"
className="app__button"
disabled={loading || page <= 1}
onClick={() => onPageChange(page - 1)}
>
{item.event_type}
</span>
<span className="check-history__meta">
{item.channel}
{item.success ? " · delivered" : " · failed"}
{item.response_status != null ? ` · HTTP ${item.response_status}` : ""}
Prev
</button>
<span className="pagination__status">
Page {page} of {totalPages}
</span>
<time dateTime={item.created_at}>{formatCreatedAt(item.created_at)}</time>
{item.error_message ? (
<p className="check-history__error">{item.error_message}</p>
) : null}
{item.target ? (
<p className="check-history__target">{item.target}</p>
) : null}
</li>
))}
</ol>
<button
type="button"
className="app__button"
disabled={loading || page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Next
</button>
</nav>
)}
</>
)
}
Loading
Loading