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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
APOLLO_DEMO_USERNAME=apollo
APOLLO_DEMO_PASSWORD=apollo
APOLLO_SEED_ON_STARTUP=false
APOLLO_ALLOW_REGISTER=true

# Due-check worker loop (seconds)
CHECK_INTERVAL_SECONDS=60
Expand Down
24 changes: 13 additions & 11 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, probe auth, thresholds, Discord/Slack, status branding, charts, CSV export, retention, incidents, tags, dashboard
- **Product:** multi-user ownership, advanced assertions, quiet hours, incident ack, charts, exports, Discord/Slack, status page, retention

## Quick start (local)

Expand All @@ -23,7 +23,7 @@ poetry run python manage.py runserver
pnpm --dir frontend dev
```

- App: http://localhost:5173 — login **`apollo` / `apollo`**
- App: http://localhost:5173 — login **`apollo` / `apollo`** (or register if `APOLLO_ALLOW_REGISTER=true`)
- Public status: http://localhost:5173/status

## Docker
Expand All @@ -35,20 +35,20 @@ docker compose up --build

- App: http://localhost:8000
- Status: http://localhost:8000/status
- Worker runs `check_endpoints --due` on an interval
- Worker runs due checks + `prune_checks` on an interval

## Features

| Area | Details |
|------|---------|
| Auth | Token login/logout/me; API protected except health + public status |
| Endpoints | CRUD, tags, public, interval, webhook/email/Discord/Slack, mute, probe auth/headers |
| Checks | Manual, due, history; body/latency assertions; SSL expiry; failure threshold |
| Alerts | Transition after N failures; muted while `mute_alerts_until` is future |
| Incidents | Auto-open after threshold, auto-resolve on recovery |
| Auth | Login/logout/me + optional register; endpoints scoped by owner (staff sees all) |
| Endpoints | CRUD, tags, public, probe auth/headers/body, mute, quiet hours |
| Checks | Manual, due, history; body/header/JSON/latency/SSL assertions; failure threshold |
| Alerts | Webhook/email/Discord/Slack after N failures; muted by datetime or quiet hours |
| Incidents | Auto open/resolve; acknowledge via API/UI |
| Status page | Public `/status` + branding via `/api/status/config/` |
| Dashboard | Uptime, latency, due, open incidents, hourly charts, CSV export |
| Retention | `prune_checks` / worker deletes checks older than `CHECK_RETENTION_DAYS` |
| Dashboard | Uptime, latency, charts, CSV export |
| Retention | Worker prunes checks older than `CHECK_RETENTION_DAYS` |

## Key API routes

Expand All @@ -59,15 +59,17 @@ docker compose up --build
| GET/PATCH | `/api/status/config/` | token |
| GET | `/api/exports/{checks\|alerts\|incidents}.csv` | token |
| POST | `/api/auth/login/` | public |
| POST | `/api/auth/register/` | public (if enabled) |
| GET | `/api/dashboard/` | token |
| CRUD | `/api/endpoints/` | token |
| GET | `/api/incidents/?status=open` | token |
| POST | `/api/incidents/{id}/acknowledge/` | token |
| GET/POST | `/api/tags/` | token |
| GET | `/api/alerts/` | token |

## Env highlights

See `.env.example` for full list (`DATABASE_URL`, `CHECK_INTERVAL_SECONDS`, `EMAIL_*` / mailer settings, demo user, etc.).
See `.env.example` (`DATABASE_URL`, `CHECK_INTERVAL_SECONDS`, `CHECK_RETENTION_DAYS`, `APOLLO_ALLOW_REGISTER`, `EMAIL_*`, demo user, etc.).

## Commands

Expand Down
15 changes: 11 additions & 4 deletions config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import os
from pathlib import Path

from django.core.exceptions import ImproperlyConfigured
from dotenv import load_dotenv

BASE_DIR = Path(__file__).resolve().parent.parent
Expand All @@ -31,11 +32,16 @@ def env_list(name: str, default: str = "") -> list[str]:
return [item.strip() for item in raw.split(",") if item.strip()]


SECRET_KEY = env(
"DJANGO_SECRET_KEY",
"django-insecure-d90!b7=ck2e9yg59eu4)03zucv&yjj2z3hnoiss2z&yt1g2p)q",
)
DEBUG = env_bool("DJANGO_DEBUG", True)
SECRET_KEY = env("DJANGO_SECRET_KEY")
if not SECRET_KEY:
if DEBUG:
# Local/dev only — never used when DEBUG is false.
SECRET_KEY = "dev-only-change-me"
else:
raise ImproperlyConfigured(
"DJANGO_SECRET_KEY must be set when DJANGO_DEBUG is false."
)
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1")
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS", "")

Expand Down Expand Up @@ -165,6 +171,7 @@ def env_list(name: str, default: str = "") -> list[str]:
APOLLO_DEMO_USERNAME = env("APOLLO_DEMO_USERNAME", "apollo") or "apollo"
APOLLO_DEMO_PASSWORD = env("APOLLO_DEMO_PASSWORD", "apollo") or "apollo"
APOLLO_SEED_ON_STARTUP = env_bool("APOLLO_SEED_ON_STARTUP", False)
APOLLO_ALLOW_REGISTER = env_bool("APOLLO_ALLOW_REGISTER", True)
CHECK_INTERVAL_SECONDS = int(env("CHECK_INTERVAL_SECONDS", "60") or "60")
CHECK_RETENTION_DAYS = int(env("CHECK_RETENTION_DAYS", "30") or "30")

Expand Down
5 changes: 4 additions & 1 deletion conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
from rest_framework.authtoken.models import Token
from rest_framework.test import APIClient

# Fixed test credential — not a real account secret.
TEST_PASSWORD = "pytest-only-pass"


@pytest.fixture
def user(db) -> User:
account = User.objects.create_user(username="tester", password="secret123")
account = User.objects.create_user(username="tester", password=TEST_PASSWORD)
Token.objects.create(user=account)
return account

Expand Down
87 changes: 87 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -253,13 +253,55 @@
color: #5a6670;
}

.endpoint-form__section {
margin-bottom: 0.85rem;
border: 1px solid rgba(26, 31, 36, 0.12);
background: rgba(255, 255, 255, 0.25);
}

.endpoint-form__section > summary {
padding: 0.65rem 0.85rem;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #5a6670;
cursor: pointer;
user-select: none;
}

.endpoint-form__section[open] > summary {
border-bottom: 1px solid rgba(26, 31, 36, 0.1);
}

.endpoint-form__section > .endpoint-form__grid {
padding: 0.85rem;
margin-bottom: 0;
}

.endpoint-form__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem 1rem;
margin-bottom: 1rem;
}

.endpoint-form__quiet-hours {
display: grid;
gap: 0.35rem;
}

.endpoint-form__quiet-label {
font-size: 0.9rem;
color: #4a5560;
}

.endpoint-form__quiet-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem 1rem;
}

.endpoint-form__wide {
grid-column: 1 / -1;
}
Expand Down Expand Up @@ -305,6 +347,29 @@
max-width: 24rem;
}

.login__tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}

.login__tab {
flex: 1;
border: 1px solid rgba(26, 31, 36, 0.2);
background: transparent;
color: #4a5560;
padding: 0.45rem 0.75rem;
font: inherit;
font-size: 0.9rem;
cursor: pointer;
}

.login__tab--active {
background: rgba(26, 31, 36, 0.06);
color: #1a1f24;
font-weight: 500;
}

.login__header {
margin-bottom: 1.75rem;
}
Expand Down Expand Up @@ -526,6 +591,28 @@
color: #9b2c2c;
}

.app__badge--ack {
border-color: rgba(31, 107, 69, 0.35);
color: #1f6b45;
}

.app__button--ack {
margin-top: 0.5rem;
border-color: rgba(31, 107, 69, 0.35);
color: #1f6b45;
}

.dashboard__incident-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
}

.dashboard__incident-item .app__button--ack {
margin-top: 0.45rem;
}

.status-page__nav {
display: flex;
align-items: center;
Expand Down
32 changes: 30 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
type MonitoredEndpoint,
} from "./store/endpointsSlice"
import { fetchHealth } from "./store/healthSlice"
import { fetchIncidents } from "./store/incidentsSlice"
import { acknowledgeIncident, fetchIncidents } from "./store/incidentsSlice"
import "./App.css"

function statusLabel(status: string | undefined) {
Expand All @@ -42,6 +42,7 @@ function AuthenticatedApp() {
const health = useAppSelector((state) => state.health)
const endpoints = useAppSelector((state) => state.endpoints)
const dashboard = useAppSelector((state) => state.dashboard)
const incidents = useAppSelector((state) => state.incidents)
const [editing, setEditing] = useState<MonitoredEndpoint | null>(null)
const [historyOpenId, setHistoryOpenId] = useState<number | null>(null)
const [alertsOpenId, setAlertsOpenId] = useState<number | null>(null)
Expand Down Expand Up @@ -121,6 +122,13 @@ function AuthenticatedApp() {
void dispatch(fetchEndpointAlerts({ endpointId, page }))
}

async function handleAcknowledge(incidentId: number) {
await dispatch(acknowledgeIncident(incidentId)).unwrap()
void dispatch(fetchDashboard(dashboard.hours))
void dispatch(fetchIncidents("open"))
void dispatch(fetchEndpoints())
}

const dueCount = endpoints.items.filter((item) => item.is_active && item.is_due).length

return (
Expand Down Expand Up @@ -227,8 +235,16 @@ function AuthenticatedApp() {
<div>
<p className="app__row-name">
{endpoint.name}
{endpoint.owner_username && (
<span className="app__muted"> · {endpoint.owner_username}</span>
)}
{endpoint.open_incident && (
<span className="app__badge app__badge--incident">incident</span>
<>
<span className="app__badge app__badge--incident">incident</span>
{endpoint.open_incident.acknowledged_at && (
<span className="app__badge app__badge--ack">acknowledged</span>
)}
</>
)}
{!endpoint.is_active && (
<span className="app__muted"> · inactive</span>
Expand Down Expand Up @@ -294,6 +310,18 @@ function AuthenticatedApp() {
{last?.latency_ms != null ? ` · ${last.latency_ms}ms` : ""}
{last?.status_code != null ? ` · HTTP ${last.status_code}` : ""}
</p>
{endpoint.open_incident && !endpoint.open_incident.acknowledged_at && (
<button
type="button"
className="app__button app__button--ack"
disabled={incidents.acknowledgingId === endpoint.open_incident.id}
onClick={() => void handleAcknowledge(endpoint.open_incident!.id)}
>
{incidents.acknowledgingId === endpoint.open_incident.id
? "Acknowledging…"
: "Acknowledge incident"}
</button>
)}
</div>
<div className="app__row-actions">
<button
Expand Down
31 changes: 28 additions & 3 deletions frontend/src/components/DashboardPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
setDashboardHours,
type DashboardSeriesBucket,
} from "../store/dashboardSlice"
import { fetchEndpoints } from "../store/endpointsSlice"
import { acknowledgeIncident, fetchIncidents } from "../store/incidentsSlice"

function formatPercent(value: number | null) {
if (value == null) return "n/a"
Expand Down Expand Up @@ -119,10 +121,18 @@ async function downloadExport(path: string, filename: string) {
export function DashboardPanel() {
const dispatch = useAppDispatch()
const { data, loading, error, hours } = useAppSelector((state) => state.dashboard)
const { acknowledgingId } = useAppSelector((state) => state.incidents)
const summary = data?.summary
const [exportError, setExportError] = useState<string | null>(null)
const [exporting, setExporting] = useState<string | null>(null)

async function handleAcknowledge(incidentId: number) {
await dispatch(acknowledgeIncident(incidentId)).unwrap()
void dispatch(fetchDashboard(hours))
void dispatch(fetchIncidents("open"))
void dispatch(fetchEndpoints())
}

async function handleExport(path: string, filename: string, key: string) {
setExportError(null)
setExporting(key)
Expand Down Expand Up @@ -328,11 +338,26 @@ export function DashboardPanel() {
<h3>Open incidents</h3>
<ul className="dashboard__list">
{data.open_incident_list.map((incident) => (
<li key={incident.id}>
<strong>{incident.endpoint_name}</strong>
<span className="app__badge app__badge--incident">open</span>
<li key={incident.id} className="dashboard__incident-item">
<div className="dashboard__incident-head">
<strong>{incident.endpoint_name}</strong>
<span className="app__badge app__badge--incident">open</span>
{incident.acknowledged_at && (
<span className="app__badge app__badge--ack">acknowledged</span>
)}
</div>
<p className="dashboard__incident-summary">{incident.summary}</p>
<p className="status-page__meta">Opened {formatTime(incident.opened_at)}</p>
{!incident.acknowledged_at && (
<button
type="button"
className="app__button app__button--ack"
disabled={acknowledgingId === incident.id}
onClick={() => void handleAcknowledge(incident.id)}
>
{acknowledgingId === incident.id ? "Acknowledging…" : "Acknowledge"}
</button>
)}
</li>
))}
</ul>
Expand Down
Loading
Loading